@mostajs/orm-cli 0.2.2 → 0.3.0
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 +623 -5
- package/package.json +1 -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 ;;
|
|
@@ -594,7 +596,8 @@ prompt_dialect() {
|
|
|
594
596
|
sqlite) suggest="./data.sqlite" ;;
|
|
595
597
|
postgres) suggest="postgres://user:pw@localhost:5432/app" ;;
|
|
596
598
|
mysql|mariadb) suggest="mysql://user:pw@localhost:3306/app" ;;
|
|
597
|
-
|
|
599
|
+
# MongoDB : include ?authSource=admin (common pitfall without it)
|
|
600
|
+
mongodb) suggest="mongodb://devuser:devpass26@localhost:27017/app?authSource=admin" ;;
|
|
598
601
|
mssql) suggest="mssql://user:pw@localhost:1433/app" ;;
|
|
599
602
|
oracle) suggest="oracle://user:pw@localhost:1521/ORCLPDB" ;;
|
|
600
603
|
db2) suggest="db2://user:pw@localhost:50000/app" ;;
|
|
@@ -772,6 +775,46 @@ function stripScheme(uri) {
|
|
|
772
775
|
return uri;
|
|
773
776
|
}
|
|
774
777
|
|
|
778
|
+
// Provide dialect-specific hints for common auth / connection errors.
|
|
779
|
+
function hintFor(dialect, rawUri, err) {
|
|
780
|
+
const msg = (err && err.message) ? err.message : '';
|
|
781
|
+
const code = err && (err.code ?? err.codeName);
|
|
782
|
+
|
|
783
|
+
// MongoDB code 18 : Authentication failed → missing ?authSource=admin
|
|
784
|
+
if (dialect === 'mongodb' && (code === 18 || /AuthenticationFailed|18/.test(msg))) {
|
|
785
|
+
if (!/authSource=/.test(rawUri)) {
|
|
786
|
+
return 'MongoDB users are usually declared in the admin DB. Add ?authSource=admin to the URI :\n'
|
|
787
|
+
+ ' ' + (rawUri.includes('?') ? rawUri + '&authSource=admin' : rawUri + '?authSource=admin');
|
|
788
|
+
}
|
|
789
|
+
return 'Verify credentials (username / password) in the URI.';
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// PostgreSQL common errors
|
|
793
|
+
if (dialect === 'postgres' || dialect === 'cockroachdb') {
|
|
794
|
+
if (/password authentication failed/i.test(msg)) return 'Wrong PG password. Check the URI or run: psql "' + rawUri + '" -c "SELECT 1"';
|
|
795
|
+
if (/ECONNREFUSED|connect ECONNREFUSED/i.test(msg)) return 'PG server not running on that host/port. Try: pg_isready -h HOST -p PORT';
|
|
796
|
+
if (/no pg_hba.conf entry/i.test(msg)) return 'Server rejects the connection (pg_hba.conf). Add your IP or use SSL.';
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// MySQL common errors
|
|
800
|
+
if (dialect === 'mysql' || dialect === 'mariadb') {
|
|
801
|
+
if (/Access denied for user/i.test(msg)) return 'Wrong MySQL password. Try: mysql -u USER -p -h HOST';
|
|
802
|
+
if (/ECONNREFUSED/i.test(msg)) return 'MySQL not running. Try: systemctl status mysql';
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// SQLite common errors
|
|
806
|
+
if (dialect === 'sqlite') {
|
|
807
|
+
if (/SQLITE_CANTOPEN/i.test(msg)) return 'Cannot open SQLite file. Check the path exists and is writable.';
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Generic network errors
|
|
811
|
+
if (/ECONNREFUSED/i.test(msg)) return 'Service is not reachable at that host/port.';
|
|
812
|
+
if (/ENOTFOUND|getaddrinfo/i.test(msg)) return 'DNS resolution failed. Check the hostname.';
|
|
813
|
+
if (/ETIMEDOUT/i.test(msg)) return 'Connection timed out. Check firewall / VPN / host.';
|
|
814
|
+
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
|
|
775
818
|
let ok = 0, fail = 0;
|
|
776
819
|
for (const [dialect, rawUri] of pairs) {
|
|
777
820
|
const uri = dialect === 'sqlite' ? stripScheme(rawUri) : rawUri;
|
|
@@ -790,6 +833,9 @@ for (const [dialect, rawUri] of pairs) {
|
|
|
790
833
|
} catch (e) {
|
|
791
834
|
console.error(' \u2717 ' + (e.message ?? e));
|
|
792
835
|
if (e.code) console.error(' code : ' + e.code);
|
|
836
|
+
if (e.codeName) console.error(' codeName : ' + e.codeName);
|
|
837
|
+
const hint = hintFor(dialect, rawUri, e);
|
|
838
|
+
if (hint) console.error(' \u2192 ' + hint);
|
|
793
839
|
fail++;
|
|
794
840
|
}
|
|
795
841
|
}
|
|
@@ -800,7 +846,66 @@ EOF
|
|
|
800
846
|
|
|
801
847
|
cd "$PROJECT_ROOT"
|
|
802
848
|
node "$CONFIG_DIR/test-connections.mjs" "${args[@]}" 2>&1 | tee "$LOG_DIR/test-connections.log"
|
|
849
|
+
local test_rc=${PIPESTATUS[0]}
|
|
803
850
|
echo
|
|
851
|
+
|
|
852
|
+
# Offer to auto-fix common MongoDB auth issue (missing ?authSource=admin)
|
|
853
|
+
if [[ $test_rc -ne 0 ]] && grep -qE "authSource=admin|AuthenticationFailed|code : 18" "$LOG_DIR/test-connections.log"; then
|
|
854
|
+
echo
|
|
855
|
+
warn "Detected MongoDB authentication failure that is usually fixed by adding"
|
|
856
|
+
warn " ?authSource=admin to the URI."
|
|
857
|
+
if confirm "Append '?authSource=admin' to your MongoDB URI now?"; then
|
|
858
|
+
# Fix the primary SGBD_URI if it's mongodb
|
|
859
|
+
if [[ "${DB_DIALECT:-}" == "mongodb" ]] && [[ -n "${SGBD_URI:-}" ]] && [[ ! "$SGBD_URI" =~ authSource= ]]; then
|
|
860
|
+
local new_uri
|
|
861
|
+
if [[ "$SGBD_URI" =~ \? ]]; then
|
|
862
|
+
new_uri="${SGBD_URI}&authSource=admin"
|
|
863
|
+
else
|
|
864
|
+
new_uri="${SGBD_URI}?authSource=admin"
|
|
865
|
+
fi
|
|
866
|
+
save_var SGBD_URI "$new_uri"
|
|
867
|
+
ok "Updated SGBD_URI : $new_uri"
|
|
868
|
+
fi
|
|
869
|
+
# Fix any MongoDB extra binding missing authSource
|
|
870
|
+
if [[ -n "${EXTRA_BINDINGS:-}" ]]; then
|
|
871
|
+
local new_bindings=""
|
|
872
|
+
local IFS=';'
|
|
873
|
+
for b in $EXTRA_BINDINGS; do
|
|
874
|
+
local name="${b%%:*}"
|
|
875
|
+
local rest="${b#*:}"
|
|
876
|
+
local dialect="${rest%%:*}"
|
|
877
|
+
local uri="${rest#*:}"
|
|
878
|
+
if [[ "$dialect" == "mongodb" ]] && [[ ! "$uri" =~ authSource= ]]; then
|
|
879
|
+
if [[ "$uri" =~ \? ]]; then
|
|
880
|
+
uri="${uri}&authSource=admin"
|
|
881
|
+
else
|
|
882
|
+
uri="${uri}?authSource=admin"
|
|
883
|
+
fi
|
|
884
|
+
fi
|
|
885
|
+
new_bindings+="${new_bindings:+;}${name}:${dialect}:${uri}"
|
|
886
|
+
done
|
|
887
|
+
IFS=$' \t\n'
|
|
888
|
+
save_var EXTRA_BINDINGS "$new_bindings"
|
|
889
|
+
ok "Updated EXTRA_BINDINGS"
|
|
890
|
+
fi
|
|
891
|
+
echo
|
|
892
|
+
info "Re-running the test with the fixed URI..."
|
|
893
|
+
# Rebuild args with fresh env
|
|
894
|
+
load_env
|
|
895
|
+
args=()
|
|
896
|
+
[[ -n "${DB_DIALECT:-}" && -n "${SGBD_URI:-}" ]] && args+=("${DB_DIALECT}|${SGBD_URI}")
|
|
897
|
+
if [[ -n "${EXTRA_BINDINGS:-}" ]]; then
|
|
898
|
+
local IFS=';'
|
|
899
|
+
for b in $EXTRA_BINDINGS; do
|
|
900
|
+
local rest="${b#*:}"
|
|
901
|
+
args+=("${rest%%:*}|${rest#*:}")
|
|
902
|
+
done
|
|
903
|
+
IFS=$' \t\n'
|
|
904
|
+
fi
|
|
905
|
+
node "$CONFIG_DIR/test-connections.mjs" "${args[@]}" 2>&1 | tee "$LOG_DIR/test-connections.log"
|
|
906
|
+
fi
|
|
907
|
+
fi
|
|
908
|
+
|
|
804
909
|
pause
|
|
805
910
|
}
|
|
806
911
|
|
|
@@ -908,6 +1013,21 @@ const schemaStrategy = process.env.DB_SCHEMA_STRATEGY ?? 'update';
|
|
|
908
1013
|
const poolSize = parseInt(process.env.DB_POOL_SIZE ?? '20', 10);
|
|
909
1014
|
const showSql = process.env.DB_SHOW_SQL === 'true';
|
|
910
1015
|
|
|
1016
|
+
function hintFor(dialect, rawUri, err) {
|
|
1017
|
+
const msg = (err && err.message) ? err.message : '';
|
|
1018
|
+
const code = err && (err.code ?? err.codeName);
|
|
1019
|
+
if (dialect === 'mongodb' && (code === 18 || /AuthenticationFailed|18/.test(msg))) {
|
|
1020
|
+
if (!/authSource=/.test(rawUri)) {
|
|
1021
|
+
return 'Missing ?authSource=admin on MongoDB URI. Try :\n '
|
|
1022
|
+
+ (rawUri.includes('?') ? rawUri + '&authSource=admin' : rawUri + '?authSource=admin');
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
if (/ECONNREFUSED/i.test(msg)) return 'Service not running at that host/port';
|
|
1026
|
+
if (/ENOTFOUND/i.test(msg)) return 'DNS resolution failed';
|
|
1027
|
+
if (/ETIMEDOUT/i.test(msg)) return 'Connection timed out';
|
|
1028
|
+
return null;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
911
1031
|
for (const [dialect, rawUri] of pairs) {
|
|
912
1032
|
const uri = dialect === 'sqlite' ? stripScheme(rawUri) : rawUri;
|
|
913
1033
|
process.stdout.write('→ ' + dialect.padEnd(12) + ' : ' + rawUri + '\n');
|
|
@@ -920,6 +1040,8 @@ for (const [dialect, rawUri] of pairs) {
|
|
|
920
1040
|
} catch (e) {
|
|
921
1041
|
console.error(' ✗ ' + dialect + ' failed : ' + (e.message ?? e));
|
|
922
1042
|
if (e.code) console.error(' code : ' + e.code);
|
|
1043
|
+
const hint = hintFor(dialect, rawUri, e);
|
|
1044
|
+
if (hint) console.error(' → ' + hint);
|
|
923
1045
|
fail++;
|
|
924
1046
|
}
|
|
925
1047
|
}
|
|
@@ -1245,12 +1367,23 @@ show_urls() {
|
|
|
1245
1367
|
load_env
|
|
1246
1368
|
local ip
|
|
1247
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
|
+
|
|
1248
1379
|
echo
|
|
1249
1380
|
echo -e "${BOLD}Access URLs${RESET}"
|
|
1250
|
-
echo -e " Dev server (local) : ${CYAN}http://localhost:${
|
|
1251
|
-
echo -e " Dev server (mobile) : ${CYAN}http://${ip}:${
|
|
1252
|
-
echo -e " mosta-net
|
|
1253
|
-
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}"
|
|
1254
1387
|
}
|
|
1255
1388
|
|
|
1256
1389
|
# ============================================================
|
|
@@ -1344,6 +1477,491 @@ action_healthcheck() {
|
|
|
1344
1477
|
pause
|
|
1345
1478
|
}
|
|
1346
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
|
|
1512
|
+
echo -e " ${CYAN}b${RESET}) Back"
|
|
1513
|
+
echo
|
|
1514
|
+
local choice
|
|
1515
|
+
choice=$(ask "Choice" "1")
|
|
1516
|
+
case "$choice" in
|
|
1517
|
+
1) action_seed_upload ;;
|
|
1518
|
+
2) action_seed_generate_templates ;;
|
|
1519
|
+
3) action_seed_apply "validate" ;;
|
|
1520
|
+
4) action_seed_apply "apply" ;;
|
|
1521
|
+
5) action_seed_apply "upsert" ;;
|
|
1522
|
+
6) action_seed_apply "truncate-apply" ;;
|
|
1523
|
+
7) action_seed_dump ;;
|
|
1524
|
+
8) action_seed_clear ;;
|
|
1525
|
+
9) action_seed_show ;;
|
|
1526
|
+
b|B) return ;;
|
|
1527
|
+
*) warn "Unknown"; pause ;;
|
|
1528
|
+
esac
|
|
1529
|
+
menu_seeding
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
# ------------------------------------------------------------
|
|
1533
|
+
# seed : upload / import
|
|
1534
|
+
# ------------------------------------------------------------
|
|
1535
|
+
|
|
1536
|
+
action_seed_upload() {
|
|
1537
|
+
header
|
|
1538
|
+
echo -e "${BOLD}${MAGENTA}▶ Upload seed file(s)${RESET}"
|
|
1539
|
+
echo
|
|
1540
|
+
local seed_dir="$CONFIG_DIR/seeds"
|
|
1541
|
+
mkdir -p "$seed_dir"
|
|
1542
|
+
|
|
1543
|
+
echo "Supported forms :"
|
|
1544
|
+
dim " (a) Single file with all collections : seeds.json → { users: [...], posts: [...] }"
|
|
1545
|
+
dim " (b) One file per collection (preferred): seeds/users.json = [...rows...]"
|
|
1546
|
+
dim " (c) CSV (header row = field names) : seeds/users.csv"
|
|
1547
|
+
echo
|
|
1548
|
+
local src
|
|
1549
|
+
src=$(ask "Path to seed file or directory (tilde OK)")
|
|
1550
|
+
[[ -z "$src" ]] && return
|
|
1551
|
+
src="${src/#\~/$HOME}"
|
|
1552
|
+
[[ ! -e "$src" ]] && { err "Path does not exist : $src"; pause; return; }
|
|
1553
|
+
|
|
1554
|
+
if [[ -d "$src" ]]; then
|
|
1555
|
+
local n=0
|
|
1556
|
+
for f in "$src"/*.{json,csv}; do
|
|
1557
|
+
[[ -f "$f" ]] || continue
|
|
1558
|
+
cp "$f" "$seed_dir/" && n=$((n+1))
|
|
1559
|
+
done
|
|
1560
|
+
ok "Imported $n file(s) from $src → $seed_dir"
|
|
1561
|
+
elif [[ "$src" =~ \.json$ ]]; then
|
|
1562
|
+
# Case (a) or (b) — detect
|
|
1563
|
+
local is_map
|
|
1564
|
+
is_map=$(node -e "
|
|
1565
|
+
const d = JSON.parse(require('fs').readFileSync('$src','utf8'));
|
|
1566
|
+
console.log(!Array.isArray(d) && typeof d === 'object' ? 'yes' : 'no');
|
|
1567
|
+
" 2>/dev/null)
|
|
1568
|
+
if [[ "$is_map" == "yes" ]]; then
|
|
1569
|
+
# Split into per-collection files
|
|
1570
|
+
node -e "
|
|
1571
|
+
const { writeFileSync } = require('fs');
|
|
1572
|
+
const d = JSON.parse(require('fs').readFileSync('$src','utf8'));
|
|
1573
|
+
let n = 0;
|
|
1574
|
+
for (const [coll, rows] of Object.entries(d)) {
|
|
1575
|
+
if (!Array.isArray(rows)) continue;
|
|
1576
|
+
writeFileSync('$seed_dir/' + coll + '.json', JSON.stringify(rows, null, 2));
|
|
1577
|
+
console.log(' wrote ' + coll + '.json (' + rows.length + ' rows)');
|
|
1578
|
+
n++;
|
|
1579
|
+
}
|
|
1580
|
+
console.log('split ' + n + ' collections');
|
|
1581
|
+
" 2>&1 | tee -a "$LOG_DIR/seed.log"
|
|
1582
|
+
else
|
|
1583
|
+
# Single-collection array
|
|
1584
|
+
local base; base=$(basename "$src")
|
|
1585
|
+
cp "$src" "$seed_dir/$base"
|
|
1586
|
+
ok "Copied → $seed_dir/$base"
|
|
1587
|
+
fi
|
|
1588
|
+
elif [[ "$src" =~ \.csv$ ]]; then
|
|
1589
|
+
local base; base=$(basename "$src" .csv)
|
|
1590
|
+
# Convert CSV to JSON (minimal — header row + comma-separated values)
|
|
1591
|
+
node -e "
|
|
1592
|
+
const { readFileSync, writeFileSync } = require('fs');
|
|
1593
|
+
const lines = readFileSync('$src','utf8').split(/\r?\n/).filter(Boolean);
|
|
1594
|
+
if (lines.length < 2) { console.error('CSV too small'); process.exit(1); }
|
|
1595
|
+
const headers = lines[0].split(',');
|
|
1596
|
+
const rows = lines.slice(1).map(line => {
|
|
1597
|
+
const vals = line.split(',');
|
|
1598
|
+
const o = {};
|
|
1599
|
+
headers.forEach((h, i) => { o[h.trim()] = vals[i]?.trim() ?? null; });
|
|
1600
|
+
return o;
|
|
1601
|
+
});
|
|
1602
|
+
writeFileSync('$seed_dir/${base}.json', JSON.stringify(rows, null, 2));
|
|
1603
|
+
console.log('Converted ' + rows.length + ' rows → $seed_dir/${base}.json');
|
|
1604
|
+
" 2>&1 | tee -a "$LOG_DIR/seed.log"
|
|
1605
|
+
else
|
|
1606
|
+
err "Unsupported file type — use .json or .csv"
|
|
1607
|
+
fi
|
|
1608
|
+
pause
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
# ------------------------------------------------------------
|
|
1612
|
+
# seed : generate empty templates
|
|
1613
|
+
# ------------------------------------------------------------
|
|
1614
|
+
|
|
1615
|
+
action_seed_generate_templates() {
|
|
1616
|
+
header
|
|
1617
|
+
echo -e "${BOLD}${MAGENTA}▶ Generate empty seed templates${RESET}"
|
|
1618
|
+
echo
|
|
1619
|
+
|
|
1620
|
+
if [[ ! -f "$GENERATED_DIR/entities.json" ]]; then
|
|
1621
|
+
err "No entities.json — run menu 1 (Convert) first"
|
|
1622
|
+
pause; return
|
|
1623
|
+
fi
|
|
1624
|
+
|
|
1625
|
+
local seed_dir="$CONFIG_DIR/seeds"
|
|
1626
|
+
mkdir -p "$seed_dir"
|
|
1627
|
+
|
|
1628
|
+
if ! confirm "Generate a template .json per entity (will NOT overwrite existing files)?"; then
|
|
1629
|
+
return
|
|
1630
|
+
fi
|
|
1631
|
+
|
|
1632
|
+
node -e "
|
|
1633
|
+
const { readFileSync, writeFileSync, existsSync } = require('fs');
|
|
1634
|
+
const entities = JSON.parse(readFileSync('$GENERATED_DIR/entities.json','utf8'));
|
|
1635
|
+
let created = 0, skipped = 0;
|
|
1636
|
+
for (const e of entities) {
|
|
1637
|
+
const file = '$seed_dir/' + e.collection + '.json';
|
|
1638
|
+
if (existsSync(file)) { skipped++; continue; }
|
|
1639
|
+
|
|
1640
|
+
// Build a sample row from field defaults
|
|
1641
|
+
const sample = {};
|
|
1642
|
+
for (const [k, def] of Object.entries(e.fields ?? {})) {
|
|
1643
|
+
if (def.default !== undefined && typeof def.default !== 'object') sample[k] = def.default;
|
|
1644
|
+
else if (def.type === 'string' && def.enum) sample[k] = def.enum[0] ?? '';
|
|
1645
|
+
else if (def.type === 'string') sample[k] = 'example';
|
|
1646
|
+
else if (def.type === 'number') sample[k] = 0;
|
|
1647
|
+
else if (def.type === 'boolean') sample[k] = false;
|
|
1648
|
+
else if (def.type === 'date') sample[k] = new Date().toISOString();
|
|
1649
|
+
else if (def.type === 'json') sample[k] = {};
|
|
1650
|
+
else if (def.type === 'array') sample[k] = [];
|
|
1651
|
+
}
|
|
1652
|
+
writeFileSync(file, JSON.stringify([sample], null, 2));
|
|
1653
|
+
created++;
|
|
1654
|
+
}
|
|
1655
|
+
console.log('Created ' + created + ', skipped ' + skipped + ' (already existed)');
|
|
1656
|
+
" 2>&1 | tee -a "$LOG_DIR/seed.log"
|
|
1657
|
+
pause
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
# ------------------------------------------------------------
|
|
1661
|
+
# seed : validate + apply
|
|
1662
|
+
# ------------------------------------------------------------
|
|
1663
|
+
|
|
1664
|
+
action_seed_apply() {
|
|
1665
|
+
local mode="$1" # validate | apply | upsert | truncate-apply
|
|
1666
|
+
header
|
|
1667
|
+
local title="Validate seeds (dry-run)"
|
|
1668
|
+
case "$mode" in
|
|
1669
|
+
apply) title="Apply seeds (insert)" ;;
|
|
1670
|
+
upsert) title="Apply seeds (upsert by id)" ;;
|
|
1671
|
+
truncate-apply) title="DESTRUCTIVE: truncate + apply" ;;
|
|
1672
|
+
esac
|
|
1673
|
+
echo -e "${BOLD}${MAGENTA}▶ $title${RESET}"
|
|
1674
|
+
echo
|
|
1675
|
+
|
|
1676
|
+
load_env
|
|
1677
|
+
|
|
1678
|
+
if [[ ! -f "$GENERATED_DIR/entities.json" ]]; then
|
|
1679
|
+
err "No entities.json — run menu 1 (Convert) first"
|
|
1680
|
+
pause; return
|
|
1681
|
+
fi
|
|
1682
|
+
|
|
1683
|
+
local seed_dir="$CONFIG_DIR/seeds"
|
|
1684
|
+
local file_count
|
|
1685
|
+
file_count=$(ls -1 "$seed_dir"/*.json 2>/dev/null | wc -l)
|
|
1686
|
+
if [[ $file_count -eq 0 ]]; then
|
|
1687
|
+
err "No seed files in $seed_dir — use menu S → 1 or 2"
|
|
1688
|
+
pause; return
|
|
1689
|
+
fi
|
|
1690
|
+
|
|
1691
|
+
if [[ "$mode" == "truncate-apply" ]]; then
|
|
1692
|
+
warn "This will TRUNCATE all tables listed in seeds before inserting."
|
|
1693
|
+
confirm "Really wipe the tables ?" || return
|
|
1694
|
+
if ! confirm "Are you SURE ? This cannot be undone." ; then return; fi
|
|
1695
|
+
fi
|
|
1696
|
+
|
|
1697
|
+
if [[ "$mode" != "validate" ]]; then
|
|
1698
|
+
if [[ -z "${DB_DIALECT:-}" || -z "${SGBD_URI:-}" ]]; then
|
|
1699
|
+
err "No DB configured (menu 2 first)"
|
|
1700
|
+
pause; return
|
|
1701
|
+
fi
|
|
1702
|
+
# Dialect specific driver
|
|
1703
|
+
ensure_pkg "@mostajs/orm" || return
|
|
1704
|
+
ensure_dialect_driver "$DB_DIALECT" || warn "Driver for $DB_DIALECT may be missing"
|
|
1705
|
+
fi
|
|
1706
|
+
|
|
1707
|
+
local orm_path
|
|
1708
|
+
orm_path=$(resolve_pkg_path "@mostajs/orm" 2>/dev/null)
|
|
1709
|
+
if [[ "$mode" != "validate" ]] && [[ -z "$orm_path" ]]; then
|
|
1710
|
+
err "Cannot resolve @mostajs/orm — install it first"
|
|
1711
|
+
pause; return
|
|
1712
|
+
fi
|
|
1713
|
+
|
|
1714
|
+
cat > "$CONFIG_DIR/seed-runner.mjs" <<EOF
|
|
1715
|
+
import { readFileSync, readdirSync } from 'fs';
|
|
1716
|
+
import { join } from 'path';
|
|
1717
|
+
|
|
1718
|
+
const MODE = process.argv[2] ?? 'validate';
|
|
1719
|
+
const SEEDDIR = process.argv[3];
|
|
1720
|
+
const ENTPATH = process.argv[4];
|
|
1721
|
+
const DIALECT = process.env.DB_DIALECT ?? '';
|
|
1722
|
+
const URI = process.env.SGBD_URI ?? '';
|
|
1723
|
+
|
|
1724
|
+
function stripScheme(u) {
|
|
1725
|
+
if (u.startsWith('sqlite://')) return u.slice(9);
|
|
1726
|
+
if (u.startsWith('sqlite:')) return u.slice(7);
|
|
1727
|
+
return u;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
const entities = JSON.parse(readFileSync(ENTPATH, 'utf8'));
|
|
1731
|
+
const entityByCollection = Object.fromEntries(entities.map(e => [e.collection, e]));
|
|
1732
|
+
const entityByName = Object.fromEntries(entities.map(e => [e.name, e]));
|
|
1733
|
+
|
|
1734
|
+
// ---------- Load seed files ----------
|
|
1735
|
+
const seeds = {}; // collection -> rows
|
|
1736
|
+
for (const f of readdirSync(SEEDDIR).filter(x => x.endsWith('.json'))) {
|
|
1737
|
+
const coll = f.replace(/\\.json$/, '');
|
|
1738
|
+
let data;
|
|
1739
|
+
try {
|
|
1740
|
+
data = JSON.parse(readFileSync(join(SEEDDIR, f), 'utf8'));
|
|
1741
|
+
} catch (e) {
|
|
1742
|
+
console.error(' \u2717 ' + f + ' : invalid JSON (' + e.message + ')');
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1745
|
+
if (!Array.isArray(data)) {
|
|
1746
|
+
console.error(' \u2717 ' + f + ' : file is not an array of rows');
|
|
1747
|
+
continue;
|
|
1748
|
+
}
|
|
1749
|
+
seeds[coll] = data;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// ---------- Validate ----------
|
|
1753
|
+
function validateRow(row, entity) {
|
|
1754
|
+
const errors = [];
|
|
1755
|
+
const fieldNames = new Set(Object.keys(entity.fields ?? {}));
|
|
1756
|
+
const relationNames = new Set(Object.keys(entity.relations ?? {}));
|
|
1757
|
+
// Required fields
|
|
1758
|
+
for (const [k, def] of Object.entries(entity.fields ?? {})) {
|
|
1759
|
+
if (def.required && (row[k] === undefined || row[k] === null)) {
|
|
1760
|
+
errors.push('missing required field "' + k + '"');
|
|
1761
|
+
}
|
|
1762
|
+
// Enum
|
|
1763
|
+
if (def.enum && row[k] !== undefined && !def.enum.includes(row[k])) {
|
|
1764
|
+
errors.push('field "' + k + '" not in enum ' + JSON.stringify(def.enum) + ' (got: ' + JSON.stringify(row[k]) + ')');
|
|
1765
|
+
}
|
|
1766
|
+
// Type basic check
|
|
1767
|
+
if (row[k] !== undefined && row[k] !== null) {
|
|
1768
|
+
const val = row[k];
|
|
1769
|
+
const t = def.type;
|
|
1770
|
+
if (t === 'number' && typeof val !== 'number') errors.push('field "' + k + '" expected number, got ' + typeof val);
|
|
1771
|
+
if (t === 'boolean' && typeof val !== 'boolean') errors.push('field "' + k + '" expected boolean, got ' + typeof val);
|
|
1772
|
+
if (t === 'string' && typeof val !== 'string') errors.push('field "' + k + '" expected string, got ' + typeof val);
|
|
1773
|
+
if (t === 'date' && typeof val !== 'string' && !(val instanceof Date)) errors.push('field "' + k + '" expected date, got ' + typeof val);
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
// Unknown fields (warn-level)
|
|
1777
|
+
const warnings = [];
|
|
1778
|
+
for (const k of Object.keys(row)) {
|
|
1779
|
+
if (!fieldNames.has(k) && !relationNames.has(k) && k !== 'id' && k !== '_id') {
|
|
1780
|
+
warnings.push('unknown field "' + k + '" (not in schema)');
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
return { errors, warnings };
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
let totalRows = 0;
|
|
1787
|
+
let totalErrors = 0;
|
|
1788
|
+
let totalWarnings = 0;
|
|
1789
|
+
const reports = [];
|
|
1790
|
+
|
|
1791
|
+
for (const [coll, rows] of Object.entries(seeds)) {
|
|
1792
|
+
const entity = entityByCollection[coll] ?? entityByName[coll];
|
|
1793
|
+
if (!entity) {
|
|
1794
|
+
console.error(' \u2717 ' + coll + ' : no matching entity (collection or name)');
|
|
1795
|
+
continue;
|
|
1796
|
+
}
|
|
1797
|
+
let collErrs = 0, collWarns = 0;
|
|
1798
|
+
rows.forEach((row, i) => {
|
|
1799
|
+
const { errors, warnings } = validateRow(row, entity);
|
|
1800
|
+
if (errors.length) {
|
|
1801
|
+
collErrs += errors.length;
|
|
1802
|
+
for (const e of errors) console.error(' \u2717 ' + coll + '[' + i + '] : ' + e);
|
|
1803
|
+
}
|
|
1804
|
+
collWarns += warnings.length;
|
|
1805
|
+
for (const w of warnings) console.warn(' \u26A0 ' + coll + '[' + i + '] : ' + w);
|
|
1806
|
+
});
|
|
1807
|
+
const mark = collErrs === 0 ? '\u2713' : '\u2717';
|
|
1808
|
+
console.log(' ' + mark + ' ' + coll + ' : ' + rows.length + ' rows, ' + collErrs + ' errors, ' + collWarns + ' warnings');
|
|
1809
|
+
reports.push({ coll, rows: rows.length, errors: collErrs, warnings: collWarns });
|
|
1810
|
+
totalRows += rows.length;
|
|
1811
|
+
totalErrors += collErrs;
|
|
1812
|
+
totalWarnings += collWarns;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
console.log();
|
|
1816
|
+
console.log('Validation : ' + totalRows + ' rows · ' + totalErrors + ' errors · ' + totalWarnings + ' warnings');
|
|
1817
|
+
|
|
1818
|
+
if (MODE === 'validate') {
|
|
1819
|
+
process.exit(totalErrors > 0 ? 1 : 0);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
if (totalErrors > 0) {
|
|
1823
|
+
console.error('Refusing to apply : fix validation errors first (run menu S → 3).');
|
|
1824
|
+
process.exit(2);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// ---------- Apply ----------
|
|
1828
|
+
const { getDialect } = await import('$orm_path');
|
|
1829
|
+
const uri = DIALECT === 'sqlite' ? stripScheme(URI) : URI;
|
|
1830
|
+
const d = await getDialect({ dialect: DIALECT, uri, schemaStrategy: 'update' });
|
|
1831
|
+
await d.initSchema(entities);
|
|
1832
|
+
|
|
1833
|
+
let inserted = 0, failed = 0;
|
|
1834
|
+
|
|
1835
|
+
for (const [coll, rows] of Object.entries(seeds)) {
|
|
1836
|
+
const entity = entityByCollection[coll] ?? entityByName[coll];
|
|
1837
|
+
if (!entity) continue;
|
|
1838
|
+
|
|
1839
|
+
if (MODE === 'truncate-apply') {
|
|
1840
|
+
try {
|
|
1841
|
+
await d.deleteMany(entity, {});
|
|
1842
|
+
console.log(' \u2205 truncated ' + coll);
|
|
1843
|
+
} catch (e) {
|
|
1844
|
+
console.error(' \u2717 truncate ' + coll + ' : ' + (e.message ?? e));
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
for (const row of rows) {
|
|
1849
|
+
try {
|
|
1850
|
+
if (MODE === 'upsert' && (row.id || row._id)) {
|
|
1851
|
+
const id = row.id ?? row._id;
|
|
1852
|
+
const existing = await d.findById(entity, String(id)).catch(() => null);
|
|
1853
|
+
if (existing) {
|
|
1854
|
+
await d.update(entity, String(id), row);
|
|
1855
|
+
} else {
|
|
1856
|
+
await d.create(entity, row);
|
|
1857
|
+
}
|
|
1858
|
+
} else {
|
|
1859
|
+
await d.create(entity, row);
|
|
1860
|
+
}
|
|
1861
|
+
inserted++;
|
|
1862
|
+
} catch (e) {
|
|
1863
|
+
failed++;
|
|
1864
|
+
console.error(' \u2717 ' + coll + ' : ' + (e.message ?? e));
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
console.log(' \u2713 ' + coll + ' done');
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
console.log();
|
|
1871
|
+
console.log('Applied : ' + inserted + ' inserted · ' + failed + ' failed');
|
|
1872
|
+
await d.disconnect().catch(() => {});
|
|
1873
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
1874
|
+
EOF
|
|
1875
|
+
|
|
1876
|
+
cd "$PROJECT_ROOT" || return
|
|
1877
|
+
export DB_DIALECT="${DB_DIALECT:-}"
|
|
1878
|
+
export SGBD_URI="${SGBD_URI:-}"
|
|
1879
|
+
node "$CONFIG_DIR/seed-runner.mjs" "$mode" "$seed_dir" "$GENERATED_DIR/entities.json" 2>&1 | tee "$LOG_DIR/seed-${mode}.log"
|
|
1880
|
+
echo
|
|
1881
|
+
pause
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
# ------------------------------------------------------------
|
|
1885
|
+
# seed : dump current DB → seed files
|
|
1886
|
+
# ------------------------------------------------------------
|
|
1887
|
+
|
|
1888
|
+
action_seed_dump() {
|
|
1889
|
+
header
|
|
1890
|
+
echo -e "${BOLD}${MAGENTA}▶ Dump current DB rows${RESET}"
|
|
1891
|
+
echo
|
|
1892
|
+
load_env
|
|
1893
|
+
if [[ -z "${DB_DIALECT:-}" || -z "${SGBD_URI:-}" ]]; then
|
|
1894
|
+
err "No DB configured (menu 2 first)"
|
|
1895
|
+
pause; return
|
|
1896
|
+
fi
|
|
1897
|
+
if [[ ! -f "$GENERATED_DIR/entities.json" ]]; then
|
|
1898
|
+
err "No entities.json — run menu 1 (Convert) first"
|
|
1899
|
+
pause; return
|
|
1900
|
+
fi
|
|
1901
|
+
ensure_pkg "@mostajs/orm" || return
|
|
1902
|
+
local orm_path
|
|
1903
|
+
orm_path=$(resolve_pkg_path "@mostajs/orm") || return
|
|
1904
|
+
local dump_dir="$CONFIG_DIR/seeds-dump"
|
|
1905
|
+
mkdir -p "$dump_dir"
|
|
1906
|
+
|
|
1907
|
+
cat > "$CONFIG_DIR/seed-dump.mjs" <<EOF
|
|
1908
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
1909
|
+
import { getDialect } from '$orm_path';
|
|
1910
|
+
|
|
1911
|
+
const entities = JSON.parse(readFileSync('$GENERATED_DIR/entities.json','utf8'));
|
|
1912
|
+
const DIALECT = process.env.DB_DIALECT;
|
|
1913
|
+
let URI = process.env.SGBD_URI;
|
|
1914
|
+
if (DIALECT === 'sqlite') {
|
|
1915
|
+
if (URI.startsWith('sqlite://')) URI = URI.slice(9);
|
|
1916
|
+
else if (URI.startsWith('sqlite:')) URI = URI.slice(7);
|
|
1917
|
+
}
|
|
1918
|
+
const d = await getDialect({ dialect: DIALECT, uri: URI });
|
|
1919
|
+
await d.initSchema(entities);
|
|
1920
|
+
|
|
1921
|
+
for (const e of entities) {
|
|
1922
|
+
const rows = await d.find(e, {}, { limit: 10000 });
|
|
1923
|
+
writeFileSync('$dump_dir/' + e.collection + '.json', JSON.stringify(rows, null, 2));
|
|
1924
|
+
console.log(' \u2713 ' + e.collection + ' : ' + rows.length + ' rows');
|
|
1925
|
+
}
|
|
1926
|
+
await d.disconnect().catch(() => {});
|
|
1927
|
+
EOF
|
|
1928
|
+
cd "$PROJECT_ROOT"
|
|
1929
|
+
DB_DIALECT="$DB_DIALECT" SGBD_URI="$SGBD_URI" node "$CONFIG_DIR/seed-dump.mjs" 2>&1 | tee "$LOG_DIR/seed-dump.log"
|
|
1930
|
+
echo
|
|
1931
|
+
ok "Dump written to $dump_dir"
|
|
1932
|
+
pause
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
action_seed_clear() {
|
|
1936
|
+
header
|
|
1937
|
+
local seed_dir="$CONFIG_DIR/seeds"
|
|
1938
|
+
local count; count=$(ls -1 "$seed_dir"/*.json 2>/dev/null | wc -l)
|
|
1939
|
+
[[ $count -eq 0 ]] && { warn "Already empty"; pause; return; }
|
|
1940
|
+
if confirm "Delete all $count seed files in $seed_dir ?"; then
|
|
1941
|
+
rm -f "$seed_dir"/*.json
|
|
1942
|
+
ok "Cleared"
|
|
1943
|
+
fi
|
|
1944
|
+
pause
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
action_seed_show() {
|
|
1948
|
+
header
|
|
1949
|
+
local seed_dir="$CONFIG_DIR/seeds"
|
|
1950
|
+
local files=()
|
|
1951
|
+
for f in "$seed_dir"/*.json; do [[ -f "$f" ]] && files+=("$f"); done
|
|
1952
|
+
[[ ${#files[@]} -eq 0 ]] && { warn "No seed files"; pause; return; }
|
|
1953
|
+
echo "Pick a file to display :"
|
|
1954
|
+
local i=1
|
|
1955
|
+
for f in "${files[@]}"; do
|
|
1956
|
+
echo -e " ${CYAN}$i${RESET}) $(basename "$f")"
|
|
1957
|
+
i=$((i+1))
|
|
1958
|
+
done
|
|
1959
|
+
local num; num=$(ask "Number" 1)
|
|
1960
|
+
local idx=$((num-1))
|
|
1961
|
+
[[ $idx -ge 0 && $idx -lt ${#files[@]} ]] && ${PAGER:-less} "${files[$idx]}"
|
|
1962
|
+
pause
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1347
1965
|
# ============================================================
|
|
1348
1966
|
# ACTION 9 : GENERATE BOILERPLATE
|
|
1349
1967
|
# ============================================================
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mostajs/orm-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|