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