@ecomcx/create-nexus 0.0.0-stage → 1.24.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/README.md CHANGED
@@ -1,3 +1,14 @@
1
- # Temporary Holding Version
1
+ # @ecomcx/create-nexus
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ CLI for Ecomcx Nexus CMS themes and plugins: create, validate, pack, test and deploy.
4
+
5
+ ```bash
6
+ npm create @ecomcx/nexus theme "My theme" # new theme (code; --blocks for a block theme)
7
+ npm create @ecomcx/nexus plugin "My plugin" # new plugin (plugin.json + one C# file)
8
+ npx @ecomcx/create-nexus validate # check the package
9
+ npx @ecomcx/create-nexus pack # zip for upload / the Store
10
+ npx @ecomcx/create-nexus env up && npx @ecomcx/create-nexus dev # local CMS, reinstall on save
11
+ ```
12
+
13
+ Themes and plugins import `@ecomcx/nexus-theme-sdk` / `@ecomcx/nexus-admin-sdk`; the scaffold installs them as npm aliases
14
+ (`"@ecomcx/nexus-theme-sdk": "npm:@ecomcx/nexus-theme-sdk@^x.y.z"`). Guides: theme-guide.md and plugin-guide.md in the CMS repository.
package/bin/cms.mjs ADDED
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ // create-cms — tạo, kiểm tra, đóng gói và triển khai theme/plugin cho CMS.
3
+ // create-cms theme "Tên" [--parent default] [--blocks] [--dir x]
4
+ // create-cms plugin "Tên" [--admin] [--frontend] [--project] [--no-backend] [--tests] [--dir x]
5
+ // create-cms validate [thư-mục]
6
+ // create-cms upgrade [thư-mục]
7
+ // create-cms pack [thư-mục] [--out dist]
8
+ // create-cms deploy [thư-mục] [--url …] [--user …] [--password …] [--activate]
9
+ // create-cms dev [thư-mục] [--url …] [--user …] [--password …] (tự đóng gói + cài lại khi sửa file)
10
+ // create-cms env up | status | down [--purge]
11
+ import { CLI } from "../lib/npm.mjs";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { c, parseArgs } from "../lib/util.mjs";
15
+ import { scaffoldPlugin, scaffoldTheme } from "../lib/scaffold.mjs";
16
+ import { validate } from "../lib/validate.mjs";
17
+ import { upgrade } from "../lib/upgrade.mjs";
18
+ import { pack } from "../lib/pack.mjs";
19
+ import { check, deploy } from "../lib/deploy.mjs";
20
+ import { env, findEnv } from "../lib/env.mjs";
21
+
22
+ const HELP = `${c.bold("create-cms")} — công cụ viết theme/plugin cho CMS
23
+
24
+ ${c.cyan('theme "Tên"')} [--parent default] tạo theme (child theme với --parent; --blocks: dựng bằng khối, sửa trong Trình dựng)
25
+ ${c.cyan('plugin "Tên"')} [--admin] [--frontend] [--tests] tạo plugin: plugin.json + một file C# (CMS tự biên dịch)
26
+ --admin / --frontend: thêm giao diện React riêng · --project: dự án csproj · --no-backend
27
+ ${c.cyan("validate")} [thư-mục] kiểm tra theme.json / plugin.json
28
+ ${c.cyan("upgrade")} [thư-mục] đưa project theme/plugin lên SDK mới nhất (@ecomcx/nexus-theme-sdk, @ecomcx/nexus-admin-sdk) rồi kiểm tra lại
29
+ ${c.cyan("test")} [thư-mục] đóng gói + chạy test xUnit trong tests/ trên CMS thử (Cms.Plugin.Testing)
30
+ ${c.cyan("check")} [thư-mục] chạy thử trên CMS: manifest, biên dịch, quét bảo mật (như khi cài / nộp Store)
31
+ ${c.cyan("pack")} [thư-mục] [--out dist] đóng gói → dist/<slug>-<version>.zip
32
+ ${c.cyan("deploy")} [thư-mục] [--activate] đóng gói + cài lên CMS (--url, --user, --password)
33
+ ${c.cyan("dev")} [thư-mục] tự đóng gói + cài lại mỗi khi sửa file
34
+ ${c.cyan("env")} up [--dev] [--build DIR] | status | logs [api|builder] [-f] | down [--purge]
35
+ CMS chạy bằng Docker để thử theme/plugin (--dev: sửa là thấy, không build lại)
36
+
37
+ Mặc định CMS ở http://localhost:5100 (hoặc môi trường của \`env up\`, biến CMS_URL / CMS_USER / CMS_PASSWORD).`;
38
+
39
+ const args = parseArgs(process.argv.slice(2));
40
+ const [cmd, ...rest] = args._;
41
+
42
+ /** Thông tin đăng nhập: tham số dòng lệnh → môi trường của `env up` (.cms-env) → biến môi trường (deploy tự đọc). */
43
+ function connection() {
44
+ const saved = findEnv();
45
+ return {
46
+ url: args.url ?? (process.env.CMS_URL ? undefined : saved?.url),
47
+ user: args.user ?? (process.env.CMS_USER ? undefined : saved?.user),
48
+ password: args.password ?? (process.env.CMS_PASSWORD ? undefined : saved?.password),
49
+ activate: !!args.activate,
50
+ };
51
+ }
52
+
53
+ function report({ kind, errors, warnings }, dir) {
54
+ warnings.forEach((w) => console.log(c.yellow(" ! " + w)));
55
+ errors.forEach((e) => console.log(c.red(" ✗ " + e)));
56
+ if (errors.length) return false;
57
+ console.log(c.green(`✓ ${kind === "theme" ? "Theme" : "Plugin"} hợp lệ`) + c.dim(` (${path.relative(process.cwd(), dir) || "."})`));
58
+ return true;
59
+ }
60
+
61
+ async function packAndReport(dir) {
62
+ const r = await pack(dir, { out: args.out });
63
+ console.log(c.green(`✓ ${path.relative(process.cwd(), r.zipPath)}`) + c.dim(` (${(r.size / 1024).toFixed(0)} KB)`));
64
+ return r;
65
+ }
66
+
67
+ /** Theo dõi thay đổi (bỏ dist, node_modules, bin, obj) rồi đóng gói + cài lại; gộp các lần lưu liên tiếp. */
68
+ async function dev(dir) {
69
+ const conn = connection();
70
+ let running = false, again = false, timer;
71
+ const cycle = async () => {
72
+ if (running) { again = true; return; }
73
+ running = true;
74
+ try { await deploy(await packAndReport(dir), { ...conn, activate: true }); }
75
+ catch (e) { console.log(c.red("✗ " + e.message)); }
76
+ running = false;
77
+ if (again) { again = false; cycle(); }
78
+ };
79
+ await cycle();
80
+ // Môi trường `env up --dev` đã gắn thẳng thư mục này vào website: phần website (theme, frontend/ của plugin) tự nạp lại,
81
+ // chỉ cài lại khi đổi phần cần máy chủ (backend, admin, plugin.json, locales)
82
+ const mounted = (findEnv()?.dev && (findEnv().mounts ?? []).some((m) => path.resolve(m.dir) === path.resolve(dir)));
83
+ const live = (file) => mounted && (fs.existsSync(path.join(dir, "theme.json")) || /^frontend[\\/]/.test(file));
84
+ console.log(c.dim(`• Đang theo dõi thay đổi…${mounted ? " (website tự nạp lại, không cần build)" : ""} (Ctrl+C để dừng)`));
85
+ const ignored = /(^|[\\/])(dist|node_modules|bin|obj|\.git|\.next)([\\/]|$)/;
86
+ fs.watch(dir, { recursive: true }, (_e, file) => {
87
+ if (!file || ignored.test(file)) return;
88
+ if (live(file)) { console.log(c.dim(`• ${file} — website tự nạp lại`)); return; }
89
+ clearTimeout(timer);
90
+ timer = setTimeout(() => { console.log(c.cyan(`\n• ${file} thay đổi`)); cycle(); }, 400);
91
+ });
92
+ }
93
+
94
+ try {
95
+ const dir = path.resolve(rest[0] && !["theme", "plugin", "env"].includes(cmd) ? rest[0] : ".");
96
+ switch (cmd) {
97
+ case "theme":
98
+ case "plugin": {
99
+ const name = rest[0];
100
+ if (!name) throw new Error(`Thiếu tên: create-cms ${cmd} "Tên ${cmd}"`);
101
+ const { root } = cmd === "theme"
102
+ ? scaffoldTheme(name, { parent: args.parent, dir: args.dir, blocks: !!args.blocks })
103
+ : scaffoldPlugin(name, { backend: args["no-backend"] ? false : args.project ? "project" : "source", admin: !!args.admin, frontend: !!args.frontend, tests: !!args.tests, dir: args.dir });
104
+ console.log(c.green(`✓ Đã tạo ${cmd} ở ${path.relative(process.cwd(), root)}`));
105
+ console.log(c.dim(` cd ${path.relative(process.cwd(), root)} && npx ${CLI} dev`));
106
+ break;
107
+ }
108
+ case "validate":
109
+ if (!report(validate(dir), dir)) process.exit(1);
110
+ break;
111
+ case "upgrade":
112
+ // Project tạo từ trước: lên SDK mới nhất rồi kiểm tra lại (validate báo API cũ / khai báo cần CMS mới hơn)
113
+ await upgrade(dir);
114
+ if (fs.existsSync(path.join(dir, "theme.json")) || fs.existsSync(path.join(dir, "plugin.json"))) report(validate(dir), dir);
115
+ break;
116
+ case "pack":
117
+ await packAndReport(dir);
118
+ break;
119
+ case "deploy":
120
+ await deploy(await packAndReport(dir), connection());
121
+ break;
122
+ case "test": {
123
+ // Đóng gói → chạy dotnet test cho tests/*.csproj (Cms.Plugin.Testing cài gói lên CMS thử một lần rồi chạy test)
124
+ const testsDir = path.join(dir, "tests");
125
+ if (!fs.existsSync(testsDir) || !fs.readdirSync(testsDir).some((f) => f.endsWith(".csproj")))
126
+ throw new Error("Không thấy tests/*.csproj — tạo bằng: create-cms plugin \"Tên\" --tests (hoặc xem tài liệu Cms.Plugin.Testing)");
127
+ const pkg = await packAndReport(dir);
128
+ const conn = connection();
129
+ const env = { ...process.env, CMS_TEST_PACKAGE: path.resolve(pkg.zipPath), CMS_TEST_SLUG: pkg.manifest.slug,
130
+ ...(conn.url ? { CMS_URL: conn.url } : {}), ...(conn.user ? { CMS_USER: conn.user } : {}), ...(conn.password ? { CMS_PASSWORD: conn.password } : {}) };
131
+ const { spawnSync } = await import("node:child_process");
132
+ const r = spawnSync("dotnet", ["test", testsDir, "--nologo"], { stdio: "inherit", env });
133
+ if (r.status !== 0) process.exitCode = r.status ?? 1;
134
+ break;
135
+ }
136
+ case "check":
137
+ if (!(await check(await packAndReport(dir), connection()))) process.exitCode = 1;
138
+ break;
139
+ case "dev":
140
+ await dev(dir);
141
+ break;
142
+ case "env":
143
+ await env(rest[0], args);
144
+ break;
145
+ case undefined:
146
+ case "help":
147
+ case "--help":
148
+ console.log(HELP);
149
+ break;
150
+ default:
151
+ console.log(HELP);
152
+ process.exit(1);
153
+ }
154
+ } catch (e) {
155
+ console.error(c.red("✗ " + (e?.message ?? e)));
156
+ process.exit(1);
157
+ }
package/lib/deploy.mjs ADDED
@@ -0,0 +1,97 @@
1
+ import fs from "node:fs";
2
+ import { c } from "./util.mjs";
3
+
4
+ /** Đăng nhập CMS và upload gói zip (dùng cho `cms deploy` / `cms dev`). */
5
+ export async function deploy({ zipPath, kind, manifest }, { url, user, password, activate }) {
6
+ const base = (url ?? process.env.CMS_URL ?? "http://localhost:5100").replace(/\/$/, "");
7
+ user ??= process.env.CMS_USER ?? "admin";
8
+ password ??= process.env.CMS_PASSWORD;
9
+ if (!password) throw new Error("Thiếu mật khẩu: dùng --password hoặc biến môi trường CMS_PASSWORD");
10
+
11
+ const login = await fetch(`${base}/api/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ login: user, password }) });
12
+ if (!login.ok) throw new Error(`Đăng nhập thất bại (${login.status})`);
13
+ const { accessToken } = await login.json();
14
+ const auth = { authorization: `Bearer ${accessToken}` };
15
+
16
+ const form = new FormData();
17
+ form.append("file", new Blob([fs.readFileSync(zipPath)], { type: "application/zip" }), zipPath.split("/").pop());
18
+ const res = await fetch(`${base}/api/admin/${kind === "theme" ? "themes" : "plugins"}/upload`, { method: "POST", headers: auth, body: form });
19
+ const body = await res.json().catch(() => ({}));
20
+ if (!res.ok) {
21
+ const details = body.errors ? "\n" + Object.values(body.errors).flat().map((e) => " - " + e).join("\n") : "";
22
+ throw new Error(`${body.title ?? "Upload thất bại"} (${res.status})${details}`);
23
+ }
24
+ console.log(c.green(` ✓ ${body.message}`));
25
+ if (body.restartRequired) await waitHealthy(base);
26
+
27
+ if (body.buildId) {
28
+ process.stdout.write(c.dim(` • Đang build #${body.buildId}`));
29
+ for (let i = 0; i < 600; i++) {
30
+ await new Promise((r) => setTimeout(r, 1000));
31
+ let b;
32
+ try { b = await (await fetch(`${base}/api/admin/builds/${body.buildId}`, { headers: auth })).json(); }
33
+ catch { process.stdout.write("?"); continue; } // CMS đang khởi động lại
34
+
35
+ if (b.status === "succeeded") { console.log(c.green(" ✓ build thành công")); break; }
36
+ if (b.status === "failed") { console.log(c.red(" ✗ build lỗi\n") + (b.log ?? "").split("\n").slice(-25).join("\n")); throw new Error("Build thất bại"); }
37
+ process.stdout.write(".");
38
+ }
39
+ }
40
+
41
+ if (kind === "plugin" && activate) {
42
+ const r = await fetch(`${base}/api/admin/plugins/${manifest.slug}/activate`, { method: "POST", headers: auth });
43
+ const b = await r.json().catch(() => ({}));
44
+ if (!r.ok) throw new Error(b.title ?? "Kích hoạt thất bại");
45
+ console.log(c.green(` ✓ ${b.message}`));
46
+ if (b.restartRequired) await waitHealthy(base);
47
+ }
48
+ if (kind === "theme" && activate) {
49
+ const r = await fetch(`${base}/api/admin/themes/${manifest.slug}/activate`, { method: "POST", headers: auth });
50
+ if (!r.ok) throw new Error(((await r.json().catch(() => ({}))).title) ?? "Kích hoạt theme thất bại");
51
+ console.log(c.green(" ✓ Đã kích hoạt theme"));
52
+ }
53
+ }
54
+
55
+ async function waitHealthy(base) {
56
+ process.stdout.write(c.dim(" • Chờ CMS khởi động lại"));
57
+ await new Promise((r) => setTimeout(r, 1500));
58
+ for (let i = 0; i < 90; i++) {
59
+ try {
60
+ const r = await fetch(`${base}/api/health`);
61
+ if (r.ok && (await r.json()).status === "ok") { console.log(c.green(" ✓")); return; }
62
+ } catch { /* đang khởi động */ }
63
+ process.stdout.write(".");
64
+ await new Promise((r) => setTimeout(r, 1000));
65
+ }
66
+ throw new Error("CMS chưa khởi động lại sau 90 giây");
67
+ }
68
+
69
+ /**
70
+ * Chạy thử quy trình cài trên CMS (manifest, biên dịch mã nguồn, quét bảo mật — giống hệt khi cài và khi nộp Store) mà không cài.
71
+ * Trả true nếu gói cài được không cần xác nhận rủi ro.
72
+ */
73
+ export async function check({ zipPath, kind }, { url, user, password }) {
74
+ if (kind === "theme") throw new Error("check hiện dành cho plugin — theme: dùng validate + deploy");
75
+ const base = (url ?? process.env.CMS_URL ?? "http://localhost:5100").replace(/\/$/, "");
76
+ user ??= process.env.CMS_USER ?? "admin";
77
+ password ??= process.env.CMS_PASSWORD;
78
+ if (!password) throw new Error("Thiếu mật khẩu: dùng --password, CMS_PASSWORD hoặc `create-cms env up`");
79
+ const login = await fetch(`${base}/api/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ login: user, password }) });
80
+ if (!login.ok) throw new Error(`Đăng nhập thất bại (${login.status})`);
81
+ const { accessToken } = await login.json();
82
+ const form = new FormData();
83
+ form.append("file", new Blob([fs.readFileSync(zipPath)], { type: "application/zip" }), zipPath.split("/").pop());
84
+ const res = await fetch(`${base}/api/admin/plugins/check`, { method: "POST", headers: { authorization: `Bearer ${accessToken}` }, body: form });
85
+ const r = await res.json().catch(() => ({}));
86
+ if (!res.ok) throw new Error(r.title ?? `CMS trả ${res.status}`);
87
+ for (const e of r.errors ?? []) console.log(c.red(` ✗ ${e}`));
88
+ for (const e of r.compileErrors ?? []) console.log(c.red(` ✗ ${e}`));
89
+ for (const w of r.compileWarnings ?? []) console.log(c.yellow(` ! ${w}`));
90
+ if (r.scan) {
91
+ const tone = { safe: c.green, warning: c.yellow, dangerous: c.red, blocked: c.red }[r.scan.verdict] ?? ((s) => s);
92
+ console.log(` • Quét bảo mật: ${tone(r.scan.verdict)} (${r.scan.filesScanned} file)`);
93
+ for (const f of r.scan.findings ?? []) console.log(` ${f.severity === "critical" || f.severity === "high" ? c.red("✗") : c.yellow("!")} [${f.severity}] ${f.message}${f.file ? c.dim(` — ${f.file}`) : ""}`);
94
+ }
95
+ console.log(r.ok ? c.green(`✓ ${r.slug} ${r.version}: cài được, không cần xác nhận rủi ro`) : c.red(`✗ ${r.slug ?? ""}: chưa đạt — sửa các lỗi trên`));
96
+ return r.ok;
97
+ }
@@ -0,0 +1,58 @@
1
+ # Một tên miền cho cả website, trang quản trị (/admin) và API.
2
+ # SITE_ADDRESS là tên miền (vd example.com) → Caddy tự xin và gia hạn chứng chỉ HTTPS (Let's Encrypt).
3
+ # Mặc định ":80" → HTTP thường: chạy thử trên máy, hoặc đứng sau một proxy khác lo HTTPS (Coolify, Cloudflare…).
4
+ #
5
+ # TRUSTED_PROXIES: dải IP của proxy đứng TRƯỚC Caddy (vd "private_ranges" trên Coolify, hoặc dải IP của Cloudflare).
6
+ # Chỉ khi request đến từ các dải này Caddy mới tin X-Forwarded-For / X-Forwarded-Proto mà proxy đó gửi.
7
+ # Mặc định chỉ tin chính máy (127.0.0.1) = Caddy đứng trực tiếp ngoài Internet.
8
+ {
9
+ servers {
10
+ trusted_proxies static {$TRUSTED_PROXIES:127.0.0.1/32}
11
+ # Đọc X-Forwarded-For từ phải sang trái, bỏ qua các proxy tin cậy → không giả được IP bằng cách tự gửi header
12
+ trusted_proxies_strict
13
+ client_ip_headers X-Forwarded-For
14
+ }
15
+ }
16
+
17
+ {$SITE_ADDRESS::80} {
18
+ encode zstd gzip
19
+
20
+ # Kênh nội bộ giữa Builder và API (callback build, tải mã theme/plugin) — không mở ra Internet.
21
+ # Phải là một khối handle đứng trước: Caddy xếp "handle" trước "respond", nên "respond" đứng riêng sẽ không có tác dụng.
22
+ @internal path /api/internal /api/internal/*
23
+ handle @internal {
24
+ respond 404
25
+ }
26
+
27
+ # Route API của website Next.js (bình luận, proxy plugin, xem trước, xoá cache)
28
+ @frontend_api path /api/cms/* /api/revalidate /api/preview /api/preview/*
29
+ handle @frontend_api {
30
+ reverse_proxy builder:3000 {
31
+ header_up X-Forwarded-For {client_ip}
32
+ }
33
+ }
34
+
35
+ # CMS API, trang quản trị, file media, file tĩnh của extension, SignalR
36
+ @cms path /api/* /admin /admin/* /uploads/* /extensions/* /hubs/* /health
37
+ handle @cms {
38
+ reverse_proxy api:8080 {
39
+ # IP thật của người dùng (một giá trị) — API/Builder dùng cho rate limit, chống dò mật khẩu, nhật ký
40
+ header_up X-Forwarded-For {client_ip}
41
+ # Bật/tắt plugin backend hoặc cập nhật → API khởi động lại vài giây.
42
+ # Caddy giữ request và thử kết nối lại thay vì trả lỗi 502: người dùng chỉ thấy chậm một chút.
43
+ lb_try_duration 45s
44
+ lb_try_interval 250ms
45
+ }
46
+ }
47
+
48
+ handle {
49
+ reverse_proxy builder:3000 {
50
+ header_up X-Forwarded-For {client_ip}
51
+ }
52
+ }
53
+
54
+ header {
55
+ Strict-Transport-Security "max-age=31536000"
56
+ -Server
57
+ }
58
+ }
@@ -0,0 +1,31 @@
1
+ #!/bin/sh
2
+ # Chạy một lần mỗi khi khởi động stack: sinh secret còn thiếu vào volume /secrets (không ghi đè secret đã có).
3
+ # Mỗi file là một khoá cấu hình của CMS (đọc bằng AddKeyPerFile), vd Jwt__Secret.
4
+ # Builder chỉ nhận 3 secret nó cần (volume riêng /builder-secrets) — mã theme/plugin chạy trong Builder không đọc được
5
+ # khoá ký phiên đăng nhập hay mật khẩu database.
6
+ set -eu
7
+ umask 077
8
+ cd /secrets
9
+ gen() { [ -s "$1" ] || head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c "$2" > "$1"; }
10
+ gen db_password 32
11
+ gen redis_password 32
12
+ gen Jwt__Secret 64
13
+ gen Frontend__RevalidateSecret 40
14
+ gen Extensions__BuilderSecret 40
15
+ gen Security__FrontendApiKey 48
16
+ # Chuỗi kết nối DB, Redis dựng từ mật khẩu đã sinh
17
+ printf 'Host=postgres;Database=cms;Username=cms;Password=%s;GSS Encryption Mode=Disable' "$(cat db_password)" > ConnectionStrings__Default
18
+ printf 'redis:6379,password=%s' "$(cat redis_password)" > ConnectionStrings__Redis
19
+
20
+ # API chạy bằng user "app" (uid 1654); Postgres (uid 70) cần đọc mật khẩu của nó; Redis đọc mật khẩu lúc khởi động (root)
21
+ chown 1654:1654 /secrets/*
22
+ chmod 600 /secrets/*
23
+ chmod 644 db_password
24
+
25
+ # Secret của Builder: chỉ tiến trình Builder (root) đọc được — mã theme/plugin chạy bằng user "node" thì không
26
+ if [ -d /builder-secrets ]; then
27
+ for f in Extensions__BuilderSecret Frontend__RevalidateSecret Security__FrontendApiKey; do cp "$f" "/builder-secrets/$f"; done
28
+ chown 0:0 /builder-secrets/*
29
+ chmod 600 /builder-secrets/*
30
+ fi
31
+ echo "Secret đã sẵn sàng."
package/lib/env.mjs ADDED
@@ -0,0 +1,235 @@
1
+ // Môi trường CMS chạy bằng Docker cho người làm theme/plugin — không cần mã nguồn CMS.
2
+ // create-cms env up dựng CMS (Postgres, Redis, API, website, Caddy) + cài đặt sẵn, in tài khoản quản trị
3
+ // create-cms env status trạng thái, địa chỉ, tài khoản
4
+ // create-cms env down dừng (giữ dữ liệu) · --purge: xoá luôn dữ liệu
5
+ // create-cms env logs [api|builder|…] [-f] xem log
6
+ // --dev website chạy `next dev`; theme/plugin trong thư mục hiện tại (và thư mục con) được gắn thẳng vào → sửa là thấy
7
+ // --build DIR build image từ mã nguồn CMS ở DIR (khi chưa kéo được image dựng sẵn)
8
+ // Tài khoản được lưu ở .cms-env/credentials.json → `create-cms deploy/dev` tự dùng, không cần --password.
9
+ import { CLI } from "./npm.mjs";
10
+ import crypto from "node:crypto";
11
+ import fs from "node:fs";
12
+ import net from "node:net";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { c, readJson, run, write } from "./util.mjs";
16
+
17
+ const cliDir = path.dirname(fileURLToPath(import.meta.url));
18
+ /** Image dựng sẵn của CMS (đổi bằng --api-image/--builder-image hoặc biến CMS_API_IMAGE/CMS_BUILDER_IMAGE). */
19
+ const DEFAULT_IMAGES = { api: "ghcr.io/ecomcx-jsc/ecomcx-nexus-api:latest", builder: "ghcr.io/ecomcx-jsc/ecomcx-nexus-builder:latest" };
20
+
21
+ /** Theme/plugin đang viết ở thư mục hiện tại và thư mục con (+ --mount) → gắn vào Builder khi chạy --dev. */
22
+ export function devMounts(args) {
23
+ const roots = [process.cwd(), ...fs.readdirSync(process.cwd(), { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules").map((d) => path.resolve(d.name))];
24
+ for (const m of [args.mount ?? []].flat()) roots.push(path.resolve(m));
25
+ const mounts = [];
26
+ for (const dir of [...new Set(roots)]) {
27
+ const theme = readJson(path.join(dir, "theme.json"));
28
+ const plugin = readJson(path.join(dir, "plugin.json"));
29
+ if (theme?.slug) mounts.push({ kind: "theme", slug: theme.slug, dir, target: `/app/frontend/themes/${theme.slug}` });
30
+ else if (plugin?.slug && plugin.frontend?.entry) mounts.push({ kind: "plugin", slug: plugin.slug, dir, target: `/dev-plugins/${plugin.slug}` });
31
+ }
32
+ return mounts;
33
+ }
34
+
35
+ /** File cấu hình đi kèm gói (lib/env/) hoặc lấy từ thư mục deploy/ khi chạy trong repo CMS. */
36
+ function asset(name) {
37
+ for (const p of [path.join(cliDir, "env", name), path.resolve(cliDir, "../../../deploy", name)]) if (fs.existsSync(p)) return fs.readFileSync(p, "utf8");
38
+ throw new Error(`Thiếu file ${name} của create-cms`);
39
+ }
40
+
41
+ /** Tìm .cms-env gần nhất (thư mục hiện tại hoặc thư mục cha) — dùng cho deploy/dev. */
42
+ export function findEnv(start = process.cwd()) {
43
+ for (let dir = path.resolve(start); ; dir = path.dirname(dir)) {
44
+ const cred = readJson(path.join(dir, ".cms-env", "credentials.json"));
45
+ if (cred) return cred;
46
+ if (path.dirname(dir) === dir) return null;
47
+ }
48
+ }
49
+
50
+ const compose = (o) => `
51
+ # Sinh bởi create-cms env — môi trường thử cho theme/plugin. Không dùng cho website thật.
52
+ name: ${o.project}
53
+ services:
54
+ init:
55
+ image: alpine:3.20
56
+ command: ["/bin/sh", "/init-secrets.sh"]
57
+ volumes: [secrets:/secrets, ./init-secrets.sh:/init-secrets.sh:ro]
58
+ restart: "no"
59
+ postgres:
60
+ image: postgres:17-alpine
61
+ restart: unless-stopped
62
+ depends_on: { init: { condition: service_completed_successfully } }
63
+ environment: { POSTGRES_USER: cms, POSTGRES_DB: cms, POSTGRES_PASSWORD_FILE: /secrets/db_password }
64
+ volumes: [pgdata:/var/lib/postgresql/data, secrets:/secrets:ro]
65
+ healthcheck: { test: ["CMD-SHELL", "pg_isready -U cms"], interval: 3s, retries: 40 }
66
+ redis:
67
+ image: redis:7-alpine
68
+ restart: unless-stopped
69
+ healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 3s, retries: 20 }
70
+ api:
71
+ ${o.buildDir ? ` build: { context: ${JSON.stringify(o.buildDir)}, dockerfile: backend/Dockerfile }\n image: cms-env-api:local` : ` image: ${o.apiImage}`}
72
+ restart: unless-stopped
73
+ depends_on: { postgres: { condition: service_healthy }, redis: { condition: service_healthy } }
74
+ environment:
75
+ CMS_SECRETS_DIR: /secrets
76
+ ConnectionStrings__Redis: redis:6379
77
+ Storage__PublicMediaUrl: ${o.url}/uploads
78
+ Frontend__PublicUrl: ${o.url}
79
+ Frontend__RevalidateUrl: http://builder:3000/api/revalidate
80
+ Extensions__BuilderUrl: http://builder:4100
81
+ Extensions__CallbackBaseUrl: http://api:8080
82
+ Setup__AdminUrl: ${o.url}/admin
83
+ Security__TrustedProxyHosts__0: caddy
84
+ Cors__Origins__0: ${o.url}
85
+ Security__FrameAncestors__0: ${o.url}
86
+ volumes: [storage:/data, secrets:/secrets:ro]
87
+ builder:
88
+ ${o.buildDir ? ` build: { context: ${JSON.stringify(o.buildDir)}, dockerfile: builder/Dockerfile }\n image: cms-env-builder:local` : ` image: ${o.builderImage}`}
89
+ restart: unless-stopped
90
+ depends_on: [api]
91
+ environment: { CMS_SECRETS_DIR: /secrets, CMS_API_URL: http://api:8080, ADMIN_ORIGINS: ${o.url}, WEB_WORKERS: "1", TRUSTED_PROXY_HOSTS: caddy${o.dev ? ", BUILDER_MODE: dev, BUILDER_DEV_SERVER: \"1\"" : ""} }
92
+ volumes: [storage:/data, secrets:/secrets:ro${(o.mounts ?? []).map((m) => `, ${JSON.stringify(`${m.dir}:${m.target}:ro`)}`).join("")}]
93
+ caddy:
94
+ image: caddy:2-alpine
95
+ restart: unless-stopped
96
+ depends_on: [api, builder]
97
+ environment: { SITE_ADDRESS: ":80" }
98
+ ports: ["${o.port}:80"]
99
+ volumes: [./Caddyfile:/etc/caddy/Caddyfile:ro]
100
+ volumes: { pgdata: {}, storage: {}, secrets: {} }
101
+ `;
102
+
103
+ /** Cổng còn trống trên máy (Docker sẽ báo "port is already allocated" ở bước cuối, sau khi đã tạo container). */
104
+ function portFree(port) {
105
+ return new Promise((resolve) => {
106
+ const srv = net.createServer().once("error", () => resolve(false)).once("listening", () => srv.close(() => resolve(true)));
107
+ srv.listen(port, "0.0.0.0");
108
+ });
109
+ }
110
+
111
+ async function waitFor(url, seconds) {
112
+ const until = Date.now() + seconds * 1000;
113
+ while (Date.now() < until) {
114
+ try { const r = await fetch(url, { signal: AbortSignal.timeout(3000) }); if (r.ok) return true; } catch { /* chưa sẵn sàng */ }
115
+ await new Promise((r) => setTimeout(r, 1500));
116
+ }
117
+ return false;
118
+ }
119
+
120
+ export async function env(action, args) {
121
+ const dir = path.resolve(args.dir ?? ".cms-env");
122
+ const credFile = path.join(dir, "credentials.json");
123
+ const saved = readJson(credFile);
124
+ const project = saved?.project ?? `cms-dev-${crypto.createHash("sha1").update(dir).digest("hex").slice(0, 6)}`;
125
+ const dc = (...a) => run("docker", ["compose", "-f", path.join(dir, "docker-compose.yml"), ...a], { quiet: true });
126
+
127
+ if (action === "up") {
128
+ const port = Number(args.port ?? saved?.port ?? 8080);
129
+ const url = `http://localhost:${port}`;
130
+ const apiImage = args["api-image"] ?? process.env.CMS_API_IMAGE ?? saved?.apiImage ?? DEFAULT_IMAGES.api;
131
+ const builderImage = args["builder-image"] ?? process.env.CMS_BUILDER_IMAGE ?? saved?.builderImage ?? DEFAULT_IMAGES.builder;
132
+ const dev = Boolean(args.dev ?? saved?.dev);
133
+ const buildDir = args.build ? path.resolve(args.build) : saved?.buildDir;
134
+ const mounts = dev ? devMounts(args) : [];
135
+ // Môi trường đang chạy dùng lại cổng của nó; môi trường mới thì cổng phải trống
136
+ if (!saved && !(await portFree(port))) throw new Error(`Cổng ${port} đang được dùng — chọn cổng khác: create-cms env up --port ${port + 1}`);
137
+ write(path.join(dir, "docker-compose.yml"), compose({ project, port, url, apiImage, builderImage, dev, buildDir, mounts }));
138
+ write(path.join(dir, "Caddyfile"), asset("Caddyfile"));
139
+ write(path.join(dir, "init-secrets.sh"), asset("init-secrets.sh"));
140
+ write(path.join(dir, ".gitignore"), "*\n"); // tài khoản & cấu hình máy, không đưa vào git
141
+ if (dev) {
142
+ console.log(c.cyan(`• Chế độ dev: website chạy next dev${mounts.length ? `, gắn ${mounts.map((m) => `${m.kind} ${m.slug}`).join(", ")}` : ""}`));
143
+ if (!mounts.length) console.log(c.yellow(" ! Không thấy theme.json/plugin.json (có phần website) trong thư mục này — chạy lệnh ở thư mục chứa theme/plugin hoặc dùng --mount"));
144
+ }
145
+ if (buildDir) {
146
+ console.log(c.cyan(`• Build image từ mã nguồn ${buildDir} (lần đầu vài phút)…`));
147
+ await dc("build");
148
+ } else {
149
+ const local = async (img) => run("docker", ["image", "inspect", img], { quiet: true }).then(() => true, () => false);
150
+ const missing = [];
151
+ for (const [svc, img] of [["api", apiImage], ["builder", builderImage]]) if (!(await local(img))) missing.push(svc);
152
+ if (missing.length) console.log(c.cyan(`• Kéo image ${apiImage}…`));
153
+ try { if (missing.length) await dc("pull", ...missing); }
154
+ catch (e) {
155
+ throw new Error(`Không kéo được image CMS (${apiImage}). Image có thể chưa được phát hành công khai.\n`
156
+ + ` Có mã nguồn CMS: create-cms env up --build <thư-mục-mã-nguồn>\n Hoặc chỉ định image khác: --api-image … --builder-image …\n Chi tiết: ${e.message.split("\n").slice(-2).join(" ")}`);
157
+ }
158
+ }
159
+ try { await dc("up", "-d"); }
160
+ catch (e) {
161
+ // Lần chạy đầu hỏng giữa chừng: gỡ container vừa tạo, không để lại stack mồ côi (chưa có credentials.json để env down)
162
+ if (!saved) await dc("down", "-v", "--remove-orphans").catch(() => {});
163
+ throw e;
164
+ }
165
+ if (!(await waitFor(`${url}/api/health`, 240))) throw new Error(`CMS chưa phản hồi ở ${url} — xem log: docker compose -f ${path.relative(process.cwd(), dir)}/docker-compose.yml logs api`);
166
+
167
+ // Lần đầu website được build (1–2 phút) — chờ xong để mở ra là dùng được ngay
168
+ console.log(c.cyan("• Chờ website sẵn sàng (lần đầu cần build)…"));
169
+ const until = Date.now() + 600_000;
170
+ for (;;) {
171
+ const st = await fetch(url, { signal: AbortSignal.timeout(20_000) }).then((r) => r.status, () => 0);
172
+ if (st && st !== 502 && st !== 503) break;
173
+ if (Date.now() > until) throw new Error("Website chưa build xong sau 10 phút — xem log: docker compose logs builder");
174
+ await new Promise((r) => setTimeout(r, 3000));
175
+ }
176
+
177
+ let cred = saved;
178
+ const setup = await (await fetch(`${url}/api/setup`)).json();
179
+ if (setup.needsSetup && args["no-setup"]) {
180
+ // Tự trải nghiệm màn hình cài đặt như người dùng cuối
181
+ const token = (await dc("exec", "-T", "api", "cat", "/data/state/setup-token")).trim();
182
+ write(credFile, JSON.stringify({ project, port, url, apiImage, builderImage }, null, 2) + "\n");
183
+ console.log(c.green("✓ CMS đã chạy, chưa cài đặt"));
184
+ console.log(` Mở ${url}/admin — mã cài đặt: ${c.bold(token)}`);
185
+ return;
186
+ }
187
+ if (setup.needsSetup) {
188
+ // Cài đặt lần đầu bằng chính màn hình cài đặt của CMS (mã cài đặt đọc trong container)
189
+ const token = (await dc("exec", "-T", "api", "cat", "/data/state/setup-token")).trim();
190
+ const password = crypto.randomBytes(12).toString("base64url");
191
+ const res = await fetch(`${url}/api/setup`, {
192
+ method: "POST", headers: { "content-type": "application/json" },
193
+ body: JSON.stringify({ token, siteTitle: args.title ?? "Website thử", language: args.lang ?? "vi", timezone: "Asia/Ho_Chi_Minh",
194
+ adminUserName: "admin", adminEmail: "admin@example.com", adminPassword: password, sampleContent: true, discourageSearchEngines: true }),
195
+ });
196
+ if (!res.ok) throw new Error(`Cài đặt CMS thất bại: ${res.status} ${await res.text()}`);
197
+ cred = { url, user: "admin", password };
198
+ }
199
+ cred = { ...cred, project, port, url, apiImage, builderImage, dev, buildDir, mounts: mounts.map((m) => ({ kind: m.kind, slug: m.slug, dir: m.dir })) };
200
+ write(credFile, JSON.stringify(cred, null, 2) + "\n");
201
+ fs.chmodSync(credFile, 0o600);
202
+ console.log(c.green("✓ CMS đã sẵn sàng"));
203
+ printInfo(cred, dir);
204
+ return;
205
+ }
206
+ // env up hỏng trước khi lưu tài khoản: vẫn cho env down dọn theo docker-compose.yml đã ghi
207
+ if (!saved && !(action === "down" && fs.existsSync(path.join(dir, "docker-compose.yml"))))
208
+ throw new Error(`Chưa có môi trường ở ${dir} — chạy: npx ${CLI} env up`);
209
+ if (action === "down") {
210
+ await dc("down", ...(args.purge ? ["-v"] : []));
211
+ if (args.purge) fs.rmSync(dir, { recursive: true, force: true });
212
+ console.log(c.green(args.purge ? "✓ Đã dừng và xoá dữ liệu" : "✓ Đã dừng (dữ liệu được giữ — `env up` để chạy lại)"));
213
+ return;
214
+ }
215
+ if (action === "logs") {
216
+ const extra = (args._ ?? []).slice(2);
217
+ const service = extra.find((x) => !x.startsWith("-"));
218
+ const follow = args.follow || extra.includes("-f");
219
+ await run("docker", ["compose", "-f", path.join(dir, "docker-compose.yml"), "logs", "--tail", "200", ...(follow ? ["-f"] : []), ...(service ? [service] : [])], { quiet: false });
220
+ return;
221
+ }
222
+ if (action === "status") {
223
+ const up = await waitFor(`${saved.url}/api/health`, 2);
224
+ console.log((up ? c.green("● Đang chạy") : c.yellow("○ Đã dừng")) + (saved.dev ? c.dim(` · dev (${(saved.mounts ?? []).map((m) => m.slug).join(", ") || "không gắn thư mục"})`) : ""));
225
+ if (saved.password) printInfo(saved, dir); else console.log(` Website ${saved.url} (chưa cài đặt hoặc tự cài bằng màn hình cài đặt)`);
226
+ return;
227
+ }
228
+ throw new Error("Dùng: create-cms env up [--dev] [--build DIR] | status | logs [dịch vụ] [-f] | down [--purge]");
229
+ }
230
+
231
+ function printInfo(cred, dir) {
232
+ console.log(` Website ${cred.url}`);
233
+ console.log(` Quản trị ${cred.url}/admin (${cred.user} / ${cred.password})`);
234
+ console.log(c.dim(` Tài khoản lưu ở ${path.relative(process.cwd(), path.join(dir, "credentials.json"))} — \`create-cms dev\` tự dùng.`));
235
+ }
package/lib/npm.mjs ADDED
@@ -0,0 +1,9 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ // Gói npm (org @ecomcx) — theme/plugin import đúng tên này
4
+ export const THEME_SDK = "@ecomcx/nexus-theme-sdk", ADMIN_SDK = "@ecomcx/nexus-admin-sdk", CONTENT = "@ecomcx/nexus-content";
5
+ export const CLI = "@ecomcx/create-nexus";
6
+ /** Tên cũ (trước khi lên npm) → tên mới: core vẫn hiểu import cũ, validate nhắc đổi, upgrade giữ bí danh để code cũ còn chạy. */
7
+ export const OLD_NAMES = { "@cms/theme-sdk": THEME_SDK, "@cms/admin-sdk": ADMIN_SDK, "@cms/content": CONTENT };
8
+ /** Phiên bản của CLI = phiên bản SDK phát hành cùng đợt. */
9
+ export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;