feat: append-only version history + bliss CLI ergonomics

Snapshot the code-bearing fields of routes (verb/path/handler), templates
(name/content/test_object), and db libraries (name/library) on every
content-changing save, so nothing edited is ever lost. Recorded in a new
`versions` table via recordVersion() hooked into the create/update functions
in db.js (the single write choke point), deduped against the latest snapshot
so no-op saves, error-flag-only route writes, and server-restart WS
re-bootstraps don't pile up. The live row stays current; old snapshots are the
undo trail. Read-only, no revert UI yet.

- migrations/004_add_versions.sql: versions table + indexes
- db.js: recordVersion/getVersions/getVersion + create/update hooks
- plumbing.js: /versions/:id and per-entity .../versions list endpoints
- bliss: `versions <type> <sid> <id>` and `version <id>` reads;
  `update-settings` (edit route_prefix/head_injection, preserving the
  untouched field); `use <url>` sticky target persisted to ~/.bliss/target
  so BLISS_URL needn't be re-exported each call
- client.js: target-file precedence (BLISS_URL > ~/.bliss/target > localhost)
- SKILL.md: document the above

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-08-03 00:27:24 -04:00
parent 03a9d2143d
commit 42f4aacf7c
6 changed files with 211 additions and 7 deletions

View file

@ -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 <command>` (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 <sid> <tid> # one template incl. content + test_objec
bliss db <sid> <dbid> # one db incl. its library source
bliss files <sid> # files attached to a structure
bliss logs <sid> <rid> [--since <id>] # route logs (console.log output + errors)
bliss versions <route|template|db> <sid> <id> # save history (newest first)
bliss version <versionId> # 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 <id>` 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 "<name>" # -> {location, id}
bliss update-settings <sid> [--route-prefix <p>] [--head-injection <s>|--head-injection-file <f>]
bliss clone <sid> --name "<n>" --prefix /p [--clone-dbs]
bliss create-route <sid> <VERB> <path> # VERB: GET|POST|PUT|DELETE|WS ; -> {id}

View file

@ -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
// <id>` 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 <route|template|db> <structureId> <id>",
);
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 <versionId>");
out(await c.getJSON(`/plumbing/versions/${vid}`));
},
// ---- writes (via /workshop) ----
async "create-structure"({ pos }) {
const name = need(pos[0], "usage: bliss create-structure <name>");
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 <structureId> [--route-prefix <p>] [--head-injection <s> | --head-injection-file <f>]",
);
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],

View file

@ -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,