// Generated, do not edit. sha256:ac3a8e4322658b62e93903e91a12ebe573e29b3520917e4d09d4d77210b67abb import { mkdirSync, readFileSync, writeFileSync, readdirSync, renameSync, statSync, rmSync } from 'node:fs'; import { join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { fail } from '../shared/app-error.js'; import { CONFIG } from '../shared/config.js'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const POINTER_NAME = 'active.json'; const LEGACY_POINTER_NAME = 'actief.json'; const POINTER_FIELD = 'portfolio'; const LEGACY_POINTER_FIELD = 'portefeuille'; export const RESERVED_DIRS = Object.freeze(['tls', 'Archive']); const ARCHIVE_NAME = 'Archive'; export const DEFAULT_NAME = 'MyAssets'; export const MIGRATION_NAME = 'Test'; const PORTFOLIO_FILE_PREFIXES = ['db.sqlite', 'secrets.json']; export function installDir() { return process.env.ASSETAPP_DATA_DIR || join(ROOT, 'data'); } export function pointerFile() { return join(installDir(), POINTER_NAME); } export function archiveDir() { return join(installDir(), ARCHIVE_NAME); } export function portfolioDir(name) { assertValidName(name); const basis = resolve(installDir()); const doel = resolve(join(installDir(), name)); if (!doel.startsWith(basis + sep)) { throw new Error(`Portfolio '${name}' would land outside the data directory.`); } return doel; } const FORBIDDEN_CHARS = /[<>:"/\\|?*]/; const RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; const MAX_LENGTH = 64; const hasControlChar = (name) => [...name].some(teken => teken.codePointAt(0) < 0x20); export function assertValidName(name) { if (typeof name !== 'string' || name.length === 0) { fail('portfolios.nameRequired', null, 'A portfolio needs a name.'); } if (name.length > MAX_LENGTH) { fail('portfolios.nameTooLong', { max: MAX_LENGTH }, `A portfolio name is at most ${MAX_LENGTH} characters long.`); } if (name === '.' || name === '..') { fail('portfolios.nameIsDirRef', { naam: name }, `'${name}' is not a name but a reference to a directory.`); } if (FORBIDDEN_CHARS.test(name) || hasControlChar(name)) { fail('portfolios.nameForbiddenChars', null, 'A portfolio name may not contain any of these characters: < > : " / \\ | ? *'); } if (/[ .]$/.test(name)) { fail('portfolios.nameTrailing', null, 'A portfolio name may not end in a space or a dot.'); } if (/^ /.test(name)) { fail('portfolios.nameLeadingSpace', null, 'A portfolio name may not start with a space.'); } if (RESERVED_NAMES.test(name.split('.')[0])) { fail('portfolios.nameReserved', { naam: name }, `'${name}' is a reserved name on Windows and cannot become a directory.`); } if (RESERVED_DIRS.some(gereserveerd => normalizeName(gereserveerd) === normalizeName(name))) { fail('portfolios.nameTaken', { naam: name }, `'${name}' is a name the app uses itself.`); } return name; } export function normalizeName(name) { if (typeof name !== 'string') { throw new TypeError(`Een portefeuillenaam is tekst; gekregen: ${typeof name}.`); } return name.normalize('NFC').toLocaleLowerCase('nl'); } export function sameName(a, b) { if (a == null || b == null) return false; return normalizeName(a) === normalizeName(b); } export function readActiveName() { let rauw; try { rauw = readFileSync(pointerFile(), 'utf-8'); } catch (e) { if (e.code !== 'ENOENT') throw e; try { rauw = readFileSync(join(installDir(), LEGACY_POINTER_NAME), 'utf-8'); } catch (e2) { if (e2.code === 'ENOENT') return null; throw e2; } } let data; try { data = JSON.parse(rauw); } catch { throw new Error(`${pointerFile()} is not valid JSON; repair or remove the file.`); } const naam = data && (data[POINTER_FIELD] ?? data[LEGACY_POINTER_FIELD]); if (typeof naam !== 'string' || naam.length === 0) { throw new Error(`${pointerFile()} names no portfolio (field '${POINTER_FIELD}').`); } return assertValidName(naam); } export function listPortfolios() { let entries; try { entries = readdirSync(installDir(), { withFileTypes: true }); } catch (e) { if (e.code === 'ENOENT') return []; throw e; } const actief = readActiveName(); return entries .filter(e => e.isDirectory()) .map(e => e.name) .filter(naam => !RESERVED_DIRS.some(g => normalizeName(g) === normalizeName(naam))) .map(naam => { const map = join(installDir(), naam); const db = join(map, 'db.sqlite'); return { naam, actief: actief !== null && sameName(naam, actief), demo: sameName(naam, CONFIG.PORTFOLIO.DEMO_NAME), heeftDatabase: bestaat(db), heeftGeheimen: bestaat(join(map, 'secrets.json')), laatstGebruikt: mtimeOf(db), }; }) .sort((a, b) => a.naam.localeCompare(b.naam, 'nl')); } export function writeActiveName(name) { assertValidName(name); mkdirSync(installDir(), { recursive: true }); writeFileSync(pointerFile(), `${JSON.stringify({ [POINTER_FIELD]: name }, null, 2)}\n`, 'utf-8'); return name; } export function activeName() { return readActiveName() || DEFAULT_NAME; } export function activeDir() { return portfolioDir(activeName()); } const bestaat = (pad) => { try { statSync(pad); return true; } catch { return false; } }; const mtimeOf = (pad) => { try { return statSync(pad).mtime.toISOString(); } catch { return null; } }; function looseFiles() { let entries; try { entries = readdirSync(installDir(), { withFileTypes: true }); } catch (e) { if (e.code === 'ENOENT') return []; throw e; } return entries .filter(e => e.isFile()) .map(e => e.name) .filter(naam => PORTFOLIO_FILE_PREFIXES.some(prefix => naam.startsWith(prefix))); } export function migrateLooseLayout() { const losse = looseFiles(); if (!losse.length) return null; const doelMap = portfolioDir(MIGRATION_NAME); if (bestaat(join(doelMap, 'db.sqlite'))) { const losseNamen = losse.join(', '); throw new Error( `There are loose database files in ${installDir()} (${losseNamen}) and there is already a database ` + `in ${doelMap}. The app does not guess which one is the right one.\n` + 'Almost always a process from before this layout is still running: an MCP server or an old app ' + 'server then recreates the loose file every time. Close those processes (and restart your AI ' + 'client), check which file is the largest, and remove or rename the loose one so it no longer ' + 'starts with "db.sqlite" or "secrets.json".'); } mkdirSync(doelMap, { recursive: true }); for (const naam of losse) renameSync(join(installDir(), naam), join(doelMap, naam)); return MIGRATION_NAME; } function hernoemOudeWijzer() { const oud = join(installDir(), LEGACY_POINTER_NAME); if (!bestaat(oud) || bestaat(pointerFile())) return; const naam = readActiveName(); if (naam) writeActiveName(naam); rmSync(oud, { force: true }); } export function prepareLayout() { mkdirSync(installDir(), { recursive: true }); hernoemOudeWijzer(); const migrated = migrateLooseLayout(); let naam = readActiveName(); const fresh = !naam && !migrated && listPortfolios().length === 0; let herkomst = `uit ${POINTER_NAME}`; if (!naam) { naam = writeActiveName(migrated || DEFAULT_NAME); herkomst = migrated ? `losse bestanden verhuisd naar '${migrated}'` : 'nieuw aangemaakt'; } else if (migrated) { herkomst = `uit ${POINTER_NAME}; losse bestanden verhuisd naar '${migrated}'`; } mkdirSync(portfolioDir(naam), { recursive: true }); return { name: naam, migrated, fresh, regel: `portefeuille: ${naam} (${herkomst}) | map: ${portfolioDir(naam)}` }; }