Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

feat: ac-news CLI for prose commit summaries on news.aesthetic.computer

- news-cli.mjs: post, list, delete, commits commands with --dry-run, --editor, --file, --stdin
- NEWS-SCORE.md: guidelines for writing devlog-style news updates
- npm scripts: news, news:commits, news:post, news:list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+461
+64
at/NEWS-SCORE.md
··· 1 + # News Score 2 + 3 + Guidelines for writing prose commit summaries on [news.aesthetic.computer](https://news.aesthetic.computer). 4 + 5 + --- 6 + 7 + ## Title 8 + 9 + Always use: **Commits From {date} to {date}** 10 + 11 + Use the dates of the oldest and newest commits in the batch. 12 + 13 + ## Voice 14 + 15 + Write like Jeffrey talking to people who follow the project. Tell the story of what you worked on — the arc of a session or a day. Open with a greeting or a note about what the focus was. This is a devlog, not release notes. 16 + 17 + **News vs. Change Blog:** News tells *what happened* — the story of the work. Deeper *why* reasoning (architectural decisions, design philosophy, project direction) belongs on [change.pckt.blog](https://change.pckt.blog) via `publish-changelog.mjs`. 18 + 19 + ## Structure 20 + 21 + - **Tell the story.** "Spent the morning getting CL builds to stand on their own..." not "The build pipeline now separates C and CL paths." The commits are evidence — the post is the narrative. 22 + - **Group by session, not by system.** What did you work on first? What did that lead to? What was the last thing you touched? 23 + - **Name the right place.** Changes live in specific sub-sites and tools: 24 + - [aesthetic.computer](https://aesthetic.computer) — the main platform, pieces, prompt 25 + - [kidlisp.com](https://kidlisp.com) — KidLisp language site; Shop/Buy tab is here 26 + - [news.aesthetic.computer](https://news.aesthetic.computer) — news/updates 27 + - [prompt.ac](https://prompt.ac) — the AC prompt (also `prompt.mjs`) 28 + - [blank](https://aesthetic.computer/blank) — the AC laptop product 29 + - VS Code extension (`vscode-extension/`) — OTA status bar, build triggers 30 + - oven (`oven/`) — the native build server 31 + - session server (`session-server/`) — multiplayer, chat 32 + - AT Protocol / PDS (`at/`) — decentralized identity and data 33 + - **Don't flatten everything into "the web side."** If a change is on kidlisp.com, say kidlisp.com. If it's in the VS Code extension, say that. 34 + 35 + ## Links 36 + 37 + - **Every notable change should link to its commit** using the full GitHub URL: `https://github.com/digitpain/aesthetic-computer/commit/{hash}` 38 + - **Link product names to their AC URLs** — `[blank](https://aesthetic.computer/blank)`, `[prompt](https://aesthetic.computer/prompt)`, etc. 39 + - **Link external tools/libraries** when relevant — QuickJS, Claude Code, etc. 40 + - Use markdown: `**bold**` for emphasis, `` `code` `` for technical names, `[text](url)` for links. 41 + 42 + ## Tone 43 + 44 + - Personal, conversational. You're telling people what you've been up to. 45 + - OK to mention what's in progress or what something is a step toward. 46 + - Skip trivial version bumps and typo fixes unless they're part of the story. 47 + - Save the deeper "why" for the [change blog](https://change.pckt.blog). 48 + 49 + ## Workflow 50 + 51 + ```bash 52 + # 1. See what happened 53 + ac-news commits --since "1 week ago" 54 + 55 + # 2. Read the commits, group them mentally, write the prose 56 + 57 + # 3. Dry run 58 + ac-news post "Commits From March 17 to March 24" "Your prose..." --dry-run 59 + 60 + # 4. Post 61 + ac-news post "Commits From March 17 to March 24" "Your prose..." 62 + ``` 63 + 64 + Or use `--editor` to write in your editor, or `--file` to post from a markdown file.
+393
at/news-cli.mjs
··· 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 delete <code> # delete a post (admin) 15 + */ 16 + 17 + import { MongoClient } from "mongodb"; 18 + import { AtpAgent } from "@atproto/api"; 19 + import { config } from "dotenv"; 20 + import { execSync } from "child_process"; 21 + import { randomBytes } from "crypto"; 22 + import { readFileSync, writeFileSync, unlinkSync } from "fs"; 23 + import { tmpdir } from "os"; 24 + import { join } from "path"; 25 + 26 + config({ 27 + path: new URL("../.devcontainer/envs/devcontainer.env", import.meta.url), 28 + }); 29 + config({ path: new URL(".env", import.meta.url) }); 30 + 31 + const MONGODB_URI = process.env.MONGODB_CONNECTION_STRING; 32 + const MONGODB_NAME = process.env.MONGODB_NAME || "aesthetic"; 33 + const ADMIN_SUB = process.env.ADMIN_SUB; 34 + const PDS_URL = process.env.PDS_URL || "https://at.aesthetic.computer"; 35 + 36 + // --------------------------------------------------------------------------- 37 + // Args 38 + // --------------------------------------------------------------------------- 39 + 40 + function parseArgs(argv) { 41 + const out = { _: [] }; 42 + for (let i = 0; i < argv.length; i++) { 43 + const t = argv[i]; 44 + if (!t.startsWith("--")) { 45 + out._.push(t); 46 + continue; 47 + } 48 + const key = t.slice(2); 49 + const next = argv[i + 1]; 50 + if (next && !next.startsWith("--")) { 51 + out[key] = next; 52 + i++; 53 + } else out[key] = true; 54 + } 55 + return out; 56 + } 57 + 58 + // --------------------------------------------------------------------------- 59 + // Short code (same as publish-commits.mjs) 60 + // --------------------------------------------------------------------------- 61 + 62 + const ALPHABET = "bcdfghjklmnpqrstvwxyzaeiou23456789"; 63 + 64 + function randomCode(len = 3) { 65 + const bytes = randomBytes(len); 66 + return Array.from(bytes) 67 + .map((b) => ALPHABET[b % ALPHABET.length]) 68 + .join(""); 69 + } 70 + 71 + async function uniqueCode(collection) { 72 + for (let i = 0; i < 100; i++) { 73 + const code = `n${randomCode()}`; 74 + const exists = await collection.findOne({ code }); 75 + if (!exists) return code; 76 + } 77 + throw new Error("Could not generate unique code after 100 attempts"); 78 + } 79 + 80 + // --------------------------------------------------------------------------- 81 + // ATProto sync 82 + // --------------------------------------------------------------------------- 83 + 84 + async function syncToAtproto(db, sub, newsData, refId) { 85 + const users = db.collection("users"); 86 + const user = await users.findOne({ _id: sub }); 87 + 88 + if (!user?.atproto?.did || !user?.atproto?.password) { 89 + console.log(" No ATProto account — skipping PDS sync."); 90 + return null; 91 + } 92 + 93 + const agent = new AtpAgent({ service: PDS_URL }); 94 + await agent.login({ 95 + identifier: user.atproto.did, 96 + password: user.atproto.password, 97 + }); 98 + 99 + const record = { 100 + $type: "computer.aesthetic.news", 101 + headline: newsData.headline, 102 + when: newsData.when.toISOString(), 103 + ref: refId, 104 + }; 105 + if (newsData.body) record.body = newsData.body; 106 + 107 + const res = await agent.com.atproto.repo.createRecord({ 108 + repo: user.atproto.did, 109 + collection: "computer.aesthetic.news", 110 + record, 111 + }); 112 + 113 + return { 114 + rkey: res.data.uri.split("/").pop(), 115 + uri: res.data.uri, 116 + did: user.atproto.did, 117 + }; 118 + } 119 + 120 + // --------------------------------------------------------------------------- 121 + // DB helper 122 + // --------------------------------------------------------------------------- 123 + 124 + async function withDb(fn) { 125 + if (!MONGODB_URI) { 126 + console.error("MONGODB_CONNECTION_STRING not set."); 127 + process.exit(1); 128 + } 129 + const client = new MongoClient(MONGODB_URI); 130 + try { 131 + await client.connect(); 132 + const db = client.db(MONGODB_NAME); 133 + await fn(db); 134 + } finally { 135 + await client.close(); 136 + } 137 + } 138 + 139 + // --------------------------------------------------------------------------- 140 + // Commands 141 + // --------------------------------------------------------------------------- 142 + 143 + async function commandCommits(args) { 144 + const gitArgs = ["git", "log", "--oneline", "--no-decorate"]; 145 + 146 + if (args.since) { 147 + gitArgs.push(`--since="${args.since}"`); 148 + } else if (args.from) { 149 + gitArgs.push(args.to ? `${args.from}..${args.to}` : `${args.from}..HEAD`); 150 + } else { 151 + gitArgs.push("-n", `${args.count || 20}`); 152 + } 153 + 154 + gitArgs.push('--format="%h %s"'); 155 + 156 + const log = execSync(gitArgs.join(" "), { encoding: "utf8" }).trim(); 157 + if (!log) { 158 + console.log("No commits found."); 159 + return; 160 + } 161 + 162 + const lines = log.split("\n"); 163 + console.log(`\n${lines.length} recent commit(s):\n`); 164 + for (const line of lines) { 165 + console.log(` ${line}`); 166 + } 167 + console.log( 168 + "\nUse these to write your prose update, then post with:\n ac-news post \"Title\" \"Your prose summary...\"\n", 169 + ); 170 + } 171 + 172 + async function commandPost(args) { 173 + const title = args._[1]; 174 + if (!title) { 175 + console.error( 176 + 'Usage: ac-news post "Title" "Body"\n' + 177 + ' ac-news post "Title" --file update.md\n' + 178 + ' ac-news post "Title" --editor\n' + 179 + ' echo "body" | ac-news post "Title" --stdin', 180 + ); 181 + process.exit(1); 182 + } 183 + 184 + let body; 185 + 186 + if (args.file) { 187 + body = readFileSync(args.file, "utf8").trim(); 188 + } else if (args.stdin) { 189 + body = readFileSync("/dev/stdin", "utf8").trim(); 190 + } else if (args.editor) { 191 + const editor = process.env.EDITOR || "vi"; 192 + const tmpFile = join(tmpdir(), `ac-news-${Date.now()}.md`); 193 + writeFileSync(tmpFile, ""); 194 + try { 195 + execSync(`${editor} ${tmpFile}`, { stdio: "inherit" }); 196 + body = readFileSync(tmpFile, "utf8").trim(); 197 + } finally { 198 + try { 199 + unlinkSync(tmpFile); 200 + } catch {} 201 + } 202 + if (!body) { 203 + console.log("Empty body — cancelled."); 204 + return; 205 + } 206 + } else { 207 + body = args._[2] || ""; 208 + } 209 + 210 + if (!ADMIN_SUB) { 211 + console.error("ADMIN_SUB not set."); 212 + process.exit(1); 213 + } 214 + 215 + const dryRun = !!args["dry-run"]; 216 + 217 + console.log(`\n Title: ${title}`); 218 + console.log(` Body: ${body.length} chars\n`); 219 + console.log(body); 220 + 221 + if (dryRun) { 222 + console.log("\n--dry-run: not posting."); 223 + return; 224 + } 225 + 226 + await withDb(async (db) => { 227 + const posts = db.collection("news-posts"); 228 + const votes = db.collection("news-votes"); 229 + 230 + const code = await uniqueCode(posts); 231 + const now = new Date(); 232 + 233 + const doc = { 234 + code, 235 + title, 236 + url: "", 237 + text: body, 238 + user: ADMIN_SUB, 239 + when: now, 240 + updated: now, 241 + score: 1, 242 + commentCount: 0, 243 + status: "live", 244 + }; 245 + 246 + await posts.insertOne(doc); 247 + await votes.insertOne({ 248 + itemType: "post", 249 + itemId: code, 250 + user: ADMIN_SUB, 251 + when: now, 252 + }); 253 + 254 + console.log(`\nPosted: https://news.aesthetic.computer/${code}`); 255 + 256 + // ATProto sync 257 + try { 258 + const atproto = await syncToAtproto( 259 + db, 260 + ADMIN_SUB, 261 + { headline: title, body, when: now }, 262 + doc._id?.toString(), 263 + ); 264 + if (atproto) { 265 + await posts.updateOne({ code }, { $set: { atproto } }); 266 + console.log(` ATProto: ${atproto.uri}`); 267 + } 268 + } catch (e) { 269 + console.log(` ATProto sync failed: ${e.message}`); 270 + } 271 + }); 272 + } 273 + 274 + async function commandList(args) { 275 + const limit = parseInt(args.limit) || 10; 276 + 277 + await withDb(async (db) => { 278 + const posts = db.collection("news-posts"); 279 + const items = await posts 280 + .find({ status: "live" }) 281 + .sort({ when: -1 }) 282 + .limit(limit) 283 + .toArray(); 284 + 285 + if (items.length === 0) { 286 + console.log("No posts."); 287 + return; 288 + } 289 + 290 + console.log(`\n${items.length} recent post(s):\n`); 291 + for (const item of items) { 292 + const date = item.when.toISOString().slice(0, 10); 293 + const comments = item.commentCount || 0; 294 + const titlePreview = 295 + item.title.length > 60 ? item.title.slice(0, 60) + "..." : item.title; 296 + console.log(` ${item.code} ${date} ${titlePreview} (${comments}c)`); 297 + } 298 + console.log(); 299 + }); 300 + } 301 + 302 + async function commandDelete(args) { 303 + const code = args._[1]; 304 + if (!code) { 305 + console.error("Usage: ac-news delete <code>"); 306 + process.exit(1); 307 + } 308 + 309 + if (!ADMIN_SUB) { 310 + console.error("ADMIN_SUB not set."); 311 + process.exit(1); 312 + } 313 + 314 + await withDb(async (db) => { 315 + const posts = db.collection("news-posts"); 316 + const post = await posts.findOne({ code }); 317 + 318 + if (!post) { 319 + console.error(`Post not found: ${code}`); 320 + process.exit(1); 321 + } 322 + 323 + console.log(`Deleting: "${post.title}" (${code})`); 324 + await posts.updateOne({ code }, { $set: { status: "dead" } }); 325 + console.log("Deleted (marked dead)."); 326 + }); 327 + } 328 + 329 + // --------------------------------------------------------------------------- 330 + // Help 331 + // --------------------------------------------------------------------------- 332 + 333 + function printHelp() { 334 + console.log(`ac-news — Post prose updates to news.aesthetic.computer 335 + 336 + Usage: ac-news <command> [options] 337 + 338 + Compose: 339 + commits [--count N] [--since "..."] Show recent commits for reference 340 + post "Title" "Body" Post a prose update 341 + post "Title" --file path.md Post from a markdown file 342 + post "Title" --editor Open $EDITOR to write the body 343 + post "Title" --stdin Read body from stdin 344 + post ... --dry-run Preview without posting 345 + 346 + Manage: 347 + list [--limit N] List recent posts 348 + delete <code> Delete a post (admin) 349 + 350 + Examples: 351 + ac-news commits --since "1 week ago" 352 + ac-news post "Dev Update" "The native OS build system got a major overhaul..." 353 + ac-news post "Weekly Update" --file updates/2026-03-24.md 354 + ac-news post "What's New" --editor 355 + ac-news list 356 + `); 357 + } 358 + 359 + // --------------------------------------------------------------------------- 360 + // Main 361 + // --------------------------------------------------------------------------- 362 + 363 + const COMMANDS = { 364 + commits: commandCommits, 365 + post: commandPost, 366 + list: commandList, 367 + delete: commandDelete, 368 + }; 369 + 370 + async function main() { 371 + const args = parseArgs(process.argv.slice(2)); 372 + const command = args._[0] || "help"; 373 + 374 + if (command === "help" || command === "--help" || command === "-h") { 375 + printHelp(); 376 + return; 377 + } 378 + 379 + const handler = COMMANDS[command]; 380 + if (!handler) { 381 + console.error(`Unknown command: ${command}\n`); 382 + printHelp(); 383 + process.exitCode = 1; 384 + return; 385 + } 386 + 387 + await handler(args); 388 + } 389 + 390 + main().catch((err) => { 391 + console.error(`ac-news: ${err.message}`); 392 + process.exit(1); 393 + });
+4
package.json
··· 53 53 "at:sync": "node at/cli.mjs sync:status", 54 54 "at:standard:sync": "node at/scripts/atproto/backfill-standard-site-documents.mjs", 55 55 "at:standard:dry": "node at/scripts/atproto/backfill-standard-site-documents.mjs --dry-run", 56 + "news": "node at/news-cli.mjs", 57 + "news:commits": "node at/news-cli.mjs commits", 58 + "news:post": "node at/news-cli.mjs post", 59 + "news:list": "node at/news-cli.mjs list", 56 60 "profile:secret:rotate": "node utilities/rotate-profile-stream-secret.mjs", 57 61 "ac": "./aesthetic", 58 62 "admin:udp": "ssh root@157.245.134.225",