// The app folders of this machine: one list shared by the CLI and the desktop app, ~/.storebound/projects.json // (STOREBOUND_REGISTRY names another file). `storebound desktop` in an app folder, `storebound apps add`, `ws init` and // the desktop's App folders screen add to it; `storebound apps folders` and the desktop show it; `storebound ws ...` // without a storebound.workspace.json works over it. The desktop keeps its per-folder extras (mapped accounts, saved // reads) in its own state by the same id. Only paths and names: no keys, nothing from inside the folders. // { "version": 1, "projects": [{ "id": "<16 hex>", "path": "/abs/folder", "name": "Star Dash", "added": "" }] } // Written atomically (temp file, rename); a missing or unreadable file reads as empty (an unreadable one is kept). import { createHash } from 'node:crypto'; import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; export interface RegistryProject { id: string; path: string; name: string; added: string } export interface Registry { version: 1; projects: RegistryProject[] } export const registryFile = (env: Record = process.env): string => env.STOREBOUND_REGISTRY || join(homedir(), '.storebound', 'projects.json'); /** The id of a folder the CLI adds: stable per absolute path (the same as `ws desktop export`'s project ids). */ export const folderId = (absPath: string): string => createHash('sha256').update(`project:${absPath}`).digest('hex').slice(0, 16); const ID = /^[0-9a-f]{16}$/; /** Parses the file's text; drops entries that are not well formed (an id, an absolute path). Pure. */ export function parseRegistry(text: string): Registry { let raw: unknown; try { raw = JSON.parse(text); } catch { return { version: 1, projects: [] }; } const list = (raw as { projects?: unknown })?.projects; const out: RegistryProject[] = []; if (Array.isArray(list)) for (const p of list) { const o = p as Partial; if (typeof o?.path !== 'string' || !o.path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(o.path)) continue; if (out.some(x => x.path === o.path)) continue; out.push({ id: typeof o.id === 'string' && ID.test(o.id) && !out.some(x => x.id === o.id) ? o.id : folderId(o.path), path: o.path, name: typeof o.name === 'string' && o.name.trim() ? o.name : basename(o.path), added: typeof o.added === 'string' ? o.added : '' }); } return { version: 1, projects: out }; } export function readRegistry(file = registryFile()): Registry { if (!existsSync(file)) return { version: 1, projects: [] }; return parseRegistry(readFileSync(file, 'utf8')); } export function writeRegistry(r: Registry, file = registryFile()): void { mkdirSync(dirname(file), { recursive: true }); if (existsSync(file)) { // a file this version cannot read is kept once instead of being replaced silently try { JSON.parse(readFileSync(file, 'utf8')); } catch { copyFileSync(file, `${file}.unreadable`); } } const tmp = `${file}.tmp-${process.pid}`; writeFileSync(tmp, JSON.stringify({ version: 1, projects: r.projects }, null, 2) + '\n'); renameSync(tmp, file); } /** Adds a folder (by absolute path; the same path again only updates an empty name). Pure on the registry. */ export function withFolder(r: Registry, path: string, name: string | undefined, now: string): { registry: Registry; project: RegistryProject; added: boolean } { const abs = resolve(path); const have = r.projects.find(p => p.path === abs); if (have) return { registry: r, project: have, added: false }; const project = { id: folderId(abs), path: abs, name: name?.trim() || basename(abs), added: now }; return { registry: { version: 1, projects: [...r.projects, project] }, project, added: true }; } /** Removes a folder by path or id. Pure. */ export function withoutFolder(r: Registry, pathOrId: string): { registry: Registry; removed: RegistryProject | null } { const abs = ID.test(pathOrId) ? '' : resolve(pathOrId); const removed = r.projects.find(p => p.id === pathOrId || p.path === abs) ?? null; return { registry: removed ? { version: 1, projects: r.projects.filter(p => p !== removed) } : r, removed }; } /** Reads, adds and writes (only when it changed). */ export function addFolder(path: string, name?: string, file = registryFile()): { project: RegistryProject; added: boolean } { const { registry, project, added } = withFolder(readRegistry(file), path, name, new Date().toISOString()); if (added) writeRegistry(registry, file); return { project, added }; } export function removeFolder(pathOrId: string, file = registryFile()): RegistryProject | null { const { registry, removed } = withoutFolder(readRegistry(file), pathOrId); if (removed) writeRegistry(registry, file); return removed; }