init f0ckm

This commit is contained in:
2026-04-25 19:51:52 +02:00
commit b646107eb7
241 changed files with 70364 additions and 0 deletions

60
debug/autotagger.mjs Normal file
View File

@@ -0,0 +1,60 @@
import db from "../src/inc/sql.mjs";
import lib from "../src/inc/lib.mjs";
(async () => {
const _args = process.argv.slice(2);
const _from = +_args[0];
const _to = _from + 500;
const f0cks = await db`
select *
from items
where
id not in (select item_id from tags_assign group by item_id) and
mime like 'image/%' and
id between ${_from} and ${_to}
`;
console.time('blah');
for(let f of f0cks) {
const tmp = await lib.detectNSFW(f.dest);
console.log(
'https://f0ck.me/' + f.id,
tmp.isNSFW,
tmp.score.toFixed(2),
{
sexy: tmp.scores.sexy.toFixed(2),
porn: tmp.scores.porn.toFixed(2),
hentai: tmp.scores.hentai.toFixed(2),
neutral: tmp.scores.neutral.toFixed(2)
}
);
await db`
insert into "tags_assign" ${
db({
item_id: f.id,
tag_id: tmp.nsfw ? 2 : 1,
user_id: 1
})
}
`;
if(tmp.hentai >= .7) {
await db`
insert into "tags_assign" ${
db({
item_id: f.id,
tag_id: 4, // hentai
user_id: 1 // autotagger
})
}
`;
}
};
console.timeEnd('blah');
process.exit();
})();

78
debug/backfill_phash.mjs Normal file
View File

@@ -0,0 +1,78 @@
import db from "../src/inc/sql.mjs";
import queue from "../src/inc/queue.mjs";
import fs from "fs";
import path from "path";
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const PROJECT_ROOT = path.resolve(__dirname, '..');
const BATCH_SIZE = 50;
async function backfill() {
console.log("Starting PHash backfill...");
try {
// Count total items needing backfill
const countResult = await db`SELECT count(*) as count FROM items WHERE (phash IS NULL OR phash = '') AND active = true`;
const total = countResult[0].count;
console.log(`Found ${total} items to process.`);
let processed = 0;
let errors = 0;
while (true) {
const items = await db`
SELECT id, dest
FROM items
WHERE (phash IS NULL OR phash = '')
AND active = true
ORDER BY id DESC
LIMIT ${BATCH_SIZE}
`;
if (items.length === 0) break;
for (const item of items) {
// Correctly resolve path relative to project root
const filePath = path.join(PROJECT_ROOT, 'public', 'b', item.dest);
if (!fs.existsSync(filePath)) {
console.log(`[SKIP] File not found: ${item.dest} (ID: ${item.id})`);
// Mark as MISSING so we don't pick it up again
await db`UPDATE items SET phash = 'MISSING' WHERE id = ${item.id}`;
processed++;
continue;
}
try {
console.log(`[PROCESSING] ID ${item.id}: ${item.dest}`);
const phash = await queue.generatePHash(filePath);
if (phash) {
await db`UPDATE items SET phash = ${phash} WHERE id = ${item.id}`;
console.log(`[SUCCESS] ID ${item.id}: Updated PHash.`);
} else {
console.log(`[FAILED] ID ${item.id}: Could not generate PHash.`);
// Mark as ERROR so we don't pick it up again
await db`UPDATE items SET phash = 'ERROR' WHERE id = ${item.id}`;
errors++;
}
} catch (err) {
console.error(`[ERROR] ID ${item.id}:`, err);
await db`UPDATE items SET phash = 'ERROR' WHERE id = ${item.id}`;
errors++;
}
processed++;
}
console.log(`Progress: ${processed} completed (Remaining in batch loop...)`);
}
console.log("Backfill complete!");
process.exit(0);
} catch (err) {
console.error("Fatal error:", err);
process.exit(1);
}
}
backfill();

View File

@@ -0,0 +1,102 @@
/**
* Backfill script to generate blurred thumbnails for existing NSFW items
*
* Run with: node debug/blur_existing_thumbnails.mjs
*/
import { exec as _exec } from "child_process";
import fs from "fs";
import path from "path";
import cfg from "../src/inc/config.mjs";
import sql from "../src/inc/sql.mjs";
const db = sql;
const exec = (cmd) => new Promise((resolve, reject) => {
_exec(cmd, { maxBuffer: 5e3 * 1024 }, (err, stdout, stderr) => {
if (err) {
err.stderr = stderr;
return reject(err);
}
resolve({ stdout });
});
});
async function main() {
console.log('[BACKFILL] Starting blurred thumbnail generation for existing NSFW items...');
// Find all items with NSFW tag (tag_id = 2) that are active
const nsfwItems = await db`
SELECT DISTINCT items.id
FROM items
JOIN tags_assign ON tags_assign.item_id = items.id
WHERE tags_assign.tag_id = 2
AND items.active = true
`;
console.log(`[BACKFILL] Found ${nsfwItems.length} NSFW items`);
const tDir = cfg.paths.t;
console.log(`[BACKFILL] Thumbnail directory: ${tDir}`);
// Debug: check if directory exists and list some files
try {
const files = await fs.promises.readdir(tDir);
console.log(`[BACKFILL] Directory exists, contains ${files.length} files`);
console.log(`[BACKFILL] Sample files: ${files.slice(0, 5).join(', ')}`);
} catch (e) {
console.error(`[BACKFILL] ERROR: Cannot access thumbnail directory: ${e.message}`);
process.exit(1);
}
let processed = 0;
let skipped = 0;
let errors = 0;
for (const item of nsfwItems) {
const src = path.join(tDir, `${item.id}.webp`);
const dst = path.join(tDir, `${item.id}_blur.webp`);
// Check if blur already exists
try {
await fs.promises.access(dst);
skipped++;
continue; // Already exists
} catch {
// Doesn't exist, proceed
}
// Check if source exists
try {
await fs.promises.access(src);
} catch {
console.log(`[BACKFILL] Source thumbnail missing for item ${item.id}`);
errors++;
continue;
}
// Generate blurred thumbnail
try {
await exec(`magick "${src}" -blur 0x20 "${dst}"`);
processed++;
if (processed % 100 === 0) {
console.log(`[BACKFILL] Progress: ${processed} processed, ${skipped} skipped, ${errors} errors`);
}
} catch (err) {
console.error(`[BACKFILL] Failed to blur item ${item.id}:`, err.message);
errors++;
}
}
console.log(`[BACKFILL] Complete!`);
console.log(` Processed: ${processed}`);
console.log(` Skipped (already exist): ${skipped}`);
console.log(` Errors: ${errors}`);
process.exit(0);
}
main().catch(err => {
console.error('[BACKFILL] Fatal error:', err);
process.exit(1);
});

95
debug/clean.mjs Normal file
View File

@@ -0,0 +1,95 @@
import cfg from "../src/inc/config.mjs";
import db from "../src/inc/sql.mjs";
import fs from "node:fs";
import readline from 'node:readline/promises';
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
// npm run clean -- --dry-run
const dry = !!process.argv.filter(a => a == '--dry-run').length;
console.log(`dry run? ${dry}`);
const dirs = {
b: "./public/b",
t: "./public/t",
tmp: "./tmp"
};
const files = {
b: (await fs.promises.readdir(dirs.b)) .filter(f => f !== '.empty'),
t: (await fs.promises.readdir(dirs.t)) .filter(f => f !== '.empty'),
tmp: (await fs.promises.readdir(dirs.tmp)).filter(f => f !== '.empty')
};
const extensions = [ ...Object.values(cfg.mimes), 'mov' ];
const count = {
missing: { b: [], t: [] },
invalid: { b: [], t: [] },
spare: { b: [], t: [] },
tmp: files.tmp
};
const rows = await db`select id, dest from items where active = true`;
const f0cks = {
b: rows.flatMap(f => f.dest),
t: rows.flatMap(f => `${f.id}.webp`)
};
// missing
for(const row of rows) {
if(!fs.existsSync(`${dirs.b}/${row.dest}`))
count.missing.b.push(row.id);
if(!fs.existsSync(`${dirs.t}/${row.id}.webp`))
count.missing.t.push(row.id);
}
// invalid
count.invalid.b = files.b.filter(f => !extensions.includes(f.toLowerCase().split('.')[1]));
count.invalid.t = files.t.filter(f => !f.endsWith('.webp'));
// spare
for(const file of files.b)
if(!f0cks.b.includes(file))
count.spare.b.push(`${dirs.b}/${file}`);
for(const file of files.t)
if(!f0cks.t.includes(file))
count.spare.t.push(`${dirs.t}/${file}`);
// show confusing summary
console.log(count);
// delete spare if --dry-run
if(!dry) {
let q;
if(count.spare.b.length > 0) {
q = (await rl.question(`delete ${count.spare.b.length} unnecessary files in ${dirs.b}? [y/N] `)) == 'y';
if(q) {
await Promise.all(count.spare.b.map(f => fs.promises.unlink(f)));
console.log(`deleted ${count.spare.b.length} files`);
}
else
console.log('abort...');
}
if(count.spare.t.length > 0) {
q = (await rl.question(`delete ${count.spare.t.length} unnecessary files in ${dirs.t}? [y/N] `)) == 'y';
if(q) {
await Promise.all(count.spare.t.map(f => fs.promises.unlink(f)));
console.log(`deleted ${count.spare.t.length} files`);
}
else
console.log('abort...');
}
if(files.tmp.length > 0) {
q = (await rl.question(`delete ${files.tmp.length} files in ${dirs.tmp}? [y/N] `)) == 'y';
if(q) {
await Promise.all(files.tmp.map(f => fs.promises.unlink(`${dirs.tmp}/${f}`)));
console.log(`deleted ${files.tmp.length} files`);
}
else
console.log('abort...');
}
}
// close connection
await db.end();
process.exit();

104
debug/find_duplicates.mjs Normal file
View File

@@ -0,0 +1,104 @@
import db from "../src/inc/sql.mjs";
const THRESHOLD = 15;
const REQUIRED_MATCHES = 2;
// Hamming distance helper — operates on a single hex-encoded hash segment
const getHammingDistance = (h1, h2) => {
if (!h1 || !h2 || h1.length !== h2.length) return 9999;
let distance = 0;
for (let i = 0; i < h1.length; i += 2) {
const v1 = parseInt(h1.substr(i, 2), 16);
const v2 = parseInt(h2.substr(i, 2), 16);
let xor = v1 ^ v2;
while (xor) {
distance += xor & 1;
xor >>= 1;
}
}
return distance;
};
async function findDuplicates() {
console.log("Fetching items...");
// Fetch all valid phashes
const items = await db`
SELECT id, phash
FROM items
WHERE phash IS NOT NULL
AND phash != ''
AND phash != 'MISSING'
AND phash != 'ERROR'
AND phash NOT LIKE '00000000%'
ORDER BY id ASC
`;
console.log(`Checking ${items.length} items for duplicates (Threshold: ${THRESHOLD}, Required frame matches: ${REQUIRED_MATCHES})...`);
const duplicates = new Map(); // Map<OriginalID, List<{id, dist}>>
const processed = new Set();
for (let i = 0; i < items.length; i++) {
const current = items[i];
if (processed.has(current.id)) continue;
const matchList = [];
for (let j = i + 1; j < items.length; j++) {
const compare = items[j];
if (processed.has(compare.id)) continue;
// Split multi-frame hashes properly — do NOT compare the whole string
const aHashes = current.phash.split('_');
const bHashes = compare.phash.split('_');
const framesToCompare = Math.min(aHashes.length, bHashes.length);
let matchCount = 0;
for (let f = 0; f < framesToCompare; f++) {
const dist = getHammingDistance(aHashes[f], bHashes[f]);
if (dist <= THRESHOLD) matchCount++;
}
const isMatch = (framesToCompare >= 3 && matchCount >= REQUIRED_MATCHES)
|| (framesToCompare === 2 && matchCount >= 2)
|| (framesToCompare === 1 && matchCount === 1);
if (isMatch) {
const avgDist = Math.round(
aHashes.slice(0, framesToCompare)
.reduce((sum, h, idx) => sum + getHammingDistance(h, bHashes[idx]), 0)
/ framesToCompare
);
matchList.push({ id: compare.id, dist: avgDist });
processed.add(compare.id);
}
}
if (matchList.length > 0) {
duplicates.set(current.id, matchList);
processed.add(current.id);
}
}
if (duplicates.size === 0) {
console.log("No duplicates found.");
} else {
console.log(`Found ${duplicates.size} duplicate sets:`);
console.log("---------------------------------------------------");
}
for (const [originalId, matchList] of duplicates.entries()) {
const matchStr = matchList.map(m => `ID:${m.id} (avg-dist:${m.dist})`).join(", ");
console.log(`Original ID: ${originalId} matches with: ${matchStr}`);
}
process.exit(0);
}
findDuplicates().catch(err => {
console.error(err);
process.exit(1);
});

72
debug/fix_deleted.mjs Normal file
View File

@@ -0,0 +1,72 @@
import db from "../src/inc/sql.mjs";
import { promises as fs } from "fs";
(async () => {
console.log("Starting migration...");
// 1. Ensure column exists
try {
await db`select is_deleted from items limit 1`;
console.log("Column 'is_deleted' already exists.");
} catch (err) {
if (err.message.includes('column "is_deleted" does not exist')) {
console.log("Column 'is_deleted' missing. Adding it now...");
await db`ALTER TABLE items ADD COLUMN is_deleted BOOLEAN DEFAULT FALSE`;
console.log("Column added successfully.");
} else {
console.error("Unexpected error checking column:", err);
process.exit(1);
}
}
const items = await db`select id, dest from items where active = false`;
console.log(`Found ${items.length} inactive items.`);
let trashCount = 0;
let pendingCount = 0;
let brokenCount = 0;
for (const item of items) {
try {
await fs.access(`./deleted/b/${item.dest}`);
// File exists in deleted, mark as is_deleted = true
await db`update items set is_deleted = true where id = ${item.id}`;
trashCount++;
} catch {
// Not in deleted, check public
try {
await fs.access(`./public/b/${item.dest}`);
// In public, is_deleted = false (default)
pendingCount++;
} catch {
// Not in either? Broken.
console.log(`Item ${item.id} (${item.dest}) missing from both locations. Cleaning up...`);
// 2. Fix FK constraint: Check if this item is used as an avatar
try {
// Find a safe fallback avatar (active item)
const fallback = await db`select id from items where active = true limit 1`;
if (fallback.length > 0) {
const safeId = fallback[0].id;
const users = await db`update "user_options" set avatar = ${safeId} where avatar = ${item.id} returning user_id`;
if (users.length > 0) {
console.log(` > Reassigned avatar for ${users.length} users (from ${item.id} to ${safeId})`);
}
}
} catch (fkErr) {
console.error(` ! Error fixing avatar FK for ${item.id}:`, fkErr.message);
}
await db`delete from items where id = ${item.id}`;
brokenCount++;
}
}
}
console.log(`Migration complete.`);
console.log(`Trash (soft-deleted): ${trashCount}`);
console.log(`Pending: ${pendingCount}`);
console.log(`Broken: ${brokenCount}`);
process.exit(0);
})();

84
debug/recreate_hashes.mjs Normal file
View File

@@ -0,0 +1,84 @@
import fs from 'fs';
import crypto from 'crypto';
import db from '../src/inc/sql.mjs';
import path from 'path';
const run = async () => {
console.log('Starting hash recreation (Production Mode - Streams)...');
try {
// Fetch only necessary columns
const items = await db`SELECT id, dest, checksum, size FROM items ORDER BY id ASC`;
console.log(`Found ${items.length} items. Processing...`);
let updated = 0;
let errors = 0;
let skipped = 0;
for (const [index, item] of items.entries()) {
const filePath = path.join('./public/b', item.dest);
try {
if (!fs.existsSync(filePath)) {
// Silent error in logs for missing files to avoid spamming "thousands" of lines if many are missing
// Use verbose logging if needed, but here we'll just count them.
// Actually, precise logs are better for "production" to know what's wrong.
console.error(`[MISSING] File not found for item ${item.id}: ${filePath}`);
errors++;
continue;
}
// Get file size without reading content
const stats = await fs.promises.stat(filePath);
const size = stats.size;
// Calculate hash using stream to ensure low memory usage
const hash = await new Promise((resolve, reject) => {
const hashStream = crypto.createHash('sha256');
const rs = fs.createReadStream(filePath);
rs.on('error', reject);
rs.on('data', chunk => hashStream.update(chunk));
rs.on('end', () => resolve(hashStream.digest('hex')));
});
if (hash !== item.checksum || size !== item.size) {
console.log(`[UPDATE] Item ${item.id} (${index + 1}/${items.length})`);
if (hash !== item.checksum) console.log(` - Hash: ${item.checksum} -> ${hash}`);
if (size !== item.size) console.log(` - Size: ${item.size} -> ${size}`);
await db`
UPDATE items
SET checksum = ${hash}, size = ${size}
WHERE id = ${item.id}
`;
updated++;
} else {
skipped++;
}
// Log progress every 100 items
if ((index + 1) % 100 === 0) {
console.log(`Progress: ${index + 1}/${items.length} (Updated: ${updated}, Errors: ${errors})`);
}
} catch (err) {
console.error(`[ERROR] Processing item ${item.id}:`, err);
errors++;
}
}
console.log('Done.');
console.log(`Total: ${items.length}`);
console.log(`Updated: ${updated}`);
console.log(`Skipped (No changes): ${skipped}`);
console.log(`Errors (Missing files): ${errors}`);
} catch (err) {
console.error('Fatal error:', err);
} finally {
process.exit(0);
}
};
run();

87
debug/thumbnailer.mjs Normal file
View File

@@ -0,0 +1,87 @@
import sql from "../src/inc/sql.mjs";
import fs from "fs";
import { exec as _exec } from "child_process";
const exec = cmd => new Promise((resolve, reject) => {
_exec(cmd, { maxBuffer: 5e3 * 1024 }, (err, stdout, stderr) => {
if(err)
return reject(err);
if(stderr)
console.error(stderr);
resolve({ stdout: stdout });
});
});
const _args = process.argv.slice(2);
const _itemid = +_args[0] || 0;
// Ensure temp and output directories exist
if (!fs.existsSync('./tmp')) fs.mkdirSync('./tmp', { recursive: true });
if (!fs.existsSync('./public/t')) fs.mkdirSync('./public/t', { recursive: true });
let items;
if(_itemid > 0)
items = await sql`select id, dest, mime, src from "items" where id = ${_itemid}`;
else
items = await sql`select id, dest, mime, src from "items"`;
let count = 1;
let total = items.length;
for(let item of items) {
const itemid = item.id;
const filename = item.dest;
const mime = item.mime;
const link = item.src;
try {
if(mime.startsWith('video/') || mime == 'image/gif') {
const seeks = ['20%', '40%', '60%', '80%'];
for (const seek of seeks) {
await exec(`ffmpegthumbnailer -i./public/b/${filename} -s1024 -t ${seek} -o./tmp/${itemid}.png`);
try {
const { stdout } = await exec(`magick "./tmp/${itemid}.png" -colorspace Gray -format "%[fx:mean]" info:`);
if (parseFloat(stdout.trim()) > 0.05) break;
} catch (e) { break; }
}
}
else if(mime.startsWith('image/') && mime != 'image/gif')
await exec(`magick ./public/b/${filename}[0] ./tmp/${itemid}.png`);
else if(mime.startsWith('audio/')) {
if(link.match(/soundcloud/)) {
let cover = (await exec(`yt-dlp --get-thumbnail "${link}"`)).stdout.trim();
if(!cover.match(/default_avatar/)) {
cover = cover.replace(/-(large|original)\./, '-t500x500.');
try {
await exec(`wget "${cover}" -O ./tmp/${itemid}.jpg`);
const size = (await fs.promises.stat(`./tmp/${itemid}.jpg`)).size;
if(size >= 0) {
await exec(`magick ./tmp/${itemid}.jpg ./tmp/${itemid}.png`);
await exec(`magick ./tmp/${itemid}.jpg ./public/ca/${itemid}.webp`);
}
} catch(err) {
//console.log(err);
}
}
else {
await exec(`ffmpeg -i ./public/b/${filename} -update 1 -map 0:v -map 0:1 -c copy ./tmp/${itemid}.png`);
await exec(`magick ./tmp/${itemid}.png ./public/ca/${itemid}.webp`);
}
}
else {
await exec(`ffmpeg -i ./public/b/${filename} -update 1 -map 0:v -map 0:1 -c copy ./tmp/${itemid}.png`);
await exec(`magick ./tmp/${itemid}.png ./public/ca/${itemid}.webp`);
}
}
await exec(`magick "./tmp/${itemid}.png" -resize "128x128^" -gravity center -crop 128x128+0+0 +repage ./public/t/${itemid}.webp`);
await fs.promises.unlink(`./tmp/${itemid}.png`).catch(err => {});
await fs.promises.unlink(`./tmp/${itemid}.jpg`).catch(err => {});
} catch(err) {
console.error(`Failed to generate thumbnail for ${itemid}:`, err.message);
// await exec(`magick ./mugge.png ./public/t/${itemid}.webp`);
}
console.log(`current: ${itemid} (${count} / ${total})`);
count++;
}
console.log('Thumbnail generation complete.');
process.exit(0);

34
debug/trigger.mjs Normal file
View File

@@ -0,0 +1,34 @@
import { promises as fs } from "fs";
(async () => {
const _args = process.argv.slice(2);
const _e = {
network: "console",
message: _args.join(" "),
args: _args.slice(1),
channel: "console",
user: {
prefix: "console!console@console",
nick: "console",
username: "console",
account: "console"
},
reply: (...args) => console.log(args),
replyAction: (...args) => console.log(args),
replyNotice: (...args) => console.log(args)
};
const trigger = (await Promise.all((await fs.readdir("./src/inc/trigger"))
.filter(f => f.endsWith(".mjs"))
.map(async t => await (await import(`../src/inc/trigger/${t}`)).default())
)).filter(t => t[0].call.test(_e.message)).map(t => ({ name: t[0].name, f: t[0].f }));
try {
if(trigger.length === 0)
return console.error("no matches");
console.log(`triggered > ${trigger[0].name} (${_e.message})`);
await trigger[0].f(_e);
} catch(err) {
console.error(err);
}
})();

110
debug/user-admin.mjs Normal file
View File

@@ -0,0 +1,110 @@
import db from "../src/inc/sql.mjs";
import lib from "../src/inc/lib.mjs";
const usage = () => {
console.log(`
Usage: node user-admin.mjs <command> [args]
Commands:
hash <password> - Generate a password hash
create <user> <pass> [--admin] [--mod] - Create a new user
delete <user> - Delete a user
passwd <user> <new_pass> - Change a user's password
list - List all users
`);
process.exit(1);
};
const args = process.argv.slice(2);
if (args.length === 0) usage();
const cmd = args[0];
(async () => {
try {
switch (cmd) {
case "hash": {
if (args.length < 2) usage();
const hash = await lib.hash(args[1]);
console.log(`Hash: ${hash}`);
break;
}
case "create": {
if (args.length < 3) usage();
const username = args[1];
const password = args[2];
const isAdmin = args.includes("--admin");
const isMod = args.includes("--mod");
const hash = await lib.hash(password);
const ts = ~~(Date.now() / 1e3);
const existing = await db`select id from "user" where login = ${username.toLowerCase()}`;
if (existing.length > 0) {
console.error(`Error: User '${username}' already exists.`);
process.exit(1);
}
const newUser = await db`
insert into "user" ("login", "user", "password", "admin", "is_moderator", "created_at")
values (${username.toLowerCase()}, ${username}, ${hash}, ${isAdmin}, ${isMod}, to_timestamp(${ts}))
returning id
`;
const userId = newUser[0].id;
// Add default options
await db`
insert into user_options (user_id, mode, theme, avatar)
values (${userId}, 3, 'amoled', 0)
`;
console.log(`Successfully created user '${username}' (ID: ${userId})`);
break;
}
case "delete": {
if (args.length < 2) usage();
const username = args[1].toLowerCase();
const result = await db`delete from "user" where login = ${username} returning id`;
if (result.length === 0) {
console.error(`Error: User '${username}' not found.`);
process.exit(1);
}
console.log(`Successfully deleted user '${username}' (ID: ${result[0].id})`);
break;
}
case "passwd": {
if (args.length < 3) usage();
const username = args[1].toLowerCase();
const newPass = args[2];
const hash = await lib.hash(newPass);
const result = await db`update "user" set password = ${hash} where login = ${username} returning id`;
if (result.length === 0) {
console.error(`Error: User '${username}' not found.`);
process.exit(1);
}
console.log(`Successfully updated password for user '${username}'`);
break;
}
case "list": {
const users = await db`select id, login, "user", admin, is_moderator, created_at from "user" order by id asc`;
console.table(users);
break;
}
default:
usage();
}
} catch (err) {
console.error("Fatal error:", err);
} finally {
process.exit(0);
}
})();