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