2026-08-02 23:00:26 -04:00
|
|
|
// 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");
|
|
|
|
|
|
2026-08-03 00:27:24 -04:00
|
|
|
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(/\/$/, "");
|
2026-08-02 23:00:26 -04:00
|
|
|
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,
|
2026-08-03 00:27:24 -04:00
|
|
|
setTarget,
|
2026-08-02 23:00:26 -04:00
|
|
|
request,
|
|
|
|
|
getJSON,
|
|
|
|
|
getText,
|
|
|
|
|
writeAndGetId,
|
|
|
|
|
writeText,
|
|
|
|
|
};
|