featchoremiscmiscmisc
This commit is contained in:
parent
fb3d904f5b
commit
44f345fff0
8 changed files with 914 additions and 0 deletions
108
backup-deployed.sh
Executable file
108
backup-deployed.sh
Executable file
|
|
@ -0,0 +1,108 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
ACTIVATE=false
|
||||
if [[ "${1:-}" == "--activate" ]]; then
|
||||
ACTIVATE=true
|
||||
shift
|
||||
fi
|
||||
if [[ "$#" -ne 0 ]]; then
|
||||
echo "Usage: $0 [--activate]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
REMOTE="${BLISS_BACKUP_REMOTE:-root@134.122.14.193}"
|
||||
APP_DIR="${BLISS_BACKUP_APP_DIR:-/home/nodejs/bliss2}"
|
||||
LOCAL_DIR="${BLISS_BACKUP_LOCAL_DIR:-$(pwd)/backups}"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
ARCHIVE="$LOCAL_DIR/bliss-backup-$STAMP.tar.gz"
|
||||
PARTIAL="$ARCHIVE.partial"
|
||||
|
||||
mkdir -p "$LOCAL_DIR"
|
||||
rm -f "$PARTIAL"
|
||||
|
||||
cleanup() {
|
||||
rm -f "$PARTIAL"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Backing up $REMOTE:$APP_DIR"
|
||||
echo "You may be prompted for the droplet passcode."
|
||||
|
||||
ssh -o ConnectTimeout=15 "$REMOTE" bash -s -- "$APP_DIR" >"$PARTIAL" <<'REMOTE_SCRIPT'
|
||||
set -Eeuo pipefail
|
||||
|
||||
APP_DIR="$1"
|
||||
test -d "$APP_DIR/public"
|
||||
test -d "$APP_DIR/dbs"
|
||||
test -f "$APP_DIR/package.json"
|
||||
|
||||
TMP_DIR="$(mktemp -d /root/bliss-backup.XXXXXX)"
|
||||
cleanup_remote() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup_remote EXIT
|
||||
|
||||
mkdir -p "$TMP_DIR/dbs"
|
||||
cd "$APP_DIR"
|
||||
|
||||
echo "Creating consistent SQLite snapshots..." >&2
|
||||
BACKUP_DIR="$TMP_DIR/dbs" node <<'NODE'
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
(async () => {
|
||||
const names = fs.readdirSync("dbs").filter(name => name.endsWith(".sqlite"));
|
||||
if (names.length === 0) throw new Error("No SQLite databases found in dbs/");
|
||||
|
||||
for (const name of names) {
|
||||
const source = new Database(path.resolve("dbs", name), { readonly: true });
|
||||
const destination = path.join(process.env.BACKUP_DIR, name);
|
||||
await source.backup(destination);
|
||||
source.close();
|
||||
|
||||
const snapshot = new Database(destination, { readonly: true });
|
||||
const result = snapshot.pragma("quick_check", { simple: true });
|
||||
snapshot.close();
|
||||
if (result !== "ok") throw new Error(`${name}: SQLite quick_check returned ${result}`);
|
||||
console.error(` ${name}: OK`);
|
||||
}
|
||||
})().catch(error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
|
||||
echo "Copying static files..." >&2
|
||||
cp -a public "$TMP_DIR/public"
|
||||
|
||||
printf 'source=%s\ncreated_utc=%s\n' \
|
||||
"$APP_DIR" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$TMP_DIR/MANIFEST.txt"
|
||||
|
||||
echo "Streaming compressed archive..." >&2
|
||||
tar -C "$TMP_DIR" -czf - MANIFEST.txt public dbs
|
||||
REMOTE_SCRIPT
|
||||
|
||||
echo "Verifying downloaded archive..."
|
||||
gzip -t "$PARTIAL"
|
||||
tar -tzf "$PARTIAL" >/dev/null
|
||||
|
||||
DB_COUNT="$(tar -tzf "$PARTIAL" | awk '/^dbs\/[^/]+\.sqlite$/ {count++} END {print count+0}')"
|
||||
if [[ "$DB_COUNT" -eq 0 ]]; then
|
||||
echo "Backup verification failed: archive contains no SQLite databases" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mv "$PARTIAL" "$ARCHIVE"
|
||||
trap - EXIT
|
||||
|
||||
echo "Backup complete: $ARCHIVE"
|
||||
echo "SQLite databases: $DB_COUNT"
|
||||
du -h "$ARCHIVE"
|
||||
|
||||
if [[ "$ACTIVATE" == true ]]; then
|
||||
echo
|
||||
"$(dirname "$0")/switch-bliss-data.sh" "$ARCHIVE"
|
||||
fi
|
||||
102
bliss-cli/SKILL.md
Normal file
102
bliss-cli/SKILL.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
---
|
||||
name: bliss
|
||||
description: Drive a remote Bliss instance from the command line the way a human uses the /workshop UI — create/edit structures, routes, templates, databases, run the db REPL, read logs. Use when asked to build, inspect, or modify Bliss Structures against a running instance.
|
||||
---
|
||||
|
||||
# bliss CLI
|
||||
|
||||
`bliss` talks to a running Bliss instance exactly as a human does through the
|
||||
workshop UI. **Reads** come back as JSON from the `/plumbing` API (pipe into
|
||||
`jq`). **Writes** go through the same `/workshop/*` endpoints the UI posts to.
|
||||
|
||||
## Setup
|
||||
|
||||
The CLI is at `bliss-cli/bliss` in the repo. Configure via env vars:
|
||||
|
||||
```bash
|
||||
export BLISS_URL=https://your-instance.example.com # default http://localhost:3000
|
||||
export BLISS_USER=you
|
||||
export BLISS_PASS=secret
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
## The model (what you're editing)
|
||||
|
||||
A **Structure** is a mini web app. It has **routes** (a VERB + path + a JS
|
||||
`handler` string run in a sandbox), **templates** (Eta, with a `test_object`
|
||||
used for preview), and **databases** (each with a `library` JS module and its
|
||||
own SQLite file). Ids are integers. Build a structure by: create it → add a db →
|
||||
write its library → add routes whose handlers call the library → add templates
|
||||
the handlers render.
|
||||
|
||||
## Reads (JSON)
|
||||
|
||||
```bash
|
||||
bliss structures # list all structures
|
||||
bliss structure <sid> # structure + its routes, templates, dbs, files
|
||||
bliss route <sid> <rid> # one route incl. its handler source
|
||||
bliss template <sid> <tid> # one template incl. content + test_object
|
||||
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)
|
||||
```
|
||||
|
||||
All emit JSON, so: `bliss structure 3 | jq '.routes[].path'`.
|
||||
|
||||
## Writes
|
||||
|
||||
```bash
|
||||
bliss create-structure "<name>" # -> {location, id}
|
||||
bliss clone <sid> --name "<n>" --prefix /p [--clone-dbs]
|
||||
|
||||
bliss create-route <sid> <VERB> <path> # VERB: GET|POST|PUT|DELETE|WS ; -> {id}
|
||||
bliss update-route <sid> <rid> --handler-file <f|-> # replace handler code
|
||||
[--scaffold-file <f>] [--path <p>] [--verb <v>]
|
||||
|
||||
bliss create-template <sid> "<name>" # -> {id}
|
||||
bliss update-template <sid> <tid> --content-file <f|-> [--test-object-file <f>]
|
||||
|
||||
bliss create-db <sid> "<name>" # -> {id}
|
||||
bliss attach-db <sid> <dbid> <alias> # share an existing db under an alias
|
||||
bliss update-library <sid> <dbid> --file <f|-> # returns eval output/errors
|
||||
|
||||
bliss repl <sid> <dbid> '<code>' # run JS/SQL against the db; --file <f|-> also works
|
||||
bliss upload <sid> <path> # upload a static file
|
||||
```
|
||||
|
||||
## Previews
|
||||
|
||||
```bash
|
||||
bliss preview-route <sid> <rid> # rendered HTML of a GET route
|
||||
bliss preview-template <sid> <tid> # rendered HTML using the template's test_object
|
||||
```
|
||||
|
||||
## Editing code (handlers, templates, library)
|
||||
|
||||
Handlers/templates/library are multiline code. Write the code to a temp file and
|
||||
pass `--*-file`, or pipe via `-` (stdin):
|
||||
|
||||
```bash
|
||||
cat > /tmp/handler.js <<'EOF'
|
||||
function handler(req, res) {
|
||||
const { library } = require('db')('main');
|
||||
res.render('home', { items: library.all() });
|
||||
}
|
||||
EOF
|
||||
bliss update-route 3 12 --handler-file /tmp/handler.js
|
||||
|
||||
echo "function handler(req,res){ res.send('hi') }" | bliss update-route 3 12 --handler-file -
|
||||
```
|
||||
|
||||
The db `library` runs with `sql` (the better-sqlite3 instance) in scope and sets
|
||||
`module.exports`. Test it fast with `bliss repl <sid> <dbid> 'library.someFn()'`.
|
||||
|
||||
## Typical loop
|
||||
|
||||
1. `bliss structures` / `bliss structure <sid>` to orient.
|
||||
2. Edit a handler/template/library into a temp file, push with the matching
|
||||
`update-*` command.
|
||||
3. `bliss preview-route` / `bliss repl` to see the result; `bliss logs` to debug.
|
||||
253
bliss-cli/bliss
Executable file
253
bliss-cli/bliss
Executable file
|
|
@ -0,0 +1,253 @@
|
|||
#!/usr/bin/env node
|
||||
// bliss — drive a remote Bliss instance the way a human uses the workshop UI.
|
||||
//
|
||||
// Reads -> /plumbing/* (JSON, pipe into jq/grep)
|
||||
// Writes -> /workshop/* (same endpoints the UI posts to)
|
||||
//
|
||||
// Config via env: BLISS_URL (default http://localhost:3000), BLISS_USER, BLISS_PASS.
|
||||
// Multiline code (handlers, templates, library, repl) is read from a --file, or
|
||||
// from stdin when the file arg is "-".
|
||||
|
||||
const fs = require("fs");
|
||||
const c = require("./client");
|
||||
|
||||
// Don't crash with a stack trace when piped into `head`/`grep -q` etc.
|
||||
process.stdout.on("error", (e) => {
|
||||
if (e.code === "EPIPE") process.exit(0);
|
||||
throw e;
|
||||
});
|
||||
|
||||
// ---- tiny arg parser: positionals + --flags (--flag value, or bare --flag) --
|
||||
function parseArgs(argv) {
|
||||
const pos = [];
|
||||
const flags = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a.startsWith("--")) {
|
||||
const key = a.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith("--")) {
|
||||
flags[key] = true;
|
||||
} else {
|
||||
flags[key] = next;
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
pos.push(a);
|
||||
}
|
||||
}
|
||||
return { pos, flags };
|
||||
}
|
||||
|
||||
function readSource(fileArg) {
|
||||
if (fileArg === undefined) return undefined;
|
||||
if (fileArg === "-") return fs.readFileSync(0, "utf8"); // stdin
|
||||
return fs.readFileSync(fileArg, "utf8");
|
||||
}
|
||||
|
||||
function out(v) {
|
||||
process.stdout.write(typeof v === "string" ? v : JSON.stringify(v, null, 2));
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
|
||||
function need(val, msg) {
|
||||
if (val === undefined || val === "") {
|
||||
throw new Error(msg);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
const commands = {
|
||||
// ---- reads (JSON via /plumbing) ----
|
||||
async structures() {
|
||||
out(await c.getJSON("/plumbing/structures"));
|
||||
},
|
||||
async structure({ pos }) {
|
||||
const id = need(pos[0], "usage: bliss structure <structureId>");
|
||||
out(await c.getJSON(`/plumbing/structures/${id}`));
|
||||
},
|
||||
async route({ pos }) {
|
||||
const [sid, rid] = pos;
|
||||
need(sid && rid, "usage: bliss route <structureId> <routeId>");
|
||||
out(await c.getJSON(`/plumbing/structures/${sid}/routes/${rid}`));
|
||||
},
|
||||
async template({ pos }) {
|
||||
const [sid, tid] = pos;
|
||||
need(sid && tid, "usage: bliss template <structureId> <templateId>");
|
||||
out(await c.getJSON(`/plumbing/structures/${sid}/templates/${tid}`));
|
||||
},
|
||||
async db({ pos }) {
|
||||
const [sid, dbid] = pos;
|
||||
need(sid && dbid, "usage: bliss db <structureId> <dbId>");
|
||||
out(await c.getJSON(`/plumbing/structures/${sid}/dbs/${dbid}`));
|
||||
},
|
||||
async files({ pos }) {
|
||||
const sid = need(pos[0], "usage: bliss files <structureId>");
|
||||
out(await c.getJSON(`/plumbing/structures/${sid}/files`));
|
||||
},
|
||||
async logs({ pos, flags }) {
|
||||
const [sid, rid] = pos;
|
||||
need(
|
||||
sid && rid,
|
||||
"usage: bliss logs <structureId> <routeId> [--since <id>]",
|
||||
);
|
||||
const q = flags.since ? `?since=${encodeURIComponent(flags.since)}` : "";
|
||||
out(await c.getJSON(`/plumbing/structures/${sid}/routes/${rid}/logs${q}`));
|
||||
},
|
||||
|
||||
// ---- writes (via /workshop) ----
|
||||
async "create-structure"({ pos }) {
|
||||
const name = need(pos[0], "usage: bliss create-structure <name>");
|
||||
out(await c.writeAndGetId("POST", "/workshop", { name }));
|
||||
},
|
||||
async clone({ pos, flags }) {
|
||||
const sid = need(
|
||||
pos[0],
|
||||
"usage: bliss clone <structureId> --name <n> --prefix <p> [--clone-dbs]",
|
||||
);
|
||||
out(
|
||||
await c.writeAndGetId("POST", `/workshop/${sid}/clone`, {
|
||||
name: need(flags.name, "--name required"),
|
||||
route_prefix: need(flags.prefix, "--prefix required"),
|
||||
clone_dbs: flags["clone-dbs"] ? "on" : "",
|
||||
}),
|
||||
);
|
||||
},
|
||||
async "create-route"({ pos }) {
|
||||
const [sid, verb, path] = pos;
|
||||
need(
|
||||
sid && verb && path,
|
||||
"usage: bliss create-route <structureId> <VERB> <path>",
|
||||
);
|
||||
out(
|
||||
await c.writeAndGetId("POST", `/workshop/${sid}/route`, { verb, path }),
|
||||
);
|
||||
},
|
||||
async "update-route"({ pos, flags }) {
|
||||
const [sid, rid] = pos;
|
||||
need(
|
||||
sid && rid,
|
||||
"usage: bliss update-route <structureId> <routeId> [--handler-file <f|->] [--scaffold-file <f>] [--path <p>] [--verb <v>]",
|
||||
);
|
||||
const body = {};
|
||||
const handler = readSource(flags["handler-file"]);
|
||||
if (handler !== undefined) body.handler = handler;
|
||||
const scaffold = readSource(flags["scaffold-file"]);
|
||||
if (scaffold !== undefined) body.scaffold_page = scaffold;
|
||||
if (flags.path) body.path = flags.path;
|
||||
if (flags.verb) body.verb = flags.verb;
|
||||
out(await c.writeText("PUT", `/workshop/${sid}/route/${rid}`, body));
|
||||
},
|
||||
async "create-template"({ pos }) {
|
||||
const [sid, name] = pos;
|
||||
need(sid && name, "usage: bliss create-template <structureId> <name>");
|
||||
out(await c.writeAndGetId("POST", `/workshop/${sid}/template`, { name }));
|
||||
},
|
||||
async "update-template"({ pos, flags }) {
|
||||
const [sid, tid] = pos;
|
||||
need(
|
||||
sid && tid,
|
||||
"usage: bliss update-template <structureId> <templateId> --content-file <f|-> [--test-object-file <f>]",
|
||||
);
|
||||
const body = {};
|
||||
const content = readSource(flags["content-file"]);
|
||||
if (content !== undefined) body.content = content;
|
||||
const testObj = readSource(flags["test-object-file"]);
|
||||
if (testObj !== undefined) body.test_object = testObj;
|
||||
out(await c.writeText("PUT", `/workshop/${sid}/template/${tid}`, body));
|
||||
},
|
||||
async "create-db"({ pos }) {
|
||||
const [sid, name] = pos;
|
||||
need(sid && name, "usage: bliss create-db <structureId> <name>");
|
||||
out(await c.writeAndGetId("POST", `/workshop/${sid}/db`, { name }));
|
||||
},
|
||||
async "attach-db"({ pos }) {
|
||||
const [sid, dbid, alias] = pos;
|
||||
need(
|
||||
sid && dbid && alias,
|
||||
"usage: bliss attach-db <structureId> <dbId> <alias>",
|
||||
);
|
||||
out(
|
||||
await c.writeText("POST", `/workshop/${sid}/db/attach`, {
|
||||
db_id: dbid,
|
||||
alias,
|
||||
}),
|
||||
);
|
||||
},
|
||||
async "update-library"({ pos, flags }) {
|
||||
const [sid, dbid] = pos;
|
||||
need(
|
||||
sid && dbid,
|
||||
"usage: bliss update-library <structureId> <dbId> --file <f|->",
|
||||
);
|
||||
const library = need(readSource(flags.file), "--file required");
|
||||
out(
|
||||
await c.writeText("PUT", `/workshop/${sid}/db/${dbid}/library`, {
|
||||
library,
|
||||
}),
|
||||
);
|
||||
},
|
||||
async repl({ pos, flags }) {
|
||||
const [sid, dbid] = pos;
|
||||
need(
|
||||
sid && dbid,
|
||||
"usage: bliss repl <structureId> <dbId> ['<code>' | --file <f|->]",
|
||||
);
|
||||
const code = flags.file !== undefined ? readSource(flags.file) : pos[2];
|
||||
need(code, "provide code inline or via --file");
|
||||
out(
|
||||
await c.writeText("POST", `/workshop/${sid}/db/${dbid}/repl`, { code }),
|
||||
);
|
||||
},
|
||||
async "preview-route"({ pos }) {
|
||||
const [sid, rid] = pos;
|
||||
need(sid && rid, "usage: bliss preview-route <structureId> <routeId>");
|
||||
out(await c.getText(`/workshop/${sid}/route/${rid}/preview`));
|
||||
},
|
||||
async "preview-template"({ pos }) {
|
||||
const [sid, tid] = pos;
|
||||
need(
|
||||
sid && tid,
|
||||
"usage: bliss preview-template <structureId> <templateId>",
|
||||
);
|
||||
out(await c.getText(`/workshop/${sid}/template/${tid}/preview`));
|
||||
},
|
||||
async upload({ pos }) {
|
||||
const [sid, filepath] = pos;
|
||||
need(sid && filepath, "usage: bliss upload <structureId> <filepath>");
|
||||
const buf = fs.readFileSync(filepath);
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([buf]), require("path").basename(filepath));
|
||||
const res = await c.request("POST", `/workshop/${sid}/files`, {
|
||||
body: form,
|
||||
});
|
||||
out(await res.text());
|
||||
},
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
if (!cmd || cmd === "help" || cmd === "--help") {
|
||||
out(
|
||||
"bliss <command> [args] (against " +
|
||||
c.BASE +
|
||||
")\n\n" +
|
||||
Object.keys(commands)
|
||||
.sort()
|
||||
.map((k) => " " + k)
|
||||
.join("\n"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const handler = commands[cmd];
|
||||
if (!handler) {
|
||||
process.stderr.write("unknown command: " + cmd + " (try `bliss help`)\n");
|
||||
process.exit(1);
|
||||
}
|
||||
await handler(parseArgs(rest));
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
process.stderr.write("error: " + e.message + "\n");
|
||||
process.exit(1);
|
||||
});
|
||||
154
bliss-cli/client.js
Normal file
154
bliss-cli/client.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// 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 BASE = (process.env.BLISS_URL || "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",
|
||||
);
|
||||
|
||||
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,
|
||||
request,
|
||||
getJSON,
|
||||
getText,
|
||||
writeAndGetId,
|
||||
writeText,
|
||||
};
|
||||
1
index.js
1
index.js
|
|
@ -57,6 +57,7 @@ app.use(
|
|||
);
|
||||
|
||||
app.use("/", wsRouter);
|
||||
app.use("/plumbing", require("./plumbing"));
|
||||
|
||||
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY
|
||||
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY
|
||||
|
|
|
|||
88
plumbing.js
Normal file
88
plumbing.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// plumbing.js — read-only JSON utility API.
|
||||
//
|
||||
// This is plumbing, not part of the product surface. The workshop UI renders
|
||||
// HTML; anything that wants structured data (the bliss CLI, scripts, tooling)
|
||||
// reads it here instead of scraping fragments. Read-only by design: all writes
|
||||
// still go through the real /workshop/* endpoints a human uses, so this stays a
|
||||
// thin mirror of model.* with no business logic of its own.
|
||||
|
||||
const express = require("express");
|
||||
const model = require("./db");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Wrap a handler so thrown errors come back as JSON instead of an HTML stack.
|
||||
function json(handler) {
|
||||
return (req, res) => {
|
||||
try {
|
||||
const body = handler(req);
|
||||
if (body === undefined || body === null) {
|
||||
return res.status(404).json({ error: "not found" });
|
||||
}
|
||||
return res.json(body);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: String(e), stack: e.stack });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// All structures.
|
||||
router.get(
|
||||
"/structures",
|
||||
json(() => model.getStructures()),
|
||||
);
|
||||
|
||||
// One structure with everything the sidebar shows, in one call.
|
||||
router.get(
|
||||
"/structures/:id",
|
||||
json((req) => {
|
||||
const structure = model.getStructure(req.params.id);
|
||||
if (!structure) return null;
|
||||
return {
|
||||
structure,
|
||||
routes: model.getRoutes(req.params.id),
|
||||
templates: model.getTemplates(req.params.id),
|
||||
dbs: model.getDbsForStructure(req.params.id),
|
||||
files: model.getFilesForStruct(req.params.id),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// One route, including its handler source.
|
||||
router.get(
|
||||
"/structures/:id/routes/:routeId",
|
||||
json((req) => model.getRoute(req.params.routeId)),
|
||||
);
|
||||
|
||||
// One template, including content + test_object.
|
||||
router.get(
|
||||
"/structures/:id/templates/:templateId",
|
||||
json((req) => model.getTemplate(req.params.templateId)),
|
||||
);
|
||||
|
||||
// One db (scoped to the structure), including its library source.
|
||||
router.get(
|
||||
"/structures/:id/dbs/:dbId",
|
||||
json((req) => model.getDbForStructure(req.params.id, req.params.dbId)),
|
||||
);
|
||||
|
||||
// Files attached to a structure.
|
||||
router.get(
|
||||
"/structures/:id/files",
|
||||
json((req) => model.getFilesForStruct(req.params.id)),
|
||||
);
|
||||
|
||||
// Logs for a route. ?since=<id> returns only newer rows (for polling).
|
||||
router.get(
|
||||
"/structures/:id/routes/:routeId/logs",
|
||||
json((req) => {
|
||||
const { since } = req.query;
|
||||
const logs =
|
||||
since !== undefined
|
||||
? model.getNewLogsByRoute(req.params.routeId, since)
|
||||
: model.getLogsByRoute(req.params.routeId);
|
||||
return { logs, since: model.getMostRecentLogIdByRoute(req.params.routeId) };
|
||||
}),
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
94
switch-bliss-data.sh
Executable file
94
switch-bliss-data.sh
Executable file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 BACKUP.tar.gz" >&2
|
||||
}
|
||||
|
||||
if [[ "$#" -ne 1 ]]; then
|
||||
usage
|
||||
exit 2
|
||||
fi
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INPUT="$1"
|
||||
if [[ "$INPUT" != /* ]]; then
|
||||
INPUT="$(cd "$(dirname "$INPUT")" && pwd)/$(basename "$INPUT")"
|
||||
fi
|
||||
|
||||
test -f "$INPUT" || { echo "Archive not found: $INPUT" >&2; exit 1; }
|
||||
gzip -t "$INPUT"
|
||||
|
||||
STAGE="$(mktemp -d "$ROOT_DIR/.data-switch.XXXXXX")"
|
||||
OLD="$(mktemp -d "$ROOT_DIR/.data-previous.XXXXXX")"
|
||||
COMMITTED=false
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$STAGE"
|
||||
if [[ "$COMMITTED" == false ]]; then
|
||||
if [[ -d "$OLD/public" ]]; then
|
||||
rm -rf "$ROOT_DIR/public"
|
||||
mv "$OLD/public" "$ROOT_DIR/public"
|
||||
fi
|
||||
if [[ -d "$OLD/dbs" ]]; then
|
||||
rm -rf "$ROOT_DIR/dbs"
|
||||
mv "$OLD/dbs" "$ROOT_DIR/dbs"
|
||||
fi
|
||||
fi
|
||||
rm -rf "$OLD"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
tar -C "$STAGE" -xzf "$INPUT"
|
||||
test -d "$STAGE/public" || { echo "Archive has no public/ directory" >&2; exit 1; }
|
||||
test -d "$STAGE/dbs" || { echo "Archive has no dbs/ directory" >&2; exit 1; }
|
||||
|
||||
mapfile -t DATABASES < <(find "$STAGE/dbs" -maxdepth 1 -type f -name '*.sqlite' -print)
|
||||
if [[ "${#DATABASES[@]}" -eq 0 ]]; then
|
||||
echo "Archive contains no SQLite databases" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for database in "${DATABASES[@]}"; do
|
||||
DB_FILE="$database" node <<'NODE'
|
||||
const Database = require("better-sqlite3");
|
||||
const db = new Database(process.env.DB_FILE, { readonly: true });
|
||||
const result = db.pragma("quick_check", { simple: true });
|
||||
db.close();
|
||||
if (result !== "ok") {
|
||||
console.error(`${process.env.DB_FILE}: quick_check returned ${result}`);
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
done
|
||||
|
||||
for process_dir in /proc/[0-9]*; do
|
||||
PROCESS_CWD="$(readlink "$process_dir/cwd" 2>/dev/null || true)"
|
||||
PROCESS_CMD="$(tr '\0' ' ' < "$process_dir/cmdline" 2>/dev/null || true)"
|
||||
if [[ "$PROCESS_CWD" == "$ROOT_DIR" ]] &&
|
||||
[[ "$PROCESS_CMD" =~ (^|[[:space:]])(\./)?index\.js([[:space:]]|$) ]]; then
|
||||
echo "Refusing to switch data while the local Bliss server is running." >&2
|
||||
echo "Stop it first, then rerun this command." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
PREVIOUS="$ROOT_DIR/backups/bliss-local-before-$STAMP.tar.gz"
|
||||
mkdir -p "$ROOT_DIR/backups"
|
||||
|
||||
echo "Saving current local data to $PREVIOUS"
|
||||
tar -C "$ROOT_DIR" -czf "$PREVIOUS" public dbs
|
||||
gzip -t "$PREVIOUS"
|
||||
|
||||
mv "$ROOT_DIR/public" "$OLD/public"
|
||||
mv "$ROOT_DIR/dbs" "$OLD/dbs"
|
||||
mv "$STAGE/public" "$ROOT_DIR/public"
|
||||
mv "$STAGE/dbs" "$ROOT_DIR/dbs"
|
||||
COMMITTED=true
|
||||
|
||||
echo "Activated data from: $INPUT"
|
||||
echo "Previous local data: $PREVIOUS"
|
||||
echo "To switch back:"
|
||||
printf ' %q %q\n' "$0" "$PREVIOUS"
|
||||
114
update-deployed.sh
Executable file
114
update-deployed.sh
Executable file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
REMOTE="${BLISS_DEPLOY_REMOTE:-root@134.122.14.193}"
|
||||
APP_DIR="${BLISS_DEPLOY_APP_DIR:-/home/nodejs/bliss2}"
|
||||
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
LOCAL_BRANCH="${BLISS_DEPLOY_BRANCH:-main}"
|
||||
TRANSFER_DIR="$(mktemp -d)"
|
||||
CONTROL_SOCKET="$TRANSFER_DIR/ssh-control"
|
||||
LOCAL_BUNDLE="$TRANSFER_DIR/bliss.bundle"
|
||||
|
||||
cleanup() {
|
||||
ssh -S "$CONTROL_SOCKET" -O exit "$REMOTE" >/dev/null 2>&1 || true
|
||||
rm -rf "$TRANSFER_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Updating $REMOTE:$APP_DIR"
|
||||
echo "You may be prompted for the droplet passcode."
|
||||
|
||||
git -C "$ROOT_DIR" rev-parse --verify "$LOCAL_BRANCH" >/dev/null
|
||||
echo "Bundling committed local branch $LOCAL_BRANCH..."
|
||||
git -C "$ROOT_DIR" bundle create "$LOCAL_BUNDLE" "$LOCAL_BRANCH"
|
||||
|
||||
ssh -M -S "$CONTROL_SOCKET" -o ControlPersist=60 -o ConnectTimeout=15 -fnNT "$REMOTE"
|
||||
REMOTE_BUNDLE="$(ssh -S "$CONTROL_SOCKET" "$REMOTE" mktemp /tmp/bliss-deploy.XXXXXX.bundle)"
|
||||
scp -q -o ControlPath="$CONTROL_SOCKET" "$LOCAL_BUNDLE" "$REMOTE:$REMOTE_BUNDLE"
|
||||
|
||||
ssh -S "$CONTROL_SOCKET" "$REMOTE" bash -s -- "$APP_DIR" "$REMOTE_BUNDLE" "$LOCAL_BRANCH" <<'REMOTE_SCRIPT'
|
||||
set -Eeuo pipefail
|
||||
|
||||
APP_DIR="$1"
|
||||
DEPLOY_BUNDLE="$2"
|
||||
DEPLOY_BRANCH="$3"
|
||||
cleanup_bundle() {
|
||||
rm -f "$DEPLOY_BUNDLE"
|
||||
}
|
||||
trap cleanup_bundle EXIT
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
test -f package.json || { echo "No package.json in $APP_DIR" >&2; exit 1; }
|
||||
test -d .git || { echo "$APP_DIR is not a Git checkout" >&2; exit 1; }
|
||||
|
||||
if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then
|
||||
echo "Refusing to pull: the deployed checkout has uncommitted tracked changes:" >&2
|
||||
git status --short --untracked-files=no >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OLD_HEAD="$(git rev-parse HEAD)"
|
||||
OLD_LOCK="$(sha256sum package-lock.json 2>/dev/null | awk '{print $1}' || true)"
|
||||
|
||||
echo "Pulling Git changes..."
|
||||
git fetch "$DEPLOY_BUNDLE" "$DEPLOY_BRANCH"
|
||||
git merge --ff-only FETCH_HEAD
|
||||
|
||||
NEW_HEAD="$(git rev-parse HEAD)"
|
||||
NEW_LOCK="$(sha256sum package-lock.json 2>/dev/null | awk '{print $1}' || true)"
|
||||
|
||||
if [[ ! -d node_modules || "$OLD_LOCK" != "$NEW_LOCK" ]]; then
|
||||
echo "Installing production dependencies..."
|
||||
if [[ -f package-lock.json ]]; then
|
||||
npm ci --omit=dev
|
||||
else
|
||||
npm install --omit=dev
|
||||
fi
|
||||
else
|
||||
echo "Dependencies unchanged; skipping npm install."
|
||||
fi
|
||||
|
||||
mapfile -t APP_PIDS < <(
|
||||
for process_dir in /proc/[0-9]*; do
|
||||
[[ "$(readlink "$process_dir/cwd" 2>/dev/null || true)" == "$APP_DIR" ]] || continue
|
||||
[[ "$(cat "$process_dir/comm" 2>/dev/null || true)" == "node" ]] || continue
|
||||
basename "$process_dir"
|
||||
done
|
||||
)
|
||||
|
||||
if [[ "${#APP_PIDS[@]}" -eq 0 ]]; then
|
||||
echo "No running Node process found for $APP_DIR; refusing to guess how to start it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_PID="${APP_PIDS[0]}"
|
||||
SERVICE=""
|
||||
|
||||
while IFS= read -r unit; do
|
||||
[[ -n "$unit" ]] || continue
|
||||
if [[ "$(systemctl show -p MainPID --value "$unit" 2>/dev/null || true)" == "$APP_PID" ]]; then
|
||||
SERVICE="$unit"
|
||||
break
|
||||
fi
|
||||
done < <(systemctl list-units --type=service --all --no-legend --plain 2>/dev/null | awk '{print $1}')
|
||||
|
||||
if [[ -n "$SERVICE" ]]; then
|
||||
echo "Restarting systemd service $SERVICE..."
|
||||
systemctl restart "$SERVICE"
|
||||
systemctl is-active --quiet "$SERVICE"
|
||||
systemctl --no-pager --full status "$SERVICE" | sed -n '1,12p'
|
||||
elif command -v pm2 >/dev/null 2>&1 && pm2 pid all 2>/dev/null | grep -qx "$APP_PID"; then
|
||||
echo "Restarting PM2 process $APP_PID..."
|
||||
pm2 restart "$APP_PID" --update-env
|
||||
pm2 show "$APP_PID"
|
||||
else
|
||||
echo "Found Node PID $APP_PID, but it is not managed by systemd or PM2." >&2
|
||||
echo "The code was updated from $OLD_HEAD to $NEW_HEAD, but the process was not restarted." >&2
|
||||
echo "Refusing to kill it without knowing the correct startup mechanism." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deployment updated successfully: $OLD_HEAD -> $NEW_HEAD"
|
||||
REMOTE_SCRIPT
|
||||
Loading…
Add table
Add a link
Reference in a new issue