html2pptx-local-mcp 1.1.17

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.
@@ -0,0 +1,588 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { createServer } from "node:http";
4
+ import { platform } from "node:os";
5
+ import { mkdir, readFile, realpath, stat, writeFile, } from "node:fs/promises";
6
+ import { dirname, extname, join, relative, resolve, sep, } from "node:path";
7
+ import * as p from "@clack/prompts";
8
+ import pc from "picocolors";
9
+ const AUTO_PORT = 0;
10
+ const MAX_WRITE_BYTES = 5 * 1024 * 1024;
11
+ const ALLOWED_EXTENSIONS = [".html", ".htm"];
12
+ const ALLOWED_EXT = new Set(ALLOWED_EXTENSIONS);
13
+ const DISALLOWED_TOP_DIRECTORIES = [
14
+ "public",
15
+ ".next",
16
+ ".git",
17
+ "node_modules",
18
+ "app",
19
+ "pages",
20
+ "components",
21
+ "lib",
22
+ "convex",
23
+ "scripts",
24
+ "mcp",
25
+ "worker",
26
+ "src",
27
+ ];
28
+ const DISALLOWED_TOP_DIRS = new Set(DISALLOWED_TOP_DIRECTORIES);
29
+ const EDITOR_SERVER_STATE_FILE = ".html2pptx/edit-slide/editor-server.json";
30
+ const LEGACY_EDITOR_SERVER_STATE_FILE = ".open-slide/editor-server.json";
31
+ const EDITOR_BASE_URL_EXAMPLE = "http://localhost:<port>";
32
+ function sha256(content) {
33
+ return createHash("sha256").update(content).digest("hex");
34
+ }
35
+ function generateSessionToken() {
36
+ return randomBytes(32).toString("base64url");
37
+ }
38
+ function toPosixPath(filePath) {
39
+ return filePath.split(sep).join("/");
40
+ }
41
+ function relativeToRoot(root, abs) {
42
+ return toPosixPath(relative(root, abs));
43
+ }
44
+ function isLoopbackHostname(hostname) {
45
+ const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
46
+ return (host === "localhost" ||
47
+ host === "::1" ||
48
+ host === "0:0:0:0:0:0:0:1" ||
49
+ /^127(?:\.\d{1,3}){3}$/.test(host));
50
+ }
51
+ function isLoopbackHostHeader(host) {
52
+ if (!host)
53
+ return false;
54
+ try {
55
+ return isLoopbackHostname(new URL(`http://${host}`).hostname);
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ function parsePort(value) {
62
+ if (!value)
63
+ return AUTO_PORT;
64
+ const port = Number.parseInt(value, 10);
65
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
66
+ throw new Error("port must be an integer from 0 to 65535");
67
+ }
68
+ return port;
69
+ }
70
+ function isAllowedEditorBaseUrl(url) {
71
+ if (!["http:", "https:"].includes(url.protocol))
72
+ return false;
73
+ return isLoopbackHostname(url.hostname);
74
+ }
75
+ function normalizeBaseUrl(raw) {
76
+ const base = new URL(raw);
77
+ base.hash = "";
78
+ base.search = "";
79
+ if (!isAllowedEditorBaseUrl(base)) {
80
+ throw new Error(`baseUrl for local file editing must be a loopback http(s) origin such as ${EDITOR_BASE_URL_EXAMPLE}. Hosted editor URLs are not allowed.`);
81
+ }
82
+ return base;
83
+ }
84
+ async function readRegisteredEditorBaseUrl(root) {
85
+ for (const stateFile of [EDITOR_SERVER_STATE_FILE, LEGACY_EDITOR_SERVER_STATE_FILE]) {
86
+ try {
87
+ const raw = await readFile(join(root, stateFile), "utf8");
88
+ const state = JSON.parse(raw);
89
+ if (typeof state.baseUrl === "string")
90
+ return state.baseUrl;
91
+ }
92
+ catch {
93
+ // Try the next known state location.
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+ async function resolveEditorBaseUrl(root, explicitBaseUrl) {
99
+ const raw = explicitBaseUrl || await readRegisteredEditorBaseUrl(root);
100
+ if (!raw) {
101
+ throw new Error("Local editor UI is not registered. Start it with `node scripts/dev-studio.mjs`, then rerun `html2pptx edit`, or pass --base-url http://localhost:<port>.");
102
+ }
103
+ const baseUrl = normalizeBaseUrl(raw);
104
+ if (!(await isEditorBaseReachable(baseUrl))) {
105
+ throw new Error(`Local editor UI is not reachable at ${baseUrl.origin}. Start it with \`node scripts/dev-studio.mjs\`, then rerun \`html2pptx edit\`, or pass the active --base-url.`);
106
+ }
107
+ return baseUrl;
108
+ }
109
+ async function isEditorBaseReachable(baseUrl) {
110
+ const probeUrl = new URL("/api/edit-slide/local-health", baseUrl);
111
+ const controller = new AbortController();
112
+ const timer = setTimeout(() => controller.abort(), 7000);
113
+ try {
114
+ const response = await fetch(probeUrl, {
115
+ headers: { accept: "application/json" },
116
+ signal: controller.signal,
117
+ });
118
+ if (!response.ok)
119
+ return false;
120
+ const payload = (await response.json());
121
+ return payload.app === "html2pptx-local-editor" && payload.ok === true;
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ finally {
127
+ clearTimeout(timer);
128
+ }
129
+ }
130
+ function buildEditorUrl(baseUrl, rel, bridgeUrl, sessionToken) {
131
+ const editorUrl = new URL("/edit-slide", baseUrl);
132
+ editorUrl.searchParams.set("file", rel);
133
+ editorUrl.searchParams.set("bridge", bridgeUrl);
134
+ editorUrl.hash = new URLSearchParams({ bridgeToken: sessionToken }).toString();
135
+ return editorUrl;
136
+ }
137
+ function allowedOrigin(origin, editorOrigin) {
138
+ if (!origin)
139
+ return true;
140
+ try {
141
+ const url = new URL(origin);
142
+ return url.origin === editorOrigin || isLoopbackHostname(url.hostname);
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ }
148
+ function applyCors(req, res, editorOrigin) {
149
+ const origin = req.headers.origin;
150
+ if (typeof origin === "string" && !allowedOrigin(origin, editorOrigin)) {
151
+ sendJson(res, 403, { error: "forbidden origin" });
152
+ return false;
153
+ }
154
+ res.setHeader("Vary", "Origin, Access-Control-Request-Headers, Access-Control-Request-Method");
155
+ res.setHeader("Access-Control-Allow-Origin", typeof origin === "string" ? origin : editorOrigin);
156
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
157
+ res.setHeader("Access-Control-Allow-Headers", "content-type,x-edit-slide-local,x-open-slide-local,x-edit-slide-token");
158
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
159
+ return true;
160
+ }
161
+ function sendJson(res, status, payload) {
162
+ res.statusCode = status;
163
+ res.setHeader("content-type", "application/json; charset=utf-8");
164
+ res.end(JSON.stringify(payload));
165
+ }
166
+ async function readExisting(abs) {
167
+ try {
168
+ return await readFile(abs, "utf8");
169
+ }
170
+ catch (error) {
171
+ if (error?.code === "ENOENT")
172
+ return null;
173
+ throw error;
174
+ }
175
+ }
176
+ async function resolveReal(abs) {
177
+ try {
178
+ return await realpath(abs);
179
+ }
180
+ catch {
181
+ let parent = dirname(abs);
182
+ while (parent !== dirname(parent)) {
183
+ try {
184
+ const realParent = await realpath(parent);
185
+ return join(realParent, relative(parent, abs));
186
+ }
187
+ catch {
188
+ parent = dirname(parent);
189
+ }
190
+ }
191
+ return abs;
192
+ }
193
+ }
194
+ async function safePath(ctx, rel) {
195
+ if (typeof rel !== "string" || !rel) {
196
+ throw new Error("missing path");
197
+ }
198
+ const normalized = rel.replace(/^\/+/, "");
199
+ const abs = resolve(ctx.root, normalized);
200
+ if (abs !== ctx.root && !abs.startsWith(ctx.root + sep)) {
201
+ throw new Error("path escape");
202
+ }
203
+ const ext = extname(abs).toLowerCase();
204
+ if (!ALLOWED_EXT.has(ext)) {
205
+ throw new Error("only .html/.htm files are allowed");
206
+ }
207
+ const real = await resolveReal(abs);
208
+ if (real !== ctx.root && !real.startsWith(ctx.root + sep)) {
209
+ throw new Error("path escape via symlink");
210
+ }
211
+ for (const candidate of [relative(ctx.root, abs), relative(ctx.root, real)]) {
212
+ const first = candidate.split(sep)[0];
213
+ if (DISALLOWED_TOP_DIRS.has(first)) {
214
+ throw new Error(`writes under ${first}/ are not allowed`);
215
+ }
216
+ }
217
+ return real;
218
+ }
219
+ function buildPolicy(ctx) {
220
+ return {
221
+ enabled: true,
222
+ root: ctx.root,
223
+ sessionTokenRequired: true,
224
+ allowedExtensions: ALLOWED_EXTENSIONS,
225
+ disallowedTopDirectories: DISALLOWED_TOP_DIRECTORIES,
226
+ stateDirectory: relativeToRoot(ctx.root, ctx.localStateDir),
227
+ historyEnabled: false,
228
+ maxWriteBytes: MAX_WRITE_BYTES,
229
+ requestGuards: [
230
+ "127.0.0.1 listener only",
231
+ "matching editor Origin",
232
+ "per-session bridge token required for reads and writes",
233
+ "X-Edit-Slide-Local: 1 required for writes",
234
+ "path traversal and symlink escape checks",
235
+ "optimistic hash check before writes",
236
+ ],
237
+ };
238
+ }
239
+ function tokensMatch(actual, expected) {
240
+ if (!actual || !expected)
241
+ return false;
242
+ const actualBuf = Buffer.from(actual);
243
+ const expectedBuf = Buffer.from(expected);
244
+ if (actualBuf.length !== expectedBuf.length)
245
+ return false;
246
+ return timingSafeEqual(actualBuf, expectedBuf);
247
+ }
248
+ function requestToken(req, reqUrl) {
249
+ const headerToken = req.headers["x-edit-slide-token"];
250
+ if (typeof headerToken === "string" && headerToken)
251
+ return headerToken;
252
+ return reqUrl.searchParams.get("token") || "";
253
+ }
254
+ function validateBridgeRequest(req, ctx, reqUrl) {
255
+ if (!isLoopbackHostHeader(req.headers.host)) {
256
+ return false;
257
+ }
258
+ if (!allowedOrigin(typeof req.headers.origin === "string" ? req.headers.origin : undefined, ctx.editorOrigin)) {
259
+ return false;
260
+ }
261
+ return tokensMatch(requestToken(req, reqUrl), ctx.sessionToken);
262
+ }
263
+ async function readFileMeta(ctx, abs) {
264
+ const content = await readFile(abs, "utf8");
265
+ const fileStat = await stat(abs);
266
+ return {
267
+ path: relativeToRoot(ctx.root, abs),
268
+ size: content.length,
269
+ bytes: Buffer.byteLength(content, "utf8"),
270
+ sha256: sha256(content),
271
+ mtimeMs: fileStat.mtimeMs,
272
+ };
273
+ }
274
+ function validateWriteRequest(req, ctx, reqUrl) {
275
+ if (!validateBridgeRequest(req, ctx, reqUrl)) {
276
+ return false;
277
+ }
278
+ return (req.headers["x-edit-slide-local"] === "1" ||
279
+ req.headers["x-open-slide-local"] === "1");
280
+ }
281
+ async function readBody(req) {
282
+ const chunks = [];
283
+ let total = 0;
284
+ for await (const chunk of req) {
285
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
286
+ total += buf.length;
287
+ if (total > MAX_WRITE_BYTES + 1024 * 1024) {
288
+ throw new Error("request body too large");
289
+ }
290
+ chunks.push(buf);
291
+ }
292
+ return Buffer.concat(chunks).toString("utf8");
293
+ }
294
+ async function handleGet(ctx, req, reqUrl, res) {
295
+ if (!validateBridgeRequest(req, ctx, reqUrl)) {
296
+ sendJson(res, 403, { error: "forbidden" });
297
+ return;
298
+ }
299
+ if (reqUrl.searchParams.get("policy") === "1") {
300
+ sendJson(res, 200, { policy: buildPolicy(ctx) });
301
+ return;
302
+ }
303
+ const rel = reqUrl.searchParams.get("path");
304
+ try {
305
+ const abs = await safePath(ctx, rel || "");
306
+ if (reqUrl.searchParams.get("versions") === "1") {
307
+ sendJson(res, 200, {
308
+ path: relativeToRoot(ctx.root, abs),
309
+ current: await readFileMeta(ctx, abs),
310
+ versions: [],
311
+ policy: buildPolicy(ctx),
312
+ });
313
+ return;
314
+ }
315
+ const versionId = reqUrl.searchParams.get("version");
316
+ if (versionId) {
317
+ sendJson(res, 410, { error: "version history disabled" });
318
+ return;
319
+ }
320
+ if (reqUrl.searchParams.get("meta") === "1") {
321
+ sendJson(res, 200, {
322
+ ...(await readFileMeta(ctx, abs)),
323
+ policy: buildPolicy(ctx),
324
+ });
325
+ return;
326
+ }
327
+ const content = await readFile(abs, "utf8");
328
+ const fileStat = await stat(abs);
329
+ const contentHash = sha256(content);
330
+ sendJson(res, 200, {
331
+ path: relativeToRoot(ctx.root, abs),
332
+ content,
333
+ size: content.length,
334
+ bytes: Buffer.byteLength(content, "utf8"),
335
+ sha256: contentHash,
336
+ mtimeMs: fileStat.mtimeMs,
337
+ policy: buildPolicy(ctx),
338
+ });
339
+ }
340
+ catch (error) {
341
+ const message = error?.message || "read failed";
342
+ const status = error?.status || (message.includes("ENOENT") ? 404 : 400);
343
+ sendJson(res, status, { error: message });
344
+ }
345
+ }
346
+ async function handlePost(ctx, req, res) {
347
+ const reqUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
348
+ if (!validateWriteRequest(req, ctx, reqUrl)) {
349
+ sendJson(res, 403, { error: "forbidden" });
350
+ return;
351
+ }
352
+ let body;
353
+ try {
354
+ body = JSON.parse(await readBody(req));
355
+ }
356
+ catch (error) {
357
+ sendJson(res, 400, { error: error?.message || "invalid json" });
358
+ return;
359
+ }
360
+ const { path: rel, content, baseHash, restoreVersion } = body || {};
361
+ if (typeof restoreVersion === "string" && restoreVersion) {
362
+ sendJson(res, 410, { error: "version history disabled" });
363
+ return;
364
+ }
365
+ if (typeof content !== "string") {
366
+ sendJson(res, 400, { error: "missing content" });
367
+ return;
368
+ }
369
+ if (Buffer.byteLength(content, "utf8") > MAX_WRITE_BYTES) {
370
+ sendJson(res, 413, { error: "content too large (>5MB)" });
371
+ return;
372
+ }
373
+ try {
374
+ const abs = await safePath(ctx, rel || "");
375
+ const before = await readExisting(abs);
376
+ const beforeHash = before == null ? null : sha256(before);
377
+ if (beforeHash !== null && typeof baseHash !== "string") {
378
+ sendJson(res, 428, { error: "missing baseHash for existing file", currentHash: beforeHash });
379
+ return;
380
+ }
381
+ if (beforeHash === null && baseHash != null) {
382
+ sendJson(res, 409, { error: "baseHash supplied for a new file", currentHash: null });
383
+ return;
384
+ }
385
+ if (beforeHash !== null && baseHash !== beforeHash) {
386
+ sendJson(res, 409, {
387
+ error: "file changed on disk; reload before saving",
388
+ conflict: true,
389
+ path: relativeToRoot(ctx.root, abs),
390
+ expectedHash: baseHash,
391
+ currentHash: beforeHash,
392
+ policy: buildPolicy(ctx),
393
+ });
394
+ return;
395
+ }
396
+ const afterHash = sha256(content);
397
+ if (beforeHash === afterHash) {
398
+ const fileStat = await stat(abs);
399
+ sendJson(res, 200, {
400
+ ok: true,
401
+ path: relativeToRoot(ctx.root, abs),
402
+ bytes: Buffer.byteLength(content, "utf8"),
403
+ sha256: afterHash,
404
+ beforeHash,
405
+ backupPath: null,
406
+ mtimeMs: fileStat.mtimeMs,
407
+ policy: buildPolicy(ctx),
408
+ });
409
+ return;
410
+ }
411
+ await mkdir(dirname(abs), { recursive: true });
412
+ await writeFile(abs, content, "utf8");
413
+ const fileStat = await stat(abs);
414
+ const bytes = Buffer.byteLength(content, "utf8");
415
+ sendJson(res, 200, {
416
+ ok: true,
417
+ path: relativeToRoot(ctx.root, abs),
418
+ bytes,
419
+ sha256: afterHash,
420
+ beforeHash,
421
+ backupPath: null,
422
+ mtimeMs: fileStat.mtimeMs,
423
+ policy: buildPolicy(ctx),
424
+ });
425
+ }
426
+ catch (error) {
427
+ sendJson(res, 400, { error: error?.message || "write failed" });
428
+ }
429
+ }
430
+ function createBridgeServer(ctx) {
431
+ return createServer(async (req, res) => {
432
+ if (!applyCors(req, res, ctx.editorOrigin))
433
+ return;
434
+ if (req.method === "OPTIONS") {
435
+ res.statusCode = 204;
436
+ res.end();
437
+ return;
438
+ }
439
+ if (!isLoopbackHostHeader(req.headers.host)) {
440
+ sendJson(res, 403, { error: "forbidden host" });
441
+ return;
442
+ }
443
+ const reqUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
444
+ if (reqUrl.pathname === "/") {
445
+ sendJson(res, 200, { ok: true, service: "html2pptx edit bridge", root: ctx.root });
446
+ return;
447
+ }
448
+ if (reqUrl.pathname !== "/api/edit-slide/file") {
449
+ sendJson(res, 404, { error: "not found" });
450
+ return;
451
+ }
452
+ if (req.method === "GET") {
453
+ await handleGet(ctx, req, reqUrl, res);
454
+ return;
455
+ }
456
+ if (req.method === "POST") {
457
+ await handlePost(ctx, req, res);
458
+ return;
459
+ }
460
+ sendJson(res, 405, { error: "method not allowed" });
461
+ });
462
+ }
463
+ function listen(server, requestedPort) {
464
+ return new Promise((resolveListen, rejectListen) => {
465
+ const onError = (error) => {
466
+ server.off("listening", onListening);
467
+ rejectListen(error);
468
+ };
469
+ const onListening = () => {
470
+ server.off("error", onError);
471
+ const address = server.address();
472
+ if (!address || typeof address === "string") {
473
+ rejectListen(new Error("failed to read bridge port"));
474
+ return;
475
+ }
476
+ resolveListen(address.port);
477
+ };
478
+ server.once("error", onError);
479
+ server.once("listening", onListening);
480
+ server.listen(requestedPort, "127.0.0.1");
481
+ });
482
+ }
483
+ function openUrl(url) {
484
+ const os = platform();
485
+ const child = os === "darwin"
486
+ ? spawn("open", [url], { detached: true, stdio: "ignore" })
487
+ : os === "win32"
488
+ ? spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" })
489
+ : spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
490
+ child.unref();
491
+ }
492
+ function stopOnSignal(server) {
493
+ for (const signal of ["SIGINT", "SIGTERM"]) {
494
+ process.once(signal, () => {
495
+ server.close(() => process.exit(0));
496
+ });
497
+ }
498
+ }
499
+ function bail(message, options) {
500
+ if (options.json) {
501
+ console.log(JSON.stringify({ success: false, error: message }));
502
+ }
503
+ else {
504
+ p.log.error(message);
505
+ }
506
+ process.exit(1);
507
+ }
508
+ export async function editCommand(input, options = {}) {
509
+ if (!input) {
510
+ bail("HTML file path is required. Example: html2pptx edit ./html2pptx/slides.html", options);
511
+ }
512
+ const root = await realpath(process.cwd());
513
+ const abs = resolve(root, input);
514
+ const rel = relativeToRoot(root, abs);
515
+ const ext = extname(abs).toLowerCase();
516
+ if (!ALLOWED_EXT.has(ext)) {
517
+ bail("Only .html/.htm files can be opened in the editor.", options);
518
+ }
519
+ if (abs !== root && !abs.startsWith(root + sep)) {
520
+ bail("The file must be inside the current working directory.", options);
521
+ }
522
+ try {
523
+ await stat(abs);
524
+ }
525
+ catch {
526
+ bail(`File not found: ${rel}`, options);
527
+ }
528
+ let baseUrl;
529
+ let requestedPort;
530
+ try {
531
+ baseUrl = await resolveEditorBaseUrl(root, options.baseUrl);
532
+ requestedPort = parsePort(options.port);
533
+ }
534
+ catch (error) {
535
+ bail(error.message, options);
536
+ }
537
+ const ctx = {
538
+ root,
539
+ editorOrigin: baseUrl.origin,
540
+ localStateDir: join(root, ".html2pptx", "edit-slide"),
541
+ sessionToken: generateSessionToken(),
542
+ };
543
+ const server = createBridgeServer(ctx);
544
+ let bridgePort;
545
+ try {
546
+ bridgePort = await listen(server, requestedPort);
547
+ }
548
+ catch (error) {
549
+ bail(error.message || "failed to start local edit bridge", options);
550
+ }
551
+ const bridgeUrl = `http://127.0.0.1:${bridgePort}`;
552
+ const editorUrl = buildEditorUrl(baseUrl, rel, bridgeUrl, ctx.sessionToken);
553
+ if (options.json) {
554
+ console.log(JSON.stringify({
555
+ success: true,
556
+ editorUrl: editorUrl.toString(),
557
+ bridgeUrl,
558
+ sessionTokenRequired: true,
559
+ file: rel,
560
+ root,
561
+ }));
562
+ }
563
+ else {
564
+ p.log.success(`Local edit bridge listening on ${pc.cyan(bridgeUrl)} ${pc.dim("(session token required)")}`);
565
+ if (options.noOpen) {
566
+ p.log.info(`Open in editor: ${pc.cyan(editorUrl.toString())}`);
567
+ }
568
+ else {
569
+ p.log.info("Opening the editor in your browser. Use --no-open to print the tokenized URL instead.");
570
+ }
571
+ p.log.info(pc.dim("Press Ctrl+C to stop the bridge."));
572
+ }
573
+ if (!options.noOpen) {
574
+ openUrl(editorUrl.toString());
575
+ }
576
+ stopOnSignal(server);
577
+ await new Promise((resolveClose) => server.on("close", resolveClose));
578
+ }
579
+ export const editCommandInternalsForTest = {
580
+ buildEditorUrl,
581
+ createBridgeServer,
582
+ generateSessionToken,
583
+ listen,
584
+ normalizeBaseUrl,
585
+ parsePort,
586
+ readRegisteredEditorBaseUrl,
587
+ resolveEditorBaseUrl,
588
+ };
@@ -0,0 +1 @@
1
+ export declare function initCommand(): Promise<void>;
@@ -0,0 +1,35 @@
1
+ import * as p from "@clack/prompts";
2
+ import pc from "picocolors";
3
+ import { loadConfig, saveConfig, getConfigPath } from "../config.js";
4
+ export async function initCommand() {
5
+ p.intro(pc.bgCyan(pc.black(" html2pptx ")));
6
+ const config = await loadConfig();
7
+ const hasExisting = !!config.apiKey;
8
+ if (hasExisting) {
9
+ p.log.info(`Existing API key found: ${pc.dim(config.apiKey.slice(0, 12) + "...")}`);
10
+ }
11
+ p.log.info(`Get your API key at: ${pc.cyan(pc.underline("https://html2pptx.app/dashboard/en?tab=api-keys"))}`);
12
+ const result = await p.group({
13
+ apiKey: () => p.text({
14
+ message: "Enter your API key",
15
+ placeholder: "sk_live_...",
16
+ initialValue: config.apiKey ?? "",
17
+ validate(value) {
18
+ if (!value)
19
+ return "API key is required";
20
+ if (!value.startsWith("sk_live_"))
21
+ return 'API key should start with "sk_live_"';
22
+ },
23
+ }),
24
+ }, {
25
+ onCancel() {
26
+ p.cancel("Setup cancelled.");
27
+ process.exit(0);
28
+ },
29
+ });
30
+ await saveConfig({
31
+ apiKey: result.apiKey,
32
+ });
33
+ p.log.success(`Config saved to ${pc.dim(getConfigPath())}`);
34
+ p.outro("You're all set! Run " + pc.cyan("html2pptx convert") + " to start.");
35
+ }
@@ -0,0 +1 @@
1
+ export declare function logoutCommand(): Promise<void>;
@@ -0,0 +1,19 @@
1
+ import { unlink } from "node:fs/promises";
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import { loadConfig, getConfigPath } from "../config.js";
5
+ export async function logoutCommand() {
6
+ const config = await loadConfig();
7
+ if (!config.apiKey) {
8
+ p.log.warn("No API key found. You're already logged out.");
9
+ return;
10
+ }
11
+ try {
12
+ await unlink(getConfigPath());
13
+ p.log.success(`API key removed. Config deleted at ${pc.dim(getConfigPath())}`);
14
+ }
15
+ catch {
16
+ p.log.error("Failed to remove config file.");
17
+ process.exit(1);
18
+ }
19
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `html2pptx templates publish` is intentionally disabled.
3
+ *
4
+ * Marketplace template draft creation is HTML-only and remote-MCP-only:
5
+ * html2pptx_validate_template_html -> html2pptx_publish_template.
6
+ */
7
+ export interface PublishOptions {
8
+ json?: boolean;
9
+ }
10
+ export declare function publishCommand(_input: string | undefined, options?: PublishOptions): Promise<void>;
@@ -0,0 +1,17 @@
1
+ import pc from "picocolors";
2
+ const REMOTE_MCP_MESSAGE = "テンプレートdraft作成はHTMLのみ、remote MCPのみ対応しています。\n\n" +
3
+ "CLI / Web UI / REST API からは公開用draftを作成できません。Claude Code / Codex などの remote MCP から " +
4
+ "`html2pptx_validate_template_html` を実行し、すべてのエラーを修正してから " +
5
+ "`html2pptx_publish_template` を呼び出してください。";
6
+ export async function publishCommand(_input, options = {}) {
7
+ bail(REMOTE_MCP_MESSAGE, options);
8
+ }
9
+ function bail(msg, options) {
10
+ if (options.json) {
11
+ console.log(JSON.stringify({ ok: false, error: msg }));
12
+ }
13
+ else {
14
+ console.error(pc.red(msg));
15
+ }
16
+ process.exit(1);
17
+ }
@@ -0,0 +1,5 @@
1
+ interface StatusOptions {
2
+ baseUrl?: string;
3
+ }
4
+ export declare function statusCommand(options?: StatusOptions): Promise<void>;
5
+ export {};