From 44f345fff0ea01d659b9c87cdf5ca711df6db1c5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 2 Aug 2026 23:00:26 -0400 Subject: [PATCH] featchoremiscmiscmisc --- backup-deployed.sh | 108 ++++++++++++++++++ bliss-cli/SKILL.md | 102 +++++++++++++++++ bliss-cli/bliss | 253 +++++++++++++++++++++++++++++++++++++++++++ bliss-cli/client.js | 154 ++++++++++++++++++++++++++ index.js | 1 + plumbing.js | 88 +++++++++++++++ switch-bliss-data.sh | 94 ++++++++++++++++ update-deployed.sh | 114 +++++++++++++++++++ 8 files changed, 914 insertions(+) create mode 100755 backup-deployed.sh create mode 100644 bliss-cli/SKILL.md create mode 100755 bliss-cli/bliss create mode 100644 bliss-cli/client.js create mode 100644 plumbing.js create mode 100755 switch-bliss-data.sh create mode 100755 update-deployed.sh diff --git a/backup-deployed.sh b/backup-deployed.sh new file mode 100755 index 0000000..893676f --- /dev/null +++ b/backup-deployed.sh @@ -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 diff --git a/bliss-cli/SKILL.md b/bliss-cli/SKILL.md new file mode 100644 index 0000000..7f52057 --- /dev/null +++ b/bliss-cli/SKILL.md @@ -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 ` (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 # structure + its routes, templates, dbs, files +bliss route # one route incl. its handler source +bliss template # one template incl. content + test_object +bliss db # one db incl. its library source +bliss files # files attached to a structure +bliss logs [--since ] # route logs (console.log output + errors) +``` + +All emit JSON, so: `bliss structure 3 | jq '.routes[].path'`. + +## Writes + +```bash +bliss create-structure "" # -> {location, id} +bliss clone --name "" --prefix /p [--clone-dbs] + +bliss create-route # VERB: GET|POST|PUT|DELETE|WS ; -> {id} +bliss update-route --handler-file # replace handler code + [--scaffold-file ] [--path

] [--verb ] + +bliss create-template "" # -> {id} +bliss update-template --content-file [--test-object-file ] + +bliss create-db "" # -> {id} +bliss attach-db # share an existing db under an alias +bliss update-library --file # returns eval output/errors + +bliss repl '' # run JS/SQL against the db; --file also works +bliss upload # upload a static file +``` + +## Previews + +```bash +bliss preview-route # rendered HTML of a GET route +bliss preview-template # 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 'library.someFn()'`. + +## Typical loop + +1. `bliss structures` / `bliss structure ` 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. diff --git a/bliss-cli/bliss b/bliss-cli/bliss new file mode 100755 index 0000000..eb673cf --- /dev/null +++ b/bliss-cli/bliss @@ -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 "); + out(await c.getJSON(`/plumbing/structures/${id}`)); + }, + async route({ pos }) { + const [sid, rid] = pos; + need(sid && rid, "usage: bliss route "); + out(await c.getJSON(`/plumbing/structures/${sid}/routes/${rid}`)); + }, + async template({ pos }) { + const [sid, tid] = pos; + need(sid && tid, "usage: bliss template "); + out(await c.getJSON(`/plumbing/structures/${sid}/templates/${tid}`)); + }, + async db({ pos }) { + const [sid, dbid] = pos; + need(sid && dbid, "usage: bliss db "); + out(await c.getJSON(`/plumbing/structures/${sid}/dbs/${dbid}`)); + }, + async files({ pos }) { + const sid = need(pos[0], "usage: bliss files "); + out(await c.getJSON(`/plumbing/structures/${sid}/files`)); + }, + async logs({ pos, flags }) { + const [sid, rid] = pos; + need( + sid && rid, + "usage: bliss logs [--since ]", + ); + 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 "); + out(await c.writeAndGetId("POST", "/workshop", { name })); + }, + async clone({ pos, flags }) { + const sid = need( + pos[0], + "usage: bliss clone --name --prefix

[--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 ", + ); + 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 [--handler-file ] [--scaffold-file ] [--path

] [--verb ]", + ); + 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 "); + 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 --content-file [--test-object-file ]", + ); + 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 "); + 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 ", + ); + 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 --file ", + ); + 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 ['' | --file ]", + ); + 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 "); + out(await c.getText(`/workshop/${sid}/route/${rid}/preview`)); + }, + async "preview-template"({ pos }) { + const [sid, tid] = pos; + need( + sid && tid, + "usage: bliss preview-template ", + ); + out(await c.getText(`/workshop/${sid}/template/${tid}/preview`)); + }, + async upload({ pos }) { + const [sid, filepath] = pos; + need(sid && filepath, "usage: bliss upload "); + 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 [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); +}); diff --git a/bliss-cli/client.js b/bliss-cli/client.js new file mode 100644 index 0000000..9035806 --- /dev/null +++ b/bliss-cli/client.js @@ -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, +}; diff --git a/index.js b/index.js index 46e95bb..b4aef76 100644 --- a/index.js +++ b/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 diff --git a/plumbing.js b/plumbing.js new file mode 100644 index 0000000..37691aa --- /dev/null +++ b/plumbing.js @@ -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= 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; diff --git a/switch-bliss-data.sh b/switch-bliss-data.sh new file mode 100755 index 0000000..dbde5f4 --- /dev/null +++ b/switch-bliss-data.sh @@ -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" diff --git a/update-deployed.sh b/update-deployed.sh new file mode 100755 index 0000000..1d5e6bb --- /dev/null +++ b/update-deployed.sh @@ -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