Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/usr/bin/env node
2/**
3 * keep-cli.mjs - Interactive CLI for keeping KidLisp pieces on Tezos
4 *
5 * Features:
6 * - Shows AC user info and wallet status
7 * - Previews the piece and artifact
8 * - Uploads to IPFS with progress
9 * - Connects to Temple via Beacon P2P
10 * - Resumes from any stage if interrupted
11 *
12 * Usage:
13 * node keep-cli.mjs <piece-code>
14 * node keep-cli.mjs puf
15 * node keep-cli.mjs puf --resume ipfs # Resume from IPFS upload
16 * node keep-cli.mjs puf --preview # Preview artifact only
17 */
18
19import { pairWallet, sendContractCall } from "./beacon-node.mjs";
20import { connect } from "../system/backend/database.mjs";
21import { analyzeKidLisp, ANALYZER_VERSION } from "../system/backend/kidlisp-analyzer.mjs";
22import { TezosToolkit } from "@taquito/taquito";
23import * as fs from "fs";
24import * as path from "path";
25import * as readline from "readline";
26
27// ═══════════════════════════════════════════════════════════════════
28// Configuration
29// ═══════════════════════════════════════════════════════════════════
30
31const CONTRACT_ADDRESS = "KT1StXrQNvRd9dNPpHdCGEstcGiBV6neq79K"; // Ghostnet v3
32const NETWORK = "ghostnet";
33const RPC_URL = "https://ghostnet.ecadinfra.com";
34const OVEN_URL = process.env.OVEN_URL || "https://oven.aesthetic.computer";
35const MINT_FEE = "5000000"; // 5 XTZ in mutez
36
37// State file for resume functionality
38const STATE_DIR = "/tmp/keep-cli";
39const getStateFile = (code) => `${STATE_DIR}/${code}.json`;
40
41// ═══════════════════════════════════════════════════════════════════
42// Colors & Formatting
43// ═══════════════════════════════════════════════════════════════════
44
45const RESET = "\x1b[0m";
46const BOLD = "\x1b[1m";
47const DIM = "\x1b[2m";
48const ITALIC = "\x1b[3m";
49const UNDERLINE = "\x1b[4m";
50
51const BLACK = "\x1b[30m";
52const RED = "\x1b[31m";
53const GREEN = "\x1b[32m";
54const YELLOW = "\x1b[33m";
55const BLUE = "\x1b[34m";
56const MAGENTA = "\x1b[35m";
57const CYAN = "\x1b[36m";
58const WHITE = "\x1b[37m";
59
60const BG_BLACK = "\x1b[40m";
61const BG_RED = "\x1b[41m";
62const BG_GREEN = "\x1b[42m";
63const BG_YELLOW = "\x1b[43m";
64const BG_BLUE = "\x1b[44m";
65const BG_MAGENTA = "\x1b[45m";
66const BG_CYAN = "\x1b[46m";
67const BG_WHITE = "\x1b[47m";
68
69function box(title, content, color = CYAN) {
70 const width = 66;
71 const top = `${color}╔${"═".repeat(width)}╗${RESET}`;
72 const bottom = `${color}╚${"═".repeat(width)}╝${RESET}`;
73 const titleLine = `${color}║${RESET} ${BOLD}${title}${RESET}${" ".repeat(width - title.length - 2)}${color}║${RESET}`;
74 console.log(top);
75 console.log(titleLine);
76 if (content) {
77 for (const line of content.split("\n")) {
78 const paddedLine = line.padEnd(width - 2);
79 console.log(`${color}║${RESET} ${paddedLine}${color}║${RESET}`);
80 }
81 }
82 console.log(bottom);
83}
84
85function section(title) {
86 console.log(`\n${BOLD}${CYAN}▸ ${title}${RESET}\n`);
87}
88
89function status(icon, message, detail = "") {
90 const detailStr = detail ? `${DIM} ${detail}${RESET}` : "";
91 console.log(` ${icon} ${message}${detailStr}`);
92}
93
94function progress(current, total, label) {
95 const width = 40;
96 const filled = Math.round((current / total) * width);
97 const bar = "█".repeat(filled) + "░".repeat(width - filled);
98 const pct = Math.round((current / total) * 100);
99 process.stdout.write(`\r ${CYAN}${bar}${RESET} ${pct}% ${DIM}${label}${RESET}`);
100 if (current === total) console.log();
101}
102
103function clearLine() {
104 process.stdout.write("\r" + " ".repeat(80) + "\r");
105}
106
107// ═══════════════════════════════════════════════════════════════════
108// State Management (for resume)
109// ═══════════════════════════════════════════════════════════════════
110
111function loadState(code) {
112 try {
113 const stateFile = getStateFile(code);
114 if (fs.existsSync(stateFile)) {
115 return JSON.parse(fs.readFileSync(stateFile, "utf8"));
116 }
117 } catch (e) {
118 // Ignore errors
119 }
120 return null;
121}
122
123function saveState(code, state) {
124 try {
125 if (!fs.existsSync(STATE_DIR)) {
126 fs.mkdirSync(STATE_DIR, { recursive: true });
127 }
128 fs.writeFileSync(getStateFile(code), JSON.stringify(state, null, 2));
129 } catch (e) {
130 console.error(`${RED}Warning: Could not save state: ${e.message}${RESET}`);
131 }
132}
133
134function clearState(code) {
135 try {
136 const stateFile = getStateFile(code);
137 if (fs.existsSync(stateFile)) {
138 fs.unlinkSync(stateFile);
139 }
140 } catch (e) {
141 // Ignore
142 }
143}
144
145// ═══════════════════════════════════════════════════════════════════
146// Database & User Info
147// ═══════════════════════════════════════════════════════════════════
148
149let db = null;
150
151async function initDB() {
152 if (!db) {
153 const conn = await connect();
154 db = conn.db;
155 }
156 return db;
157}
158
159async function getUserByHandle(handle) {
160 const db = await initDB();
161 return db.collection("@handles").findOne({ _id: handle.replace(/^@/, "") });
162}
163
164async function getUserById(userId) {
165 const db = await initDB();
166 return db.collection("users").findOne({ _id: userId });
167}
168
169async function getPiece(code) {
170 const db = await initDB();
171 // KidLisp pieces are in the 'kidlisp' collection, stored by 'code'
172 const piece = await db.collection("kidlisp").findOne({ code: code });
173 return piece;
174}
175
176async function getPinataCredentials() {
177 const db = await initDB();
178 const secrets = await db.collection("secrets").findOne({ _id: "pinata" });
179 if (!secrets) throw new Error("Pinata credentials not found");
180 return { jwt: secrets.jwt };
181}
182
183// ═══════════════════════════════════════════════════════════════════
184// Tezos Helpers
185// ═══════════════════════════════════════════════════════════════════
186
187function stringToBytes(str) {
188 return Buffer.from(str, "utf8").toString("hex");
189}
190
191async function checkMintStatus(pieceName) {
192 const keyBytes = stringToBytes(pieceName);
193 const url = `https://api.${NETWORK}.tzkt.io/v1/contracts/${CONTRACT_ADDRESS}/bigmaps/content_hashes/keys/${keyBytes}`;
194
195 try {
196 const response = await fetch(url);
197 if (response.status === 200) {
198 const data = await response.json();
199 if (data.active) {
200 return {
201 minted: true,
202 tokenId: data.value,
203 objktUrl: `https://ghostnet.objkt.com/asset/${CONTRACT_ADDRESS}/${data.value}`,
204 };
205 }
206 }
207 return { minted: false };
208 } catch (e) {
209 return { minted: false };
210 }
211}
212
213async function getWalletBalance(address) {
214 try {
215 const tezos = new TezosToolkit(RPC_URL);
216 const balance = await tezos.tz.getBalance(address);
217 return balance.toNumber() / 1000000;
218 } catch (e) {
219 return null;
220 }
221}
222
223// ═══════════════════════════════════════════════════════════════════
224// IPFS Upload
225// ═══════════════════════════════════════════════════════════════════
226
227async function uploadToIPFS(content, name, pinata) {
228 const formData = new FormData();
229
230 // Create blob from content
231 const blob = new Blob([content], { type: "text/html" });
232 formData.append("file", blob, name);
233
234 // Add metadata
235 formData.append("pinataMetadata", JSON.stringify({
236 name: `keep-${name}`,
237 keyvalues: { type: "keep-artifact" }
238 }));
239
240 const response = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
241 method: "POST",
242 headers: {
243 "Authorization": `Bearer ${pinata.jwt}`,
244 },
245 body: formData,
246 });
247
248 if (!response.ok) {
249 throw new Error(`Pinata upload failed: ${response.status}`);
250 }
251
252 const result = await response.json();
253 return {
254 cid: result.IpfsHash,
255 url: `ipfs://${result.IpfsHash}`,
256 gateway: `https://gateway.pinata.cloud/ipfs/${result.IpfsHash}`,
257 };
258}
259
260async function uploadJSONToIPFS(data, name, pinata) {
261 const response = await fetch("https://api.pinata.cloud/pinning/pinJSONToIPFS", {
262 method: "POST",
263 headers: {
264 "Authorization": `Bearer ${pinata.jwt}`,
265 "Content-Type": "application/json",
266 },
267 body: JSON.stringify({
268 pinataContent: data,
269 pinataMetadata: { name: `keep-${name}-metadata` },
270 }),
271 });
272
273 if (!response.ok) {
274 throw new Error(`Pinata JSON upload failed: ${response.status}`);
275 }
276
277 const result = await response.json();
278 return {
279 cid: result.IpfsHash,
280 url: `ipfs://${result.IpfsHash}`,
281 gateway: `https://gateway.pinata.cloud/ipfs/${result.IpfsHash}`,
282 };
283}
284
285// ═══════════════════════════════════════════════════════════════════
286// Artifact Generation
287// ═══════════════════════════════════════════════════════════════════
288
289const dev = process.env.NODE_ENV !== "production";
290const BUNDLE_BASE = dev ? "https://localhost:8888/api" : "https://aesthetic.computer/api";
291
292async function generateArtifact(code, piece) {
293 // Use the local dev server or production bundle endpoint
294 const bundleUrl = `${BUNDLE_BASE}/bundle-html?code=${encodeURIComponent(code)}&format=json`;
295
296 status("📦", "Generating artifact bundle...", bundleUrl);
297
298 // Allow self-signed certs in dev
299 if (dev) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
300
301 const response = await fetch(bundleUrl);
302 if (!response.ok) {
303 throw new Error(`Bundle generation failed: ${response.status}`);
304 }
305
306 const data = await response.json();
307 if (data.error) {
308 throw new Error(`Bundle error: ${data.error}`);
309 }
310
311 const html = Buffer.from(data.content, "base64").toString("utf8");
312 return html;
313}
314
315async function generateThumbnail(code) {
316 const thumbUrl = `${OVEN_URL}/thumbnail?code=${encodeURIComponent(code)}&width=512&height=512`;
317
318 status("🖼️ ", "Generating thumbnail...", thumbUrl);
319
320 const response = await fetch(thumbUrl, { timeout: 60000 });
321 if (!response.ok) {
322 console.log(`${YELLOW} ⚠ Thumbnail generation failed, using placeholder${RESET}`);
323 return null;
324 }
325
326 return await response.arrayBuffer();
327}
328
329// ═══════════════════════════════════════════════════════════════════
330// Build Michelson Parameters
331// ═══════════════════════════════════════════════════════════════════
332
333function buildKeepParams(metadata) {
334 // Build the deeply nested Michelson params for the keep entrypoint
335 return {
336 prim: "Pair",
337 args: [
338 { bytes: metadata.artifactUri },
339 { prim: "Pair", args: [
340 { bytes: metadata.attributes },
341 { prim: "Pair", args: [
342 { bytes: metadata.content_hash },
343 { prim: "Pair", args: [
344 { bytes: metadata.content_type },
345 { prim: "Pair", args: [
346 { bytes: metadata.creators },
347 { prim: "Pair", args: [
348 { bytes: metadata.decimals },
349 { prim: "Pair", args: [
350 { bytes: metadata.description },
351 { prim: "Pair", args: [
352 { bytes: metadata.displayUri },
353 { prim: "Pair", args: [
354 { bytes: metadata.formats },
355 { prim: "Pair", args: [
356 { bytes: metadata.isBooleanAmount },
357 { prim: "Pair", args: [
358 { bytes: metadata.metadata_uri },
359 { prim: "Pair", args: [
360 { bytes: metadata.name },
361 { prim: "Pair", args: [
362 { string: metadata.owner },
363 { prim: "Pair", args: [
364 { bytes: metadata.rights },
365 { prim: "Pair", args: [
366 { bytes: metadata.shouldPreferSymbol },
367 { prim: "Pair", args: [
368 { bytes: metadata.symbol },
369 { prim: "Pair", args: [
370 { bytes: metadata.tags },
371 { bytes: metadata.thumbnailUri }
372 ]}
373 ]}
374 ]}
375 ]}
376 ]}
377 ]}
378 ]}
379 ]}
380 ]}
381 ]}
382 ]}
383 ]}
384 ]}
385 ]}
386 ]}
387 ]}
388 ]
389 };
390}
391
392// ═══════════════════════════════════════════════════════════════════
393// Interactive Prompts
394// ═══════════════════════════════════════════════════════════════════
395
396function prompt(question) {
397 return new Promise((resolve) => {
398 const rl = readline.createInterface({
399 input: process.stdin,
400 output: process.stdout
401 });
402 rl.question(question, (answer) => {
403 rl.close();
404 resolve(answer.trim());
405 });
406 });
407}
408
409async function confirm(question) {
410 const answer = await prompt(`${question} ${DIM}(y/n)${RESET} `);
411 return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
412}
413
414// ═══════════════════════════════════════════════════════════════════
415// Main CLI Flow
416// ═══════════════════════════════════════════════════════════════════
417
418async function main() {
419 const args = process.argv.slice(2);
420 let pieceCode = args.find(a => !a.startsWith("--"));
421 const resumeStage = args.includes("--resume") ? args[args.indexOf("--resume") + 1] : null;
422 const previewOnly = args.includes("--preview");
423 const skipConfirm = args.includes("--yes") || args.includes("-y");
424
425 console.clear();
426
427 // ─────────────────────────────────────────────────────────────────
428 // Header
429 // ─────────────────────────────────────────────────────────────────
430
431 console.log(`
432${MAGENTA}${BOLD}
433 ██╗ ██╗███████╗███████╗██████╗
434 ██║ ██╔╝██╔════╝██╔════╝██╔══██╗
435 █████╔╝ █████╗ █████╗ ██████╔╝
436 ██╔═██╗ ██╔══╝ ██╔══╝ ██╔═══╝
437 ██║ ██╗███████╗███████╗██║
438 ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝
439${RESET}
440 ${DIM}KidLisp NFT Minting CLI for Aesthetic Computer${RESET}
441 ${DIM}Network: ${CYAN}${NETWORK}${RESET} ${DIM}| Contract: ${CYAN}${CONTRACT_ADDRESS.slice(0, 12)}...${RESET}
442`);
443
444 // ─────────────────────────────────────────────────────────────────
445 // Get piece code
446 // ─────────────────────────────────────────────────────────────────
447
448 if (!pieceCode) {
449 pieceCode = await prompt(`${CYAN}?${RESET} Enter piece code (e.g., ${GREEN}puf${RESET}): `);
450 if (!pieceCode) {
451 console.log(`${RED}✗ No piece code provided${RESET}\n`);
452 process.exit(1);
453 }
454 }
455
456 const cleanCode = pieceCode.replace(/^\$/, "");
457
458 // ─────────────────────────────────────────────────────────────────
459 // Load saved state (for resume)
460 // ─────────────────────────────────────────────────────────────────
461
462 let state = loadState(cleanCode) || {
463 stage: "init",
464 code: cleanCode,
465 startedAt: new Date().toISOString(),
466 };
467
468 if (resumeStage) {
469 status("🔄", `Resuming from stage: ${resumeStage}`);
470 state.stage = resumeStage;
471 }
472
473 // ─────────────────────────────────────────────────────────────────
474 // Step 1: Load piece from database
475 // ─────────────────────────────────────────────────────────────────
476
477 section("Piece Information");
478
479 status("🔍", "Loading piece from database...");
480
481 let piece;
482 try {
483 piece = await getPiece(cleanCode);
484 } catch (e) {
485 console.log(`${RED} ✗ Database error: ${e.message}${RESET}`);
486 process.exit(1);
487 }
488
489 if (!piece) {
490 console.log(`${RED} ✗ Piece not found: $${cleanCode}${RESET}`);
491 console.log(`${DIM} Make sure the piece exists in the database${RESET}\n`);
492 process.exit(1);
493 }
494
495 // Get owner info (KidLisp pieces use 'user' field for owner ID)
496 let owner = null;
497 let ownerHandle = null;
498 if (piece.user) {
499 owner = await getUserById(piece.user);
500 if (owner?.atproto?.handle) {
501 // Handle is stored in atproto.handle (e.g. "jeffrey.at.aesthetic.computer" -> "@jeffrey")
502 const fullHandle = owner.atproto.handle;
503 ownerHandle = fullHandle.replace('.at.aesthetic.computer', '');
504 } else if (owner?.handle) {
505 ownerHandle = owner.handle;
506 }
507 }
508
509 console.log();
510 box(`📜 $${cleanCode}`, [
511 `${BOLD}Name:${RESET} ${piece.name || cleanCode}`,
512 `${BOLD}Owner:${RESET} ${ownerHandle ? `@${ownerHandle}` : piece.user || "unknown"}`,
513 `${BOLD}Created:${RESET} ${piece.when ? new Date(piece.when).toLocaleDateString() : "unknown"}`,
514 `${BOLD}Hits:${RESET} ${piece.hits || 0}`,
515 `${BOLD}Lines:${RESET} ${piece.source?.split("\\n").length || "?"} lines of KidLisp`,
516 ].join("\\n"), GREEN);
517
518 // Show code preview (KidLisp uses 'source' field)
519 if (piece.source) {
520 console.log(`\n${DIM} Code preview:${RESET}`);
521 const lines = piece.source.split("\n").slice(0, 5);
522 for (const line of lines) {
523 console.log(`${DIM} ${line.slice(0, 60)}${line.length > 60 ? "..." : ""}${RESET}`);
524 }
525 if (piece.source.split("\n").length > 5) {
526 console.log(`${DIM} ... (${piece.source.split("\n").length - 5} more lines)${RESET}`);
527 }
528 }
529
530 // ─────────────────────────────────────────────────────────────────
531 // Step 2: Check mint status
532 // ─────────────────────────────────────────────────────────────────
533
534 section("Mint Status");
535
536 status("🔎", "Checking if already minted...");
537
538 const mintStatus = await checkMintStatus(cleanCode);
539
540 if (mintStatus.minted) {
541 console.log(`\n${YELLOW} ⚠ This piece is already minted!${RESET}`);
542 console.log(`${DIM} Token ID: ${mintStatus.tokenId}${RESET}`);
543 console.log(`${DIM} ${mintStatus.objktUrl}${RESET}\n`);
544
545 if (!await confirm(`${YELLOW}Continue anyway?${RESET}`)) {
546 process.exit(0);
547 }
548 } else {
549 status("✓", "Not yet minted", GREEN);
550 }
551
552 // ─────────────────────────────────────────────────────────────────
553 // Step 3: Connect wallet
554 // ─────────────────────────────────────────────────────────────────
555
556 section("Wallet Connection");
557
558 let walletAddress = state.walletAddress;
559 let client = null;
560
561 if (!walletAddress || state.stage === "init") {
562 status("📱", "Connecting to Temple wallet via Beacon P2P...");
563 console.log();
564
565 try {
566 const pairResult = await pairWallet("Aesthetic Computer Keep");
567
568 if (!pairResult?.permissionResponse?.address) {
569 throw new Error("Failed to get wallet address");
570 }
571
572 client = pairResult.client;
573 walletAddress = pairResult.permissionResponse.address;
574
575 state.walletAddress = walletAddress;
576 state.stage = "wallet";
577 saveState(cleanCode, state);
578
579 } catch (e) {
580 console.log(`${RED} ✗ Wallet connection failed: ${e.message}${RESET}\n`);
581 process.exit(1);
582 }
583 } else {
584 status("✓", `Using saved wallet: ${walletAddress.slice(0, 12)}...`, GREEN);
585 // Need to reconnect for the transaction
586 status("📱", "Reconnecting wallet for transaction...");
587 console.log();
588
589 try {
590 const pairResult = await pairWallet("Aesthetic Computer Keep");
591 client = pairResult.client;
592 walletAddress = pairResult.permissionResponse.address;
593 } catch (e) {
594 console.log(`${RED} ✗ Wallet reconnection failed: ${e.message}${RESET}\n`);
595 process.exit(1);
596 }
597 }
598
599 // Get wallet balance
600 const balance = await getWalletBalance(walletAddress);
601
602 console.log();
603 box("💳 Wallet", [
604 `${BOLD}Address:${RESET} ${walletAddress}`,
605 `${BOLD}Balance:${RESET} ${balance !== null ? `${balance.toFixed(2)} ꜩ` : "unknown"}`,
606 `${BOLD}Network:${RESET} ${NETWORK}`,
607 ].join("\n"), BLUE);
608
609 if (balance !== null && balance < 6) {
610 console.log(`\n${YELLOW} ⚠ Low balance! You need at least 6 ꜩ (5 ꜩ mint fee + gas)${RESET}`);
611 console.log(`${DIM} Get testnet XTZ: https://faucet.ghostnet.teztnets.com${RESET}\n`);
612 }
613
614 // ─────────────────────────────────────────────────────────────────
615 // Step 4: Generate artifact
616 // ─────────────────────────────────────────────────────────────────
617
618 section("Artifact Generation");
619
620 let artifactHtml = state.artifactHtml;
621 let thumbnailData = state.thumbnailCid ? { cid: state.thumbnailCid } : null;
622
623 if (!artifactHtml || state.stage === "wallet") {
624 try {
625 artifactHtml = await generateArtifact(cleanCode, piece);
626 status("✓", `Bundle generated (${(artifactHtml.length / 1024).toFixed(1)} KB)`, GREEN);
627
628 state.artifactHtml = artifactHtml;
629 state.stage = "artifact";
630 saveState(cleanCode, state);
631
632 } catch (e) {
633 console.log(`${RED} ✗ Artifact generation failed: ${e.message}${RESET}\n`);
634 process.exit(1);
635 }
636 } else {
637 status("✓", `Using cached artifact (${(artifactHtml.length / 1024).toFixed(1)} KB)`, GREEN);
638 }
639
640 // Preview option
641 if (previewOnly) {
642 const previewPath = `/tmp/keep-preview-${cleanCode}.html`;
643 fs.writeFileSync(previewPath, artifactHtml);
644 console.log(`\n${CYAN} Preview saved to: ${previewPath}${RESET}`);
645 console.log(`${DIM} Open in browser to test the artifact${RESET}\n`);
646
647 const openPreview = await confirm(`${CYAN}Open in browser?${RESET}`);
648 if (openPreview) {
649 const { exec } = await import("child_process");
650 exec(`$BROWSER "file://${previewPath}"`);
651 }
652
653 if (!await confirm(`${CYAN}Continue with minting?${RESET}`)) {
654 process.exit(0);
655 }
656 }
657
658 // ─────────────────────────────────────────────────────────────────
659 // Step 5: Upload to IPFS
660 // ─────────────────────────────────────────────────────────────────
661
662 section("IPFS Upload");
663
664 let pinata;
665 try {
666 pinata = await getPinataCredentials();
667 status("✓", "Pinata credentials loaded", GREEN);
668 } catch (e) {
669 console.log(`${RED} ✗ Could not load Pinata credentials: ${e.message}${RESET}\n`);
670 process.exit(1);
671 }
672
673 let artifactCid = state.artifactCid;
674 let metadataCid = state.metadataCid;
675
676 // Upload artifact HTML
677 if (!artifactCid || state.stage === "artifact") {
678 status("📤", "Uploading artifact to IPFS...");
679
680 try {
681 const result = await uploadToIPFS(artifactHtml, `${cleanCode}.html`, pinata);
682 artifactCid = result.cid;
683
684 status("✓", `Artifact uploaded: ${CYAN}${artifactCid.slice(0, 20)}...${RESET}`, GREEN);
685 console.log(`${DIM} Gateway: ${result.gateway}${RESET}`);
686
687 state.artifactCid = artifactCid;
688 state.stage = "ipfs-artifact";
689 saveState(cleanCode, state);
690
691 } catch (e) {
692 console.log(`${RED} ✗ Artifact upload failed: ${e.message}${RESET}\n`);
693 console.log(`${DIM} Run with --resume ipfs-artifact to retry${RESET}\n`);
694 process.exit(1);
695 }
696 } else {
697 status("✓", `Using cached artifact CID: ${artifactCid.slice(0, 20)}...`, GREEN);
698 }
699
700 // Build metadata
701 const now = new Date().toISOString();
702 const metadata = {
703 name: `$${cleanCode}`,
704 symbol: `$${cleanCode}`,
705 description: piece.description || `KidLisp piece: $${cleanCode}`,
706 artifactUri: `ipfs://${artifactCid}`,
707 displayUri: `https://aesthetic.computer/$${cleanCode}`,
708 thumbnailUri: thumbnailData?.cid ? `ipfs://${thumbnailData.cid}` : `https://aesthetic.computer/$${cleanCode}/thumbnail`,
709 creators: [walletAddress],
710 decimals: 0,
711 isBooleanAmount: true,
712 shouldPreferSymbol: false,
713 date: now,
714 tags: ["KidLisp"],
715 attributes: [],
716 formats: [
717 {
718 uri: `ipfs://${artifactCid}`,
719 mimeType: "text/html",
720 fileName: `${cleanCode}.html`,
721 }
722 ],
723 rights: "",
724 };
725
726 // Upload metadata JSON
727 if (!metadataCid || state.stage === "ipfs-artifact") {
728 status("📤", "Uploading metadata to IPFS...");
729
730 try {
731 const result = await uploadJSONToIPFS(metadata, cleanCode, pinata);
732 metadataCid = result.cid;
733
734 status("✓", `Metadata uploaded: ${CYAN}${metadataCid.slice(0, 20)}...${RESET}`, GREEN);
735 console.log(`${DIM} Gateway: ${result.gateway}${RESET}`);
736
737 state.metadataCid = metadataCid;
738 state.stage = "ipfs-metadata";
739 saveState(cleanCode, state);
740
741 } catch (e) {
742 console.log(`${RED} ✗ Metadata upload failed: ${e.message}${RESET}\n`);
743 console.log(`${DIM} Run with --resume ipfs-metadata to retry${RESET}\n`);
744 process.exit(1);
745 }
746 } else {
747 status("✓", `Using cached metadata CID: ${metadataCid.slice(0, 20)}...`, GREEN);
748 }
749
750 // ─────────────────────────────────────────────────────────────────
751 // Step 6: Mint confirmation
752 // ─────────────────────────────────────────────────────────────────
753
754 section("Ready to Mint");
755
756 console.log();
757 box("🏺 Keep Summary", [
758 `${BOLD}Piece:${RESET} $${cleanCode}`,
759 `${BOLD}Owner:${RESET} ${walletAddress.slice(0, 20)}...`,
760 `${BOLD}Artifact:${RESET} ipfs://${artifactCid.slice(0, 20)}...`,
761 `${BOLD}Metadata:${RESET} ipfs://${metadataCid.slice(0, 20)}...`,
762 `${BOLD}Mint Fee:${RESET} 5 ꜩ`,
763 `${BOLD}Network:${RESET} ${NETWORK}`,
764 `${BOLD}Contract:${RESET} ${CONTRACT_ADDRESS}`,
765 ].join("\n"), MAGENTA);
766
767 console.log();
768
769 if (!skipConfirm) {
770 const proceed = await confirm(`${BOLD}${GREEN}Proceed with minting?${RESET}`);
771 if (!proceed) {
772 console.log(`\n${YELLOW}Minting cancelled. State saved for resume.${RESET}\n`);
773 process.exit(0);
774 }
775 }
776
777 // ─────────────────────────────────────────────────────────────────
778 // Step 7: Execute mint transaction
779 // ─────────────────────────────────────────────────────────────────
780
781 section("Minting");
782
783 // Build Michelson parameters
784 const keepMetadata = {
785 artifactUri: stringToBytes(`ipfs://${artifactCid}`),
786 attributes: stringToBytes("[]"),
787 content_hash: stringToBytes(cleanCode),
788 content_type: stringToBytes("text/html"),
789 creators: stringToBytes(JSON.stringify([walletAddress])),
790 decimals: stringToBytes("0"),
791 description: stringToBytes(metadata.description),
792 displayUri: stringToBytes(metadata.displayUri),
793 formats: stringToBytes(JSON.stringify(metadata.formats)),
794 isBooleanAmount: stringToBytes("true"),
795 metadata_uri: stringToBytes(`ipfs://${metadataCid}`),
796 name: stringToBytes(metadata.name),
797 owner: walletAddress,
798 rights: stringToBytes(""),
799 shouldPreferSymbol: stringToBytes("false"),
800 symbol: stringToBytes(metadata.symbol),
801 tags: stringToBytes(JSON.stringify(metadata.tags)),
802 thumbnailUri: stringToBytes(metadata.thumbnailUri),
803 };
804
805 const keepParams = buildKeepParams(keepMetadata);
806
807 status("📤", "Sending transaction to wallet...");
808 console.log(`\n${YELLOW} 📱 Check Temple wallet to approve the transaction${RESET}\n`);
809
810 try {
811 const response = await sendContractCall(
812 client,
813 CONTRACT_ADDRESS,
814 "keep",
815 keepParams,
816 MINT_FEE
817 );
818
819 if (response.transactionHash) {
820 state.txHash = response.transactionHash;
821 state.stage = "complete";
822 saveState(cleanCode, state);
823
824 console.log();
825 box("🎉 Keep Minted Successfully!", [
826 `${BOLD}Transaction:${RESET} ${response.transactionHash}`,
827 ``,
828 `${BOLD}View on TzKT:${RESET}`,
829 ` https://ghostnet.tzkt.io/${response.transactionHash}`,
830 ``,
831 `${BOLD}View on Objkt:${RESET}`,
832 ` https://ghostnet.objkt.com/asset/${CONTRACT_ADDRESS}`,
833 ].join("\n"), GREEN);
834
835 // Clear state on success
836 clearState(cleanCode);
837
838 } else {
839 throw new Error("No transaction hash returned");
840 }
841
842 } catch (e) {
843 console.log(`\n${RED} ✗ Minting failed: ${e.message}${RESET}\n`);
844 console.log(`${DIM} State saved. Run again to retry from mint step.${RESET}\n`);
845 process.exit(1);
846 }
847
848 console.log();
849}
850
851// Run
852main().catch(err => {
853 console.error(`${RED}Error: ${err.message}${RESET}`);
854 if (err.stack && process.env.DEBUG) {
855 console.error(err.stack);
856 }
857 process.exit(1);
858});