@nextxai/cli 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.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/ai/client.js +221 -0
- package/dist/auth/ci.js +64 -0
- package/dist/auth/credentials.js +192 -0
- package/dist/auth/device.js +76 -0
- package/dist/auth/login.js +145 -0
- package/dist/auth/loopback.js +98 -0
- package/dist/auth/mcp-oauth.js +174 -0
- package/dist/auth/session.js +205 -0
- package/dist/bin/nextx.js +5 -0
- package/dist/commands/ai.js +514 -0
- package/dist/commands/api.js +87 -0
- package/dist/commands/auth.js +165 -0
- package/dist/commands/docs.js +55 -0
- package/dist/commands/help.js +48 -0
- package/dist/commands/keys.js +85 -0
- package/dist/commands/mcp.js +202 -0
- package/dist/commands/tools.js +186 -0
- package/dist/commands/util.js +224 -0
- package/dist/commands/webhooks.js +249 -0
- package/dist/commands/workspace.js +44 -0
- package/dist/core/brand.js +51 -0
- package/dist/core/config.js +52 -0
- package/dist/core/context.js +42 -0
- package/dist/core/errors.js +28 -0
- package/dist/core/http.js +170 -0
- package/dist/core/i18n.js +90 -0
- package/dist/core/lock.js +44 -0
- package/dist/core/output.js +74 -0
- package/dist/core/paths.js +29 -0
- package/dist/core/prompt.js +137 -0
- package/dist/core/version.js +13 -0
- package/dist/main.js +38 -0
- package/dist/mcp/client.js +114 -0
- package/dist/program.js +102 -0
- package/dist/repl/App.js +771 -0
- package/dist/repl/components.js +66 -0
- package/dist/repl/index.js +12 -0
- package/dist/repl/markdown.js +57 -0
- package/dist/repl/slash.js +53 -0
- package/package.json +62 -4
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fetch } from "undici";
|
|
4
|
+
import { brand } from "../core/brand.js";
|
|
5
|
+
import { context } from "../core/context.js";
|
|
6
|
+
import { CliError, Exit } from "../core/errors.js";
|
|
7
|
+
import { currentLang, tr } from "../core/i18n.js";
|
|
8
|
+
import { c, emit, info, json, ok, table, warn } from "../core/output.js";
|
|
9
|
+
import { get, post, del, requestRaw, errorFor } from "../core/http.js";
|
|
10
|
+
import { confirm, hasTty } from "../core/prompt.js";
|
|
11
|
+
const when = (iso) => (iso ? iso.slice(0, 19).replace("T", " ") : "");
|
|
12
|
+
export async function eventTypes() {
|
|
13
|
+
return get("/webhook/public/event-types", { anonymous: true });
|
|
14
|
+
}
|
|
15
|
+
/** Checks both the NextX header (sha256=HMAC(ts.body)) and Standard Webhooks (v1,base64 HMAC(id.ts.body)). */
|
|
16
|
+
export function verifySignature(secret, body, headers, toleranceSec = 300) {
|
|
17
|
+
const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
18
|
+
const eq = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
19
|
+
const ts = h["x-nextx-timestamp"] ?? h["webhook-timestamp"];
|
|
20
|
+
if (ts && Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec)
|
|
21
|
+
return { ok: false, reason: tr("dấu thời gian quá cũ", "timestamp too old") };
|
|
22
|
+
if (h["x-nextx-signature-256"] && ts) {
|
|
23
|
+
const expected = "sha256=" + createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");
|
|
24
|
+
const got = [h["x-nextx-signature-256"], h["x-nextx-signature-256-previous"]].filter(Boolean);
|
|
25
|
+
if (got.some((g) => eq(g, expected)))
|
|
26
|
+
return { ok: true, scheme: "Signature-256" };
|
|
27
|
+
}
|
|
28
|
+
if (h["webhook-signature"] && h["webhook-id"] && ts) {
|
|
29
|
+
const key = secret.startsWith("whsec_") ? Buffer.from(secret.slice(6), "base64") : Buffer.from(secret);
|
|
30
|
+
const expected = createHmac("sha256", key).update(`${h["webhook-id"]}.${ts}.${body}`).digest("base64");
|
|
31
|
+
if (h["webhook-signature"].split(" ").some((s) => eq(s.replace(/^v1,/, ""), expected)))
|
|
32
|
+
return { ok: true, scheme: "webhook-signature" };
|
|
33
|
+
}
|
|
34
|
+
return { ok: false, reason: tr("chữ ký không khớp", "signature mismatch") };
|
|
35
|
+
}
|
|
36
|
+
async function findEndpoint(id) {
|
|
37
|
+
const e = (await get("/webhook/endpoints")).find((x) => x.id.startsWith(id) || x.url === id);
|
|
38
|
+
if (!e)
|
|
39
|
+
throw new CliError(tr("Không thấy endpoint này.", "Endpoint not found."), Exit.notFound);
|
|
40
|
+
return e;
|
|
41
|
+
}
|
|
42
|
+
export function registerWebhooks(program) {
|
|
43
|
+
const wh = program.command("webhooks").alias("webhook").description(tr("sự kiện webhook: xem, bắn thử, nghe về máy", "webhook events: browse, trigger, listen locally"));
|
|
44
|
+
wh.command("events").description(tr("danh sách sự kiện", "list events")).option("--module <m>")
|
|
45
|
+
.action(async (o) => {
|
|
46
|
+
let list = await eventTypes();
|
|
47
|
+
if (o.module)
|
|
48
|
+
list = list.filter((e) => e.service.toLowerCase() === o.module.toLowerCase());
|
|
49
|
+
emit(list, () => info(table([tr("Sự kiện", "Event"), tr("Phân hệ", "Module"), tr("Mô tả", "Description"), tr("Gói", "Plan")], list.map((e) => [e.event, e.service, (currentLang() === "en" ? e.descriptionEn ?? e.title : e.title), e.planRequired ?? ""]))));
|
|
50
|
+
});
|
|
51
|
+
wh.command("schema <event>").description(tr("schema và ví dụ payload", "payload schema and example"))
|
|
52
|
+
.action(async (event) => {
|
|
53
|
+
const e = (await eventTypes()).find((x) => x.event === event);
|
|
54
|
+
if (!e)
|
|
55
|
+
throw new CliError(tr("Không có sự kiện này.", "No such event."), Exit.notFound, `${brand().bin} webhooks events`);
|
|
56
|
+
emit(e, () => {
|
|
57
|
+
info(`${c.bold(e.event)} — ${e.title}`);
|
|
58
|
+
if (e.description)
|
|
59
|
+
info(currentLang() === "en" ? e.descriptionEn ?? e.description : e.description);
|
|
60
|
+
info(c.dim(tr("\nVí dụ:", "\nExample:")));
|
|
61
|
+
json(e.example);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
const ep = wh.command("endpoints").description(tr("endpoint nhận webhook của workspace", "your webhook endpoints"));
|
|
65
|
+
ep.command("list", { isDefault: true }).action(async () => {
|
|
66
|
+
const list = await get("/webhook/endpoints");
|
|
67
|
+
emit(list, () => info(table(["ID", "URL", tr("Sự kiện", "Events"), tr("Trạng thái", "Status"), tr("Lỗi liên tiếp", "Failures")], list.map((e) => [e.id.slice(0, 8), e.url, e.events.length > 3 ? `${e.events.slice(0, 3).join(", ")} +${e.events.length - 3}` : e.events.join(", "), e.isActive ? c.green(e.status) : c.dim(e.status), e.consecutiveFailures || ""]))));
|
|
68
|
+
});
|
|
69
|
+
ep.command("create <url>")
|
|
70
|
+
.requiredOption("--events <list>", tr("sự kiện, phẩy ngăn cách", "events, comma separated"))
|
|
71
|
+
.option("--description <text>")
|
|
72
|
+
.action(async (url, o) => {
|
|
73
|
+
const events = o.events.split(",").map((s) => s.trim()).filter(Boolean);
|
|
74
|
+
const types = await eventTypes();
|
|
75
|
+
const services = [...new Set(events.map((e) => types.find((t) => t.event === e)?.service).filter(Boolean))];
|
|
76
|
+
if (services.length !== 1)
|
|
77
|
+
throw new CliError(tr("Mỗi endpoint nhận sự kiện của MỘT phân hệ.", "Each endpoint receives events of ONE module."), Exit.usage);
|
|
78
|
+
const r = await post("/webhook/endpoints", { url, description: o.description, events, service: services[0] });
|
|
79
|
+
emit(r, () => {
|
|
80
|
+
ok(tr("Đã tạo endpoint.", "Endpoint created."));
|
|
81
|
+
if (r.secret)
|
|
82
|
+
info(` ${tr("Khoá ký", "Signing secret")}: ${c.bold(String(r.secret))} ${c.dim(tr("(chỉ hiện một lần)", "(shown once)"))}`);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
ep.command("delete <id>").action(async (id) => {
|
|
86
|
+
const e = await findEndpoint(id);
|
|
87
|
+
if (hasTty() && !(await confirm(tr(`Xoá endpoint ${e.url}?`, `Delete endpoint ${e.url}?`))))
|
|
88
|
+
return;
|
|
89
|
+
await del(`/webhook/endpoints/${e.id}`);
|
|
90
|
+
ok(tr("Đã xoá.", "Deleted."));
|
|
91
|
+
});
|
|
92
|
+
ep.command("rotate-secret <id>").description(tr("đổi khoá ký (khoá cũ còn hiệu lực một thời gian)", "rotate the signing secret (old one stays valid for a while)"))
|
|
93
|
+
.action(async (id) => {
|
|
94
|
+
const e = await findEndpoint(id);
|
|
95
|
+
const r = await post(`/webhook/endpoints/${e.id}/rotate-secret`, {});
|
|
96
|
+
emit(r, () => ok(`${tr("Khoá ký mới", "New secret")}: ${c.bold(String(r.secret ?? ""))}`));
|
|
97
|
+
});
|
|
98
|
+
wh.command("trigger <event>").description(tr("bắn một sự kiện mẫu tới endpoint", "send a sample event to an endpoint"))
|
|
99
|
+
.option("--endpoint <id>", tr("endpoint đích (mặc định: endpoint đầu tiên nhận sự kiện này)", "target endpoint (default: first one subscribed)"))
|
|
100
|
+
.action(async (event, o) => {
|
|
101
|
+
const list = await get("/webhook/endpoints");
|
|
102
|
+
const target = o.endpoint ? await findEndpoint(o.endpoint) : list.find((e) => e.events.includes(event));
|
|
103
|
+
if (!target)
|
|
104
|
+
throw new CliError(tr("Chưa có endpoint nào nhận sự kiện này.", "No endpoint subscribes to this event."), Exit.notFound, tr(`Dùng \`${brand().bin} webhooks listen --events ${event}\` để nghe về máy.`, `Use \`${brand().bin} webhooks listen --events ${event}\` to receive it locally.`));
|
|
105
|
+
const r = await post(`/webhook/endpoints/${target.id}/test`, { eventType: event });
|
|
106
|
+
emit(r, () => ok(tr(`Đã gửi ${event} → ${target.url}`, `Sent ${event} → ${target.url}`) + (r.responseStatus ? ` · HTTP ${r.responseStatus}` : "")));
|
|
107
|
+
});
|
|
108
|
+
wh.command("deliveries").description(tr("lượt giao gần đây", "recent deliveries"))
|
|
109
|
+
.option("--status <s>", "success | failed | pending")
|
|
110
|
+
.option("--endpoint <id>")
|
|
111
|
+
.option("--limit <n>", "", "30")
|
|
112
|
+
.action(async (o) => {
|
|
113
|
+
const path = o.endpoint ? `/webhook/endpoints/${(await findEndpoint(o.endpoint)).id}/deliveries` : "/webhook/deliveries";
|
|
114
|
+
const r = await get(path, { query: { status: o.status, pageSize: o.limit } });
|
|
115
|
+
const list = Array.isArray(r) ? r : r.items;
|
|
116
|
+
emit(list, () => info(table(["ID", tr("Sự kiện", "Event"), tr("Kết quả", "Result"), "HTTP", "ms", tr("Thử lại", "Retries"), tr("Lúc", "At")], list.map((d) => [
|
|
117
|
+
d.id.slice(0, 8), d.eventType, d.status === "success" ? c.green(d.status) : d.status === "failed" ? c.red(d.status) : d.status, d.responseStatus ?? "", d.durationMs ?? "", `${d.retryCount}/${d.maxRetries}`, when(d.createdAt),
|
|
118
|
+
]))));
|
|
119
|
+
});
|
|
120
|
+
wh.command("retry <deliveryId>").description(tr("gửi lại một lượt giao", "retry a delivery")).action(async (id) => {
|
|
121
|
+
const full = id.length >= 32 ? id : (await get("/webhook/deliveries", { query: { pageSize: 100 } }).then((r) => (Array.isArray(r) ? r : r.items).find((d) => d.id.startsWith(id))?.id));
|
|
122
|
+
if (!full)
|
|
123
|
+
throw new CliError(tr("Không thấy lượt giao này.", "Delivery not found."), Exit.notFound);
|
|
124
|
+
const r = await post(`/webhook/deliveries/${full}/retry`, {});
|
|
125
|
+
emit(r, () => ok(tr("Đã xếp gửi lại.", "Retry queued.")));
|
|
126
|
+
});
|
|
127
|
+
wh.command("verify").description(tr("kiểm chữ ký webhook tại máy (không gọi máy chủ)", "verify a webhook signature locally (no server call)"))
|
|
128
|
+
.requiredOption("--secret <secret>")
|
|
129
|
+
.requiredOption("--payload <file>", tr("thân thô đã nhận (- = stdin)", "raw received body (- = stdin)"))
|
|
130
|
+
.option("-H, --header <K: V>", tr("header đã nhận (lặp được)", "received header (repeatable)"), (v, p = []) => [...p, v])
|
|
131
|
+
.option("--signature <sig>", tr("chữ ký trong header *-Signature-256", "signature from the *-Signature-256 header"))
|
|
132
|
+
.option("--timestamp <ts>", tr("dấu thời gian trong header *-Timestamp", "timestamp from the *-Timestamp header"))
|
|
133
|
+
.action((o) => {
|
|
134
|
+
const body = readFileSync(o.payload === "-" ? 0 : o.payload, "utf8");
|
|
135
|
+
const headers = {};
|
|
136
|
+
for (const h of o.header ?? []) {
|
|
137
|
+
const i = h.indexOf(":");
|
|
138
|
+
if (i > 0)
|
|
139
|
+
headers[h.slice(0, i).trim()] = h.slice(i + 1).trim();
|
|
140
|
+
}
|
|
141
|
+
if (o.signature)
|
|
142
|
+
headers["x-nextx-signature-256"] = o.signature;
|
|
143
|
+
if (o.timestamp)
|
|
144
|
+
headers["x-nextx-timestamp"] = o.timestamp;
|
|
145
|
+
const r = verifySignature(o.secret, body, headers);
|
|
146
|
+
emit(r, () => (r.ok ? ok(tr(`Chữ ký hợp lệ (${r.scheme})`, `Valid signature (${r.scheme})`)) : warn(tr(`Không hợp lệ: ${r.reason}`, `Invalid: ${r.reason}`))));
|
|
147
|
+
if (!r.ok)
|
|
148
|
+
process.exitCode = Exit.general;
|
|
149
|
+
});
|
|
150
|
+
wh.command("listen")
|
|
151
|
+
.description(tr("nhận sự kiện thật về máy và chuyển tới địa chỉ local", "receive real events on this machine and forward them locally"))
|
|
152
|
+
.option("--forward-to <url>", tr("vd localhost:3000/hook", "e.g. localhost:3000/hook"))
|
|
153
|
+
.option("--events <list>", tr("sự kiện, phẩy ngăn cách (mặc định: tất cả)", "events, comma separated (default: all)"))
|
|
154
|
+
.option("--print-secret", tr("in đủ khoá ký", "print the full signing secret"))
|
|
155
|
+
.option("--print-json", tr("in thân sự kiện", "print event bodies"))
|
|
156
|
+
.action(async (o) => listen(o));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Dev listen protocol (webhook service, permission webhook.endpoints.manage):
|
|
160
|
+
* GET /api/v1/webhook/dev-listen?events=a,b (SSE)
|
|
161
|
+
* data: {"type":"ready","listenerId","secret","idleTimeoutSec","events"}
|
|
162
|
+
* data: {"type":"delivery","deliveryId","eventType","headers":{…},"body":"<raw>"}
|
|
163
|
+
* ": ping" comments every 15 s
|
|
164
|
+
* Best effort: no delivery records, no retries. The listener expires idleTimeoutSec after the stream closes.
|
|
165
|
+
*/
|
|
166
|
+
async function listen(o) {
|
|
167
|
+
const target = o.forwardTo ? (/^https?:\/\//.test(o.forwardTo) ? o.forwardTo : `http://${o.forwardTo}`) : undefined;
|
|
168
|
+
if (target && !["localhost", "127.0.0.1", "[::1]"].includes(new URL(target).hostname))
|
|
169
|
+
throw new CliError(tr("--forward-to chỉ nhận địa chỉ trên máy này (localhost).", "--forward-to only accepts local addresses (localhost)."), Exit.usage);
|
|
170
|
+
if (context().env === "prod")
|
|
171
|
+
warn(c.red(tr("PROD: dữ liệu thật của khách sẽ chảy về máy này.", "PROD: real customer data will flow to this machine.")));
|
|
172
|
+
const stop = new AbortController();
|
|
173
|
+
process.once("SIGINT", () => stop.abort());
|
|
174
|
+
let backoff = 1000;
|
|
175
|
+
while (!stop.signal.aborted) {
|
|
176
|
+
let res;
|
|
177
|
+
try {
|
|
178
|
+
res = await requestRaw("GET", "/webhook/dev-listen", { query: { events: o.events }, signal: stop.signal, headers: { Accept: "text/event-stream" } });
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
if (stop.signal.aborted)
|
|
182
|
+
break;
|
|
183
|
+
warn(tr(`Mất kết nối, thử lại sau ${backoff / 1000}s…`, `Disconnected, retrying in ${backoff / 1000}s…`));
|
|
184
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
185
|
+
backoff = Math.min(backoff * 2, 30_000);
|
|
186
|
+
void e;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (res.status === 404)
|
|
190
|
+
throw new CliError(tr("Máy chủ chưa hỗ trợ nghe webhook về máy.", "The server does not support local webhook listening yet."), Exit.general);
|
|
191
|
+
if (!res.ok)
|
|
192
|
+
throw errorFor(res.status, await res.json().catch(() => undefined), res);
|
|
193
|
+
backoff = 1000;
|
|
194
|
+
const decoder = new TextDecoder();
|
|
195
|
+
let buf = "";
|
|
196
|
+
try {
|
|
197
|
+
for await (const part of res.body) {
|
|
198
|
+
buf += decoder.decode(part, { stream: true });
|
|
199
|
+
let idx;
|
|
200
|
+
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
201
|
+
const frame = buf.slice(0, idx);
|
|
202
|
+
buf = buf.slice(idx + 2);
|
|
203
|
+
const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trimStart()).join("\n");
|
|
204
|
+
if (!data)
|
|
205
|
+
continue;
|
|
206
|
+
const msg = JSON.parse(data);
|
|
207
|
+
if (msg.type === "ready") {
|
|
208
|
+
const sec = msg.secret ?? "";
|
|
209
|
+
ok(tr(`Đang nghe trên ${c.bold(context().env)} · khoá ký ${c.yellow(o.printSecret ? sec : `${sec.slice(0, 6)}…${sec.slice(-3)}`)}`, `Listening on ${c.bold(context().env)} · signing secret ${c.yellow(o.printSecret ? sec : `${sec.slice(0, 6)}…${sec.slice(-3)}`)}`));
|
|
210
|
+
info(c.dim(tr(" Ctrl+C dừng · sự kiện thật của workspace, đã ký như lượt giao thật", " Ctrl+C to stop · real workspace events, signed like real deliveries")));
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (msg.type !== "delivery")
|
|
214
|
+
continue;
|
|
215
|
+
const at = new Date().toTimeString().slice(0, 8);
|
|
216
|
+
if (o.printJson)
|
|
217
|
+
info(msg.body ?? "");
|
|
218
|
+
if (!target) {
|
|
219
|
+
info(` ${at} ${(msg.eventType ?? "").padEnd(22)} ${c.dim(msg.deliveryId?.slice(0, 12) ?? "")}`);
|
|
220
|
+
if (o.printJson)
|
|
221
|
+
continue;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const started = Date.now();
|
|
225
|
+
let status = 0;
|
|
226
|
+
let respBody = "";
|
|
227
|
+
try {
|
|
228
|
+
const r = await fetch(target, { method: "POST", headers: { "Content-Type": "application/json", ...msg.headers }, body: msg.body });
|
|
229
|
+
status = r.status;
|
|
230
|
+
respBody = (await r.text()).slice(0, 2000);
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
respBody = String(e.message);
|
|
234
|
+
}
|
|
235
|
+
const ms = Date.now() - started;
|
|
236
|
+
const code = status ? (status < 300 ? c.green(String(status)) : c.red(String(status))) : c.red(tr("lỗi", "error"));
|
|
237
|
+
info(` ${at} ${(msg.eventType ?? "").padEnd(22)} → ${code} ${String(ms).padStart(4)}ms ${c.dim(msg.deliveryId?.slice(0, 12) ?? "")}`);
|
|
238
|
+
if (status >= 300 && respBody)
|
|
239
|
+
info(c.dim(` ${respBody.slice(0, 200).replace(/\s+/g, " ")}`));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch (e) {
|
|
244
|
+
if (stop.signal.aborted)
|
|
245
|
+
break;
|
|
246
|
+
void e;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { context } from "../core/context.js";
|
|
2
|
+
import { CliError, Exit } from "../core/errors.js";
|
|
3
|
+
import { tr } from "../core/i18n.js";
|
|
4
|
+
import { c, emit, info, ok, table } from "../core/output.js";
|
|
5
|
+
import { getProfile } from "../core/config.js";
|
|
6
|
+
import { hasTty, select } from "../core/prompt.js";
|
|
7
|
+
import { findWorkspace, listWorkspaces, requirePerson, switchWorkspace } from "../auth/session.js";
|
|
8
|
+
export async function pickWorkspace(key) {
|
|
9
|
+
await requirePerson();
|
|
10
|
+
const list = await listWorkspaces();
|
|
11
|
+
if (key) {
|
|
12
|
+
const w = findWorkspace(list, key);
|
|
13
|
+
if (!w)
|
|
14
|
+
throw new CliError(tr(`Không thấy workspace "${key}".`, `Workspace "${key}" not found.`), Exit.notFound, tr("Xem danh sách: workspace list", "See: workspace list"));
|
|
15
|
+
return w;
|
|
16
|
+
}
|
|
17
|
+
if (!hasTty())
|
|
18
|
+
throw new CliError(tr("Cần mã workspace khi không có terminal.", "Pass a workspace code without a terminal."), Exit.usage);
|
|
19
|
+
const cur = getProfile(context().profile)?.workspaceId;
|
|
20
|
+
const w = await select(tr("Chọn workspace:", "Choose a workspace:"), list.map((x) => ({ label: x.name, hint: [x.code, x.primaryGroupName, x.id === cur ? tr("đang dùng", "current") : ""].filter(Boolean).join(" · "), value: x })), Math.max(0, list.findIndex((x) => x.id === cur)));
|
|
21
|
+
if (!w)
|
|
22
|
+
throw new CliError(tr("Đã huỷ.", "Cancelled."), Exit.interrupted);
|
|
23
|
+
return w;
|
|
24
|
+
}
|
|
25
|
+
export function registerWorkspace(program) {
|
|
26
|
+
const ws = program.command("workspace").alias("ws").description(tr("các workspace (công ty) của bạn", "your workspaces (companies)"));
|
|
27
|
+
ws.command("list", { isDefault: true })
|
|
28
|
+
.description(tr("danh sách workspace", "list workspaces"))
|
|
29
|
+
.action(async () => {
|
|
30
|
+
await requirePerson();
|
|
31
|
+
const list = await listWorkspaces();
|
|
32
|
+
const cur = getProfile(context().profile)?.workspaceId;
|
|
33
|
+
emit(list.map((w) => ({ ...w, current: w.id === cur })), () => info(table(["", tr("Tên", "Name"), tr("Mã", "Code"), tr("Vai trò", "Role"), tr("Trạng thái", "Status")], list.map((w) => [
|
|
34
|
+
w.id === cur ? c.green("●") : "", w.name, w.code, w.primaryGroupName ?? "", w.status ?? "",
|
|
35
|
+
]))));
|
|
36
|
+
});
|
|
37
|
+
ws.command("use [code]")
|
|
38
|
+
.description(tr("đổi workspace đang làm việc (lưu vào hồ sơ)", "switch the working workspace (saved in the profile)"))
|
|
39
|
+
.action(async (code) => {
|
|
40
|
+
const w = await pickWorkspace(code);
|
|
41
|
+
await switchWorkspace(w);
|
|
42
|
+
emit({ workspace: w }, () => ok(tr(`Đang làm việc trên ${c.bold(w.name)}`, `Now working on ${c.bold(w.name)}`)));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// One codebase, several branded binaries. The entry file (src/bin/<brand>.ts) picks the brand before
|
|
2
|
+
// anything else is imported; every user-facing name, host and storage location comes from here.
|
|
3
|
+
// <brand:nextx>
|
|
4
|
+
const NEXTX = {
|
|
5
|
+
id: "nextx",
|
|
6
|
+
bin: "nextx",
|
|
7
|
+
name: "NextX",
|
|
8
|
+
aiName: "NextX AI",
|
|
9
|
+
pkg: "@nextxai/cli",
|
|
10
|
+
clientId: "nextx-cli",
|
|
11
|
+
envPrefix: "NEXTX",
|
|
12
|
+
mobileApp: "NextX",
|
|
13
|
+
modules: null,
|
|
14
|
+
env: {
|
|
15
|
+
prod: {
|
|
16
|
+
api: "https://api.nextx.vn",
|
|
17
|
+
auth: "https://auth.nextx.vn",
|
|
18
|
+
mcp: "https://mcp.nextx.vn/mcp",
|
|
19
|
+
app: "https://app.nextx.vn",
|
|
20
|
+
docs: "https://developers.nextx.vn",
|
|
21
|
+
id: "https://id.nextx.vn",
|
|
22
|
+
},
|
|
23
|
+
sandbox: {
|
|
24
|
+
api: "https://api-dev.nextx.vn",
|
|
25
|
+
auth: "https://auth-dev.nextx.vn",
|
|
26
|
+
mcp: "https://mcp-dev.nextx.vn/mcp",
|
|
27
|
+
app: "https://app-dev.nextx.vn",
|
|
28
|
+
docs: "https://developers-dev.nextx.vn",
|
|
29
|
+
id: "https://id-dev.nextx.vn",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
// </brand:nextx>
|
|
34
|
+
// Exported trees keep only their own brand (scripts/export.sh strips the other block and its line here).
|
|
35
|
+
const BRANDS = {
|
|
36
|
+
nextx: NEXTX, // <brand-line:nextx>
|
|
37
|
+
};
|
|
38
|
+
let current = Object.values(BRANDS)[0];
|
|
39
|
+
export function setBrand(id) {
|
|
40
|
+
const b = BRANDS[id];
|
|
41
|
+
if (b)
|
|
42
|
+
current = b;
|
|
43
|
+
}
|
|
44
|
+
export function brand() {
|
|
45
|
+
return current;
|
|
46
|
+
}
|
|
47
|
+
/** Reads `<PREFIX>_<name>` from the environment, e.g. envVar("API_KEY") → NEXTX_API_KEY. */
|
|
48
|
+
export function envVar(name) {
|
|
49
|
+
const v = process.env[`${current.envPrefix}_${name}`];
|
|
50
|
+
return v === undefined || v === "" ? undefined : v;
|
|
51
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// ~/.config/<bin>/config.toml — preferences and profiles. Never holds tokens or keys (see credentials.ts).
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
3
|
+
import { parse, stringify } from "smol-toml";
|
|
4
|
+
import { configFile } from "./paths.js";
|
|
5
|
+
import { envVar } from "./brand.js";
|
|
6
|
+
const EMPTY = { defaultProfile: "default", profiles: {} };
|
|
7
|
+
export function loadConfig() {
|
|
8
|
+
const f = configFile();
|
|
9
|
+
if (!existsSync(f))
|
|
10
|
+
return structuredClone(EMPTY);
|
|
11
|
+
const raw = parse(readFileSync(f, "utf8"));
|
|
12
|
+
return { ...structuredClone(EMPTY), ...raw, profiles: (raw.profiles ?? {}) };
|
|
13
|
+
}
|
|
14
|
+
export function saveConfig(c) {
|
|
15
|
+
const f = configFile();
|
|
16
|
+
const tmp = `${f}.tmp`;
|
|
17
|
+
writeFileSync(tmp, stringify(c), { mode: 0o600 });
|
|
18
|
+
renameSync(tmp, f);
|
|
19
|
+
}
|
|
20
|
+
/** Profile chosen by --profile, then <PREFIX>_PROFILE, then the configured default. */
|
|
21
|
+
export function profileName(flag) {
|
|
22
|
+
return flag ?? envVar("PROFILE") ?? loadConfig().defaultProfile;
|
|
23
|
+
}
|
|
24
|
+
export function getProfile(name) {
|
|
25
|
+
return loadConfig().profiles[name];
|
|
26
|
+
}
|
|
27
|
+
export function updateProfile(name, patch) {
|
|
28
|
+
const c = loadConfig();
|
|
29
|
+
const next = { env: "prod", ...c.profiles[name], ...patch };
|
|
30
|
+
c.profiles[name] = next;
|
|
31
|
+
saveConfig(c);
|
|
32
|
+
return next;
|
|
33
|
+
}
|
|
34
|
+
export function removeProfile(name) {
|
|
35
|
+
const c = loadConfig();
|
|
36
|
+
delete c.profiles[name];
|
|
37
|
+
saveConfig(c);
|
|
38
|
+
}
|
|
39
|
+
/** Dotted keys settable with `config set`. */
|
|
40
|
+
export const SETTABLE = {
|
|
41
|
+
lang: (v) => (v === "en" ? "en" : "vi"),
|
|
42
|
+
mode: (v) => {
|
|
43
|
+
if (v !== "read" && v !== "propose" && v !== "auto")
|
|
44
|
+
throw new Error("mode: read | propose | auto");
|
|
45
|
+
return v;
|
|
46
|
+
},
|
|
47
|
+
updateChannel: (v) => (v === "next" ? "next" : "latest"),
|
|
48
|
+
updateCheck: (v) => v !== "false" && v !== "0",
|
|
49
|
+
telemetry: (v) => v !== "false" && v !== "0",
|
|
50
|
+
color: (v) => v !== "false" && v !== "0",
|
|
51
|
+
defaultProfile: (v) => v,
|
|
52
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Options shared by every command, resolved once from global flags + environment + config.
|
|
2
|
+
import { brand, envVar } from "./brand.js";
|
|
3
|
+
import { getProfile, loadConfig, profileName } from "./config.js";
|
|
4
|
+
let ctx;
|
|
5
|
+
export function initContext(flags) {
|
|
6
|
+
const profile = profileName(flags.profile);
|
|
7
|
+
const p = getProfile(profile);
|
|
8
|
+
const envFromVar = envVar("ENV");
|
|
9
|
+
const env = flags.sandbox || envFromVar === "sandbox" ? "sandbox" : envFromVar === "prod" ? "prod" : (p?.env ?? "prod");
|
|
10
|
+
const cfg = loadConfig();
|
|
11
|
+
ctx = {
|
|
12
|
+
profile,
|
|
13
|
+
env,
|
|
14
|
+
endpoints: overrideEndpoints(brand().env[env]),
|
|
15
|
+
workspace: flags.workspace ?? envVar("WORKSPACE"),
|
|
16
|
+
agent: flags.agent ?? p?.agent,
|
|
17
|
+
mode: flags.mode ?? cfg.mode ?? "propose",
|
|
18
|
+
debug: flags.debug === true || envVar("DEBUG") === "1",
|
|
19
|
+
verbose: flags.verbose === true,
|
|
20
|
+
interactive: process.stdin.isTTY === true && process.stdout.isTTY === true,
|
|
21
|
+
};
|
|
22
|
+
return ctx;
|
|
23
|
+
}
|
|
24
|
+
/** <PREFIX>_API_URL etc. let us point a build at a local stack; never documented for customers. */
|
|
25
|
+
function overrideEndpoints(e) {
|
|
26
|
+
return {
|
|
27
|
+
api: envVar("API_URL") ?? e.api,
|
|
28
|
+
auth: envVar("AUTH_URL") ?? e.auth,
|
|
29
|
+
mcp: envVar("MCP_URL") ?? e.mcp,
|
|
30
|
+
app: envVar("APP_URL") ?? e.app,
|
|
31
|
+
docs: envVar("DOCS_URL") ?? e.docs,
|
|
32
|
+
id: envVar("ID_URL") ?? e.id,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function context() {
|
|
36
|
+
if (!ctx)
|
|
37
|
+
return initContext({});
|
|
38
|
+
return ctx;
|
|
39
|
+
}
|
|
40
|
+
export function setContext(patch) {
|
|
41
|
+
ctx = { ...context(), ...patch };
|
|
42
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Exit codes are part of the public contract (docs: /docs/cli#exit-codes). Do not renumber.
|
|
2
|
+
export const Exit = {
|
|
3
|
+
ok: 0,
|
|
4
|
+
general: 1,
|
|
5
|
+
usage: 2,
|
|
6
|
+
auth: 3,
|
|
7
|
+
forbidden: 4,
|
|
8
|
+
quota: 5,
|
|
9
|
+
network: 6,
|
|
10
|
+
notConfirmed: 7,
|
|
11
|
+
notFound: 8,
|
|
12
|
+
interrupted: 130,
|
|
13
|
+
};
|
|
14
|
+
export class CliError extends Error {
|
|
15
|
+
exitCode;
|
|
16
|
+
hint;
|
|
17
|
+
requestId;
|
|
18
|
+
constructor(message, exitCode = Exit.general, hint, requestId) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.exitCode = exitCode;
|
|
21
|
+
this.hint = hint;
|
|
22
|
+
this.requestId = requestId;
|
|
23
|
+
this.name = "CliError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function isCliError(e) {
|
|
27
|
+
return e instanceof CliError;
|
|
28
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Every call to the platform goes through here: identity headers, retry on expired token, error → exit code.
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { arch, platform } from "node:os";
|
|
4
|
+
import { fetch, EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
|
|
5
|
+
import { brand } from "./brand.js";
|
|
6
|
+
import { context } from "./context.js";
|
|
7
|
+
import { CliError, Exit } from "./errors.js";
|
|
8
|
+
import { t, currentLang } from "./i18n.js";
|
|
9
|
+
import { VERSION } from "./version.js";
|
|
10
|
+
import { currentAuth, refreshSession } from "../auth/session.js";
|
|
11
|
+
setGlobalDispatcher(new EnvHttpProxyAgent());
|
|
12
|
+
export const userAgent = () => `${brand().bin}-cli/${VERSION} (${platform()}; ${arch()}; node ${process.versions.node})`;
|
|
13
|
+
export function apiPath(path) {
|
|
14
|
+
if (/^https?:\/\//.test(path))
|
|
15
|
+
return path;
|
|
16
|
+
const p = path.startsWith("/") ? path : `/${path}`;
|
|
17
|
+
return p.startsWith("/api/") ? p : `/api/v1${p}`;
|
|
18
|
+
}
|
|
19
|
+
function buildUrl(path, opts) {
|
|
20
|
+
const base = opts.baseUrl ?? context().endpoints.api;
|
|
21
|
+
const url = new URL(/^https?:\/\//.test(path) ? path : base + (opts.baseUrl ? path : apiPath(path)));
|
|
22
|
+
for (const [k, v] of Object.entries(opts.query ?? {}))
|
|
23
|
+
if (v !== undefined)
|
|
24
|
+
url.searchParams.set(k, String(v));
|
|
25
|
+
return url.toString();
|
|
26
|
+
}
|
|
27
|
+
function authHeaders(auth) {
|
|
28
|
+
if (!auth)
|
|
29
|
+
return {};
|
|
30
|
+
if (auth.kind === "apiKey")
|
|
31
|
+
return { "X-Api-Key": auth.key };
|
|
32
|
+
return { Authorization: `Bearer ${auth.token}` };
|
|
33
|
+
}
|
|
34
|
+
function debug(line) {
|
|
35
|
+
if (context().debug)
|
|
36
|
+
process.stderr.write(`\u001b[2m[debug] ${line}\u001b[22m\n`);
|
|
37
|
+
}
|
|
38
|
+
async function send(method, url, opts, auth) {
|
|
39
|
+
const headers = {
|
|
40
|
+
"User-Agent": userAgent(),
|
|
41
|
+
"X-Client-Type": "cli",
|
|
42
|
+
"Accept-Language": currentLang(),
|
|
43
|
+
Accept: "application/json, text/event-stream",
|
|
44
|
+
...(opts.bearer ? { Authorization: `Bearer ${opts.bearer}` } : opts.anonymous ? {} : authHeaders(auth)),
|
|
45
|
+
...opts.headers,
|
|
46
|
+
};
|
|
47
|
+
let body;
|
|
48
|
+
if (opts.body !== undefined) {
|
|
49
|
+
if (typeof opts.body === "string")
|
|
50
|
+
body = opts.body;
|
|
51
|
+
else {
|
|
52
|
+
body = JSON.stringify(opts.body);
|
|
53
|
+
headers["Content-Type"] ??= "application/json";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (opts.idempotencyKey)
|
|
57
|
+
headers["Idempotency-Key"] = opts.idempotencyKey === true ? randomUUID() : opts.idempotencyKey;
|
|
58
|
+
const started = Date.now();
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(url, { method, headers, body, signal: opts.signal });
|
|
61
|
+
debug(`${method} ${url.replace(/([?&](token|key)=)[^&]+/gi, "$1…")} → ${res.status} ${Date.now() - started}ms rid=${res.headers.get("x-request-id") ?? "-"}`);
|
|
62
|
+
return res;
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
throw networkError(e, url);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function networkError(e, url) {
|
|
69
|
+
if (e?.name === "AbortError")
|
|
70
|
+
return new CliError(t("interrupted"), Exit.interrupted);
|
|
71
|
+
const cause = e?.cause?.code ?? e?.code ?? "";
|
|
72
|
+
if (/CERT|SSL|TLS|SELF_SIGNED|UNABLE_TO_VERIFY/i.test(cause))
|
|
73
|
+
return new CliError(t("net.tls"), Exit.network, t("net.tls.hint"));
|
|
74
|
+
return new CliError(t("net.failed", { host: new URL(url).host }) + (cause ? ` (${cause})` : ""), Exit.network, t("net.failed.hint"));
|
|
75
|
+
}
|
|
76
|
+
async function readBody(res) {
|
|
77
|
+
const text = await res.text();
|
|
78
|
+
if (!text)
|
|
79
|
+
return undefined;
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(text);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return text;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Human message from ProblemDetails / {code,message} / plain text bodies. */
|
|
88
|
+
export function serverMessage(body) {
|
|
89
|
+
if (!body || typeof body !== "object")
|
|
90
|
+
return { message: typeof body === "string" ? body.slice(0, 300) : undefined };
|
|
91
|
+
const b = body;
|
|
92
|
+
// ProblemDetails: errors is either a list of messages or { field: [messages] }.
|
|
93
|
+
const errors = Array.isArray(b.errors)
|
|
94
|
+
? b.errors.map(String).join("; ")
|
|
95
|
+
: b.errors && typeof b.errors === "object"
|
|
96
|
+
? Object.entries(b.errors).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : String(v)}`).join("; ")
|
|
97
|
+
: undefined;
|
|
98
|
+
const code = (b.code ?? b.errorCode ?? b.error ?? b.type);
|
|
99
|
+
const generic = typeof b.title === "string" && /validation errors occurred/i.test(b.title);
|
|
100
|
+
const message = (b.message ?? b.detail ?? b.error_description ?? (generic && errors ? errors : (b.title ?? errors)));
|
|
101
|
+
return { code: typeof code === "string" ? code : undefined, message: typeof message === "string" ? message : undefined };
|
|
102
|
+
}
|
|
103
|
+
export function errorFor(status, body, res) {
|
|
104
|
+
const { code, message } = serverMessage(body);
|
|
105
|
+
const rid = (res.headers.get("x-request-id") ?? res.headers.get("x-correlation-id") ?? body?.traceId ?? undefined)?.split(",")[0]?.trim();
|
|
106
|
+
const minVersion = res.headers.get("x-min-cli-version");
|
|
107
|
+
if (status === 426 || (minVersion && isOlder(VERSION, minVersion)))
|
|
108
|
+
return new CliError(t("http.tooOld", { cur: VERSION, min: minVersion ?? "?" }), Exit.general, t("http.tooOld.hint"));
|
|
109
|
+
if (status === 401) {
|
|
110
|
+
if (code && /revoked|blacklist/i.test(code))
|
|
111
|
+
return new CliError(t("auth.revoked"), Exit.auth, t("auth.expired.hint"));
|
|
112
|
+
return new CliError(message ?? t("auth.expired"), Exit.auth, t("auth.expired.hint"));
|
|
113
|
+
}
|
|
114
|
+
if (status === 403)
|
|
115
|
+
return new CliError(message ?? t("http.forbidden"), Exit.forbidden, undefined, rid);
|
|
116
|
+
if (status === 404)
|
|
117
|
+
return new CliError(message ?? t("http.notFound"), Exit.notFound, undefined, rid);
|
|
118
|
+
if (status === 429) {
|
|
119
|
+
const sec = Number(res.headers.get("retry-after") ?? body?.retryAfterSeconds ?? 0);
|
|
120
|
+
return new CliError(message ?? t("http.rateLimited", { sec }), Exit.quota, undefined, rid);
|
|
121
|
+
}
|
|
122
|
+
if (status >= 500)
|
|
123
|
+
return new CliError(t("http.server", { rid: rid ?? "-" }), Exit.general, t("http.server.hint"), rid);
|
|
124
|
+
return new CliError(message ?? `HTTP ${status}${code ? ` ${code}` : ""}`, status === 409 || status === 410 ? Exit.notConfirmed : Exit.general, undefined, rid);
|
|
125
|
+
}
|
|
126
|
+
export function isOlder(a, b) {
|
|
127
|
+
const pa = a.split(/[.-]/).map((x) => parseInt(x, 10) || 0);
|
|
128
|
+
const pb = b.split(/[.-]/).map((x) => parseInt(x, 10) || 0);
|
|
129
|
+
for (let i = 0; i < 3; i++)
|
|
130
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
131
|
+
return (pa[i] ?? 0) < (pb[i] ?? 0);
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
/** Raw response with the shared identity + one refresh-and-retry on 401. */
|
|
135
|
+
export async function requestRaw(method, path, opts = {}) {
|
|
136
|
+
const url = buildUrl(path, opts);
|
|
137
|
+
const auth = opts.anonymous || opts.bearer ? undefined : await currentAuth();
|
|
138
|
+
let res = await send(method, url, opts, auth);
|
|
139
|
+
if (res.status === 401 && auth?.kind === "session") {
|
|
140
|
+
await res.body?.cancel();
|
|
141
|
+
const fresh = await refreshSession(auth.token);
|
|
142
|
+
res = await send(method, url, opts, fresh);
|
|
143
|
+
}
|
|
144
|
+
return res;
|
|
145
|
+
}
|
|
146
|
+
export async function request(method, path, opts = {}) {
|
|
147
|
+
let res = await requestRaw(method, path, opts);
|
|
148
|
+
// Short rate limits are waited out once; long ones surface to the user.
|
|
149
|
+
if (res.status === 429 && !opts.allow?.includes(429)) {
|
|
150
|
+
const sec = Number(res.headers.get("retry-after") ?? 0);
|
|
151
|
+
if (sec > 0 && sec <= 30) {
|
|
152
|
+
await res.body?.cancel();
|
|
153
|
+
process.stderr.write(`${t("http.rateLimited", { sec })}\n`);
|
|
154
|
+
await new Promise((r) => setTimeout(r, sec * 1000));
|
|
155
|
+
res = await requestRaw(method, path, opts);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const requestId = res.headers.get("x-request-id") ?? undefined;
|
|
159
|
+
if (opts.raw)
|
|
160
|
+
return { status: res.status, data: res, headers: res.headers, requestId };
|
|
161
|
+
const data = await readBody(res);
|
|
162
|
+
if (!res.ok && !opts.allow?.includes(res.status))
|
|
163
|
+
throw errorFor(res.status, data, res);
|
|
164
|
+
return { status: res.status, data: data, headers: res.headers, requestId };
|
|
165
|
+
}
|
|
166
|
+
export const get = (path, opts) => request("GET", path, opts).then((r) => r.data);
|
|
167
|
+
export const post = (path, body, opts) => request("POST", path, { ...opts, body }).then((r) => r.data);
|
|
168
|
+
export const put = (path, body, opts) => request("PUT", path, { ...opts, body }).then((r) => r.data);
|
|
169
|
+
export const patch = (path, body, opts) => request("PATCH", path, { ...opts, body }).then((r) => r.data);
|
|
170
|
+
export const del = (path, opts) => request("DELETE", path, opts).then((r) => r.data);
|