/**
* ─────────────────────────────────────────────
* SPB Git — Personal Git Platform
* ─────────────────────────────────────────────
* Author : Simon-Pierre Boucher
* Contact : contact@spboucher.ai
* File : src/lib/util.mjs
* Purpose : Small shared helpers (validation, formatting, atomic IO)
* License : MIT © Simon-Pierre Boucher
* ─────────────────────────────────────────────
*/
import { writeFileSync, renameSync, mkdirSync } from 'node:fs';
import { dirname, join, normalize } from 'node:path';
import { randomBytes, createHash } from 'node:crypto';
/** Repo naming rule from CLAUDE.md §3.1 — reject everything else hard. */
export const REPO_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
/**
* @param {string} name
* @returns {boolean} true when the name is a safe, valid repository name
*/
export function isValidRepoName(name) {
if (typeof name !== 'string' || !REPO_NAME_RE.test(name)) return false;
if (name === '.' || name === '..' || name.includes('..')) return false;
if (name.endsWith('.git')) return false;
return true;
}
/**
* Join `child` under `root` and guarantee the result stays inside `root`.
* @param {string} root absolute base directory
* @param {...string} segments path pieces (may come from user input)
* @returns {string} normalized absolute path
* @throws when the resolved path escapes the root
*/
export function safeJoin(root, ...segments) {
const joined = normalize(join(root, ...segments));
const normalizedRoot = normalize(root).replace(/\/+$/, '');
if (joined !== normalizedRoot && !joined.startsWith(normalizedRoot + '/')) {
throw new Error('path traversal rejected');
}
return joined;
}
/**
* Validate a repo-relative tree path (no traversal, no absolute, no NUL).
* @param {string} path
* @returns {boolean}
*/
export function isValidTreePath(path) {
if (typeof path !== 'string') return false;
if (path.includes('\0') || path.startsWith('/')) return false;
const parts = path.split('/');
return parts.every((p) => p !== '' && p !== '.' && p !== '..');
}
/**
* Write JSON atomically (tmp file + rename) so readers never see partial data.
* @param {string} path
* @param {unknown} value
*/
export function atomicWriteJSON(path, value) {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
renameSync(tmp, path);
}
/**
* @param {number} bytes
* @returns {string} human size, e.g. "4.2 MB"
*/
export function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let value = bytes;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i += 1;
}
return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
}
/**
* @param {Date|number|string} input
* @returns {string} GitHub-style relative time ("3 h ago")
*/
export function relativeTime(input) {
const date = input instanceof Date ? input : new Date(input);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (!Number.isFinite(seconds)) return '';
if (seconds < 0) return 'just now';
if (seconds < 45) return 'just now';
if (seconds < 90) return '1 min ago';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return days === 1 ? 'yesterday' : `${days} days ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months} mo ago`;
const years = Math.floor(days / 365);
return years <= 1 ? '1 year ago' : `${years} years ago`;
}
/**
* @param {string} text
* @returns {string} HTML-escaped text
*/
export function escapeHtml(text) {
return String(text)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
/**
* Deterministic 5×5 identicon SVG for an author email.
* @param {string} email
* @param {number} [size] rendered square size in px
* @returns {string} inline SVG markup
*/
export function identiconSvg(email, size = 32) {
const hash = createHash('sha256').update(email.trim().toLowerCase()).digest();
const hue = ((hash[0] << 8) | hash[1]) % 360;
const fg = `hsl(${hue} 55% 55%)`;
const bg = 'transparent';
const cell = size / 5;
let rects = '';
for (let x = 0; x < 3; x += 1) {
for (let y = 0; y < 5; y += 1) {
if (hash[2 + x * 5 + y] % 2 === 0) continue;
for (const cx of x === 2 ? [2] : [x, 4 - x]) {
rects += ``;
}
}
}
return ``;
}
/**
* Run at most `limit` async jobs concurrently.
* @template T,R
* @param {T[]} items
* @param {number} limit
* @param {(item: T, index: number) => Promise} worker
* @returns {Promise} results in input order
*/
export async function mapLimit(items, limit, worker) {
const results = new Array(items.length);
let next = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const index = next;
next += 1;
results[index] = await worker(items[index], index);
}
});
await Promise.all(runners);
return results;
}