|
| 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 | +} |
0 commit comments