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 = ` +
+ MMOSHCODEsocials + Back to the pit +
+
+
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..b9aaa96 --- /dev/null +++ b/apps/pwa/test/socials.test.mjs @@ -0,0 +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"; + +const require = createRequire(import.meta.url); +let hasDeps = true; +try { + require("express"); +} catch { + hasDeps = false; +} + +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", { 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", { skip }, async () => { + const { html } = await composer(); + 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); +});