@minhkhoa/aimanagement 0.0.0-stage → 0.1.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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -2
  3. package/bin/aiteam.mjs +236 -0
  4. package/bin/node-version-policy.mjs +71 -0
  5. package/dist/server/aiteam-server.cjs +1367 -0
  6. package/dist/web/404/index.html +1 -0
  7. package/dist/web/404.html +1 -0
  8. package/dist/web/__next.__PAGE__.txt +30 -0
  9. package/dist/web/__next._full.txt +33 -0
  10. package/dist/web/__next._tree.txt +12 -0
  11. package/dist/web/_next/static/chunks/06prn7ws0iehx.js +1 -0
  12. package/dist/web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  13. package/dist/web/_next/static/chunks/0m9c3j80p7em7.js +1 -0
  14. package/dist/web/_next/static/chunks/0yoyw60ppazor.js +1 -0
  15. package/dist/web/_next/static/chunks/23e6jep2anmtk.js +1 -0
  16. package/dist/web/_next/static/chunks/29ngus7-foeep.js +1 -0
  17. package/dist/web/_next/static/chunks/2o5inzcsuoklg.js +1 -0
  18. package/dist/web/_next/static/chunks/2qscnk31ofx_i.js +1 -0
  19. package/dist/web/_next/static/chunks/2rtnqj6xgeh6o.js +1 -0
  20. package/dist/web/_next/static/chunks/2sjvv027waa32.js +1 -0
  21. package/dist/web/_next/static/chunks/2uj9f-bl63j5x.js +1 -0
  22. package/dist/web/_next/static/chunks/310vm2bl3xxpt.js +31 -0
  23. package/dist/web/_next/static/chunks/36_68lhy49zlz.js +55 -0
  24. package/dist/web/_next/static/chunks/39qpoin4yhruw.css +2 -0
  25. package/dist/web/_next/static/chunks/3b7v8xei-k6e1.js +22 -0
  26. package/dist/web/_next/static/chunks/3qfy96eoyh-ek.js +1 -0
  27. package/dist/web/_next/static/chunks/3qrd4fw14ii6e.js +1 -0
  28. package/dist/web/_next/static/chunks/3s1u2_-g19txt.css +14 -0
  29. package/dist/web/_next/static/chunks/3wnxqwb2tnys-.js +10 -0
  30. package/dist/web/_next/static/chunks/turbopack-2u1d16houkiyo.js +1 -0
  31. package/dist/web/_next/static/ijXCyL0v-NhTojj_uhUdh/_buildManifest.js +11 -0
  32. package/dist/web/_next/static/ijXCyL0v-NhTojj_uhUdh/_clientMiddlewareManifest.js +1 -0
  33. package/dist/web/_next/static/ijXCyL0v-NhTojj_uhUdh/_ssgManifest.js +1 -0
  34. package/dist/web/_next/static/media/1f6c95fdd7b08cc8-s.0jaqhc1tb-5xm.woff2 +0 -0
  35. package/dist/web/_next/static/media/237858b39a32779d-s.p.2cyn07wtgehh0.woff2 +0 -0
  36. package/dist/web/_next/static/media/25d341d5876d8ede-s.p.2wu4yy186g04j.woff2 +0 -0
  37. package/dist/web/_next/static/media/3c61b7007b1b2764-s.0n_ln9vqskgts.woff2 +0 -0
  38. package/dist/web/_next/static/media/6ac706d910f2bd04-s.p.25pbmqxk8u2s7.woff2 +0 -0
  39. package/dist/web/_next/static/media/77d9a04c69f7b11b-s.p.1uklb5el4zgvl.woff2 +0 -0
  40. package/dist/web/_next/static/media/7f1976624c2b6255-s.p.1hvvd1t-trplq.woff2 +0 -0
  41. package/dist/web/_next/static/media/92b6de9cb55945e2-s.2eksvhm1z0jwa.woff2 +0 -0
  42. package/dist/web/_next/static/media/b4fb5dfe9580f890-s.p.1zvg0ggiaivzo.woff2 +0 -0
  43. package/dist/web/_next/static/media/d81b50f8e1aba043-s.05i4gb4p0piv4.woff2 +0 -0
  44. package/dist/web/_next/static/media/dd018b41519c2d93-s.p.2g-0we2o5_ngd.woff2 +0 -0
  45. package/dist/web/_next/static/media/edb5f85793adc1bb-s.p.1gepa_7fcx9fm.woff2 +0 -0
  46. package/dist/web/_next/static/media/icon.0_1epmo0g6idl.svg +1 -0
  47. package/dist/web/_not-found/__next._full.txt +26 -0
  48. package/dist/web/_not-found/__next._not-found.__PAGE__.txt +32 -0
  49. package/dist/web/_not-found/__next._tree.txt +5 -0
  50. package/dist/web/_not-found/index.html +1 -0
  51. package/dist/web/_not-found/index.txt +26 -0
  52. package/dist/web/icon.svg +1 -0
  53. package/dist/web/index.html +1 -0
  54. package/dist/web/index.txt +33 -0
  55. package/dist/web/monitor/__next._full.txt +37 -0
  56. package/dist/web/monitor/__next._tree.txt +13 -0
  57. package/dist/web/monitor/__next.monitor.__PAGE__.txt +35 -0
  58. package/dist/web/monitor/index.html +1 -0
  59. package/dist/web/monitor/index.txt +37 -0
  60. package/dist/web/org/__next._full.txt +37 -0
  61. package/dist/web/org/__next._tree.txt +13 -0
  62. package/dist/web/org/__next.org.__PAGE__.txt +35 -0
  63. package/dist/web/org/index.html +1 -0
  64. package/dist/web/org/index.txt +37 -0
  65. package/dist/web/settings/__next._full.txt +36 -0
  66. package/dist/web/settings/__next._tree.txt +13 -0
  67. package/dist/web/settings/__next.settings.__PAGE__.txt +34 -0
  68. package/dist/web/settings/index.html +1 -0
  69. package/dist/web/settings/index.txt +36 -0
  70. package/dist/web/tasks/__next._full.txt +37 -0
  71. package/dist/web/tasks/__next._tree.txt +13 -0
  72. package/dist/web/tasks/__next.tasks.__PAGE__.txt +35 -0
  73. package/dist/web/tasks/index.html +1 -0
  74. package/dist/web/tasks/index.txt +37 -0
  75. package/dist/web/workflows/__next._full.txt +36 -0
  76. package/dist/web/workflows/__next._tree.txt +13 -0
  77. package/dist/web/workflows/__next.workflows.__PAGE__.txt +34 -0
  78. package/dist/web/workflows/index.html +1 -0
  79. package/dist/web/workflows/index.txt +36 -0
  80. package/package.json +69 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 minhkhoa99
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,47 @@
1
- # Temporary Holding Version
1
+ # AI Team (aimanagement)
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
+ Nền tảng điều phối nhiều agent AI theo phòng ban, chạy hoàn toàn trên máy của bạn.
4
+ Agent dùng Claude Code, Codex (bằng tài khoản đã đăng nhập) hoặc Custom API.
5
+
6
+ ## Cài đặt và chạy
7
+
8
+ Cần Node LTS đã vá bảo mật: 22.23.4+, 24.22.1+ hoặc 26.12.1+ ([tải Node](https://nodejs.org/en/download)).
9
+
10
+ ```
11
+ npm i -g @minhkhoa/aimanagement
12
+ aimanagement
13
+ ```
14
+
15
+ Lệnh mở trình duyệt tại `http://127.0.0.1:7419` (cổng bận thì tự dùng cổng trống kế tiếp, URL thật
16
+ được in ra). Không cần tải mã nguồn: gói đã có sẵn server bundle và giao diện tĩnh, không có
17
+ dependency nào phải cài thêm.
18
+
19
+ ```
20
+ aimanagement --port 8080 # chọn cổng khác
21
+ aimanagement --no-open # không tự mở trình duyệt
22
+ aimanagement doctor # kiểm tra node, git, Claude Code, Codex
23
+ ```
24
+
25
+ - Dữ liệu và log nằm ở `~/.aiteam` (đổi bằng biến `AITEAM_HOME`); cổng ưu tiên đổi bằng `AITEAM_PORT`.
26
+ - Server chỉ nghe `127.0.0.1`; chỉ trình duyệt mở bằng liên kết lệnh in ra mới vào được.
27
+ - Agent chạy bằng Claude Code hoặc Codex cần CLI tương ứng đã cài và đăng nhập (`aimanagement doctor`).
28
+ - Cập nhật: `npm i -g @minhkhoa/aimanagement@latest`. Gỡ: `npm rm -g @minhkhoa/aimanagement` (dữ liệu ở `~/.aiteam` vẫn giữ).
29
+
30
+ ## Phát triển
31
+
32
+ ```
33
+ npm install
34
+ npm run build
35
+ node bin/aiteam.mjs # như lệnh aimanagement, chạy từ mã nguồn
36
+ npm run lint && npm test # kiểm tra
37
+ npm run test:smoke # chạy thử bản build
38
+ npm pack # tạo gói .tgz (tự build và chạy thử trước)
39
+ ```
40
+
41
+ Quy tắc code: [CLAUDE.md](CLAUDE.md).
42
+
43
+ - [Kế hoạch thực hiện](docs/IMPLEMENTATION_PLAN.md)
44
+ - [Kế hoạch mapping giao diện](docs/UI_MAPPING.md)
45
+ - [Giao diện mẫu tương tác](docs/design/aiteam-ui.html) (tải về rồi mở bằng trình duyệt)
46
+
47
+ Công nghệ: NestJS, Next.js, SQLite (`node:sqlite`). Giấy phép: [MIT](LICENSE).
package/bin/aiteam.mjs ADDED
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ // Launcher of the `aimanagement` command (AI Team). Four jobs only: check Node, prepare ~/.aiteam, load the bundled server
3
+ // (which applies migrations and listens on 127.0.0.1), open the browser.
4
+ // Sub-command: `aimanagement doctor` checks the local toolchain.
5
+ import { evaluateNodeVersion } from './node-version-policy.mjs';
6
+
7
+ const nodeVerdict = evaluateNodeVersion(process.version);
8
+ if (!nodeVerdict.accepted && process.argv[2] !== 'doctor') {
9
+ console.error(`aimanagement: ${nodeVerdict.reason}`);
10
+ console.error('Tải bản vá tại https://nodejs.org/en/download');
11
+ process.exit(1);
12
+ }
13
+
14
+ // node:sqlite prints an ExperimentalWarning on every start. Hide exactly that one; every other
15
+ // process warning is still printed.
16
+ process.removeAllListeners('warning');
17
+ process.on('warning', (warning) => {
18
+ const isSqliteExperimentalWarning =
19
+ warning.name === 'ExperimentalWarning' && /SQLite/i.test(warning.message);
20
+ if (!isSqliteExperimentalWarning) {
21
+ console.warn(`${warning.name}: ${warning.message}`);
22
+ }
23
+ });
24
+
25
+ const { spawn, spawnSync } = await import('node:child_process');
26
+ const { randomBytes } = await import('node:crypto');
27
+ const { existsSync, mkdirSync, readFileSync } = await import('node:fs');
28
+ const { createRequire } = await import('node:module');
29
+ const { homedir } = await import('node:os');
30
+ const path = (await import('node:path')).default;
31
+ const { fileURLToPath } = await import('node:url');
32
+ const { parseArgs } = await import('node:util');
33
+
34
+ const SESSION_TOKEN_BYTES = 32;
35
+ const DOCTOR_COMMAND_TIMEOUT_MS = 15_000;
36
+
37
+ const binDirectory = path.dirname(fileURLToPath(import.meta.url));
38
+ const packageRoot = path.resolve(binDirectory, '..');
39
+ const serverBundlePath = path.join(packageRoot, 'dist', 'server', 'aiteam-server.cjs');
40
+ const webRootDirectory = path.join(packageRoot, 'dist', 'web');
41
+
42
+ function readPackageVersion() {
43
+ const packageManifest = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
44
+ return packageManifest.version;
45
+ }
46
+
47
+ function printUsage() {
48
+ console.log(`Cách dùng:
49
+ aimanagement [--port <số>] [--no-open] Khởi động AI Team và mở trình duyệt
50
+ aimanagement doctor Kiểm tra Node, git, Claude Code, Codex
51
+
52
+ Cổng: --port, nếu không có thì AITEAM_PORT, nếu không có thì 7419.
53
+ Cổng đang bận thì tự thử cổng trống kế tiếp; URL in ra là cổng thật.
54
+
55
+ Biến môi trường:
56
+ AITEAM_HOME Thư mục dữ liệu (mặc định ~/.aiteam)
57
+ AITEAM_PORT Cổng ưu tiên`);
58
+ }
59
+
60
+ function parseLauncherArguments(argv) {
61
+ const { values, positionals } = parseArgs({
62
+ args: argv,
63
+ allowPositionals: true,
64
+ options: {
65
+ port: { type: 'string' },
66
+ 'no-open': { type: 'boolean', default: false },
67
+ help: { type: 'boolean', short: 'h', default: false },
68
+ },
69
+ });
70
+ return {
71
+ subcommand: positionals[0],
72
+ portOverride: values.port,
73
+ shouldOpenBrowser: !values['no-open'],
74
+ shouldPrintHelp: values.help,
75
+ };
76
+ }
77
+
78
+ /** --port wins over AITEAM_PORT, which wins over the default port. */
79
+ function resolvePreferredPort(portOverride, defaultPort) {
80
+ const [sourceLabel, rawPort] =
81
+ portOverride !== undefined
82
+ ? ['--port', portOverride]
83
+ : process.env.AITEAM_PORT !== undefined && process.env.AITEAM_PORT !== ''
84
+ ? ['AITEAM_PORT', process.env.AITEAM_PORT]
85
+ : ['mặc định', String(defaultPort)];
86
+ const port = Number(rawPort);
87
+ if (!/^\d+$/.test(rawPort) || port < 1 || port > 65_535) {
88
+ throw new Error(`${sourceLabel} phải là số nguyên 1-65535, nhận được "${rawPort}".`);
89
+ }
90
+ return port;
91
+ }
92
+
93
+ function resolveAiteamHome() {
94
+ return process.env.AITEAM_HOME ?? path.join(homedir(), '.aiteam');
95
+ }
96
+
97
+ function prepareDataDirectories(aiteamHome) {
98
+ const directories = {
99
+ dataDirectory: path.join(aiteamHome, 'data'),
100
+ logDirectory: path.join(aiteamHome, 'logs'),
101
+ };
102
+ for (const directory of Object.values(directories)) {
103
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
104
+ }
105
+ return directories;
106
+ }
107
+
108
+ function openBrowser(url) {
109
+ const [command, args] =
110
+ process.platform === 'win32'
111
+ ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
112
+ : process.platform === 'darwin'
113
+ ? ['open', [url]]
114
+ : ['xdg-open', [url]];
115
+ const browserProcess = spawn(command, args, { detached: true, stdio: 'ignore' });
116
+ browserProcess.on('error', (error) => {
117
+ console.warn(`Không mở được trình duyệt (${error.message}). Hãy tự mở URL ở trên.`);
118
+ });
119
+ browserProcess.unref();
120
+ }
121
+
122
+ async function startApplication(options) {
123
+ if (!existsSync(serverBundlePath)) {
124
+ throw new Error(`Không tìm thấy ${serverBundlePath}. Chạy "npm run build" trước.`);
125
+ }
126
+ const aiteamHome = resolveAiteamHome();
127
+ const { dataDirectory, logDirectory } = prepareDataDirectories(aiteamHome);
128
+ const version = readPackageVersion();
129
+ const sessionToken = randomBytes(SESSION_TOKEN_BYTES).toString('base64url');
130
+
131
+ const { startAiteamServer, DEFAULT_SERVER_PORT, SESSION_QUERY_PARAM } = createRequire(
132
+ import.meta.url,
133
+ )(serverBundlePath);
134
+ const preferredPort = resolvePreferredPort(options.portOverride, DEFAULT_SERVER_PORT);
135
+ const runningServer = await startAiteamServer({
136
+ preferredPort,
137
+ version,
138
+ sessionToken,
139
+ dataDirectory,
140
+ logDirectory,
141
+ secretKeyFilePath: path.join(aiteamHome, 'secret.key'),
142
+ webRootDirectory,
143
+ installFatalErrorHandlers: true,
144
+ });
145
+
146
+ // Opened once: the server swaps the token for an HttpOnly cookie and redirects to /.
147
+ const openUrl = `${runningServer.baseUrl}/?${SESSION_QUERY_PARAM}=${sessionToken}`;
148
+ const portNotice =
149
+ runningServer.listenPort === preferredPort
150
+ ? ''
151
+ : `
152
+ Cổng ${preferredPort} đang bận, dùng cổng ${runningServer.listenPort}.`;
153
+ const readyAfterMs = Math.round(performance.now());
154
+ console.log(`AI Team v${version} đang chạy
155
+ Mở: ${openUrl}
156
+ Dữ liệu: ${aiteamHome}
157
+ Log: ${path.join(logDirectory, 'aiteam.log')}
158
+ Sẵn sàng sau ${readyAfterMs} ms. Nhấn Ctrl+C để dừng.${portNotice}`);
159
+
160
+ if (options.shouldOpenBrowser) {
161
+ openBrowser(openUrl);
162
+ }
163
+
164
+ let isStopping = false;
165
+ const stopOnSignal = (signal) => {
166
+ if (isStopping) {
167
+ console.error('Buộc dừng.');
168
+ process.exit(1);
169
+ }
170
+ isStopping = true;
171
+ console.log(`\nNhận ${signal}, đang dừng...`);
172
+ runningServer.stopServer().then(
173
+ () => process.exit(0),
174
+ (error) => {
175
+ console.error('Lỗi khi dừng server:', error);
176
+ process.exit(1);
177
+ },
178
+ );
179
+ };
180
+ process.on('SIGINT', stopOnSignal);
181
+ process.on('SIGTERM', stopOnSignal);
182
+ }
183
+
184
+ /** Runs a fixed, trusted command line (no user input) and returns its first output line. */
185
+ function probeCommand(commandLine) {
186
+ // shell is required on Windows to resolve .cmd shims (npm-installed CLIs); the command line is
187
+ // a constant from this file, never user input.
188
+ const result = spawnSync(commandLine, {
189
+ shell: true,
190
+ encoding: 'utf8',
191
+ timeout: DOCTOR_COMMAND_TIMEOUT_MS,
192
+ windowsHide: true,
193
+ });
194
+ const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
195
+ const firstLine = output.split(/\r?\n/).find((line) => line.trim().length > 0) ?? '';
196
+ if (result.error !== undefined) {
197
+ return { passed: false, detail: result.error.message };
198
+ }
199
+ return { passed: result.status === 0, detail: firstLine || `exit code ${result.status}` };
200
+ }
201
+
202
+ function runDoctorChecks() {
203
+ const checks = [
204
+ { label: 'node', outcome: { passed: nodeVerdict.accepted, detail: nodeVerdict.reason } },
205
+ { label: 'git', outcome: probeCommand('git --version') },
206
+ { label: 'claude --version', outcome: probeCommand('claude --version') },
207
+ { label: 'codex --version', outcome: probeCommand('codex --version') },
208
+ { label: 'codex login status', outcome: probeCommand('codex login status') },
209
+ ];
210
+
211
+ console.log('aimanagement doctor');
212
+ for (const { label, outcome } of checks) {
213
+ console.log(` ${outcome.passed ? '[ok] ' : '[lỗi]'} ${label.padEnd(20)} ${outcome.detail}`);
214
+ }
215
+ const failedCount = checks.filter((check) => !check.outcome.passed).length;
216
+ console.log(failedCount === 0 ? 'Mọi kiểm tra đều đạt.' : `${failedCount} kiểm tra chưa đạt.`);
217
+ return failedCount === 0 ? 0 : 1;
218
+ }
219
+
220
+ try {
221
+ const options = parseLauncherArguments(process.argv.slice(2));
222
+ if (options.shouldPrintHelp) {
223
+ printUsage();
224
+ } else if (options.subcommand === 'doctor') {
225
+ process.exitCode = runDoctorChecks();
226
+ } else if (options.subcommand === undefined) {
227
+ await startApplication(options);
228
+ } else {
229
+ printUsage();
230
+ process.exitCode = 1;
231
+ }
232
+ } catch (error) {
233
+ const code = error?.code === undefined ? '' : ` [${error.code}]`;
234
+ console.error(`aimanagement: ${error?.message ?? error}${code}`);
235
+ process.exitCode = 1;
236
+ }
@@ -0,0 +1,71 @@
1
+ // Minimum Node.js version accepted per release line. Only LTS lines that still receive security
2
+ // fixes are allowed, each at its latest security release.
3
+ //
4
+ // Current floor: October 2026 security release (CVE-2026-111497 "Node2Shell" Brotli RCE and
5
+ // 9 other CVEs): 22.23.4, 24.22.1, 26.12.1.
6
+ // Update this table (and package.json "engines", .nvmrc, CLAUDE.md) on every Node security release.
7
+ //
8
+ // Keep this file plain ES2015+ without newer syntax: it must run on old Node to print a clear error.
9
+
10
+ export const MINIMUM_PATCHED_VERSION_BY_MAJOR = {
11
+ 22: '22.23.4',
12
+ 24: '24.22.1',
13
+ 26: '26.12.1',
14
+ };
15
+
16
+ /** Release lines newer than the newest known LTS are accepted (future LTS / current releases). */
17
+ const NEWEST_KNOWN_MAJOR = 26;
18
+
19
+ function parseNodeVersion(versionText) {
20
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(versionText);
21
+ if (match === null) {
22
+ return null;
23
+ }
24
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
25
+ }
26
+
27
+ function compareVersionParts(left, right) {
28
+ for (let index = 0; index < 3; index += 1) {
29
+ if (left[index] !== right[index]) {
30
+ return left[index] - right[index];
31
+ }
32
+ }
33
+ return 0;
34
+ }
35
+
36
+ /**
37
+ * @param {string} versionText e.g. process.version ("v22.23.4")
38
+ * @returns {{ accepted: boolean, reason: string }}
39
+ */
40
+ export function evaluateNodeVersion(versionText) {
41
+ const version = parseNodeVersion(versionText);
42
+ if (version === null) {
43
+ return { accepted: false, reason: `Không đọc được phiên bản Node "${versionText}".` };
44
+ }
45
+ const major = version[0];
46
+ const supportedLines = Object.values(MINIMUM_PATCHED_VERSION_BY_MAJOR).join(', ');
47
+
48
+ if (major > NEWEST_KNOWN_MAJOR) {
49
+ return { accepted: true, reason: `Node ${versionText} mới hơn các dòng LTS đã biết.` };
50
+ }
51
+
52
+ const minimumText = MINIMUM_PATCHED_VERSION_BY_MAJOR[major];
53
+ if (minimumText === undefined) {
54
+ return {
55
+ accepted: false,
56
+ reason: `Node ${versionText} không thuộc dòng LTS còn được vá bảo mật. Hãy cài một trong: ${supportedLines} (hoặc mới hơn trong cùng dòng).`,
57
+ };
58
+ }
59
+
60
+ const minimum = parseNodeVersion(minimumText);
61
+ if (compareVersionParts(version, minimum) < 0) {
62
+ return {
63
+ accepted: false,
64
+ reason: `Node ${versionText} thiếu bản vá bảo mật. Cần tối thiểu v${minimumText} cho dòng ${major}.x.`,
65
+ };
66
+ }
67
+ return {
68
+ accepted: true,
69
+ reason: `Node ${versionText} đã có bản vá bảo mật mới nhất của dòng ${major}.x.`,
70
+ };
71
+ }