Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions apps/pwa/src/routes/socials.mjs
Original file line number Diff line number Diff line change
@@ -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 = `
<header class="bar"><div class="wrap bar-inner">
<a class="brand" href="/"><span class="mark">M</span>MOSHCODE<span class="app">socials</span></a>
<a class="btn" href="/">Back to the pit</a>
</div></header>
<main class="wrap" style="max-width:760px;padding:44px 0 64px">
<div class="label acid" style="margin-bottom:10px">NOSTR · KIND 1</div>
<h1 style="font-size:2rem;margin:0 0 10px">Post from the pit.</h1>
<p class="dim mono" style="margin:0 0 24px;line-height:1.65">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.</p>

<div class="card">
<div class="card-head"><span class="h">Draft</span><span class="pill" id="count">0 chars</span></div>
<div class="card-body">
<textarea id="message" rows="8" autofocus placeholder="what's moshing?" style="width:100%;resize:vertical"></textarea>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px">
<button class="btn acid" id="publish" type="button">Connect signer + publish</button>
<span class="mono dim" id="status" role="status" aria-live="polite">nothing is posted until you click</span>
</div>
</div>
</div>

<div class="card" style="margin-top:18px">
<div class="card-head"><span class="h">Relays</span><span class="pill">publish to any that accept</span></div>
<div class="card-body mono dim" style="font-size:.78rem;line-height:1.8">
${NOSTR_RELAYS.map((relay) => `<div><span class="beat"></span> ${relay}</div>`).join("")}
</div>
</div>
</main>${footer}

<script>
window.wnjParams = { position: "bottom", accent: "green", compactMode: true };
</script>
<script src="https://cdn.jsdelivr.net/npm/window.nostr.js@0.5.0/dist/window.nostr.min.js"></script>
<script>
(function () {
var RELAYS = ${relays};
var message = document.getElementById("message");
var publish = document.getElementById("publish");
var status = document.getElementById("status");
var count = document.getElementById("count");
message.value = new URLSearchParams(location.hash.slice(1)).get("text") || "";

function recount() { count.textContent = Array.from(message.value).length + " chars" }
recount();
message.addEventListener("input", recount);

function sendToRelay(relay, event) {
return new Promise(function (resolve) {
var settled = false;
var ws;
function done(ok, detail) {
if (settled) return;
settled = true;
clearTimeout(timer);
try { ws.close() } catch (_) {}
resolve({ relay: relay, ok: ok, detail: detail || "" });
}
var timer = setTimeout(function () { done(false, "timeout") }, 8000);
try { ws = new WebSocket(relay) } catch (error) { done(false, error.message); return }
ws.addEventListener("open", function () { ws.send(JSON.stringify(["EVENT", event])) });
ws.addEventListener("message", function (incoming) {
try {
var reply = JSON.parse(incoming.data);
if (reply[0] === "OK" && reply[1] === event.id) done(Boolean(reply[2]), String(reply[3] || ""));
} catch (_) {}
});
ws.addEventListener("error", function () { done(false, "connection failed") });
ws.addEventListener("close", function () { done(false, "closed without an acknowledgement") });
});
}

publish.addEventListener("click", async function () {
var content = message.value.trim();
if (!content) { status.textContent = "write something first"; message.focus(); return }
publish.disabled = true;
status.textContent = "waiting for your signer…";
try {
if (!window.nostr) throw new Error("no Nostr signer is available in this browser");
var pubkey = await window.nostr.getPublicKey();
var event = await window.nostr.signEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: content,
pubkey: pubkey
});
status.textContent = "signed—publishing to relays…";
var results = await Promise.all(RELAYS.map(function (relay) { return sendToRelay(relay, event) }));
var accepted = results.filter(function (result) { return result.ok });
if (!accepted.length) {
var reasons = results.map(function (result) { return result.relay + ": " + result.detail }).join(" · ");
throw new Error("no relay accepted the event (" + reasons + ")");
}
status.textContent = "published to " + accepted.length + "/" + RELAYS.length + " relays 🤘";
publish.textContent = "Published";
} catch (error) {
status.textContent = error && error.message ? error.message : String(error);
publish.disabled = false;
}
});
})();
</script>`;

return page({ title: "moshcode ▸ post to Nostr", body });
}

socialsRouter.get("/socials/nostr", (_req, res) => {
res.type("html").send(nostrComposerPage());
});
2 changes: 2 additions & 0 deletions apps/pwa/src/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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(
Expand Down
43 changes: 43 additions & 0 deletions apps/pwa/test/socials.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
4 changes: 4 additions & 0 deletions src/cli-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,10 @@ export const PIT_COMMANDS = [
description: "list workflow tools, or run one" },
{ name: "trade", args: "<verb> [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: '<social> "message"', pitOnly: true,
description: "open a social composer with a prepared post" },
{ name: "install", args: "<engine|tool>", cli: "install",
description: "install an engine or workflow tool" },
{ name: "upgrade", aliases: ["update"], args: "[name…]", cli: "upgrade",
Expand Down
72 changes: 72 additions & 0 deletions src/socials.mjs
Original file line number Diff line number Diff line change
@@ -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 <social> "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 <social> "message"' };

const url = socialPostUrl(social.name, message, { env });
const opened = Boolean(canOpen() && open(url));
return { ok: true, social: social.name, message, url, opened };
}
25 changes: 25 additions & 0 deletions src/tui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <social> "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.
*
Expand Down Expand Up @@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions test/socials.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading