From ae99fd364575a8e88d8a5b53bb7fd55a032a865d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 4 Aug 2026 19:02:32 +0000 Subject: [PATCH 1/2] feat(socials): post to Bluesky and Nostr from the pit --- README.md | 18 +++++ apps/pwa/src/routes/socials.mjs | 124 ++++++++++++++++++++++++++++++++ apps/pwa/src/server.mjs | 2 + apps/pwa/test/socials.test.mjs | 24 +++++++ src/cli-schema.mjs | 4 ++ src/socials.mjs | 72 +++++++++++++++++++ src/tui.mjs | 25 +++++++ test/socials.test.mjs | 49 +++++++++++++ 8 files changed, 318 insertions(+) create mode 100644 apps/pwa/src/routes/socials.mjs create mode 100644 apps/pwa/test/socials.test.mjs create mode 100644 src/socials.mjs create mode 100644 test/socials.test.mjs diff --git a/README.md b/README.md index 737b593..e74dda3 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,24 @@ Alpaca's CLI has no confirmation prompts; `--submit` intentionally removes MoshCode's preview guard. Live trading additionally requires Alpaca's `--live` opt-in or corresponding environment setting. +### Social posting from the pit + +The pit can hand a prepared post to Bluesky or Nostr without storing either +account's credentials in MoshCode: + +```text +/socials +/post bsky "shipped it 🤘" +/post nostr "shipped it 🤘" +``` + +Bluesky opens its official compose intent. Nostr opens the MoshCode composer, +connects to a NIP-07 browser signer (or a NIP-46 bunker through +[`window.nostr.js`](https://github.com/fiatjaf/window.nostr.js)), signs a kind-1 +event, and publishes it to the displayed relays. Both flows leave the final +confirmation in the browser. If the pit is remote or headless, `/post` prints +the composer URL instead. + ## Browser terminal (`moshcode console`) A real terminal in the browser — arrow keys, history, full-screen TUIs — because diff --git a/apps/pwa/src/routes/socials.mjs b/apps/pwa/src/routes/socials.mjs new file mode 100644 index 0000000..760ca1b --- /dev/null +++ b/apps/pwa/src/routes/socials.mjs @@ -0,0 +1,124 @@ +// Browser-side social composers. The CLI only hands a draft to these pages; +// account authorization and the final publish stay in the user's browser. +import { Router } from "express"; +import { page, footer } from "../lib/html.mjs"; + +export const socialsRouter = Router(); + +export const NOSTR_RELAYS = [ + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.primal.net", +]; + +export function nostrComposerPage() { + const relays = JSON.stringify(NOSTR_RELAYS); + const body = ` +
+
+
NOSTR ¡ KIND 1
+

Post from the pit.

+

Your draft stayed in the URL fragment—it was never sent to MoshCode. Connect a browser signer, review the text, then publish it to the relays below.

+ +
+
Draft0 chars
+
+ +
+ + nothing is posted until you click +
+
+
+ +
+
Relayspublish to any that accept
+
+ ${NOSTR_RELAYS.map((relay) => `
${relay}
`).join("")} +
+
+
${footer} + + + + `; + + return page({ title: "moshcode ▸ post to Nostr", body }); +} + +socialsRouter.get("/socials/nostr", (_req, res) => { + res.type("html").send(nostrComposerPage()); +}); diff --git a/apps/pwa/src/server.mjs b/apps/pwa/src/server.mjs index 7b94e90..9cc8dd7 100644 --- a/apps/pwa/src/server.mjs +++ b/apps/pwa/src/server.mjs @@ -14,6 +14,7 @@ import { cliRouter } from "./routes/cli.mjs"; import { sessionsRouter } from "./routes/sessions.mjs"; import { pagesRouter } from "./routes/pages.mjs"; import { moshpitRouter } from "./routes/moshpit.mjs"; +import { socialsRouter } from "./routes/socials.mjs"; const app = express(); app.disable("x-powered-by"); @@ -54,6 +55,7 @@ app.use(creditsRouter); app.use(cliRouter); // /cli/authorize, /cli/token, /api/me app.use(sessionsRouter); // /sessions (live CLI mirror) + /api/sessions app.use(pagesRouter); // /app, /settings +app.use(socialsRouter); // public browser composers used by /post app.use(moshpitRouter); // /pit + /api/moshpit/* — the namespace app.use((req, res) => res.status(404).type("html").send( diff --git a/apps/pwa/test/socials.test.mjs b/apps/pwa/test/socials.test.mjs new file mode 100644 index 0000000..1cf36bd --- /dev/null +++ b/apps/pwa/test/socials.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { NOSTR_RELAYS, nostrComposerPage } from "../src/routes/socials.mjs"; + +test("Nostr composer loads the pinned NIP-07/NIP-46 bridge", () => { + const html = nostrComposerPage(); + assert.match(html, /window\.nostr\.js@0\.5\.0\/dist\/window\.nostr\.min\.js/); + assert.match(html, /window\.nostr\.getPublicKey\(\)/); + assert.match(html, /window\.nostr\.signEvent\(/); +}); + +test("Nostr composer creates kind-1 events and publishes to every named relay", () => { + const html = nostrComposerPage(); + assert.match(html, /kind: 1/); + assert.match(html, /\["EVENT", event\]/); + for (const relay of NOSTR_RELAYS) assert.ok(html.includes(relay), `${relay} is not rendered`); +}); + +test("Nostr composer reads the draft from the fragment", () => { + const html = nostrComposerPage(); + assert.match(html, /location\.hash\.slice\(1\)/); + assert.doesNotMatch(html, /location\.search/); +}); diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 3c4e640..7a46e2b 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -499,6 +499,10 @@ export const PIT_COMMANDS = [ description: "list workflow tools, or run one" }, { name: "trade", args: " [args…]", cli: "trade", description: "look up markets and preview/place Alpaca orders" }, + { name: "socials", aliases: ["social"], pitOnly: true, + description: "list social networks available for posting" }, + { name: "post", args: ' "message"', pitOnly: true, + description: "open a social composer with a prepared post" }, { name: "install", args: "", cli: "install", description: "install an engine or workflow tool" }, { name: "upgrade", aliases: ["update"], args: "[name…]", cli: "upgrade", diff --git a/src/socials.mjs b/src/socials.mjs new file mode 100644 index 0000000..d8b4337 --- /dev/null +++ b/src/socials.mjs @@ -0,0 +1,72 @@ +import { canOpenBrowser, openBrowser } from "./open-url.mjs"; + +const DEFAULT_APP = "https://app.moshcode.sh"; + +export const SOCIALS = [ + { + name: "bluesky", + aliases: ["bsky"], + description: "official Bluesky browser composer", + }, + { + name: "nostr", + aliases: [], + description: "NIP-07/NIP-46 browser signer + relay publish", + }, +]; + +export function resolveSocial(name) { + const wanted = String(name ?? "").trim().toLowerCase(); + return SOCIALS.find((social) => + social.name === wanted || social.aliases.includes(wanted)) ?? null; +} + +function appOrigin(env = process.env) { + return String(env.MOSHCODE_API || DEFAULT_APP).replace(/\/+$/, ""); +} + +/** + * Build the browser hand-off without opening anything. Nostr keeps the draft + * in the fragment so it never reaches app.moshcode.sh access logs or Referer + * headers; the composer reads it entirely in the browser. + */ +export function socialPostUrl(name, message, { env = process.env } = {}) { + const social = resolveSocial(name); + if (!social) return null; + const text = String(message ?? ""); + if (social.name === "bluesky") { + return `https://bsky.app/intent/compose?${new URLSearchParams({ text })}`; + } + return `${appOrigin(env)}/socials/nostr#${new URLSearchParams({ text })}`; +} + +export function socialRoster() { + return SOCIALS.map((social) => ({ ...social, aliases: [...social.aliases] })); +} + +/** + * Open a provider composer. Posting remains an explicit browser confirmation: + * Bluesky requires it, and Nostr asks the browser signer before relay publish. + */ +export function postSocial(args, { + env = process.env, + canOpen = canOpenBrowser, + open = openBrowser, +} = {}) { + const [requested, ...words] = Array.isArray(args) ? args : []; + const social = resolveSocial(requested); + if (!requested) return { ok: false, error: 'usage: /post "message"' }; + if (!social) { + return { + ok: false, + error: `unknown social "${requested}". try: ${SOCIALS.map((entry) => entry.name).join(", ")}`, + }; + } + + const message = words.join(" ").trim(); + if (!message) return { ok: false, error: 'usage: /post "message"' }; + + const url = socialPostUrl(social.name, message, { env }); + const opened = Boolean(canOpen() && open(url)); + return { ok: true, social: social.name, message, url, opened }; +} diff --git a/src/tui.mjs b/src/tui.mjs index f109bf5..2085469 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -10,6 +10,7 @@ import path from "node:path"; import { ENGINES, agentLaunchArgs, resolveEngine, engineStatus, openSession } from "./engines.mjs"; import { TOOLS, resolveTool, toolStatus, openTool } from "./tools.mjs"; import { tradeArgs, tradeUsage } from "./trade.mjs"; +import { postSocial, socialRoster } from "./socials.mjs"; import { runUpgrade } from "./upgrade.mjs"; import { locate, tilde } from "./pwd.mjs"; import { createPrd, listPrds, authoringPrompt } from "./prd.mjs"; @@ -156,6 +157,15 @@ function printTools() { console.log(ash(" → ") + acid("https://dev.profullstack.com/")); } +function printSocials() { + console.log(bone(" socials") + ash(" — compose with ") + acid('/post "message"')); + for (const social of socialRoster()) { + const aliases = social.aliases.length ? ` (${social.aliases.join(", ")})` : ""; + console.log(` ${acid("●")} ${bone(social.name.padEnd(9))} ${ash(social.description + aliases)}`); + } + console.log(ash(" the browser always asks you to confirm before anything is published")); +} + /** * The moshscript vocabulary, split the way the CLI's help splits it. * @@ -660,6 +670,21 @@ export async function tui() { rl = mkrl(); continue; } + if (cmd === "socials" || cmd === "social") { + printSocials(); + continue; + } + if (cmd === "post") { + const result = postSocial(rest); + if (!result.ok) { console.log(err(result.error)); continue; } + if (result.opened) { + console.log(ok(`opened the ${result.social} composer — confirm the post in your browser 🤘`)); + } else { + console.log(info(`open this ${result.social} composer in a browser:`)); + console.log(` ${result.url}`); + } + continue; + } // Bare engine name → open it. const resolved = resolveEngine(cmd); if (resolved) { diff --git a/test/socials.test.mjs b/test/socials.test.mjs new file mode 100644 index 0000000..99b8a51 --- /dev/null +++ b/test/socials.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { postSocial, resolveSocial, socialPostUrl, socialRoster } from "../src/socials.mjs"; + +test("social roster includes Bluesky and Nostr with aliases", () => { + assert.deepEqual(socialRoster().map((social) => social.name), ["bluesky", "nostr"]); + assert.equal(resolveSocial("bsky")?.name, "bluesky"); + assert.equal(resolveSocial("NOSTR")?.name, "nostr"); + assert.equal(resolveSocial("twitter"), null); +}); + +test("Bluesky posts use the official compose intent", () => { + const url = new URL(socialPostUrl("bluesky", "hello & goodbye")); + assert.equal(url.origin + url.pathname, "https://bsky.app/intent/compose"); + assert.equal(url.searchParams.get("text"), "hello & goodbye"); +}); + +test("Nostr drafts stay in the URL fragment and honor a self-hosted app", () => { + const url = new URL(socialPostUrl("nostr", "draft #1", { + env: { MOSHCODE_API: "https://mosh.example/" }, + })); + assert.equal(url.origin + url.pathname, "https://mosh.example/socials/nostr"); + assert.equal(url.search, ""); + assert.equal(new URLSearchParams(url.hash.slice(1)).get("text"), "draft #1"); +}); + +test("postSocial opens a prepared composer when a browser is available", () => { + let opened = ""; + const result = postSocial(["bsky", "two", "words"], { + canOpen: () => true, + open: (url) => { opened = url; return true; }, + }); + + assert.equal(result.ok, true); + assert.equal(result.social, "bluesky"); + assert.equal(result.message, "two words"); + assert.equal(result.opened, true); + assert.equal(opened, result.url); +}); + +test("postSocial reports missing messages and unknown networks without opening", () => { + let opens = 0; + const options = { canOpen: () => true, open: () => { opens++; return true; } }; + assert.match(postSocial([], options).error, /usage: \/post/); + assert.match(postSocial(["nostr"], options).error, /usage: \/post/); + assert.match(postSocial(["twitter", "hello"], options).error, /unknown social/); + assert.equal(opens, 0); +}); From d24a21c97e098cd049ac4592051ce7b57e9365a4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 6 Aug 2026 11:43:43 +0000 Subject: [PATCH 2/2] test(socials): skip the PWA composer test when express is absent apps/pwa is not a pnpm workspace package, so the root `pnpm install` never installs its dependencies and CI's root `node --test` cannot resolve express. The composer test imported the route module statically, so that resolution failure crashed the file instead of skipping it. Probe for express with createRequire and defer the route import, matching the guard the other 38 PWA tests already use. --- apps/pwa/test/socials.test.mjs | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/pwa/test/socials.test.mjs b/apps/pwa/test/socials.test.mjs index 1cf36bd..b9aaa96 100644 --- a/apps/pwa/test/socials.test.mjs +++ b/apps/pwa/test/socials.test.mjs @@ -1,24 +1,43 @@ +// Unit tests for the browser-side Nostr composer. +// +// Same shape as the other PWA tests: the route module pulls in express at load +// time, so probe for it first and skip cleanly when the PWA dependencies aren't +// installed — that keeps the root `pnpm test` green in a fresh clone. import assert from "node:assert/strict"; +import { createRequire } from "node:module"; import test from "node:test"; -import { NOSTR_RELAYS, nostrComposerPage } from "../src/routes/socials.mjs"; +const require = createRequire(import.meta.url); +let hasDeps = true; +try { + require("express"); +} catch { + hasDeps = false; +} -test("Nostr composer loads the pinned NIP-07/NIP-46 bridge", () => { - const html = nostrComposerPage(); +const skip = hasDeps ? false : "PWA dependencies are not installed"; + +async function composer() { + const { NOSTR_RELAYS, nostrComposerPage } = await import("../src/routes/socials.mjs"); + return { NOSTR_RELAYS, html: nostrComposerPage() }; +} + +test("Nostr composer loads the pinned NIP-07/NIP-46 bridge", { skip }, async () => { + const { html } = await composer(); assert.match(html, /window\.nostr\.js@0\.5\.0\/dist\/window\.nostr\.min\.js/); assert.match(html, /window\.nostr\.getPublicKey\(\)/); assert.match(html, /window\.nostr\.signEvent\(/); }); -test("Nostr composer creates kind-1 events and publishes to every named relay", () => { - const html = nostrComposerPage(); +test("Nostr composer creates kind-1 events and publishes to every named relay", { skip }, async () => { + const { NOSTR_RELAYS, html } = await composer(); assert.match(html, /kind: 1/); assert.match(html, /\["EVENT", event\]/); for (const relay of NOSTR_RELAYS) assert.ok(html.includes(relay), `${relay} is not rendered`); }); -test("Nostr composer reads the draft from the fragment", () => { - const html = nostrComposerPage(); +test("Nostr composer reads the draft from the fragment", { skip }, async () => { + const { html } = await composer(); assert.match(html, /location\.hash\.slice\(1\)/); assert.doesNotMatch(html, /location\.search/); });