@mostajs/orm-cli 0.2.3 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/mostajs.sh +667 -9
- package/package.json +2 -1
package/bin/mostajs.sh
CHANGED
|
@@ -437,6 +437,7 @@ menu_main() {
|
|
|
437
437
|
echo -e " ${CYAN}7${RESET}) View logs"
|
|
438
438
|
echo -e " ${CYAN}8${RESET}) Health checks"
|
|
439
439
|
echo -e " ${CYAN}9${RESET}) Generate boilerplate (src/db.ts with bridge)"
|
|
440
|
+
echo -e " ${CYAN}s${RESET}) ${BOLD}Seeding${RESET} (upload / validate / apply seed data)"
|
|
440
441
|
echo -e " ${CYAN}0${RESET}) About / Help"
|
|
441
442
|
echo
|
|
442
443
|
echo -e " ${RED}q${RESET}) Quit"
|
|
@@ -453,6 +454,7 @@ menu_main() {
|
|
|
453
454
|
7) action_logs ;;
|
|
454
455
|
8) action_healthcheck ;;
|
|
455
456
|
9) action_generate_boilerplate ;;
|
|
457
|
+
s|S) menu_seeding ;;
|
|
456
458
|
0) action_about ;;
|
|
457
459
|
q|Q) exit 0 ;;
|
|
458
460
|
*) warn "Unknown choice"; pause ;;
|
|
@@ -1365,12 +1367,23 @@ show_urls() {
|
|
|
1365
1367
|
load_env
|
|
1366
1368
|
local ip
|
|
1367
1369
|
ip=$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost)
|
|
1370
|
+
|
|
1371
|
+
# Parse mosta-net URL from config (full URL, not just port)
|
|
1372
|
+
local mosta_url="${MOSTA_NET_URL:-http://localhost:14488}"
|
|
1373
|
+
local mosta_host="${mosta_url#*://}"
|
|
1374
|
+
mosta_host="${mosta_host%%/*}"
|
|
1375
|
+
|
|
1376
|
+
# App URL : derive from APP_PORT
|
|
1377
|
+
local app_port="${APP_PORT:-3000}"
|
|
1378
|
+
|
|
1368
1379
|
echo
|
|
1369
1380
|
echo -e "${BOLD}Access URLs${RESET}"
|
|
1370
|
-
echo -e " Dev server (local) : ${CYAN}http://localhost:${
|
|
1371
|
-
echo -e " Dev server (mobile) : ${CYAN}http://${ip}:${
|
|
1372
|
-
echo -e " mosta-net
|
|
1373
|
-
echo -e "
|
|
1381
|
+
echo -e " Dev server (local) : ${CYAN}http://localhost:${app_port}${RESET}"
|
|
1382
|
+
echo -e " Dev server (mobile) : ${CYAN}http://${ip}:${app_port}${RESET}"
|
|
1383
|
+
echo -e " mosta-net base : ${CYAN}${mosta_url}${RESET}"
|
|
1384
|
+
echo -e " REST CRUD : ${CYAN}${mosta_url}/api/v1/<collection>${RESET}"
|
|
1385
|
+
echo -e " MCP endpoint (AI) : ${CYAN}${mosta_url}/mcp${RESET}"
|
|
1386
|
+
echo -e " LAN (mobile) : ${CYAN}http://${ip}${mosta_host#localhost}${RESET}"
|
|
1374
1387
|
}
|
|
1375
1388
|
|
|
1376
1389
|
# ============================================================
|
|
@@ -1464,6 +1477,594 @@ action_healthcheck() {
|
|
|
1464
1477
|
pause
|
|
1465
1478
|
}
|
|
1466
1479
|
|
|
1480
|
+
# ============================================================
|
|
1481
|
+
# MENU S : SEEDING
|
|
1482
|
+
# ============================================================
|
|
1483
|
+
|
|
1484
|
+
menu_seeding() {
|
|
1485
|
+
load_env
|
|
1486
|
+
header
|
|
1487
|
+
echo -e "${BOLD}${MAGENTA}▶ Seeding — populate databases with test / initial data${RESET}"
|
|
1488
|
+
echo
|
|
1489
|
+
local seed_dir="$CONFIG_DIR/seeds"
|
|
1490
|
+
mkdir -p "$seed_dir"
|
|
1491
|
+
local count
|
|
1492
|
+
count=$(ls -1 "$seed_dir"/*.json 2>/dev/null | wc -l)
|
|
1493
|
+
echo -e " Seed directory : ${DIM}${seed_dir}${RESET}"
|
|
1494
|
+
echo -e " Seed files : ${DIM}${count} .json${RESET}"
|
|
1495
|
+
[[ $count -gt 0 ]] && ls "$seed_dir"/*.json 2>/dev/null | while read f; do
|
|
1496
|
+
local rows; rows=$(node -e "try{console.log(JSON.parse(require('fs').readFileSync('$f','utf8')).length)}catch{console.log('?')}" 2>/dev/null)
|
|
1497
|
+
dim " - $(basename "$f") (${rows} rows)"
|
|
1498
|
+
done
|
|
1499
|
+
echo
|
|
1500
|
+
echo -e "${BOLD}━━━ SEEDING MENU ━━━${RESET}"
|
|
1501
|
+
echo
|
|
1502
|
+
echo -e " ${CYAN}1${RESET}) Upload / import seed file(s)"
|
|
1503
|
+
echo -e " ${CYAN}2${RESET}) Generate seed templates (one empty .json per entity)"
|
|
1504
|
+
echo -e " ${CYAN}3${RESET}) Validate seeds against schema (dry-run, no DB writes)"
|
|
1505
|
+
echo -e " ${CYAN}4${RESET}) Apply seeds to primary DB"
|
|
1506
|
+
echo -e " ${CYAN}5${RESET}) Apply seeds with upsert (insert or update by id)"
|
|
1507
|
+
echo -e " ${CYAN}6${RESET}) Truncate + apply (DESTRUCTIVE — wipes tables first)"
|
|
1508
|
+
echo -e " ${CYAN}7${RESET}) Dump current DB rows → .mostajs/seeds-dump/"
|
|
1509
|
+
echo -e " ${CYAN}8${RESET}) Clear the seeds directory"
|
|
1510
|
+
echo -e " ${CYAN}9${RESET}) Show a seed file"
|
|
1511
|
+
echo -e " ${CYAN}h${RESET}) ${BOLD}Hash plain-text passwords in seed files${RESET} (bcrypt)"
|
|
1512
|
+
echo
|
|
1513
|
+
echo -e " ${CYAN}b${RESET}) Back"
|
|
1514
|
+
echo
|
|
1515
|
+
local choice
|
|
1516
|
+
choice=$(ask "Choice" "1")
|
|
1517
|
+
case "$choice" in
|
|
1518
|
+
1) action_seed_upload ;;
|
|
1519
|
+
2) action_seed_generate_templates ;;
|
|
1520
|
+
3) action_seed_apply "validate" ;;
|
|
1521
|
+
4) action_seed_apply "apply" ;;
|
|
1522
|
+
5) action_seed_apply "upsert" ;;
|
|
1523
|
+
6) action_seed_apply "truncate-apply" ;;
|
|
1524
|
+
7) action_seed_dump ;;
|
|
1525
|
+
8) action_seed_clear ;;
|
|
1526
|
+
9) action_seed_show ;;
|
|
1527
|
+
h|H) action_seed_hash_passwords ;;
|
|
1528
|
+
b|B) return ;;
|
|
1529
|
+
*) warn "Unknown"; pause ;;
|
|
1530
|
+
esac
|
|
1531
|
+
menu_seeding
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
# ------------------------------------------------------------
|
|
1535
|
+
# seed : hash plain-text passwords
|
|
1536
|
+
# ------------------------------------------------------------
|
|
1537
|
+
|
|
1538
|
+
action_seed_hash_passwords() {
|
|
1539
|
+
header
|
|
1540
|
+
echo -e "${BOLD}${MAGENTA}▶ Hash plain-text passwords in seed files${RESET}"
|
|
1541
|
+
echo
|
|
1542
|
+
|
|
1543
|
+
local seed_dir="$CONFIG_DIR/seeds"
|
|
1544
|
+
local count
|
|
1545
|
+
count=$(ls -1 "$seed_dir"/*.json 2>/dev/null | wc -l)
|
|
1546
|
+
if [[ $count -eq 0 ]]; then
|
|
1547
|
+
err "No seed files in $seed_dir (menu S → 1 first)"
|
|
1548
|
+
pause; return
|
|
1549
|
+
fi
|
|
1550
|
+
|
|
1551
|
+
echo "Auto-detects fields named :"
|
|
1552
|
+
dim " password, passwordHash, hashedPassword, pwd, userPassword"
|
|
1553
|
+
echo
|
|
1554
|
+
echo "Skips values that are already bcrypt hashes (start with \$2a\$ / \$2b\$ / \$2y\$, 60 chars)"
|
|
1555
|
+
echo
|
|
1556
|
+
|
|
1557
|
+
local cost
|
|
1558
|
+
cost=$(ask "bcrypt cost factor" "${BCRYPT_COST:-10}")
|
|
1559
|
+
if ! confirm "Hash all plain passwords in $seed_dir ?"; then
|
|
1560
|
+
return
|
|
1561
|
+
fi
|
|
1562
|
+
|
|
1563
|
+
# Ensure bcryptjs is available
|
|
1564
|
+
ensure_pkg "bcryptjs" || { pause; return; }
|
|
1565
|
+
|
|
1566
|
+
cat > "$CONFIG_DIR/seed-hash.mjs" <<EOF
|
|
1567
|
+
import { readdirSync, readFileSync, writeFileSync } from 'fs';
|
|
1568
|
+
import { join } from 'path';
|
|
1569
|
+
import bcrypt from 'bcryptjs';
|
|
1570
|
+
|
|
1571
|
+
const SEEDDIR = '$seed_dir';
|
|
1572
|
+
const COST = $cost;
|
|
1573
|
+
|
|
1574
|
+
// Field names commonly used for passwords
|
|
1575
|
+
const PASSWORD_FIELDS = ['password', 'passwordHash', 'hashedPassword', 'pwd', 'userPassword'];
|
|
1576
|
+
|
|
1577
|
+
// Test if a string is already a bcrypt hash
|
|
1578
|
+
const isBcrypt = v => typeof v === 'string' && /^\\\$2[ayb]\\\$\\d{1,2}\\\$/.test(v) && v.length === 60;
|
|
1579
|
+
|
|
1580
|
+
let totalHashed = 0;
|
|
1581
|
+
let totalSkipped = 0;
|
|
1582
|
+
const summary = [];
|
|
1583
|
+
|
|
1584
|
+
for (const f of readdirSync(SEEDDIR).filter(x => x.endsWith('.json'))) {
|
|
1585
|
+
const path = join(SEEDDIR, f);
|
|
1586
|
+
let data;
|
|
1587
|
+
try { data = JSON.parse(readFileSync(path, 'utf8')); }
|
|
1588
|
+
catch { continue; }
|
|
1589
|
+
|
|
1590
|
+
if (!Array.isArray(data)) continue;
|
|
1591
|
+
|
|
1592
|
+
let hashed = 0, skipped = 0;
|
|
1593
|
+
for (const row of data) {
|
|
1594
|
+
for (const field of PASSWORD_FIELDS) {
|
|
1595
|
+
const val = row[field];
|
|
1596
|
+
if (typeof val !== 'string' || val.length === 0) continue;
|
|
1597
|
+
if (isBcrypt(val)) { skipped++; continue; }
|
|
1598
|
+
row[field] = bcrypt.hashSync(val, COST);
|
|
1599
|
+
hashed++;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
if (hashed > 0) {
|
|
1604
|
+
writeFileSync(path, JSON.stringify(data, null, 2));
|
|
1605
|
+
summary.push({ file: f, hashed, skipped });
|
|
1606
|
+
}
|
|
1607
|
+
totalHashed += hashed;
|
|
1608
|
+
totalSkipped += skipped;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
for (const s of summary) {
|
|
1612
|
+
console.log(' \u2713 ' + s.file + ' : ' + s.hashed + ' hashed' + (s.skipped ? ' (+ ' + s.skipped + ' already hashed)' : ''));
|
|
1613
|
+
}
|
|
1614
|
+
console.log();
|
|
1615
|
+
console.log('Total : ' + totalHashed + ' hashed, ' + totalSkipped + ' already-hashed skipped');
|
|
1616
|
+
if (totalHashed === 0 && totalSkipped === 0) {
|
|
1617
|
+
console.log('No password fields found.');
|
|
1618
|
+
}
|
|
1619
|
+
EOF
|
|
1620
|
+
cd "$PROJECT_ROOT"
|
|
1621
|
+
node "$CONFIG_DIR/seed-hash.mjs" 2>&1 | tee "$LOG_DIR/seed-hash.log"
|
|
1622
|
+
echo
|
|
1623
|
+
pause
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
# ------------------------------------------------------------
|
|
1627
|
+
# seed : upload / import
|
|
1628
|
+
# ------------------------------------------------------------
|
|
1629
|
+
|
|
1630
|
+
action_seed_upload() {
|
|
1631
|
+
header
|
|
1632
|
+
echo -e "${BOLD}${MAGENTA}▶ Upload seed file(s)${RESET}"
|
|
1633
|
+
echo
|
|
1634
|
+
local seed_dir="$CONFIG_DIR/seeds"
|
|
1635
|
+
mkdir -p "$seed_dir"
|
|
1636
|
+
|
|
1637
|
+
echo "Supported forms :"
|
|
1638
|
+
dim " (a) Single file with all collections : seeds.json → { users: [...], posts: [...] }"
|
|
1639
|
+
dim " (b) One file per collection (preferred): seeds/users.json = [...rows...]"
|
|
1640
|
+
dim " (c) CSV (header row = field names) : seeds/users.csv"
|
|
1641
|
+
echo
|
|
1642
|
+
local src
|
|
1643
|
+
src=$(ask "Path to seed file or directory (tilde OK)")
|
|
1644
|
+
[[ -z "$src" ]] && return
|
|
1645
|
+
src="${src/#\~/$HOME}"
|
|
1646
|
+
[[ ! -e "$src" ]] && { err "Path does not exist : $src"; pause; return; }
|
|
1647
|
+
|
|
1648
|
+
if [[ -d "$src" ]]; then
|
|
1649
|
+
local n=0
|
|
1650
|
+
for f in "$src"/*.{json,csv}; do
|
|
1651
|
+
[[ -f "$f" ]] || continue
|
|
1652
|
+
cp "$f" "$seed_dir/" && n=$((n+1))
|
|
1653
|
+
done
|
|
1654
|
+
ok "Imported $n file(s) from $src → $seed_dir"
|
|
1655
|
+
elif [[ "$src" =~ \.json$ ]]; then
|
|
1656
|
+
# Case (a) or (b) — detect
|
|
1657
|
+
local is_map
|
|
1658
|
+
is_map=$(node -e "
|
|
1659
|
+
const d = JSON.parse(require('fs').readFileSync('$src','utf8'));
|
|
1660
|
+
console.log(!Array.isArray(d) && typeof d === 'object' ? 'yes' : 'no');
|
|
1661
|
+
" 2>/dev/null)
|
|
1662
|
+
if [[ "$is_map" == "yes" ]]; then
|
|
1663
|
+
# Split into per-collection files
|
|
1664
|
+
node -e "
|
|
1665
|
+
const { writeFileSync } = require('fs');
|
|
1666
|
+
const d = JSON.parse(require('fs').readFileSync('$src','utf8'));
|
|
1667
|
+
let n = 0;
|
|
1668
|
+
for (const [coll, rows] of Object.entries(d)) {
|
|
1669
|
+
if (!Array.isArray(rows)) continue;
|
|
1670
|
+
writeFileSync('$seed_dir/' + coll + '.json', JSON.stringify(rows, null, 2));
|
|
1671
|
+
console.log(' wrote ' + coll + '.json (' + rows.length + ' rows)');
|
|
1672
|
+
n++;
|
|
1673
|
+
}
|
|
1674
|
+
console.log('split ' + n + ' collections');
|
|
1675
|
+
" 2>&1 | tee -a "$LOG_DIR/seed.log"
|
|
1676
|
+
else
|
|
1677
|
+
# Single-collection array
|
|
1678
|
+
local base; base=$(basename "$src")
|
|
1679
|
+
cp "$src" "$seed_dir/$base"
|
|
1680
|
+
ok "Copied → $seed_dir/$base"
|
|
1681
|
+
fi
|
|
1682
|
+
elif [[ "$src" =~ \.csv$ ]]; then
|
|
1683
|
+
local base; base=$(basename "$src" .csv)
|
|
1684
|
+
# Convert CSV to JSON (minimal — header row + comma-separated values)
|
|
1685
|
+
node -e "
|
|
1686
|
+
const { readFileSync, writeFileSync } = require('fs');
|
|
1687
|
+
const lines = readFileSync('$src','utf8').split(/\r?\n/).filter(Boolean);
|
|
1688
|
+
if (lines.length < 2) { console.error('CSV too small'); process.exit(1); }
|
|
1689
|
+
const headers = lines[0].split(',');
|
|
1690
|
+
const rows = lines.slice(1).map(line => {
|
|
1691
|
+
const vals = line.split(',');
|
|
1692
|
+
const o = {};
|
|
1693
|
+
headers.forEach((h, i) => { o[h.trim()] = vals[i]?.trim() ?? null; });
|
|
1694
|
+
return o;
|
|
1695
|
+
});
|
|
1696
|
+
writeFileSync('$seed_dir/${base}.json', JSON.stringify(rows, null, 2));
|
|
1697
|
+
console.log('Converted ' + rows.length + ' rows → $seed_dir/${base}.json');
|
|
1698
|
+
" 2>&1 | tee -a "$LOG_DIR/seed.log"
|
|
1699
|
+
else
|
|
1700
|
+
err "Unsupported file type — use .json or .csv"
|
|
1701
|
+
fi
|
|
1702
|
+
pause
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
# ------------------------------------------------------------
|
|
1706
|
+
# seed : generate empty templates
|
|
1707
|
+
# ------------------------------------------------------------
|
|
1708
|
+
|
|
1709
|
+
action_seed_generate_templates() {
|
|
1710
|
+
header
|
|
1711
|
+
echo -e "${BOLD}${MAGENTA}▶ Generate empty seed templates${RESET}"
|
|
1712
|
+
echo
|
|
1713
|
+
|
|
1714
|
+
if [[ ! -f "$GENERATED_DIR/entities.json" ]]; then
|
|
1715
|
+
err "No entities.json — run menu 1 (Convert) first"
|
|
1716
|
+
pause; return
|
|
1717
|
+
fi
|
|
1718
|
+
|
|
1719
|
+
local seed_dir="$CONFIG_DIR/seeds"
|
|
1720
|
+
mkdir -p "$seed_dir"
|
|
1721
|
+
|
|
1722
|
+
if ! confirm "Generate a template .json per entity (will NOT overwrite existing files)?"; then
|
|
1723
|
+
return
|
|
1724
|
+
fi
|
|
1725
|
+
|
|
1726
|
+
node -e "
|
|
1727
|
+
const { readFileSync, writeFileSync, existsSync } = require('fs');
|
|
1728
|
+
const entities = JSON.parse(readFileSync('$GENERATED_DIR/entities.json','utf8'));
|
|
1729
|
+
let created = 0, skipped = 0;
|
|
1730
|
+
for (const e of entities) {
|
|
1731
|
+
const file = '$seed_dir/' + e.collection + '.json';
|
|
1732
|
+
if (existsSync(file)) { skipped++; continue; }
|
|
1733
|
+
|
|
1734
|
+
// Build a sample row from field defaults
|
|
1735
|
+
const sample = {};
|
|
1736
|
+
for (const [k, def] of Object.entries(e.fields ?? {})) {
|
|
1737
|
+
if (def.default !== undefined && typeof def.default !== 'object') sample[k] = def.default;
|
|
1738
|
+
else if (def.type === 'string' && def.enum) sample[k] = def.enum[0] ?? '';
|
|
1739
|
+
else if (def.type === 'string') sample[k] = 'example';
|
|
1740
|
+
else if (def.type === 'number') sample[k] = 0;
|
|
1741
|
+
else if (def.type === 'boolean') sample[k] = false;
|
|
1742
|
+
else if (def.type === 'date') sample[k] = new Date().toISOString();
|
|
1743
|
+
else if (def.type === 'json') sample[k] = {};
|
|
1744
|
+
else if (def.type === 'array') sample[k] = [];
|
|
1745
|
+
}
|
|
1746
|
+
writeFileSync(file, JSON.stringify([sample], null, 2));
|
|
1747
|
+
created++;
|
|
1748
|
+
}
|
|
1749
|
+
console.log('Created ' + created + ', skipped ' + skipped + ' (already existed)');
|
|
1750
|
+
" 2>&1 | tee -a "$LOG_DIR/seed.log"
|
|
1751
|
+
pause
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
# ------------------------------------------------------------
|
|
1755
|
+
# seed : validate + apply
|
|
1756
|
+
# ------------------------------------------------------------
|
|
1757
|
+
|
|
1758
|
+
action_seed_apply() {
|
|
1759
|
+
local mode="$1" # validate | apply | upsert | truncate-apply
|
|
1760
|
+
header
|
|
1761
|
+
local title="Validate seeds (dry-run)"
|
|
1762
|
+
case "$mode" in
|
|
1763
|
+
apply) title="Apply seeds (insert)" ;;
|
|
1764
|
+
upsert) title="Apply seeds (upsert by id)" ;;
|
|
1765
|
+
truncate-apply) title="DESTRUCTIVE: truncate + apply" ;;
|
|
1766
|
+
esac
|
|
1767
|
+
echo -e "${BOLD}${MAGENTA}▶ $title${RESET}"
|
|
1768
|
+
echo
|
|
1769
|
+
|
|
1770
|
+
load_env
|
|
1771
|
+
|
|
1772
|
+
if [[ ! -f "$GENERATED_DIR/entities.json" ]]; then
|
|
1773
|
+
err "No entities.json — run menu 1 (Convert) first"
|
|
1774
|
+
pause; return
|
|
1775
|
+
fi
|
|
1776
|
+
|
|
1777
|
+
local seed_dir="$CONFIG_DIR/seeds"
|
|
1778
|
+
local file_count
|
|
1779
|
+
file_count=$(ls -1 "$seed_dir"/*.json 2>/dev/null | wc -l)
|
|
1780
|
+
if [[ $file_count -eq 0 ]]; then
|
|
1781
|
+
err "No seed files in $seed_dir — use menu S → 1 or 2"
|
|
1782
|
+
pause; return
|
|
1783
|
+
fi
|
|
1784
|
+
|
|
1785
|
+
if [[ "$mode" == "truncate-apply" ]]; then
|
|
1786
|
+
warn "This will TRUNCATE all tables listed in seeds before inserting."
|
|
1787
|
+
confirm "Really wipe the tables ?" || return
|
|
1788
|
+
if ! confirm "Are you SURE ? This cannot be undone." ; then return; fi
|
|
1789
|
+
fi
|
|
1790
|
+
|
|
1791
|
+
if [[ "$mode" != "validate" ]]; then
|
|
1792
|
+
if [[ -z "${DB_DIALECT:-}" || -z "${SGBD_URI:-}" ]]; then
|
|
1793
|
+
err "No DB configured (menu 2 first)"
|
|
1794
|
+
pause; return
|
|
1795
|
+
fi
|
|
1796
|
+
# Dialect specific driver
|
|
1797
|
+
ensure_pkg "@mostajs/orm" || return
|
|
1798
|
+
ensure_dialect_driver "$DB_DIALECT" || warn "Driver for $DB_DIALECT may be missing"
|
|
1799
|
+
fi
|
|
1800
|
+
|
|
1801
|
+
local orm_path
|
|
1802
|
+
orm_path=$(resolve_pkg_path "@mostajs/orm" 2>/dev/null)
|
|
1803
|
+
if [[ "$mode" != "validate" ]] && [[ -z "$orm_path" ]]; then
|
|
1804
|
+
err "Cannot resolve @mostajs/orm — install it first"
|
|
1805
|
+
pause; return
|
|
1806
|
+
fi
|
|
1807
|
+
|
|
1808
|
+
cat > "$CONFIG_DIR/seed-runner.mjs" <<EOF
|
|
1809
|
+
import { readFileSync, readdirSync } from 'fs';
|
|
1810
|
+
import { join } from 'path';
|
|
1811
|
+
|
|
1812
|
+
const MODE = process.argv[2] ?? 'validate';
|
|
1813
|
+
const SEEDDIR = process.argv[3];
|
|
1814
|
+
const ENTPATH = process.argv[4];
|
|
1815
|
+
const DIALECT = process.env.DB_DIALECT ?? '';
|
|
1816
|
+
const URI = process.env.SGBD_URI ?? '';
|
|
1817
|
+
|
|
1818
|
+
function stripScheme(u) {
|
|
1819
|
+
if (u.startsWith('sqlite://')) return u.slice(9);
|
|
1820
|
+
if (u.startsWith('sqlite:')) return u.slice(7);
|
|
1821
|
+
return u;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
const entities = JSON.parse(readFileSync(ENTPATH, 'utf8'));
|
|
1825
|
+
const entityByCollection = Object.fromEntries(entities.map(e => [e.collection, e]));
|
|
1826
|
+
const entityByName = Object.fromEntries(entities.map(e => [e.name, e]));
|
|
1827
|
+
|
|
1828
|
+
// ---------- Load seed files ----------
|
|
1829
|
+
const seeds = {}; // collection -> rows
|
|
1830
|
+
for (const f of readdirSync(SEEDDIR).filter(x => x.endsWith('.json'))) {
|
|
1831
|
+
const coll = f.replace(/\\.json$/, '');
|
|
1832
|
+
let data;
|
|
1833
|
+
try {
|
|
1834
|
+
data = JSON.parse(readFileSync(join(SEEDDIR, f), 'utf8'));
|
|
1835
|
+
} catch (e) {
|
|
1836
|
+
console.error(' \u2717 ' + f + ' : invalid JSON (' + e.message + ')');
|
|
1837
|
+
continue;
|
|
1838
|
+
}
|
|
1839
|
+
if (!Array.isArray(data)) {
|
|
1840
|
+
console.error(' \u2717 ' + f + ' : file is not an array of rows');
|
|
1841
|
+
continue;
|
|
1842
|
+
}
|
|
1843
|
+
seeds[coll] = data;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// ---------- Validate ----------
|
|
1847
|
+
const PASSWORD_FIELDS = ['password', 'passwordHash', 'hashedPassword', 'pwd', 'userPassword'];
|
|
1848
|
+
const isBcrypt = v => typeof v === 'string' && /^\\\$2[ayb]\\\$\\d{1,2}\\\$/.test(v) && v.length === 60;
|
|
1849
|
+
|
|
1850
|
+
function validateRow(row, entity) {
|
|
1851
|
+
const errors = [];
|
|
1852
|
+
const fieldNames = new Set(Object.keys(entity.fields ?? {}));
|
|
1853
|
+
const relationNames = new Set(Object.keys(entity.relations ?? {}));
|
|
1854
|
+
// Required fields
|
|
1855
|
+
for (const [k, def] of Object.entries(entity.fields ?? {})) {
|
|
1856
|
+
if (def.required && (row[k] === undefined || row[k] === null)) {
|
|
1857
|
+
errors.push('missing required field "' + k + '"');
|
|
1858
|
+
}
|
|
1859
|
+
// Enum
|
|
1860
|
+
if (def.enum && row[k] !== undefined && !def.enum.includes(row[k])) {
|
|
1861
|
+
errors.push('field "' + k + '" not in enum ' + JSON.stringify(def.enum) + ' (got: ' + JSON.stringify(row[k]) + ')');
|
|
1862
|
+
}
|
|
1863
|
+
// Type basic check
|
|
1864
|
+
if (row[k] !== undefined && row[k] !== null) {
|
|
1865
|
+
const val = row[k];
|
|
1866
|
+
const t = def.type;
|
|
1867
|
+
if (t === 'number' && typeof val !== 'number') errors.push('field "' + k + '" expected number, got ' + typeof val);
|
|
1868
|
+
if (t === 'boolean' && typeof val !== 'boolean') errors.push('field "' + k + '" expected boolean, got ' + typeof val);
|
|
1869
|
+
if (t === 'string' && typeof val !== 'string') errors.push('field "' + k + '" expected string, got ' + typeof val);
|
|
1870
|
+
if (t === 'date' && typeof val !== 'string' && !(val instanceof Date)) errors.push('field "' + k + '" expected date, got ' + typeof val);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
// Unknown fields (warn-level)
|
|
1874
|
+
const warnings = [];
|
|
1875
|
+
for (const k of Object.keys(row)) {
|
|
1876
|
+
if (!fieldNames.has(k) && !relationNames.has(k) && k !== 'id' && k !== '_id') {
|
|
1877
|
+
warnings.push('unknown field "' + k + '" (not in schema)');
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
// Password fields that look like plain-text (not bcrypt)
|
|
1881
|
+
for (const pf of PASSWORD_FIELDS) {
|
|
1882
|
+
if (row[pf] !== undefined && row[pf] !== null && row[pf] !== '' && !isBcrypt(row[pf])) {
|
|
1883
|
+
warnings.push('field "' + pf + '" does not look like a bcrypt hash — run menu S \u2192 h to hash');
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
return { errors, warnings };
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
let totalRows = 0;
|
|
1890
|
+
let totalErrors = 0;
|
|
1891
|
+
let totalWarnings = 0;
|
|
1892
|
+
const reports = [];
|
|
1893
|
+
|
|
1894
|
+
for (const [coll, rows] of Object.entries(seeds)) {
|
|
1895
|
+
const entity = entityByCollection[coll] ?? entityByName[coll];
|
|
1896
|
+
if (!entity) {
|
|
1897
|
+
console.error(' \u2717 ' + coll + ' : no matching entity (collection or name)');
|
|
1898
|
+
continue;
|
|
1899
|
+
}
|
|
1900
|
+
let collErrs = 0, collWarns = 0;
|
|
1901
|
+
rows.forEach((row, i) => {
|
|
1902
|
+
const { errors, warnings } = validateRow(row, entity);
|
|
1903
|
+
if (errors.length) {
|
|
1904
|
+
collErrs += errors.length;
|
|
1905
|
+
for (const e of errors) console.error(' \u2717 ' + coll + '[' + i + '] : ' + e);
|
|
1906
|
+
}
|
|
1907
|
+
collWarns += warnings.length;
|
|
1908
|
+
for (const w of warnings) console.warn(' \u26A0 ' + coll + '[' + i + '] : ' + w);
|
|
1909
|
+
});
|
|
1910
|
+
const mark = collErrs === 0 ? '\u2713' : '\u2717';
|
|
1911
|
+
console.log(' ' + mark + ' ' + coll + ' : ' + rows.length + ' rows, ' + collErrs + ' errors, ' + collWarns + ' warnings');
|
|
1912
|
+
reports.push({ coll, rows: rows.length, errors: collErrs, warnings: collWarns });
|
|
1913
|
+
totalRows += rows.length;
|
|
1914
|
+
totalErrors += collErrs;
|
|
1915
|
+
totalWarnings += collWarns;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
console.log();
|
|
1919
|
+
console.log('Validation : ' + totalRows + ' rows · ' + totalErrors + ' errors · ' + totalWarnings + ' warnings');
|
|
1920
|
+
|
|
1921
|
+
if (MODE === 'validate') {
|
|
1922
|
+
process.exit(totalErrors > 0 ? 1 : 0);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
if (totalErrors > 0) {
|
|
1926
|
+
console.error('Refusing to apply : fix validation errors first (run menu S → 3).');
|
|
1927
|
+
process.exit(2);
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// ---------- Apply ----------
|
|
1931
|
+
const { getDialect } = await import('$orm_path');
|
|
1932
|
+
const uri = DIALECT === 'sqlite' ? stripScheme(URI) : URI;
|
|
1933
|
+
const d = await getDialect({ dialect: DIALECT, uri, schemaStrategy: 'update' });
|
|
1934
|
+
await d.initSchema(entities);
|
|
1935
|
+
|
|
1936
|
+
let inserted = 0, failed = 0;
|
|
1937
|
+
|
|
1938
|
+
for (const [coll, rows] of Object.entries(seeds)) {
|
|
1939
|
+
const entity = entityByCollection[coll] ?? entityByName[coll];
|
|
1940
|
+
if (!entity) continue;
|
|
1941
|
+
|
|
1942
|
+
if (MODE === 'truncate-apply') {
|
|
1943
|
+
try {
|
|
1944
|
+
await d.deleteMany(entity, {});
|
|
1945
|
+
console.log(' \u2205 truncated ' + coll);
|
|
1946
|
+
} catch (e) {
|
|
1947
|
+
console.error(' \u2717 truncate ' + coll + ' : ' + (e.message ?? e));
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
for (const row of rows) {
|
|
1952
|
+
try {
|
|
1953
|
+
if (MODE === 'upsert' && (row.id || row._id)) {
|
|
1954
|
+
const id = row.id ?? row._id;
|
|
1955
|
+
const existing = await d.findById(entity, String(id)).catch(() => null);
|
|
1956
|
+
if (existing) {
|
|
1957
|
+
await d.update(entity, String(id), row);
|
|
1958
|
+
} else {
|
|
1959
|
+
await d.create(entity, row);
|
|
1960
|
+
}
|
|
1961
|
+
} else {
|
|
1962
|
+
await d.create(entity, row);
|
|
1963
|
+
}
|
|
1964
|
+
inserted++;
|
|
1965
|
+
} catch (e) {
|
|
1966
|
+
failed++;
|
|
1967
|
+
console.error(' \u2717 ' + coll + ' : ' + (e.message ?? e));
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
console.log(' \u2713 ' + coll + ' done');
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
console.log();
|
|
1974
|
+
console.log('Applied : ' + inserted + ' inserted · ' + failed + ' failed');
|
|
1975
|
+
await d.disconnect().catch(() => {});
|
|
1976
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
1977
|
+
EOF
|
|
1978
|
+
|
|
1979
|
+
cd "$PROJECT_ROOT" || return
|
|
1980
|
+
export DB_DIALECT="${DB_DIALECT:-}"
|
|
1981
|
+
export SGBD_URI="${SGBD_URI:-}"
|
|
1982
|
+
node "$CONFIG_DIR/seed-runner.mjs" "$mode" "$seed_dir" "$GENERATED_DIR/entities.json" 2>&1 | tee "$LOG_DIR/seed-${mode}.log"
|
|
1983
|
+
echo
|
|
1984
|
+
pause
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
# ------------------------------------------------------------
|
|
1988
|
+
# seed : dump current DB → seed files
|
|
1989
|
+
# ------------------------------------------------------------
|
|
1990
|
+
|
|
1991
|
+
action_seed_dump() {
|
|
1992
|
+
header
|
|
1993
|
+
echo -e "${BOLD}${MAGENTA}▶ Dump current DB rows${RESET}"
|
|
1994
|
+
echo
|
|
1995
|
+
load_env
|
|
1996
|
+
if [[ -z "${DB_DIALECT:-}" || -z "${SGBD_URI:-}" ]]; then
|
|
1997
|
+
err "No DB configured (menu 2 first)"
|
|
1998
|
+
pause; return
|
|
1999
|
+
fi
|
|
2000
|
+
if [[ ! -f "$GENERATED_DIR/entities.json" ]]; then
|
|
2001
|
+
err "No entities.json — run menu 1 (Convert) first"
|
|
2002
|
+
pause; return
|
|
2003
|
+
fi
|
|
2004
|
+
ensure_pkg "@mostajs/orm" || return
|
|
2005
|
+
local orm_path
|
|
2006
|
+
orm_path=$(resolve_pkg_path "@mostajs/orm") || return
|
|
2007
|
+
local dump_dir="$CONFIG_DIR/seeds-dump"
|
|
2008
|
+
mkdir -p "$dump_dir"
|
|
2009
|
+
|
|
2010
|
+
cat > "$CONFIG_DIR/seed-dump.mjs" <<EOF
|
|
2011
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
2012
|
+
import { getDialect } from '$orm_path';
|
|
2013
|
+
|
|
2014
|
+
const entities = JSON.parse(readFileSync('$GENERATED_DIR/entities.json','utf8'));
|
|
2015
|
+
const DIALECT = process.env.DB_DIALECT;
|
|
2016
|
+
let URI = process.env.SGBD_URI;
|
|
2017
|
+
if (DIALECT === 'sqlite') {
|
|
2018
|
+
if (URI.startsWith('sqlite://')) URI = URI.slice(9);
|
|
2019
|
+
else if (URI.startsWith('sqlite:')) URI = URI.slice(7);
|
|
2020
|
+
}
|
|
2021
|
+
const d = await getDialect({ dialect: DIALECT, uri: URI });
|
|
2022
|
+
await d.initSchema(entities);
|
|
2023
|
+
|
|
2024
|
+
for (const e of entities) {
|
|
2025
|
+
const rows = await d.find(e, {}, { limit: 10000 });
|
|
2026
|
+
writeFileSync('$dump_dir/' + e.collection + '.json', JSON.stringify(rows, null, 2));
|
|
2027
|
+
console.log(' \u2713 ' + e.collection + ' : ' + rows.length + ' rows');
|
|
2028
|
+
}
|
|
2029
|
+
await d.disconnect().catch(() => {});
|
|
2030
|
+
EOF
|
|
2031
|
+
cd "$PROJECT_ROOT"
|
|
2032
|
+
DB_DIALECT="$DB_DIALECT" SGBD_URI="$SGBD_URI" node "$CONFIG_DIR/seed-dump.mjs" 2>&1 | tee "$LOG_DIR/seed-dump.log"
|
|
2033
|
+
echo
|
|
2034
|
+
ok "Dump written to $dump_dir"
|
|
2035
|
+
pause
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
action_seed_clear() {
|
|
2039
|
+
header
|
|
2040
|
+
local seed_dir="$CONFIG_DIR/seeds"
|
|
2041
|
+
local count; count=$(ls -1 "$seed_dir"/*.json 2>/dev/null | wc -l)
|
|
2042
|
+
[[ $count -eq 0 ]] && { warn "Already empty"; pause; return; }
|
|
2043
|
+
if confirm "Delete all $count seed files in $seed_dir ?"; then
|
|
2044
|
+
rm -f "$seed_dir"/*.json
|
|
2045
|
+
ok "Cleared"
|
|
2046
|
+
fi
|
|
2047
|
+
pause
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
action_seed_show() {
|
|
2051
|
+
header
|
|
2052
|
+
local seed_dir="$CONFIG_DIR/seeds"
|
|
2053
|
+
local files=()
|
|
2054
|
+
for f in "$seed_dir"/*.json; do [[ -f "$f" ]] && files+=("$f"); done
|
|
2055
|
+
[[ ${#files[@]} -eq 0 ]] && { warn "No seed files"; pause; return; }
|
|
2056
|
+
echo "Pick a file to display :"
|
|
2057
|
+
local i=1
|
|
2058
|
+
for f in "${files[@]}"; do
|
|
2059
|
+
echo -e " ${CYAN}$i${RESET}) $(basename "$f")"
|
|
2060
|
+
i=$((i+1))
|
|
2061
|
+
done
|
|
2062
|
+
local num; num=$(ask "Number" 1)
|
|
2063
|
+
local idx=$((num-1))
|
|
2064
|
+
[[ $idx -ge 0 && $idx -lt ${#files[@]} ]] && ${PAGER:-less} "${files[$idx]}"
|
|
2065
|
+
pause
|
|
2066
|
+
}
|
|
2067
|
+
|
|
1467
2068
|
# ============================================================
|
|
1468
2069
|
# ACTION 9 : GENERATE BOILERPLATE
|
|
1469
2070
|
# ============================================================
|
|
@@ -1624,6 +2225,55 @@ EOF
|
|
|
1624
2225
|
|
|
1625
2226
|
run_subcommand() {
|
|
1626
2227
|
case "$1" in
|
|
2228
|
+
hash|h)
|
|
2229
|
+
# mostajs hash <plaintext> [cost]
|
|
2230
|
+
local pw="${2:-}"
|
|
2231
|
+
local cost="${3:-10}"
|
|
2232
|
+
[[ -z "$pw" ]] && { echo "Usage: mostajs hash <password> [cost=10]" >&2; exit 1; }
|
|
2233
|
+
# Try local project, then CLI's own node_modules (bcryptjs is a dep)
|
|
2234
|
+
local bcrypt_dir=""
|
|
2235
|
+
if [[ -d "$PROJECT_ROOT/node_modules/bcryptjs" ]]; then
|
|
2236
|
+
bcrypt_dir="$PROJECT_ROOT/node_modules/bcryptjs"
|
|
2237
|
+
else
|
|
2238
|
+
local cli_dir
|
|
2239
|
+
cli_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
2240
|
+
[[ -d "$cli_dir/node_modules/bcryptjs" ]] && bcrypt_dir="$cli_dir/node_modules/bcryptjs"
|
|
2241
|
+
fi
|
|
2242
|
+
if [[ -z "$bcrypt_dir" ]]; then
|
|
2243
|
+
ensure_pkg "bcryptjs" >/dev/null 2>&1 || {
|
|
2244
|
+
err "bcryptjs not available. Install it manually : npm install bcryptjs"
|
|
2245
|
+
exit 1
|
|
2246
|
+
}
|
|
2247
|
+
bcrypt_dir="$PROJECT_ROOT/node_modules/bcryptjs"
|
|
2248
|
+
fi
|
|
2249
|
+
BCRYPT_PASSWORD="$pw" BCRYPT_COST="$cost" BCRYPT_DIR="$bcrypt_dir" node -e "
|
|
2250
|
+
const bcrypt = require(process.env.BCRYPT_DIR);
|
|
2251
|
+
const h = bcrypt.hashSync(process.env.BCRYPT_PASSWORD, parseInt(process.env.BCRYPT_COST, 10));
|
|
2252
|
+
console.log(h);
|
|
2253
|
+
"
|
|
2254
|
+
;;
|
|
2255
|
+
verify|v)
|
|
2256
|
+
# mostajs verify <plaintext> <hash>
|
|
2257
|
+
local pw="${2:-}"
|
|
2258
|
+
local hashval="${3:-}"
|
|
2259
|
+
[[ -z "$pw" || -z "$hashval" ]] && { echo "Usage: mostajs verify <password> <hash>" >&2; exit 1; }
|
|
2260
|
+
local bcrypt_dir=""
|
|
2261
|
+
if [[ -d "$PROJECT_ROOT/node_modules/bcryptjs" ]]; then
|
|
2262
|
+
bcrypt_dir="$PROJECT_ROOT/node_modules/bcryptjs"
|
|
2263
|
+
else
|
|
2264
|
+
local cli_dir
|
|
2265
|
+
cli_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
2266
|
+
[[ -d "$cli_dir/node_modules/bcryptjs" ]] && bcrypt_dir="$cli_dir/node_modules/bcryptjs"
|
|
2267
|
+
fi
|
|
2268
|
+
if [[ -z "$bcrypt_dir" ]]; then
|
|
2269
|
+
ensure_pkg "bcryptjs" >/dev/null 2>&1 || exit 1
|
|
2270
|
+
bcrypt_dir="$PROJECT_ROOT/node_modules/bcryptjs"
|
|
2271
|
+
fi
|
|
2272
|
+
BCRYPT_PASSWORD="$pw" BCRYPT_HASH="$hashval" BCRYPT_DIR="$bcrypt_dir" node -e "
|
|
2273
|
+
const bcrypt = require(process.env.BCRYPT_DIR);
|
|
2274
|
+
console.log(bcrypt.compareSync(process.env.BCRYPT_PASSWORD, process.env.BCRYPT_HASH) ? 'match' : 'no match');
|
|
2275
|
+
"
|
|
2276
|
+
;;
|
|
1627
2277
|
convert|c)
|
|
1628
2278
|
detect_project
|
|
1629
2279
|
[[ ${#DETECTED_TYPES[@]} -eq 0 ]] && { err "No schema found"; exit 1; }
|
|
@@ -1652,11 +2302,17 @@ run_subcommand() {
|
|
|
1652
2302
|
help|-h|--help)
|
|
1653
2303
|
cat <<EOF
|
|
1654
2304
|
Usage :
|
|
1655
|
-
$CLI_NAME
|
|
1656
|
-
$CLI_NAME convert
|
|
1657
|
-
$CLI_NAME detect
|
|
1658
|
-
$CLI_NAME health
|
|
1659
|
-
$CLI_NAME
|
|
2305
|
+
$CLI_NAME Interactive menu
|
|
2306
|
+
$CLI_NAME convert Run conversion (auto-detect schema type)
|
|
2307
|
+
$CLI_NAME detect Print detected schemas
|
|
2308
|
+
$CLI_NAME health Run health checks
|
|
2309
|
+
$CLI_NAME hash <password> [cost] Hash a password with bcrypt (cost default 10)
|
|
2310
|
+
$CLI_NAME verify <password> <hash> Check if a plain password matches a bcrypt hash
|
|
2311
|
+
$CLI_NAME version Print version
|
|
2312
|
+
|
|
2313
|
+
Examples:
|
|
2314
|
+
$CLI_NAME hash 'Admin@123456' → \$2b\$10\$N9qo8uLOickgx2ZMRZoMyeIjZA...
|
|
2315
|
+
$CLI_NAME verify 'Admin@123456' '\$2b\$10\$N9qo...'
|
|
1660
2316
|
EOF
|
|
1661
2317
|
;;
|
|
1662
2318
|
*)
|
|
@@ -1677,6 +2333,8 @@ EOF
|
|
|
1677
2333
|
|
|
1678
2334
|
# Non-interactive mode if args provided
|
|
1679
2335
|
if [[ $# -gt 0 ]]; then
|
|
2336
|
+
detect_project # populates PKG_MANAGER, PROJECT_ROOT, etc.
|
|
2337
|
+
load_env # optional config for subcommands that need it
|
|
1680
2338
|
run_subcommand "$@"
|
|
1681
2339
|
exit 0
|
|
1682
2340
|
fi
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mostajs/orm-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Universal CLI to integrate @mostajs/orm into any project — auto-detects Prisma, OpenAPI, JSON Schema. Interactive menu + subcommands. 13 databases.",
|
|
5
5
|
"author": "Dr Hamid MADANI <drmdh@msn.com>",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@mostajs/orm": "^1.9.2",
|
|
46
|
+
"bcryptjs": "^2.4.3",
|
|
46
47
|
"better-sqlite3": "^12.9.0"
|
|
47
48
|
}
|
|
48
49
|
}
|