@nextxai/cli 0.1.1 → 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 +19 -1
- package/dist/ai/client.js +32 -10
- package/dist/ai/local.js +286 -0
- package/dist/ai/models.js +87 -0
- package/dist/auth/ci.js +1 -1
- package/dist/auth/mcp-oauth.js +10 -23
- package/dist/auth/session.js +7 -1
- package/dist/commands/ai.js +122 -19
- package/dist/commands/api.js +3 -2
- package/dist/commands/auth.js +7 -3
- package/dist/commands/ci-policy.js +98 -0
- package/dist/commands/ci.js +96 -0
- package/dist/commands/help.js +2 -2
- package/dist/commands/keys.js +2 -1
- package/dist/commands/mcp.js +5 -3
- package/dist/commands/model.js +106 -0
- package/dist/commands/tools.js +5 -1
- package/dist/commands/util.js +1 -1
- package/dist/commands/webhooks.js +65 -14
- package/dist/core/brand.js +6 -3
- package/dist/core/errors.js +4 -0
- package/dist/core/http.js +15 -4
- package/dist/mcp/client.js +5 -2
- package/dist/program.js +5 -0
- package/dist/repl/App.js +53 -18
- package/dist/repl/index.js +1 -1
- package/dist/repl/slash.js +11 -7
- package/package.json +2 -1
package/dist/commands/ai.js
CHANGED
|
@@ -10,7 +10,10 @@ import { get, post, put, del } from "../core/http.js";
|
|
|
10
10
|
import { getProfile, updateProfile } from "../core/config.js";
|
|
11
11
|
import { hasTty, confirm, confirmTyped, select } from "../core/prompt.js";
|
|
12
12
|
import { requirePerson, withWorkspace } from "../auth/session.js";
|
|
13
|
-
import { ask, attach, composeText, confirmAction, listConversations, openConversation, renameConversation, pinConversation, deleteConversation, history, riskOf, confirmWord, } from "../ai/client.js";
|
|
13
|
+
import { ask, attach, attachmentKind, composeText, confirmAction, listConversations, openConversation, renameConversation, pinConversation, deleteConversation, history, riskOf, confirmWord, } from "../ai/client.js";
|
|
14
|
+
import { LocalChat } from "../ai/local.js";
|
|
15
|
+
import { mcpToken } from "../auth/mcp-oauth.js";
|
|
16
|
+
import { defaultModel, resolveModel } from "../ai/models.js";
|
|
14
17
|
// ── NEXTX.md ───────────────────────────────────────────────────────────────────────────────────────
|
|
15
18
|
const NOTES_MAX = 4096;
|
|
16
19
|
export function notesFileName() {
|
|
@@ -64,7 +67,8 @@ export function renderBlocks(blocks) {
|
|
|
64
67
|
export function proposalCard(a) {
|
|
65
68
|
const env = context().env === "prod" ? c.red(tr("GHI VÀO PROD", "WRITES TO PROD")) : c.yellow("SANDBOX");
|
|
66
69
|
const risk = riskOf(a);
|
|
67
|
-
const tag = risk === "large" ? c.red(tr("GHI LỚN · ", "LARGE WRITE · ")) : risk === "finance" ? c.red(tr("TÀI CHÍNH · ", "FINANCE · "))
|
|
70
|
+
const tag = risk === "large" ? c.red(tr("GHI LỚN · ", "LARGE WRITE · ")) : risk === "finance" ? c.red(tr("TÀI CHÍNH · ", "FINANCE · "))
|
|
71
|
+
: risk === "device" ? c.red(tr("THIẾT BỊ · ", "DEVICE · ")) : "";
|
|
68
72
|
const left = a.expiresAt ? Math.max(0, Math.round((Date.parse(a.expiresAt) - Date.now()) / 60000)) : 15;
|
|
69
73
|
const lines = [
|
|
70
74
|
c.yellow(`┌ ${tr("Đề xuất", "Proposal")} `) + tag + env + c.yellow(` · ${tr(`hết hạn sau ${left} phút`, `expires in ${left} min`)}`),
|
|
@@ -83,22 +87,59 @@ export function proposalCard(a) {
|
|
|
83
87
|
* Rules (docs §2.6): read mode never writes; finance never from the terminal; no TTY ⇒ never;
|
|
84
88
|
* large writes need the typed number; auto mode skips the y/N for normal writes only.
|
|
85
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
|
+
}
|
|
86
105
|
export async function handleProposal(sessionId, a, mode = context().mode) {
|
|
87
106
|
if (!a.writes) {
|
|
88
107
|
const r = await confirmAction(sessionId, a);
|
|
89
108
|
return { written: false, message: r.message };
|
|
90
109
|
}
|
|
91
110
|
const risk = riskOf(a);
|
|
92
|
-
|
|
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}`;
|
|
93
113
|
if (mode === "read") {
|
|
94
114
|
info(c.dim(tr(" Chế độ chỉ đọc: không ghi. Đổi chế độ: --mode propose", " Read-only mode: nothing written. Switch with --mode propose")));
|
|
95
115
|
return { written: false };
|
|
96
116
|
}
|
|
97
|
-
if (risk === "finance") {
|
|
98
|
-
info(c.dim(
|
|
117
|
+
if (risk === "finance" || risk === "device") {
|
|
118
|
+
info(c.dim(risk === "device"
|
|
119
|
+
? tr(` Việc chạm thiết bị / tự động hoá chỉ xác nhận trong app: ${context().endpoints.app}`, ` Device and automation actions are confirmed in the app only: ${context().endpoints.app}`)
|
|
120
|
+
: tr(` Việc tài chính chỉ xác nhận trong app: ${context().endpoints.app}`, ` Finance actions are confirmed in the app only: ${context().endpoints.app}`)));
|
|
99
121
|
return { written: false };
|
|
100
122
|
}
|
|
101
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
|
+
}
|
|
102
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)}`));
|
|
103
144
|
return { written: false };
|
|
104
145
|
}
|
|
@@ -154,17 +195,59 @@ export async function resolveConversation(o) {
|
|
|
154
195
|
}
|
|
155
196
|
export async function runRoot(o, words) {
|
|
156
197
|
const printMode = o.print !== undefined && o.print !== false;
|
|
198
|
+
const spec = o.model ?? defaultModel();
|
|
199
|
+
const model = spec ? resolveModel(spec) : undefined;
|
|
157
200
|
if (!printMode && hasTty() && outputFormat() === "text") {
|
|
158
201
|
await requirePerson();
|
|
202
|
+
// The MCP grant may need a browser round-trip; do it before the chat UI owns the terminal.
|
|
203
|
+
if (model)
|
|
204
|
+
await mcpToken({ interactive: true });
|
|
159
205
|
const { startRepl } = await import("../repl/index.js");
|
|
160
|
-
return withWorkspace(context().workspace, () => startRepl({ flags: o, initialQuestion: words.join(" ").trim() || undefined }));
|
|
206
|
+
return withWorkspace(context().workspace, () => startRepl({ flags: o, initialQuestion: words.join(" ").trim() || undefined, model }));
|
|
161
207
|
}
|
|
162
208
|
const q = [typeof o.print === "string" ? o.print : "", ...words].join(" ").trim();
|
|
163
209
|
const piped = await readStdin();
|
|
164
210
|
if (!q && !piped)
|
|
165
|
-
throw new CliError(tr(`Thiếu câu hỏi. Ví dụ: ${brand().bin} -p "
|
|
211
|
+
throw new CliError(tr(`Thiếu câu hỏi. Ví dụ: ${brand().bin} -p "${brand().example.vi}"`, `Missing question. Example: ${brand().bin} -p "${brand().example.en}"`), Exit.usage);
|
|
166
212
|
await requirePerson();
|
|
167
|
-
|
|
213
|
+
const question = q || tr("Xử lý nội dung đính kèm.", "Process the attached content.");
|
|
214
|
+
await withWorkspace(context().workspace, () => (model ? printLocal(o, model, question, piped) : printAsk(o, question, piped)));
|
|
215
|
+
}
|
|
216
|
+
/** -p with --model: same output contract as the platform assistant, answered by the user's model. */
|
|
217
|
+
async function printLocal(o, model, question, piped) {
|
|
218
|
+
const attachments = [];
|
|
219
|
+
for (const f of (o.file ?? []).slice(0, 3)) {
|
|
220
|
+
if (attachmentKind(f) !== "text")
|
|
221
|
+
throw new CliError(tr(`Model riêng chỉ đọc tệp chữ: ${f}`, `Your own model reads text files only: ${f}`), Exit.usage);
|
|
222
|
+
attachments.push(await attach(f));
|
|
223
|
+
}
|
|
224
|
+
if (piped)
|
|
225
|
+
attachments.push({ path: "-", name: "stdin", kind: "text", text: piped.slice(0, 20_000) });
|
|
226
|
+
const fmt = outputFormat();
|
|
227
|
+
const chat = new LocalChat(model, hasTty(), context().mode === "read");
|
|
228
|
+
const outcome = await chat.ask({
|
|
229
|
+
text: composeText(question, attachments, findProjectNotes()?.text),
|
|
230
|
+
onChunk: (ch) => {
|
|
231
|
+
if (fmt === "stream-json")
|
|
232
|
+
return json(ch);
|
|
233
|
+
if (fmt !== "text")
|
|
234
|
+
return;
|
|
235
|
+
if (ch.type === "token")
|
|
236
|
+
process.stdout.write(ch.text ?? "");
|
|
237
|
+
else if (ch.type === "activity" && ch.activity && !o.quiet)
|
|
238
|
+
process.stderr.write(c.dim(`⏺ ${ch.activity.label}\n`));
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
if (fmt === "json")
|
|
242
|
+
json({ model: model.label, text: outcome.text, notices: outcome.notices, tools: outcome.activities.map((a) => a.toolName), workspace: getProfile(context().profile)?.workspaceCode ?? null, env: context().env, ended: outcome.ended });
|
|
243
|
+
else if (fmt === "text") {
|
|
244
|
+
if (outcome.text && !outcome.text.endsWith("\n"))
|
|
245
|
+
process.stdout.write("\n");
|
|
246
|
+
for (const n of outcome.notices)
|
|
247
|
+
process.stderr.write(`${c.yellow("⚠")} ${n.message}\n`);
|
|
248
|
+
}
|
|
249
|
+
if (outcome.ended === "error")
|
|
250
|
+
process.exitCode = Exit.general;
|
|
168
251
|
}
|
|
169
252
|
async function printAsk(o, question, piped) {
|
|
170
253
|
const sessionId = await resolveConversation(o);
|
|
@@ -195,11 +278,26 @@ async function printAsk(o, question, piped) {
|
|
|
195
278
|
},
|
|
196
279
|
onRetry: (n) => stderr.write(c.yellow(tr(`⚠ Mất kết nối, đang nối lại (${n}/2)…\n`, `⚠ Connection lost, reconnecting (${n}/2)…\n`))),
|
|
197
280
|
});
|
|
198
|
-
|
|
281
|
+
const conversationId = outcome.sessionId || sessionId;
|
|
282
|
+
updateProfile(context().profile, { lastConversation: conversationId });
|
|
199
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;
|
|
200
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;
|
|
201
299
|
json({
|
|
202
|
-
conversationId
|
|
300
|
+
conversationId,
|
|
203
301
|
runId: outcome.runId ?? null,
|
|
204
302
|
messageId: outcome.done?.messageId ?? null,
|
|
205
303
|
text: outcome.text,
|
|
@@ -207,7 +305,9 @@ async function printAsk(o, question, piped) {
|
|
|
207
305
|
followUps: outcome.done?.followUps ?? [],
|
|
208
306
|
notices: outcome.notices,
|
|
209
307
|
agent: outcome.agent ?? null,
|
|
210
|
-
proposals: proposal ? [{ kind: proposal.kind, label: proposal.label, prompt: proposal.confirmPrompt, writes: proposal.writes, expiresAt: proposal.expiresAt ?? null, risk: riskOf(proposal),
|
|
308
|
+
proposals: proposal ? [{ kind: proposal.kind, label: proposal.label, prompt: proposal.confirmPrompt, writes: proposal.writes, expiresAt: proposal.expiresAt ?? null, risk: riskOf(proposal),
|
|
309
|
+
confirm: confirmHint(proposal.nonce, conversationId),
|
|
310
|
+
...(applied ? { applied: { written: applied.written, message: applied.message } } : {}) }] : [],
|
|
211
311
|
workspace: getProfile(context().profile)?.workspaceCode ?? null,
|
|
212
312
|
env: context().env,
|
|
213
313
|
ended: outcome.ended,
|
|
@@ -225,12 +325,12 @@ async function printAsk(o, question, piped) {
|
|
|
225
325
|
if (proposal) {
|
|
226
326
|
for (const l of proposalCard(proposal))
|
|
227
327
|
stderr.write(`${l}\n`);
|
|
228
|
-
await handleProposal(
|
|
328
|
+
written = (await handleProposal(conversationId, proposal)).written;
|
|
229
329
|
}
|
|
230
330
|
}
|
|
231
331
|
if (outcome.notices.some((n) => n.code === "ai_not_purchased"))
|
|
232
332
|
process.exitCode = Exit.forbidden;
|
|
233
|
-
else if (proposal?.writes)
|
|
333
|
+
else if (proposal?.writes && !written)
|
|
234
334
|
process.exitCode = Exit.notConfirmed;
|
|
235
335
|
else if (outcome.ended === "error")
|
|
236
336
|
process.exitCode = Exit.general;
|
|
@@ -352,13 +452,16 @@ export function registerAi(program) {
|
|
|
352
452
|
const head = await findChangeset(id);
|
|
353
453
|
if (context().mode === "read")
|
|
354
454
|
throw new CliError(tr("Chế độ chỉ đọc.", "Read-only mode."), Exit.forbidden);
|
|
355
|
-
|
|
356
|
-
|
|
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);
|
|
357
458
|
const env = context().env === "prod" ? c.red("PROD") : c.yellow("SANDBOX");
|
|
358
459
|
info(`${c.bold(head.title)} · ${env}`);
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
+
}
|
|
362
465
|
const r = await post(`/ai/changesets/${head.id}/apply`, undefined, { query: { expectedVersion: head.version } });
|
|
363
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)}`)));
|
|
364
467
|
});
|
|
@@ -464,7 +567,7 @@ export function registerAi(program) {
|
|
|
464
567
|
});
|
|
465
568
|
ai.command("usage").description(tr("lượt AI tháng này", "AI turns this month"))
|
|
466
569
|
.action(async () => {
|
|
467
|
-
const u = await
|
|
570
|
+
const u = await (await import("../ai/client.js")).usage();
|
|
468
571
|
emit(u, () => info(`${tr("Gói", "Plan")} ${c.bold(u.planName ?? "-")} · ${tr("đã dùng", "used")} ${u.used ?? 0}${u.limit ? `/${u.limit}` : ""} · ${tr("mở lại", "resets")} ${u.resetsAt?.slice(0, 10) ?? ""}`));
|
|
469
572
|
});
|
|
470
573
|
ai.command("feedback <messageId> <vote>").description(tr("báo câu trả lời tốt (up) / chưa tốt (down)", "rate an answer: up / down"))
|
package/dist/commands/api.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { brand } from "../core/brand.js";
|
|
2
3
|
import { context } from "../core/context.js";
|
|
3
4
|
import { CliError, Exit } from "../core/errors.js";
|
|
4
5
|
import { tr } from "../core/i18n.js";
|
|
@@ -25,7 +26,7 @@ export function select(value, path) {
|
|
|
25
26
|
export function registerApi(program) {
|
|
26
27
|
program
|
|
27
28
|
.command("api <method> <path>")
|
|
28
|
-
.description(tr(
|
|
29
|
+
.description(tr(`gọi thẳng API (vd: api ${brand().apiExample})`, `call the API directly (e.g. api ${brand().apiExample})`))
|
|
29
30
|
.option("-f, --raw-field <key=value>", tr("tham số dạng chuỗi: query với GET/DELETE, thân JSON với POST/PUT/PATCH (lặp được)", "string parameter: query for GET/DELETE, JSON body for POST/PUT/PATCH (repeatable)"), (v, p = []) => [...p, v])
|
|
30
31
|
.option("-F, --field <key=value>", tr("như -f nhưng đoán kiểu: số, true/false, JSON", "like -f but typed: numbers, true/false, JSON"), (v, p = []) => [...p, v])
|
|
31
32
|
.option("--input <file>", tr("thân request từ tệp JSON (- = stdin)", "request body from a JSON file (- = stdin)"))
|
|
@@ -61,7 +62,7 @@ export function registerApi(program) {
|
|
|
61
62
|
let page = Number(query?.pageNumber ?? query?.page ?? 1);
|
|
62
63
|
for (;;) {
|
|
63
64
|
const q = o.paginate ? { ...query, pageNumber: page } : query;
|
|
64
|
-
const r = await request(method, path, { query: q, body, headers, idempotencyKey: o.idempotencyKey });
|
|
65
|
+
const r = await request(method, path, { query: q, body, headers, idempotencyKey: o.idempotencyKey, verbatim: true });
|
|
65
66
|
if (o.include) {
|
|
66
67
|
info(c.dim(`HTTP ${r.status}`));
|
|
67
68
|
r.headers.forEach?.((v, k) => info(c.dim(`${k}: ${v}`)));
|
package/dist/commands/auth.js
CHANGED
|
@@ -21,7 +21,7 @@ export function browserAvailable() {
|
|
|
21
21
|
}
|
|
22
22
|
export async function usage() {
|
|
23
23
|
try {
|
|
24
|
-
return await
|
|
24
|
+
return await (await import("../ai/client.js")).usage();
|
|
25
25
|
}
|
|
26
26
|
catch {
|
|
27
27
|
return undefined;
|
|
@@ -31,8 +31,8 @@ function printLogin(r) {
|
|
|
31
31
|
const p = getProfile(context().profile);
|
|
32
32
|
ok(tr(`Đã đăng nhập ${c.bold(r.userName ?? "")} (${r.userEmail ?? ""})`, `Logged in as ${c.bold(r.userName ?? "")} (${r.userEmail ?? ""})`));
|
|
33
33
|
info(` ${tr("Workspace", "Workspace")} ${c.bold(r.workspaceName ?? p?.workspaceName ?? "")} · ${context().env === "prod" ? "prod" : c.yellow("sandbox")}`);
|
|
34
|
-
info(` ${tr("Phiên", "Session")} ${tr("tự gia hạn khi dùng, hết hạn nếu bỏ không 30 ngày", "renews while used, expires after 30 idle days")} · ${storageKind() === "keychain" ? "Keychain" : tr("tệp 0600", "0600 file")}`);
|
|
35
|
-
info(c.dim(` ${tr("Thử", "Try")}: ${brand().bin} · ${brand().bin} -p "${tr(
|
|
34
|
+
info(` ${tr("Phiên", "Session")} ${r.session.via === "ci" ? tr("15 phút, không gia hạn — hết thì đăng nhập CI lại", "15 minutes, no renewal — logs in again through CI when it ends") : tr("tự gia hạn khi dùng, hết hạn nếu bỏ không 30 ngày", "renews while used, expires after 30 idle days")} · ${storageKind() === "keychain" ? "Keychain" : tr("tệp 0600", "0600 file")}`);
|
|
35
|
+
info(c.dim(` ${tr("Thử", "Try")}: ${brand().bin} · ${brand().bin} -p "${tr(brand().example.vi, brand().example.en)}" · ${brand().bin} help`));
|
|
36
36
|
}
|
|
37
37
|
export function registerAuth(program) {
|
|
38
38
|
const b = brand();
|
|
@@ -47,6 +47,10 @@ export function registerAuth(program) {
|
|
|
47
47
|
.action(async (o) => {
|
|
48
48
|
const ws = context().workspace;
|
|
49
49
|
let r;
|
|
50
|
+
if ((o.ci && b.notYet?.includes("ci")) || (o.device && b.notYet?.includes("device"))) {
|
|
51
|
+
const { notYetError } = await import("../core/errors.js");
|
|
52
|
+
throw notYetError(tr(`${b.name} chưa mở ${o.ci ? "đăng nhập CI bằng OIDC" : "đăng nhập bằng mã thiết bị"}`, `${b.name} does not offer ${o.ci ? "CI OIDC login" : "device-code login"} yet`), o.ci ? tr(`dùng khoá API: ${b.envPrefix}_API_KEY`, `use an API key: ${b.envPrefix}_API_KEY`) : tr(`dùng ${b.bin} login --qr (quét bằng app ${b.mobileApp}) hoặc --no-browser`, `use ${b.bin} login --qr (scan with the ${b.mobileApp} app) or --no-browser`));
|
|
53
|
+
}
|
|
50
54
|
if (o.ci)
|
|
51
55
|
r = await loginCi({ workspace: ws });
|
|
52
56
|
else if (o.device)
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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 { requirePerson } from "../auth/session.js";
|
|
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
|
+
}
|
|
16
|
+
const when = (s) => (s ? s.slice(0, 16).replace("T", " ") : "—");
|
|
17
|
+
export function workflowSample(repo = "acme/web", ws = "my-workspace") {
|
|
18
|
+
const b = brand();
|
|
19
|
+
return `# .github/workflows/report.yml (${repo})
|
|
20
|
+
permissions:
|
|
21
|
+
id-token: write # cho phép lấy token OIDC — không cần secret nào
|
|
22
|
+
contents: read
|
|
23
|
+
jobs:
|
|
24
|
+
report:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- run: npx -y ${b.pkg} login --ci --workspace ${ws}
|
|
28
|
+
- run: npx -y ${b.pkg} -p "${tr(b.example.vi, b.example.en)}" --json > report.json`;
|
|
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
|
+
}
|
|
45
|
+
export function registerCi(program) {
|
|
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)"));
|
|
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"));
|
|
48
|
+
trust.hook("preAction", async () => {
|
|
49
|
+
if (brand().notYet?.includes("ci")) {
|
|
50
|
+
const { notYetError } = await import("../core/errors.js");
|
|
51
|
+
throw notYetError(tr(`${brand().name} chưa mở đăng nhập CI bằng OIDC`, `${brand().name} does not offer CI OIDC login yet`), tr(`dùng khoá API: ${brand().envPrefix}_API_KEY`, `use an API key: ${brand().envPrefix}_API_KEY`));
|
|
52
|
+
}
|
|
53
|
+
await requirePerson();
|
|
54
|
+
});
|
|
55
|
+
trust.command("list", { isDefault: true }).action(async () => {
|
|
56
|
+
const list = await get("/iam/ci-trusts");
|
|
57
|
+
emit(list, () => info(list.length
|
|
58
|
+
? table(["ID", tr("Kho", "Repository"), "Ref", "Env", tr("Dùng lần cuối", "Last used"), ""], list.map((t) => [
|
|
59
|
+
t.id.slice(0, 8), `${t.provider}:${t.repository}`, t.refPattern, t.environment ?? "", when(t.lastUsedAt), t.revokedAt ? c.red(tr("đã thu hồi", "revoked")) : c.green("●"),
|
|
60
|
+
]))
|
|
61
|
+
: tr(`Chưa có kho nào. Thêm: ${brand().bin} ci trust add --repo owner/repo`, `None yet. Add one: ${brand().bin} ci trust add --repo owner/repo`)));
|
|
62
|
+
});
|
|
63
|
+
trust.command("add")
|
|
64
|
+
.description(tr("cho một kho đăng nhập (token đứng tên bạn, sống 15 phút)", "allow a repository to log in (tokens act as you, live 15 minutes)"))
|
|
65
|
+
.requiredOption("--repo <owner/repo>")
|
|
66
|
+
.option("--provider <p>", "github | gitlab", "github")
|
|
67
|
+
.option("--ref <ref>", tr("nhánh hoặc tiền tố tag, vd main · refs/tags/v*", "branch or tag prefix, e.g. main · refs/tags/v*"), "main")
|
|
68
|
+
.option("--environment <name>", tr("environment GitHub bắt buộc (khuyên dùng cho nhánh chính)", "required GitHub environment (recommended for the main branch)"))
|
|
69
|
+
.option("--expires <dur>", tr("thời hạn: 90d, 1y", "lifetime: 90d, 1y"))
|
|
70
|
+
.option("--name <name>")
|
|
71
|
+
.action(async (o) => {
|
|
72
|
+
const t = await post("/iam/ci-trusts", {
|
|
73
|
+
provider: o.provider, repository: o.repo, refPattern: o.ref, environment: o.environment, name: o.name,
|
|
74
|
+
expiresAt: o.expires ? parseDuration(o.expires) : null,
|
|
75
|
+
});
|
|
76
|
+
emit(t, () => {
|
|
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>`)));
|
|
79
|
+
info(c.dim(tr("\nMẫu workflow:", "\nSample workflow:")));
|
|
80
|
+
info(workflowSample(t.repository));
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
trust.command("revoke <id>").description(tr("thu hồi (có hiệu lực ngay)", "revoke (immediate)")).action(async (id) => {
|
|
84
|
+
const t = await findTrust(id);
|
|
85
|
+
if (hasTty() && !(await confirm(tr(`Thu hồi ${t.repository}?`, `Revoke ${t.repository}?`))))
|
|
86
|
+
return;
|
|
87
|
+
await post(`/iam/ci-trusts/${t.id}/revoke`, {});
|
|
88
|
+
ok(tr("Đã thu hồi.", "Revoked."));
|
|
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);
|
|
92
|
+
ci.command("example").description(tr("in mẫu workflow GitHub Actions", "print a sample GitHub Actions workflow"))
|
|
93
|
+
.option("--repo <owner/repo>").option("--workspace <code>")
|
|
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)));
|
|
96
|
+
}
|
package/dist/commands/help.js
CHANGED
|
@@ -3,7 +3,7 @@ import { tr } from "../core/i18n.js";
|
|
|
3
3
|
import { json } from "../core/output.js";
|
|
4
4
|
import { VERSION } from "../core/version.js";
|
|
5
5
|
import { Exit } from "../core/errors.js";
|
|
6
|
-
import {
|
|
6
|
+
import { SHORTCUTS, slashCommands } from "../repl/slash.js";
|
|
7
7
|
const opt = (o) => ({ flags: o.flags, description: o.description, ...(o.defaultValue !== undefined ? { default: o.defaultValue } : {}), ...(o.argChoices ? { choices: o.argChoices } : {}) });
|
|
8
8
|
function walk(cmd) {
|
|
9
9
|
return {
|
|
@@ -25,7 +25,7 @@ export function helpTree(program) {
|
|
|
25
25
|
version: VERSION,
|
|
26
26
|
brand: b.id,
|
|
27
27
|
root: walk(program),
|
|
28
|
-
slashCommands:
|
|
28
|
+
slashCommands: slashCommands().map((s) => ({ name: `/${s.name}`, args: s.args ?? null, description: s.description() })),
|
|
29
29
|
shortcuts: SHORTCUTS.map((s) => ({ keys: s.keys, description: s.description() })),
|
|
30
30
|
exitCodes: Object.entries(Exit).map(([name, code]) => ({ name, code })),
|
|
31
31
|
environment: ["API_KEY", "TOKEN", "PROFILE", "ENV", "WORKSPACE", "LANG", "DEBUG", "NO_UPDATE_CHECK", "NO_KEYCHAIN", "ID_TOKEN"].map((v) => `${b.envPrefix}_${v}`),
|
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
|
@@ -138,8 +138,10 @@ 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
|
-
let
|
|
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
|
+
const url = apiKey ? `${context().endpoints.api}/api/v1/mcp` : context().endpoints.mcp;
|
|
144
|
+
let token = apiKey ?? (await mcpToken({ interactive: false }));
|
|
143
145
|
let sessionId;
|
|
144
146
|
const rl = createInterface({ input: process.stdin });
|
|
145
147
|
const write = (m) => process.stdout.write(`${JSON.stringify(m)}\n`);
|
|
@@ -166,7 +168,7 @@ async function serveStdio() {
|
|
|
166
168
|
});
|
|
167
169
|
try {
|
|
168
170
|
let res = await send();
|
|
169
|
-
if (res.status === 401) {
|
|
171
|
+
if (res.status === 401 && !apiKey) {
|
|
170
172
|
token = await mcpToken({ interactive: false });
|
|
171
173
|
res = await send();
|
|
172
174
|
}
|