webcake-storefront-mcp 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +32 -0
- package/dist/auth/login.js +120 -10
- package/dist/changelog.json +14 -14
- package/dist/install.js +429 -107
- package/dist/server.js +4 -0
- package/dist/tools/articles.js +30 -9
- package/dist/tools/catalog-write.js +126 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -266,6 +266,22 @@ export class WebcakeCmsApi {
|
|
|
266
266
|
deleteArticle(id) {
|
|
267
267
|
return this.request("DELETE", `/api/v1/cms_function/${this.siteId}/blog/article/${id}`);
|
|
268
268
|
}
|
|
269
|
+
/** Create a blog/article category. Command-based: pass a `commands` array whose entries
|
|
270
|
+
* each carry a caller-generated `data.id` (the new category id). Response is generic. */
|
|
271
|
+
createBlogCategory(commands) {
|
|
272
|
+
return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/blog/categories/create`, {
|
|
273
|
+
body: { site_id: this.siteId, commands },
|
|
274
|
+
timeout: 60000,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
/** Create a blog article via the dashboard command pipeline (supports category linkage,
|
|
278
|
+
* images, summary, content). Caller generates the article id in each command's data.id. */
|
|
279
|
+
createBlogArticle(commands) {
|
|
280
|
+
return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/blog/articles/create`, {
|
|
281
|
+
body: { site_id: this.siteId, commands },
|
|
282
|
+
timeout: 60000,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
269
285
|
// ── Products ──
|
|
270
286
|
listProducts(query) {
|
|
271
287
|
return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/products/all`, { query });
|
|
@@ -282,6 +298,22 @@ export class WebcakeCmsApi {
|
|
|
282
298
|
getProductsByCategory(categoryId) {
|
|
283
299
|
return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/categories/products`, { query: { category_id: categoryId } });
|
|
284
300
|
}
|
|
301
|
+
/** Create a product. Body wraps fields in `product_params`; the backend generates the
|
|
302
|
+
* id/slug and sets is_published. Pass variations (price/stock per SKU) + categories. */
|
|
303
|
+
createProduct(productParams) {
|
|
304
|
+
return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/products/create`, {
|
|
305
|
+
body: { site_id: this.siteId, product_params: productParams },
|
|
306
|
+
timeout: 60000,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/** Create a product category. Command-based: pass a `commands` array whose entries each
|
|
310
|
+
* carry a caller-generated `data.id` (the new category id). Response is generic. */
|
|
311
|
+
createProductCategory(commands) {
|
|
312
|
+
return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/categories/create`, {
|
|
313
|
+
body: { site_id: this.siteId, commands },
|
|
314
|
+
timeout: 60000,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
285
317
|
// ── Orders ──
|
|
286
318
|
listOrders(query) {
|
|
287
319
|
return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/orders/all`, { query });
|
package/dist/auth/login.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Browser login: spins up a loopback server, opens the builder app's /mcp-
|
|
1
|
+
// Browser login: spins up a loopback server, opens the builder app's /mcp-storefront
|
|
2
2
|
// page, and receives the user's token back on the local callback — then saves it to
|
|
3
3
|
// the local config db so the stdio server picks it up automatically.
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
|
-
import { spawn } from "node:child_process";
|
|
6
|
+
import { spawn, execFile } from "node:child_process";
|
|
7
7
|
import { resolveSettings } from "../config.js";
|
|
8
8
|
import { setConfig } from "../db.js";
|
|
9
9
|
function parseArgs(argv) {
|
|
@@ -24,37 +24,144 @@ function parseArgs(argv) {
|
|
|
24
24
|
return opts;
|
|
25
25
|
}
|
|
26
26
|
function openBrowser(url) {
|
|
27
|
-
const
|
|
27
|
+
const platform = process.platform;
|
|
28
28
|
try {
|
|
29
|
-
|
|
29
|
+
if (platform === "win32") {
|
|
30
|
+
// `cmd /c start` parses an unquoted `&` as a command separator, which would cut
|
|
31
|
+
// the connect URL right before `&state=...`. Pass args verbatim with the URL
|
|
32
|
+
// double-quoted; the first quoted arg ("") is `start`'s window title.
|
|
33
|
+
spawn("cmd", ["/c", "start", '""', `"${url}"`], {
|
|
34
|
+
stdio: "ignore",
|
|
35
|
+
detached: true,
|
|
36
|
+
windowsVerbatimArguments: true,
|
|
37
|
+
}).unref();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const cmd = platform === "darwin" ? "open" : "xdg-open";
|
|
41
|
+
spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
|
|
30
42
|
}
|
|
31
43
|
catch {
|
|
32
44
|
/* user can open the URL manually */
|
|
33
45
|
}
|
|
34
46
|
}
|
|
35
|
-
|
|
47
|
+
// Chrome won't let a page close a tab it didn't open (window.close() is blocked),
|
|
48
|
+
// so instead we bring the user's terminal back to the foreground from Node. We
|
|
49
|
+
// snapshot whatever app is frontmost just before opening the browser (that's the
|
|
50
|
+
// terminal/IDE that ran the command) and re-activate it once the token arrives.
|
|
51
|
+
// macOS only (AppleScript); a no-op elsewhere — the success page still shows.
|
|
52
|
+
function captureFrontmostApp() {
|
|
53
|
+
if (process.platform !== "darwin")
|
|
54
|
+
return Promise.resolve(undefined);
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
let done = false;
|
|
57
|
+
const finish = (v) => {
|
|
58
|
+
if (done)
|
|
59
|
+
return;
|
|
60
|
+
done = true;
|
|
61
|
+
resolve(v);
|
|
62
|
+
};
|
|
63
|
+
const child = execFile("osascript", ["-e", 'tell application "System Events" to get name of first application process whose frontmost is true'], (err, stdout) => finish(err ? undefined : stdout.trim() || undefined));
|
|
64
|
+
// Never let login stall: the first run may hang on the macOS Automation
|
|
65
|
+
// permission prompt. Give up after 2s (re-focus is just a nicety).
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
try {
|
|
68
|
+
child.kill();
|
|
69
|
+
}
|
|
70
|
+
catch { /* noop */ }
|
|
71
|
+
finish(undefined);
|
|
72
|
+
}, 2000).unref();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function activateApp(name) {
|
|
76
|
+
if (!name || process.platform !== "darwin")
|
|
77
|
+
return;
|
|
78
|
+
// Re-focus by process name via System Events (works for terminals whose app
|
|
79
|
+
// name differs from the process, e.g. iTerm/Terminal/Warp/VS Code).
|
|
80
|
+
execFile("osascript", ["-e", `tell application "System Events" to set frontmost of (first application process whose name is "${name.replace(/"/g, '\\"')}") to true`], () => { });
|
|
81
|
+
}
|
|
82
|
+
const SUCCESS_HTML = `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
83
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Connected to WebCake</title>
|
|
84
|
+
<style>
|
|
85
|
+
:root{color-scheme:light dark}
|
|
86
|
+
*{box-sizing:border-box}
|
|
87
|
+
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
|
88
|
+
font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
|
89
|
+
background:linear-gradient(135deg,#e7f6f0 0%,#eafaf4 100%);color:#0f2e23}
|
|
90
|
+
@media(prefers-color-scheme:dark){body{background:linear-gradient(135deg,#0b1f18 0%,#06140f 100%);color:#dcf2e8}}
|
|
91
|
+
.card{background:#fff;border-radius:20px;padding:48px 40px;max-width:430px;width:calc(100% - 32px);
|
|
92
|
+
text-align:center;box-shadow:0 20px 60px rgba(16,139,103,.20);animation:rise .5s cubic-bezier(.2,.8,.2,1)}
|
|
93
|
+
@media(prefers-color-scheme:dark){.card{background:#10241c;box-shadow:0 20px 60px rgba(0,0,0,.5)}}
|
|
94
|
+
@keyframes rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}
|
|
95
|
+
.badge{width:84px;height:84px;margin:0 auto 24px;border-radius:50%;display:flex;align-items:center;justify-content:center;
|
|
96
|
+
background:linear-gradient(135deg,#13a87b,#108B67);box-shadow:0 8px 24px rgba(16,139,103,.42);animation:pop .45s .15s both cubic-bezier(.2,1.4,.4,1)}
|
|
97
|
+
@keyframes pop{from{transform:scale(0)}to{transform:scale(1)}}
|
|
98
|
+
.badge svg{width:44px;height:44px;stroke:#fff;stroke-width:3.5;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
99
|
+
.badge path{stroke-dasharray:32;stroke-dashoffset:32;animation:draw .4s .4s forwards ease-out}
|
|
100
|
+
@keyframes draw{to{stroke-dashoffset:0}}
|
|
101
|
+
h1{margin:0 0 10px;font-size:1.55rem;font-weight:700}
|
|
102
|
+
p{margin:0;font-size:1rem;line-height:1.6;color:#4b6a5f}
|
|
103
|
+
@media(prefers-color-scheme:dark){p{color:#9cc7b8}}
|
|
104
|
+
.hint{margin-top:24px;display:inline-flex;align-items:center;gap:8px;padding:10px 16px;border-radius:10px;
|
|
105
|
+
background:#f0f7f4;font-size:.9rem;color:#3d5b50}
|
|
106
|
+
@media(prefers-color-scheme:dark){.hint{background:#0b1f18;color:#9cc7b8}}
|
|
107
|
+
.hint code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:600;color:#108B67}
|
|
108
|
+
@media(prefers-color-scheme:dark){.hint code{color:#5fd3ac}}
|
|
109
|
+
</style></head>
|
|
110
|
+
<body>
|
|
111
|
+
<main class="card">
|
|
112
|
+
<div class="badge"><svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg></div>
|
|
113
|
+
<h1>Connected to WebCake</h1>
|
|
114
|
+
<p>Your storefront account is linked. You can close this tab now.</p>
|
|
115
|
+
<div class="hint">👉 Return to your <code>terminal</code> to continue</div>
|
|
116
|
+
</main>
|
|
117
|
+
<script>
|
|
118
|
+
// The terminal is re-focused from the CLI side. Still try window.close() for
|
|
119
|
+
// browsers that allow it (no-op in Chrome for tabs it didn't open) — no alert,
|
|
120
|
+
// which would only steal focus back from the terminal.
|
|
121
|
+
setTimeout(function(){ try { window.close(); } catch (e) {} }, 800);
|
|
122
|
+
</script>
|
|
123
|
+
</body></html>`;
|
|
36
124
|
function readQuery(url) {
|
|
37
125
|
const q = (url ?? "").indexOf("?");
|
|
38
126
|
return new URLSearchParams(q === -1 ? "" : (url ?? "").slice(q + 1));
|
|
39
127
|
}
|
|
128
|
+
function pathOf(url) {
|
|
129
|
+
const u = url ?? "/";
|
|
130
|
+
const q = u.indexOf("?");
|
|
131
|
+
return q === -1 ? u : u.slice(0, q);
|
|
132
|
+
}
|
|
40
133
|
export async function runLogin(argv) {
|
|
41
134
|
const opts = parseArgs(argv);
|
|
42
135
|
const settings = resolveSettings({ apiUrl: opts.apiUrl, appUrl: opts.appUrl });
|
|
43
136
|
const appUrl = settings.appUrl.replace(/\/$/, "");
|
|
44
137
|
const apiUrl = settings.apiUrl.replace(/\/$/, "");
|
|
45
138
|
const state = randomBytes(16).toString("hex");
|
|
139
|
+
// Remember the terminal that's frontmost now, so we can re-focus it once the
|
|
140
|
+
// browser hands the token back (Chrome can't auto-close its own tab).
|
|
141
|
+
const terminalApp = await captureFrontmostApp();
|
|
46
142
|
await new Promise((resolve, reject) => {
|
|
143
|
+
// `close()` only stops NEW connections; a browser keep-alive socket would keep
|
|
144
|
+
// the event loop (and the CLI) alive, so drop the live ones too.
|
|
145
|
+
const shutdown = () => {
|
|
146
|
+
server.close();
|
|
147
|
+
server.closeAllConnections?.();
|
|
148
|
+
};
|
|
47
149
|
const server = createServer((req, res) => {
|
|
150
|
+
// Ignore stray requests (favicon, etc.) so they don't trip the token check.
|
|
151
|
+
if (pathOf(req.url) !== "/callback") {
|
|
152
|
+
res.writeHead(404, { "content-type": "text/plain" }).end("Not found");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
48
155
|
const params = readQuery(req.url);
|
|
49
156
|
const token = params.get("token") || params.get("jwt");
|
|
50
157
|
const wsid = params.get("wsid") || params.get("session_id") || "";
|
|
51
158
|
const returnedState = params.get("state");
|
|
52
159
|
if (!token) {
|
|
53
|
-
res.writeHead(400, { "content-type": "text/html" }).end("<p>Missing token.</p>");
|
|
54
|
-
return;
|
|
160
|
+
res.writeHead(400, { "content-type": "text/html" }).end("<p>Missing token — re-run the command.</p>");
|
|
161
|
+
return; // keep listening — the user can retry until the timeout
|
|
55
162
|
}
|
|
56
163
|
if (returnedState && returnedState !== state) {
|
|
57
|
-
res.writeHead(400, { "content-type": "text/html" }).end("<p>State mismatch.</p>");
|
|
164
|
+
res.writeHead(400, { "content-type": "text/html" }).end("<p>State mismatch (login link truncated or expired) — re-run the command.</p>");
|
|
58
165
|
return;
|
|
59
166
|
}
|
|
60
167
|
setConfig("token", token);
|
|
@@ -66,9 +173,12 @@ export async function runLogin(argv) {
|
|
|
66
173
|
setConfig("site_id", opts.siteId);
|
|
67
174
|
res.writeHead(200, { "content-type": "text/html" }).end(SUCCESS_HTML);
|
|
68
175
|
console.error(`\n✓ Connected. Token${wsid ? " + session" : ""} saved to local config (api ${apiUrl || "<unset>"}).`);
|
|
69
|
-
|
|
176
|
+
// Pull the terminal/IDE back to the front (best-effort; no-op off macOS).
|
|
177
|
+
activateApp(terminalApp);
|
|
178
|
+
shutdown();
|
|
70
179
|
resolve();
|
|
71
180
|
});
|
|
181
|
+
server.on("error", reject);
|
|
72
182
|
server.listen(opts.port ?? 0, "127.0.0.1", () => {
|
|
73
183
|
const addr = server.address();
|
|
74
184
|
const port = typeof addr === "object" && addr ? addr.port : opts.port;
|
|
@@ -80,7 +190,7 @@ export async function runLogin(argv) {
|
|
|
80
190
|
openBrowser(full);
|
|
81
191
|
});
|
|
82
192
|
setTimeout(() => {
|
|
83
|
-
|
|
193
|
+
shutdown();
|
|
84
194
|
reject(new Error("login timed out after 180s."));
|
|
85
195
|
}, 180_000).unref();
|
|
86
196
|
});
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.5.0",
|
|
4
|
+
"d": "23/06/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "New create_product tool creates a storefront product (simple name + price, or advanced with named attributes and per-SKU variations); accepts hosted…",
|
|
7
|
+
"vi": "Tool mới create_product tạo sản phẩm cho storefront (đơn giản với tên + giá, hoặc nâng cao với attributes và variations theo từng SKU); nhận URL ảnh…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.4.0",
|
|
11
|
+
"d": "23/06/2026",
|
|
12
|
+
"type": "Changed",
|
|
13
|
+
"en": "The install command's interactive wizard now presents numbered choices with ANSI colour output and a completion summary.",
|
|
14
|
+
"vi": "Trình hướng dẫn tương tác của lệnh install nay hiển thị các lựa chọn được đánh số kèm màu ANSI và thông báo tóm tắt sau khi hoàn tất."
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.3.0",
|
|
4
18
|
"d": "23/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Changed",
|
|
27
41
|
"en": "The install command's interactive wizard now runs a three-step flow: environment selection (prod / staging / local), authentication, then IDE…",
|
|
28
42
|
"vi": "Trình hướng dẫn tương tác của lệnh install nay chạy theo 3 bước: chọn môi trường (prod / staging / local), xác thực, rồi cấu hình IDE."
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.1.2",
|
|
32
|
-
"d": "23/06/2026",
|
|
33
|
-
"type": "Fixed",
|
|
34
|
-
"en": "The server no longer crashes at startup in container environments built with npm ci --ignore-scripts; the better-sqlite3 native SQLite module has…",
|
|
35
|
-
"vi": "Server không còn bị crash khi khởi động trong môi trường container được build bằng npm ci --ignore-scripts; module SQLite native better-sqlite3 đã…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.1.1",
|
|
39
|
-
"d": "23/06/2026",
|
|
40
|
-
"type": "Added",
|
|
41
|
-
"en": "The serve command's OAuth token store now optionally uses Postgres (via DATABASE_URL) for durable persistence across restarts and shared state…",
|
|
42
|
-
"vi": "Kho lưu trữ token OAuth của lệnh serve nay hỗ trợ tùy chọn sử dụng Postgres (qua DATABASE_URL) để lưu token bền vững qua các lần khởi động lại và…"
|
|
43
43
|
}
|
|
44
44
|
]
|
package/dist/install.js
CHANGED
|
@@ -1,17 +1,51 @@
|
|
|
1
1
|
// Installer: writes (or removes) this MCP server's entry in the config files of the
|
|
2
|
-
// supported IDEs. Flag-driven; falls back to a
|
|
3
|
-
|
|
4
|
-
import {
|
|
2
|
+
// supported IDEs / agents. Flag-driven; falls back to a friendly numbered wizard on a
|
|
3
|
+
// TTY. Mirrors webcake-landing-mcp's installer (numbered choices, colours, multi-IDE).
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir, platform } from "node:os";
|
|
5
6
|
import { dirname, join } from "node:path";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import {
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { createInterface } from "node:readline";
|
|
8
10
|
import { runLogin } from "./auth/login.js";
|
|
9
|
-
const
|
|
11
|
+
const NAME = "webcake-storefront";
|
|
12
|
+
const PKG = "webcake-storefront-mcp";
|
|
13
|
+
const HOME = homedir();
|
|
14
|
+
const PLAT = platform(); // 'darwin' | 'linux' | 'win32'
|
|
15
|
+
const APPDATA = process.env.APPDATA || join(HOME, "AppData", "Roaming");
|
|
16
|
+
const LOCALAPPDATA = process.env.LOCALAPPDATA || join(HOME, "AppData", "Local");
|
|
17
|
+
// ── tiny ANSI palette + log helpers (mirrors webcake-landing-mcp's installer) ──
|
|
18
|
+
const c = {
|
|
19
|
+
reset: "\x1b[0m",
|
|
20
|
+
bold: "\x1b[1m",
|
|
21
|
+
dim: "\x1b[2m",
|
|
22
|
+
red: "\x1b[31m",
|
|
23
|
+
green: "\x1b[32m",
|
|
24
|
+
yellow: "\x1b[33m",
|
|
25
|
+
cyan: "\x1b[36m",
|
|
26
|
+
magenta: "\x1b[35m",
|
|
27
|
+
gray: "\x1b[90m",
|
|
28
|
+
};
|
|
29
|
+
const log = (m = "", color = "") => console.log(`${color}${m}${c.reset}`);
|
|
30
|
+
const info = (m) => log(` ${c.cyan}›${c.reset} ${m}`);
|
|
31
|
+
const ok = (m) => log(` ${c.green}✓${c.reset} ${m}`);
|
|
32
|
+
const warn = (m) => log(` ${c.yellow}!${c.reset} ${m}`);
|
|
33
|
+
/** One-shot prompt on stdout (fresh readline per question — simple + robust). */
|
|
34
|
+
function ask(question) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
37
|
+
rl.question(question, (answer) => {
|
|
38
|
+
rl.close();
|
|
39
|
+
resolve(answer.trim());
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
10
44
|
function parseArgs(argv) {
|
|
11
45
|
const o = { uninstall: false, ides: [] };
|
|
12
46
|
for (let i = 0; i < argv.length; i++) {
|
|
13
47
|
const a = argv[i];
|
|
14
|
-
if (a === "--uninstall")
|
|
48
|
+
if (a === "--uninstall" || a === "uninstall")
|
|
15
49
|
o.uninstall = true;
|
|
16
50
|
else if (a === "--ide")
|
|
17
51
|
o.ides.push(...(argv[++i] || "").split(",").map((s) => s.trim()).filter(Boolean));
|
|
@@ -32,25 +66,15 @@ function parseArgs(argv) {
|
|
|
32
66
|
}
|
|
33
67
|
return o;
|
|
34
68
|
}
|
|
35
|
-
|
|
36
|
-
const APP_SUPPORT = process.platform === "darwin" ? join(HOME, "Library", "Application Support") : join(HOME, ".config");
|
|
37
|
-
// IDE -> config file + whether it nests under "mcpServers" (vs "mcp").
|
|
38
|
-
const IDE_CONFIGS = {
|
|
39
|
-
"claude-desktop": { path: join(APP_SUPPORT, "Claude", "claude_desktop_config.json"), key: "mcpServers" },
|
|
40
|
-
"claude-code": { path: join(HOME, ".claude.json"), key: "mcpServers" },
|
|
41
|
-
cursor: { path: join(HOME, ".cursor", "mcp.json"), key: "mcpServers" },
|
|
42
|
-
windsurf: { path: join(HOME, ".codeium", "windsurf", "mcp_config.json"), key: "mcpServers" },
|
|
43
|
-
vscode: { path: join(APP_SUPPORT, "Code", "User", "mcp.json"), key: "mcpServers" },
|
|
44
|
-
};
|
|
45
|
-
const ALL_IDES = Object.keys(IDE_CONFIGS);
|
|
69
|
+
// ── launch command (npx vs local node) ───────────────────────────────────────
|
|
46
70
|
function resolveLaunch(opts) {
|
|
47
71
|
const self = fileURLToPath(import.meta.url);
|
|
48
72
|
const ranViaNpx = self.includes("/_npx/") || self.includes("\\_npx\\");
|
|
49
73
|
const useNpx = opts.launch === "npx" || (opts.launch !== "local" && ranViaNpx);
|
|
50
74
|
if (useNpx)
|
|
51
|
-
return { command: "npx", args: ["-y",
|
|
75
|
+
return { command: "npx", args: ["-y", PKG] };
|
|
52
76
|
const entry = join(dirname(self), "index.js");
|
|
53
|
-
return { command:
|
|
77
|
+
return { command: process.execPath, args: [entry] };
|
|
54
78
|
}
|
|
55
79
|
function buildEnv(opts) {
|
|
56
80
|
const env = {};
|
|
@@ -66,118 +90,416 @@ function buildEnv(opts) {
|
|
|
66
90
|
env.WEBCAKE_SITE_ID = opts.siteId;
|
|
67
91
|
return env;
|
|
68
92
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
93
|
+
// ── generic JSON config (Claude Desktop/Code, Cursor, Windsurf, VS Code, …) ───
|
|
94
|
+
function mergeJson(file, launch, env) {
|
|
95
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
96
|
+
let cfg = {};
|
|
97
|
+
if (existsSync(file)) {
|
|
98
|
+
const raw = readFileSync(file, "utf8").trim();
|
|
99
|
+
if (raw) {
|
|
100
|
+
try {
|
|
101
|
+
cfg = JSON.parse(raw);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
warn(`Skip ${file} (invalid JSON: ${e.message})`);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
74
108
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
function writeJson(path, data) {
|
|
80
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
81
|
-
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
82
|
-
}
|
|
83
|
-
function applyToIde(ide, opts, launch, env) {
|
|
84
|
-
const cfg = IDE_CONFIGS[ide];
|
|
85
|
-
if (!cfg)
|
|
86
|
-
return `skip ${ide} (unknown)`;
|
|
87
|
-
const json = readJson(cfg.path);
|
|
88
|
-
const bag = (json[cfg.key] ||= {});
|
|
89
|
-
if (opts.uninstall) {
|
|
90
|
-
if (!(SERVER_KEY in bag))
|
|
91
|
-
return `${ide}: nothing to remove`;
|
|
92
|
-
delete bag[SERVER_KEY];
|
|
93
|
-
writeJson(cfg.path, json);
|
|
94
|
-
return `${ide}: removed (${cfg.path})`;
|
|
95
|
-
}
|
|
96
|
-
bag[SERVER_KEY] = {
|
|
109
|
+
if (typeof cfg.mcpServers !== "object" || !cfg.mcpServers)
|
|
110
|
+
cfg.mcpServers = {};
|
|
111
|
+
cfg.mcpServers[NAME] = {
|
|
97
112
|
command: launch.command,
|
|
98
113
|
args: launch.args,
|
|
99
114
|
...(Object.keys(env).length ? { env } : {}),
|
|
100
115
|
};
|
|
101
|
-
|
|
102
|
-
return
|
|
116
|
+
writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
// ── OpenCode config (its own shape, not mcpServers) ──────────────────────────
|
|
120
|
+
// { "mcp": { "<name>": { "type": "local", "command": [cmd, ...args], "environment": {…} } } }
|
|
121
|
+
function mergeOpencodeJson(file, launch, env) {
|
|
122
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
123
|
+
let cfg = {};
|
|
124
|
+
if (existsSync(file)) {
|
|
125
|
+
const raw = readFileSync(file, "utf8").trim();
|
|
126
|
+
if (raw) {
|
|
127
|
+
try {
|
|
128
|
+
cfg = JSON.parse(raw);
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
warn(`Skip ${file} (invalid JSON: ${e.message})`);
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (typeof cfg.mcp !== "object" || !cfg.mcp)
|
|
137
|
+
cfg.mcp = {};
|
|
138
|
+
cfg.mcp[NAME] = {
|
|
139
|
+
type: "local",
|
|
140
|
+
command: [launch.command, ...launch.args],
|
|
141
|
+
enabled: true,
|
|
142
|
+
...(Object.keys(env).length ? { environment: env } : {}),
|
|
143
|
+
};
|
|
144
|
+
writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
// ── TOML config (Codex) ──────────────────────────────────────────────────────
|
|
148
|
+
function configureCodex(launch, env) {
|
|
149
|
+
const dir = join(HOME, ".codex");
|
|
150
|
+
const cfg = join(dir, "config.toml");
|
|
151
|
+
mkdirSync(dir, { recursive: true });
|
|
152
|
+
const argsToml = launch.args.map((a) => `"${a}"`).join(", ");
|
|
153
|
+
const envParts = Object.entries(env)
|
|
154
|
+
.map(([k, v]) => `"${k}" = "${v}"`)
|
|
155
|
+
.join(", ");
|
|
156
|
+
const envLine = envParts ? `env = { ${envParts} }\n` : "";
|
|
157
|
+
const block = `\n[mcp_servers.${NAME}]\ncommand = "${launch.command}"\nargs = [${argsToml}]\n${envLine}`;
|
|
158
|
+
let content = existsSync(cfg) ? readFileSync(cfg, "utf8") : "# WebCake Storefront MCP\n";
|
|
159
|
+
content = content.replace(new RegExp(`\\n?\\[mcp_servers\\.${NAME}\\][\\s\\S]*?(?=\\n\\[|$)`), "");
|
|
160
|
+
content = content.trimEnd() + "\n" + block;
|
|
161
|
+
writeFileSync(cfg, content);
|
|
162
|
+
}
|
|
163
|
+
// ── IDE config-file locations ────────────────────────────────────────────────
|
|
164
|
+
function claudeDesktopPath() {
|
|
165
|
+
if (PLAT === "win32") {
|
|
166
|
+
// Microsoft Store build is MSIX-sandboxed: it reads/writes config inside its
|
|
167
|
+
// package container, NOT %APPDATA%\Claude. Detect that container first.
|
|
168
|
+
const packages = join(LOCALAPPDATA, "Packages");
|
|
169
|
+
if (existsSync(packages)) {
|
|
170
|
+
try {
|
|
171
|
+
const pkg = readdirSync(packages).find((n) => /^Claude_/i.test(n));
|
|
172
|
+
if (pkg)
|
|
173
|
+
return join(packages, pkg, "LocalCache", "Roaming", "Claude", "claude_desktop_config.json");
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
/* fall through to the Win32 default */
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return join(APPDATA, "Claude", "claude_desktop_config.json");
|
|
180
|
+
}
|
|
181
|
+
const mac = join(HOME, "Library", "Application Support", "Claude");
|
|
182
|
+
const dir = existsSync(mac) ? mac : join(HOME, ".config", "Claude");
|
|
183
|
+
return join(dir, "claude_desktop_config.json");
|
|
184
|
+
}
|
|
185
|
+
function vscodeUserDir() {
|
|
186
|
+
if (PLAT === "win32")
|
|
187
|
+
return join(APPDATA, "Code", "User");
|
|
188
|
+
const mac = join(HOME, "Library", "Application Support", "Code", "User");
|
|
189
|
+
if (existsSync(mac))
|
|
190
|
+
return mac;
|
|
191
|
+
const lin = join(HOME, ".config", "Code", "User");
|
|
192
|
+
if (existsSync(lin))
|
|
193
|
+
return lin;
|
|
194
|
+
return join(HOME, ".vscode");
|
|
195
|
+
}
|
|
196
|
+
const vscodeUserPath = () => join(vscodeUserDir(), "mcp.json");
|
|
197
|
+
const cursorPath = () => join(HOME, ".cursor", "mcp.json");
|
|
198
|
+
const windsurfPath = () => join(HOME, ".codeium", "windsurf", "mcp_config.json");
|
|
199
|
+
const claudeJsonPath = () => join(HOME, ".claude.json");
|
|
200
|
+
const antigravityPath = () => join(HOME, ".gemini", "antigravity", "mcp_config.json");
|
|
201
|
+
const geminiPath = () => join(HOME, ".gemini", "settings.json");
|
|
202
|
+
const kiroPath = () => join(HOME, ".kiro", "settings", "mcp.json");
|
|
203
|
+
const clinePath = () => join(vscodeUserDir(), "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json");
|
|
204
|
+
const opencodePath = () => join(HOME, ".config", "opencode", "opencode.json");
|
|
205
|
+
function hasClaudeCli() {
|
|
206
|
+
const probe = spawnSync(PLAT === "win32" ? "where" : "which", ["claude"], { stdio: "ignore" });
|
|
207
|
+
return probe.status === 0;
|
|
208
|
+
}
|
|
209
|
+
// ── per-IDE configure ────────────────────────────────────────────────────────
|
|
210
|
+
function configureClaudeCode(launch, env) {
|
|
211
|
+
info("Claude Code…");
|
|
212
|
+
if (hasClaudeCli()) {
|
|
213
|
+
spawnSync("claude", ["mcp", "remove", NAME], { stdio: "ignore" });
|
|
214
|
+
const envFlags = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
|
|
215
|
+
const r = spawnSync("claude", ["mcp", "add", NAME, ...envFlags, "--", launch.command, ...launch.args], {
|
|
216
|
+
stdio: "inherit",
|
|
217
|
+
});
|
|
218
|
+
if (r.status === 0) {
|
|
219
|
+
ok("Claude Code configured via CLI — verify: claude mcp list");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
warn("claude CLI failed — falling back to ~/.claude.json");
|
|
223
|
+
}
|
|
224
|
+
if (mergeJson(claudeJsonPath(), launch, env))
|
|
225
|
+
ok(`Claude Code configured (${claudeJsonPath()})`);
|
|
226
|
+
}
|
|
227
|
+
function configureClaudeDesktop(launch, env) {
|
|
228
|
+
info("Claude Desktop…");
|
|
229
|
+
if (mergeJson(claudeDesktopPath(), launch, env)) {
|
|
230
|
+
ok(`Claude Desktop configured (${claudeDesktopPath()})`);
|
|
231
|
+
warn("Restart Claude Desktop to load the server.");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function configureCursor(launch, env) {
|
|
235
|
+
info("Cursor…");
|
|
236
|
+
if (mergeJson(cursorPath(), launch, env))
|
|
237
|
+
ok(`Cursor configured (${cursorPath()})`);
|
|
238
|
+
}
|
|
239
|
+
function configureWindsurf(launch, env) {
|
|
240
|
+
info("Windsurf…");
|
|
241
|
+
if (mergeJson(windsurfPath(), launch, env))
|
|
242
|
+
ok(`Windsurf configured (${windsurfPath()})`);
|
|
243
|
+
}
|
|
244
|
+
function configureAugment(launch, env) {
|
|
245
|
+
info("Augment / VS Code…");
|
|
246
|
+
if (mergeJson(vscodeUserPath(), launch, env))
|
|
247
|
+
ok(`VS Code configured (${vscodeUserPath()})`);
|
|
248
|
+
}
|
|
249
|
+
function configureCodexIde(launch, env) {
|
|
250
|
+
info("Codex…");
|
|
251
|
+
configureCodex(launch, env);
|
|
252
|
+
ok(`Codex configured (${join(HOME, ".codex", "config.toml")}) — restart Codex.`);
|
|
253
|
+
}
|
|
254
|
+
function configureAntigravity(launch, env) {
|
|
255
|
+
info("Antigravity…");
|
|
256
|
+
if (mergeJson(antigravityPath(), launch, env)) {
|
|
257
|
+
ok(`Antigravity configured (${antigravityPath()})`);
|
|
258
|
+
warn("In Antigravity: Agent Manager → Manage MCP Servers → Refresh (or restart).");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function configureGemini(launch, env) {
|
|
262
|
+
info("Gemini CLI…");
|
|
263
|
+
if (mergeJson(geminiPath(), launch, env))
|
|
264
|
+
ok(`Gemini CLI configured (${geminiPath()})`);
|
|
265
|
+
}
|
|
266
|
+
function configureCline(launch, env) {
|
|
267
|
+
info("Cline…");
|
|
268
|
+
if (mergeJson(clinePath(), launch, env))
|
|
269
|
+
ok(`Cline configured (${clinePath()})`);
|
|
270
|
+
}
|
|
271
|
+
function configureKiro(launch, env) {
|
|
272
|
+
info("Kiro…");
|
|
273
|
+
if (mergeJson(kiroPath(), launch, env))
|
|
274
|
+
ok(`Kiro configured (${kiroPath()})`);
|
|
275
|
+
}
|
|
276
|
+
function configureOpencode(launch, env) {
|
|
277
|
+
info("OpenCode…");
|
|
278
|
+
if (mergeOpencodeJson(opencodePath(), launch, env))
|
|
279
|
+
ok(`OpenCode configured (${opencodePath()})`);
|
|
280
|
+
}
|
|
281
|
+
const CONFIGURATORS = {
|
|
282
|
+
"claude-desktop": { label: "Claude Desktop", run: configureClaudeDesktop },
|
|
283
|
+
"claude-code": { label: "Claude Code (CLI)", run: configureClaudeCode },
|
|
284
|
+
cursor: { label: "Cursor", run: configureCursor },
|
|
285
|
+
windsurf: { label: "Windsurf", run: configureWindsurf },
|
|
286
|
+
augment: { label: "Augment / VS Code", run: configureAugment },
|
|
287
|
+
codex: { label: "Codex", run: configureCodexIde },
|
|
288
|
+
antigravity: { label: "Antigravity", run: configureAntigravity },
|
|
289
|
+
gemini: { label: "Gemini CLI", run: configureGemini },
|
|
290
|
+
cline: { label: "Cline", run: configureCline },
|
|
291
|
+
kiro: { label: "Kiro", run: configureKiro },
|
|
292
|
+
opencode: { label: "OpenCode", run: configureOpencode },
|
|
293
|
+
};
|
|
294
|
+
// Numbered wizard order = insertion order of CONFIGURATORS.
|
|
295
|
+
const IDE_ORDER = Object.keys(CONFIGURATORS);
|
|
296
|
+
const IDE_ALIASES = {
|
|
297
|
+
"claude-desktop": "claude-desktop",
|
|
298
|
+
desktop: "claude-desktop",
|
|
299
|
+
"claude-code": "claude-code",
|
|
300
|
+
claude: "claude-code",
|
|
301
|
+
code: "claude-code",
|
|
302
|
+
cursor: "cursor",
|
|
303
|
+
windsurf: "windsurf",
|
|
304
|
+
augment: "augment",
|
|
305
|
+
vscode: "augment",
|
|
306
|
+
"vs-code": "augment",
|
|
307
|
+
codex: "codex",
|
|
308
|
+
antigravity: "antigravity",
|
|
309
|
+
gemini: "gemini",
|
|
310
|
+
"gemini-cli": "gemini",
|
|
311
|
+
cline: "cline",
|
|
312
|
+
kiro: "kiro",
|
|
313
|
+
opencode: "opencode",
|
|
314
|
+
all: "all",
|
|
315
|
+
};
|
|
316
|
+
function normalizeIdes(ides) {
|
|
317
|
+
const set = new Set();
|
|
318
|
+
for (const raw of ides) {
|
|
319
|
+
const id = IDE_ALIASES[raw.trim().toLowerCase()];
|
|
320
|
+
if (id === "all")
|
|
321
|
+
return [...IDE_ORDER];
|
|
322
|
+
if (id)
|
|
323
|
+
set.add(id);
|
|
324
|
+
else
|
|
325
|
+
warn(`Unknown IDE: ${raw}`);
|
|
326
|
+
}
|
|
327
|
+
return [...set];
|
|
328
|
+
}
|
|
329
|
+
function runConfigure(ides, launch, env) {
|
|
330
|
+
for (const id of ides) {
|
|
331
|
+
const conf = CONFIGURATORS[id];
|
|
332
|
+
if (conf)
|
|
333
|
+
conf.run(launch, env);
|
|
334
|
+
else
|
|
335
|
+
warn(`Unknown IDE: ${id}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// ── uninstall ────────────────────────────────────────────────────────────────
|
|
339
|
+
function removeFromJson(file) {
|
|
340
|
+
if (!existsSync(file))
|
|
341
|
+
return;
|
|
342
|
+
try {
|
|
343
|
+
const cfg = JSON.parse(readFileSync(file, "utf8"));
|
|
344
|
+
if (cfg.mcpServers && cfg.mcpServers[NAME]) {
|
|
345
|
+
delete cfg.mcpServers[NAME];
|
|
346
|
+
writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
|
|
347
|
+
ok(`Cleaned ${file}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
/* ignore unparseable files */
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function uninstall() {
|
|
355
|
+
log(`\n${c.bold} Removing ${PKG} from every IDE config${c.reset}\n`);
|
|
356
|
+
if (hasClaudeCli())
|
|
357
|
+
spawnSync("claude", ["mcp", "remove", NAME], { stdio: "ignore" });
|
|
358
|
+
[
|
|
359
|
+
claudeJsonPath(),
|
|
360
|
+
claudeDesktopPath(),
|
|
361
|
+
join(HOME, ".config", "Claude", "claude_desktop_config.json"),
|
|
362
|
+
cursorPath(),
|
|
363
|
+
windsurfPath(),
|
|
364
|
+
vscodeUserPath(),
|
|
365
|
+
antigravityPath(),
|
|
366
|
+
geminiPath(),
|
|
367
|
+
clinePath(),
|
|
368
|
+
kiroPath(),
|
|
369
|
+
].forEach(removeFromJson);
|
|
370
|
+
// OpenCode keeps the server under its own `mcp` key, not `mcpServers`.
|
|
371
|
+
const oc = opencodePath();
|
|
372
|
+
if (existsSync(oc)) {
|
|
373
|
+
try {
|
|
374
|
+
const cfg = JSON.parse(readFileSync(oc, "utf8"));
|
|
375
|
+
if (cfg.mcp && cfg.mcp[NAME]) {
|
|
376
|
+
delete cfg.mcp[NAME];
|
|
377
|
+
writeFileSync(oc, JSON.stringify(cfg, null, 2) + "\n");
|
|
378
|
+
ok(`Cleaned ${oc}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
/* ignore unparseable files */
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const codex = join(HOME, ".codex", "config.toml");
|
|
386
|
+
if (existsSync(codex)) {
|
|
387
|
+
let content = readFileSync(codex, "utf8");
|
|
388
|
+
content = content.replace(new RegExp(`\\n?\\[mcp_servers\\.${NAME}\\][\\s\\S]*?(?=\\n\\[|$)`), "");
|
|
389
|
+
writeFileSync(codex, content.trimEnd() + "\n");
|
|
390
|
+
ok("Cleaned Codex config.toml");
|
|
391
|
+
}
|
|
392
|
+
log(`\n${c.green}${c.bold} ✓ Removed. Restart your IDE.${c.reset}\n`);
|
|
103
393
|
}
|
|
104
394
|
/**
|
|
105
|
-
* Interactive wizard (TTY only): pick environment → authenticate (browser
|
|
106
|
-
* recommended, or paste a token) → pick IDEs.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
395
|
+
* Interactive numbered wizard (TTY only): pick environment → authenticate (browser
|
|
396
|
+
* login, recommended, or paste a token) → pick IDEs by number. Returns whether a
|
|
397
|
+
* browser login completed (token then lives in the local config db, so it is NOT
|
|
398
|
+
* written into the IDE env block) and a short auth note for the summary.
|
|
109
399
|
*/
|
|
110
400
|
async function promptInteractive(opts) {
|
|
111
401
|
let loggedIn = false;
|
|
402
|
+
let authNote = opts.token ? "token (from flag)" : "";
|
|
112
403
|
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
113
|
-
return { loggedIn };
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
console.error(`Login didn't complete (${e?.message ?? e}). Paste a token now, or run \`npx -y webcake-storefront-mcp login\` later.`);
|
|
139
|
-
}
|
|
404
|
+
return { loggedIn, authNote };
|
|
405
|
+
// 1) Environment — one choice sets the API + app base URLs (default prod).
|
|
406
|
+
if (!opts.env && !process.env.WEBCAKE_ENV) {
|
|
407
|
+
log(`\n${c.bold}1) Environment${c.reset} ${c.gray}(sets the WebCake API + app URLs)${c.reset}`);
|
|
408
|
+
log(` ${c.bold}1${c.reset}) prod ${c.gray}api.storefront.webcake.io${c.reset} ${c.gray}(default)${c.reset}`);
|
|
409
|
+
log(` ${c.bold}2${c.reset}) staging ${c.gray}api.staging.storecake.io${c.reset}`);
|
|
410
|
+
log(` ${c.bold}3${c.reset}) local ${c.gray}localhost:24679${c.reset}`);
|
|
411
|
+
const pick = (await ask(` ${c.cyan}Select [1=prod, Enter to accept]:${c.reset} `)).trim();
|
|
412
|
+
opts.env = { "1": "prod", "2": "staging", "3": "local" }[pick] ?? "prod";
|
|
413
|
+
}
|
|
414
|
+
if (opts.env)
|
|
415
|
+
process.env.WEBCAKE_ENV = opts.env; // so the login flow opens the right app
|
|
416
|
+
// 2) Authentication — browser login (recommended), paste a token, or skip.
|
|
417
|
+
if (!opts.token && !process.env.WEBCAKE_TOKEN) {
|
|
418
|
+
log(`\n${c.bold}2) Connect your WebCake account${c.reset}`);
|
|
419
|
+
log(` ${c.bold}1${c.reset}) Log in via browser ${c.gray}(recommended — opens WebCake, saves a token)${c.reset}`);
|
|
420
|
+
log(` ${c.bold}2${c.reset}) Paste a token manually`);
|
|
421
|
+
log(` ${c.bold}3${c.reset}) Skip for now ${c.gray}(set credentials later with the login command)${c.reset}`);
|
|
422
|
+
const choice = (await ask(` ${c.cyan}Select [1]:${c.reset} `)).trim() || "1";
|
|
423
|
+
if (choice === "1") {
|
|
424
|
+
info("Opening your browser to log in…");
|
|
425
|
+
try {
|
|
426
|
+
await runLogin([]); // loopback browser flow → saves token + session to local config db
|
|
427
|
+
loggedIn = true;
|
|
428
|
+
authNote = "browser login (saved to local config)";
|
|
140
429
|
}
|
|
141
|
-
|
|
142
|
-
|
|
430
|
+
catch (e) {
|
|
431
|
+
warn(`Login didn't complete (${e?.message ?? e}). Paste a token now, or run \`npx -y ${PKG} login\` later.`);
|
|
432
|
+
const t = (await ask(` ${c.cyan}Token (paste JWT, or Enter to skip):${c.reset} `)).trim();
|
|
143
433
|
if (t)
|
|
144
434
|
opts.token = t;
|
|
145
|
-
const s = (await
|
|
435
|
+
const s = t ? (await ask(` ${c.cyan}Session id (x-session-id):${c.reset} `)).trim() : "";
|
|
146
436
|
if (s)
|
|
147
437
|
opts.sessionId = s;
|
|
148
438
|
}
|
|
149
439
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
440
|
+
else if (choice === "2") {
|
|
441
|
+
const t = (await ask(` ${c.cyan}Token (paste JWT):${c.reset} `)).trim();
|
|
442
|
+
if (t)
|
|
443
|
+
opts.token = t;
|
|
444
|
+
const s = (await ask(` ${c.cyan}Session id (x-session-id):${c.reset} `)).trim();
|
|
445
|
+
if (s)
|
|
446
|
+
opts.sessionId = s;
|
|
447
|
+
authNote = opts.token ? "pasted token" : "none";
|
|
154
448
|
}
|
|
155
|
-
// Site is chosen at runtime — use the list_my_sites / switch_site tools in chat.
|
|
156
449
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
450
|
+
if (!authNote)
|
|
451
|
+
authNote = opts.token || process.env.WEBCAKE_TOKEN ? "token" : "none — log in later";
|
|
452
|
+
// 3) IDEs to configure — numbered, comma-separated (e.g. 1,2 or the "All" number).
|
|
453
|
+
if (!opts.ides.length) {
|
|
454
|
+
log(`\n${c.bold}3) Which IDE(s) / agent(s) to configure?${c.reset}`);
|
|
455
|
+
IDE_ORDER.forEach((id, i) => {
|
|
456
|
+
const n = `${c.bold}${String(i + 1).padStart(2)}${c.reset}`;
|
|
457
|
+
log(` ${n}) ${CONFIGURATORS[id].label}`);
|
|
458
|
+
});
|
|
459
|
+
const allNum = IDE_ORDER.length + 1;
|
|
460
|
+
log(` ${c.bold}${String(allNum).padStart(2)}${c.reset}) ${c.green}All of the above${c.reset} ${c.gray} 0) Skip${c.reset}`);
|
|
461
|
+
const pick = await ask(` ${c.cyan}Select (comma-separated, e.g. 1,2):${c.reset} `);
|
|
462
|
+
const picks = pick.split(",").map((s) => s.trim()).filter(Boolean);
|
|
463
|
+
if (picks.includes(String(allNum)))
|
|
464
|
+
opts.ides = [...IDE_ORDER];
|
|
465
|
+
else
|
|
466
|
+
opts.ides = picks.map((n) => IDE_ORDER[Number(n) - 1]).filter(Boolean);
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
opts.ides = normalizeIdes(opts.ides);
|
|
164
470
|
}
|
|
165
|
-
|
|
471
|
+
// Site is chosen at runtime — use the list_my_sites / switch_site tools in chat.
|
|
472
|
+
return { loggedIn, authNote };
|
|
166
473
|
}
|
|
167
474
|
export async function runInstaller(argv) {
|
|
168
475
|
const opts = parseArgs(argv);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (
|
|
172
|
-
|
|
476
|
+
log(`\n${c.magenta}${c.bold} WebCake Storefront MCP${c.reset} ${c.gray}— installer${c.reset}`);
|
|
477
|
+
log(`${c.gray} Build sites, pages, products & orders on WebCake from a prompt.${c.reset}`);
|
|
478
|
+
if (opts.uninstall)
|
|
479
|
+
return uninstall();
|
|
480
|
+
const { loggedIn, authNote } = await promptInteractive(opts);
|
|
481
|
+
// Normalize IDE selection (expands "all", resolves aliases). The wizard already
|
|
482
|
+
// yields canonical keys; this also covers the flag-driven / non-TTY path where
|
|
483
|
+
// promptInteractive returns early without touching opts.ides.
|
|
484
|
+
const ides = normalizeIdes(opts.ides);
|
|
485
|
+
if (!ides.length) {
|
|
486
|
+
if (!process.stdin.isTTY) {
|
|
487
|
+
warn("No --ide given (or not a TTY). Nothing to configure.");
|
|
488
|
+
log(`${c.gray} Try: npx -y ${PKG} install --ide all${c.reset}\n`);
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
warn("No IDE selected — nothing to configure.");
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
173
495
|
const launch = resolveLaunch(opts);
|
|
174
496
|
const env = buildEnv(opts);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
497
|
+
log(`\n${c.bold}Writing config${c.reset} ${c.gray}(launch: ${launch.command} ${launch.args.join(" ")})${c.reset}`);
|
|
498
|
+
runConfigure(ides, launch, env);
|
|
499
|
+
log(`\n${c.green}${c.bold} ✓ Done.${c.reset}`);
|
|
500
|
+
log(` ${c.gray}Environment : ${opts.env || process.env.WEBCAKE_ENV || "prod"}${c.reset}`);
|
|
501
|
+
log(` ${c.gray}Auth : ${authNote}${c.reset}`);
|
|
502
|
+
if (loggedIn)
|
|
503
|
+
log(` ${c.gray}Token saved to local config (not written into IDE files).${c.reset}`);
|
|
504
|
+
log(` ${c.cyan}Restart your IDE, then ask the AI to build a storefront page.${c.reset}\n`);
|
|
183
505
|
}
|
package/dist/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { registerArticleTools } from "./tools/articles.js";
|
|
|
7
7
|
import { registerCustomerTools } from "./tools/customers.js";
|
|
8
8
|
import { registerAutomationTools } from "./tools/automation.js";
|
|
9
9
|
import { registerProductTools } from "./tools/products.js";
|
|
10
|
+
import { registerCatalogWriteTools } from "./tools/catalog-write.js";
|
|
10
11
|
import { registerOrderTools } from "./tools/orders.js";
|
|
11
12
|
import { registerSiteStyleTools } from "./tools/site-style.js";
|
|
12
13
|
import { registerAppTools } from "./tools/apps.js";
|
|
@@ -22,6 +23,8 @@ IMPORTANT: When the user asks ANY question about their website, store, products,
|
|
|
22
23
|
|
|
23
24
|
You can also BUILD pages: use get_build_guide, list_elements, get_element to learn the BuilderX component model, new_section/new_element to compose, validate_page to check, then build_page (dry_run first) to create. Publishing is site-level via publish_site.
|
|
24
25
|
|
|
26
|
+
To make a generated site look real, also CREATE DATA so dataset bindings resolve: create_product_category + create_product (storefront), create_blog_category + create_article (blog). Get image URLs from search_images / upload_images first, then reference them. A good flow for a fresh site: create_site → create a few categories → create products in them → build_page (home + store/blog pages) → publish_site.
|
|
27
|
+
|
|
25
28
|
Workflow:
|
|
26
29
|
1. On first interaction, call get_current_context. The site is NOT set from env — if no site is selected yet, call list_my_sites and ask the user which site to work on, then switch_site (the choice is saved and reused next session). To start from scratch, create_site makes a new site and switches to it; then build a homepage with build_page (type:'main', is_homepage:true).
|
|
27
30
|
2. Before answering a site-specific question, query the relevant tool.
|
|
@@ -50,6 +53,7 @@ export function createServer(api, opts = {}) {
|
|
|
50
53
|
registerCustomerTools(server, api, handle);
|
|
51
54
|
registerAutomationTools(server, api, handle);
|
|
52
55
|
registerProductTools(server, api, handle);
|
|
56
|
+
registerCatalogWriteTools(server, api, handle);
|
|
53
57
|
registerOrderTools(server, api, handle);
|
|
54
58
|
registerSiteStyleTools(server, api, handle);
|
|
55
59
|
registerAppTools(server, api, handle);
|
package/dist/tools/articles.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
export function registerArticleTools(server, api, handle) {
|
|
3
4
|
server.tool("list_articles", "List blog articles (metadata only, without HTML content). Use get_article to get full content", {
|
|
4
5
|
page: z.number().optional().describe("Page number"),
|
|
@@ -27,16 +28,36 @@ export function registerArticleTools(server, api, handle) {
|
|
|
27
28
|
server.tool("get_article", "Get article details by ID", {
|
|
28
29
|
id: z.string().describe("Article ID"),
|
|
29
30
|
}, ({ id }) => handle(() => api.getArticle(id)));
|
|
30
|
-
server.tool("create_article",
|
|
31
|
+
server.tool("create_article", `Create a blog article so blog/post pages (post-list, grid-blog, post-overlay) have content.
|
|
32
|
+
Built via the dashboard command pipeline: title + optional summary, HTML content, image URLs, and
|
|
33
|
+
category linkage. Pass category_ids from create_blog_category / list articles' categories so the
|
|
34
|
+
post shows up under those categories (it is also auto-filed under the default category). Image URLs
|
|
35
|
+
must be hosted (search_images / upload_images). The backend generates the id and slug.`, {
|
|
31
36
|
name: z.string().describe("Article title"),
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
content: z.string().optional().describe("HTML content of the post"),
|
|
38
|
+
summary: z.string().optional().describe("Short summary / excerpt"),
|
|
39
|
+
images: z.array(z.string()).optional().describe("Hosted image URLs; the first is the cover image"),
|
|
40
|
+
category_ids: z.array(z.string()).optional().describe("Blog category IDs to file the post under (from create_blog_category)"),
|
|
41
|
+
}, ({ name, content, summary, images, category_ids }) => handle(async () => {
|
|
42
|
+
const id = randomUUID();
|
|
43
|
+
const commands = [{ name: "create_article", data: { id, name } }];
|
|
44
|
+
if (summary)
|
|
45
|
+
commands.push({ name: "summary_article", data: { id, summary } });
|
|
46
|
+
if (images && images.length)
|
|
47
|
+
commands.push({ name: "image_article", data: { id, images } });
|
|
48
|
+
if (content)
|
|
49
|
+
commands.push({ name: "content_article", data: { id, content } });
|
|
50
|
+
if (category_ids && category_ids.length)
|
|
51
|
+
commands.push({ name: "bulk_add_category_to_article", data: { id, ids: category_ids } });
|
|
52
|
+
await api.createBlogArticle(commands);
|
|
53
|
+
return {
|
|
54
|
+
success: true,
|
|
55
|
+
article_id: id,
|
|
56
|
+
name,
|
|
57
|
+
categories: category_ids || [],
|
|
58
|
+
cover: images?.[0] || null,
|
|
59
|
+
};
|
|
60
|
+
}));
|
|
40
61
|
server.tool("update_article", "Update a blog article", {
|
|
41
62
|
id: z.string().describe("Article ID"),
|
|
42
63
|
name: z.string().optional().describe("New title"),
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Write tools for catalog DATA — products, product categories, blog categories — so a
|
|
2
|
+
// generated site has real content to render (grid-product / grid-category / post-list
|
|
3
|
+
// bindings resolve only when these exist). Articles already have create_article.
|
|
4
|
+
//
|
|
5
|
+
// The category endpoints are COMMAND-based and the CALLER must generate the new id
|
|
6
|
+
// (the response is a generic {code:success}), so we mint a UUID and return it.
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
const variationSpec = z.object({
|
|
10
|
+
retail_price: z.number().describe("Selling price (what the customer pays)"),
|
|
11
|
+
original_price: z.number().optional().describe("List/compare-at price (defaults to retail_price)"),
|
|
12
|
+
remain_quantity: z.number().optional().describe("Stock quantity (default 100)"),
|
|
13
|
+
custom_id: z.string().optional().describe("SKU for this variation (auto-generated if omitted)"),
|
|
14
|
+
images: z.array(z.string()).optional().describe("Hosted image URLs for this variation"),
|
|
15
|
+
weight: z.number().optional().describe("Weight in grams (default 0)"),
|
|
16
|
+
fields: z
|
|
17
|
+
.array(z.object({ name: z.string(), value: z.string() }))
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Attribute values for this variation, e.g. [{name:'Color',value:'Đen'},{name:'Size',value:'M'}]"),
|
|
20
|
+
});
|
|
21
|
+
export function registerCatalogWriteTools(server, api, handle) {
|
|
22
|
+
server.tool("create_product", `Create a product so the storefront has real merchandise (grid-product / slider-product bindings need this).
|
|
23
|
+
Simple use: pass name + price (+ images, category_ids). One default variation with the price/stock is created for you.
|
|
24
|
+
Advanced use: pass attributes (e.g. Color/Size) + variations for a multi-SKU product.
|
|
25
|
+
Images must be HOSTED URLs — get them from search_images or upload_images first. The backend generates id, slug and publishes the product.`, {
|
|
26
|
+
name: z.string().describe("Product name"),
|
|
27
|
+
price: z.number().optional().describe("Selling price (used when you don't pass variations). Required unless variations are given."),
|
|
28
|
+
original_price: z.number().optional().describe("List/compare-at price for the simple single-variation case"),
|
|
29
|
+
stock: z.number().default(100).describe("Stock quantity for the simple single-variation case"),
|
|
30
|
+
sku: z.string().optional().describe("SKU / custom_id for the simple case (auto-generated if omitted)"),
|
|
31
|
+
images: z.array(z.string()).optional().describe("Hosted image URLs (search_images/upload_images). First image becomes the product thumbnail."),
|
|
32
|
+
description: z.string().optional().describe("Product description (HTML allowed)"),
|
|
33
|
+
category_ids: z.array(z.string()).optional().describe("Product category IDs to file the product under (from create_product_category / list_categories)"),
|
|
34
|
+
attributes: z
|
|
35
|
+
.array(z.object({ name: z.string(), values: z.array(z.string()) }))
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("Variant axes, e.g. [{name:'Color',values:['Đen','Trắng']},{name:'Size',values:['S','M','L']}]"),
|
|
38
|
+
variations: z.array(variationSpec).optional().describe("Explicit per-SKU variations. Omit to auto-build one from price/stock/sku."),
|
|
39
|
+
}, ({ name, price, original_price, stock, sku, images, description, category_ids, attributes, variations }) => handle(async () => {
|
|
40
|
+
let vars = variations;
|
|
41
|
+
if (!vars || !vars.length) {
|
|
42
|
+
if (price == null)
|
|
43
|
+
throw new Error("Provide `price` (or explicit `variations`) to create a product.");
|
|
44
|
+
vars = [
|
|
45
|
+
{
|
|
46
|
+
custom_id: sku || `SKU-${randomUUID().slice(0, 8)}`,
|
|
47
|
+
retail_price: price,
|
|
48
|
+
original_price: original_price ?? price,
|
|
49
|
+
remain_quantity: stock ?? 100,
|
|
50
|
+
images: images || [],
|
|
51
|
+
weight: 0,
|
|
52
|
+
fields: [],
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
// Normalise: fill SKU / original_price / stock defaults per variation.
|
|
58
|
+
vars = vars.map((v) => ({
|
|
59
|
+
custom_id: v.custom_id || `SKU-${randomUUID().slice(0, 8)}`,
|
|
60
|
+
retail_price: v.retail_price,
|
|
61
|
+
original_price: v.original_price ?? v.retail_price,
|
|
62
|
+
remain_quantity: v.remain_quantity ?? 100,
|
|
63
|
+
images: v.images || [],
|
|
64
|
+
weight: v.weight ?? 0,
|
|
65
|
+
fields: v.fields || [],
|
|
66
|
+
is_hidden: false,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
const productParams = {
|
|
70
|
+
name,
|
|
71
|
+
variations: vars,
|
|
72
|
+
...(description ? { description } : {}),
|
|
73
|
+
...(attributes ? { product_attributes: attributes } : {}),
|
|
74
|
+
...(category_ids ? { categories: category_ids } : {}),
|
|
75
|
+
...(images && images.length ? { image: images[0] } : {}),
|
|
76
|
+
};
|
|
77
|
+
const res = await api.createProduct(productParams);
|
|
78
|
+
const data = res?.data;
|
|
79
|
+
const productId = data?.attributes?.id || data?.id || data?.product?.id || null;
|
|
80
|
+
return {
|
|
81
|
+
success: true,
|
|
82
|
+
product_id: productId,
|
|
83
|
+
name,
|
|
84
|
+
slug: data?.attributes?.slug || data?.slug || null,
|
|
85
|
+
variations: vars.length,
|
|
86
|
+
categories: category_ids || [],
|
|
87
|
+
raw: productId ? undefined : res, // surface raw response only if we couldn't find the id
|
|
88
|
+
};
|
|
89
|
+
}));
|
|
90
|
+
server.tool("create_product_category", `Create a product category (so grid-category / a category page has something to show, and products can be filed under it).
|
|
91
|
+
Returns the new category id — pass it to create_product's category_ids. Image must be a hosted URL.`, {
|
|
92
|
+
name: z.string().describe("Category name"),
|
|
93
|
+
image: z.string().optional().describe("Hosted image URL for the category card"),
|
|
94
|
+
description: z.string().optional().describe("Category description"),
|
|
95
|
+
parent_id: z.string().optional().describe("Parent category id for a sub-category"),
|
|
96
|
+
}, ({ name, image, description, parent_id }) => handle(async () => {
|
|
97
|
+
const id = randomUUID();
|
|
98
|
+
const commands = [
|
|
99
|
+
{ name: "create_category", data: { id, name, ...(parent_id ? { parent_id } : {}) } },
|
|
100
|
+
];
|
|
101
|
+
if (image)
|
|
102
|
+
commands.push({ name: "image_category", data: { id, image } });
|
|
103
|
+
if (description) {
|
|
104
|
+
commands.push({
|
|
105
|
+
name: "multi_description",
|
|
106
|
+
data: { id, multi_description: [{ id: randomUUID(), title: name, description }] },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
await api.createProductCategory(commands);
|
|
110
|
+
return { success: true, category_id: id, name, ...(parent_id ? { parent_id } : {}) };
|
|
111
|
+
}));
|
|
112
|
+
server.tool("create_blog_category", `Create a blog/article category. Returns the new category id — pass it to create_article's category_id so posts are grouped (post-list / blog pages bind to it). Image must be a hosted URL.`, {
|
|
113
|
+
name: z.string().describe("Blog category name"),
|
|
114
|
+
image: z.string().optional().describe("Hosted image URL"),
|
|
115
|
+
description: z.string().optional().describe("Category description"),
|
|
116
|
+
}, ({ name, image, description }) => handle(async () => {
|
|
117
|
+
const id = randomUUID();
|
|
118
|
+
const commands = [{ name: "create_category", data: { id, name } }];
|
|
119
|
+
if (description)
|
|
120
|
+
commands.push({ name: "description_category", data: { id, description } });
|
|
121
|
+
if (image)
|
|
122
|
+
commands.push({ name: "image_category", data: { id, image } });
|
|
123
|
+
await api.createBlogCategory(commands);
|
|
124
|
+
return { success: true, category_id: id, name };
|
|
125
|
+
}));
|
|
126
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-storefront-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "MCP server for the WebCake/StoreCake storefront builder — page CRUD, page authoring, products, orders, and more",
|
|
5
5
|
"mcpName": "io.github.vuluu2k/webcake-storefront-mcp",
|
|
6
6
|
"license": "MIT",
|