Skip to content

Commit 41c685a

Browse files
ralyodioclaude
andcommitted
feat(cli): moshcode login/whoami/logout — OAuth PKCE loopback auth
`moshcode login` opens the browser to app.moshcode.sh, runs an authorization- code + PKCE flow over a 127.0.0.1 loopback, and stores an API token in ~/.moshcode/credentials.json. notify()/ask() fall back to that token, so moshscript reaches you after a single login with no env vars. Also /login, /whoami, /logout in the TUI. Verified live end-to-end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f106339 commit 41c685a

6 files changed

Lines changed: 140 additions & 9 deletions

File tree

bin/moshcode.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { runUpgrade } from "../src/upgrade.mjs";
1818
import { mcpCommand, skillCommand } from "../src/integrations.mjs";
1919
import { locate, tilde } from "../src/pwd.mjs";
2020
import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs";
21+
import { login, whoami, logout } from "../src/auth.mjs";
2122
import { tui } from "../src/tui.mjs";
2223

2324
const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -121,6 +122,9 @@ usage:
121122
moshcode prd [idea] publish the next numbered PRD (OpenPRD) to
122123
prd/NNNN-slug.md and hand it to an engine to
123124
author; no arg lists existing PRDs
125+
moshcode login authenticate this machine with app.moshcode.sh
126+
(browser OAuth+PKCE) so notify()/ask() reach you
127+
moshcode whoami | logout show / clear the logged-in account
124128
moshcode pwd show the current dir + git repo/branch/origin
125129
moshcode engines list engines + install status
126130
moshcode tools list workflow tools + install status
@@ -248,6 +252,13 @@ async function main() {
248252
}
249253
return;
250254
}
255+
if (cmd === "login") {
256+
try { const { email } = await login(); console.log(`✓ logged in${email ? ` as ${email}` : ""} 🤘 — notify()/ask() will reach you now.`); }
257+
catch (e) { console.error(String(e.message || e)); process.exitCode = 1; }
258+
return;
259+
}
260+
if (cmd === "whoami") { await whoami(); return; }
261+
if (cmd === "logout") { logout(); return; }
251262
if (cmd === "run") {
252263
let max = 3, dryRun = false;
253264
const positional = []; // first is the file; the rest reach the script as argv

src/auth.mjs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// `moshcode login` — browser OAuth-style flow (authorization code + PKCE +
2+
// loopback) that authenticates against the moshcode app (app.moshcode.sh) and
3+
// stores an API token locally. That token is what moshscript's notify()/ask()
4+
// use to reach you (see src/notify.mjs), so a single `moshcode login` wires up
5+
// the whole human-in-the-loop for scripts too.
6+
import http from "node:http";
7+
import crypto from "node:crypto";
8+
import fs from "node:fs";
9+
import os from "node:os";
10+
import path from "node:path";
11+
import { spawn } from "node:child_process";
12+
13+
const API = () => (process.env.MOSHCODE_API || "https://app.moshcode.sh").replace(/\/+$/, "");
14+
const CREDS_DIR = path.join(os.homedir(), ".moshcode");
15+
export const credsPath = path.join(CREDS_DIR, "credentials.json");
16+
17+
const b64url = (buf) => Buffer.from(buf).toString("base64url");
18+
19+
/** Stored credentials, or null. { api, token, email } */
20+
export function loadCreds() {
21+
try { return JSON.parse(fs.readFileSync(credsPath, "utf8")); } catch { return null; }
22+
}
23+
function saveCreds(creds) {
24+
fs.mkdirSync(CREDS_DIR, { recursive: true });
25+
fs.writeFileSync(credsPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
26+
}
27+
28+
function openBrowser(url) {
29+
const [cmd, args] =
30+
process.platform === "darwin" ? ["open", [url]]
31+
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
32+
: ["xdg-open", [url]];
33+
try { const c = spawn(cmd, args, { stdio: "ignore", detached: true }); c.on("error", () => {}); c.unref(); } catch { /* print fallback */ }
34+
}
35+
36+
const donePage = (msg) =>
37+
`<!doctype html><meta charset=utf-8><body style="background:#070806;color:#edf2e4;font-family:ui-monospace,monospace;text-align:center;padding:16vh 24px">
38+
<div style="font-size:2rem">🤘</div><h1 style="color:#a6ff1a">${msg}</h1><p>Return to your terminal. You can close this tab.</p></body>`;
39+
40+
/** Run the login flow. Returns { email } on success; throws on failure/timeout. */
41+
export function login({ timeoutMs = 180000 } = {}) {
42+
const verifier = b64url(crypto.randomBytes(32));
43+
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
44+
const state = b64url(crypto.randomBytes(16));
45+
46+
return new Promise((resolve, reject) => {
47+
const server = http.createServer(async (req, res) => {
48+
const url = new URL(req.url, "http://127.0.0.1");
49+
if (url.pathname !== "/callback") { res.writeHead(404).end(); return; }
50+
const err = url.searchParams.get("error");
51+
const code = url.searchParams.get("code");
52+
const gotState = url.searchParams.get("state");
53+
try {
54+
if (err) throw new Error(`authorization denied (${err})`);
55+
if (!code || gotState !== state) throw new Error("bad authorization response (state mismatch)");
56+
const tokRes = await fetch(`${API()}/cli/token`, {
57+
method: "POST",
58+
headers: { "content-type": "application/json" },
59+
body: JSON.stringify({ code, code_verifier: verifier }),
60+
});
61+
if (!tokRes.ok) throw new Error(`token exchange failed (${tokRes.status})`);
62+
const tok = await tokRes.json();
63+
saveCreds({ api: API(), token: tok.access_token, email: tok.user?.email || null, id: tok.user?.id });
64+
res.writeHead(200, { "content-type": "text/html" }).end(donePage("you're in 🤘"));
65+
server.close();
66+
resolve({ email: tok.user?.email || null });
67+
} catch (e) {
68+
res.writeHead(400, { "content-type": "text/html" }).end(donePage("login failed — check the terminal"));
69+
server.close();
70+
reject(e);
71+
}
72+
});
73+
74+
server.listen(0, "127.0.0.1", () => {
75+
const port = server.address().port;
76+
const redirect = `http://127.0.0.1:${port}/callback`;
77+
const authUrl = `${API()}/cli/authorize?` + new URLSearchParams({
78+
redirect_uri: redirect, state, code_challenge: challenge, code_challenge_method: "S256",
79+
name: `moshcode cli @ ${os.hostname()}`,
80+
});
81+
console.log(`\n🔑 opening your browser to authorize the moshcode CLI…`);
82+
console.log(` if it doesn't open, visit:\n ${authUrl}\n`);
83+
openBrowser(authUrl);
84+
});
85+
86+
const timer = setTimeout(() => { server.close(); reject(new Error("login timed out — run `moshcode login` again")); }, timeoutMs);
87+
server.on("close", () => clearTimeout(timer));
88+
});
89+
}
90+
91+
/** Print who is logged in (verified against the app). */
92+
export async function whoami() {
93+
const creds = loadCreds();
94+
if (!creds?.token) { console.log("not logged in — run: moshcode login"); return; }
95+
try {
96+
const res = await fetch(`${creds.api || API()}/api/me`, { headers: { authorization: `Bearer ${creds.token}` } });
97+
if (res.status === 401) { console.log("session expired — run: moshcode login"); return; }
98+
const me = await res.json();
99+
console.log(`${me.email || me.name || "moshcoder"} 🤘 (${me.credits ?? "?"} credits) @ ${creds.api || API()}`);
100+
} catch {
101+
console.log(`${creds.email || "logged in"} @ ${creds.api || API()} (couldn't reach the app to verify)`);
102+
}
103+
}
104+
105+
/** Forget local credentials. */
106+
export function logout() {
107+
try { fs.rmSync(credsPath); console.log("logged out 🤘"); }
108+
catch { console.log("already logged out"); }
109+
}

src/commands.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ const COMMANDS = [
8888
ctx.out(` 🔔 notify() → ${msg}`);
8989
if (ctx.dryRun) return { dryRun: true };
9090
const r = await ingestApproval({ message: msg, kind: "notify", script: "moshscript", iter: ctx.iter });
91-
if (!r.ok) { ctx.out(` ! notify failed (${r.error || r.status}) — set MOSHCODE_API_KEY`); return null; }
91+
if (!r.ok) { ctx.out(` ! notify failed (${r.error || r.status}) — run \`moshcode login\``); return null; }
9292
ctx.out(` 🔗 ${r.url}`);
9393
if (r.warning) ctx.out(` ⚠ ${r.warning}`);
9494
return { id: r.id, url: r.url };
@@ -109,7 +109,7 @@ const COMMANDS = [
109109
return null;
110110
}
111111
const r = await ingestApproval({ message: prompt, kind: "ask", script: "moshscript", iter: ctx.iter });
112-
if (!r.ok) { ctx.out(` ! ask failed (${r.error || r.status}) — set MOSHCODE_API_KEY`); return null; }
112+
if (!r.ok) { ctx.out(` ! ask failed (${r.error || r.status}) — run \`moshcode login\``); return null; }
113113
ctx.out(` 🔗 approve/instruct: ${r.url}`);
114114
ctx.out(" ⏳ waiting for a human…");
115115
const reply = await pollApproval(r.id);

src/notify.mjs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@
99
// (from the app's Settings → API keys). MOSHCODE_WEBHOOK_SECRET optionally signs
1010
// the ingest for defense in depth. The HTTP layer is injectable for tests.
1111
import crypto from "node:crypto";
12+
import { loadCreds } from "./auth.mjs";
1213

13-
const API = (process.env.MOSHCODE_API || "https://app.moshcode.sh").replace(/\/+$/, "");
14-
const KEY = () => process.env.MOSHCODE_API_KEY || "";
14+
// Prefer explicit env; otherwise fall back to `moshcode login` credentials, so a
15+
// script Just Works after login without exporting anything.
16+
const API = () => (process.env.MOSHCODE_API || loadCreds()?.api || "https://app.moshcode.sh").replace(/\/+$/, "");
17+
const KEY = () => process.env.MOSHCODE_API_KEY || loadCreds()?.token || "";
1518
const SECRET = () => process.env.MOSHCODE_WEBHOOK_SECRET || "";
1619

1720
function signHeaders(body) {
@@ -24,11 +27,11 @@ function signHeaders(body) {
2427

2528
/** POST an approval to the app. Returns { ok, id, url, delivered, charged, warning } or { ok:false }. */
2629
export async function ingestApproval(payload, { fetchImpl = fetch } = {}) {
27-
if (!KEY()) return { ok: false, error: "no MOSHCODE_API_KEY set" };
30+
if (!KEY()) return { ok: false, error: "not logged in" };
2831
const body = JSON.stringify(payload);
2932
let res;
3033
try {
31-
res = await fetchImpl(`${API}/api/approvals`, {
34+
res = await fetchImpl(`${API()}/api/approvals`, {
3235
method: "POST",
3336
headers: { "content-type": "application/json", authorization: `Bearer ${KEY()}`, ...signHeaders(body) },
3437
body,
@@ -54,7 +57,7 @@ export async function pollApproval(id, opts = {}) {
5457
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
5558
} = opts;
5659

57-
const url = `${API}/api/approvals/${id}`;
60+
const url = `${API()}/api/approvals/${id}`;
5861
const headers = KEY() ? { authorization: `Bearer ${KEY()}` } : {};
5962
const start = now();
6063
for (;;) {

src/tui.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { TOOLS, resolveTool, toolStatus, openTool } from "./tools.mjs";
1212
import { runUpgrade } from "./upgrade.mjs";
1313
import { locate, tilde } from "./pwd.mjs";
1414
import { createPrd, listPrds, authoringPrompt } from "./prd.mjs";
15+
import { login, whoami, logout } from "./auth.mjs";
1516
import { runScript } from "./runtime.mjs";
1617
import { moshVocabulary } from "./commands.mjs";
1718
import { mcpCommand, skillCommand } from "./integrations.mjs";
@@ -309,6 +310,13 @@ export async function tui() {
309310
if (cmd === "quit" || cmd === "exit" || cmd === "q") break;
310311
if (cmd === "help" || cmd === "?" || cmd === "h") { printHelp(); continue; }
311312
if (cmd === "pwd" || cmd === "where") { printPwd(); continue; }
313+
if (cmd === "login") {
314+
try { const { email } = await login(); console.log(ok(`logged in${email ? ` as ${email}` : ""} 🤘`)); }
315+
catch (e) { console.log(err(String(e.message || e))); }
316+
continue;
317+
}
318+
if (cmd === "whoami") { await whoami(); continue; }
319+
if (cmd === "logout") { logout(); continue; }
312320
if (cmd === "run") {
313321
if (!rest[0]) { console.log(err("usage: /run <file.mosh>")); continue; }
314322
await runFile(rest[0]);

test/notify.test.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import test from "node:test";
33

44
import { ingestApproval, pollApproval } from "../src/notify.mjs";
55

6-
test("ingestApproval fails cleanly without an API key", async () => {
6+
test("ingestApproval fails cleanly when not authenticated", async () => {
77
delete process.env.MOSHCODE_API_KEY;
88
const r = await ingestApproval({ message: "hi" }, { fetchImpl: async () => ({ ok: true, json: async () => ({}) }) });
99
assert.equal(r.ok, false);
10-
assert.match(r.error, /MOSHCODE_API_KEY/);
10+
assert.match(r.error, /not logged in/);
1111
});
1212

1313
test("ingestApproval posts to the app with a Bearer key and returns {id,url}", async () => {

0 commit comments

Comments
 (0)