monacloud-mcp 0.3.2 → 0.4.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/README.md +25 -2
- package/STATUS.md +31 -1
- package/dist/clients.js +22 -2
- package/dist/compute.d.ts +1 -1
- package/dist/compute.js +16 -3
- package/dist/http.js +9 -5
- package/dist/index.js +1 -1
- package/dist/jobs.d.ts +1 -8
- package/dist/jobs.js +2 -0
- package/dist/local-app.d.ts +21 -0
- package/dist/local-app.js +108 -0
- package/dist/local.d.ts +19 -0
- package/dist/local.js +282 -0
- package/dist/server.js +5 -5
- package/dist/templates.js +1 -1
- package/docs/ai-agent.md +13 -1
- package/docs/local-deploy.md +41 -0
- package/docs/wave-ab.md +2 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,6 +4,29 @@
|
|
|
4
4
|
|
|
5
5
|
Human đăng ký, duyệt chi phí, thêm DNS khi cần, nạp tiền và cung cấp OTP/KYC khi bắt buộc. Các bước tạo tài nguyên, đọc trạng thái, cấu hình webhook, test và deploy được thiết kế để AI agent làm qua MCP.
|
|
6
6
|
|
|
7
|
+
## 0.4.0 — Đưa thư mục hiện tại lên web
|
|
8
|
+
|
|
9
|
+
Prompt Claude Code: **“Đưa dự án này lên MONA Cloud, dùng thư mục hiện tại”**.
|
|
10
|
+
|
|
11
|
+
AI làm 99%: `cloud_app_detect(local_dir)` offline → đọc host và giá → sandbox nếu cần host mới → hỏi duyệt chi phí một lần → `cloud_app_create(local_dir)` → kiểm và trả URL. Có domain riêng thì `cloud_app_domain_add` và hướng dẫn CNAME. Human đăng ký MONA Pass qua device flow và nạp ví khi hết credit 20k.
|
|
12
|
+
|
|
13
|
+
| Tool | Đầu vào / hành vi 0.4.0 |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `cloud_app_detect` | `local_dir`: stack, port, start, Dockerfile, build_type, tên env từ `.env.example`; không mạng |
|
|
16
|
+
| `cloud_app_create` | `local_dir`, `name?`, `build_type?`, `env?`, `port?`, `domain?`: ZIP → tạo source=upload → multipart → poll → `{url, app_id, build, seconds}` |
|
|
17
|
+
| `cloud_app_deploy` | `app_id`, `local_dir?`: có thư mục thì ZIP mới, upload, chờ rồi deploy; bỏ thư mục để dùng bản cũ |
|
|
18
|
+
| `cloud_app_domain_add` | `app_id`, `host`: gắn domain và nhận hướng dẫn CNAME |
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
cloud_app_detect({ local_dir: "/absolute/path/to/project" })
|
|
22
|
+
cloud_app_create({ local_dir: "/absolute/path/to/project", name: "shop", sandbox: true })
|
|
23
|
+
// Sau khi đã duyệt chi phí
|
|
24
|
+
cloud_app_create({ local_dir: "/absolute/path/to/project", name: "shop" })
|
|
25
|
+
cloud_app_deploy({ app_id: "<app_id>", local_dir: "/absolute/path/to/project" })
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
ZIP tối đa 80 MiB, loại `.env*`, `*.pem`, `.git`, `node_modules` và symlink; áp dụng `.gitignore`/`.dockerignore`. `dist` được giữ mặc định. Git vẫn dùng `repo_url` như trước. [Hợp đồng upload, giới hạn và cách tiếp tục job](docs/local-deploy.md).
|
|
29
|
+
|
|
7
30
|
## Yêu cầu
|
|
8
31
|
|
|
9
32
|
- Node.js 20 trở lên.
|
|
@@ -160,7 +183,7 @@ cloud_services_list({ sandbox: true })
|
|
|
160
183
|
|
|
161
184
|
- `monacloud://llms`: mô tả máy đọc của toàn stack MONA Cloud.
|
|
162
185
|
- `monacloud://status`: health tổng hợp MONA Pass, Billing, compute MONA Cloud, MONA Pay và MONA Mail (`/v1/healthz`).
|
|
163
|
-
- Prompt `dung-app-ban-hang-monacloud`: chuỗi VPS → database → VA/QR → webhook → deploy, đọc giá, duyệt chi phí rồi tạo;
|
|
186
|
+
- Prompt `dung-app-ban-hang-monacloud`: chuỗi VPS → database → VA/QR → webhook → deploy, đọc giá, duyệt chi phí rồi tạo; dự án local dùng `cloud_app_detect` rồi `cloud_app_create(local_dir)`; git dùng `repo_url`.
|
|
164
187
|
- Prompt `gui-mail-otp-monamail(app_name?, framework?, domain?)`: account → thử onboarding → domain/DNS → verify → API key → `.env` → SDK OTP → webhook bounced; chỉ dừng ở bước thêm DNS hoặc nạp tiền.
|
|
165
188
|
|
|
166
189
|
## Spend guard và lỗi cho AI
|
|
@@ -251,7 +274,7 @@ Test dùng Node built-in, MCP transport thật qua stdio, process con và mock H
|
|
|
251
274
|
| `cloud_app_domain_add` / `cloud_app_logs` / `cloud_app_delete` | CNAME domain, log build, xoá app; **đang mở** |
|
|
252
275
|
| `cloud_app_host_list` | Xem host và tài nguyên/chi phí; **đang mở** |
|
|
253
276
|
|
|
254
|
-
15 tool mới có 15 alias `vibecloud_` tương ứng. Tổng với MONA Pay 0.5.5 đang có: **
|
|
277
|
+
15 tool mới có 15 alias `vibecloud_` tương ứng. Tổng với MONA Pay 0.5.5 đang có: **135 tool** ở 0.4.0, gồm 20 Mail và 27 alias compute (thêm cloud_app_detect/vibecloud_app_detect). Package/MCP binary version: **0.4.0**.
|
|
255
278
|
|
|
256
279
|
```text
|
|
257
280
|
cloud_plan_list({ ram_gb: 4 })
|
package/STATUS.md
CHANGED
|
@@ -1,4 +1,34 @@
|
|
|
1
|
-
# STATUS — monacloud-mcp 0.
|
|
1
|
+
# STATUS — monacloud-mcp 0.4.0
|
|
2
|
+
|
|
3
|
+
Updated: 2026-09-05 (Asia/Ho_Chi_Minh)
|
|
4
|
+
|
|
5
|
+
## Brief deploy local — hoàn tất offline
|
|
6
|
+
|
|
7
|
+
- `cloud_app_detect(local_dir)` và alias `vibecloud_app_detect`: Node/Next/Vite/Python/PHP/static, port/start/Dockerfile/build_type, tên env từ `.env.example`; không mạng, không chạy code dự án.
|
|
8
|
+
- `cloud_app_create(local_dir)`: ZIP nguồn local trước HTTP, tạo `{source:"upload",name,build_type,env,port,domain?}`, nhận `{id,upload_url,max_bytes}`, multipart `archive` + `build_path`, poll job, trả URL/app_id/build/seconds. Giữ luồng git và mặc định 0.3.x.
|
|
9
|
+
- ZIP deflate/UTF-8/UNIX mode, tối đa 83,886,080 bytes (80 MiB); loại node_modules/.git/.env*/pem/symlink, áp dụng `.gitignore` root+nested và `.dockerignore` root. Giữ dist mặc định để không phá Dockerfile COPY output. Không cần dependency hoặc shell ZIP.
|
|
10
|
+
- Local create chưa có host/preview: tự sandbox, trả estimate + needs_cost_approval; agent hỏi human duyệt một lần rồi gọi thật. Preview cùng cấu hình giữ tối đa 10 phút trong MCP session. Host chưa ready và balance thiếu đều chặn tạo thật; sandbox bỏ Billing.
|
|
11
|
+
- Redeploy có local_dir: zip mới, xác minh source=upload, upload và chờ job trước POST deploy. Không local_dir: dùng bản đã upload. 409 upload_required, upload/build lỗi và timeout giữ hướng dẫn tiếp tục app/job hiện có.
|
|
12
|
+
- Upload dùng đúng endpoint cùng origin, chặn redirect, không tự set multipart Content-Type, kiểm thêm max_bytes backend; error sau create giữ app_id để không tạo trùng.
|
|
13
|
+
- Prompt, llms, agent guide và README theo luồng AI làm 99%; duyệt chi phí một lần, trả URL, domain+CNAME, MONA Pass/device flow và nạp ví khi hết credit 20k.
|
|
14
|
+
- Package/binary/MCP handshake 0.4.0; 135 tool, 2 resource, 2 prompt. Không đổi dependency, không publish, không gọi API production.
|
|
15
|
+
|
|
16
|
+
## Gate bàn giao
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
mcp: npm test → 47 tests, 47 pass, 0 fail, 0 skip (gồm TypeScript build)
|
|
20
|
+
cli: npm test → 36 tests, 36 pass, 0 fail, 0 skip
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Test gồm ZIP/CRC/binary/UTF-8/executable bits/ignore/secrets, archive vượt 80 MiB trước HTTP, mock multipart round-trip với native Request/FormData, backend max_bytes/URL không hợp lệ, sandbox/estimate, upload/build lỗi, wait=false/timeout, source mismatch/redeploy và CLI executable → MCP thật → HTTP mock. Test launcher đặt NODE_USE_SYSTEM_CA=0 riêng cho test child vì Node 22 thừa kế biến 1 bị SIGSEGV khi đọc macOS Keychain trong sandbox; runtime sản phẩm không đổi cấu hình CA.
|
|
24
|
+
|
|
25
|
+
Chi tiết hợp đồng và giới hạn: [docs/local-deploy.md](docs/local-deploy.md). Chỉ kiểm chứng offline/mock theo brief; backend upload đang được triển khai song song, chưa kiểm thử production.
|
|
26
|
+
|
|
27
|
+
CODEX DONE
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
# Lịch sử — monacloud-mcp 0.3.0
|
|
2
32
|
|
|
3
33
|
Updated: 2026-09-05 (Asia/Ho_Chi_Minh)
|
|
4
34
|
|
package/dist/clients.js
CHANGED
|
@@ -66,8 +66,28 @@ export class CloudClients {
|
|
|
66
66
|
: nextStep, { status: error.status, details: error.details, requestId: error.requestId });
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
-
balance() {
|
|
70
|
-
|
|
69
|
+
async balance() {
|
|
70
|
+
try {
|
|
71
|
+
return await this.billing('/v1/balance');
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
// Ví local (token vc_live_* hoặc tài khoản chưa chuyển ví chung): billing trả 401/403/404 → đọc số dư qua compute /api/me.
|
|
75
|
+
const status = error instanceof CloudError ? error.status ?? 0 : 0;
|
|
76
|
+
if (!(error instanceof CloudError) || ![401, 403, 404, 502, 503].includes(status))
|
|
77
|
+
throw error;
|
|
78
|
+
const root = asObject(unwrapData(await this.vibecloud('/api/me')));
|
|
79
|
+
const me = asObject(root.user ?? root); // compute trả {user:{credit_vnd, promo_credit_vnd, total_credit_vnd}}
|
|
80
|
+
const balanceVnd = me.total_credit_vnd !== undefined
|
|
81
|
+
? Number(me.total_credit_vnd)
|
|
82
|
+
: Number(me.credit_vnd ?? me.balance_vnd ?? 0) + Number(me.promo_credit_vnd ?? 0);
|
|
83
|
+
return {
|
|
84
|
+
balance_vnd: Number.isFinite(balanceVnd) ? balanceVnd : 0,
|
|
85
|
+
currency: 'VND',
|
|
86
|
+
wallet: 'local',
|
|
87
|
+
source: '/api/me',
|
|
88
|
+
note: 'Số dư ví local MONA Cloud compute (billing.monacloud.vn không nhận token này). Nạp tại console → ví. / Local compute wallet balance.',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
71
91
|
}
|
|
72
92
|
ledger(cursor, limit = 50) {
|
|
73
93
|
return this.billing('/v1/ledger', { query: { cursor, limit } });
|
package/dist/compute.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import type { CloudClients } from './clients.js';
|
|
3
3
|
type Row = Record<string, unknown>;
|
|
4
|
-
export declare const APP_FLOW = "
|
|
4
|
+
export declare const APP_FLOW = "AI l\u00E0m 99%: cloud_app_detect(local_dir) offline \u2192 cloud_app_host_list, cloud_prices/cloud_packages/cloud_plan_list v\u00E0 cloud_balance \u2192 cloud_app_create(local_dir, sandbox=true) n\u1EBFu ch\u01B0a c\u00F3 app host \u0111\u1EC3 \u01B0\u1EDBc t\u00EDnh chi ph\u00ED gi\u1EDD/g\u00F3i \u2192 h\u1ECFi human m\u1ED9t l\u1EA7n duy\u1EC7t chi ph\u00ED n\u1EBFu ch\u01B0a \u0111\u01B0\u1EE3c duy\u1EC7t \u2192 cloud_app_create(local_dir, sandbox=false) \u2192 poll t\u1EDBi done/succeeded \u2192 ki\u1EC3m v\u00E0 tr\u1EA3 URL. N\u1EBFu human c\u00F3 domain: cloud_app_domain_add v\u00E0 h\u01B0\u1EDBng d\u1EABn CNAME t\u1EEB API. Human \u0111\u0103ng k\u00FD MONA Pass b\u1EB1ng device flow, n\u1EA1p ti\u1EC1n khi h\u1EBFt credit 20k; AI l\u00E0m c\u00E1c b\u01B0\u1EDBc c\u00F2n l\u1EA1i. App git cloud_app_create(repo_url) \u0111\u00E3 live; kh\u00F4ng g\u1ECDi agent_deploy cho deploy d\u1EF1 \u00E1n. / Detect locally, estimate, obtain cost approval once, upload and deploy, return URL, then attach an optional domain.";
|
|
5
5
|
export declare function rows(value: unknown, key: string): Row[];
|
|
6
6
|
export declare function planEstimate(clients: CloudClients, code: string, cycle?: string): Promise<{
|
|
7
7
|
plan: Row;
|
package/dist/compute.js
CHANGED
|
@@ -2,7 +2,9 @@ import { z } from 'zod';
|
|
|
2
2
|
import { CloudError, runTool } from './errors.js';
|
|
3
3
|
import { unwrapData } from './http.js';
|
|
4
4
|
import { finishAppJob } from './jobs.js';
|
|
5
|
-
|
|
5
|
+
import { detectProject } from './local.js';
|
|
6
|
+
import { localAppTools } from './local-app.js';
|
|
7
|
+
export const APP_FLOW = 'AI làm 99%: cloud_app_detect(local_dir) offline → cloud_app_host_list, cloud_prices/cloud_packages/cloud_plan_list và cloud_balance → cloud_app_create(local_dir, sandbox=true) nếu chưa có app host để ước tính chi phí giờ/gói → hỏi human một lần duyệt chi phí nếu chưa được duyệt → cloud_app_create(local_dir, sandbox=false) → poll tới done/succeeded → kiểm và trả URL. Nếu human có domain: cloud_app_domain_add và hướng dẫn CNAME từ API. Human đăng ký MONA Pass bằng device flow, nạp tiền khi hết credit 20k; AI làm các bước còn lại. App git cloud_app_create(repo_url) đã live; không gọi agent_deploy cho deploy dự án. / Detect locally, estimate, obtain cost approval once, upload and deploy, return URL, then attach an optional domain.';
|
|
6
8
|
const sandbox = z.boolean().optional().describe('Thử 0đ, không tạo hạ tầng thật / Sandbox, no charge');
|
|
7
9
|
const id = z.string().trim().min(1).max(255);
|
|
8
10
|
const planCode = z.string().trim().min(2).max(64);
|
|
@@ -90,11 +92,22 @@ export function registerComputeTools(server, clients) {
|
|
|
90
92
|
const result = await clients.vibecloud(path, { ...options, ...(isSandbox ? { headers: { 'X-Vibecloud-Sandbox': '1' } } : {}) });
|
|
91
93
|
return isSandbox ? { ...(Array.isArray(result) ? { items: result } : object(result)), sandbox: true } : result;
|
|
92
94
|
};
|
|
93
|
-
|
|
95
|
+
const localDir = z.string().min(1).max(4096).refine((value) => value.trim().length > 0 && !/[\x00-\x1f]/.test(value), 'local_dir không hợp lệ.');
|
|
96
|
+
const local = localAppTools(clients);
|
|
97
|
+
register('cloud_app_detect', 'Nhận diện Node/Next/Vite/Python/PHP/static, port, start, Dockerfile, build_type và tên biến .env.example; hoàn toàn offline, không thực thi code dự án. / Detect a local app without network.', z.object({ local_dir: localDir }).strict(), ({ local_dir }) => detectProject(local_dir));
|
|
98
|
+
register('cloud_app_create', appNotice + APP_FLOW, z.object({ local_dir: localDir.optional(), name: z.string().trim().min(1).max(80).optional(), repo_url: repo.optional(), branch: gitPath.optional(), build_type: z.enum(['dockerfile', 'nixpacks', 'static']).optional(), dockerfile: gitPath.optional(), env: env.default({}), domain: domain.optional(), app_host_id: id.optional(), port: z.number().int().min(1).max(65535).optional(), sandbox, ...poll }).strict()
|
|
99
|
+
.refine((args) => Boolean(args.local_dir) !== Boolean(args.repo_url), 'Chọn đúng một local_dir hoặc repo_url.')
|
|
100
|
+
.refine((args) => !args.local_dir || (!args.branch && !args.app_host_id && (!args.dockerfile || args.dockerfile === 'Dockerfile')), 'Upload local dùng Dockerfile ở root; branch/app_host_id chỉ dùng cho git.'), async (args) => {
|
|
101
|
+
if (args.local_dir)
|
|
102
|
+
return local.create(args);
|
|
103
|
+
const { sandbox: requested, wait, interval_sec, timeout_sec, local_dir, name, ...fields } = args;
|
|
104
|
+
const body = { ...fields, branch: fields.branch || 'main', build_type: fields.build_type || 'dockerfile', dockerfile: fields.dockerfile || 'Dockerfile', port: fields.port || 3000 };
|
|
105
|
+
return finishAppJob(clients, await clients.guardedVibecloud('/api/apps', { method: 'POST', body }, requested), clients.sandboxEnabled(requested), wait, interval_sec, timeout_sec);
|
|
106
|
+
});
|
|
94
107
|
register('cloud_app_list', appNotice + 'Liệt kê app trước khi tạo để tránh trùng. / List apps.', z.object({ sandbox }).strict(), ({ sandbox }) => compute('/api/apps', {}, sandbox));
|
|
95
108
|
register('cloud_app_host_list', appNotice + 'Đọc app host; chưa có host thì sandbox cloud_app_create trước để ước tính. / List app hosts.', z.object({ sandbox }).strict(), ({ sandbox }) => compute('/api/app-hosts', {}, sandbox));
|
|
96
109
|
register('cloud_app_get', appNotice + 'Đọc status, URL, lần deploy và app host. / Inspect app.', appId, ({ app_id, sandbox }) => compute(`/api/apps/${encodeURIComponent(app_id)}`, {}, sandbox));
|
|
97
|
-
register('cloud_app_deploy', appNotice + '
|
|
110
|
+
register('cloud_app_deploy', appNotice + 'App upload: local_dir đóng ZIP mới → upload → chờ job → deploy; bỏ local_dir để redeploy bản đã upload. / Redeploy uploaded source or a git app and poll.', appId.extend({ local_dir: localDir.optional(), ...poll }), async (args) => args.local_dir ? local.deploy(args) : finishAppJob(clients, await clients.guardedVibecloud(`/api/apps/${encodeURIComponent(args.app_id)}/deploy`, { method: 'POST' }, args.sandbox), clients.sandboxEnabled(args.sandbox), args.wait, args.interval_sec, args.timeout_sec));
|
|
98
111
|
register('cloud_app_env_set', appNotice + 'Thay toàn bộ env sau khi được duyệt, gửi đầy đủ map cần giữ; không log secret. Gọi cloud_app_deploy sau đó để áp dụng. / Set app environment.', appId.extend({ env }), ({ app_id, env, sandbox }) => compute(`/api/apps/${encodeURIComponent(app_id)}/env`, { method: 'PUT', body: { env } }, sandbox));
|
|
99
112
|
register('cloud_app_domain_add', appNotice + 'Thêm domain đã được duyệt, trả hướng dẫn CNAME từ API; chờ DNS trước kiểm HTTPS. / Attach custom domain.', appId.extend({ host: domain }), ({ app_id, host, sandbox }) => compute(`/api/apps/${encodeURIComponent(app_id)}/domains`, { method: 'POST', body: { host } }, sandbox));
|
|
100
113
|
register('cloud_app_logs', appNotice + 'Đọc tối đa 500 dòng log; có thể chứa secret, không đưa nguyên log ra công khai. / Read deployment logs.', appId.extend({ deployment: id.optional() }), ({ app_id, deployment, sandbox }) => compute(`/api/apps/${encodeURIComponent(app_id)}/logs`, { query: { deployment } }, sandbox));
|
package/dist/http.js
CHANGED
|
@@ -27,9 +27,11 @@ export function apiError(response, body) {
|
|
|
27
27
|
if (response.status === 401 || response.status === 403) {
|
|
28
28
|
return new CloudError(code, `Token không có quyền hoặc đã hết hạn. ${rawMessage}`, 'Chạy `monacloud-mcp login` rồi thử lại; kiểm tra audience/scope nếu lỗi vẫn còn.', { status: response.status, details: body, requestId });
|
|
29
29
|
}
|
|
30
|
-
return new CloudError(code, rawMessage,
|
|
31
|
-
? '
|
|
32
|
-
:
|
|
30
|
+
return new CloudError(code, rawMessage, code === 'upload_required'
|
|
31
|
+
? 'Gọi cloud_app_deploy với app_id hiện có và local_dir để upload source; không tạo app mới.'
|
|
32
|
+
: response.status >= 500
|
|
33
|
+
? 'Kiểm tra monacloud://status rồi thử lại.'
|
|
34
|
+
: 'Kiểm tra tham số theo mô tả tool rồi gọi lại.', { status: response.status, details: body, requestId });
|
|
33
35
|
}
|
|
34
36
|
export async function requestJson(url, options = {}) {
|
|
35
37
|
const parsedUrl = new URL(url);
|
|
@@ -42,14 +44,16 @@ export async function requestJson(url, options = {}) {
|
|
|
42
44
|
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
|
43
45
|
...options.headers,
|
|
44
46
|
};
|
|
45
|
-
|
|
47
|
+
const multipart = options.body instanceof FormData;
|
|
48
|
+
if (options.body !== undefined && !multipart)
|
|
46
49
|
headers['Content-Type'] = 'application/json';
|
|
47
50
|
let response;
|
|
48
51
|
try {
|
|
49
52
|
response = await (options.fetchImpl || fetch)(parsedUrl, {
|
|
50
53
|
method: options.method || 'GET',
|
|
51
54
|
headers,
|
|
52
|
-
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
55
|
+
body: multipart ? options.body : options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
56
|
+
...(multipart ? { redirect: 'error' } : {}),
|
|
53
57
|
...(options.timeoutMs ? { signal: AbortSignal.timeout(options.timeoutMs) } : {}),
|
|
54
58
|
});
|
|
55
59
|
}
|
package/dist/index.js
CHANGED
package/dist/jobs.d.ts
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
1
|
import type { CloudClients } from './clients.js';
|
|
2
2
|
export declare const TERMINAL_JOBS: Set<string>;
|
|
3
3
|
export declare function pollJob(clients: CloudClients, jobId: string, wait: boolean, intervalSeconds: number, timeoutSeconds: number, sandbox: boolean): Promise<Record<string, unknown>>;
|
|
4
|
-
export declare function finishAppJob(clients: CloudClients, created: unknown, sandbox: boolean, wait: boolean, interval: number, timeout: number): Promise<
|
|
5
|
-
job_id?: string | undefined;
|
|
6
|
-
estimate?: {} | undefined;
|
|
7
|
-
app_id?: string | undefined;
|
|
8
|
-
url?: string | undefined;
|
|
9
|
-
application_id?: string | undefined;
|
|
10
|
-
sandbox?: boolean | undefined;
|
|
11
|
-
}>;
|
|
4
|
+
export declare function finishAppJob(clients: CloudClients, created: unknown, sandbox: boolean, wait: boolean, interval: number, timeout: number): Promise<Record<string, unknown>>;
|
package/dist/jobs.js
CHANGED
|
@@ -37,6 +37,8 @@ export async function finishAppJob(clients, created, sandbox, wait, interval, ti
|
|
|
37
37
|
...(result.estimated_app_host ? { estimate: result.estimated_app_host } : {}),
|
|
38
38
|
...(typeof result.app_id === 'string' ? { app_id: result.app_id } : {}),
|
|
39
39
|
...(typeof result.url === 'string' ? { url: result.url } : {}),
|
|
40
|
+
...(result.build !== undefined ? { build: result.build } : {}),
|
|
41
|
+
...(typeof result.seconds === 'number' ? { seconds: result.seconds } : {}),
|
|
40
42
|
...(typeof result.application_id === 'string' ? { application_id: result.application_id } : {}),
|
|
41
43
|
...(sandbox ? { sandbox: true } : {}) };
|
|
42
44
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CloudClients } from './clients.js';
|
|
2
|
+
type Row = Record<string, any>;
|
|
3
|
+
export declare function localAppTools(clients: CloudClients): {
|
|
4
|
+
create(args: Row): Promise<{
|
|
5
|
+
sandbox: boolean;
|
|
6
|
+
needs_cost_approval: boolean;
|
|
7
|
+
next_step: string;
|
|
8
|
+
} | {
|
|
9
|
+
estimate?: {} | undefined;
|
|
10
|
+
}>;
|
|
11
|
+
deploy(args: Row): Promise<{
|
|
12
|
+
app_id: any;
|
|
13
|
+
phase: string;
|
|
14
|
+
next_step: string;
|
|
15
|
+
} | {
|
|
16
|
+
app_id: any;
|
|
17
|
+
build: unknown;
|
|
18
|
+
seconds: {};
|
|
19
|
+
}>;
|
|
20
|
+
};
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { CloudError } from './errors.js';
|
|
2
|
+
import { unwrapData } from './http.js';
|
|
3
|
+
import { finishAppJob } from './jobs.js';
|
|
4
|
+
import { archiveProject, detectProject, MAX_ARCHIVE_BYTES } from './local.js';
|
|
5
|
+
const object = (value) => value && typeof value === 'object' ? value : {};
|
|
6
|
+
const data = (value) => object(unwrapData(value));
|
|
7
|
+
const ready = (host) => host.status === 'active' && (!host.power_state || host.power_state === 'running') && (!host.app_host_status || host.app_host_status === 'ready');
|
|
8
|
+
export function localAppTools(clients) {
|
|
9
|
+
// Preview evidence is scoped to this MCP session and exact payload, expires after ten minutes.
|
|
10
|
+
// This is an estimate cache, not a substitute for the agent obtaining human cost approval.
|
|
11
|
+
const previews = new Map();
|
|
12
|
+
const finish = async (response, args, sandbox) => finishAppJob(clients, response, sandbox, args.wait, args.interval_sec, args.timeout_sec);
|
|
13
|
+
async function upload(appId, archive, sandbox, start) {
|
|
14
|
+
const path = `/api/apps/${encodeURIComponent(appId)}/upload`;
|
|
15
|
+
if (start) {
|
|
16
|
+
const limit = start.max_bytes;
|
|
17
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || typeof start.upload_url !== 'string')
|
|
18
|
+
throw new CloudError('invalid_upload_response', 'API thiếu upload_url/max_bytes hợp lệ.', `Đọc cloud_app_get app_id=${appId}; không tạo app mới.`);
|
|
19
|
+
let valid = false;
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(start.upload_url, clients.config.vibecloudApi);
|
|
22
|
+
const expected = new URL(`${clients.config.vibecloudApi}${path}`);
|
|
23
|
+
valid = url.href === expected.href;
|
|
24
|
+
}
|
|
25
|
+
catch { /* Do not forward project files or bearer tokens to another destination. */ }
|
|
26
|
+
if (!valid)
|
|
27
|
+
throw new CloudError('invalid_upload_url', 'upload_url không khớp endpoint upload của app trên Compute API.', `Đọc cloud_app_get app_id=${appId}; không tạo app mới.`);
|
|
28
|
+
if (archive.bytes.length > Math.min(limit, MAX_ARCHIVE_BYTES))
|
|
29
|
+
throw new CloudError('archive_too_large', `ZIP ${archive.bytes.length} bytes vượt max_bytes=${Math.min(limit, MAX_ARCHIVE_BYTES)} từ API.`, `Giảm dung lượng rồi cloud_app_deploy app_id=${appId} với local_dir; không tạo app mới.`);
|
|
30
|
+
}
|
|
31
|
+
const form = new FormData();
|
|
32
|
+
form.set('archive', new Blob([new Uint8Array(archive.bytes)], { type: 'application/zip' }), 'project.zip');
|
|
33
|
+
form.set('build_path', archive.build_path);
|
|
34
|
+
return clients.vibecloud(path, { method: 'POST', body: form, timeoutMs: 120_000,
|
|
35
|
+
...(sandbox ? { headers: { 'X-Vibecloud-Sandbox': '1' } } : {}) });
|
|
36
|
+
}
|
|
37
|
+
async function submit(payload, archive, args, sandbox) {
|
|
38
|
+
const started = Date.now();
|
|
39
|
+
const created = data(await clients.guardedVibecloud('/api/apps', { method: 'POST', body: payload }, sandbox));
|
|
40
|
+
// Existing sandbox implementations may return an estimate job without an upload slot.
|
|
41
|
+
if (sandbox && !created.upload_url)
|
|
42
|
+
return finish(created, args, true);
|
|
43
|
+
if (typeof created.id !== 'string' || !created.id)
|
|
44
|
+
throw new CloudError('invalid_upload_response', 'API không trả app id cho source=upload.', 'Đọc cloud_app_list trước khi thử lại; không tạo trùng app.');
|
|
45
|
+
const appId = created.id;
|
|
46
|
+
try {
|
|
47
|
+
const result = await finish(await upload(appId, archive, sandbox, created), args, sandbox);
|
|
48
|
+
return { ...result, app_id: appId, build: result.build ?? result.status, seconds: result.seconds ?? (Date.now() - started) / 1000 };
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error instanceof CloudError)
|
|
52
|
+
throw new CloudError(error.code, error.message, `${error.nextStep} App đã tạo: app_id=${appId}; sửa rồi cloud_app_deploy với local_dir, không gọi cloud_app_create lại.`, { status: error.status, requestId: error.requestId });
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
async create(args) {
|
|
58
|
+
const detected = await detectProject(args.local_dir);
|
|
59
|
+
const archive = await archiveProject(detected.local_dir); // Validate and enforce 80 MiB before any HTTP.
|
|
60
|
+
const payload = { source: 'upload', name: args.name || detected.name, build_type: args.build_type || detected.build_type,
|
|
61
|
+
env: args.env || {}, port: args.port || detected.port, ...(args.domain ? { domain: args.domain } : {}) };
|
|
62
|
+
const key = JSON.stringify({ directory: detected.local_dir, ...payload });
|
|
63
|
+
const sandbox = clients.sandboxEnabled(args.sandbox);
|
|
64
|
+
let estimate;
|
|
65
|
+
if (!sandbox) {
|
|
66
|
+
const hostResult = unwrapData(await clients.vibecloud('/api/app-hosts'));
|
|
67
|
+
const hosts = Array.isArray(hostResult) ? hostResult : object(hostResult).app_hosts ?? object(hostResult).items;
|
|
68
|
+
if (!Array.isArray(hosts))
|
|
69
|
+
throw new CloudError('invalid_app_hosts', 'Không đọc được danh sách app host.', 'Gọi cloud_app_host_list rồi thử lại.');
|
|
70
|
+
if (!hosts.some(ready)) {
|
|
71
|
+
if (hosts.length)
|
|
72
|
+
throw new CloudError('app_host_not_ready', 'App host hiện có chưa sẵn sàng.', 'Đọc cloud_app_host_list và start host trước khi deploy.');
|
|
73
|
+
const cached = previews.get(key);
|
|
74
|
+
if (!cached || Date.now() - cached.at > 600_000) {
|
|
75
|
+
const preview = await submit(payload, archive, { ...args, wait: true }, true);
|
|
76
|
+
estimate = preview.estimate;
|
|
77
|
+
if (estimate && ['done', 'succeeded'].includes(String(preview.status)))
|
|
78
|
+
previews.set(key, { at: Date.now(), estimate });
|
|
79
|
+
return { ...preview, sandbox: true, needs_cost_approval: true,
|
|
80
|
+
next_step: 'Báo estimate chi phí app host theo giờ/gói, hỏi human duyệt một lần; sau khi đã duyệt gọi cloud_app_create với cùng local_dir và sandbox=false. Sandbox chưa phải website thật.' };
|
|
81
|
+
}
|
|
82
|
+
estimate = cached.estimate;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const result = await submit(payload, archive, args, sandbox);
|
|
86
|
+
if (sandbox && result.estimate && ['done', 'succeeded'].includes(String(result.status)))
|
|
87
|
+
previews.set(key, { at: Date.now(), estimate: result.estimate });
|
|
88
|
+
return { ...result, ...(estimate ? { estimate } : {}) };
|
|
89
|
+
},
|
|
90
|
+
async deploy(args) {
|
|
91
|
+
const started = Date.now(), sandbox = clients.sandboxEnabled(args.sandbox);
|
|
92
|
+
const archive = await archiveProject(args.local_dir);
|
|
93
|
+
const path = `/api/apps/${encodeURIComponent(args.app_id)}`;
|
|
94
|
+
const app = data(await clients.vibecloud(path, sandbox ? { headers: { 'X-Vibecloud-Sandbox': '1' } } : {}));
|
|
95
|
+
if (app.source !== 'upload')
|
|
96
|
+
throw new CloudError('app_source_mismatch', 'local_dir chỉ dùng để redeploy app có source=upload.', 'Tạo app local bằng cloud_app_create hoặc bỏ local_dir để redeploy repo git.');
|
|
97
|
+
if (!sandbox)
|
|
98
|
+
await clients.spendGuard();
|
|
99
|
+
// The deploy endpoint must not race the upload job, even with wait=false for the final deploy.
|
|
100
|
+
const uploaded = await finish(await upload(args.app_id, archive, sandbox), { ...args, wait: true }, sandbox);
|
|
101
|
+
if (!['done', 'succeeded'].includes(String(uploaded.status)))
|
|
102
|
+
return { ...uploaded, app_id: args.app_id, phase: 'upload',
|
|
103
|
+
next_step: `Poll cloud_job_status job_id=${uploaded.job_id}; khi done/succeeded gọi cloud_app_deploy app_id=${args.app_id} không có local_dir.` };
|
|
104
|
+
const result = await finish(await clients.guardedVibecloud(`${path}/deploy`, { method: 'POST' }, sandbox), args, sandbox);
|
|
105
|
+
return { ...result, app_id: args.app_id, build: result.build ?? result.status, seconds: result.seconds ?? (Date.now() - started) / 1000 };
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
package/dist/local.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const MAX_ARCHIVE_BYTES: number;
|
|
2
|
+
export declare function projectRoot(directory: string): Promise<string>;
|
|
3
|
+
export declare function detectProject(directory: string): Promise<{
|
|
4
|
+
local_dir: string;
|
|
5
|
+
name: string;
|
|
6
|
+
stack: string;
|
|
7
|
+
port: number;
|
|
8
|
+
start_command: string | null;
|
|
9
|
+
dockerfile: boolean;
|
|
10
|
+
build_type: string;
|
|
11
|
+
env_required: string[];
|
|
12
|
+
warnings: string[];
|
|
13
|
+
network: boolean;
|
|
14
|
+
}>;
|
|
15
|
+
export declare function archiveProject(directory: string, limit?: number): Promise<{
|
|
16
|
+
bytes: Buffer<ArrayBuffer>;
|
|
17
|
+
file_count: number;
|
|
18
|
+
build_path: string;
|
|
19
|
+
}>;
|
package/dist/local.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open, readdir, realpath } from 'node:fs/promises';
|
|
3
|
+
import { basename, join, resolve, sep } from 'node:path';
|
|
4
|
+
import { createDeflateRaw } from 'node:zlib';
|
|
5
|
+
import { CloudError } from './errors.js';
|
|
6
|
+
export const MAX_ARCHIVE_BYTES = 80 * 1024 * 1024;
|
|
7
|
+
const fail = (code, message) => { throw new CloudError(code, message, 'Kiểm tra local_dir và ignore rules rồi thử lại; không cần git remote.'); };
|
|
8
|
+
export async function projectRoot(directory) {
|
|
9
|
+
try {
|
|
10
|
+
const root = await realpath(resolve(directory));
|
|
11
|
+
if (!(await lstat(root)).isDirectory())
|
|
12
|
+
return fail('invalid_local_dir', 'local_dir phải là thư mục dự án.');
|
|
13
|
+
return root;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error instanceof CloudError)
|
|
17
|
+
throw error;
|
|
18
|
+
return fail('invalid_local_dir', 'Không đọc được thư mục local_dir.');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Never follow a project symlink to credentials or files outside the chosen tree.
|
|
22
|
+
async function readText(root, name) {
|
|
23
|
+
let file;
|
|
24
|
+
try {
|
|
25
|
+
if (!(await lstat(join(root, name))).isFile())
|
|
26
|
+
return undefined;
|
|
27
|
+
file = await open(join(root, name), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
28
|
+
const info = await file.stat();
|
|
29
|
+
if (!info.isFile())
|
|
30
|
+
return undefined;
|
|
31
|
+
if (info.size > 1024 * 1024)
|
|
32
|
+
return fail('local_metadata_too_large', `${name} vượt 1 MiB; không thể đọc an toàn metadata/ignore rules.`);
|
|
33
|
+
return await file.readFile('utf8');
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (['ENOENT', 'ELOOP'].includes(String(error.code)))
|
|
37
|
+
return undefined;
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await file?.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function detectProject(directory) {
|
|
45
|
+
const root = await projectRoot(directory);
|
|
46
|
+
const files = new Set((await readdir(root, { withFileTypes: true })).filter((f) => f.isFile()).map((f) => f.name));
|
|
47
|
+
const packageText = await readText(root, 'package.json');
|
|
48
|
+
let pkg = {};
|
|
49
|
+
if (packageText) {
|
|
50
|
+
try {
|
|
51
|
+
pkg = JSON.parse(packageText) || {};
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return fail('invalid_package_json', 'package.json không phải JSON hợp lệ.');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
58
|
+
const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
59
|
+
const docker = await readText(root, 'Dockerfile');
|
|
60
|
+
let stack = 'static', port = 80, start = null;
|
|
61
|
+
const warnings = [];
|
|
62
|
+
if (packageText) {
|
|
63
|
+
stack = deps.next ? 'next' : deps.vite ? 'vite' : 'node';
|
|
64
|
+
port = stack === 'vite' ? 4173 : 3000;
|
|
65
|
+
start = typeof scripts.start === 'string' ? 'npm run start'
|
|
66
|
+
: stack === 'vite' ? 'npm run build && npx --no-install vite preview --host 0.0.0.0'
|
|
67
|
+
: files.has('server.js') ? 'node server.js' : files.has('index.js') ? 'node index.js' : null;
|
|
68
|
+
if (!start)
|
|
69
|
+
warnings.push('Chưa tìm thấy lệnh start; AI cần bổ sung scripts.start hoặc Dockerfile.');
|
|
70
|
+
}
|
|
71
|
+
else if (files.has('requirements.txt') || files.has('pyproject.toml') || files.has('manage.py') || files.has('app.py')) {
|
|
72
|
+
stack = 'python';
|
|
73
|
+
port = 8000;
|
|
74
|
+
start = files.has('manage.py') ? 'gunicorn <project>.wsgi:application --bind 0.0.0.0:8000' : files.has('app.py') ? 'gunicorn app:app --bind 0.0.0.0:8000' : null;
|
|
75
|
+
warnings.push('Xác minh module Python, framework và dependency trước khi dùng lệnh start gợi ý.');
|
|
76
|
+
}
|
|
77
|
+
else if (files.has('composer.json') || files.has('index.php')) {
|
|
78
|
+
stack = 'php';
|
|
79
|
+
port = 8080;
|
|
80
|
+
start = `php -S 0.0.0.0:8080${files.has('artisan') ? ' -t public' : ''}`;
|
|
81
|
+
warnings.push('Lệnh PHP chỉ là gợi ý kiểm thử; cấu hình web server production trong Dockerfile.');
|
|
82
|
+
}
|
|
83
|
+
const startScript = typeof scripts.start === 'string' ? scripts.start : '';
|
|
84
|
+
const configuredPort = startScript.match(/(?:--port(?:=|\s+)|-p\s+|\bPORT=)(\d+)/)?.[1];
|
|
85
|
+
const exposed = docker?.match(/^\s*EXPOSE\s+(\d+)(?:\/tcp)?\b/im)?.[1];
|
|
86
|
+
const guessed = Number(exposed || configuredPort);
|
|
87
|
+
if (guessed >= 1 && guessed <= 65535)
|
|
88
|
+
port = guessed;
|
|
89
|
+
const envExample = await readText(root, '.env.example');
|
|
90
|
+
const envRequired = [...new Set([...envExample?.matchAll(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/gm) || []].map((m) => m[1]))];
|
|
91
|
+
return { local_dir: root, name: basename(root).toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'app',
|
|
92
|
+
stack, port, start_command: start, dockerfile: docker !== undefined, build_type: docker !== undefined ? 'dockerfile' : stack === 'static' ? 'static' : 'nixpacks',
|
|
93
|
+
env_required: envRequired, warnings, network: false };
|
|
94
|
+
}
|
|
95
|
+
function glob(pattern) {
|
|
96
|
+
let expression = '';
|
|
97
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
98
|
+
const c = pattern[i];
|
|
99
|
+
if (c === '*') {
|
|
100
|
+
if (pattern[i + 1] === '*') {
|
|
101
|
+
while (pattern[i + 1] === '*')
|
|
102
|
+
i++;
|
|
103
|
+
if (pattern[i + 1] === '/') {
|
|
104
|
+
expression += '(?:.*/)?';
|
|
105
|
+
i++;
|
|
106
|
+
}
|
|
107
|
+
else
|
|
108
|
+
expression += '.*';
|
|
109
|
+
}
|
|
110
|
+
else
|
|
111
|
+
expression += '[^/]*';
|
|
112
|
+
}
|
|
113
|
+
else if (c === '?')
|
|
114
|
+
expression += '[^/]';
|
|
115
|
+
else if (c === '[') {
|
|
116
|
+
const end = pattern.indexOf(']', i + 1);
|
|
117
|
+
if (end > i + 1) {
|
|
118
|
+
expression += `[${pattern.slice(i + 1, end).replace(/^!/, '^')}]`;
|
|
119
|
+
i = end;
|
|
120
|
+
}
|
|
121
|
+
else
|
|
122
|
+
expression += '\\[';
|
|
123
|
+
}
|
|
124
|
+
else if (c === '\\' && i + 1 < pattern.length)
|
|
125
|
+
expression += pattern[++i].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
126
|
+
else
|
|
127
|
+
expression += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
128
|
+
}
|
|
129
|
+
return expression;
|
|
130
|
+
}
|
|
131
|
+
function parseIgnore(text, base = '', docker = false) {
|
|
132
|
+
return (text || '').split(/\r?\n/).flatMap((line) => {
|
|
133
|
+
line = docker ? line.trim() : line.replace(/(?<!\\)\s+$/, '');
|
|
134
|
+
if (!line || line.startsWith('#') || line === '.')
|
|
135
|
+
return [];
|
|
136
|
+
const negate = line.startsWith('!');
|
|
137
|
+
if (negate)
|
|
138
|
+
line = line.slice(1);
|
|
139
|
+
const directoryOnly = !docker && line.endsWith('/');
|
|
140
|
+
const anchored = line.startsWith('/');
|
|
141
|
+
line = line.replace(/^\/+|\/+$/g, '');
|
|
142
|
+
if (!line)
|
|
143
|
+
return [];
|
|
144
|
+
const prefix = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
145
|
+
const anywhere = !docker && !anchored && !line.includes('/');
|
|
146
|
+
try {
|
|
147
|
+
return [{ regex: new RegExp(`^${prefix}${anywhere ? '(?:.*/)?' : ''}${glob(line)}$`), negate, directoryOnly }];
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return fail('invalid_ignore', 'Ignore pattern không hợp lệ.');
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
function ignored(path, directory, rules) {
|
|
155
|
+
let excluded = false;
|
|
156
|
+
for (const rule of rules)
|
|
157
|
+
if ((!rule.directoryOnly || directory) && rule.regex.test(path))
|
|
158
|
+
excluded = !rule.negate;
|
|
159
|
+
return excluded;
|
|
160
|
+
}
|
|
161
|
+
const sensitive = (name) => name === 'node_modules' || name === '.git' || /^\.env/i.test(name) || /\.pem$/i.test(name);
|
|
162
|
+
const crcTable = Array.from({ length: 256 }, (_, value) => {
|
|
163
|
+
for (let n = 0; n < 8; n++)
|
|
164
|
+
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
|
165
|
+
return value >>> 0;
|
|
166
|
+
});
|
|
167
|
+
// ZIP32 with deflate, UTF-8 filenames and UNIX executable bits. No shell or package install.
|
|
168
|
+
// dist is retained by default: even a Dockerfile may COPY prebuilt output.
|
|
169
|
+
export async function archiveProject(directory, limit = MAX_ARCHIVE_BYTES) {
|
|
170
|
+
const root = await projectRoot(directory);
|
|
171
|
+
const maxBytes = Math.min(MAX_ARCHIVE_BYTES, limit);
|
|
172
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)
|
|
173
|
+
return fail('invalid_upload_limit', 'max_bytes không hợp lệ.');
|
|
174
|
+
const local = [], central = [];
|
|
175
|
+
let offset = 0, centralSize = 0, count = 0;
|
|
176
|
+
const check = (size) => {
|
|
177
|
+
if (size > maxBytes)
|
|
178
|
+
fail('archive_too_large', `ZIP vượt giới hạn ${maxBytes} bytes (${(maxBytes / 1024 / 1024).toFixed(2)} MiB; tối đa 80 MiB). Bỏ file lớn bằng ignore rules.`);
|
|
179
|
+
};
|
|
180
|
+
const dockerRules = parseIgnore(await readText(root, '.dockerignore'), '', true);
|
|
181
|
+
async function walk(relative, inheritedGit, dockerParentIgnored = false) {
|
|
182
|
+
const dir = join(root, relative);
|
|
183
|
+
const gitRules = [...inheritedGit, ...parseIgnore(await readText(dir, '.gitignore'), relative)];
|
|
184
|
+
const entries = (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
if (sensitive(entry.name) || entry.isSymbolicLink() || (!entry.isFile() && !entry.isDirectory()))
|
|
187
|
+
continue;
|
|
188
|
+
const path = relative + entry.name;
|
|
189
|
+
if (/[\x00-\x1f\\]/.test(path))
|
|
190
|
+
fail('invalid_archive_path', 'Tên file chứa ký tự không an toàn cho ZIP.');
|
|
191
|
+
const directory = entry.isDirectory();
|
|
192
|
+
if (ignored(path, directory, gitRules))
|
|
193
|
+
continue;
|
|
194
|
+
// Docker allows !dir/file even under an excluded directory; Git requires re-including the parent.
|
|
195
|
+
let dockerIgnored = dockerParentIgnored;
|
|
196
|
+
for (const rule of dockerRules)
|
|
197
|
+
if (rule.regex.test(path))
|
|
198
|
+
dockerIgnored = !rule.negate;
|
|
199
|
+
if (directory) {
|
|
200
|
+
if (!dockerIgnored || dockerRules.some((rule) => rule.negate))
|
|
201
|
+
await walk(`${path}/`, gitRules, dockerIgnored);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (dockerIgnored)
|
|
205
|
+
continue;
|
|
206
|
+
const absolute = await realpath(join(root, path));
|
|
207
|
+
if (!absolute.startsWith(root + sep))
|
|
208
|
+
fail('invalid_archive_path', 'File nằm ngoài local_dir.');
|
|
209
|
+
const file = await open(join(root, path), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
210
|
+
try {
|
|
211
|
+
const info = await file.stat();
|
|
212
|
+
if (!info.isFile())
|
|
213
|
+
continue;
|
|
214
|
+
if (info.size > 0xffffffff || count >= 65535)
|
|
215
|
+
fail('archive_too_large', 'Dự án vượt giới hạn ZIP32 (4 GiB/file hoặc 65535 file).');
|
|
216
|
+
const name = Buffer.from(path);
|
|
217
|
+
if (name.length > 65535)
|
|
218
|
+
fail('invalid_archive_path', 'Tên file quá dài cho ZIP.');
|
|
219
|
+
const chunks = [];
|
|
220
|
+
let size = 0, compressedSize = 0, crc = 0xffffffff;
|
|
221
|
+
const stream = file.createReadStream({ autoClose: false });
|
|
222
|
+
const deflate = createDeflateRaw();
|
|
223
|
+
stream.on('data', (chunk) => {
|
|
224
|
+
size += chunk.length;
|
|
225
|
+
for (const byte of chunk)
|
|
226
|
+
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
227
|
+
});
|
|
228
|
+
stream.on('error', (error) => deflate.destroy(error));
|
|
229
|
+
stream.pipe(deflate);
|
|
230
|
+
try {
|
|
231
|
+
for await (const chunk of deflate) {
|
|
232
|
+
compressedSize += chunk.length;
|
|
233
|
+
check(offset + compressedSize + centralSize + 30 + 46 + name.length * 2 + 22);
|
|
234
|
+
chunks.push(chunk);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
stream.destroy();
|
|
239
|
+
deflate.destroy();
|
|
240
|
+
}
|
|
241
|
+
if (size !== info.size)
|
|
242
|
+
fail('local_file_changed', 'File thay đổi trong lúc đóng ZIP; dừng tác vụ ghi file rồi thử lại.');
|
|
243
|
+
const checksum = (crc ^ 0xffffffff) >>> 0;
|
|
244
|
+
const header = Buffer.alloc(30);
|
|
245
|
+
header.writeUInt32LE(0x04034b50);
|
|
246
|
+
header.writeUInt16LE(20, 4);
|
|
247
|
+
header.writeUInt16LE(0x800, 6);
|
|
248
|
+
header.writeUInt16LE(8, 8);
|
|
249
|
+
header.writeUInt16LE(33, 12);
|
|
250
|
+
header.writeUInt32LE(checksum, 14);
|
|
251
|
+
header.writeUInt32LE(compressedSize, 18);
|
|
252
|
+
header.writeUInt32LE(size, 22);
|
|
253
|
+
header.writeUInt16LE(name.length, 26);
|
|
254
|
+
const index = Buffer.alloc(46);
|
|
255
|
+
index.writeUInt32LE(0x02014b50);
|
|
256
|
+
index.writeUInt16LE(0x314, 4);
|
|
257
|
+
header.copy(index, 6, 4, 30);
|
|
258
|
+
index.writeUInt32LE(((info.mode & 0xffff) * 65536) >>> 0, 38);
|
|
259
|
+
index.writeUInt32LE(offset, 42);
|
|
260
|
+
local.push(header, name, ...chunks);
|
|
261
|
+
central.push(index, name);
|
|
262
|
+
offset += header.length + name.length + compressedSize;
|
|
263
|
+
centralSize += index.length + name.length;
|
|
264
|
+
count++;
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
await file.close();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
await walk('', []);
|
|
272
|
+
if (!count)
|
|
273
|
+
return fail('empty_archive', 'Không còn file nào sau khi áp dụng ignore rules.');
|
|
274
|
+
const end = Buffer.alloc(22);
|
|
275
|
+
end.writeUInt32LE(0x06054b50);
|
|
276
|
+
end.writeUInt16LE(count, 8);
|
|
277
|
+
end.writeUInt16LE(count, 10);
|
|
278
|
+
end.writeUInt32LE(centralSize, 12);
|
|
279
|
+
end.writeUInt32LE(offset, 16);
|
|
280
|
+
check(offset + centralSize + end.length);
|
|
281
|
+
return { bytes: Buffer.concat([...local, ...central, end]), file_count: count, build_path: '.' };
|
|
282
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -90,7 +90,7 @@ export function createServer(dependencies = {}) {
|
|
|
90
90
|
const auth = new AuthManager(config, env, fetchImpl);
|
|
91
91
|
const clients = new CloudClients(config, auth, fetchImpl);
|
|
92
92
|
const catalog = new TemplateCatalog(config, fetchImpl);
|
|
93
|
-
const server = new McpServer({ name: 'monacloud-mcp', version: '0.
|
|
93
|
+
const server = new McpServer({ name: 'monacloud-mcp', version: '0.4.0' }, { instructions: `${ENTITY}\nDùng cloud_* cho tài khoản, ví và hạ tầng; monapay_* cho thu tiền; mail_* để gửi email giao dịch (MONA Mail); agent_* cho catalog. Không bao giờ yêu cầu mật khẩu sản phẩm. Đọc → ước tính → hỏi duyệt nếu chưa được duyệt → làm. Dừng khi cần DNS, nạp tiền, duyệt chi phí, OTP hoặc KYC. ${APP_FLOW}` });
|
|
94
94
|
server.registerTool('cloud_whoami', {
|
|
95
95
|
title: 'Tài khoản MONA Cloud',
|
|
96
96
|
description: 'Xác minh MONA Pass hiện tại. / Return the current MONA Pass profile.',
|
|
@@ -307,7 +307,7 @@ export function createServer(dependencies = {}) {
|
|
|
307
307
|
registerMailTools(server, clients, config);
|
|
308
308
|
server.registerTool('agent_templates_list', {
|
|
309
309
|
title: 'Catalog MONA Agent',
|
|
310
|
-
description: 'Đọc template từ thư mục local, URL catalog hoặc catalog wave 1 tích hợp. Deploy
|
|
310
|
+
description: 'Đọc template từ thư mục local, URL catalog hoặc catalog wave 1 tích hợp. Deploy dự án: cloud_app_detect rồi cloud_app_create(local_dir), sandbox trước nếu chưa có host.',
|
|
311
311
|
}, () => runTool(() => catalog.list()));
|
|
312
312
|
server.registerTool('agent_templates_get', {
|
|
313
313
|
title: 'Chi tiết MONA Agent template',
|
|
@@ -316,7 +316,7 @@ export function createServer(dependencies = {}) {
|
|
|
316
316
|
}, ({ template }) => runTool(() => catalog.get(template)));
|
|
317
317
|
server.registerTool('agent_deploy', {
|
|
318
318
|
title: 'Dùng ngay MONA Agent template',
|
|
319
|
-
description: 'Gọi cùng runtime với cloud_agent_deploy.
|
|
319
|
+
description: 'Gọi cùng runtime với cloud_agent_deploy. Deploy thư mục: cloud_app_detect → cloud_app_create(local_dir); git dùng repo_url (đã live), sandbox trước nếu chưa có host.',
|
|
320
320
|
inputSchema: { template: z.string().regex(/^[a-z0-9][a-z0-9-]{0,79}$/) },
|
|
321
321
|
}, ({ template }) => runTool(() => agentRuntimeStub(template)));
|
|
322
322
|
const imported = importedMonaPayTools(config, env, fetchImpl);
|
|
@@ -347,7 +347,7 @@ export function createServer(dependencies = {}) {
|
|
|
347
347
|
}, async (uri) => ({ contents: [{
|
|
348
348
|
uri: uri.href,
|
|
349
349
|
mimeType: 'text/plain',
|
|
350
|
-
text: `${ENTITY}\n\n${APP_FLOW}\n\nHuman đăng ký MONA Pass, duyệt chi phí, thêm DNS khi cần, nạp tiền và cung cấp OTP/KYC bắt buộc. AI dùng MCP làm phần còn lại.\n\n- cloud_*: tài khoản, ví, ledger, usage, budget, VPS, database, job, vòng đời service, plans, subscriptions, invoices/PDF, credit và apps từ git (
|
|
350
|
+
text: `${ENTITY}\n\n${APP_FLOW}\n\nHuman đăng ký MONA Pass, duyệt chi phí, thêm DNS khi cần, nạp tiền và cung cấp OTP/KYC bắt buộc. AI dùng MCP làm phần còn lại.\n\n- cloud_*: tài khoản, ví, ledger, usage, budget, VPS, database, job, vòng đời service, plans, subscriptions, invoices/PDF, credit và apps từ git hoặc thư mục local (source=upload); cloud_app_detect hoàn toàn offline.\n- monapay_*: nối ngân hàng, checkout/QR, giao dịch, webhook và email.\n- mail_*: tài khoản, domain, API key, gửi mail, trạng thái, webhook, suppression (MONA Mail https://monamail.vn, API https://api.monamail.vn)\n- agent_*: catalog và deploy template.\n\nMONA Cloud: https://monacloud.vn\nCompute API: https://api.monacloud.vn\nMONA Pay: https://monapay.vn\n`,
|
|
351
351
|
}] }));
|
|
352
352
|
server.registerResource('monacloud-status', 'monacloud://status', {
|
|
353
353
|
title: 'Trạng thái hệ MONA Cloud',
|
|
@@ -374,7 +374,7 @@ export function createServer(dependencies = {}) {
|
|
|
374
374
|
role: 'user',
|
|
375
375
|
content: {
|
|
376
376
|
type: 'text',
|
|
377
|
-
text: `Dựng app bán hàng ${app_name || 'của tôi'} bằng ${framework || 'stack phù hợp'} trên MONA Cloud
|
|
377
|
+
text: `Dựng app bán hàng ${app_name || 'của tôi'} bằng ${framework || 'stack phù hợp'} trên MONA Cloud. ${APP_FLOW}\nTheo đúng thứ tự:\n1. Gọi cloud_whoami, cloud_balance, cloud_plan_list, cloud_packages và cloud_prices. Báo ước tính và hỏi duyệt chi phí nếu chưa được duyệt. Nếu thiếu tiền, gọi cloud_topup rồi dừng để người dùng quét VietQR.\n2. Nếu dùng VPS gọi cloud_vps_create và cloud_db_create; nếu deploy dự án dùng cloud_app_detect(local_dir), cloud_app_host_list rồi cloud_app_create(local_dir), sandbox trước nếu chưa có host. Poll từng job bằng cloud_job_status tới done/succeeded.\n3. Gọi monapay_link nếu MONA Pay còn ở lớp chuyển tiếp. Gọi monapay_whoami; nếu chưa có VA, nối ngân hàng bằng chuỗi monapay_link_bank_start → HỎI OTP → verify → notification_register → HỎI OTP lần 2 → verify. Không tự đoán OTP.\n4. Viết endpoint webhook có HMAC và idempotency theo transaction_code; đăng ký bằng monapay_create_webhook, bắn monapay_test_webhook và đọc monapay_webhook_logs.\n5. Tích hợp monapay_create_checkout hoặc monapay_create_qr vào app, chỉ giao hàng sau CHECKOUT_PAID.\n6. Với dự án local, dùng cloud_app_create(local_dir) sau khi duyệt ước tính; git dùng repo_url; kiểm cloud_app_get/cloud_app_logs và health rồi báo URL. Chỉ dùng VPS thủ công khi người dùng chọn. Không yêu cầu người dùng mở dashboard ngoài bước nạp tiền/OTP bắt buộc.`,
|
|
378
378
|
},
|
|
379
379
|
}] }));
|
|
380
380
|
server.registerPrompt('gui-mail-otp-monamail', {
|
package/dist/templates.js
CHANGED
|
@@ -111,7 +111,7 @@ export class TemplateCatalog {
|
|
|
111
111
|
...template,
|
|
112
112
|
source: 'built-in-wave-1-catalog',
|
|
113
113
|
status: 'catalog_only',
|
|
114
|
-
next_step: 'Catalog runtime chưa được phát hành. Deploy
|
|
114
|
+
next_step: 'Catalog runtime chưa được phát hành. Deploy dự án: cloud_app_detect(local_dir), cloud_app_host_list rồi cloud_app_create(local_dir); git dùng repo_url (đã live), sandbox trước nếu chưa có app host; báo ước tính và hỏi duyệt trước khi tạo thật.',
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
117
|
}
|
package/docs/ai-agent.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
Tài liệu này dành cho Claude, Codex, Cursor và agent dùng Model Context Protocol. Mục tiêu là hoàn thành tích hợp end-to-end mà không yêu cầu người dùng mở dashboard; các bước human gồm đăng ký MONA Pass, duyệt chi phí, thêm DNS khi cần, nạp tiền và OTP/KYC bắt buộc.
|
|
4
4
|
|
|
5
|
+
## Luồng chuẩn 0.4.0: AI làm 99%
|
|
6
|
+
|
|
7
|
+
Prompt Claude Code: **“Đưa dự án này lên MONA Cloud, dùng thư mục hiện tại”**.
|
|
8
|
+
|
|
9
|
+
1. `cloud_app_detect({local_dir: "/đường/dẫn/tuyệt/đối"})` nhận diện stack/port/build và tên env hoàn toàn offline. AI sửa start/Dockerfile nếu cần, không chạy lệnh start gợi ý một cách mù quáng, không đọc/in giá trị `.env.example` ra chat.
|
|
10
|
+
2. `cloud_app_list`, `cloud_app_host_list`, `cloud_prices`/`cloud_packages`/`cloud_plan_list` và `cloud_balance`. Chưa có host thì `cloud_app_create` cùng local_dir với `sandbox:true` để lấy `estimate` chi phí giờ/gói. Hỏi human **một lần** duyệt chi phí; đã duyệt thì tiếp tục.
|
|
11
|
+
3. `cloud_app_create({local_dir, name?, env?, sandbox:false})` đóng ZIP và upload. Poll tới done/succeeded, kiểm HTTPS/health rồi trả `{url, app_id, build, seconds}`. Khi response có `sandbox:true`/`needs_cost_approval:true`, đó vẫn là preview; làm theo `next_step` trước khi gọi thật.
|
|
12
|
+
4. Nếu human có domain, `cloud_app_domain_add({app_id, host})`, đưa đúng CNAME do API trả, chờ DNS và kiểm HTTPS. Không tự đoán target CNAME.
|
|
13
|
+
5. Sửa code local: `cloud_app_deploy({app_id, local_dir})`. Chỉ đổi env: `cloud_app_env_set` với map đầy đủ rồi `cloud_app_deploy({app_id})`. Timeout giữ job_id/app_id để tiếp tục, không tạo app mới.
|
|
14
|
+
|
|
15
|
+
Human chỉ cần đăng ký MONA Pass/device flow, duyệt chi phí một lần và nạp tiền khi hết credit 20k; DNS là bước thêm khi dùng domain riêng. AI làm các bước kỹ thuật còn lại. `.env*` kể cả `.env.example` không vào ZIP; truyền secret cần thiết qua `env`, không công khai. Xem [hợp đồng local deploy](local-deploy.md). VPS/database thủ công dưới đây chỉ dùng khi dự án cần hoặc người dùng chọn.
|
|
16
|
+
|
|
5
17
|
## Quy tắc vận hành
|
|
6
18
|
|
|
7
19
|
1. Bắt đầu bằng `cloud_whoami`. Nếu nhận `login_required`, yêu cầu người dùng chạy `monacloud-mcp login`; không hỏi username hoặc password trong chat.
|
|
@@ -119,7 +131,7 @@ Dùng `monapay_create_qr` khi đã có đủ thông tin ACB/VA và cần render
|
|
|
119
131
|
|
|
120
132
|
### 6. Deploy và xác minh
|
|
121
133
|
|
|
122
|
-
Với
|
|
134
|
+
Với dự án local, ưu tiên `cloud_app_detect` rồi `cloud_app_create(local_dir)`; git dùng `repo_url`: đọc `cloud_app_host_list`, sandbox trước nếu chưa có host, báo ước tính và duyệt rồi deploy thật. Dùng secret env, chạy migration và kiểm HTTPS/health. Chỉ triển khai VPS thủ công nếu người dùng chọn. Sau đó:
|
|
123
135
|
|
|
124
136
|
- tạo checkout sandbox;
|
|
125
137
|
- tạo transaction sandbox;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Local deploy 0.4.0
|
|
2
|
+
|
|
3
|
+
Prompt Claude Code: **“Đưa dự án này lên MONA Cloud, dùng thư mục hiện tại”**.
|
|
4
|
+
|
|
5
|
+
`local_dir` nằm trên máy chạy MCP; dùng đường dẫn tuyệt đối khi MCP host có cwd khác editor. Không cần git, remote hoặc commit. CLI cùng máy gọi đúng các tool này qua stdio, dùng cùng cơ chế ZIP và MONA Pass.
|
|
6
|
+
|
|
7
|
+
| Tool | Hợp đồng |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `cloud_app_detect({local_dir})` | Chỉ đọc local: `{local_dir,name,stack,port,start_command,dockerfile,build_type,env_required,warnings,network:false}`. Stack node/next/vite/python/php/static; `env_required` là tên biến trong `.env.example`, không gồm giá trị. Lệnh start là gợi ý cần AI xác minh. |
|
|
10
|
+
| `cloud_app_create({local_dir,name?,build_type?,env?,port?,domain?,sandbox?,wait?,interval_sec?,timeout_sec?})` | POST `/api/apps` JSON `{source:"upload",name,build_type,env,port,domain?}` → `{id,upload_url,max_bytes}` → POST multipart upload → poll job |
|
|
11
|
+
| `cloud_app_deploy({app_id,local_dir?,sandbox?,wait?,interval_sec?,timeout_sec?})` | Có local_dir: GET app, yêu cầu source=upload, ZIP mới → upload → chờ upload done/succeeded → POST deploy → poll. Không local_dir: POST deploy dùng source đã lưu. |
|
|
12
|
+
|
|
13
|
+
Mỗi tool có alias `vibecloud_` cùng schema/handler. Tổng 135 tool, 2 resource, 2 prompt. Phiên bản package, binary và handshake là 0.4.0.
|
|
14
|
+
|
|
15
|
+
Upload gửi đúng POST `/api/apps/{id}/upload`, FormData gồm `archive` (file `project.zip`, MIME `application/zip`) và `build_path` (`.`). ZIP chứa nội dung thư mục ở root, không bọc thêm tên thư mục. Fetch tự sinh multipart boundary, dùng token compute hiện tại; upload không follow redirect. `upload_url` phải đúng endpoint cùng origin, không gửi ZIP/token sang host khác. Nếu server trả `max_bytes` nhỏ hơn giới hạn local, MCP dừng upload và giữ app_id cho lần sửa tiếp.
|
|
16
|
+
|
|
17
|
+
Create upload chờ job upload/build và trả `{url,app_id,build,seconds}` cùng status/job_id. `build` giữ dữ liệu API; nếu API không có thì dùng trạng thái build/job. `seconds` giữ số API trả hoặc thời gian xử lý request khi chưa có. `wait:false` có thể trả job queued và chưa có URL; với redeploy có source mới, MCP vẫn phải chờ job upload trước khi gửi deploy. Job upload timeout trả `phase:"upload"`: poll job đó rồi gọi deploy không local_dir. Không tự retry POST hoặc tạo app khác.
|
|
18
|
+
|
|
19
|
+
## ZIP và nhận diện offline
|
|
20
|
+
|
|
21
|
+
- Không cài dependency, chạy shell, thực thi code dự án hoặc gửi mạng trong detect/ZIP.
|
|
22
|
+
- Giới hạn ZIP local **83,886,080 bytes (80 MiB)**, tính cả header/central directory; kiểm trước HTTP. ZIP32 deflate, UTF-8 filenames, giữ bit executable. Giới hạn ZIP32: 65,535 file, 4 GiB mỗi file.
|
|
23
|
+
- Luôn loại `node_modules`, `.git`, `.env*` (kể cả `.env.example`), `*.pem` ở mọi cấp và mọi symlink. Ignore negation không thể đưa các file này trở lại.
|
|
24
|
+
- Áp dụng `.gitignore` ở root và thư mục con, `.dockerignore` ở root. Hai bộ rule cùng áp dụng: file phải được cả hai cho phép. Hỗ trợ `*`, `**`, `?`, character classes, comments, anchored paths và negation `!`. Git không re-include được file nếu thư mục cha vẫn bị exclude; Docker cho phép `!dir/file` dưới thư mục excluded.
|
|
25
|
+
- `dist` không tự loại: Dockerfile có thể `COPY dist`. Chỉ thêm `dist` vào ignore khi build có thể tạo lại nó. File được ignore theo cấu hình dự án vẫn bị bỏ.
|
|
26
|
+
- Metadata/ignore file tối đa 1 MiB; lỗi đọc/JSON/ignore/ZIP báo lỗi rõ ràng, không upload một phần. Không sinh archive tạm trên đĩa.
|
|
27
|
+
- Dockerfile root gợi ý `dockerfile`; không có Dockerfile thì Node/Next/Vite/Python/PHP gợi ý `nixpacks`, static gợi ý `static`. Port ưu tiên EXPOSE, port trong scripts.start, rồi mặc định stack. AI cần xác minh start và bind `0.0.0.0`.
|
|
28
|
+
|
|
29
|
+
## AI làm 99%, duyệt chi phí một lần
|
|
30
|
+
|
|
31
|
+
Detect → đọc app host/giá giờ hoặc gói và ví → sandbox nếu chưa có host → báo estimate để human duyệt một lần → create local thật → poll, kiểm và trả URL. Host hiện có tiếp tục tính phí theo kỳ đang dùng; host chưa ready thì cần start trước. Human đăng ký MONA Pass/device flow và nạp ví khi hết credit 20k. Có domain riêng: `cloud_app_domain_add`, đưa CNAME từ API và kiểm DNS/HTTPS.
|
|
32
|
+
|
|
33
|
+
Nếu gọi create local thật khi chưa có host và chưa có preview hợp lệ trong MCP session, MCP chỉ chạy sandbox và trả `needs_cost_approval:true`. Preview cùng thư mục/cấu hình có estimate terminal được giữ 10 phút trong session. Sau khi agent đã lấy duyệt chi phí, gọi lại cấu hình đó với `sandbox:false`; nếu preview hết hạn, báo lại estimate trước khi tiếp tục. Sandbox explicit hoặc `MONACLOUD_SANDBOX=1` luôn 0đ, không đọc Billing. Backend sandbox cũ trả thẳng job estimate cũng được hỗ trợ.
|
|
34
|
+
|
|
35
|
+
Lỗi upload/build sau create có app_id trong `next_step`; sửa rồi dùng `cloud_app_deploy` với cùng app_id/local_dir. 409 `upload_required` hướng dẫn upload lại bằng tool deploy. 402 hướng dẫn nạp ví. Không báo URL sandbox hoặc job đang chạy là website thật.
|
|
36
|
+
|
|
37
|
+
## Tương thích git
|
|
38
|
+
|
|
39
|
+
`cloud_app_create({repo_url,branch?,build_type?,dockerfile?,env?,domain?,app_host_id?,port?})` giữ mặc định main/dockerfile/Dockerfile/3000 như 0.3.x. Chọn đúng một `local_dir` hoặc `repo_url`. `branch`, `app_host_id` và Dockerfile tuỳ chỉnh chỉ áp dụng git; backend upload tự chọn host theo tài khoản, dùng Dockerfile root. Không đổi tên field hợp đồng backend trong brief ngày 05/09/2026.
|
|
40
|
+
|
|
41
|
+
Tất cả kiểm chứng bản này dùng local test và HTTP mock; không gọi production và không publish.
|
package/docs/wave-ab.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Wave A/B — gói tháng, hoá đơn và app từ git (0.3.0)
|
|
2
2
|
|
|
3
|
+
Bổ sung 0.4.0: [deploy từ thư mục local và cloud_app_detect](local-deploy.md). Các mặc định repo/branch dưới đây áp dụng cho nguồn git.
|
|
4
|
+
|
|
3
5
|
Luồng mặc định: **đọc → ước tính VND → hỏi duyệt nếu chưa được duyệt → làm → kiểm kết quả**. Các tool dưới đây đều có alias `vibecloud_` cùng hậu tố, schema và hành vi; riêng alias VPS cũ vẫn là `vibecloud_create_vps`.
|
|
4
6
|
|
|
5
7
|
## Bảng tool
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "monacloud-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Unified MCP server for MONA Cloud, MONA Pay and MONA Agent.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "tsc -p tsconfig.json",
|
|
21
|
-
"test": "npm run build && node
|
|
21
|
+
"test": "npm run build && node support/test.mjs",
|
|
22
22
|
"start": "node dist/index.js"
|
|
23
23
|
},
|
|
24
24
|
"engines": {
|