diff --git a/bliss-cli/SKILL.md b/bliss-cli/SKILL.md index efebf72..953653b 100644 --- a/bliss-cli/SKILL.md +++ b/bliss-cli/SKILL.md @@ -23,6 +23,15 @@ It logs in once and caches the session cookie under `~/.bliss/`, re-logging-in automatically when it expires. Run it as `node bliss-cli/bliss ` (or `./bliss-cli/bliss` if on PATH). +Instead of re-exporting `BLISS_URL` every time, set a **sticky target** once — +it persists to `~/.bliss/target` and is used until you change it (a `BLISS_URL` +env var still overrides it when set): + +```bash +bliss use https://your-instance.example.com # set the target instance +bliss use # print the current target +``` + ## The model (what you're editing) A **Structure** is a mini web app. It has **routes** (a VERB + path + a JS @@ -42,14 +51,34 @@ bliss template # one template incl. content + test_objec bliss db # one db incl. its library source bliss files # files attached to a structure bliss logs [--since ] # route logs (console.log output + errors) + +bliss versions # save history (newest first) +bliss version # one version incl. full snapshot ``` All emit JSON, so: `bliss structure 3 | jq '.routes[].path'`. +## Version history (undo trail) + +Every content-changing save of a route handler, template, or db library records +an append-only snapshot. The live row is always the current version; old ones +are kept so nothing edited is ever lost. `versions` lists the summaries (id, +`created_at`, `bytes`) newest-first; `version ` returns the full snapshot +(the versioned fields). No-op re-saves are deduped, so identical content doesn't +pile up. There's no revert command yet — to roll back, fetch an old snapshot and +re-`update-*` its content: + +```bash +bliss versions route 3 12 # what saves exist +bliss version 24 | jq -r .snapshot.handler > /tmp/old.js +bliss update-route 3 12 --handler-file /tmp/old.js # restore it as a new save +``` + ## Writes ```bash bliss create-structure "" # -> {location, id} +bliss update-settings [--route-prefix

] [--head-injection |--head-injection-file ] bliss clone --name "" --prefix /p [--clone-dbs] bliss create-route # VERB: GET|POST|PUT|DELETE|WS ; -> {id} diff --git a/bliss-cli/bliss b/bliss-cli/bliss index 2c110cf..2fe9bc5 100755 --- a/bliss-cli/bliss +++ b/bliss-cli/bliss @@ -58,6 +58,16 @@ function need(val, msg) { } const commands = { + // Set (or, with no arg, show) the sticky target instance. Persisted to + // ~/.bliss/target so it survives across invocations without re-exporting + // BLISS_URL. BLISS_URL in the env still overrides this when set. + async use({ pos }) { + if (pos[0]) { + out("target set: " + c.setTarget(pos[0])); + } else { + out(c.BASE); + } + }, // ---- reads (JSON via /plumbing) ---- async structures() { out(await c.getJSON("/plumbing/structures")); @@ -94,12 +104,52 @@ const commands = { const q = flags.since ? `?since=${encodeURIComponent(flags.since)}` : ""; out(await c.getJSON(`/plumbing/structures/${sid}/routes/${rid}/logs${q}`)); }, + // List the saved versions (newest first) of a route handler, template, or db + // library. Each row is a summary (id, created_at, bytes) — `bliss version + // ` fetches the full snapshot. + async versions({ pos }) { + const [type, sid, id] = pos; + const paths = { route: "routes", template: "templates", db: "dbs" }; + need( + paths[type] && sid && id, + "usage: bliss versions ", + ); + out( + await c.getJSON( + `/plumbing/structures/${sid}/${paths[type]}/${id}/versions`, + ), + ); + }, + // One version including its full snapshot (the versioned fields). + async version({ pos }) { + const vid = need(pos[0], "usage: bliss version "); + out(await c.getJSON(`/plumbing/versions/${vid}`)); + }, // ---- writes (via /workshop) ---- async "create-structure"({ pos }) { const name = need(pos[0], "usage: bliss create-structure "); out(await c.writeAndGetId("POST", "/workshop", { name })); }, + // Edit a structure's settings (route prefix, head injection). The settings + // endpoint wants both fields, so we fetch the current structure and only + // override what was passed — leaving the other untouched. + async "update-settings"({ pos, flags }) { + const sid = need( + pos[0], + "usage: bliss update-settings [--route-prefix

] [--head-injection | --head-injection-file ]", + ); + const { structure } = await c.getJSON(`/plumbing/structures/${sid}`); + const head = + flags["head-injection-file"] !== undefined + ? readSource(flags["head-injection-file"]) + : flags["head-injection"]; + const body = { + route_prefix: flags["route-prefix"] ?? structure.route_prefix ?? "", + head_injection: head ?? structure.head_injection ?? "", + }; + out(await c.writeText("PUT", `/workshop/${sid}/settings`, body)); + }, async clone({ pos, flags }) { const sid = need( pos[0], diff --git a/bliss-cli/client.js b/bliss-cli/client.js index 9035806..a82c14f 100644 --- a/bliss-cli/client.js +++ b/bliss-cli/client.js @@ -10,16 +10,37 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); -const BASE = (process.env.BLISS_URL || "http://localhost:3000").replace( - /\/$/, - "", -); +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_DIR = path.join(os.homedir(), ".bliss"); const JAR_FILE = path.join( JAR_DIR, "session-" + Buffer.from(BASE).toString("hex").slice(0, 24) + ".txt", @@ -146,6 +167,7 @@ async function getText(urlPath) { module.exports = { BASE, + setTarget, request, getJSON, getText, diff --git a/db.js b/db.js index d4c2b90..83f7ce0 100644 --- a/db.js +++ b/db.js @@ -137,6 +137,8 @@ function createRoute(verb, path, structureId, handler) { createScaffoldPage(routeId); } + recordVersion("route", routeId, structureId, { verb, path, handler }); + return routeId; // Returns the route_id of the newly created route } @@ -175,12 +177,66 @@ function update(table, fields, obj) { return db.prepare(`UPDATE ${table} SET ${placeholders} WHERE id = ?`).run(...values); } +// ---- version history (append-only) -------------------------------------- +// +// Snapshot the code-bearing fields of a route/template/db every time they +// change, so nothing a user (or the plumber) edits is ever truly lost. Deduped +// against the latest snapshot for that entity, so no-op saves, error-flag-only +// route writes, and server-restart WS re-bootstraps don't pile up noise. +// snapshotFor() defines exactly which fields are versioned per type; keep the +// key order stable so the dedup string compare is reliable. +function snapshotFor(entityType, obj) { + switch (entityType) { + case "route": + return { verb: obj.verb, path: obj.path, handler: obj.handler }; + case "template": + return { name: obj.name, content: obj.content, test_object: obj.test_object }; + case "db": + return { name: obj.name, library: obj.library }; + default: + throw new Error(`unknown version entity type: ${entityType}`); + } +} + +function recordVersion(entityType, entityId, structureId, obj) { + const json = JSON.stringify(snapshotFor(entityType, obj)); + const last = db + .prepare( + "SELECT snapshot FROM versions WHERE entity_type = ? AND entity_id = ? ORDER BY id DESC LIMIT 1", + ) + .get(entityType, entityId); + if (last && last.snapshot === json) return; // content unchanged — skip + db.prepare( + "INSERT INTO versions (entity_type, entity_id, structure_id, snapshot) VALUES (?, ?, ?, ?)", + ).run(entityType, entityId, structureId, json); +} + +// Version list for one entity (newest first). Omits the snapshot body to stay +// scannable; fetch a single version to get its full content. +function getVersions(entityType, entityId) { + return db + .prepare( + `SELECT id, entity_type, entity_id, structure_id, created_at, length(snapshot) AS bytes + FROM versions WHERE entity_type = ? AND entity_id = ? ORDER BY id DESC`, + ) + .all(entityType, entityId); +} + +// One version with its full snapshot parsed back into fields. +function getVersion(versionId) { + const row = db.prepare("SELECT * FROM versions WHERE id = ?").get(versionId); + if (!row) return null; + return { ...row, snapshot: JSON.parse(row.snapshot) }; +} + function updateRoute(route) { update("routes", ["verb", "path", "structure_id", "handler", "updated_at", "error"], route); + recordVersion("route", route.id, route.structure_id, route); } function updateDb(appDb) { update("dbs", ["name", "library"], appDb); + recordVersion("db", appDb.id, appDb.structure_id, appDb); } function updateStruct(struct) { @@ -189,6 +245,7 @@ function updateStruct(struct) { function updateTemplate(template) { update("templates", ["content", "name", "test_object"], template); + recordVersion("template", template.id, template.structure_id, template); } function getTemplates(structureId) { @@ -210,11 +267,17 @@ function getTemplateContentByName(structId, name) { } function createTemplate(structureId, name, content, testObjectString) { - return db + const id = db .prepare( "INSERT INTO templates (structure_id, name, content, test_object) VALUES (?, ?, ?, ?)", ) .run(structureId, name, content, testObjectString).lastInsertRowid; + recordVersion("template", id, structureId, { + name, + content, + test_object: testObjectString, + }); + return id; } function getDbsForStructure(structureId) { @@ -282,7 +345,9 @@ function createDb(structId, name) { return dbId; }); - return transaction(); + const dbId = transaction(); + recordVersion("db", dbId, structId, { name, library: "" }); + return dbId; } function attachDb(structId, dbId, alias) { @@ -615,6 +680,8 @@ module.exports = { updateDb, updateStruct, updateTemplate, + getVersions, + getVersion, getTemplater, getTemplates, getTemplate, diff --git a/migrations/004_add_versions.sql b/migrations/004_add_versions.sql new file mode 100644 index 0000000..bed6d9e --- /dev/null +++ b/migrations/004_add_versions.sql @@ -0,0 +1,15 @@ +-- Append-only version history for the code-bearing entities: route handlers, +-- template content, and db libraries. Every content-changing save records a +-- snapshot here (see recordVersion in db.js). The live rows in routes/templates/ +-- dbs remain the current version; this table is the "plumber's" undo trail. +CREATE TABLE versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL CHECK(entity_type IN ('route', 'template', 'db')), + entity_id INTEGER NOT NULL, + structure_id INTEGER, + snapshot TEXT NOT NULL, -- JSON of the versioned fields + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_versions_entity ON versions (entity_type, entity_id, id); +CREATE INDEX idx_versions_structure ON versions (structure_id, id); diff --git a/plumbing.js b/plumbing.js index 37691aa..7fb1e69 100644 --- a/plumbing.js +++ b/plumbing.js @@ -72,6 +72,27 @@ router.get( json((req) => model.getFilesForStruct(req.params.id)), ); +// Version history (newest first) for a route handler, template, or db library. +// Each row is a summary; fetch /versions/:versionId for the full snapshot. +router.get( + "/structures/:id/routes/:routeId/versions", + json((req) => model.getVersions("route", req.params.routeId)), +); +router.get( + "/structures/:id/templates/:templateId/versions", + json((req) => model.getVersions("template", req.params.templateId)), +); +router.get( + "/structures/:id/dbs/:dbId/versions", + json((req) => model.getVersions("db", req.params.dbId)), +); + +// One version, including its full snapshot (the versioned fields). +router.get( + "/versions/:versionId", + json((req) => model.getVersion(req.params.versionId)), +); + // Logs for a route. ?since= returns only newer rows (for polling). router.get( "/structures/:id/routes/:routeId/logs",