@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 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 (`nextx`), with slash commands, file attachments, permission modes and confirmed writes.
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) => farm() ? nf.openConversation(forceNew) : post(`${BASE}/init`, { forceNew });
15
- export const listConversations = (limit = 30) => farm() ? nf.listConversations(limit) : get(`${BASE}/conversations`, { query: { limit } });
16
- export const renameConversation = (id, title) => farm() ? nf.renameConversation(id, title) : patch(`${BASE}/conversations/${id}/title`, { title });
17
- export const pinConversation = (id, pinned) => farm() ? nf.pinConversation(id, pinned) : put(`${BASE}/conversations/${id}/pin`, { pinned });
18
- export const deleteConversation = (id) => farm() ? nf.deleteConversation(id) : del(`${BASE}/conversations/${id}`);
19
- export const history = (sessionId) => farm() ? nf.history(sessionId) : get(`${BASE}/history`, { query: { sessionId } });
20
- export const feedback = (messageId, helpful) => farm() ? nf.feedback(messageId, helpful) : post(`${BASE}/messages/${messageId}/feedback`, { helpful });
21
- export const escalate = (sessionId, topic) => farm() ? nf.escalate(sessionId, topic) : post(`${BASE}/escalate`, { sessionId, topic });
22
- export const usage = () => farm() ? nf.usage() : get(`${BASE}/usage`);
23
- export const confirmAction = (sessionId, a) => farm() ? nf.confirmAction(sessionId, a) :
24
- post(`${BASE}/${sessionId}/confirm-action`, { kind: a.kind, payloadJson: a.payloadJson, nonce: a.nonce });
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,
@@ -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`. NextFarm IAM is snake_case: a camelCase key is ignored and answers 401 "required". */
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
- return JSON.stringify(snakeOnWire(url) ? snakeKeys(body) : body);
85
+ void url;
86
+ return JSON.stringify(body);
87
87
  }
88
88
  export async function refreshSession(staleAccess) {
89
89
  const profile = context().profile;
@@ -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
- // NextFarm proposals are confirmed by id; the default platform's by reopening the conversation.
98
- const resume = brand().platform === "nextfarm" ? `${brand().bin} ai apply ${a.nonce}` : `${brand().bin} -r ${sessionId}`;
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: brand().platform === "nextfarm" ? `${brand().bin} ai apply ${proposal.nonce}` : `${brand().bin} -r ${conversationId}` }] : [],
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
- if (!hasTty())
410
- throw new CliError(tr("Áp dụng bản nháp cần terminal tương tác (hoặc chính sách duyệt trước).", "Applying needs an interactive terminal (or a pre-approval policy)."), Exit.notConfirmed);
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
- const yes = head.risk >= 3 ? await confirmTyped("", tr("dong-y", "confirm")) : await confirm(tr("Áp dụng?", "Apply?"));
414
- if (!yes)
415
- return void info(tr("Không ghi gì.", "Nothing written."));
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
+ }
@@ -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 = (await get("/iam/ci-trusts")).find((x) => x.id.startsWith(id) || x.repository === id);
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
- .action((o) => info(workflowSample(o.repo, o.workspace)));
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
  }
@@ -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("mã quyền, phẩy ngăn cách, vd crm.contacts.view,sales.orders.view", "permission codes, comma separated, e.g. crm.contacts.view,sales.orders.view"))
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) => {
@@ -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, envVar } from "../core/brand.js";
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
- // NextFarm API keys speak MCP through the gateway (same read-only route rules as the key everywhere else).
142
- const apiKey = brand().platform === "nextfarm" ? envVar("API_KEY") : undefined;
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;
@@ -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 ??= (brand().platform === "nextfarm" ? await nextFarmPublicTools() : await get("/ai/public/tools", { anonymous: true }))
14
+ publicCache ??= (await loadPublicTools())
16
15
  .filter((t) => !brand().modules || brand().modules.includes(t.module));
17
16
  return publicCache;
18
17
  }
19
- /** NextFarm: `{ version, tools: [{ name, service, scope, title, title_en, summary, summary_en, parameters, example_args }] }`, read verbatim so schema property names stay as the tool expects them. */
20
- async function nextFarmPublicTools() {
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") && brand().platform === "nextfarm") {
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
- const farm = () => brand().platform === "nextfarm";
13
- /** Signature headers per platform: `X-Nextfarm-Signature` on NextFarm, `…-Signature-256` otherwise (both sha256=HMAC(ts.body)). */
14
- const sigHeader = () => (farm() ? "x-nextfarm-signature" : "x-nextx-signature-256");
15
- const tsHeader = () => (farm() ? "x-nextfarm-timestamp" : "x-nextx-timestamp");
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
- if (!farm())
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
- /** NextFarm endpoints carry `eventTypes` and no single module. */
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
- /** NextFarm keeps deliveries per endpoint only; the CLI merges them. */
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: farm() ? "X-Nextfarm-Signature" : "Signature-256" };
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
- // NextFarm sends a signed `webhook.test` and answers with the receiver's status straight away.
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)
@@ -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, // <brand-line:nextx>
39
+ nextx: NEXTX,
41
40
  };
42
41
  let current = Object.values(BRANDS)[0];
43
42
  export function setBrand(id) {