@cerkel/cli 0.0.0-stage → 1.0.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/README.md +502 -2
- package/dist/api-url.js +42 -0
- package/dist/api.js +48 -0
- package/dist/bin.js +157 -0
- package/dist/chunk.js +31 -0
- package/dist/cli.js +497 -0
- package/dist/client.js +125 -0
- package/dist/command.js +2 -0
- package/dist/commands/args.js +107 -0
- package/dist/commands/auth.js +255 -0
- package/dist/commands/call.js +67 -0
- package/dist/commands/collection.js +121 -0
- package/dist/commands/content.js +281 -0
- package/dist/commands/customer.js +126 -0
- package/dist/commands/dev.js +261 -0
- package/dist/commands/draft.js +101 -0
- package/dist/commands/file.js +53 -0
- package/dist/commands/index.js +43 -0
- package/dist/commands/inventory.js +103 -0
- package/dist/commands/offer.js +92 -0
- package/dist/commands/order-args.js +37 -0
- package/dist/commands/order.js +219 -0
- package/dist/commands/price.js +55 -0
- package/dist/commands/product.js +265 -0
- package/dist/commands/promo.js +140 -0
- package/dist/commands/redirect.js +94 -0
- package/dist/commands/report.js +93 -0
- package/dist/commands/section.js +233 -0
- package/dist/commands/store.js +23 -0
- package/dist/commands/theme-tools.js +432 -0
- package/dist/commands/theme.js +388 -0
- package/dist/commands/whatsapp.js +39 -0
- package/dist/commands/zone.js +90 -0
- package/dist/commerce.js +138 -0
- package/dist/content-files.js +246 -0
- package/dist/context.js +5 -0
- package/dist/credentials.js +201 -0
- package/dist/diff.js +146 -0
- package/dist/env.js +24 -0
- package/dist/errors.js +73 -0
- package/dist/format.js +13 -0
- package/dist/layout.js +122 -0
- package/dist/logins.js +49 -0
- package/dist/raizn-alias.js +6 -0
- package/dist/redact.js +27 -0
- package/dist/session.js +266 -0
- package/dist/state.js +153 -0
- package/dist/sync.js +121 -0
- package/dist/zip.js +88 -0
- package/package.json +37 -3
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { bool, str } from "../command.js";
|
|
4
|
+
import { READ_HELP, READ_OPTIONS, WRITE_HELP, WRITE_OPTIONS, diffLines, flagsOf, runRead, runWrite, storeAndClient } from "../commerce.js";
|
|
5
|
+
import { addressOf, hashText, listContentFiles, menuPathOf, pageCreateArgs, pageFileOf, pagePathOf, pageUpdateArgs, parseMenu, parsePage, readContentState, readText, serializeMenu, serializePage, writeContentState, writeText, } from "../content-files.js";
|
|
6
|
+
import { CliError } from "../errors.js";
|
|
7
|
+
import { plural, table } from "../format.js";
|
|
8
|
+
const SYNC_OPTIONS = { ...WRITE_OPTIONS, dir: { type: "string" }, force: { type: "boolean" } };
|
|
9
|
+
const dirOf = (s, v) => path.resolve(s.ctx.cwd, typeof v["dir"] === "string" ? v["dir"] : ".");
|
|
10
|
+
async function fetchPages(client, store) {
|
|
11
|
+
const out = [];
|
|
12
|
+
for (let page = 1;; page++) {
|
|
13
|
+
const r = await client.call("search_pages", { store, include_body: true, limit: 200, page });
|
|
14
|
+
out.push(...r.pages);
|
|
15
|
+
if (out.length >= r.total || r.pages.length === 0)
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
/** `cerkel page pull` / `cerkel menu pull`: write every page (or menu) of the store into the folder. */
|
|
21
|
+
async function pull(s, v, kind) {
|
|
22
|
+
const flags = flagsOf(v);
|
|
23
|
+
const dir = dirOf(s, v);
|
|
24
|
+
const prior = await readContentState(dir);
|
|
25
|
+
const { client, store } = await storeAndClient(s, { ...(flags.store ?? prior?.store ? { store: flags.store ?? prior.store } : {}) });
|
|
26
|
+
if (prior && prior.store !== store)
|
|
27
|
+
throw new CliError(`${dir} holds ${prior.store}'s content, not ${store}'s. Pull into another folder.`);
|
|
28
|
+
const files = new Map();
|
|
29
|
+
if (kind === "page")
|
|
30
|
+
for (const p of await fetchPages(client, store))
|
|
31
|
+
files.set(pagePathOf(p), { id: p.id, text: serializePage(pageFileOf(p)) });
|
|
32
|
+
else
|
|
33
|
+
for (const m of (await client.call("search_menus", { store })).menus)
|
|
34
|
+
files.set(menuPathOf(m.handle), { id: m.id, text: serializeMenu(m) });
|
|
35
|
+
// Never overwrite local edits silently: a file changed since the last pull/push needs --force.
|
|
36
|
+
const state = { version: 1, api: s.api().href, store, pulledAt: s.ctx.now().toISOString(), files: { ...(prior?.files ?? {}) } };
|
|
37
|
+
const mine = (rel) => { const a = addressOf(rel); return !!a && a.type === kind; };
|
|
38
|
+
const edited = [];
|
|
39
|
+
for (const rel of new Set([...files.keys(), ...Object.keys(state.files).filter(mine)])) {
|
|
40
|
+
const local = await readText(dir, rel);
|
|
41
|
+
if (local === null)
|
|
42
|
+
continue;
|
|
43
|
+
const base = state.files[rel]?.hash;
|
|
44
|
+
const incoming = files.get(rel)?.text;
|
|
45
|
+
if (hashText(local) !== base && (incoming === undefined || hashText(local) !== hashText(incoming)))
|
|
46
|
+
edited.push(rel);
|
|
47
|
+
}
|
|
48
|
+
if (edited.length && !bool(v, "force")) {
|
|
49
|
+
throw new CliError(`These files have local changes that pulling would overwrite:\n${edited.map((f) => ` ${f}`).join("\n")}\nPush them first, or pull with --force to discard them.`);
|
|
50
|
+
}
|
|
51
|
+
for (const rel of Object.keys(state.files).filter(mine)) {
|
|
52
|
+
if (!files.has(rel)) {
|
|
53
|
+
delete state.files[rel];
|
|
54
|
+
await rm(path.join(dir, ...rel.split("/")), { force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const [rel, f] of files) {
|
|
58
|
+
await writeText(dir, rel, f.text);
|
|
59
|
+
state.files[rel] = { id: f.id, hash: hashText(f.text) };
|
|
60
|
+
}
|
|
61
|
+
await writeContentState(dir, state);
|
|
62
|
+
if (flags.json)
|
|
63
|
+
s.out(JSON.stringify({ store, dir, files: [...files.keys()] }, null, 2));
|
|
64
|
+
else
|
|
65
|
+
s.out(`Pulled ${plural(files.size, kind)} of ${store} into ${path.relative(s.ctx.cwd, dir) || "."}.`);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
/** `cerkel page push` / `cerkel menu push`: send the files changed since the last pull or push. */
|
|
69
|
+
async function push(s, v, kind, only) {
|
|
70
|
+
const flags = flagsOf(v);
|
|
71
|
+
const force = bool(v, "force");
|
|
72
|
+
if (!flags.yes && !flags.dryRun && !s.ctx.io.isTTY) {
|
|
73
|
+
throw new CliError("This changes the store, and there is no terminal to confirm in. Re-run with --yes to apply it, or --dry-run to only see the change.");
|
|
74
|
+
}
|
|
75
|
+
const dir = dirOf(s, v);
|
|
76
|
+
const state = await readContentState(dir);
|
|
77
|
+
if (!state)
|
|
78
|
+
throw new CliError(`${dir} has no pulled content. Run \`cerkel ${kind} pull --store <slug>\` first.`);
|
|
79
|
+
if (flags.store && flags.store !== state.store)
|
|
80
|
+
throw new CliError(`${dir} holds ${state.store}'s content, not ${flags.store}'s.`);
|
|
81
|
+
const { client, store } = await storeAndClient(s, { store: state.store });
|
|
82
|
+
const wanted = only.map((f) => path.relative(dir, path.resolve(s.ctx.cwd, f)).split(path.sep).join("/"));
|
|
83
|
+
for (const w of wanted)
|
|
84
|
+
if (!addressOf(w) || addressOf(w).type !== kind)
|
|
85
|
+
throw new CliError(`${w} is not a ${kind} file (${kind === "page" ? "pages/<handle>.html, policies/<handle>.html or blogs/<blog>/<handle>.html" : "menus/<handle>.json"}).`);
|
|
86
|
+
const local = wanted.length ? wanted : await listContentFiles(dir, [kind]);
|
|
87
|
+
const plans = [];
|
|
88
|
+
const conflicts = [];
|
|
89
|
+
for (const rel of local) {
|
|
90
|
+
const text = await readText(dir, rel);
|
|
91
|
+
if (text === null)
|
|
92
|
+
throw new CliError(`${rel} does not exist.`);
|
|
93
|
+
const entry = state.files[rel];
|
|
94
|
+
if (entry && hashText(text) === entry.hash)
|
|
95
|
+
continue;
|
|
96
|
+
const a = addressOf(rel);
|
|
97
|
+
if (a.type === "page") {
|
|
98
|
+
const file = parsePage(text, rel);
|
|
99
|
+
if (!entry) {
|
|
100
|
+
plans.push({ rel, tool: "create_page", args: pageCreateArgs(a.page, file) });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const server = await client.call("get_page", { store, page: entry.id });
|
|
104
|
+
if (hashText(serializePage(pageFileOf(server))) !== entry.hash && !force) {
|
|
105
|
+
conflicts.push(rel);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
plans.push({ rel, tool: "update_page", args: pageUpdateArgs(entry.id, a.page, file, server) });
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
const file = parseMenu(text, rel);
|
|
112
|
+
if (!entry) {
|
|
113
|
+
plans.push({ rel, tool: "create_menu", args: { handle: a.handle, title: file.title, items: file.items } });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const server = await client.call("get_menu", { store, menu: entry.id });
|
|
117
|
+
if (hashText(serializeMenu(server)) !== entry.hash && !force) {
|
|
118
|
+
conflicts.push(rel);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
plans.push({ rel, tool: "update_menu", args: { menu: entry.id, ...(a.handle !== server.handle ? { newHandle: a.handle } : {}), title: file.title, items: file.items } });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (conflicts.length) {
|
|
125
|
+
throw new CliError(`Someone changed these on the server since your last pull:\n${conflicts.map((f) => ` ${f}`).join("\n")}\nPull them again (cerkel ${kind} pull --force overwrites your copy), or push with --force to overwrite the server's.`);
|
|
126
|
+
}
|
|
127
|
+
if (!wanted.length) {
|
|
128
|
+
const gone = Object.keys(state.files).filter((rel) => addressOf(rel)?.type === kind && !local.includes(rel));
|
|
129
|
+
for (const rel of gone)
|
|
130
|
+
s.err(`${rel} was removed locally; it stays on the store. Delete it there with \`cerkel ${kind} delete ${kind === "page" ? `/${rel.replace(/\.html$/, "")}` : rel.replace(/^menus\/|\.json$/g, "")}\`.`);
|
|
131
|
+
}
|
|
132
|
+
if (!plans.length) {
|
|
133
|
+
s.out("Nothing to push: no file changed since the last pull or push.");
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
for (const p of plans)
|
|
137
|
+
p.preview = await client.call(p.tool, { store, ...p.args, dry_run: true });
|
|
138
|
+
const changing = plans.filter((p) => p.preview.changed);
|
|
139
|
+
if (flags.json && (flags.dryRun || !flags.yes))
|
|
140
|
+
s.out(JSON.stringify(changing.map((p) => ({ file: p.rel, ...p.preview })), null, 2));
|
|
141
|
+
else
|
|
142
|
+
for (const p of changing) {
|
|
143
|
+
s.out(`${p.rel}: ${p.preview.summary}`);
|
|
144
|
+
for (const line of diffLines(p.preview.diff))
|
|
145
|
+
s.out(line);
|
|
146
|
+
}
|
|
147
|
+
if (flags.dryRun) {
|
|
148
|
+
if (!flags.json)
|
|
149
|
+
s.err("Dry run: nothing was changed.");
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
if (!flags.yes) {
|
|
153
|
+
const answer = (await s.ctx.io.prompt(`Push ${plural(changing.length, "change")} to ${store}? [y/N] `)).trim().toLowerCase();
|
|
154
|
+
if (answer !== "y" && answer !== "yes") {
|
|
155
|
+
s.err("Cancelled. Nothing was changed.");
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const results = [];
|
|
160
|
+
for (const p of plans) {
|
|
161
|
+
const applied = await client.call(p.tool, { store, ...p.args, dry_run: false });
|
|
162
|
+
results.push({ file: p.rel, ...applied });
|
|
163
|
+
// Record the server's version of what was just written, and write it back so the file is exactly it.
|
|
164
|
+
const id = (p.args["page"] ?? p.args["menu"] ?? applied.result?.["pageId"] ?? applied.result?.["menuId"]);
|
|
165
|
+
if (!id)
|
|
166
|
+
continue;
|
|
167
|
+
if (kind === "page") {
|
|
168
|
+
const server = await client.call("get_page", { store, page: id });
|
|
169
|
+
const rel = pagePathOf(server);
|
|
170
|
+
const text = serializePage(pageFileOf(server));
|
|
171
|
+
if (rel !== p.rel)
|
|
172
|
+
delete state.files[p.rel];
|
|
173
|
+
await writeText(dir, rel, text);
|
|
174
|
+
state.files[rel] = { id, hash: hashText(text) };
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
const server = await client.call("get_menu", { store, menu: id });
|
|
178
|
+
const rel = menuPathOf(server.handle);
|
|
179
|
+
const text = serializeMenu(server);
|
|
180
|
+
if (rel !== p.rel)
|
|
181
|
+
delete state.files[p.rel];
|
|
182
|
+
await writeText(dir, rel, text);
|
|
183
|
+
state.files[rel] = { id, hash: hashText(text) };
|
|
184
|
+
}
|
|
185
|
+
await writeContentState(dir, state);
|
|
186
|
+
if (!flags.json)
|
|
187
|
+
s.out(`Pushed ${p.rel}`);
|
|
188
|
+
}
|
|
189
|
+
if (flags.json)
|
|
190
|
+
s.out(JSON.stringify(results, null, 2));
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
const titleOf = (t) => t["en"] || t["ar"] || Object.values(t)[0] || "";
|
|
194
|
+
const SYNC_HELP = (kind) => [
|
|
195
|
+
kind === "page"
|
|
196
|
+
? "Files: pages/<handle>.html, policies/<handle>.html, blogs/<blog>/<handle>.html — settings as JSON in <!--cerkel … -->, then the body per language after <!--lang:ar-->, <!--lang:en-->…"
|
|
197
|
+
: "Files: menus/<handle>.json — { \"title\": …, \"items\": [ { \"title\": {\"ar\": …}, \"url\": \"/collections/all\", \"children\": [ … ] } ] }",
|
|
198
|
+
"The file name is the handle. --dir <path> picks the folder (default: here).",
|
|
199
|
+
];
|
|
200
|
+
export const CONTENT_COMMANDS = {
|
|
201
|
+
"page pull": {
|
|
202
|
+
usage: "cerkel page pull [--store <slug>] [--dir <path>] [--force] [--json]",
|
|
203
|
+
summary: "Download the store's pages, policies and blog articles as files",
|
|
204
|
+
help: [...SYNC_HELP("page"), "Refuses to overwrite files you changed since the last pull or push unless --force.", READ_HELP],
|
|
205
|
+
options: SYNC_OPTIONS,
|
|
206
|
+
run: (s, v) => pull(s, v, "page"),
|
|
207
|
+
},
|
|
208
|
+
"page push": {
|
|
209
|
+
usage: "cerkel page push [files…] [--dir <path>] [--force] [--yes] [--dry-run] [--json]",
|
|
210
|
+
summary: "Send changed page files to the store (shows the changes and asks first)",
|
|
211
|
+
help: [
|
|
212
|
+
"Sends only files changed since the last pull/push; a new file creates the page. A file deleted locally is NOT deleted on the store (use cerkel page delete).",
|
|
213
|
+
"A page someone changed in the admin since your pull is refused as a conflict unless --force.",
|
|
214
|
+
WRITE_HELP,
|
|
215
|
+
],
|
|
216
|
+
options: SYNC_OPTIONS,
|
|
217
|
+
positionals: true,
|
|
218
|
+
run: (s, v, pos) => push(s, v, "page", pos),
|
|
219
|
+
},
|
|
220
|
+
"page list": {
|
|
221
|
+
usage: "cerkel page list [--kind page|policy|article] [--search <text>] [--store <slug>] [--json]",
|
|
222
|
+
summary: "List the store's pages, policies and blog articles",
|
|
223
|
+
help: [READ_HELP],
|
|
224
|
+
options: { ...READ_OPTIONS, kind: { type: "string" }, search: { type: "string" } },
|
|
225
|
+
run: (s, v) => {
|
|
226
|
+
const kind = str(v, "kind");
|
|
227
|
+
if (kind && !["page", "policy", "article"].includes(kind))
|
|
228
|
+
throw new CliError("--kind is page, policy or article.");
|
|
229
|
+
return runRead(s, flagsOf(v), "search_pages", { limit: 200, ...(kind ? { kind } : {}), ...(str(v, "search") ? { q: str(v, "search") } : {}) }, (d) => [
|
|
230
|
+
...table([["PATH", "TITLE", "VISIBLE", "SOURCE"], ...d.pages.map((p) => [p.path, titleOf(p.title), p.published ? "yes" : "no", p.source])]),
|
|
231
|
+
...(d.total > d.pages.length ? [`… ${d.total - d.pages.length} more (use --search)`] : []),
|
|
232
|
+
]);
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
"page delete": {
|
|
236
|
+
usage: "cerkel page delete <path|id> [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
237
|
+
summary: "Delete a page, policy or article (its URL then answers 404 unless redirected)",
|
|
238
|
+
help: ["Name it by its path: /pages/about, /policies/refund-policy, /blogs/news/launch.", WRITE_HELP],
|
|
239
|
+
options: WRITE_OPTIONS,
|
|
240
|
+
positionals: true,
|
|
241
|
+
run: (s, v, pos) => {
|
|
242
|
+
if (pos.length !== 1)
|
|
243
|
+
throw new CliError("Usage: cerkel page delete <path|id>");
|
|
244
|
+
return runWrite(s, flagsOf(v), "delete_page", { page: pos[0] });
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
"menu pull": {
|
|
248
|
+
usage: "cerkel menu pull [--store <slug>] [--dir <path>] [--force] [--json]",
|
|
249
|
+
summary: "Download the store's navigation menus as files",
|
|
250
|
+
help: [...SYNC_HELP("menu"), "Refuses to overwrite files you changed since the last pull or push unless --force.", READ_HELP],
|
|
251
|
+
options: SYNC_OPTIONS,
|
|
252
|
+
run: (s, v) => pull(s, v, "menu"),
|
|
253
|
+
},
|
|
254
|
+
"menu push": {
|
|
255
|
+
usage: "cerkel menu push [files…] [--dir <path>] [--force] [--yes] [--dry-run] [--json]",
|
|
256
|
+
summary: "Send changed menu files to the store (shows the changes and asks first)",
|
|
257
|
+
help: ["Sends only files changed since the last pull/push; a new file creates the menu. Each file replaces that menu's whole item tree.", WRITE_HELP],
|
|
258
|
+
options: SYNC_OPTIONS,
|
|
259
|
+
positionals: true,
|
|
260
|
+
run: (s, v, pos) => push(s, v, "menu", pos),
|
|
261
|
+
},
|
|
262
|
+
"menu list": {
|
|
263
|
+
usage: "cerkel menu list [--store <slug>] [--json]",
|
|
264
|
+
summary: "List the store's navigation menus",
|
|
265
|
+
help: [READ_HELP],
|
|
266
|
+
options: READ_OPTIONS,
|
|
267
|
+
run: (s, v) => runRead(s, flagsOf(v), "search_menus", {}, (d) => table([["HANDLE", "TITLE", "ITEMS"], ...d.menus.map((m) => [m.handle, m.title, String(m.itemCount)])])),
|
|
268
|
+
},
|
|
269
|
+
"menu delete": {
|
|
270
|
+
usage: "cerkel menu delete <handle> [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
271
|
+
summary: "Delete a navigation menu",
|
|
272
|
+
help: [WRITE_HELP],
|
|
273
|
+
options: WRITE_OPTIONS,
|
|
274
|
+
positionals: true,
|
|
275
|
+
run: (s, v, pos) => {
|
|
276
|
+
if (pos.length !== 1)
|
|
277
|
+
throw new CliError("Usage: cerkel menu delete <handle>");
|
|
278
|
+
return runWrite(s, flagsOf(v), "delete_menu", { menu: pos[0] });
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { bool, str } from "../command.js";
|
|
2
|
+
import { READ_HELP, READ_OPTIONS, WRITE_HELP, WRITE_OPTIONS, flagsOf, runRead, runWrite, show } from "../commerce.js";
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { table } from "../format.js";
|
|
5
|
+
import { listFlag, need } from "./order-args.js";
|
|
6
|
+
const pct = (r) => (r === null ? "—" : `${Math.round(r * 100)}%`);
|
|
7
|
+
export const CUSTOMER_COMMANDS = {
|
|
8
|
+
"customer list": {
|
|
9
|
+
usage: "cerkel customer list [--q <text>] [--flag blocked|watch|vip]… [--tag <t>]… [--refusers] [--sort lastAt|orders|ordered|collected|refused|name] [--limit <n>] [--page <n>] [--store <slug>] [--json]",
|
|
10
|
+
summary: "Search the store's customers",
|
|
11
|
+
help: ["Everyone who placed a real order in this store. Phones are masked unless your login may see customer details.", READ_HELP],
|
|
12
|
+
options: { ...READ_OPTIONS, q: { type: "string" }, flag: { type: "string", multiple: true }, tag: { type: "string", multiple: true }, refusers: { type: "boolean" }, sort: { type: "string" }, limit: { type: "string" }, page: { type: "string" } },
|
|
13
|
+
run: (s, v) => runRead(s, flagsOf(v), "search_customers", {
|
|
14
|
+
...(str(v, "q") ? { q: str(v, "q") } : {}), ...(listFlag(v, "flag") ? { flag: listFlag(v, "flag") } : {}), ...(listFlag(v, "tag") ? { tag: listFlag(v, "tag") } : {}),
|
|
15
|
+
...(bool(v, "refusers") ? { refusers: true } : {}), ...(str(v, "sort") ? { sort: str(v, "sort") } : {}),
|
|
16
|
+
...(str(v, "limit") ? { limit: Number(str(v, "limit")) } : {}), ...(str(v, "page") ? { page: Number(str(v, "page")) } : {}),
|
|
17
|
+
}, (d) => [
|
|
18
|
+
...table([["Name", "Phone", "Latest order", "Orders", "Ordered", "Refused", "Flags", "Tags"], ...d.customers.map((c) => [c.name ?? "—", c.phone ?? "—", c.latestOrder ?? "—", String(c.orders), show(c.ordered), String(c.refused), c.flags.join(", "), c.tags.join(", ")])]),
|
|
19
|
+
`${d.customers.length} of ${d.total}.`,
|
|
20
|
+
]),
|
|
21
|
+
},
|
|
22
|
+
"customer get": {
|
|
23
|
+
usage: "cerkel customer get <phone|order> [--store <slug>] [--json]",
|
|
24
|
+
summary: "Show a customer: orders in this store, refusal rate, risk, flags and notes",
|
|
25
|
+
help: [READ_HELP],
|
|
26
|
+
options: READ_OPTIONS,
|
|
27
|
+
positionals: true,
|
|
28
|
+
run: (s, v, pos) => {
|
|
29
|
+
need(pos, 1, "cerkel customer get <phone|order>");
|
|
30
|
+
return runRead(s, flagsOf(v), "get_customer", { customer: pos[0] }, (d) => [
|
|
31
|
+
...table([
|
|
32
|
+
["Name", d.name ?? "—"], ["Phone", `${d.phone ?? "—"}${d.phoneAlt ? ` / ${d.phoneAlt}` : ""}`], ["Email", d.email ?? "—"],
|
|
33
|
+
["Refusal rate", `${pct(d.refusal.rate)} (${d.refusal.refused} refused, ${d.refusal.delivered} delivered)`],
|
|
34
|
+
["Risk", d.risk ? `${d.risk.band} (${d.risk.score})` : "—"],
|
|
35
|
+
["Flags", d.flags.map((f) => f.flag + (f.note ? ` (${f.note})` : "")).join(", ") || "—"],
|
|
36
|
+
["Tags", d.tags.join(", ") || "—"], ["Note", d.note ?? "—"], ["WhatsApp offers", d.whatsappOffers.subscribed ? "on" : "off"],
|
|
37
|
+
]),
|
|
38
|
+
"",
|
|
39
|
+
...table([["Order", "Placed", "Status", "Total"], ...d.orders.map((o) => [o.number, o.placedAt.slice(0, 10), o.status, o.total.formatted])]),
|
|
40
|
+
]);
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
"customer update": {
|
|
44
|
+
usage: "cerkel customer update <phone|order> [--name <name>] [--email <email>] [--phone-alt <phone>] [--note <text>] [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
45
|
+
summary: "Edit a customer's name, email, second number or note",
|
|
46
|
+
help: ["An empty --email / --phone-alt removes it. The main phone cannot change. Orders keep the details they were placed with.", WRITE_HELP],
|
|
47
|
+
options: { ...WRITE_OPTIONS, name: { type: "string" }, email: { type: "string" }, "phone-alt": { type: "string" }, note: { type: "string" } },
|
|
48
|
+
positionals: true,
|
|
49
|
+
run: (s, v, pos) => {
|
|
50
|
+
need(pos, 1, "cerkel customer update <phone|order> --name … | --email … | --phone-alt … | --note …");
|
|
51
|
+
const args = {
|
|
52
|
+
...(str(v, "name") !== undefined ? { name: str(v, "name") } : {}), ...(str(v, "email") !== undefined ? { email: str(v, "email") } : {}),
|
|
53
|
+
...(str(v, "phone-alt") !== undefined ? { phone_alt: str(v, "phone-alt") } : {}), ...(str(v, "note") !== undefined ? { note: str(v, "note") } : {}),
|
|
54
|
+
};
|
|
55
|
+
if (!Object.keys(args).length)
|
|
56
|
+
throw new CliError("Pass at least one of --name, --email, --phone-alt or --note.");
|
|
57
|
+
return runWrite(s, flagsOf(v), "update_customer_profile", { customer: pos[0], ...args });
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
"customer tag": {
|
|
61
|
+
usage: "cerkel customer tag <phone|order> [--add <t>]… [--remove <t>]… [--set <t,t>] [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
62
|
+
summary: "Add, remove or replace a customer's tags",
|
|
63
|
+
help: [WRITE_HELP],
|
|
64
|
+
options: { ...WRITE_OPTIONS, add: { type: "string", multiple: true }, remove: { type: "string", multiple: true }, set: { type: "string" } },
|
|
65
|
+
positionals: true,
|
|
66
|
+
run: (s, v, pos) => {
|
|
67
|
+
need(pos, 1, "cerkel customer tag <phone|order> --add <tag> | --remove <tag> | --set <tags>");
|
|
68
|
+
const set = str(v, "set");
|
|
69
|
+
return runWrite(s, flagsOf(v), "set_customer_tags", {
|
|
70
|
+
customer: pos[0],
|
|
71
|
+
...(set !== undefined ? { tags: set.split(",").map((t) => t.trim()).filter(Boolean) } : {}),
|
|
72
|
+
...(listFlag(v, "add") ? { add: listFlag(v, "add") } : {}), ...(listFlag(v, "remove") ? { remove: listFlag(v, "remove") } : {}),
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
"customer flag": {
|
|
77
|
+
usage: "cerkel customer flag <phone|order> <blocked|watch|vip> [--off] [--note <why>] [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
78
|
+
summary: "Flag a customer (blocked refuses them cash on delivery) or lift a flag with --off",
|
|
79
|
+
help: ["Flags apply in every store of the organisation; the customer's risk is recomputed right after.", WRITE_HELP],
|
|
80
|
+
options: { ...WRITE_OPTIONS, off: { type: "boolean" }, note: { type: "string" } },
|
|
81
|
+
positionals: true,
|
|
82
|
+
run: (s, v, pos) => {
|
|
83
|
+
need(pos, 2, "cerkel customer flag <phone|order> <blocked|watch|vip> [--off]");
|
|
84
|
+
return runWrite(s, flagsOf(v), "set_customer_flag", { customer: pos[0], flag: pos[1], on: !bool(v, "off"), ...(str(v, "note") ? { note: str(v, "note") } : {}) });
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
"review list": {
|
|
88
|
+
usage: "cerkel review list [--status pending|approved|rejected|hidden]… [--rating <1-5>]… [--q <text>] [--limit <n>] [--store <slug>] [--json]",
|
|
89
|
+
summary: "List the store's customer reviews",
|
|
90
|
+
help: [READ_HELP],
|
|
91
|
+
options: { ...READ_OPTIONS, status: { type: "string", multiple: true }, rating: { type: "string", multiple: true }, q: { type: "string" }, limit: { type: "string" } },
|
|
92
|
+
run: (s, v) => runRead(s, flagsOf(v), "list_reviews", {
|
|
93
|
+
...(listFlag(v, "status") ? { status: listFlag(v, "status") } : {}),
|
|
94
|
+
...(listFlag(v, "rating") ? { rating: listFlag(v, "rating").map(Number) } : {}),
|
|
95
|
+
...(str(v, "q") ? { q: str(v, "q") } : {}), ...(str(v, "limit") ? { limit: Number(str(v, "limit")) } : {}),
|
|
96
|
+
}, (d) => [
|
|
97
|
+
...table([["Id", "Status", "Rating", "Reviewer", "Order", "Review"], ...d.reviews.map((r) => [r.id, r.status, `${r.rating}★`, r.reviewer.name ?? "—", r.order ?? "—", (r.title ?? r.body ?? "").slice(0, 60)])]),
|
|
98
|
+
Object.entries(d.counts).map(([k, n]) => `${k}: ${n}`).join(" · "),
|
|
99
|
+
]),
|
|
100
|
+
},
|
|
101
|
+
"review moderate": {
|
|
102
|
+
usage: "cerkel review moderate <id> <approved|rejected|hidden|pending> [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
103
|
+
summary: "Approve, reject or hide a review",
|
|
104
|
+
help: ["Approved reviews show on the storefront.", WRITE_HELP],
|
|
105
|
+
options: WRITE_OPTIONS,
|
|
106
|
+
positionals: true,
|
|
107
|
+
run: (s, v, pos) => {
|
|
108
|
+
need(pos, 2, "cerkel review moderate <id> <approved|rejected|hidden|pending>");
|
|
109
|
+
return runWrite(s, flagsOf(v), "moderate_review", { review: pos[0], status: pos[1] });
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
"review reply": {
|
|
113
|
+
usage: "cerkel review reply <id> <text…> [--remove] [--store <slug>] [--yes] [--dry-run] [--json]",
|
|
114
|
+
summary: "Write (or with --remove, take down) the store's public reply to a review",
|
|
115
|
+
help: [WRITE_HELP],
|
|
116
|
+
options: { ...WRITE_OPTIONS, remove: { type: "boolean" } },
|
|
117
|
+
positionals: true,
|
|
118
|
+
run: (s, v, pos) => {
|
|
119
|
+
need(pos, 1, "cerkel review reply <id> <text…>");
|
|
120
|
+
const text = pos.slice(1).join(" ").trim();
|
|
121
|
+
if (!text && !bool(v, "remove"))
|
|
122
|
+
throw new CliError("Write the reply after the id, or pass --remove to take it down.");
|
|
123
|
+
return runWrite(s, flagsOf(v), "reply_review", { review: pos[0], reply: bool(v, "remove") ? "" : text });
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { getPreviewUrl } from "../api.js";
|
|
3
|
+
import { AuthError, CliError, RateLimitError, ToolRefusal, retryAt } from "../errors.js";
|
|
4
|
+
import { plural } from "../format.js";
|
|
5
|
+
import { isGeneratedPath, isLayoutPath } from "../layout.js";
|
|
6
|
+
import { localChanges, readLocal } from "../state.js";
|
|
7
|
+
import { conflictAdvice, pushChanges, pushDeletes } from "../sync.js";
|
|
8
|
+
export const DEBOUNCE_MS = 1000;
|
|
9
|
+
/** Signed preview links live 30 minutes; fetch the next one with five to spare. */
|
|
10
|
+
export const PREVIEW_REFRESH_MS = 25 * 60 * 1000;
|
|
11
|
+
const PREVIEW_MARGIN_MS = 5 * 60 * 1000;
|
|
12
|
+
const PREVIEW_RETRY_MS = 5 * 60 * 1000;
|
|
13
|
+
/**
|
|
14
|
+
* Editors save through temp files and swap files; none of them are theme files, and pushing
|
|
15
|
+
* them would only earn a `refused`. Anything outside the layout is ignored for the same reason.
|
|
16
|
+
*/
|
|
17
|
+
export function watchedPath(filename) {
|
|
18
|
+
const p = filename.split(path.sep).join("/").replace(/^\.\//, "");
|
|
19
|
+
const parts = p.split("/");
|
|
20
|
+
if (parts.some((seg) => seg.startsWith(".")))
|
|
21
|
+
return null; // .cerkel/, .raizn/, .git/, .#emacs-lock, .DS_Store
|
|
22
|
+
const base = parts[parts.length - 1] ?? "";
|
|
23
|
+
if (base.endsWith("~") || /\.sw[a-p]$/.test(base) || base === "4913" || base.startsWith("#"))
|
|
24
|
+
return null;
|
|
25
|
+
return isLayoutPath(p) ? p : null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The watch → debounce → push loop. One `put_theme_files` call per quiet second, never two in
|
|
29
|
+
* flight, and after a 429 nothing is sent until the server's retry time has passed — saves made
|
|
30
|
+
* meanwhile simply join the queue.
|
|
31
|
+
*/
|
|
32
|
+
export class DevLoop {
|
|
33
|
+
s;
|
|
34
|
+
client;
|
|
35
|
+
root;
|
|
36
|
+
state;
|
|
37
|
+
debounceMs;
|
|
38
|
+
opts;
|
|
39
|
+
pending = new Set();
|
|
40
|
+
rescanAll = false;
|
|
41
|
+
timer = null;
|
|
42
|
+
previewTimer = null;
|
|
43
|
+
inFlight = null;
|
|
44
|
+
blockedUntil = 0;
|
|
45
|
+
stopped = false;
|
|
46
|
+
finish = null;
|
|
47
|
+
done;
|
|
48
|
+
constructor(s, client, root, state, debounceMs = DEBOUNCE_MS, opts = {}) {
|
|
49
|
+
this.s = s;
|
|
50
|
+
this.client = client;
|
|
51
|
+
this.root = root;
|
|
52
|
+
this.state = state;
|
|
53
|
+
this.debounceMs = debounceMs;
|
|
54
|
+
this.opts = opts;
|
|
55
|
+
this.done = new Promise((resolve) => (this.finish = resolve));
|
|
56
|
+
}
|
|
57
|
+
get timers() {
|
|
58
|
+
return this.s.ctx.timers;
|
|
59
|
+
}
|
|
60
|
+
stamp() {
|
|
61
|
+
return `[${this.s.ctx.now().toLocaleTimeString("en-GB", { hour12: false })}]`;
|
|
62
|
+
}
|
|
63
|
+
/** A filesystem event. `null` = the OS did not say which file, so rescan everything. */
|
|
64
|
+
notify(filename) {
|
|
65
|
+
if (this.stopped)
|
|
66
|
+
return;
|
|
67
|
+
if (filename === null)
|
|
68
|
+
this.rescanAll = true;
|
|
69
|
+
else {
|
|
70
|
+
const p = watchedPath(filename);
|
|
71
|
+
if (!p)
|
|
72
|
+
return;
|
|
73
|
+
this.pending.add(p);
|
|
74
|
+
}
|
|
75
|
+
this.schedule(this.debounceMs);
|
|
76
|
+
}
|
|
77
|
+
schedule(ms) {
|
|
78
|
+
if (this.stopped)
|
|
79
|
+
return;
|
|
80
|
+
if (this.timer !== null)
|
|
81
|
+
this.timers.clearTimeout(this.timer);
|
|
82
|
+
const wait = Math.max(ms, this.blockedUntil - this.s.ctx.now().getTime());
|
|
83
|
+
this.timer = this.timers.setTimeout(() => {
|
|
84
|
+
this.timer = null;
|
|
85
|
+
void this.flush();
|
|
86
|
+
}, wait);
|
|
87
|
+
}
|
|
88
|
+
async flush() {
|
|
89
|
+
if (this.stopped)
|
|
90
|
+
return;
|
|
91
|
+
if (this.inFlight) {
|
|
92
|
+
// A push is running; the files stay queued and go in the next batch once it lands.
|
|
93
|
+
await this.inFlight;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const waitMs = this.blockedUntil - this.s.ctx.now().getTime();
|
|
97
|
+
if (waitMs > 0) {
|
|
98
|
+
this.schedule(waitMs);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const paths = [...this.pending];
|
|
102
|
+
const all = this.rescanAll;
|
|
103
|
+
this.pending.clear();
|
|
104
|
+
this.rescanAll = false;
|
|
105
|
+
this.inFlight = this.pushBatch(paths, all);
|
|
106
|
+
try {
|
|
107
|
+
await this.inFlight;
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
this.inFlight = null;
|
|
111
|
+
}
|
|
112
|
+
if (!this.stopped && (this.pending.size > 0 || this.rescanAll) && this.timer === null)
|
|
113
|
+
this.schedule(this.debounceMs);
|
|
114
|
+
}
|
|
115
|
+
requeue(paths, all) {
|
|
116
|
+
for (const p of paths)
|
|
117
|
+
this.pending.add(p);
|
|
118
|
+
if (all)
|
|
119
|
+
this.rescanAll = true;
|
|
120
|
+
}
|
|
121
|
+
async pushBatch(paths, all) {
|
|
122
|
+
const found = await localChanges(this.root, this.state, all ? undefined : paths);
|
|
123
|
+
const changes = found.changes.filter((c) => !isGeneratedPath(c.path));
|
|
124
|
+
// A watched path that is tracked but gone from disk was deleted in the editor.
|
|
125
|
+
let deleted = found.deleted;
|
|
126
|
+
if (!all) {
|
|
127
|
+
deleted = [];
|
|
128
|
+
for (const p of paths)
|
|
129
|
+
if (this.state.files[p] && !(await readLocal(this.root, p)))
|
|
130
|
+
deleted.push(p);
|
|
131
|
+
}
|
|
132
|
+
if (this.opts.noDelete)
|
|
133
|
+
deleted = [];
|
|
134
|
+
if (changes.length === 0 && deleted.length === 0)
|
|
135
|
+
return;
|
|
136
|
+
try {
|
|
137
|
+
let saved = 0;
|
|
138
|
+
let failed = 0;
|
|
139
|
+
let unchanged = 0;
|
|
140
|
+
let removed = 0;
|
|
141
|
+
if (changes.length > 0) {
|
|
142
|
+
const r = await pushChanges(this.s, this.client, this.root, this.state, changes, { force: false, compact: true });
|
|
143
|
+
saved = r.saved;
|
|
144
|
+
unchanged = r.unchanged;
|
|
145
|
+
failed = r.conflicts.length + r.invalid.length + r.refused.length;
|
|
146
|
+
conflictAdvice(this.s, r.conflicts);
|
|
147
|
+
}
|
|
148
|
+
if (deleted.length > 0) {
|
|
149
|
+
const d = await pushDeletes(this.s, this.client, this.root, this.state, deleted, { compact: true });
|
|
150
|
+
removed = d.deleted;
|
|
151
|
+
failed += d.failed.length;
|
|
152
|
+
}
|
|
153
|
+
this.s.err(`${this.stamp()} ${saved} saved${removed ? `, ${removed} deleted` : ""}${failed ? `, ${failed} not saved` : ""}${unchanged ? `, ${unchanged} unchanged` : ""}`);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
if (e instanceof RateLimitError) {
|
|
157
|
+
this.requeue(paths, all);
|
|
158
|
+
this.blockedUntil = this.s.ctx.now().getTime() + e.retryAfterSeconds * 1000;
|
|
159
|
+
this.s.err(`${this.stamp()} Rate limited: ${e.message} Waiting ${e.retryAfterSeconds}s (until ${retryAt(e.retryAfterSeconds, this.s.ctx.now())}); ${plural(this.pending.size || changes.length, "queued change")} will be sent then.`);
|
|
160
|
+
this.schedule(e.retryAfterSeconds * 1000);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (e instanceof AuthError || e instanceof ToolRefusal) {
|
|
164
|
+
// A revoked token or a theme that is no longer a draft will not fix itself; stop.
|
|
165
|
+
this.s.err(e instanceof ToolRefusal ? `The server refused the push: ${e.code}: ${e.serverMessage}` : e.message);
|
|
166
|
+
this.stop(1);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.requeue(paths, all);
|
|
170
|
+
this.s.err(`${this.stamp()} ${e instanceof Error ? e.message : String(e)} — your changes stay queued and go with your next save.`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** Print the preview link now and a fresh one before each expires. */
|
|
174
|
+
async startPreview() {
|
|
175
|
+
await this.fetchPreview(true);
|
|
176
|
+
}
|
|
177
|
+
async fetchPreview(first) {
|
|
178
|
+
if (this.stopped)
|
|
179
|
+
return;
|
|
180
|
+
let next = PREVIEW_RETRY_MS;
|
|
181
|
+
try {
|
|
182
|
+
const r = await getPreviewUrl(this.client, this.state.store, this.state.themeId);
|
|
183
|
+
if (first)
|
|
184
|
+
this.s.out(`Preview: ${r.url}`);
|
|
185
|
+
else {
|
|
186
|
+
// The storefront's preview cookie lives 30 minutes from when a link was opened; an open tab
|
|
187
|
+
// does not pick up the new link by itself and quietly shows the live site once it lapses.
|
|
188
|
+
this.s.out(`${this.stamp()} New preview link (the previous one is about to expire): ${r.url}`);
|
|
189
|
+
this.s.out(" Open this new link — tabs using the old one stop showing the draft and fall back to the live site after 30 minutes.");
|
|
190
|
+
}
|
|
191
|
+
const expires = r.expiresAt ? Date.parse(r.expiresAt) : NaN;
|
|
192
|
+
next = Number.isFinite(expires)
|
|
193
|
+
? Math.max(60_000, Math.min(PREVIEW_REFRESH_MS, expires - this.s.ctx.now().getTime() - PREVIEW_MARGIN_MS))
|
|
194
|
+
: PREVIEW_REFRESH_MS;
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
if (e instanceof AuthError) {
|
|
198
|
+
this.s.err(e.message);
|
|
199
|
+
this.stop(1);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (e instanceof RateLimitError)
|
|
203
|
+
next = Math.max(e.retryAfterSeconds * 1000, 1000);
|
|
204
|
+
this.s.err(`${this.stamp()} Could not get a preview link: ${e instanceof Error ? e.message : String(e)}`);
|
|
205
|
+
}
|
|
206
|
+
if (this.stopped)
|
|
207
|
+
return;
|
|
208
|
+
this.previewTimer = this.timers.setTimeout(() => void this.fetchPreview(false), next);
|
|
209
|
+
}
|
|
210
|
+
/** Resolves once no push is in flight — lets tests (and a clean exit) wait for the network. */
|
|
211
|
+
async idle() {
|
|
212
|
+
while (this.inFlight)
|
|
213
|
+
await this.inFlight;
|
|
214
|
+
}
|
|
215
|
+
stop(code = 0) {
|
|
216
|
+
if (this.stopped)
|
|
217
|
+
return;
|
|
218
|
+
this.stopped = true;
|
|
219
|
+
if (this.timer !== null)
|
|
220
|
+
this.timers.clearTimeout(this.timer);
|
|
221
|
+
if (this.previewTimer !== null)
|
|
222
|
+
this.timers.clearTimeout(this.previewTimer);
|
|
223
|
+
this.timer = null;
|
|
224
|
+
this.previewTimer = null;
|
|
225
|
+
this.finish?.(code);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
export async function themeDev(s, opts = {}) {
|
|
229
|
+
const { root, state } = await s.requireCheckout({ editable: true });
|
|
230
|
+
const client = await s.client(state);
|
|
231
|
+
const { changes } = await localChanges(root, state);
|
|
232
|
+
if (changes.length > 0) {
|
|
233
|
+
s.err(`Pushing ${plural(changes.length, "pending change")} first…`);
|
|
234
|
+
const r = await pushChanges(s, client, root, state, changes, { force: false, compact: true });
|
|
235
|
+
if (r.conflicts.length > 0) {
|
|
236
|
+
conflictAdvice(s, r.conflicts);
|
|
237
|
+
throw new CliError("Resolve the conflicts above, then run `cerkel theme dev` again.");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const loop = new DevLoop(s, client, root, state, DEBOUNCE_MS, opts);
|
|
241
|
+
await loop.startPreview();
|
|
242
|
+
let watcher;
|
|
243
|
+
try {
|
|
244
|
+
watcher = s.ctx.watch(root, (f) => loop.notify(f));
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
loop.stop(1);
|
|
248
|
+
throw new CliError(`Could not watch ${root}: ${e instanceof Error ? e.message : String(e)}`);
|
|
249
|
+
}
|
|
250
|
+
const offInterrupt = s.ctx.onInterrupt(() => {
|
|
251
|
+
s.err("\nStopped watching. Your pushed changes are on the draft; nothing went live.");
|
|
252
|
+
loop.stop(0);
|
|
253
|
+
});
|
|
254
|
+
s.err(`Watching ${root} for changes to "${state.themeName}" on ${state.store}. Ctrl-C to stop.`);
|
|
255
|
+
const code = await loop.done;
|
|
256
|
+
watcher.close();
|
|
257
|
+
// Let a push that was mid-flight at Ctrl-C finish and record its tags before we exit.
|
|
258
|
+
await loop.idle();
|
|
259
|
+
offInterrupt();
|
|
260
|
+
return code;
|
|
261
|
+
}
|