@nextxai/cli 0.2.0 → 0.3.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/CHANGELOG.md +13 -1
- package/dist/ai/client.js +30 -20
- package/dist/auth/session.js +3 -3
- package/dist/commands/ai.js +62 -13
- package/dist/commands/ci-policy.js +98 -0
- package/dist/commands/ci.js +28 -4
- package/dist/commands/keys.js +2 -1
- package/dist/commands/mcp.js +3 -3
- package/dist/commands/tools.js +4 -24
- package/dist/commands/webhooks.js +17 -15
- package/dist/core/brand.js +2 -3
- package/dist/core/http.js +11 -3
- package/dist/mcp/client.js +1 -1
- package/dist/repl/App.js +0 -68
- package/dist/repl/slash.js +1 -4
- package/package.json +1 -1
- package/dist/ai/nextfarm.js +0 -387
- package/dist/commands/ai-nextfarm.js +0 -109
- package/dist/core/case.js +0 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
- Help examples (`keys create --scopes`, `ci policy`) show permission codes of your own product.
|
|
6
|
+
- Release: a version already on npm is skipped instead of failing the release.
|
|
7
|
+
|
|
8
|
+
## 0.3.0
|
|
9
|
+
|
|
10
|
+
- `ci policy request|list|show|revoke|kinds`: pre-approval policies let a CI repository write on its own — chosen write permissions, a total row ceiling (up to 1000), at most 30 days. Anyone requests; an API-key manager approves (or `--approve` if you are one). A CI session cannot request, approve or revoke a policy.
|
|
11
|
+
- In CI (`login --ci`, no terminal) with `--mode auto`, proposals are sent for confirmation and the server applies them only within an active policy; otherwise they stay a proposal (exit code 7). `-p --json` reports `proposals[0].applied` and exits 0 when written.
|
|
12
|
+
- A CI session calls the API directly with the permissions of the person who added the repository; it cannot create API keys, CI trusts, policies, sessions or other long-lived access.
|
|
13
|
+
- `ci trust add` no longer takes write options — use `ci policy request`. Policy ids are shown by their last 8 characters.
|
|
14
|
+
|
|
3
15
|
## 0.2.0
|
|
4
16
|
|
|
5
17
|
- `--model`: answer with your own model on your machine — Claude, Gemini, GPT, Grok or Ollama — using your workspace tools over MCP. Writes still only create a proposal you confirm. `model add|list|remove|default`, and `/model` switches inside the chat.
|
|
@@ -15,7 +27,7 @@
|
|
|
15
27
|
|
|
16
28
|
First release.
|
|
17
29
|
|
|
18
|
-
- Chat with the assistant in the terminal
|
|
30
|
+
- Chat with the assistant in the terminal, with slash commands, file attachments, permission modes and confirmed writes.
|
|
19
31
|
- Login: browser (PKCE), QR via the mobile app, device code, API key, CI OIDC.
|
|
20
32
|
- `-p` / `--json` / `--output-format stream-json` for scripts and agents.
|
|
21
33
|
- `tools`, `api`, `keys`, `webhooks` (including `listen --forward-to` and `verify`), `mcp install|serve`, `doctor`, `completion`, `update`, `init`.
|
package/dist/ai/client.js
CHANGED
|
@@ -7,21 +7,37 @@ import { get, post, put, patch, del, requestRaw, errorFor, networkError } from "
|
|
|
7
7
|
import { CliError, Exit } from "../core/errors.js";
|
|
8
8
|
import { tr } from "../core/i18n.js";
|
|
9
9
|
import { brand } from "../core/brand.js";
|
|
10
|
-
import * as nf from "./nextfarm.js";
|
|
11
|
-
/** NextFarm talks to its own assistant API (see nextfarm.ts); the other brands share this file's contract. */
|
|
12
|
-
const farm = () => brand().platform === "nextfarm";
|
|
13
10
|
const BASE = "/operator/widget/support";
|
|
14
|
-
export const openConversation = (forceNew) =>
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
export const
|
|
24
|
-
|
|
11
|
+
export const openConversation = (forceNew) => {
|
|
12
|
+
return post(`${BASE}/init`, { forceNew });
|
|
13
|
+
};
|
|
14
|
+
export const listConversations = (limit = 30) => {
|
|
15
|
+
return get(`${BASE}/conversations`, { query: { limit } });
|
|
16
|
+
};
|
|
17
|
+
export const renameConversation = (id, title) => {
|
|
18
|
+
return patch(`${BASE}/conversations/${id}/title`, { title });
|
|
19
|
+
};
|
|
20
|
+
export const pinConversation = (id, pinned) => {
|
|
21
|
+
return put(`${BASE}/conversations/${id}/pin`, { pinned });
|
|
22
|
+
};
|
|
23
|
+
export const deleteConversation = (id) => {
|
|
24
|
+
return del(`${BASE}/conversations/${id}`);
|
|
25
|
+
};
|
|
26
|
+
export const history = (sessionId) => {
|
|
27
|
+
return get(`${BASE}/history`, { query: { sessionId } });
|
|
28
|
+
};
|
|
29
|
+
export const feedback = (messageId, helpful) => {
|
|
30
|
+
return post(`${BASE}/messages/${messageId}/feedback`, { helpful });
|
|
31
|
+
};
|
|
32
|
+
export const escalate = (sessionId, topic) => {
|
|
33
|
+
return post(`${BASE}/escalate`, { sessionId, topic });
|
|
34
|
+
};
|
|
35
|
+
export const usage = () => {
|
|
36
|
+
return get(`${BASE}/usage`);
|
|
37
|
+
};
|
|
38
|
+
export const confirmAction = (sessionId, a) => {
|
|
39
|
+
return post(`${BASE}/${sessionId}/confirm-action`, { kind: a.kind, payloadJson: a.payloadJson, nonce: a.nonce });
|
|
40
|
+
};
|
|
25
41
|
const TABLE = new Set([".xlsx", ".csv"]);
|
|
26
42
|
const IMAGE = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
|
|
27
43
|
const TEXT = new Set([".txt", ".md", ".json", ".log", ".yml", ".yaml", ".tsv", ".xml", ".html", ".sql"]);
|
|
@@ -29,8 +45,6 @@ const MAX_BYTES = 10 * 1024 * 1024;
|
|
|
29
45
|
const MAX_TEXT = 20_000;
|
|
30
46
|
export function attachmentKind(path) {
|
|
31
47
|
const ext = extname(path).toLowerCase();
|
|
32
|
-
if (farm() && nf.isDocument(path))
|
|
33
|
-
return "table";
|
|
34
48
|
if (TABLE.has(ext))
|
|
35
49
|
return "table";
|
|
36
50
|
if (ext in IMAGE)
|
|
@@ -67,8 +81,6 @@ export async function attach(path, sessionId) {
|
|
|
67
81
|
const cut = raw.length > MAX_TEXT;
|
|
68
82
|
return { path, name, kind, text: cut ? raw.slice(0, MAX_TEXT) : raw, summary: tr(`${raw.length.toLocaleString("vi-VN")} ký tự${cut ? ", đã cắt" : ""}`, `${raw.length} chars${cut ? ", truncated" : ""}`) };
|
|
69
83
|
}
|
|
70
|
-
if (farm())
|
|
71
|
-
return nf.attachBinary(path, kind);
|
|
72
84
|
if (size > MAX_BYTES)
|
|
73
85
|
throw new CliError(tr("Tệp quá lớn (tối đa 10 MB).", "File too large (max 10 MB)."), Exit.usage);
|
|
74
86
|
const body = readFileSync(path);
|
|
@@ -98,8 +110,6 @@ export function composeText(question, attachments, projectNotes) {
|
|
|
98
110
|
* so the server replays the stored answer instead of answering (and charging) twice.
|
|
99
111
|
*/
|
|
100
112
|
export async function ask(input) {
|
|
101
|
-
if (farm())
|
|
102
|
-
return nf.ask(input);
|
|
103
113
|
const key = randomUUID();
|
|
104
114
|
const body = {
|
|
105
115
|
sessionId: input.sessionId,
|
package/dist/auth/session.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// Order: <PREFIX>_API_KEY → <PREFIX>_TOKEN → stored session of the profile. `status` prints which one is used.
|
|
3
3
|
import { fetch } from "undici";
|
|
4
4
|
import { brand, envVar } from "../core/brand.js";
|
|
5
|
-
import { snakeKeys, snakeOnWire } from "../core/case.js";
|
|
6
5
|
import { context } from "../core/context.js";
|
|
7
6
|
import { CliError, Exit } from "../core/errors.js";
|
|
8
7
|
import { t } from "../core/i18n.js";
|
|
@@ -80,10 +79,11 @@ export function sessionFrom(body, via, prev) {
|
|
|
80
79
|
* Refreshes the stored session. `staleAccess` is the token the caller saw: if another process already
|
|
81
80
|
* replaced it while we waited for the lock, its result is reused instead of refreshing again.
|
|
82
81
|
*/
|
|
83
|
-
/** Body for `/iam/auth/refresh-token`.
|
|
82
|
+
/** Body for `/iam/auth/refresh-token`. An IAM that speaks snake_case ignores a camelCase key and answers 401 "required". */
|
|
84
83
|
export function refreshBody(url, refreshToken) {
|
|
85
84
|
const body = { refreshToken };
|
|
86
|
-
|
|
85
|
+
void url;
|
|
86
|
+
return JSON.stringify(body);
|
|
87
87
|
}
|
|
88
88
|
export async function refreshSession(staleAccess) {
|
|
89
89
|
const profile = context().profile;
|
package/dist/commands/ai.js
CHANGED
|
@@ -14,7 +14,6 @@ import { ask, attach, attachmentKind, composeText, confirmAction, listConversati
|
|
|
14
14
|
import { LocalChat } from "../ai/local.js";
|
|
15
15
|
import { mcpToken } from "../auth/mcp-oauth.js";
|
|
16
16
|
import { defaultModel, resolveModel } from "../ai/models.js";
|
|
17
|
-
import { registerAiNextFarm } from "./ai-nextfarm.js";
|
|
18
17
|
// ── NEXTX.md ───────────────────────────────────────────────────────────────────────────────────────
|
|
19
18
|
const NOTES_MAX = 4096;
|
|
20
19
|
export function notesFileName() {
|
|
@@ -88,14 +87,29 @@ export function proposalCard(a) {
|
|
|
88
87
|
* Rules (docs §2.6): read mode never writes; finance never from the terminal; no TTY ⇒ never;
|
|
89
88
|
* large writes need the typed number; auto mode skips the y/N for normal writes only.
|
|
90
89
|
*/
|
|
90
|
+
/** Thẻ đăng nhập CI (login --ci): không có người bấm, máy chủ quyết theo chính sách duyệt trước (ci policy). */
|
|
91
|
+
async function isCiSession() {
|
|
92
|
+
const { currentAuth } = await import("../auth/session.js");
|
|
93
|
+
const a = await currentAuth().catch(() => undefined);
|
|
94
|
+
return a?.kind === "session" && a.session.via === "ci";
|
|
95
|
+
}
|
|
96
|
+
/** Lệnh để người xác nhận đề xuất sau: nền tảng mặc định mở lại hội thoại, nền tảng khác có thể theo id đề xuất. */
|
|
97
|
+
function confirmHint(nonce, conversationId) {
|
|
98
|
+
return `${brand().bin} -r ${conversationId}`;
|
|
99
|
+
}
|
|
100
|
+
/** 403 của cổng ghi CI ⇒ việc chưa ghi, đề xuất còn chờ người duyệt — báo rõ, không coi là lỗi chương trình. */
|
|
101
|
+
function ciDenied(e) {
|
|
102
|
+
const err = e;
|
|
103
|
+
return err instanceof CliError && err.exitCode === Exit.forbidden ? err.message : undefined;
|
|
104
|
+
}
|
|
91
105
|
export async function handleProposal(sessionId, a, mode = context().mode) {
|
|
92
106
|
if (!a.writes) {
|
|
93
107
|
const r = await confirmAction(sessionId, a);
|
|
94
108
|
return { written: false, message: r.message };
|
|
95
109
|
}
|
|
96
110
|
const risk = riskOf(a);
|
|
97
|
-
//
|
|
98
|
-
|
|
111
|
+
// Đề xuất của nền tảng mặc định xác nhận bằng cách mở lại hội thoại.
|
|
112
|
+
let resume = `${brand().bin} -r ${sessionId}`;
|
|
99
113
|
if (mode === "read") {
|
|
100
114
|
info(c.dim(tr(" Chế độ chỉ đọc: không ghi. Đổi chế độ: --mode propose", " Read-only mode: nothing written. Switch with --mode propose")));
|
|
101
115
|
return { written: false };
|
|
@@ -107,6 +121,25 @@ export async function handleProposal(sessionId, a, mode = context().mode) {
|
|
|
107
121
|
return { written: false };
|
|
108
122
|
}
|
|
109
123
|
if (!hasTty()) {
|
|
124
|
+
// CI + --mode auto: gửi Xác nhận, máy chủ chỉ ghi trong chính sách duyệt trước của kho này (ci policy).
|
|
125
|
+
if (brand().platform === "nextx" && mode === "auto" && (await isCiSession())) {
|
|
126
|
+
try {
|
|
127
|
+
const r = await confirmAction(sessionId, a);
|
|
128
|
+
if (r.success)
|
|
129
|
+
ok(r.message || tr("Đã ghi (CI, trong phạm vi duyệt trước).", "Written (CI, within the pre-approved scope)."));
|
|
130
|
+
else
|
|
131
|
+
warn(r.message);
|
|
132
|
+
return { written: r.success, message: r.message };
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
const why = ciDenied(e);
|
|
136
|
+
if (!why)
|
|
137
|
+
throw e;
|
|
138
|
+
info(` ${c.yellow("⚠")} ${why}`);
|
|
139
|
+
info(c.dim(tr(` Cho CI tự ghi: ${brand().bin} ci policy request`, ` Let CI write: ${brand().bin} ci policy request`)));
|
|
140
|
+
return { written: false, message: why };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
110
143
|
info(tr(` Chưa ghi. Xác nhận trong 15 phút: ${c.bold(resume)}`, ` Not written. Confirm within 15 minutes: ${c.bold(resume)}`));
|
|
111
144
|
return { written: false };
|
|
112
145
|
}
|
|
@@ -248,7 +281,21 @@ async function printAsk(o, question, piped) {
|
|
|
248
281
|
const conversationId = outcome.sessionId || sessionId;
|
|
249
282
|
updateProfile(context().profile, { lastConversation: conversationId });
|
|
250
283
|
const proposal = outcome.proposedAction;
|
|
284
|
+
// Written in this run (CI pre-approval, or confirmed at the prompt) ⇒ nothing is left to confirm, exit 0.
|
|
285
|
+
let written = false;
|
|
251
286
|
if (fmt === "json") {
|
|
287
|
+
const risk = proposal ? riskOf(proposal) : undefined;
|
|
288
|
+
const allowedRisk = context().mode !== "read" && risk !== "finance" && risk !== "device";
|
|
289
|
+
let applied;
|
|
290
|
+
// Nền tảng mặc định: phiên CI + --mode auto ⇒ gửi Xác nhận, máy chủ chỉ ghi trong chính sách duyệt trước (403 ⇒ chưa ghi, kèm lý do).
|
|
291
|
+
if (!applied && proposal?.writes && brand().platform === "nextx" && allowedRisk && context().mode === "auto" && !hasTty() && (await isCiSession()))
|
|
292
|
+
applied = await confirmAction(conversationId, proposal).then((r) => ({ written: r.success, message: r.message }), (e) => {
|
|
293
|
+
const why = ciDenied(e);
|
|
294
|
+
if (!why)
|
|
295
|
+
throw e;
|
|
296
|
+
return { written: false, message: why };
|
|
297
|
+
});
|
|
298
|
+
written = applied?.written ?? false;
|
|
252
299
|
json({
|
|
253
300
|
conversationId,
|
|
254
301
|
runId: outcome.runId ?? null,
|
|
@@ -259,7 +306,8 @@ async function printAsk(o, question, piped) {
|
|
|
259
306
|
notices: outcome.notices,
|
|
260
307
|
agent: outcome.agent ?? null,
|
|
261
308
|
proposals: proposal ? [{ kind: proposal.kind, label: proposal.label, prompt: proposal.confirmPrompt, writes: proposal.writes, expiresAt: proposal.expiresAt ?? null, risk: riskOf(proposal),
|
|
262
|
-
confirm:
|
|
309
|
+
confirm: confirmHint(proposal.nonce, conversationId),
|
|
310
|
+
...(applied ? { applied: { written: applied.written, message: applied.message } } : {}) }] : [],
|
|
263
311
|
workspace: getProfile(context().profile)?.workspaceCode ?? null,
|
|
264
312
|
env: context().env,
|
|
265
313
|
ended: outcome.ended,
|
|
@@ -277,12 +325,12 @@ async function printAsk(o, question, piped) {
|
|
|
277
325
|
if (proposal) {
|
|
278
326
|
for (const l of proposalCard(proposal))
|
|
279
327
|
stderr.write(`${l}\n`);
|
|
280
|
-
await handleProposal(conversationId, proposal);
|
|
328
|
+
written = (await handleProposal(conversationId, proposal)).written;
|
|
281
329
|
}
|
|
282
330
|
}
|
|
283
331
|
if (outcome.notices.some((n) => n.code === "ai_not_purchased"))
|
|
284
332
|
process.exitCode = Exit.forbidden;
|
|
285
|
-
else if (proposal?.writes)
|
|
333
|
+
else if (proposal?.writes && !written)
|
|
286
334
|
process.exitCode = Exit.notConfirmed;
|
|
287
335
|
else if (outcome.ended === "error")
|
|
288
336
|
process.exitCode = Exit.general;
|
|
@@ -355,8 +403,6 @@ export function registerAi(program) {
|
|
|
355
403
|
await deleteConversation(id);
|
|
356
404
|
ok(tr("Đã xoá.", "Deleted."));
|
|
357
405
|
});
|
|
358
|
-
if (brand().platform === "nextfarm")
|
|
359
|
-
return registerAiNextFarm(ai, program);
|
|
360
406
|
ai.command("why [runId]")
|
|
361
407
|
.description(tr("vì sao AI trả lời vậy: các bước, công cụ đã gọi", "why the AI answered that way: steps and tools"))
|
|
362
408
|
.action(async (runId) => {
|
|
@@ -406,13 +452,16 @@ export function registerAi(program) {
|
|
|
406
452
|
const head = await findChangeset(id);
|
|
407
453
|
if (context().mode === "read")
|
|
408
454
|
throw new CliError(tr("Chế độ chỉ đọc.", "Read-only mode."), Exit.forbidden);
|
|
409
|
-
|
|
410
|
-
|
|
455
|
+
const ci = !hasTty() && (await isCiSession());
|
|
456
|
+
if (!hasTty() && !ci)
|
|
457
|
+
throw new CliError(tr("Áp dụng bản nháp cần terminal tương tác (hoặc đăng nhập CI có phạm vi duyệt trước).", "Applying needs an interactive terminal (or a CI login with a pre-approved scope)."), Exit.notConfirmed);
|
|
411
458
|
const env = context().env === "prod" ? c.red("PROD") : c.yellow("SANDBOX");
|
|
412
459
|
info(`${c.bold(head.title)} · ${env}`);
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
460
|
+
if (!ci) {
|
|
461
|
+
const yes = head.risk >= 3 ? await confirmTyped("", tr("dong-y", "confirm")) : await confirm(tr("Áp dụng?", "Apply?"));
|
|
462
|
+
if (!yes)
|
|
463
|
+
return void info(tr("Không ghi gì.", "Nothing written."));
|
|
464
|
+
}
|
|
416
465
|
const r = await post(`/ai/changesets/${head.id}/apply`, undefined, { query: { expectedVersion: head.version } });
|
|
417
466
|
emit(r, () => ok(tr(`Đã áp dụng · hoàn tác: ${brand().bin} ai undo ${short(head.id)}`, `Applied · undo: ${brand().bin} ai undo ${short(head.id)}`)));
|
|
418
467
|
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { brand } from "../core/brand.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 { get, post } from "../core/http.js";
|
|
6
|
+
import { confirm, hasTty } from "../core/prompt.js";
|
|
7
|
+
import { context } from "../core/context.js";
|
|
8
|
+
import { requirePerson } from "../auth/session.js";
|
|
9
|
+
import { parseDuration } from "./keys.js";
|
|
10
|
+
const BASE = "/ai/ci-write-policies";
|
|
11
|
+
// Ví dụ + nơi duyệt theo nền tảng: có nền tảng ghi theo loại dòng nhật ký, nền tảng mặc định theo mã quyền ghi.
|
|
12
|
+
const exampleKinds = () => brand().examples.ciKinds;
|
|
13
|
+
function approvePlace() {
|
|
14
|
+
return tr("Thiết lập › Ứng dụng & API › CI", "Settings › Apps & API › CI");
|
|
15
|
+
}
|
|
16
|
+
const day = (s) => (s ? s.slice(0, 10) : "—");
|
|
17
|
+
const shortId = (id) => id.slice(-8);
|
|
18
|
+
function stateLabel(s) {
|
|
19
|
+
switch (s) {
|
|
20
|
+
case "active": return c.green(tr("đang hiệu lực", "active"));
|
|
21
|
+
case "pending": return c.yellow(tr("chờ duyệt", "pending"));
|
|
22
|
+
case "exhausted": return c.dim(tr("hết trần", "ceiling reached"));
|
|
23
|
+
case "expired": return c.dim(tr("hết hạn", "expired"));
|
|
24
|
+
default: return c.red(tr("đã thu hồi", "revoked"));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function findPolicy(id) {
|
|
28
|
+
// Id UUID v7: 8 ký tự ĐẦU trùng nhau giữa các chính sách tạo cùng lúc (tiền tố thời gian) ⇒ bảng hiện 8 ký tự CUỐI, tìm khớp cả hai đầu.
|
|
29
|
+
const all = (await get(BASE)).items;
|
|
30
|
+
const hits = all.filter((x) => x.id === id || x.id.startsWith(id) || x.id.endsWith(id));
|
|
31
|
+
if (hits.length > 1)
|
|
32
|
+
throw new CliError(tr("Mã ngắn trùng nhiều chính sách — gõ thêm ký tự.", "Short id matches several policies — type more characters."), Exit.usage);
|
|
33
|
+
const p = hits[0];
|
|
34
|
+
if (!p)
|
|
35
|
+
throw new CliError(tr("Không thấy chính sách này.", "Policy not found."), Exit.notFound);
|
|
36
|
+
return p;
|
|
37
|
+
}
|
|
38
|
+
export function registerCiPolicy(ci) {
|
|
39
|
+
const policy = ci.command("policy")
|
|
40
|
+
.description(tr("chính sách duyệt trước: cho CI tự ghi trong trần", "pre-approval policies: let CI write within a ceiling"));
|
|
41
|
+
policy.hook("preAction", async () => { await requirePerson(); });
|
|
42
|
+
policy.command("list", { isDefault: true }).action(async () => {
|
|
43
|
+
const { items } = await get(BASE);
|
|
44
|
+
emit(items, () => info(items.length
|
|
45
|
+
? table(["ID", tr("Kho", "Repository"), tr("Được ghi", "Kinds"), tr("Đã dùng", "Used"), tr("Hết hạn", "Expires"), ""], items.map((p) => [
|
|
46
|
+
shortId(p.id), p.repository ?? shortId(p.ciTrustId), p.kinds.join(", "), `${p.usedRecords}/${p.maxRecords}`, day(p.expiresAt), stateLabel(p.state),
|
|
47
|
+
]))
|
|
48
|
+
: tr(`Chưa có chính sách — CI chỉ in link để xác nhận. Xin: ${brand().bin} ci policy request --trust <kho> --kinds ${exampleKinds()}`, `No policy — CI only prints a link to confirm. Request one: ${brand().bin} ci policy request --trust <repo> --kinds ${exampleKinds()}`)));
|
|
49
|
+
});
|
|
50
|
+
policy.command("kinds").description(tr("loại ghi đưa được vào chính sách", "kinds a policy can cover")).action(async () => {
|
|
51
|
+
const { items } = await get(`${BASE}/kinds`);
|
|
52
|
+
emit(items, () => info(items.join("\n")));
|
|
53
|
+
});
|
|
54
|
+
policy.command("show <id>").action(async (id) => {
|
|
55
|
+
const p = await findPolicy(id);
|
|
56
|
+
emit(p, () => {
|
|
57
|
+
info(`${c.bold(p.repository ?? p.ciTrustId)} ${stateLabel(p.state)}`);
|
|
58
|
+
info(tr(`Được ghi: ${p.kinds.join(", ")}`, `Kinds: ${p.kinds.join(", ")}`));
|
|
59
|
+
info(tr(`Đã dùng: ${p.usedRecords}/${p.maxRecords} · hết hạn ${day(p.expiresAt)} · ghi lần cuối ${day(p.lastUsedAt)}`, `Used: ${p.usedRecords}/${p.maxRecords} · expires ${day(p.expiresAt)} · last write ${day(p.lastUsedAt)}`));
|
|
60
|
+
if (p.note)
|
|
61
|
+
info(c.dim(p.note));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
policy.command("request")
|
|
65
|
+
.description(tr("xin cho một kho CI tự ghi (người quản lý khoá API duyệt; có quyền thì có hiệu lực ngay)", "ask to let a CI repository write (an API-key manager approves; with that permission it is active at once)"))
|
|
66
|
+
.requiredOption("--trust <id|owner/repo>", tr("khai báo CI (xem: ci trust list)", "CI trust (see: ci trust list)"))
|
|
67
|
+
.requiredOption("--kinds <a,b>", tr("loại ghi (xem: ci policy kinds)", "kinds (see: ci policy kinds)"))
|
|
68
|
+
.option("--max <n>", tr("trần số bản ghi (1–1000)", "record ceiling (1–1000)"), "50")
|
|
69
|
+
.option("--expires <dur>", tr("thời hạn tối đa 30d", "lifetime, at most 30d"), "14d")
|
|
70
|
+
.option("--note <text>")
|
|
71
|
+
.option("--approve", tr("duyệt luôn (cần quyền quản lý khoá API)", "approve now (needs API-key manage permission)"))
|
|
72
|
+
.action(async (o) => {
|
|
73
|
+
const want = o.trust.trim().toLowerCase();
|
|
74
|
+
const trust = (await get("/iam/ci-trusts")).find((t) => !t.revokedAt && (t.id.startsWith(want) || t.repository === want));
|
|
75
|
+
if (!trust)
|
|
76
|
+
throw new CliError(tr(`Không thấy kho CI "${o.trust}".`, `No CI trust "${o.trust}".`), Exit.notFound, `${brand().bin} ci trust list`);
|
|
77
|
+
const max = Number(o.max);
|
|
78
|
+
if (!Number.isInteger(max) || max < 1 || max > 1000)
|
|
79
|
+
throw new CliError(tr("--max từ 1 đến 1000.", "--max must be 1–1000."), Exit.usage);
|
|
80
|
+
const p = await post(BASE, {
|
|
81
|
+
ciTrustId: trust.id, repository: trust.repository, kinds: o.kinds.split(",").map((k) => k.trim()).filter(Boolean),
|
|
82
|
+
maxRecords: max, expiresAt: parseDuration(o.expires), note: o.note ?? null, approve: !!o.approve,
|
|
83
|
+
});
|
|
84
|
+
emit(p, () => {
|
|
85
|
+
ok(p.state === "active"
|
|
86
|
+
? tr(`CI của ${trust.repository} được tự ghi ${p.kinds.join(", ")} — tối đa ${p.maxRecords} bản ghi tới ${day(p.expiresAt)}.`, `CI of ${trust.repository} may write ${p.kinds.join(", ")} — up to ${p.maxRecords} records until ${day(p.expiresAt)}.`)
|
|
87
|
+
: tr(`Đã gửi duyệt (${shortId(p.id)}). Người quản lý duyệt tại ${context().endpoints.app} › ${approvePlace()}.`, `Sent for approval (${shortId(p.id)}). A manager approves in ${context().endpoints.app} › ${approvePlace()}.`));
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
policy.command("revoke <id>").description(tr("thu hồi (lượt ghi kế tiếp quay về chỉ in link)", "revoke (the next write goes back to a link)"))
|
|
91
|
+
.action(async (id) => {
|
|
92
|
+
const p = await findPolicy(id);
|
|
93
|
+
if (hasTty() && !(await confirm(tr(`Thu hồi chính sách của ${p.repository ?? shortId(p.id)}?`, `Revoke the policy for ${p.repository ?? shortId(p.id)}?`))))
|
|
94
|
+
return;
|
|
95
|
+
await post(`${BASE}/${p.id}/revoke`, {});
|
|
96
|
+
ok(tr("Đã thu hồi.", "Revoked."));
|
|
97
|
+
});
|
|
98
|
+
}
|
package/dist/commands/ci.js
CHANGED
|
@@ -6,6 +6,13 @@ import { get, post } from "../core/http.js";
|
|
|
6
6
|
import { confirm, hasTty } from "../core/prompt.js";
|
|
7
7
|
import { requirePerson } from "../auth/session.js";
|
|
8
8
|
import { parseDuration } from "./keys.js";
|
|
9
|
+
import { registerCiPolicy } from "./ci-policy.js";
|
|
10
|
+
async function findTrust(id) {
|
|
11
|
+
const t = (await get("/iam/ci-trusts")).find((x) => x.id.startsWith(id) || x.repository === id);
|
|
12
|
+
if (!t)
|
|
13
|
+
throw new CliError(tr("Không thấy khai báo này.", "Not found."), Exit.notFound);
|
|
14
|
+
return t;
|
|
15
|
+
}
|
|
9
16
|
const when = (s) => (s ? s.slice(0, 16).replace("T", " ") : "—");
|
|
10
17
|
export function workflowSample(repo = "acme/web", ws = "my-workspace") {
|
|
11
18
|
const b = brand();
|
|
@@ -20,6 +27,21 @@ jobs:
|
|
|
20
27
|
- run: npx -y ${b.pkg} login --ci --workspace ${ws}
|
|
21
28
|
- run: npx -y ${b.pkg} -p "${tr(b.example.vi, b.example.en)}" --json > report.json`;
|
|
22
29
|
}
|
|
30
|
+
/** Mẫu CI có GHI: chỉ chạy được sau khi quản trị mở phạm vi (ci trust set --allow … --max-per-run …). */
|
|
31
|
+
export function workflowWriteSample(repo = "acme/web", ws = "my-workspace") {
|
|
32
|
+
const b = brand();
|
|
33
|
+
return `# .github/workflows/follow-up.yml (${repo})
|
|
34
|
+
# ${tr("Ghi chỉ trong chính sách duyệt trước (ci policy request); ngoài phạm vi thì để lại đề xuất chờ người duyệt (mã thoát 7).", "Writes only within an approved policy (ci policy request); anything else stays a proposal for a person (exit code 7).")}
|
|
35
|
+
permissions:
|
|
36
|
+
id-token: write
|
|
37
|
+
contents: read
|
|
38
|
+
jobs:
|
|
39
|
+
follow-up:
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
steps:
|
|
42
|
+
- run: npx -y ${b.pkg} login --ci --workspace ${ws}
|
|
43
|
+
- run: npx -y ${b.pkg} -p "${tr("tạo việc gọi lại cho khách quá hạn chăm sóc 7 ngày", "create call-back tasks for customers not contacted in 7 days")}" --mode auto`;
|
|
44
|
+
}
|
|
23
45
|
export function registerCi(program) {
|
|
24
46
|
const ci = program.command("ci").description(tr("đăng nhập từ CI không cần bí mật (OIDC)", "secretless CI login (OIDC)"));
|
|
25
47
|
const trust = ci.command("trust").description(tr("kho CI được phép đăng nhập workspace này", "CI repositories allowed to log in to this workspace"));
|
|
@@ -53,20 +75,22 @@ export function registerCi(program) {
|
|
|
53
75
|
});
|
|
54
76
|
emit(t, () => {
|
|
55
77
|
ok(tr(`Đã cho ${t.provider}:${t.repository} (${t.refPattern}) đăng nhập`, `Allowed ${t.provider}:${t.repository} (${t.refPattern})`));
|
|
78
|
+
info(c.dim(tr(`\nCho CI tự ghi trong trần: ${brand().bin} ci policy request --trust ${t.repository} --kinds <mã>`, `\nLet CI write within a ceiling: ${brand().bin} ci policy request --trust ${t.repository} --kinds <code>`)));
|
|
56
79
|
info(c.dim(tr("\nMẫu workflow:", "\nSample workflow:")));
|
|
57
80
|
info(workflowSample(t.repository));
|
|
58
81
|
});
|
|
59
82
|
});
|
|
60
83
|
trust.command("revoke <id>").description(tr("thu hồi (có hiệu lực ngay)", "revoke (immediate)")).action(async (id) => {
|
|
61
|
-
const t =
|
|
62
|
-
if (!t)
|
|
63
|
-
throw new CliError(tr("Không thấy khai báo này.", "Not found."), Exit.notFound);
|
|
84
|
+
const t = await findTrust(id);
|
|
64
85
|
if (hasTty() && !(await confirm(tr(`Thu hồi ${t.repository}?`, `Revoke ${t.repository}?`))))
|
|
65
86
|
return;
|
|
66
87
|
await post(`/iam/ci-trusts/${t.id}/revoke`, {});
|
|
67
88
|
ok(tr("Đã thu hồi.", "Revoked."));
|
|
68
89
|
});
|
|
90
|
+
// Chính sách duyệt trước: cùng hợp đồng /ai/ci-write-policies trên mọi nền tảng.
|
|
91
|
+
registerCiPolicy(ci);
|
|
69
92
|
ci.command("example").description(tr("in mẫu workflow GitHub Actions", "print a sample GitHub Actions workflow"))
|
|
70
93
|
.option("--repo <owner/repo>").option("--workspace <code>")
|
|
71
|
-
.
|
|
94
|
+
.option("--write", tr("mẫu có tự ghi (cần chính sách: ci policy request)", "sample that writes (needs a policy: ci policy request)"))
|
|
95
|
+
.action((o) => info(o.write ? workflowWriteSample(o.repo, o.workspace) : workflowSample(o.repo, o.workspace)));
|
|
72
96
|
}
|
package/dist/commands/keys.js
CHANGED
|
@@ -20,6 +20,7 @@ async function findKey(id) {
|
|
|
20
20
|
throw new CliError(tr("Không thấy khoá này.", "Key not found."), Exit.notFound);
|
|
21
21
|
return k;
|
|
22
22
|
}
|
|
23
|
+
const scopeExample = () => brand().examples.scopes;
|
|
23
24
|
export function registerKeys(program) {
|
|
24
25
|
const keys = program.command("keys").description(tr("khoá API của workspace", "workspace API keys"));
|
|
25
26
|
keys.hook("preAction", async () => void (await requirePerson()));
|
|
@@ -33,7 +34,7 @@ export function registerKeys(program) {
|
|
|
33
34
|
keys.command("create")
|
|
34
35
|
.description(tr("tạo khoá mới (in khoá đúng một lần)", "create a key (printed once)"))
|
|
35
36
|
.requiredOption("--name <name>")
|
|
36
|
-
.requiredOption("--scopes <codes>", tr(
|
|
37
|
+
.requiredOption("--scopes <codes>", tr(`mã quyền, phẩy ngăn cách, vd ${scopeExample()}`, `permission codes, comma separated, e.g. ${scopeExample()}`))
|
|
37
38
|
.option("--expires <dur>", tr("thời hạn: 30d, 6m, 1y", "lifetime: 30d, 6m, 1y"), "90d")
|
|
38
39
|
.option("--description <text>")
|
|
39
40
|
.action(async (o) => {
|
package/dist/commands/mcp.js
CHANGED
|
@@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process";
|
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
6
|
import { fetch } from "undici";
|
|
7
7
|
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
8
|
-
import { brand
|
|
8
|
+
import { brand } from "../core/brand.js";
|
|
9
9
|
import { context } from "../core/context.js";
|
|
10
10
|
import { CliError, Exit } from "../core/errors.js";
|
|
11
11
|
import { tr } from "../core/i18n.js";
|
|
@@ -138,8 +138,8 @@ export function registerMcp(program) {
|
|
|
138
138
|
}
|
|
139
139
|
/** Line-delimited JSON-RPC on stdio ⇄ Streamable HTTP to the hosted server. Logs go to stderr only. */
|
|
140
140
|
async function serveStdio() {
|
|
141
|
-
//
|
|
142
|
-
|
|
141
|
+
// On some platforms an API key speaks MCP through the gateway (same read-only route rules as the key everywhere else).
|
|
142
|
+
let apiKey = undefined;
|
|
143
143
|
const url = apiKey ? `${context().endpoints.api}/api/v1/mcp` : context().endpoints.mcp;
|
|
144
144
|
let token = apiKey ?? (await mcpToken({ interactive: false }));
|
|
145
145
|
let sessionId;
|
package/dist/commands/tools.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Option } from "commander";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { context } from "../core/context.js";
|
|
4
3
|
import { brand, envVar } from "../core/brand.js";
|
|
5
4
|
import { CliError, Exit } from "../core/errors.js";
|
|
6
5
|
import { currentLang, tr } from "../core/i18n.js";
|
|
@@ -12,17 +11,12 @@ import { authorizeMcp, readGrant, revokeGrant } from "../auth/mcp-oauth.js";
|
|
|
12
11
|
import { browserAvailable } from "./auth.js";
|
|
13
12
|
let publicCache;
|
|
14
13
|
export async function publicTools() {
|
|
15
|
-
publicCache ??= (
|
|
14
|
+
publicCache ??= (await loadPublicTools())
|
|
16
15
|
.filter((t) => !brand().modules || brand().modules.includes(t.module));
|
|
17
16
|
return publicCache;
|
|
18
17
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const r = await get("/ai/public/tools", { anonymous: true, verbatim: true });
|
|
22
|
-
return r.tools.map((t) => ({
|
|
23
|
-
name: t.name, module: t.service, title: t.title, summary: t.summary, summaryEn: t.summary_en, scope: t.scope,
|
|
24
|
-
kind: t.scope?.endsWith(":write") ? "write" : "read", parameters: t.parameters, exampleArgs: t.example_args,
|
|
25
|
-
}));
|
|
18
|
+
async function loadPublicTools() {
|
|
19
|
+
return get("/ai/public/tools", { anonymous: true });
|
|
26
20
|
}
|
|
27
21
|
/** MCP names may differ from catalog names (module prefix, dots); match loosely. */
|
|
28
22
|
export function sameTool(a, b) {
|
|
@@ -161,21 +155,7 @@ export function registerTools(program) {
|
|
|
161
155
|
.action(async (name, o) => {
|
|
162
156
|
const args = parseArgs(o);
|
|
163
157
|
let value;
|
|
164
|
-
if (envVar("API_KEY")
|
|
165
|
-
// NextFarm has no REST door for tools: the API key speaks MCP through the gateway, which keeps its read-only route rules.
|
|
166
|
-
const client = new McpClient(false, envVar("API_KEY"), `${context().endpoints.api}/api/v1/mcp`);
|
|
167
|
-
const tool = (await client.listTools()).find((t) => sameTool(t.name, name));
|
|
168
|
-
if (!tool)
|
|
169
|
-
throw new CliError(tr("Khoá này không thấy công cụ đó (thiếu phạm vi?).", "This key cannot see that tool (missing scope?)."), Exit.notFound, `${brand().bin} tools list`);
|
|
170
|
-
const r = await client.callTool(tool.name, args);
|
|
171
|
-
value = resultValue(r);
|
|
172
|
-
if (r.isError) {
|
|
173
|
-
emit({ error: value }, () => info(c.red(typeof value === "string" ? value : JSON.stringify(value, null, 2))));
|
|
174
|
-
process.exitCode = Exit.general;
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
else if (envVar("API_KEY")) {
|
|
158
|
+
if (envVar("API_KEY")) {
|
|
179
159
|
const pub = (await publicTools()).find((p) => sameTool(p.name, name));
|
|
180
160
|
if (!pub)
|
|
181
161
|
throw new CliError(tr("Không thấy công cụ công khai này.", "Public tool not found."), Exit.notFound);
|
|
@@ -9,25 +9,27 @@ import { c, emit, info, json, ok, table, warn } from "../core/output.js";
|
|
|
9
9
|
import { get, post, del, requestRaw, errorFor } from "../core/http.js";
|
|
10
10
|
import { confirm, hasTty } from "../core/prompt.js";
|
|
11
11
|
const when = (iso) => (iso ? iso.slice(0, 19).replace("T", " ") : "");
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
/** Platforms whose webhook contract differs (event list shape, endpoints with many events, per-endpoint deliveries, test call). */
|
|
13
|
+
let farm = () => false;
|
|
14
|
+
/** Signature headers per platform: `…-Signature-256` by default (sha256=HMAC(ts.body)). */
|
|
15
|
+
function sigHeader() {
|
|
16
|
+
return "x-nextx-signature-256";
|
|
17
|
+
}
|
|
18
|
+
function tsHeader() {
|
|
19
|
+
return "x-nextx-timestamp";
|
|
20
|
+
}
|
|
21
|
+
function sigScheme() {
|
|
22
|
+
return "Signature-256";
|
|
23
|
+
}
|
|
16
24
|
export async function eventTypes() {
|
|
17
|
-
|
|
18
|
-
return get("/webhook/public/event-types", { anonymous: true });
|
|
19
|
-
const r = await get("/webhook/public/event-types", { anonymous: true, verbatim: true });
|
|
20
|
-
return r.events.map((e) => ({
|
|
21
|
-
event: e.event, service: e.group, title: currentLang() === "en" ? e.title_en ?? e.title : e.title, description: e.description,
|
|
22
|
-
descriptionEn: e.description_en, planRequired: e.scope, payloadSchema: e.payload_schema, example: e.example,
|
|
23
|
-
}));
|
|
25
|
+
return get("/webhook/public/event-types", { anonymous: true });
|
|
24
26
|
}
|
|
25
|
-
/**
|
|
27
|
+
/** Some platforms send `eventTypes` per endpoint and no single module. */
|
|
26
28
|
async function listEndpoints() {
|
|
27
29
|
const list = await get("/webhook/endpoints");
|
|
28
30
|
return farm() ? list.map((e) => ({ ...e, events: e.eventTypes ?? [], status: e.isActive ? "active" : "disabled" })) : list;
|
|
29
31
|
}
|
|
30
|
-
/**
|
|
32
|
+
/** Some platforms keep deliveries per endpoint only; the CLI merges them. */
|
|
31
33
|
async function listDeliveries(o) {
|
|
32
34
|
if (!farm()) {
|
|
33
35
|
const path = o.endpoint ? `/webhook/endpoints/${(await findEndpoint(o.endpoint)).id}/deliveries` : "/webhook/deliveries";
|
|
@@ -53,7 +55,7 @@ export function verifySignature(secret, body, headers, toleranceSec = 300) {
|
|
|
53
55
|
const expected = "sha256=" + createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");
|
|
54
56
|
const got = [h[sigHeader()], h[`${sigHeader()}-previous`]].filter(Boolean);
|
|
55
57
|
if (got.some((g) => eq(g, expected)))
|
|
56
|
-
return { ok: true, scheme:
|
|
58
|
+
return { ok: true, scheme: sigScheme() };
|
|
57
59
|
}
|
|
58
60
|
if (h["webhook-signature"] && h["webhook-id"] && ts) {
|
|
59
61
|
const key = secret.startsWith("whsec_") ? Buffer.from(secret.slice(6), "base64") : Buffer.from(secret);
|
|
@@ -146,7 +148,7 @@ export function registerWebhooks(program) {
|
|
|
146
148
|
if (!target)
|
|
147
149
|
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.`));
|
|
148
150
|
if (farm()) {
|
|
149
|
-
//
|
|
151
|
+
// This platform sends a signed `webhook.test` and answers with the receiver's status straight away.
|
|
150
152
|
const t = await post(`/webhook/endpoints/${target.id}/test`, {});
|
|
151
153
|
emit(t, () => (t.ok ? ok : warn)(tr(`Đã gửi webhook.test → ${target.url}`, `Sent webhook.test → ${target.url}`) + ` · ${t.statusCode ? `HTTP ${t.statusCode}` : t.error ?? ""} · ${t.durationMs}ms`));
|
|
152
154
|
if (!t.ok)
|
package/dist/core/brand.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// One codebase, several branded binaries. The entry file (src/bin/<brand>.ts) picks the brand before
|
|
2
2
|
// anything else is imported; every user-facing name, host and storage location comes from here.
|
|
3
|
-
// <brand:nextx>
|
|
4
3
|
const NEXTX = {
|
|
5
4
|
id: "nextx",
|
|
6
5
|
platform: "nextx",
|
|
@@ -11,6 +10,7 @@ const NEXTX = {
|
|
|
11
10
|
clientId: "nextx-cli",
|
|
12
11
|
envPrefix: "NEXTX",
|
|
13
12
|
mobileApp: "NextX",
|
|
13
|
+
examples: { scopes: "crm.contacts.view,sales.orders.view", ciKinds: "crm.tasks.create" },
|
|
14
14
|
example: { vi: "doanh thu hôm qua", en: "yesterday's revenue" },
|
|
15
15
|
apiExample: "GET /crm/contacts -f q=Minh",
|
|
16
16
|
ciSettings: { vi: "Thiết lập › Ứng dụng & API › CI", en: "Settings › Apps & API › CI" },
|
|
@@ -34,10 +34,9 @@ const NEXTX = {
|
|
|
34
34
|
},
|
|
35
35
|
},
|
|
36
36
|
};
|
|
37
|
-
// </brand:nextx>
|
|
38
37
|
// Exported trees keep only their own brand (scripts/export.sh strips the other block and its line here).
|
|
39
38
|
const BRANDS = {
|
|
40
|
-
nextx: NEXTX,
|
|
39
|
+
nextx: NEXTX,
|
|
41
40
|
};
|
|
42
41
|
let current = Object.values(BRANDS)[0];
|
|
43
42
|
export function setBrand(id) {
|