Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 616 lines 18 kB view raw
1#!/usr/bin/env node 2 3/** 4 * ac-news — CLI for posting prose updates to news.aesthetic.computer 5 * 6 * Usage: 7 * ac-news post "Title" "Body prose text" 8 * ac-news post "Title" --file update.md 9 * echo "body" | ac-news post "Title" --stdin 10 * ac-news post "Title" --editor # opens $EDITOR 11 * ac-news commits # show recent commits for reference 12 * ac-news commits --since "1 week ago" 13 * ac-news list # list recent posts 14 * ac-news edit <code> --replace "old" --with "new" # find & replace in body 15 * ac-news edit <code> --editor # edit body in $EDITOR 16 * ac-news delete <code> # delete a post (admin) 17 */ 18 19import { MongoClient } from "mongodb"; 20import { AtpAgent } from "@atproto/api"; 21import { 22 ingestAll, 23 ingestFromActor, 24 getConfiguredSources, 25} from "../system/backend/news-bluesky-ingest.mjs"; 26import { config } from "dotenv"; 27import { execSync } from "child_process"; 28import { randomBytes } from "crypto"; 29import { readFileSync, writeFileSync, unlinkSync } from "fs"; 30import { tmpdir } from "os"; 31import { join } from "path"; 32 33config({ 34 path: new URL("../.devcontainer/envs/devcontainer.env", import.meta.url), 35}); 36config({ path: new URL(".env", import.meta.url) }); 37 38const MONGODB_URI = process.env.MONGODB_CONNECTION_STRING; 39const MONGODB_NAME = process.env.MONGODB_NAME || "aesthetic"; 40const ADMIN_SUB = process.env.ADMIN_SUB; 41const PDS_URL = process.env.PDS_URL || "https://at.aesthetic.computer"; 42 43// --------------------------------------------------------------------------- 44// Args 45// --------------------------------------------------------------------------- 46 47function parseArgs(argv) { 48 const out = { _: [] }; 49 for (let i = 0; i < argv.length; i++) { 50 const t = argv[i]; 51 if (!t.startsWith("--")) { 52 out._.push(t); 53 continue; 54 } 55 const key = t.slice(2); 56 const next = argv[i + 1]; 57 if (next && !next.startsWith("--")) { 58 out[key] = next; 59 i++; 60 } else out[key] = true; 61 } 62 return out; 63} 64 65// --------------------------------------------------------------------------- 66// Short code (same as publish-commits.mjs) 67// --------------------------------------------------------------------------- 68 69const ALPHABET = "bcdfghjklmnpqrstvwxyzaeiou23456789"; 70 71function randomCode(len = 3) { 72 const bytes = randomBytes(len); 73 return Array.from(bytes) 74 .map((b) => ALPHABET[b % ALPHABET.length]) 75 .join(""); 76} 77 78async function uniqueCode(collection) { 79 for (let i = 0; i < 100; i++) { 80 const code = `n${randomCode()}`; 81 const exists = await collection.findOne({ code }); 82 if (!exists) return code; 83 } 84 throw new Error("Could not generate unique code after 100 attempts"); 85} 86 87// --------------------------------------------------------------------------- 88// ATProto sync 89// --------------------------------------------------------------------------- 90 91async function syncToAtproto(db, sub, newsData, refId) { 92 const users = db.collection("users"); 93 const user = await users.findOne({ _id: sub }); 94 95 if (!user?.atproto?.did || !user?.atproto?.password) { 96 console.log(" No ATProto account — skipping PDS sync."); 97 return null; 98 } 99 100 const agent = new AtpAgent({ service: PDS_URL }); 101 await agent.login({ 102 identifier: user.atproto.did, 103 password: user.atproto.password, 104 }); 105 106 const record = { 107 $type: "computer.aesthetic.news", 108 headline: newsData.headline, 109 when: newsData.when.toISOString(), 110 ref: refId, 111 }; 112 if (newsData.body) record.body = newsData.body; 113 114 const res = await agent.com.atproto.repo.createRecord({ 115 repo: user.atproto.did, 116 collection: "computer.aesthetic.news", 117 record, 118 }); 119 120 return { 121 rkey: res.data.uri.split("/").pop(), 122 uri: res.data.uri, 123 did: user.atproto.did, 124 }; 125} 126 127// --------------------------------------------------------------------------- 128// DB helper 129// --------------------------------------------------------------------------- 130 131async function withDb(fn) { 132 if (!MONGODB_URI) { 133 console.error("MONGODB_CONNECTION_STRING not set."); 134 process.exit(1); 135 } 136 const client = new MongoClient(MONGODB_URI); 137 try { 138 await client.connect(); 139 const db = client.db(MONGODB_NAME); 140 await fn(db); 141 } finally { 142 await client.close(); 143 } 144} 145 146// --------------------------------------------------------------------------- 147// Commands 148// --------------------------------------------------------------------------- 149 150async function commandCommits(args) { 151 const gitArgs = ["git", "log", "--oneline", "--no-decorate"]; 152 153 if (args.since) { 154 gitArgs.push(`--since="${args.since}"`); 155 } else if (args.from) { 156 gitArgs.push(args.to ? `${args.from}..${args.to}` : `${args.from}..HEAD`); 157 } else { 158 gitArgs.push("-n", `${args.count || 20}`); 159 } 160 161 gitArgs.push('--format="%h %s"'); 162 163 const log = execSync(gitArgs.join(" "), { encoding: "utf8" }).trim(); 164 if (!log) { 165 console.log("No commits found."); 166 return; 167 } 168 169 const lines = log.split("\n"); 170 console.log(`\n${lines.length} recent commit(s):\n`); 171 for (const line of lines) { 172 console.log(` ${line}`); 173 } 174 console.log( 175 "\nUse these to write your prose update, then post with:\n ac-news post \"Title\" \"Your prose summary...\"\n", 176 ); 177} 178 179async function commandPost(args) { 180 const title = args._[1]; 181 if (!title) { 182 console.error( 183 'Usage: ac-news post "Title" "Body"\n' + 184 ' ac-news post "Title" --file update.md\n' + 185 ' ac-news post "Title" --editor\n' + 186 ' echo "body" | ac-news post "Title" --stdin', 187 ); 188 process.exit(1); 189 } 190 191 let body; 192 193 if (args.file) { 194 body = readFileSync(args.file, "utf8").trim(); 195 } else if (args.stdin) { 196 body = readFileSync("/dev/stdin", "utf8").trim(); 197 } else if (args.editor) { 198 const editor = process.env.EDITOR || "vi"; 199 const tmpFile = join(tmpdir(), `ac-news-${Date.now()}.md`); 200 writeFileSync(tmpFile, ""); 201 try { 202 execSync(`${editor} ${tmpFile}`, { stdio: "inherit" }); 203 body = readFileSync(tmpFile, "utf8").trim(); 204 } finally { 205 try { 206 unlinkSync(tmpFile); 207 } catch {} 208 } 209 if (!body) { 210 console.log("Empty body — cancelled."); 211 return; 212 } 213 } else { 214 body = args._[2] || ""; 215 } 216 217 if (!ADMIN_SUB) { 218 console.error("ADMIN_SUB not set."); 219 process.exit(1); 220 } 221 222 const dryRun = !!args["dry-run"]; 223 224 console.log(`\n Title: ${title}`); 225 console.log(` Body: ${body.length} chars\n`); 226 console.log(body); 227 228 if (dryRun) { 229 console.log("\n--dry-run: not posting."); 230 return; 231 } 232 233 await withDb(async (db) => { 234 const posts = db.collection("news-posts"); 235 const votes = db.collection("news-votes"); 236 237 const code = await uniqueCode(posts); 238 const now = new Date(); 239 240 const doc = { 241 code, 242 title, 243 url: "", 244 text: body, 245 user: ADMIN_SUB, 246 when: now, 247 updated: now, 248 score: 1, 249 commentCount: 0, 250 status: "live", 251 }; 252 253 await posts.insertOne(doc); 254 await votes.insertOne({ 255 itemType: "post", 256 itemId: code, 257 user: ADMIN_SUB, 258 when: now, 259 }); 260 261 console.log(`\nPosted: https://news.aesthetic.computer/${code}`); 262 263 // ATProto sync 264 try { 265 const atproto = await syncToAtproto( 266 db, 267 ADMIN_SUB, 268 { headline: title, body, when: now }, 269 doc._id?.toString(), 270 ); 271 if (atproto) { 272 await posts.updateOne({ code }, { $set: { atproto } }); 273 console.log(` ATProto: ${atproto.uri}`); 274 } 275 } catch (e) { 276 console.log(` ATProto sync failed: ${e.message}`); 277 } 278 }); 279} 280 281async function commandList(args) { 282 const limit = parseInt(args.limit) || 10; 283 284 await withDb(async (db) => { 285 const posts = db.collection("news-posts"); 286 const items = await posts 287 .find({ status: "live" }) 288 .sort({ when: -1 }) 289 .limit(limit) 290 .toArray(); 291 292 if (items.length === 0) { 293 console.log("No posts."); 294 return; 295 } 296 297 console.log(`\n${items.length} recent post(s):\n`); 298 for (const item of items) { 299 const date = item.when.toISOString().slice(0, 10); 300 const comments = item.commentCount || 0; 301 const titlePreview = 302 item.title.length > 60 ? item.title.slice(0, 60) + "..." : item.title; 303 console.log(` ${item.code} ${date} ${titlePreview} (${comments}c)`); 304 } 305 console.log(); 306 }); 307} 308 309async function commandEdit(args) { 310 const code = args._[1]; 311 if (!code) { 312 console.error( 313 'Usage: ac-news edit <code> [options]\n' + 314 ' ac-news edit ncd2 --title "New Title"\n' + 315 ' ac-news edit ncd2 --body "New body text"\n' + 316 ' ac-news edit ncd2 --editor # open $EDITOR with current body\n' + 317 ' ac-news edit ncd2 --url "https://..."\n' + 318 ' ac-news edit ncd2 --replace "old text" --with "new text"', 319 ); 320 process.exit(1); 321 } 322 323 if (!ADMIN_SUB) { 324 console.error("ADMIN_SUB not set."); 325 process.exit(1); 326 } 327 328 const dryRun = !!args["dry-run"]; 329 330 await withDb(async (db) => { 331 const posts = db.collection("news-posts"); 332 const post = await posts.findOne({ code }); 333 334 if (!post) { 335 console.error(`Post not found: ${code}`); 336 process.exit(1); 337 } 338 339 console.log(`\nEditing: "${post.title}" (${code})`); 340 341 const updates = {}; 342 343 // --title "New title" 344 if (args.title) { 345 updates.title = args.title; 346 console.log(` title → "${args.title}"`); 347 } 348 349 // --url "https://..." 350 if (args.url !== undefined) { 351 updates.url = args.url; 352 console.log(` url → "${args.url}"`); 353 } 354 355 // --replace "old" --with "new" (find-and-replace in body text) 356 if (args.replace && args.with !== undefined) { 357 const oldText = post.text || ""; 358 const count = oldText.split(args.replace).length - 1; 359 if (count === 0) { 360 console.error(` Replace string not found in body: "${args.replace}"`); 361 process.exit(1); 362 } 363 updates.text = oldText.replaceAll(args.replace, args.with); 364 console.log(` body: replaced ${count} occurrence(s) of "${args.replace}" → "${args.with}"`); 365 } 366 367 // --body "Full new body" 368 if (args.body) { 369 updates.text = args.body; 370 console.log(` body → ${args.body.length} chars`); 371 } 372 373 // --editor: open current body in $EDITOR 374 if (args.editor) { 375 const editor = process.env.EDITOR || "vi"; 376 const tmpFile = join(tmpdir(), `ac-news-edit-${Date.now()}.md`); 377 writeFileSync(tmpFile, post.text || ""); 378 try { 379 execSync(`${editor} ${tmpFile}`, { stdio: "inherit" }); 380 const newBody = readFileSync(tmpFile, "utf8").trim(); 381 if (newBody === (post.text || "").trim()) { 382 console.log(" No changes made."); 383 return; 384 } 385 updates.text = newBody; 386 console.log(` body → ${newBody.length} chars (via editor)`); 387 } finally { 388 try { unlinkSync(tmpFile); } catch {} 389 } 390 } 391 392 if (Object.keys(updates).length === 0) { 393 console.log(" Nothing to update. Use --title, --body, --url, --replace, or --editor."); 394 return; 395 } 396 397 updates.updated = new Date(); 398 399 if (dryRun) { 400 console.log("\n--dry-run: not saving."); 401 if (updates.text) { 402 console.log("\nNew body preview:\n"); 403 console.log(updates.text); 404 } 405 return; 406 } 407 408 await posts.updateOne({ code }, { $set: updates }); 409 console.log(`\nSaved: https://news.aesthetic.computer/${code}`); 410 }); 411} 412 413async function commandDelete(args) { 414 const code = args._[1]; 415 if (!code) { 416 console.error("Usage: ac-news delete <code>"); 417 process.exit(1); 418 } 419 420 if (!ADMIN_SUB) { 421 console.error("ADMIN_SUB not set."); 422 process.exit(1); 423 } 424 425 await withDb(async (db) => { 426 const posts = db.collection("news-posts"); 427 const post = await posts.findOne({ code }); 428 429 if (!post) { 430 console.error(`Post not found: ${code}`); 431 process.exit(1); 432 } 433 434 console.log(`Deleting: "${post.title}" (${code})`); 435 await posts.updateOne({ code }, { $set: { status: "dead" } }); 436 console.log("Deleted (marked dead)."); 437 }); 438} 439 440// --------------------------------------------------------------------------- 441// Bluesky ingest (external headlines from trusted sources) 442// --------------------------------------------------------------------------- 443 444async function commandPullBluesky(args) { 445 const actor = args._[1]; 446 const limit = parseInt(args.limit) || 30; 447 448 await withDb(async (db) => { 449 const database = { db }; 450 const runOne = !!actor; 451 452 if (runOne) { 453 console.log(`\n Pulling Bluesky feed: ${actor} (limit ${limit})\n`); 454 const result = await ingestFromActor(database, actor, { 455 limit, 456 log: (line) => console.log(` ${line}`), 457 }); 458 console.log( 459 `\n ${actor}: +${result.inserted} inserted, ${result.skipped} skipped, ${result.errors.length} errors`, 460 ); 461 if (result.errors.length) { 462 for (const err of result.errors) { 463 console.log(` ! ${err.uri || ""} ${err.message}`); 464 } 465 } 466 return; 467 } 468 469 const sources = getConfiguredSources(); 470 console.log(`\n Pulling ${sources.length} Bluesky source(s):\n`); 471 for (const src of sources) console.log(` - ${src}`); 472 console.log(); 473 474 const results = await ingestAll(database, { 475 limit, 476 log: (line) => console.log(` ${line}`), 477 }); 478 for (const r of results) { 479 console.log( 480 `\n ${r.actor}: +${r.inserted} inserted, ${r.skipped} skipped, ${r.errors.length} errors`, 481 ); 482 if (r.errors.length) { 483 for (const err of r.errors) { 484 console.log(` ! ${err.uri || ""} ${err.message}`); 485 } 486 } 487 } 488 }); 489} 490 491// --------------------------------------------------------------------------- 492// Screenshot (via oven) 493// --------------------------------------------------------------------------- 494 495const OVEN_URL = process.env.OVEN_URL || "https://oven.aesthetic.computer"; 496 497async function commandScreenshot(args) { 498 const piece = args._[1]; 499 if (!piece) { 500 console.error( 501 "Usage: ac-news screenshot <piece>\n" + 502 " ac-news screenshot notepat\n" + 503 " ac-news screenshot notepat --force\n" + 504 " ac-news screenshot @jeffrey/my-piece", 505 ); 506 process.exit(1); 507 } 508 509 const force = !!args.force; 510 const url = `${OVEN_URL}/news-screenshot/${encodeURIComponent(piece)}.png?json=true${force ? "&force=true" : ""}`; 511 512 console.log(`\n Capturing ${piece}...`); 513 514 const res = await fetch(url); 515 if (!res.ok) { 516 const body = await res.json().catch(() => ({})); 517 console.error(` Oven error (${res.status}): ${body.error || res.statusText}`); 518 process.exit(1); 519 } 520 521 const data = await res.json(); 522 const mdImage = `![${piece}](${data.url})`; 523 524 console.log(` ${data.cached ? "Cached" : "Captured"}: ${data.width}×${data.height}`); 525 console.log(` URL: ${data.url}`); 526 console.log(`\n Markdown (paste into post body):\n`); 527 console.log(` ${mdImage}\n`); 528} 529 530// --------------------------------------------------------------------------- 531// Help 532// --------------------------------------------------------------------------- 533 534function printHelp() { 535 console.log(`ac-news — Post prose updates to news.aesthetic.computer 536 537Usage: ac-news <command> [options] 538 539Compose: 540 commits [--count N] [--since "..."] Show recent commits for reference 541 post "Title" "Body" Post a prose update 542 post "Title" --file path.md Post from a markdown file 543 post "Title" --editor Open $EDITOR to write the body 544 post "Title" --stdin Read body from stdin 545 post ... --dry-run Preview without posting 546 547Media: 548 screenshot <piece> Capture a piece via oven (1200×675 PNG) 549 screenshot <piece> --force Force-regenerate (skip cache) 550 551Manage: 552 list [--limit N] List recent posts 553 edit <code> --title "New Title" Edit post title 554 edit <code> --body "New body" Replace post body 555 edit <code> --editor Edit body in $EDITOR 556 edit <code> --url "https://..." Change post URL 557 edit <code> --replace "old" --with "new" Find & replace in body 558 edit ... --dry-run Preview without saving 559 delete <code> Delete a post (admin) 560 561External (trusted third-party sources): 562 pull-bluesky Pull all configured Bluesky sources 563 pull-bluesky <handle-or-did> Pull a specific Bluesky account 564 pull-bluesky ... --limit 30 Override per-source post limit 565 566Examples: 567 ac-news commits --since "1 week ago" 568 ac-news post "Dev Update" "The native OS build system got a major overhaul..." 569 ac-news post "Weekly Update" --file updates/2026-03-24.md 570 ac-news post "What's New" --editor 571 ac-news edit ncd2 --replace "https://aesthetic.computer)" --with "https://aesthetic.computer/chat)" 572 ac-news screenshot notepat 573 ac-news list 574 ac-news pull-bluesky # pull all trusted Bluesky sources 575 ac-news pull-bluesky artistnewsnetwork.bsky.social 576`); 577} 578 579// --------------------------------------------------------------------------- 580// Main 581// --------------------------------------------------------------------------- 582 583const COMMANDS = { 584 commits: commandCommits, 585 post: commandPost, 586 list: commandList, 587 edit: commandEdit, 588 delete: commandDelete, 589 screenshot: commandScreenshot, 590 "pull-bluesky": commandPullBluesky, 591}; 592 593async function main() { 594 const args = parseArgs(process.argv.slice(2)); 595 const command = args._[0] || "help"; 596 597 if (command === "help" || command === "--help" || command === "-h") { 598 printHelp(); 599 return; 600 } 601 602 const handler = COMMANDS[command]; 603 if (!handler) { 604 console.error(`Unknown command: ${command}\n`); 605 printHelp(); 606 process.exitCode = 1; 607 return; 608 } 609 610 await handler(args); 611} 612 613main().catch((err) => { 614 console.error(`ac-news: ${err.message}`); 615 process.exit(1); 616});