Monorepo for Aesthetic.Computer aesthetic.computer
at main 657 lines 20 kB view raw
1#!/usr/bin/env node 2// at/cli.mjs — Unified CLI for AT Protocol tooling on Aesthetic Computer. 3// Usage: node at/cli.mjs <command> [options] 4// 5// Consolidates scattered AT scripts into one entry point. 6// Follows the same pattern as memory/cli.mjs and papers/cli.mjs. 7 8import { config } from "dotenv"; 9config(); // Load .env from at/ directory 10 11const PDS_URL = process.env.PDS_URL || "https://at.aesthetic.computer"; 12const BSKY_SERVICE = 13 process.env.BSKY_SERVICE || "https://public.api.bsky.app"; 14 15// --------------------------------------------------------------------------- 16// Argument parser (same style as memory/cli.mjs) 17// --------------------------------------------------------------------------- 18 19function parseArgs(argv) { 20 const out = { _: [] }; 21 for (let i = 0; i < argv.length; i++) { 22 const token = argv[i]; 23 if (!token.startsWith("--")) { 24 out._.push(token); 25 continue; 26 } 27 const eqIdx = token.indexOf("="); 28 if (eqIdx !== -1) { 29 out[token.slice(2, eqIdx)] = token.slice(eqIdx + 1); 30 } else { 31 const next = argv[i + 1]; 32 if (next && !next.startsWith("--")) { 33 out[token.slice(2)] = next; 34 i++; 35 } else { 36 out[token.slice(2)] = true; 37 } 38 } 39 } 40 return out; 41} 42 43// --------------------------------------------------------------------------- 44// Helpers 45// --------------------------------------------------------------------------- 46 47function requireArg(args, position, name) { 48 const val = args._[position]; 49 if (!val) { 50 console.error(`Missing required argument: <${name}>`); 51 process.exit(1); 52 } 53 return val; 54} 55 56async function fetchJSON(url) { 57 const res = await fetch(url); 58 if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); 59 return res.json(); 60} 61 62function adminAuth() { 63 const pw = process.env.PDS_ADMIN_PASSWORD; 64 if (!pw) { 65 console.error("PDS_ADMIN_PASSWORD environment variable is required."); 66 process.exit(1); 67 } 68 return `Basic ${Buffer.from(`admin:${pw}`).toString("base64")}`; 69} 70 71// --------------------------------------------------------------------------- 72// Commands 73// --------------------------------------------------------------------------- 74 75async function commandHealth() { 76 console.log(`\nPDS Health Check — ${PDS_URL}\n`); 77 78 // HTTP health 79 try { 80 const res = await fetch(`${PDS_URL}/xrpc/_health`); 81 if (res.ok) { 82 const data = await res.json(); 83 console.log(` HTTP: OK (version ${data.version || "unknown"})`); 84 } else { 85 console.log(` HTTP: FAIL (${res.status})`); 86 } 87 } catch (e) { 88 console.log(` HTTP: FAIL (${e.message})`); 89 } 90 91 // Describe server 92 try { 93 const desc = await fetchJSON( 94 `${PDS_URL}/xrpc/com.atproto.server.describeServer`, 95 ); 96 console.log(` DID: ${desc.did || "?"}`); 97 console.log( 98 ` Invite: ${desc.inviteCodeRequired ? "required" : "open"}`, 99 ); 100 if (desc.availableUserDomains?.length) { 101 console.log(` Domains: ${desc.availableUserDomains.join(", ")}`); 102 } 103 if (desc.contact?.email) { 104 console.log(` Contact: ${desc.contact.email}`); 105 } 106 } catch (e) { 107 console.log(` Server: Could not describe (${e.message})`); 108 } 109 110 console.log(); 111} 112 113async function commandResolve(args) { 114 const input = requireArg(args, 1, "handle-or-did"); 115 116 let did = input; 117 118 // Resolve handle → DID 119 if (!input.startsWith("did:")) { 120 console.log(`Resolving handle: ${input}`); 121 const profile = await fetchJSON( 122 `${BSKY_SERVICE}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(input)}`, 123 ); 124 did = profile.did; 125 console.log(` @${input} -> ${did}\n`); 126 } 127 128 // Fetch DID document 129 if (did.startsWith("did:plc:")) { 130 const doc = await fetchJSON(`https://plc.directory/${did}`); 131 132 console.log(`DID: ${did}`); 133 if (doc.alsoKnownAs?.length) { 134 doc.alsoKnownAs.forEach((aka) => { 135 const label = aka.startsWith("at://") ? `@${aka.slice(5)}` : aka; 136 console.log(`AKA: ${label}`); 137 }); 138 } 139 if (doc.service?.length) { 140 doc.service.forEach((svc) => { 141 const star = 142 svc.serviceEndpoint.includes("aesthetic.computer") ? " (ours)" : ""; 143 console.log(`Service: ${svc.type} -> ${svc.serviceEndpoint}${star}`); 144 }); 145 } 146 if (doc.verificationMethod?.length) { 147 doc.verificationMethod.forEach((vm) => { 148 const key = vm.publicKeyMultibase 149 ? vm.publicKeyMultibase.slice(0, 24) + "..." 150 : "?"; 151 console.log(`Key: ${vm.id} (${key})`); 152 }); 153 } 154 155 if (args.json) { 156 console.log(`\n${JSON.stringify(doc, null, 2)}`); 157 } 158 } else if (did.startsWith("did:web:")) { 159 const domain = did.replace("did:web:", ""); 160 const doc = await fetchJSON(`https://${domain}/.well-known/did.json`); 161 console.log(JSON.stringify(doc, null, 2)); 162 } else { 163 console.error(`Unsupported DID method: ${did}`); 164 } 165 console.log(); 166} 167 168async function commandProfile(args) { 169 const actor = requireArg(args, 1, "handle-or-did"); 170 const profile = await fetchJSON( 171 `${BSKY_SERVICE}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`, 172 ); 173 const p = profile; 174 175 console.log(`\n Handle: @${p.handle}`); 176 console.log(` DID: ${p.did}`); 177 console.log(` Name: ${p.displayName || "(none)"}`); 178 console.log(` Bio: ${p.description || "(none)"}`); 179 console.log(` Followers: ${p.followersCount || 0}`); 180 console.log(` Following: ${p.followsCount || 0}`); 181 console.log(` Posts: ${p.postsCount || 0}`); 182 if (p.avatar) console.log(` Avatar: ${p.avatar}`); 183 console.log(); 184} 185 186async function commandPosts(args) { 187 const actor = requireArg(args, 1, "handle-or-did"); 188 const limit = parseInt(args.limit) || 10; 189 190 const data = await fetchJSON( 191 `${BSKY_SERVICE}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(actor)}&limit=${limit}`, 192 ); 193 194 if (!data.feed?.length) { 195 console.log("(no posts)"); 196 return; 197 } 198 199 console.log(`\n${data.feed.length} posts from @${actor}:\n`); 200 201 data.feed.forEach((item, i) => { 202 const post = item.post; 203 const text = post.record?.text || "(no text)"; 204 const date = new Date(post.indexedAt).toLocaleDateString(); 205 const likes = post.likeCount || 0; 206 const replies = post.replyCount || 0; 207 const reposts = post.repostCount || 0; 208 console.log( 209 ` ${i + 1}. [${date}] ${text.slice(0, 80)}${text.length > 80 ? "..." : ""}`, 210 ); 211 console.log(` likes:${likes} replies:${replies} reposts:${reposts}`); 212 console.log(` ${post.uri}\n`); 213 }); 214} 215 216async function commandPost(args) { 217 const text = requireArg(args, 1, "text"); 218 219 const identifier = process.env.BSKY_IDENTIFIER; 220 const appPassword = process.env.BSKY_APP_PASSWORD; 221 const service = process.env.BSKY_SERVICE || "https://bsky.social"; 222 223 if (!identifier || !appPassword) { 224 console.error( 225 "Set BSKY_IDENTIFIER and BSKY_APP_PASSWORD in your environment.", 226 ); 227 process.exit(1); 228 } 229 230 const { AtpAgent, RichText } = await import("@atproto/api"); 231 const agent = new AtpAgent({ service }); 232 233 console.log(`Logging in as @${identifier}...`); 234 await agent.login({ identifier, password: appPassword }); 235 236 const rt = new RichText({ text }); 237 await rt.detectFacets(agent); 238 239 const postRecord = { 240 text: rt.text, 241 facets: rt.facets, 242 createdAt: new Date().toISOString(), 243 }; 244 245 // Attach image if provided 246 if (args.image) { 247 const { readFileSync } = await import("fs"); 248 const imageData = readFileSync(args.image); 249 const { data } = await agent.uploadBlob(imageData, { 250 encoding: "image/png", 251 }); 252 postRecord.embed = { 253 $type: "app.bsky.embed.images", 254 images: [ 255 { image: data.blob, alt: args.alt || "Image from Aesthetic Computer" }, 256 ], 257 }; 258 console.log(`Uploaded image: ${args.image}`); 259 } 260 261 const response = await agent.post(postRecord); 262 const rkey = response.uri.split("/").pop(); 263 264 console.log(`Posted: https://bsky.app/profile/${identifier}/post/${rkey}`); 265} 266 267async function commandRecords(args) { 268 const repo = requireArg(args, 1, "did"); 269 const collection = requireArg(args, 2, "collection"); 270 const limit = parseInt(args.limit) || 25; 271 272 const data = await fetchJSON( 273 `${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(repo)}&collection=${encodeURIComponent(collection)}&limit=${limit}`, 274 ); 275 276 if (!data.records?.length) { 277 console.log(`(no records in ${collection})`); 278 return; 279 } 280 281 console.log(`\n${data.records.length} records in ${collection}:\n`); 282 283 data.records.forEach((rec, i) => { 284 const rkey = rec.uri.split("/").pop(); 285 const val = rec.value; 286 // Show a compact summary depending on type 287 const when = val.when || val.createdAt || ""; 288 const label = 289 val.slug || val.code || val.mood || val.headline || val.text || ""; 290 console.log( 291 ` ${i + 1}. ${rkey} ${label.slice(0, 60)} ${when ? `(${when.slice(0, 10)})` : ""}`, 292 ); 293 }); 294 console.log(); 295} 296 297async function commandLexicons() { 298 const { readdirSync, readFileSync } = await import("fs"); 299 const { join, dirname } = await import("path"); 300 const { fileURLToPath } = await import("url"); 301 302 const __dirname = dirname(fileURLToPath(import.meta.url)); 303 const lexDir = join(__dirname, "lexicons", "computer", "aesthetic"); 304 305 let files; 306 try { 307 files = readdirSync(lexDir).filter((f) => f.endsWith(".json")); 308 } catch { 309 console.error(`Lexicon directory not found: ${lexDir}`); 310 return; 311 } 312 313 console.log(`\nAesthetic Computer Lexicons (${files.length}):\n`); 314 315 for (const file of files) { 316 const lex = JSON.parse(readFileSync(join(lexDir, file), "utf8")); 317 const main = lex.defs?.main; 318 const desc = main?.description || ""; 319 const required = main?.record?.required || []; 320 const props = Object.keys(main?.record?.properties || {}); 321 322 console.log(` ${lex.id}`); 323 console.log(` ${desc}`); 324 console.log(` required: ${required.join(", ") || "(none)"}`); 325 console.log(` fields: ${props.join(", ")}`); 326 console.log(); 327 } 328} 329 330async function commandInvite() { 331 const auth = adminAuth(); 332 333 const res = await fetch( 334 `${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, 335 { 336 method: "POST", 337 headers: { 338 "Content-Type": "application/json", 339 Authorization: auth, 340 }, 341 body: JSON.stringify({ useCount: 1 }), 342 }, 343 ); 344 345 if (!res.ok) { 346 const text = await res.text(); 347 console.error(`Failed to create invite: ${res.status} ${text}`); 348 process.exit(1); 349 } 350 351 const data = await res.json(); 352 console.log(`Invite code: ${data.code}`); 353} 354 355async function commandAccounts(args) { 356 const limit = parseInt(args.limit) || 50; 357 358 // listRepos is a public endpoint 359 const res = await fetch( 360 `${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=${limit}`, 361 ); 362 363 if (!res.ok) { 364 console.error(`Failed to list accounts: ${res.status}`); 365 process.exit(1); 366 } 367 368 const data = await res.json(); 369 printRepos(data); 370} 371 372function printRepos(data) { 373 const repos = data.repos || []; 374 console.log(`\n${repos.length} accounts on PDS:\n`); 375 repos.forEach((repo, i) => { 376 const active = repo.active !== false ? "" : " (inactive)"; 377 console.log( 378 ` ${String(i + 1).padStart(3)}. ${repo.did}${active}`, 379 ); 380 }); 381 console.log(); 382} 383 384async function commandAccountCheck(args) { 385 const input = requireArg(args, 1, "handle-or-did"); 386 387 // Resolve to DID if needed 388 let did = input; 389 if (!input.startsWith("did:")) { 390 const profile = await fetchJSON( 391 `${BSKY_SERVICE}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(input)}`, 392 ); 393 did = profile.did; 394 } 395 396 console.log(`\nAccount check for: ${did}\n`); 397 398 // Check DID document 399 try { 400 const doc = await fetchJSON(`https://plc.directory/${did}`); 401 const pds = doc.service?.find( 402 (s) => s.type === "AtprotoPersonalDataServer", 403 ); 404 const handle = doc.alsoKnownAs 405 ?.find((a) => a.startsWith("at://")) 406 ?.slice(5); 407 408 console.log(` Handle: @${handle || "?"}`); 409 console.log(` PDS: ${pds?.serviceEndpoint || "?"}`); 410 411 if (pds?.serviceEndpoint?.includes("aesthetic.computer")) { 412 console.log(` Ours: yes`); 413 } 414 } catch (e) { 415 console.log(` DID doc: failed (${e.message})`); 416 } 417 418 // List collections on our PDS 419 const collections = [ 420 "computer.aesthetic.painting", 421 "computer.aesthetic.mood", 422 "computer.aesthetic.piece", 423 "computer.aesthetic.kidlisp", 424 "computer.aesthetic.tape", 425 "computer.aesthetic.news", 426 ]; 427 428 console.log(`\n Records on ${PDS_URL}:`); 429 for (const col of collections) { 430 try { 431 const data = await fetchJSON( 432 `${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(col)}&limit=1`, 433 ); 434 const count = data.records?.length 435 ? `${data.records.length}+ records` 436 : "0 records"; 437 console.log(` ${col.replace("computer.aesthetic.", "")}: ${count}`); 438 } catch { 439 console.log( 440 ` ${col.replace("computer.aesthetic.", "")}: (error or not found)`, 441 ); 442 } 443 } 444 console.log(); 445} 446 447async function commandSyncStatus() { 448 console.log(`\nSync Status — ${PDS_URL}\n`); 449 450 // Get the art account DID (guest) 451 const artDid = "did:plc:tliuubv7lyv2uiknsjbf4ppw"; 452 453 const collections = [ 454 "computer.aesthetic.painting", 455 "computer.aesthetic.mood", 456 "computer.aesthetic.piece", 457 "computer.aesthetic.kidlisp", 458 "computer.aesthetic.tape", 459 "computer.aesthetic.news", 460 ]; 461 462 // Check record counts on art account as a quick indicator 463 console.log(` Art account (${artDid}):`); 464 for (const col of collections) { 465 try { 466 const data = await fetchJSON( 467 `${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(artDid)}&collection=${encodeURIComponent(col)}&limit=100`, 468 ); 469 const name = col.replace("computer.aesthetic.", ""); 470 const count = data.records?.length || 0; 471 const cursor = data.cursor ? " (more available)" : ""; 472 console.log(` ${name.padEnd(12)} ${count} records${cursor}`); 473 } catch { 474 const name = col.replace("computer.aesthetic.", ""); 475 console.log(` ${name.padEnd(12)} (error)`); 476 } 477 } 478 479 // List all repos to get user count 480 try { 481 const repos = await fetchJSON( 482 `${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=200`, 483 ); 484 const count = repos.repos?.length || 0; 485 console.log(`\n Total accounts: ${count}`); 486 } catch { 487 console.log(`\n Total accounts: (could not fetch)`); 488 } 489 490 console.log(); 491} 492 493async function commandSyncStandard() { 494 const { execFileSync } = await import("child_process"); 495 const { fileURLToPath } = await import("url"); 496 497 const scriptUrl = new URL( 498 "./scripts/atproto/backfill-standard-site-documents.mjs", 499 import.meta.url, 500 ); 501 const scriptPath = fileURLToPath(scriptUrl); 502 const passthroughArgs = process.argv.slice(3); 503 504 try { 505 execFileSync("node", [scriptPath, ...passthroughArgs], { 506 stdio: "inherit", 507 }); 508 } catch (error) { 509 process.exitCode = error.status || 1; 510 } 511} 512 513async function commandSSH(args) { 514 const { execSync } = await import("child_process"); 515 const ip = process.env.PDS_SSH_HOST || "165.227.120.137"; 516 const key = process.env.PDS_SSH_KEY || `${process.env.HOME}/.ssh/aesthetic_pds`; 517 const remoteCmd = args._.slice(1).join(" "); 518 519 const sshCmd = remoteCmd 520 ? `ssh -i ${key} root@${ip} ${JSON.stringify(remoteCmd)}` 521 : `ssh -i ${key} root@${ip}`; 522 523 console.log(`$ ${sshCmd}\n`); 524 try { 525 execSync(sshCmd, { stdio: "inherit" }); 526 } catch (e) { 527 process.exitCode = e.status || 1; 528 } 529} 530 531async function commandEnvSet(args) { 532 const { execSync } = await import("child_process"); 533 const key = args._[1]; 534 const value = args._[2]; 535 536 if (!key || !value) { 537 console.error("Usage: ac-at env:set <KEY> <VALUE>"); 538 console.error("Example: ac-at env:set PDS_CONTACT_EMAIL_ADDRESS mail@aesthetic.computer"); 539 process.exit(1); 540 } 541 542 const ip = process.env.PDS_SSH_HOST || "165.227.120.137"; 543 const sshKey = process.env.PDS_SSH_KEY || `${process.env.HOME}/.ssh/aesthetic_pds`; 544 const envFile = "/pds/pds.env"; 545 546 console.log(`Setting ${key}=${value} on PDS (${ip})...\n`); 547 548 // Check if key already exists, update or append 549 const cmd = `ssh -i ${sshKey} root@${ip} "grep -q '^${key}=' ${envFile} && sed -i 's|^${key}=.*|${key}=${value}|' ${envFile} || echo '${key}=${value}' >> ${envFile}"`; 550 551 try { 552 execSync(cmd, { stdio: "inherit" }); 553 console.log(`\nSet ${key}=${value} in ${envFile}`); 554 console.log(`Restart PDS to apply: ac-at ssh systemctl restart pds`); 555 } catch (e) { 556 console.error(`Failed to set env var: ${e.message}`); 557 process.exitCode = 1; 558 } 559} 560 561// --------------------------------------------------------------------------- 562// Help 563// --------------------------------------------------------------------------- 564 565function printHelp() { 566 console.log(`ac-at — AT Protocol CLI for Aesthetic Computer 567 568Usage: ac-at <command> [options] 569 570Query & Inspect: 571 health PDS health check 572 resolve <handle-or-did> [--json] Resolve DID document 573 profile <handle-or-did> Query profile 574 posts <handle-or-did> [--limit=N] Query posts 575 records <did> <collection> [--limit=N] List records 576 lexicons Show AC custom lexicon schemas 577 578Publish: 579 post <text> [--image=path] [--alt=text] Post to Bluesky 580 581Admin (requires PDS_ADMIN_PASSWORD): 582 invite Generate PDS invite code 583 accounts [--limit=N] List PDS accounts 584 account:check <handle-or-did> Inspect account & record counts 585 sync:status Record counts across collections 586 sync:standard [options] Mirror AC records to site.standard.document 587 588Server: 589 ssh [command] SSH into PDS droplet (or run command) 590 env:set <KEY> <VALUE> Set env var in PDS pds.env file 591 592Environment: 593 PDS_URL PDS endpoint (default: https://at.aesthetic.computer) 594 PDS_ADMIN_PASSWORD Admin password for PDS operations 595 BSKY_IDENTIFIER Bluesky handle for posting 596 BSKY_APP_PASSWORD Bluesky app password for posting 597 BSKY_SERVICE Bluesky API (default: https://public.api.bsky.app) 598 599Examples: 600 ac-at health 601 ac-at resolve aesthetic.computer 602 ac-at profile jeffrey.at.aesthetic.computer 603 ac-at posts aesthetic.computer --limit=5 604 ac-at records did:plc:k3k3wknzkcnekbnyde4dbatz computer.aesthetic.painting 605 ac-at post "Hello from AC!" --image=painting.png 606 ac-at invite 607 ac-at account:check jeffrey.at.aesthetic.computer 608 ac-at sync:status 609 ac-at sync:standard --dry-run --sources=paper,news,piece --limit=25 610`); 611} 612 613// --------------------------------------------------------------------------- 614// Main 615// --------------------------------------------------------------------------- 616 617const COMMANDS = { 618 health: commandHealth, 619 resolve: commandResolve, 620 profile: commandProfile, 621 posts: commandPosts, 622 post: commandPost, 623 records: commandRecords, 624 lexicons: commandLexicons, 625 invite: commandInvite, 626 accounts: commandAccounts, 627 "account:check": commandAccountCheck, 628 "sync:status": commandSyncStatus, 629 "sync:standard": commandSyncStandard, 630 ssh: commandSSH, 631 "env:set": commandEnvSet, 632}; 633 634async function main() { 635 const args = parseArgs(process.argv.slice(2)); 636 const command = args._[0] || "help"; 637 638 if (command === "help" || command === "--help" || command === "-h") { 639 printHelp(); 640 return; 641 } 642 643 const handler = COMMANDS[command]; 644 if (!handler) { 645 console.error(`Unknown command: ${command}\n`); 646 printHelp(); 647 process.exitCode = 1; 648 return; 649 } 650 651 await handler(args); 652} 653 654main().catch((err) => { 655 console.error(`ac-at: ${err.message}`); 656 process.exit(1); 657});