From d27ef0021ee13c6c22ab350910ac1f4ae450c788 Mon Sep 17 00:00:00 2001 From: Jeffrey Alan Scudder Date: Tue, 24 Mar 2026 13:09:19 -0700 Subject: [PATCH] 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) --- at/NEWS-SCORE.md | 64 ++++++++ at/news-cli.mjs | 393 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 4 + 3 files changed, 461 insertions(+) create mode 100644 at/NEWS-SCORE.md create mode 100644 at/news-cli.mjs diff --git a/at/NEWS-SCORE.md b/at/NEWS-SCORE.md new file mode 100644 index 0000000000..80603e847d --- /dev/null +++ b/at/NEWS-SCORE.md @@ -0,0 +1,64 @@ +# News Score + +Guidelines for writing prose commit summaries on [news.aesthetic.computer](https://news.aesthetic.computer). + +--- + +## Title + +Always use: **Commits From {date} to {date}** + +Use the dates of the oldest and newest commits in the batch. + +## Voice + +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. + +**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`. + +## Structure + +- **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. +- **Group by session, not by system.** What did you work on first? What did that lead to? What was the last thing you touched? +- **Name the right place.** Changes live in specific sub-sites and tools: + - [aesthetic.computer](https://aesthetic.computer) — the main platform, pieces, prompt + - [kidlisp.com](https://kidlisp.com) — KidLisp language site; Shop/Buy tab is here + - [news.aesthetic.computer](https://news.aesthetic.computer) — news/updates + - [prompt.ac](https://prompt.ac) — the AC prompt (also `prompt.mjs`) + - [blank](https://aesthetic.computer/blank) — the AC laptop product + - VS Code extension (`vscode-extension/`) — OTA status bar, build triggers + - oven (`oven/`) — the native build server + - session server (`session-server/`) — multiplayer, chat + - AT Protocol / PDS (`at/`) — decentralized identity and data +- **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. + +## Links + +- **Every notable change should link to its commit** using the full GitHub URL: `https://github.com/digitpain/aesthetic-computer/commit/{hash}` +- **Link product names to their AC URLs** — `[blank](https://aesthetic.computer/blank)`, `[prompt](https://aesthetic.computer/prompt)`, etc. +- **Link external tools/libraries** when relevant — QuickJS, Claude Code, etc. +- Use markdown: `**bold**` for emphasis, `` `code` `` for technical names, `[text](url)` for links. + +## Tone + +- Personal, conversational. You're telling people what you've been up to. +- OK to mention what's in progress or what something is a step toward. +- Skip trivial version bumps and typo fixes unless they're part of the story. +- Save the deeper "why" for the [change blog](https://change.pckt.blog). + +## Workflow + +```bash +# 1. See what happened +ac-news commits --since "1 week ago" + +# 2. Read the commits, group them mentally, write the prose + +# 3. Dry run +ac-news post "Commits From March 17 to March 24" "Your prose..." --dry-run + +# 4. Post +ac-news post "Commits From March 17 to March 24" "Your prose..." +``` + +Or use `--editor` to write in your editor, or `--file` to post from a markdown file. diff --git a/at/news-cli.mjs b/at/news-cli.mjs new file mode 100644 index 0000000000..3de33ede9e --- /dev/null +++ b/at/news-cli.mjs @@ -0,0 +1,393 @@ +#!/usr/bin/env node + +/** + * ac-news — CLI for posting prose updates to news.aesthetic.computer + * + * Usage: + * ac-news post "Title" "Body prose text" + * ac-news post "Title" --file update.md + * echo "body" | ac-news post "Title" --stdin + * ac-news post "Title" --editor # opens $EDITOR + * ac-news commits # show recent commits for reference + * ac-news commits --since "1 week ago" + * ac-news list # list recent posts + * ac-news delete # delete a post (admin) + */ + +import { MongoClient } from "mongodb"; +import { AtpAgent } from "@atproto/api"; +import { config } from "dotenv"; +import { execSync } from "child_process"; +import { randomBytes } from "crypto"; +import { readFileSync, writeFileSync, unlinkSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +config({ + path: new URL("../.devcontainer/envs/devcontainer.env", import.meta.url), +}); +config({ path: new URL(".env", import.meta.url) }); + +const MONGODB_URI = process.env.MONGODB_CONNECTION_STRING; +const MONGODB_NAME = process.env.MONGODB_NAME || "aesthetic"; +const ADMIN_SUB = process.env.ADMIN_SUB; +const PDS_URL = process.env.PDS_URL || "https://at.aesthetic.computer"; + +// --------------------------------------------------------------------------- +// Args +// --------------------------------------------------------------------------- + +function parseArgs(argv) { + const out = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const t = argv[i]; + if (!t.startsWith("--")) { + out._.push(t); + continue; + } + const key = t.slice(2); + const next = argv[i + 1]; + if (next && !next.startsWith("--")) { + out[key] = next; + i++; + } else out[key] = true; + } + return out; +} + +// --------------------------------------------------------------------------- +// Short code (same as publish-commits.mjs) +// --------------------------------------------------------------------------- + +const ALPHABET = "bcdfghjklmnpqrstvwxyzaeiou23456789"; + +function randomCode(len = 3) { + const bytes = randomBytes(len); + return Array.from(bytes) + .map((b) => ALPHABET[b % ALPHABET.length]) + .join(""); +} + +async function uniqueCode(collection) { + for (let i = 0; i < 100; i++) { + const code = `n${randomCode()}`; + const exists = await collection.findOne({ code }); + if (!exists) return code; + } + throw new Error("Could not generate unique code after 100 attempts"); +} + +// --------------------------------------------------------------------------- +// ATProto sync +// --------------------------------------------------------------------------- + +async function syncToAtproto(db, sub, newsData, refId) { + const users = db.collection("users"); + const user = await users.findOne({ _id: sub }); + + if (!user?.atproto?.did || !user?.atproto?.password) { + console.log(" No ATProto account — skipping PDS sync."); + return null; + } + + const agent = new AtpAgent({ service: PDS_URL }); + await agent.login({ + identifier: user.atproto.did, + password: user.atproto.password, + }); + + const record = { + $type: "computer.aesthetic.news", + headline: newsData.headline, + when: newsData.when.toISOString(), + ref: refId, + }; + if (newsData.body) record.body = newsData.body; + + const res = await agent.com.atproto.repo.createRecord({ + repo: user.atproto.did, + collection: "computer.aesthetic.news", + record, + }); + + return { + rkey: res.data.uri.split("/").pop(), + uri: res.data.uri, + did: user.atproto.did, + }; +} + +// --------------------------------------------------------------------------- +// DB helper +// --------------------------------------------------------------------------- + +async function withDb(fn) { + if (!MONGODB_URI) { + console.error("MONGODB_CONNECTION_STRING not set."); + process.exit(1); + } + const client = new MongoClient(MONGODB_URI); + try { + await client.connect(); + const db = client.db(MONGODB_NAME); + await fn(db); + } finally { + await client.close(); + } +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +async function commandCommits(args) { + const gitArgs = ["git", "log", "--oneline", "--no-decorate"]; + + if (args.since) { + gitArgs.push(`--since="${args.since}"`); + } else if (args.from) { + gitArgs.push(args.to ? `${args.from}..${args.to}` : `${args.from}..HEAD`); + } else { + gitArgs.push("-n", `${args.count || 20}`); + } + + gitArgs.push('--format="%h %s"'); + + const log = execSync(gitArgs.join(" "), { encoding: "utf8" }).trim(); + if (!log) { + console.log("No commits found."); + return; + } + + const lines = log.split("\n"); + console.log(`\n${lines.length} recent commit(s):\n`); + for (const line of lines) { + console.log(` ${line}`); + } + console.log( + "\nUse these to write your prose update, then post with:\n ac-news post \"Title\" \"Your prose summary...\"\n", + ); +} + +async function commandPost(args) { + const title = args._[1]; + if (!title) { + console.error( + 'Usage: ac-news post "Title" "Body"\n' + + ' ac-news post "Title" --file update.md\n' + + ' ac-news post "Title" --editor\n' + + ' echo "body" | ac-news post "Title" --stdin', + ); + process.exit(1); + } + + let body; + + if (args.file) { + body = readFileSync(args.file, "utf8").trim(); + } else if (args.stdin) { + body = readFileSync("/dev/stdin", "utf8").trim(); + } else if (args.editor) { + const editor = process.env.EDITOR || "vi"; + const tmpFile = join(tmpdir(), `ac-news-${Date.now()}.md`); + writeFileSync(tmpFile, ""); + try { + execSync(`${editor} ${tmpFile}`, { stdio: "inherit" }); + body = readFileSync(tmpFile, "utf8").trim(); + } finally { + try { + unlinkSync(tmpFile); + } catch {} + } + if (!body) { + console.log("Empty body — cancelled."); + return; + } + } else { + body = args._[2] || ""; + } + + if (!ADMIN_SUB) { + console.error("ADMIN_SUB not set."); + process.exit(1); + } + + const dryRun = !!args["dry-run"]; + + console.log(`\n Title: ${title}`); + console.log(` Body: ${body.length} chars\n`); + console.log(body); + + if (dryRun) { + console.log("\n--dry-run: not posting."); + return; + } + + await withDb(async (db) => { + const posts = db.collection("news-posts"); + const votes = db.collection("news-votes"); + + const code = await uniqueCode(posts); + const now = new Date(); + + const doc = { + code, + title, + url: "", + text: body, + user: ADMIN_SUB, + when: now, + updated: now, + score: 1, + commentCount: 0, + status: "live", + }; + + await posts.insertOne(doc); + await votes.insertOne({ + itemType: "post", + itemId: code, + user: ADMIN_SUB, + when: now, + }); + + console.log(`\nPosted: https://news.aesthetic.computer/${code}`); + + // ATProto sync + try { + const atproto = await syncToAtproto( + db, + ADMIN_SUB, + { headline: title, body, when: now }, + doc._id?.toString(), + ); + if (atproto) { + await posts.updateOne({ code }, { $set: { atproto } }); + console.log(` ATProto: ${atproto.uri}`); + } + } catch (e) { + console.log(` ATProto sync failed: ${e.message}`); + } + }); +} + +async function commandList(args) { + const limit = parseInt(args.limit) || 10; + + await withDb(async (db) => { + const posts = db.collection("news-posts"); + const items = await posts + .find({ status: "live" }) + .sort({ when: -1 }) + .limit(limit) + .toArray(); + + if (items.length === 0) { + console.log("No posts."); + return; + } + + console.log(`\n${items.length} recent post(s):\n`); + for (const item of items) { + const date = item.when.toISOString().slice(0, 10); + const comments = item.commentCount || 0; + const titlePreview = + item.title.length > 60 ? item.title.slice(0, 60) + "..." : item.title; + console.log(` ${item.code} ${date} ${titlePreview} (${comments}c)`); + } + console.log(); + }); +} + +async function commandDelete(args) { + const code = args._[1]; + if (!code) { + console.error("Usage: ac-news delete "); + process.exit(1); + } + + if (!ADMIN_SUB) { + console.error("ADMIN_SUB not set."); + process.exit(1); + } + + await withDb(async (db) => { + const posts = db.collection("news-posts"); + const post = await posts.findOne({ code }); + + if (!post) { + console.error(`Post not found: ${code}`); + process.exit(1); + } + + console.log(`Deleting: "${post.title}" (${code})`); + await posts.updateOne({ code }, { $set: { status: "dead" } }); + console.log("Deleted (marked dead)."); + }); +} + +// --------------------------------------------------------------------------- +// Help +// --------------------------------------------------------------------------- + +function printHelp() { + console.log(`ac-news — Post prose updates to news.aesthetic.computer + +Usage: ac-news [options] + +Compose: + commits [--count N] [--since "..."] Show recent commits for reference + post "Title" "Body" Post a prose update + post "Title" --file path.md Post from a markdown file + post "Title" --editor Open $EDITOR to write the body + post "Title" --stdin Read body from stdin + post ... --dry-run Preview without posting + +Manage: + list [--limit N] List recent posts + delete Delete a post (admin) + +Examples: + ac-news commits --since "1 week ago" + ac-news post "Dev Update" "The native OS build system got a major overhaul..." + ac-news post "Weekly Update" --file updates/2026-03-24.md + ac-news post "What's New" --editor + ac-news list +`); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const COMMANDS = { + commits: commandCommits, + post: commandPost, + list: commandList, + delete: commandDelete, +}; + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const command = args._[0] || "help"; + + if (command === "help" || command === "--help" || command === "-h") { + printHelp(); + return; + } + + const handler = COMMANDS[command]; + if (!handler) { + console.error(`Unknown command: ${command}\n`); + printHelp(); + process.exitCode = 1; + return; + } + + await handler(args); +} + +main().catch((err) => { + console.error(`ac-news: ${err.message}`); + process.exit(1); +}); diff --git a/package.json b/package.json index 4585c4acaa..b1dfd9ceb9 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,10 @@ "at:sync": "node at/cli.mjs sync:status", "at:standard:sync": "node at/scripts/atproto/backfill-standard-site-documents.mjs", "at:standard:dry": "node at/scripts/atproto/backfill-standard-site-documents.mjs --dry-run", + "news": "node at/news-cli.mjs", + "news:commits": "node at/news-cli.mjs commits", + "news:post": "node at/news-cli.mjs post", + "news:list": "node at/news-cli.mjs list", "profile:secret:rotate": "node utilities/rotate-profile-stream-secret.mjs", "ac": "./aesthetic", "admin:udp": "ssh root@157.245.134.225", -- 2.51.2