@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,107 @@
|
|
|
1
|
+
import { str } from "../command.js";
|
|
2
|
+
import { CliError } from "../errors.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Turning flags into the JSON arguments the MCP tools take. The CLI does no business logic: it
|
|
5
|
+
// only reshapes what was typed (text per language, "SKU:qty" pairs, key=value fields) and sends
|
|
6
|
+
// it; the server validates every value and its refusal is printed verbatim.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
/** Repeatable string flag values (`--title a --title b`). */
|
|
9
|
+
export const list = (v, k) => {
|
|
10
|
+
const x = v[k];
|
|
11
|
+
if (Array.isArray(x))
|
|
12
|
+
return x.filter((s) => typeof s === "string");
|
|
13
|
+
return typeof x === "string" ? [x] : [];
|
|
14
|
+
};
|
|
15
|
+
const LANG_PREFIX = /^([a-z]{2,3}):([\s\S]*)$/;
|
|
16
|
+
/**
|
|
17
|
+
* Text per language from a repeatable flag: `--title "en:Loofah" --title "ar:ليفة"`, or plain
|
|
18
|
+
* `--title "ليفة"` for the `--lang` language (default ar, the stores' default). `""` after the
|
|
19
|
+
* prefix removes that language. Undefined when the flag was not given.
|
|
20
|
+
*/
|
|
21
|
+
export function localized(v, k) {
|
|
22
|
+
const items = list(v, k);
|
|
23
|
+
if (!items.length)
|
|
24
|
+
return undefined;
|
|
25
|
+
const lang = str(v, "lang") ?? "ar";
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const item of items) {
|
|
28
|
+
const m = LANG_PREFIX.exec(item);
|
|
29
|
+
if (m)
|
|
30
|
+
out[m[1]] = m[2];
|
|
31
|
+
else
|
|
32
|
+
out[lang] = item;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/** `JL-90:5` pairs (`--line`) → [{ variant, quantity }]. */
|
|
37
|
+
export function lines(v, k) {
|
|
38
|
+
const items = list(v, k);
|
|
39
|
+
if (!items.length)
|
|
40
|
+
return undefined;
|
|
41
|
+
return items.map((item) => {
|
|
42
|
+
const i = item.lastIndexOf(":");
|
|
43
|
+
const variant = i > 0 ? item.slice(0, i).trim() : item.trim();
|
|
44
|
+
const qty = i > 0 ? Number(item.slice(i + 1)) : 1;
|
|
45
|
+
if (!variant || !Number.isInteger(qty) || qty < 1)
|
|
46
|
+
throw new CliError(`--${k} "${item}": write it as <sku>:<quantity>, e.g. JL-90:3.`);
|
|
47
|
+
return { variant, quantity: qty };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** A value typed on the command line: JSON when it parses as JSON (numbers, true/false, null, [..]), else the text. */
|
|
51
|
+
const loose = (s) => {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(s);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return s;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
/** `key=value` pairs (`--field material=cotton`) → a record; values read loosely as JSON. */
|
|
60
|
+
export function pairs(v, k, opts = {}) {
|
|
61
|
+
const items = list(v, k);
|
|
62
|
+
if (!items.length)
|
|
63
|
+
return undefined;
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const item of items) {
|
|
66
|
+
const i = item.indexOf("=");
|
|
67
|
+
if (i < 1)
|
|
68
|
+
throw new CliError(`--${k} "${item}": write it as key=value.`);
|
|
69
|
+
out[item.slice(0, i).trim()] = opts.loose === false ? item.slice(i + 1) : loose(item.slice(i + 1));
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/** A comma-separated flag (`--tags a,b`) → a list; "" → []. */
|
|
74
|
+
export const csvList = (s) => s === undefined ? undefined : s.split(",").map((x) => x.trim()).filter(Boolean);
|
|
75
|
+
/** A whole number, or a usage error naming the flag. */
|
|
76
|
+
export function int(s, name) {
|
|
77
|
+
if (s === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
const n = Number(s.trim());
|
|
80
|
+
if (!Number.isInteger(n))
|
|
81
|
+
throw new CliError(`${name} must be a whole number (got "${s}").`);
|
|
82
|
+
return n;
|
|
83
|
+
}
|
|
84
|
+
/** `on`/`off`, `yes`/`no`, `true`/`false`. */
|
|
85
|
+
export function onOff(s, name) {
|
|
86
|
+
if (s === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
const t = s.trim().toLowerCase();
|
|
89
|
+
if (["on", "yes", "true", "1"].includes(t))
|
|
90
|
+
return true;
|
|
91
|
+
if (["off", "no", "false", "0"].includes(t))
|
|
92
|
+
return false;
|
|
93
|
+
throw new CliError(`${name} takes on or off (got "${s}").`);
|
|
94
|
+
}
|
|
95
|
+
/** Only the keys whose value is defined. */
|
|
96
|
+
export const defined = (o) => Object.fromEntries(Object.entries(o).filter(([, x]) => x !== undefined));
|
|
97
|
+
/** "none"/"null"/"-" → null (clear it), else the text. */
|
|
98
|
+
export const nullable = (s) => s === undefined ? undefined : ["none", "null", "-"].includes(s.trim().toLowerCase()) ? null : s;
|
|
99
|
+
/** A per-language value as one string: English, else Arabic, else any. */
|
|
100
|
+
export const textOf = (l) => {
|
|
101
|
+
if (!l || typeof l !== "object")
|
|
102
|
+
return typeof l === "string" ? l : "";
|
|
103
|
+
const r = l;
|
|
104
|
+
return r["en"] || r["ar"] || Object.values(r).find(Boolean) || "";
|
|
105
|
+
};
|
|
106
|
+
export const LANG_OPTION = { lang: { type: "string" } };
|
|
107
|
+
export const TEXT_HELP = 'Text flags take "en:Text" / "ar:نص" (repeat for each language), or plain text in --lang (default ar). "en:" alone removes English.';
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { apiOrigin } from "../api-url.js";
|
|
3
|
+
import { listStores, oneStoreOf } from "../api.js";
|
|
4
|
+
import { AuthError, CliError, isNotOneStore } from "../errors.js";
|
|
5
|
+
import { table } from "../format.js";
|
|
6
|
+
import { envToken } from "../env.js";
|
|
7
|
+
import { bookStores, isStoreSlug } from "../logins.js";
|
|
8
|
+
/** What to say when a token is not tied to exactly one store — without naming any of them. */
|
|
9
|
+
const noStoreLine = (who) => who.stores.length > 1
|
|
10
|
+
? "this token reaches more than one store, which cerkel no longer uses. Create a token for one store in Settings → MCP."
|
|
11
|
+
: "none — this login cannot reach its store right now.";
|
|
12
|
+
function describe(s, who, origin) {
|
|
13
|
+
const you = who.you;
|
|
14
|
+
const name = [you?.name, you?.email ? `<${you.email}>` : null].filter(Boolean).join(" ");
|
|
15
|
+
s.out(`Logged in to ${origin}${name ? ` as ${name}` : ""}`);
|
|
16
|
+
const own = oneStoreOf(who);
|
|
17
|
+
// One store per login: this is the whole of what it can reach. Never a list of other stores.
|
|
18
|
+
s.out(`Store: ${own ? `${own.name} (${own.slug})` : noStoreLine(who)}`);
|
|
19
|
+
if (who.token?.prefix || who.token?.name) {
|
|
20
|
+
s.out(`Token: ${who.token.prefix ?? ""}…${who.token.name ? ` (${who.token.name})` : ""}`);
|
|
21
|
+
}
|
|
22
|
+
s.out(`Permissions: ${who.grantedPermissions.length ? who.grantedPermissions.join(", ") : "(none)"}`);
|
|
23
|
+
}
|
|
24
|
+
/** How long the browser half may take: signing in with a WhatsApp code and an authenticator included. */
|
|
25
|
+
export const BROWSER_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
26
|
+
const b64url = (b) => b.toString("base64url");
|
|
27
|
+
const checkSlug = (store) => {
|
|
28
|
+
if (store === undefined)
|
|
29
|
+
return null;
|
|
30
|
+
const slug = store.trim().toLowerCase();
|
|
31
|
+
if (!isStoreSlug(slug))
|
|
32
|
+
throw new CliError(`"${store}" is not a store slug: use the store's short name, lowercase letters, digits and hyphens (e.g. justloofah-jo).`);
|
|
33
|
+
return slug;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Shopify-CLI-style login: open the admin in the browser, the person signs in as they always do,
|
|
37
|
+
* picks the store (pre-selected by --store) and presses Allow, and the admin hands a token for THAT
|
|
38
|
+
* store back to a listener on 127.0.0.1. PKCE means the one-time code in that redirect is useless to
|
|
39
|
+
* anyone but this process (see the admin's lib/cli-login.ts for the server half).
|
|
40
|
+
*/
|
|
41
|
+
async function browserLogin(s, origin, store) {
|
|
42
|
+
const verifier = b64url(randomBytes(32));
|
|
43
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
44
|
+
const state = b64url(randomBytes(24));
|
|
45
|
+
const lb = await s.ctx.listenLoopback();
|
|
46
|
+
let q;
|
|
47
|
+
try {
|
|
48
|
+
const url = new URL("/cli/authorize", origin);
|
|
49
|
+
const query = new URLSearchParams({ port: String(lb.port), state, challenge, device: s.ctx.hostname.slice(0, 60) });
|
|
50
|
+
if (store)
|
|
51
|
+
query.set("store", store);
|
|
52
|
+
url.search = query.toString();
|
|
53
|
+
s.err(`Opening your browser to sign in to Cerkel${store ? ` for ${store}` : ""}. Choose the store, press Allow there, then come back here.`);
|
|
54
|
+
s.err(`If it does not open, visit: ${url.href}`);
|
|
55
|
+
await s.ctx.openUrl(url.href);
|
|
56
|
+
try {
|
|
57
|
+
q = await lb.waitForCallback(BROWSER_LOGIN_TIMEOUT_MS);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new CliError("Timed out waiting for the browser (5 minutes). Run `cerkel login` again.");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
lb.close();
|
|
65
|
+
}
|
|
66
|
+
if (q.get("state") !== state)
|
|
67
|
+
throw new CliError("The browser answered a different login request. Run `cerkel login` again.");
|
|
68
|
+
if (q.get("error"))
|
|
69
|
+
throw new CliError(q.get("error") === "access_denied" ? "Sign-in was cancelled in the browser." : `Sign-in failed in the browser (${q.get("error")}).`);
|
|
70
|
+
const code = q.get("code");
|
|
71
|
+
if (!code)
|
|
72
|
+
throw new CliError("The browser returned no sign-in code. Run `cerkel login` again.");
|
|
73
|
+
let res;
|
|
74
|
+
try {
|
|
75
|
+
res = await s.ctx.fetch(new URL("/api/cli/token", origin).href, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
78
|
+
body: JSON.stringify({ code, verifier }),
|
|
79
|
+
redirect: "error",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
throw new CliError(`Could not reach ${origin}: ${e instanceof Error ? e.message : String(e)}`);
|
|
84
|
+
}
|
|
85
|
+
const body = (await res.json().catch(() => ({})));
|
|
86
|
+
if (!res.ok || typeof body.token !== "string") {
|
|
87
|
+
throw new CliError(typeof body.message === "string" ? body.message : `Sign-in failed (HTTP ${res.status}). Run \`cerkel login\` again.`);
|
|
88
|
+
}
|
|
89
|
+
return body.token;
|
|
90
|
+
}
|
|
91
|
+
export async function login(s, opts = {}) {
|
|
92
|
+
const co = await s.checkout();
|
|
93
|
+
const url = s.api(co?.state);
|
|
94
|
+
const origin = apiOrigin(url);
|
|
95
|
+
// Inside a theme folder, `cerkel login` means the folder's own store.
|
|
96
|
+
const wanted = checkSlug(opts.store) ?? co?.state.store ?? null;
|
|
97
|
+
const env = envToken(s.ctx.env);
|
|
98
|
+
let token;
|
|
99
|
+
if (env) {
|
|
100
|
+
// CI: verify what the pipeline will use, but never copy an injected secret into a keychain.
|
|
101
|
+
s.err(`${env.name} is set; verifying it. It is used instead of any stored login, so nothing is saved.`);
|
|
102
|
+
token = env.value;
|
|
103
|
+
}
|
|
104
|
+
else if (!s.ctx.io.isTTY) {
|
|
105
|
+
token = (await s.ctx.io.readStdin()).trim();
|
|
106
|
+
}
|
|
107
|
+
else if (opts.token) {
|
|
108
|
+
s.err(`Create a token for one store in the Cerkel admin at Settings → AI assistant connection (MCP) (${origin}).`);
|
|
109
|
+
token = (await s.ctx.io.promptSecret("Paste your token (input hidden): ")).trim();
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
token = await browserLogin(s, origin, wanted);
|
|
113
|
+
}
|
|
114
|
+
if (!token)
|
|
115
|
+
throw new CliError("No token entered.");
|
|
116
|
+
s.addSecret(token);
|
|
117
|
+
let who;
|
|
118
|
+
try {
|
|
119
|
+
who = await listStores(s.clientWith(url, token));
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
if (e instanceof AuthError && isNotOneStore(e.reason)) {
|
|
123
|
+
throw new AuthError("Token not saved: it is not tied to one store. Create a token for one store in Settings → MCP, or run `cerkel login --store <slug>`.", e.reason);
|
|
124
|
+
}
|
|
125
|
+
if (e instanceof AuthError)
|
|
126
|
+
throw new AuthError(`Token not saved: ${e.message}`, e.reason);
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
const own = oneStoreOf(who);
|
|
130
|
+
if (!own) {
|
|
131
|
+
throw new AuthError(who.stores.length > 0
|
|
132
|
+
? "Token not saved: it reaches more than one store, which cerkel no longer uses. Create a token for one store in Settings → MCP, or run `cerkel login --store <slug>`."
|
|
133
|
+
: "Token not saved: it cannot reach its store right now. Ask an admin to check your access in Settings → Staff.");
|
|
134
|
+
}
|
|
135
|
+
if (wanted && own.slug !== wanted)
|
|
136
|
+
s.err(`Note: this login is for ${own.slug}, not ${wanted}.`);
|
|
137
|
+
if (!env) {
|
|
138
|
+
// Keep every other store's login: a Raizn-team member works on several stores.
|
|
139
|
+
await s.tidyLegacy(url);
|
|
140
|
+
const book = await s.book(url);
|
|
141
|
+
const where = await s.saveBook(url, { logins: { ...book.logins, [own.slug]: token }, legacy: book.legacy });
|
|
142
|
+
s.err(where === "keychain" ? `Login for ${own.slug} saved to the system keychain.` : `Login for ${own.slug} saved to the credentials file (no system keychain available).`);
|
|
143
|
+
}
|
|
144
|
+
describe(s, who, origin);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
export async function logout(s, opts = {}) {
|
|
148
|
+
const co = await s.checkout();
|
|
149
|
+
const url = s.api(co?.state);
|
|
150
|
+
const origin = apiOrigin(url);
|
|
151
|
+
// A 0.1.x login is filed under its store first (or dropped), so "which login" means the same as everywhere.
|
|
152
|
+
if (!opts.all)
|
|
153
|
+
await s.tidyLegacy(url);
|
|
154
|
+
const book = await s.book(url);
|
|
155
|
+
const stores = bookStores(book);
|
|
156
|
+
if (opts.all) {
|
|
157
|
+
if (stores.length === 0 && !book.legacy)
|
|
158
|
+
s.out(`No stored login for ${origin}.`);
|
|
159
|
+
else {
|
|
160
|
+
await s.saveBook(url, { logins: {}, legacy: null });
|
|
161
|
+
s.out(`Logged out of every store on ${origin}.`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
// Inside a theme folder, the folder's store; with one login, that one; otherwise say which.
|
|
166
|
+
const store = checkSlug(opts.store) ?? co?.state.store ?? (stores.length === 1 && !book.legacy ? stores[0] : null);
|
|
167
|
+
if (store) {
|
|
168
|
+
if (book.logins[store]) {
|
|
169
|
+
const rest = { ...book.logins };
|
|
170
|
+
delete rest[store];
|
|
171
|
+
await s.saveBook(url, { logins: rest, legacy: book.legacy });
|
|
172
|
+
s.out(`Logged out of ${store} on ${origin}.`);
|
|
173
|
+
}
|
|
174
|
+
else
|
|
175
|
+
s.out(`No stored login for ${store} on ${origin}.`);
|
|
176
|
+
}
|
|
177
|
+
else if (stores.length === 0 && book.legacy) {
|
|
178
|
+
await s.saveBook(url, { logins: {}, legacy: null });
|
|
179
|
+
s.out(`Logged out of ${origin}.`);
|
|
180
|
+
}
|
|
181
|
+
else if (stores.length === 0) {
|
|
182
|
+
s.out(`No stored login for ${origin}.`);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
throw new CliError("You are logged in to more than one store. Pass --store <slug>, or --all to log out of every store.");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const still = envToken(s.ctx.env);
|
|
189
|
+
if (still)
|
|
190
|
+
s.err(`Note: ${still.name} is still set in this environment, and commands will keep using it.`);
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
export async function whoami(s, opts = {}) {
|
|
194
|
+
if (opts.all)
|
|
195
|
+
return logins(s);
|
|
196
|
+
const co = await s.checkout();
|
|
197
|
+
const store = checkSlug(opts.store);
|
|
198
|
+
const c = await s.client(co?.state, store);
|
|
199
|
+
describe(s, await listStores(c), apiOrigin(c.url));
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
export async function stores(s, opts = {}) {
|
|
203
|
+
const co = await s.checkout();
|
|
204
|
+
const store = checkSlug(opts.store);
|
|
205
|
+
const r = await listStores(await s.client(co?.state, store));
|
|
206
|
+
const own = oneStoreOf(r);
|
|
207
|
+
if (!own) {
|
|
208
|
+
s.err(`Store: ${noStoreLine(r)}`);
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
for (const line of table([["SLUG", "NAME"], [own.slug, own.name]]))
|
|
212
|
+
s.out(line);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Your OWN logins on this computer, one per store. It is the one command that lists stores you are
|
|
217
|
+
* logged in to — that data is on your own disk — and each line is checked with the server using
|
|
218
|
+
* that login's own token, so nothing is learned about any store but the login's own.
|
|
219
|
+
*/
|
|
220
|
+
export async function logins(s) {
|
|
221
|
+
const co = await s.checkout();
|
|
222
|
+
const url = s.api(co?.state);
|
|
223
|
+
const origin = apiOrigin(url);
|
|
224
|
+
const envSet = envToken(s.ctx.env);
|
|
225
|
+
if (envSet)
|
|
226
|
+
s.err(`Note: ${envSet.name} is set, so commands use it instead of these logins.`);
|
|
227
|
+
await s.tidyLegacy(url);
|
|
228
|
+
const book = await s.book(url);
|
|
229
|
+
const slugs = bookStores(book);
|
|
230
|
+
if (slugs.length === 0) {
|
|
231
|
+
s.out(`No logins for ${origin}. Run \`cerkel login --store <slug>\`.`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
const rows = [["STORE", "NAME", "SIGNED IN AS", "STATUS"]];
|
|
235
|
+
for (const slug of slugs) {
|
|
236
|
+
const mark = co?.state.store === slug ? `${slug} *` : slug;
|
|
237
|
+
try {
|
|
238
|
+
const r = await listStores(s.clientWith(url, book.logins[slug]));
|
|
239
|
+
const own = oneStoreOf(r);
|
|
240
|
+
const status = own?.slug === slug ? "ok" : own ? `for ${own.slug} — run cerkel login --store ${slug}` : "cannot reach its store";
|
|
241
|
+
rows.push([mark, own?.name ?? "—", r.you?.email ?? r.you?.name ?? "—", status]);
|
|
242
|
+
}
|
|
243
|
+
catch (e) {
|
|
244
|
+
const status = e instanceof AuthError
|
|
245
|
+
? isNotOneStore(e.reason) ? `not tied to a store — run cerkel login --store ${slug}` : `signed out — run cerkel login --store ${slug}`
|
|
246
|
+
: "could not check (offline?)";
|
|
247
|
+
rows.push([mark, "—", "—", status]);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
for (const line of table(rows))
|
|
251
|
+
s.out(line);
|
|
252
|
+
if (co && slugs.includes(co.state.store))
|
|
253
|
+
s.err("* this folder's store");
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { bool, str } from "../command.js";
|
|
2
|
+
import { storeAndClient, flagsOf } from "../commerce.js";
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { table } from "../format.js";
|
|
5
|
+
/**
|
|
6
|
+
* `cerkel call TOOL --args '{…}' [--allow-writes]` — any MCP tool with JSON arguments, like Shopify's
|
|
7
|
+
* `store execute`. Read-only by default: a tool the SERVER marks as a write (its `readOnlyHint` in
|
|
8
|
+
* tools/list) is refused before it is called unless --allow-writes. `--list` prints the tools.
|
|
9
|
+
* `store` defaults to the login's store when the arguments do not name one.
|
|
10
|
+
*/
|
|
11
|
+
export const CALL_COMMANDS = {
|
|
12
|
+
call: {
|
|
13
|
+
usage: "cerkel call <tool> [--args '{\"key\": \"value\"}'] [--allow-writes] [--store <slug>]\n cerkel call --list",
|
|
14
|
+
summary: "Call any MCP tool with JSON arguments (read-only unless --allow-writes)",
|
|
15
|
+
help: [
|
|
16
|
+
"Prints the tool's answer as JSON. Whether a tool writes comes from the server's own tool list, never from this CLI.",
|
|
17
|
+
"Write tools accept \"dry_run\": true in --args to see the change without making it.",
|
|
18
|
+
],
|
|
19
|
+
options: { args: { type: "string" }, "allow-writes": { type: "boolean" }, list: { type: "boolean" }, store: { type: "string" }, json: { type: "boolean" } },
|
|
20
|
+
positionals: true,
|
|
21
|
+
run: async (s, v, pos) => {
|
|
22
|
+
const flags = flagsOf(v);
|
|
23
|
+
const { client, store } = await storeAndClient(s, flags);
|
|
24
|
+
const tools = await client.listTools();
|
|
25
|
+
if (bool(v, "list")) {
|
|
26
|
+
if (pos.length)
|
|
27
|
+
throw new CliError("Usage: cerkel call --list");
|
|
28
|
+
if (flags.json)
|
|
29
|
+
s.out(JSON.stringify(tools, null, 2));
|
|
30
|
+
else
|
|
31
|
+
for (const line of table([["TOOL", "KIND", "TITLE"], ...tools.map((t) => [t.name, t.annotations?.readOnlyHint ? "read" : t.annotations?.destructiveHint ? "write (destructive)" : "write", t.title ?? ""])]))
|
|
32
|
+
s.out(line);
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
if (pos.length !== 1)
|
|
36
|
+
throw new CliError("Usage: cerkel call <tool> [--args '{…}'] [--allow-writes] (cerkel call --list shows the tools)");
|
|
37
|
+
const tool = tools.find((t) => t.name === pos[0]);
|
|
38
|
+
if (!tool)
|
|
39
|
+
throw new CliError(`The server has no tool "${pos[0]}". cerkel call --list shows the tools.`);
|
|
40
|
+
let args = {};
|
|
41
|
+
const raw = str(v, "args");
|
|
42
|
+
if (raw !== undefined) {
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(raw);
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
throw new CliError(`--args is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
49
|
+
}
|
|
50
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
51
|
+
throw new CliError("--args must be a JSON object, e.g. '{\"code\": \"EID10\"}'.");
|
|
52
|
+
args = parsed;
|
|
53
|
+
}
|
|
54
|
+
const writes = tool.annotations?.readOnlyHint !== true;
|
|
55
|
+
if (writes && !bool(v, "allow-writes")) {
|
|
56
|
+
throw new CliError(`${tool.name} changes the store. Re-run with --allow-writes to call it (add "dry_run": true to --args to only see the change).`);
|
|
57
|
+
}
|
|
58
|
+
// The login's store, unless the tool's schema says it takes none (list_stores…).
|
|
59
|
+
const props = tool.inputSchema?.["properties"];
|
|
60
|
+
if ((!props || "store" in props) && args["store"] === undefined)
|
|
61
|
+
args["store"] = store;
|
|
62
|
+
const result = await client.call(tool.name, args);
|
|
63
|
+
s.out(JSON.stringify(result, null, 2));
|
|
64
|
+
return 0;
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { bool, str } from "../command.js";
|
|
2
|
+
import { READ_HELP, READ_OPTIONS, WRITE_HELP, WRITE_OPTIONS, flagsOf, runRead, runWrite } from "../commerce.js";
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { table } from "../format.js";
|
|
5
|
+
import { LANG_OPTION, TEXT_HELP, defined, int, localized, nullable, textOf } from "./args.js";
|
|
6
|
+
const DETAIL_OPTIONS = {
|
|
7
|
+
...LANG_OPTION,
|
|
8
|
+
title: { type: "string", multiple: true },
|
|
9
|
+
description: { type: "string", multiple: true },
|
|
10
|
+
"seo-title": { type: "string", multiple: true },
|
|
11
|
+
"seo-description": { type: "string", multiple: true },
|
|
12
|
+
handle: { type: "string" },
|
|
13
|
+
image: { type: "string" },
|
|
14
|
+
sort: { type: "string" },
|
|
15
|
+
template: { type: "string" },
|
|
16
|
+
};
|
|
17
|
+
const details = (v) => defined({
|
|
18
|
+
title: localized(v, "title"), descriptionHtml: localized(v, "description"), seoTitle: localized(v, "seo-title"), seoDescription: localized(v, "seo-description"),
|
|
19
|
+
handle: str(v, "handle"), imageUrl: nullable(str(v, "image")), sortOrder: str(v, "sort")?.toUpperCase(), templateSuffix: nullable(str(v, "template")),
|
|
20
|
+
});
|
|
21
|
+
const membersCommand = (tool, verb, summary, help) => ({
|
|
22
|
+
usage: `cerkel collection ${verb} <collection> <product>… [--yes] [--dry-run] [--json]`,
|
|
23
|
+
summary,
|
|
24
|
+
help: [help, WRITE_HELP],
|
|
25
|
+
options: WRITE_OPTIONS,
|
|
26
|
+
positionals: true,
|
|
27
|
+
run: (s, v, pos) => {
|
|
28
|
+
const [collection, ...products] = pos;
|
|
29
|
+
if (!collection || !products.length)
|
|
30
|
+
throw new CliError(`Usage: cerkel collection ${verb} <collection> <product>…`);
|
|
31
|
+
return runWrite(s, flagsOf(v), tool, { collection, products });
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
export const COLLECTION_COMMANDS = {
|
|
35
|
+
"collection list": {
|
|
36
|
+
usage: "cerkel collection list [search] [--published|--hidden] [--page N] [--store <slug>] [--json]",
|
|
37
|
+
summary: "Search the store's collections",
|
|
38
|
+
help: [READ_HELP],
|
|
39
|
+
options: { ...READ_OPTIONS, published: { type: "boolean" }, hidden: { type: "boolean" }, page: { type: "string" } },
|
|
40
|
+
positionals: true,
|
|
41
|
+
run: (s, v, pos) => runRead(s, flagsOf(v), "search_collections", defined({
|
|
42
|
+
q: pos.join(" ").trim() || undefined, published: bool(v, "published") ? true : bool(v, "hidden") ? false : undefined, page: int(str(v, "page"), "--page"),
|
|
43
|
+
}), (d) => d.collections.length === 0 ? ["No collections match."] : [
|
|
44
|
+
...table([["HANDLE", "SHOWN", "PRODUCTS", "SORT", "TITLE"], ...d.collections.map((c) => [c.handle, c.published ? "yes" : "no", String(c.products), c.sortOrder + (c.smart ? " (smart)" : ""), textOf(c.title)])]),
|
|
45
|
+
`${d.collections.length} of ${d.total}`,
|
|
46
|
+
]),
|
|
47
|
+
},
|
|
48
|
+
"collection get": {
|
|
49
|
+
usage: "cerkel collection get <handle|id> [--store <slug>] [--json]",
|
|
50
|
+
summary: "Show a collection and its products in order",
|
|
51
|
+
help: [READ_HELP],
|
|
52
|
+
options: READ_OPTIONS,
|
|
53
|
+
positionals: true,
|
|
54
|
+
run: (s, v, pos) => {
|
|
55
|
+
if (pos.length !== 1)
|
|
56
|
+
throw new CliError("Usage: cerkel collection get <handle>");
|
|
57
|
+
return runRead(s, flagsOf(v), "get_collection", { collection: pos[0] }, (c) => [
|
|
58
|
+
...table([
|
|
59
|
+
["Collection", `${textOf(c.title)} (${c.handle})`],
|
|
60
|
+
["Shown here", c.published ? "yes" : "no"],
|
|
61
|
+
["Sort", c.sortOrder],
|
|
62
|
+
["Template", c.templateSuffix ? `collection.${c.templateSuffix}` : "default"],
|
|
63
|
+
]),
|
|
64
|
+
"",
|
|
65
|
+
...(c.products.length ? table([["#", "HANDLE", "STATUS", "TITLE"], ...c.products.map((p, i) => [String(i + 1), p.handle, p.status, p.title])]) : ["No products."]),
|
|
66
|
+
]);
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
"collection create": {
|
|
70
|
+
usage: "cerkel collection create --title <text> [--handle <h>] [--description …] [--image <url>] [--sort manual|best_selling|…] [--product <handle>]… [--yes] [--dry-run] [--json]",
|
|
71
|
+
summary: "Create a collection, shown in this store",
|
|
72
|
+
help: ["Sort: MANUAL, BEST_SELLING, ALPHA_ASC, ALPHA_DESC, PRICE_ASC, PRICE_DESC, CREATED, CREATED_DESC.", TEXT_HELP, WRITE_HELP],
|
|
73
|
+
options: { ...WRITE_OPTIONS, ...DETAIL_OPTIONS, product: { type: "string", multiple: true } },
|
|
74
|
+
run: (s, v) => {
|
|
75
|
+
const d = details(v);
|
|
76
|
+
if (!d["title"])
|
|
77
|
+
throw new CliError("cerkel collection create needs --title.");
|
|
78
|
+
const products = Array.isArray(v["product"]) ? v["product"] : [];
|
|
79
|
+
return runWrite(s, flagsOf(v), "create_collection", { ...d, ...(products.length ? { products } : {}) });
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
"collection update": {
|
|
83
|
+
usage: "cerkel collection update <handle|id> [--title …] [--description …] [--seo-title …] [--seo-description …] [--handle <new>] [--image <url|none>] [--sort <order>] [--template <suffix|none>] [--yes] [--dry-run] [--json]",
|
|
84
|
+
summary: "Change a collection's title, description, SEO, image, sort order or template",
|
|
85
|
+
help: ["Only what you pass changes.", TEXT_HELP, WRITE_HELP],
|
|
86
|
+
options: { ...WRITE_OPTIONS, ...DETAIL_OPTIONS },
|
|
87
|
+
positionals: true,
|
|
88
|
+
run: (s, v, pos) => {
|
|
89
|
+
if (pos.length !== 1)
|
|
90
|
+
throw new CliError("Usage: cerkel collection update <handle> [flags]");
|
|
91
|
+
return runWrite(s, flagsOf(v), "update_collection", { collection: pos[0], ...details(v) });
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
"collection add": membersCommand("add_to_collection", "add", "Add products to the end of a collection", "Products already in it stay where they are."),
|
|
95
|
+
"collection remove": membersCommand("remove_from_collection", "remove", "Take products out of a collection", "The products themselves are not changed."),
|
|
96
|
+
"collection reorder": membersCommand("reorder_collection", "reorder", "Put a collection's products in this order", "The products you list go first, the rest keep their order after them. Shown when the sort is MANUAL."),
|
|
97
|
+
"collection publish": {
|
|
98
|
+
usage: "cerkel collection publish <handle|id> [--yes] [--dry-run] [--json]",
|
|
99
|
+
summary: "Show a collection in this store",
|
|
100
|
+
help: [WRITE_HELP],
|
|
101
|
+
options: WRITE_OPTIONS,
|
|
102
|
+
positionals: true,
|
|
103
|
+
run: (s, v, pos) => {
|
|
104
|
+
if (pos.length !== 1)
|
|
105
|
+
throw new CliError("Usage: cerkel collection publish <handle>");
|
|
106
|
+
return runWrite(s, flagsOf(v), "publish_collection", { collection: pos[0], published: true });
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
"collection unpublish": {
|
|
110
|
+
usage: "cerkel collection unpublish <handle|id> [--yes] [--dry-run] [--json]",
|
|
111
|
+
summary: "Hide a collection in this store",
|
|
112
|
+
help: [WRITE_HELP],
|
|
113
|
+
options: WRITE_OPTIONS,
|
|
114
|
+
positionals: true,
|
|
115
|
+
run: (s, v, pos) => {
|
|
116
|
+
if (pos.length !== 1)
|
|
117
|
+
throw new CliError("Usage: cerkel collection unpublish <handle>");
|
|
118
|
+
return runWrite(s, flagsOf(v), "publish_collection", { collection: pos[0], published: false });
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
};
|