Monorepo for Aesthetic.Computer
aesthetic.computer
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});