diff --git a/.changeset/sideshow-data-home.md b/.changeset/sideshow-data-home.md new file mode 100644 index 0000000..4cf8124 --- /dev/null +++ b/.changeset/sideshow-data-home.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Move the default data directory from the package-relative `/data/` to a user-owned `~/.sideshow/`. The package-relative default was read-only under `sudo npm install -g` (crashing with `EACCES` even after the #157 mkdir guard) and was wiped on every `npm install -g` upgrade — silent data loss. `~/.sideshow/` is always writable and survives reinstalls. A one-time migration copies any existing `sideshow.{db,db-wal,db-shm,json}` from the old location to the new one on first boot (only when using default paths; `SIDESHOW_DATA`/`SIDESHOW_DB` overrides are unchanged and skip the migration). diff --git a/server/index.ts b/server/index.ts index 329799b..db522b4 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,8 +1,10 @@ import { serve } from "@hono/node-server"; import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { createApp } from "./app.ts"; +import { migrateLegacyDataDir } from "./migrateDataDir.ts"; import { SqlStore } from "./sqlStore.ts"; import { createSqliteStorage, migrateJsonToSqlite } from "./sqliteStorage.ts"; import { JsonFileStore } from "./storage.ts"; @@ -32,12 +34,24 @@ const publicRead = pr === "session" || pr === "full" ? pr : undefined; // mirrors the Cloudflare Durable Object deploy — both run the same SqlStore. // SIDESHOW_STORE=json selects the legacy single-file JSON store instead. // SIDESHOW_DATA names the JSON file (and the one-time migration source); -// SIDESHOW_DB names the SQLite file. -const jsonPath = process.env.SIDESHOW_DATA ?? join(root, "data", "sideshow.json"); +// SIDESHOW_DB names the SQLite file. Both default to ~/.sideshow/ — a +// user-owned dir that survives reinstalls and is writable regardless of how +// the package was installed (a package-relative default is read-only under +// `sudo npm i -g` and wiped on upgrade). +const dataDir = join(homedir(), ".sideshow"); +const jsonPath = process.env.SIDESHOW_DATA ?? join(dataDir, "sideshow.json"); // The SQLite file defaults next to the JSON one (same dir, `.db` suffix) so a // deploy that only sets SIDESHOW_DATA still gets an isolated, co-located db — // and the migration source sits right beside it. const dbPath = process.env.SIDESHOW_DB ?? `${jsonPath.replace(/\.json$/, "")}.db`; +// Migrate from the legacy package-relative `/data/` location to the +// user-owned home dir, but only when using default paths — a user who set +// SIDESHOW_DATA or SIDESHOW_DB is managing their own location. +if (!process.env.SIDESHOW_DATA && !process.env.SIDESHOW_DB) { + if (migrateLegacyDataDir(join(root, "data"), dataDir)) { + console.log(`[sideshow] migrated existing data from ${join(root, "data")} to ${dataDir}`); + } +} let store: Store; if (process.env.SIDESHOW_STORE === "json") { store = new JsonFileStore(jsonPath); diff --git a/server/migrateDataDir.ts b/server/migrateDataDir.ts new file mode 100644 index 0000000..e2a9f27 --- /dev/null +++ b/server/migrateDataDir.ts @@ -0,0 +1,36 @@ +import { cpSync, existsSync, mkdirSync, renameSync } from "node:fs"; +import { join } from "node:path"; + +// One-time migration: the default data location moved from the package dir +// (`/data/`) to a user-owned home dir (`~/.sideshow/`). Existing installs +// may have their SQLite db and/or legacy JSON file under the old path. This +// copies those files to the new location before the store opens, so an upgrade +// preserves data without a manual move. +// +// It's a no-op if the old dir doesn't exist or has no sideshow files, and it +// never overwrites files already present at the new location (idempotent — +// safe to call on every boot). The old files are left in place as a backup +// (rename on the same filesystem moves them; a cross-device copy leaves the +// original behind). Only the four sideshow data files are touched — anything +// else in the old dir stays put. +export function migrateLegacyDataDir(oldDir: string, newDir: string): boolean { + if (!existsSync(oldDir)) return false; + const names = ["sideshow.json", "sideshow.db", "sideshow.db-wal", "sideshow.db-shm"]; + if (!names.some((n) => existsSync(join(oldDir, n)))) return false; + mkdirSync(newDir, { recursive: true }); + let moved = false; + for (const name of names) { + const src = join(oldDir, name); + if (!existsSync(src)) continue; + const dst = join(newDir, name); + if (existsSync(dst)) continue; // idempotent — never overwrite + try { + renameSync(src, dst); // atomic + fast on the same filesystem + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "EXDEV") throw e; + cpSync(src, dst); // cross-device: copy, leave original as backup + } + moved = true; + } + return moved; +} diff --git a/test/migrateDataDir.test.ts b/test/migrateDataDir.test.ts new file mode 100644 index 0000000..73e9298 --- /dev/null +++ b/test/migrateDataDir.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, existsSync, readFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { migrateLegacyDataDir } from "../server/migrateDataDir.ts"; + +const tmpDir = () => mkdtempSync(join(tmpdir(), "sideshow-datadir-")); + +test("migrates db, wal, shm, and json files from old dir to new dir", () => { + const old = tmpDir(); + const neu = join(tmpDir(), "sideshow"); + writeFileSync(join(old, "sideshow.db"), "DB"); + writeFileSync(join(old, "sideshow.db-wal"), "WAL"); + writeFileSync(join(old, "sideshow.db-shm"), "SHM"); + writeFileSync(join(old, "sideshow.json"), "JSON"); + + const moved = migrateLegacyDataDir(old, neu); + + assert.equal(moved, true); + assert.equal(readFileSync(join(neu, "sideshow.db"), "utf8"), "DB"); + assert.equal(readFileSync(join(neu, "sideshow.db-wal"), "utf8"), "WAL"); + assert.equal(readFileSync(join(neu, "sideshow.db-shm"), "utf8"), "SHM"); + assert.equal(readFileSync(join(neu, "sideshow.json"), "utf8"), "JSON"); +}); + +test("is idempotent — a second run does not overwrite the new dir", () => { + const old = tmpDir(); + const neu = join(tmpDir(), "sideshow"); + writeFileSync(join(old, "sideshow.db"), "ORIGINAL"); + writeFileSync(join(old, "sideshow.json"), "ORIGINAL_JSON"); + + migrateLegacyDataDir(old, neu); + // Simulate the user having newer data at the new location on a later boot + writeFileSync(join(neu, "sideshow.db"), "NEWER"); + const moved = migrateLegacyDataDir(old, neu); + + assert.equal(moved, false); + assert.equal(readFileSync(join(neu, "sideshow.db"), "utf8"), "NEWER"); +}); + +test("is a no-op when the old dir does not exist", () => { + const neu = join(tmpDir(), "sideshow"); + const moved = migrateLegacyDataDir(join(tmpDir(), "nonexistent"), neu); + assert.equal(moved, false); + assert.equal(existsSync(neu), false); +}); + +test("is a no-op when the old dir has no sideshow files", () => { + const old = tmpDir(); + const neu = join(tmpDir(), "sideshow"); + writeFileSync(join(old, "unrelated.txt"), "ignore me"); + const moved = migrateLegacyDataDir(old, neu); + assert.equal(moved, false); + assert.equal(existsSync(neu), false); +}); + +test("only migrates the sideshow files, leaving other files behind", () => { + const old = tmpDir(); + const neu = join(tmpDir(), "sideshow"); + writeFileSync(join(old, "sideshow.db"), "DB"); + writeFileSync(join(old, "other.txt"), "stays"); + migrateLegacyDataDir(old, neu); + assert.equal(existsSync(join(neu, "other.txt")), false); + assert.equal(existsSync(join(old, "other.txt")), true); +}); + +test("handles a partially-populated new dir (migrates only missing files)", () => { + const old = tmpDir(); + const neu = tmpDir(); + writeFileSync(join(old, "sideshow.db"), "OLD_DB"); + writeFileSync(join(old, "sideshow.json"), "OLD_JSON"); + // new dir already has a db — must not be overwritten + mkdirSync(neu, { recursive: true }); + writeFileSync(join(neu, "sideshow.db"), "EXISTING_DB"); + + migrateLegacyDataDir(old, neu); + + assert.equal(readFileSync(join(neu, "sideshow.db"), "utf8"), "EXISTING_DB"); + assert.equal(readFileSync(join(neu, "sideshow.json"), "utf8"), "OLD_JSON"); +});