Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658#!/usr/bin/env node// at/cli.mjs — Unified CLI for AT Protocol tooling on Aesthetic Computer.// Usage: node at/cli.mjs <command> [options]//// Consolidates scattered AT scripts into one entry point.// Follows the same pattern as memory/cli.mjs and papers/cli.mjs.
import { config } from "dotenv";config(); // Load .env from at/ directory
const PDS_URL = process.env.PDS_URL || "https://at.aesthetic.computer";const BSKY_SERVICE = process.env.BSKY_SERVICE || "https://public.api.bsky.app";
// ---------------------------------------------------------------------------// Argument parser (same style as memory/cli.mjs)// ---------------------------------------------------------------------------
function parseArgs(argv) { const out = { _: [] }; for (let i = 0; i < argv.length; i++) { const token = argv[i]; if (!token.startsWith("--")) { out._.push(token); continue; } const eqIdx = token.indexOf("="); if (eqIdx !== -1) { out[token.slice(2, eqIdx)] = token.slice(eqIdx + 1); } else { const next = argv[i + 1]; if (next && !next.startsWith("--")) { out[token.slice(2)] = next; i++; } else { out[token.slice(2)] = true; } } } return out;}
// ---------------------------------------------------------------------------// Helpers// ---------------------------------------------------------------------------
function requireArg(args, position, name) { const val = args._[position]; if (!val) { console.error(`Missing required argument: <${name}>`); process.exit(1); } return val;}
async function fetchJSON(url) { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); return res.json();}
function adminAuth() { const pw = process.env.PDS_ADMIN_PASSWORD; if (!pw) { console.error("PDS_ADMIN_PASSWORD environment variable is required."); process.exit(1); } return `Basic ${Buffer.from(`admin:${pw}`).toString("base64")}`;}
// ---------------------------------------------------------------------------// Commands// ---------------------------------------------------------------------------
async function commandHealth() { console.log(`\nPDS Health Check — ${PDS_URL}\n`);
// HTTP health try { const res = await fetch(`${PDS_URL}/xrpc/_health`); if (res.ok) { const data = await res.json(); console.log(` HTTP: OK (version ${data.version || "unknown"})`); } else { console.log(` HTTP: FAIL (${res.status})`); } } catch (e) { console.log(` HTTP: FAIL (${e.message})`); }
// Describe server try { const desc = await fetchJSON( `${PDS_URL}/xrpc/com.atproto.server.describeServer`, ); console.log(` DID: ${desc.did || "?"}`); console.log( ` Invite: ${desc.inviteCodeRequired ? "required" : "open"}`, ); if (desc.availableUserDomains?.length) { console.log(` Domains: ${desc.availableUserDomains.join(", ")}`); } if (desc.contact?.email) { console.log(` Contact: ${desc.contact.email}`); } } catch (e) { console.log(` Server: Could not describe (${e.message})`); }
console.log();}
async function commandResolve(args) { const input = requireArg(args, 1, "handle-or-did");
let did = input;
// Resolve handle → DID if (!input.startsWith("did:")) { console.log(`Resolving handle: ${input}`); const profile = await fetchJSON( `${BSKY_SERVICE}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(input)}`, ); did = profile.did; console.log(` @${input} -> ${did}\n`); }
// Fetch DID document if (did.startsWith("did:plc:")) { const doc = await fetchJSON(`https://plc.directory/${did}`);
console.log(`DID: ${did}`); if (doc.alsoKnownAs?.length) { doc.alsoKnownAs.forEach((aka) => { const label = aka.startsWith("at://") ? `@${aka.slice(5)}` : aka; console.log(`AKA: ${label}`); }); } if (doc.service?.length) { doc.service.forEach((svc) => { const star = svc.serviceEndpoint.includes("aesthetic.computer") ? " (ours)" : ""; console.log(`Service: ${svc.type} -> ${svc.serviceEndpoint}${star}`); }); } if (doc.verificationMethod?.length) { doc.verificationMethod.forEach((vm) => { const key = vm.publicKeyMultibase ? vm.publicKeyMultibase.slice(0, 24) + "..." : "?"; console.log(`Key: ${vm.id} (${key})`); }); }
if (args.json) { console.log(`\n${JSON.stringify(doc, null, 2)}`); } } else if (did.startsWith("did:web:")) { const domain = did.replace("did:web:", ""); const doc = await fetchJSON(`https://${domain}/.well-known/did.json`); console.log(JSON.stringify(doc, null, 2)); } else { console.error(`Unsupported DID method: ${did}`); } console.log();}
async function commandProfile(args) { const actor = requireArg(args, 1, "handle-or-did"); const profile = await fetchJSON( `${BSKY_SERVICE}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`, ); const p = profile;
console.log(`\n Handle: @${p.handle}`); console.log(` DID: ${p.did}`); console.log(` Name: ${p.displayName || "(none)"}`); console.log(` Bio: ${p.description || "(none)"}`); console.log(` Followers: ${p.followersCount || 0}`); console.log(` Following: ${p.followsCount || 0}`); console.log(` Posts: ${p.postsCount || 0}`); if (p.avatar) console.log(` Avatar: ${p.avatar}`); console.log();}
async function commandPosts(args) { const actor = requireArg(args, 1, "handle-or-did"); const limit = parseInt(args.limit) || 10;
const data = await fetchJSON( `${BSKY_SERVICE}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(actor)}&limit=${limit}`, );
if (!data.feed?.length) { console.log("(no posts)"); return; }
console.log(`\n${data.feed.length} posts from @${actor}:\n`);
data.feed.forEach((item, i) => { const post = item.post; const text = post.record?.text || "(no text)"; const date = new Date(post.indexedAt).toLocaleDateString(); const likes = post.likeCount || 0; const replies = post.replyCount || 0; const reposts = post.repostCount || 0; console.log( ` ${i + 1}. [${date}] ${text.slice(0, 80)}${text.length > 80 ? "..." : ""}`, ); console.log(` likes:${likes} replies:${replies} reposts:${reposts}`); console.log(` ${post.uri}\n`); });}
async function commandPost(args) { const text = requireArg(args, 1, "text");
const identifier = process.env.BSKY_IDENTIFIER; const appPassword = process.env.BSKY_APP_PASSWORD; const service = process.env.BSKY_SERVICE || "https://bsky.social";
if (!identifier || !appPassword) { console.error( "Set BSKY_IDENTIFIER and BSKY_APP_PASSWORD in your environment.", ); process.exit(1); }
const { AtpAgent, RichText } = await import("@atproto/api"); const agent = new AtpAgent({ service });
console.log(`Logging in as @${identifier}...`); await agent.login({ identifier, password: appPassword });
const rt = new RichText({ text }); await rt.detectFacets(agent);
const postRecord = { text: rt.text, facets: rt.facets, createdAt: new Date().toISOString(), };
// Attach image if provided if (args.image) { const { readFileSync } = await import("fs"); const imageData = readFileSync(args.image); const { data } = await agent.uploadBlob(imageData, { encoding: "image/png", }); postRecord.embed = { $type: "app.bsky.embed.images", images: [ { image: data.blob, alt: args.alt || "Image from Aesthetic Computer" }, ], }; console.log(`Uploaded image: ${args.image}`); }
const response = await agent.post(postRecord); const rkey = response.uri.split("/").pop();
console.log(`Posted: https://bsky.app/profile/${identifier}/post/${rkey}`);}
async function commandRecords(args) { const repo = requireArg(args, 1, "did"); const collection = requireArg(args, 2, "collection"); const limit = parseInt(args.limit) || 25;
const data = await fetchJSON( `${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(repo)}&collection=${encodeURIComponent(collection)}&limit=${limit}`, );
if (!data.records?.length) { console.log(`(no records in ${collection})`); return; }
console.log(`\n${data.records.length} records in ${collection}:\n`);
data.records.forEach((rec, i) => { const rkey = rec.uri.split("/").pop(); const val = rec.value; // Show a compact summary depending on type const when = val.when || val.createdAt || ""; const label = val.slug || val.code || val.mood || val.headline || val.text || ""; console.log( ` ${i + 1}. ${rkey} ${label.slice(0, 60)} ${when ? `(${when.slice(0, 10)})` : ""}`, ); }); console.log();}
async function commandLexicons() { const { readdirSync, readFileSync } = await import("fs"); const { join, dirname } = await import("path"); const { fileURLToPath } = await import("url");
const __dirname = dirname(fileURLToPath(import.meta.url)); const lexDir = join(__dirname, "lexicons", "computer", "aesthetic");
let files; try { files = readdirSync(lexDir).filter((f) => f.endsWith(".json")); } catch { console.error(`Lexicon directory not found: ${lexDir}`); return; }
console.log(`\nAesthetic Computer Lexicons (${files.length}):\n`);
for (const file of files) { const lex = JSON.parse(readFileSync(join(lexDir, file), "utf8")); const main = lex.defs?.main; const desc = main?.description || ""; const required = main?.record?.required || []; const props = Object.keys(main?.record?.properties || {});
console.log(` ${lex.id}`); console.log(` ${desc}`); console.log(` required: ${required.join(", ") || "(none)"}`); console.log(` fields: ${props.join(", ")}`); console.log(); }}
async function commandInvite() { const auth = adminAuth();
const res = await fetch( `${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: auth, }, body: JSON.stringify({ useCount: 1 }), }, );
if (!res.ok) { const text = await res.text(); console.error(`Failed to create invite: ${res.status} ${text}`); process.exit(1); }
const data = await res.json(); console.log(`Invite code: ${data.code}`);}
async function commandAccounts(args) { const limit = parseInt(args.limit) || 50;
// listRepos is a public endpoint const res = await fetch( `${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=${limit}`, );
if (!res.ok) { console.error(`Failed to list accounts: ${res.status}`); process.exit(1); }
const data = await res.json(); printRepos(data);}
function printRepos(data) { const repos = data.repos || []; console.log(`\n${repos.length} accounts on PDS:\n`); repos.forEach((repo, i) => { const active = repo.active !== false ? "" : " (inactive)"; console.log( ` ${String(i + 1).padStart(3)}. ${repo.did}${active}`, ); }); console.log();}
async function commandAccountCheck(args) { const input = requireArg(args, 1, "handle-or-did");
// Resolve to DID if needed let did = input; if (!input.startsWith("did:")) { const profile = await fetchJSON( `${BSKY_SERVICE}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(input)}`, ); did = profile.did; }
console.log(`\nAccount check for: ${did}\n`);
// Check DID document try { const doc = await fetchJSON(`https://plc.directory/${did}`); const pds = doc.service?.find( (s) => s.type === "AtprotoPersonalDataServer", ); const handle = doc.alsoKnownAs ?.find((a) => a.startsWith("at://")) ?.slice(5);
console.log(` Handle: @${handle || "?"}`); console.log(` PDS: ${pds?.serviceEndpoint || "?"}`);
if (pds?.serviceEndpoint?.includes("aesthetic.computer")) { console.log(` Ours: yes`); } } catch (e) { console.log(` DID doc: failed (${e.message})`); }
// List collections on our PDS const collections = [ "computer.aesthetic.painting", "computer.aesthetic.mood", "computer.aesthetic.piece", "computer.aesthetic.kidlisp", "computer.aesthetic.tape", "computer.aesthetic.news", ];
console.log(`\n Records on ${PDS_URL}:`); for (const col of collections) { try { const data = await fetchJSON( `${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(col)}&limit=1`, ); const count = data.records?.length ? `${data.records.length}+ records` : "0 records"; console.log(` ${col.replace("computer.aesthetic.", "")}: ${count}`); } catch { console.log( ` ${col.replace("computer.aesthetic.", "")}: (error or not found)`, ); } } console.log();}
async function commandSyncStatus() { console.log(`\nSync Status — ${PDS_URL}\n`);
// Get the art account DID (guest) const artDid = "did:plc:tliuubv7lyv2uiknsjbf4ppw";
const collections = [ "computer.aesthetic.painting", "computer.aesthetic.mood", "computer.aesthetic.piece", "computer.aesthetic.kidlisp", "computer.aesthetic.tape", "computer.aesthetic.news", ];
// Check record counts on art account as a quick indicator console.log(` Art account (${artDid}):`); for (const col of collections) { try { const data = await fetchJSON( `${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(artDid)}&collection=${encodeURIComponent(col)}&limit=100`, ); const name = col.replace("computer.aesthetic.", ""); const count = data.records?.length || 0; const cursor = data.cursor ? " (more available)" : ""; console.log(` ${name.padEnd(12)} ${count} records${cursor}`); } catch { const name = col.replace("computer.aesthetic.", ""); console.log(` ${name.padEnd(12)} (error)`); } }
// List all repos to get user count try { const repos = await fetchJSON( `${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=200`, ); const count = repos.repos?.length || 0; console.log(`\n Total accounts: ${count}`); } catch { console.log(`\n Total accounts: (could not fetch)`); }
console.log();}
async function commandSyncStandard() { const { execFileSync } = await import("child_process"); const { fileURLToPath } = await import("url");
const scriptUrl = new URL( "./scripts/atproto/backfill-standard-site-documents.mjs", import.meta.url, ); const scriptPath = fileURLToPath(scriptUrl); const passthroughArgs = process.argv.slice(3);
try { execFileSync("node", [scriptPath, ...passthroughArgs], { stdio: "inherit", }); } catch (error) { process.exitCode = error.status || 1; }}
async function commandSSH(args) { const { execSync } = await import("child_process"); const ip = process.env.PDS_SSH_HOST || "165.227.120.137"; const key = process.env.PDS_SSH_KEY || `${process.env.HOME}/.ssh/aesthetic_pds`; const remoteCmd = args._.slice(1).join(" ");
const sshCmd = remoteCmd ? `ssh -i ${key} root@${ip} ${JSON.stringify(remoteCmd)}` : `ssh -i ${key} root@${ip}`;
console.log(`$ ${sshCmd}\n`); try { execSync(sshCmd, { stdio: "inherit" }); } catch (e) { process.exitCode = e.status || 1; }}
async function commandEnvSet(args) { const { execSync } = await import("child_process"); const key = args._[1]; const value = args._[2];
if (!key || !value) { console.error("Usage: ac-at env:set <KEY> <VALUE>"); console.error("Example: ac-at env:set PDS_CONTACT_EMAIL_ADDRESS mail@aesthetic.computer"); process.exit(1); }
const ip = process.env.PDS_SSH_HOST || "165.227.120.137"; const sshKey = process.env.PDS_SSH_KEY || `${process.env.HOME}/.ssh/aesthetic_pds`; const envFile = "/pds/pds.env";
console.log(`Setting ${key}=${value} on PDS (${ip})...\n`);
// Check if key already exists, update or append const cmd = `ssh -i ${sshKey} root@${ip} "grep -q '^${key}=' ${envFile} && sed -i 's|^${key}=.*|${key}=${value}|' ${envFile} || echo '${key}=${value}' >> ${envFile}"`;
try { execSync(cmd, { stdio: "inherit" }); console.log(`\nSet ${key}=${value} in ${envFile}`); console.log(`Restart PDS to apply: ac-at ssh systemctl restart pds`); } catch (e) { console.error(`Failed to set env var: ${e.message}`); process.exitCode = 1; }}
// ---------------------------------------------------------------------------// Help// ---------------------------------------------------------------------------
function printHelp() { console.log(`ac-at — AT Protocol CLI for Aesthetic Computer
Usage: ac-at <command> [options]
Query & Inspect: health PDS health check resolve <handle-or-did> [--json] Resolve DID document profile <handle-or-did> Query profile posts <handle-or-did> [--limit=N] Query posts records <did> <collection> [--limit=N] List records lexicons Show AC custom lexicon schemas
Publish: post <text> [--image=path] [--alt=text] Post to Bluesky
Admin (requires PDS_ADMIN_PASSWORD): invite Generate PDS invite code accounts [--limit=N] List PDS accounts account:check <handle-or-did> Inspect account & record counts sync:status Record counts across collections sync:standard [options] Mirror AC records to site.standard.document
Server: ssh [command] SSH into PDS droplet (or run command) env:set <KEY> <VALUE> Set env var in PDS pds.env file
Environment: PDS_URL PDS endpoint (default: https://at.aesthetic.computer) PDS_ADMIN_PASSWORD Admin password for PDS operations BSKY_IDENTIFIER Bluesky handle for posting BSKY_APP_PASSWORD Bluesky app password for posting BSKY_SERVICE Bluesky API (default: https://public.api.bsky.app)
Examples: ac-at health ac-at resolve aesthetic.computer ac-at profile jeffrey.at.aesthetic.computer ac-at posts aesthetic.computer --limit=5 ac-at records did:plc:k3k3wknzkcnekbnyde4dbatz computer.aesthetic.painting ac-at post "Hello from AC!" --image=painting.png ac-at invite ac-at account:check jeffrey.at.aesthetic.computer ac-at sync:status ac-at sync:standard --dry-run --sources=paper,news,piece --limit=25`);}
// ---------------------------------------------------------------------------// Main// ---------------------------------------------------------------------------
const COMMANDS = { health: commandHealth, resolve: commandResolve, profile: commandProfile, posts: commandPosts, post: commandPost, records: commandRecords, lexicons: commandLexicons, invite: commandInvite, accounts: commandAccounts, "account:check": commandAccountCheck, "sync:status": commandSyncStatus, "sync:standard": commandSyncStandard, ssh: commandSSH, "env:set": commandEnvSet,};
async function main() { const args = parseArgs(process.argv.slice(2)); const command = args._[0] || "help";
if (command === "help" || command === "--help" || command === "-h") { printHelp(); return; }
const handler = COMMANDS[command]; if (!handler) { console.error(`Unknown command: ${command}\n`); printHelp(); process.exitCode = 1; return; }
await handler(args);}
main().catch((err) => { console.error(`ac-at: ${err.message}`); process.exit(1);});