Monorepo for Aesthetic.Computer aesthetic.computer
at main 858 lines 33 kB view raw
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});