// client.js — the reusable Bliss HTTP client. // // This is the "1:1 as a human" layer: it logs in with a username/password like // a person would, keeps the session cookie in a jar, and replays it. Reads go // through /plumbing (JSON); writes go through the same /workshop/* endpoints the // workshop UI posts to. Nothing here knows about MCP or the CLI specifically — // wrap it however you like. const fs = require("fs"); const os = require("os"); const path = require("path"); const JAR_DIR = path.join(os.homedir(), ".bliss"); // Sticky target so callers don't have to re-set BLISS_URL on every invocation // (shell env doesn't persist between separate CLI runs). Precedence: // BLISS_URL env > ~/.bliss/target > localhost default const TARGET_FILE = path.join(JAR_DIR, "target"); function readTarget() { try { return fs.readFileSync(TARGET_FILE, "utf8").trim() || null; } catch { return null; } } function setTarget(url) { const clean = url.replace(/\/$/, ""); fs.mkdirSync(JAR_DIR, { recursive: true }); fs.writeFileSync(TARGET_FILE, clean + "\n"); return clean; } const BASE = ( process.env.BLISS_URL || readTarget() || "http://localhost:3000" ).replace(/\/$/, ""); const USER = process.env.BLISS_USER; const PASS = process.env.BLISS_PASS; // Persist the session cookie so we don't log in on every CLI invocation. Keyed // by base URL so pointing at a different instance uses a different jar. const JAR_FILE = path.join( JAR_DIR, "session-" + Buffer.from(BASE).toString("hex").slice(0, 24) + ".txt", ); function loadCookie() { try { return fs.readFileSync(JAR_FILE, "utf8").trim() || null; } catch { return null; } } function saveCookie(cookie) { fs.mkdirSync(JAR_DIR, { recursive: true }); fs.writeFileSync(JAR_FILE, cookie, { mode: 0o600 }); } // Pull the connect.sid cookie out of a Set-Cookie header. function extractCookie(res) { const raw = res.headers.get("set-cookie"); if (!raw) return null; const m = raw.match(/connect\.sid=[^;]+/); return m ? m[0] : null; } async function login() { if (!USER || !PASS) { throw new Error( "BLISS_USER / BLISS_PASS not set (needed to log in to " + BASE + ")", ); } const res = await fetch(BASE + "/login", { method: "POST", redirect: "manual", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ username: USER, password: PASS }).toString(), }); const cookie = extractCookie(res); if (!cookie) { // A 200 back from /login means the login form re-rendered => bad creds. throw new Error("login failed for user '" + USER + "' (check BLISS_PASS)"); } saveCookie(cookie); return cookie; } // Core request. Returns { status, location, headers, text() }. Follows redirects // manually so callers can read the Location of a create (which carries the new // id). Re-logs-in once on a 401 and retries. async function request(method, urlPath, opts = {}, _retried = false) { // Log in lazily: only if we have no cookie AND creds are configured. Bliss // doesn't gate these endpoints today, so with no creds we just proceed // cookieless; a 401 below will force a login (and surface a clear error if // creds are missing). let cookie = loadCookie(); if (!cookie && USER && PASS) cookie = await login(); const headers = { ...(opts.headers || {}) }; if (cookie) headers.cookie = cookie; let body = opts.body; if (opts.json !== undefined) { headers["content-type"] = "application/json"; body = JSON.stringify(opts.json); } const res = await fetch(BASE + urlPath, { method, redirect: "manual", headers, body, }); // A fresh cookie may be issued mid-session; keep it. const rotated = extractCookie(res); if (rotated) saveCookie(rotated); if (res.status === 401 && !_retried) { await login(); return request(method, urlPath, opts, true); } return res; } async function getJSON(urlPath) { const res = await request("GET", urlPath); const text = await res.text(); if (res.status >= 400) { throw new Error("GET " + urlPath + " -> " + res.status + ": " + text); } try { return JSON.parse(text); } catch { throw new Error("GET " + urlPath + " did not return JSON:\n" + text); } } // For writes that redirect, return the trailing id from the Location header. async function writeAndGetId(method, urlPath, jsonBody) { const res = await request(method, urlPath, { json: jsonBody }); const location = res.headers.get("location") || res.headers.get("hx-redirect"); if (!location) { const text = await res.text(); throw new Error( method + " " + urlPath + " -> " + res.status + " (no redirect): " + text, ); } return { location, id: location.split("/").filter(Boolean).pop() }; } // For writes that return plain text (repl, library eval, "good"). async function writeText(method, urlPath, jsonBody) { const res = await request(method, urlPath, { json: jsonBody }); return res.text(); } async function getText(urlPath) { const res = await request("GET", urlPath); return res.text(); } module.exports = { BASE, setTarget, request, getJSON, getText, writeAndGetId, writeText, };