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 { 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 = ``;
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});