@nextxai/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/ai/client.js +221 -0
- package/dist/auth/ci.js +64 -0
- package/dist/auth/credentials.js +192 -0
- package/dist/auth/device.js +76 -0
- package/dist/auth/login.js +145 -0
- package/dist/auth/loopback.js +98 -0
- package/dist/auth/mcp-oauth.js +174 -0
- package/dist/auth/session.js +205 -0
- package/dist/bin/nextx.js +5 -0
- package/dist/commands/ai.js +514 -0
- package/dist/commands/api.js +87 -0
- package/dist/commands/auth.js +165 -0
- package/dist/commands/docs.js +55 -0
- package/dist/commands/help.js +48 -0
- package/dist/commands/keys.js +85 -0
- package/dist/commands/mcp.js +202 -0
- package/dist/commands/tools.js +186 -0
- package/dist/commands/util.js +224 -0
- package/dist/commands/webhooks.js +249 -0
- package/dist/commands/workspace.js +44 -0
- package/dist/core/brand.js +51 -0
- package/dist/core/config.js +52 -0
- package/dist/core/context.js +42 -0
- package/dist/core/errors.js +28 -0
- package/dist/core/http.js +170 -0
- package/dist/core/i18n.js +90 -0
- package/dist/core/lock.js +44 -0
- package/dist/core/output.js +74 -0
- package/dist/core/paths.js +29 -0
- package/dist/core/prompt.js +137 -0
- package/dist/core/version.js +13 -0
- package/dist/main.js +38 -0
- package/dist/mcp/client.js +114 -0
- package/dist/program.js +102 -0
- package/dist/repl/App.js +771 -0
- package/dist/repl/components.js +66 -0
- package/dist/repl/index.js +12 -0
- package/dist/repl/markdown.js +57 -0
- package/dist/repl/slash.js +53 -0
- package/package.json +62 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- Chat with the assistant in the terminal (`nextx`), with slash commands, file attachments, permission modes and confirmed writes.
|
|
8
|
+
- Login: browser (PKCE), QR via the mobile app, device code, API key, CI OIDC.
|
|
9
|
+
- `-p` / `--json` / `--output-format stream-json` for scripts and agents.
|
|
10
|
+
- `tools`, `api`, `keys`, `webhooks` (including `listen --forward-to` and `verify`), `mcp install|serve`, `doctor`, `completion`, `update`, `init`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NextX
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,54 @@
|
|
|
1
|
-
#
|
|
1
|
+
# NextX CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`nextx` brings NextX AI and the NextX developer platform to your terminal.
|
|
4
|
+
|
|
5
|
+
- **Chat** with NextX AI on your real workspace data: type `nextx`.
|
|
6
|
+
- **Script it**: `nextx -p "question" --json`, stream with `--output-format stream-json`.
|
|
7
|
+
- **Developer tools**: call the API, AI tools, API keys, webhooks (`nextx webhooks listen --forward-to localhost:3000`), MCP setup for other assistants.
|
|
8
|
+
|
|
9
|
+
Documentation: https://developers.nextx.vn/docs/cli
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
macOS / Linux:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
curl -fsSL https://developers.nextx.vn/install.sh | sh
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Windows (PowerShell):
|
|
20
|
+
|
|
21
|
+
```powershell
|
|
22
|
+
irm https://developers.nextx.vn/install.ps1 | iex
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Homebrew:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
brew install nextx-organization/tap/nextx
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
npm (Node 20+):
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install -g @nextxai/cli
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
nextx login # browser, or --qr over SSH
|
|
41
|
+
nextx # open the chat
|
|
42
|
+
nextx -p "yesterday's revenue by branch"
|
|
43
|
+
nextx doctor
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
In CI, use an API key (`NEXTX_API_KEY`) or `nextx login --ci` with GitHub Actions OIDC.
|
|
47
|
+
|
|
48
|
+
## Security
|
|
49
|
+
|
|
50
|
+
Tokens are kept in the operating system keychain when available. Writes proposed by the assistant are never applied without your confirmation in an interactive terminal. Report vulnerabilities as described in [SECURITY.md](SECURITY.md).
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Chat with the platform assistant over SSE. Shared by `-p` and the REPL.
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { basename, extname } from "node:path";
|
|
5
|
+
import { fetch } from "undici";
|
|
6
|
+
import { get, post, put, patch, del, requestRaw, errorFor, networkError } from "../core/http.js";
|
|
7
|
+
import { CliError, Exit } from "../core/errors.js";
|
|
8
|
+
import { tr } from "../core/i18n.js";
|
|
9
|
+
import { brand } from "../core/brand.js";
|
|
10
|
+
const BASE = "/operator/widget/support";
|
|
11
|
+
export const openConversation = (forceNew) => post(`${BASE}/init`, { forceNew });
|
|
12
|
+
export const listConversations = (limit = 30) => get(`${BASE}/conversations`, { query: { limit } });
|
|
13
|
+
export const renameConversation = (id, title) => patch(`${BASE}/conversations/${id}/title`, { title });
|
|
14
|
+
export const pinConversation = (id, pinned) => put(`${BASE}/conversations/${id}/pin`, { pinned });
|
|
15
|
+
export const deleteConversation = (id) => del(`${BASE}/conversations/${id}`);
|
|
16
|
+
export const history = (sessionId) => get(`${BASE}/history`, { query: { sessionId } });
|
|
17
|
+
export const feedback = (messageId, helpful) => post(`${BASE}/messages/${messageId}/feedback`, { helpful });
|
|
18
|
+
export const escalate = (sessionId, topic) => post(`${BASE}/escalate`, { sessionId, topic });
|
|
19
|
+
export const usage = () => get(`${BASE}/usage`);
|
|
20
|
+
export const confirmAction = (sessionId, a) => post(`${BASE}/${sessionId}/confirm-action`, { kind: a.kind, payloadJson: a.payloadJson, nonce: a.nonce });
|
|
21
|
+
const TABLE = new Set([".xlsx", ".csv"]);
|
|
22
|
+
const IMAGE = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
|
|
23
|
+
const TEXT = new Set([".txt", ".md", ".json", ".log", ".yml", ".yaml", ".tsv", ".xml", ".html", ".sql"]);
|
|
24
|
+
const MAX_BYTES = 10 * 1024 * 1024;
|
|
25
|
+
const MAX_TEXT = 20_000;
|
|
26
|
+
export function attachmentKind(path) {
|
|
27
|
+
const ext = extname(path).toLowerCase();
|
|
28
|
+
if (TABLE.has(ext))
|
|
29
|
+
return "table";
|
|
30
|
+
if (ext in IMAGE)
|
|
31
|
+
return "image";
|
|
32
|
+
if (TEXT.has(ext))
|
|
33
|
+
return "text";
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
async function putFile(url, body, contentType) {
|
|
37
|
+
let res;
|
|
38
|
+
try {
|
|
39
|
+
res = await fetch(url, { method: "PUT", body, headers: { "Content-Type": contentType } });
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
throw networkError(e, url);
|
|
43
|
+
}
|
|
44
|
+
if (!res.ok)
|
|
45
|
+
throw new CliError(tr(`Tải tệp lên thất bại (HTTP ${res.status}).`, `Upload failed (HTTP ${res.status}).`), Exit.general);
|
|
46
|
+
}
|
|
47
|
+
export async function attach(path, sessionId) {
|
|
48
|
+
const kind = attachmentKind(path);
|
|
49
|
+
const name = basename(path);
|
|
50
|
+
if (!kind)
|
|
51
|
+
throw new CliError(tr(`Chưa hỗ trợ loại tệp ${extname(path) || name}. Dùng .xlsx/.csv, ảnh .png/.jpg/.webp, hoặc tệp chữ (.txt .md .json …).`, `Unsupported file type ${extname(path) || name}. Use .xlsx/.csv, .png/.jpg/.webp images, or text files (.txt .md .json …).`), Exit.usage);
|
|
52
|
+
let size;
|
|
53
|
+
try {
|
|
54
|
+
size = statSync(path).size;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new CliError(tr(`Không đọc được tệp ${path}.`, `Cannot read ${path}.`), Exit.usage);
|
|
58
|
+
}
|
|
59
|
+
if (kind === "text") {
|
|
60
|
+
const raw = readFileSync(path, "utf8");
|
|
61
|
+
const cut = raw.length > MAX_TEXT;
|
|
62
|
+
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" : ""}`) };
|
|
63
|
+
}
|
|
64
|
+
if (size > MAX_BYTES)
|
|
65
|
+
throw new CliError(tr("Tệp quá lớn (tối đa 10 MB).", "File too large (max 10 MB)."), Exit.usage);
|
|
66
|
+
const body = readFileSync(path);
|
|
67
|
+
if (kind === "table") {
|
|
68
|
+
const ct = extname(path).toLowerCase() === ".csv" ? "text/csv" : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
69
|
+
const up = await post(`${BASE}/files/upload-url`, { fileName: name, contentType: ct, fileSizeBytes: size });
|
|
70
|
+
await putFile(up.uploadUrl, body, ct);
|
|
71
|
+
const s = await post(`${BASE}/files/extract`, { storageKey: up.storageKey, fileName: name, sessionId });
|
|
72
|
+
return { path, name, kind, fileKey: s.fileId, summary: `${s.rowCount.toLocaleString("vi-VN")} × ${s.columnCount}` };
|
|
73
|
+
}
|
|
74
|
+
const ct = IMAGE[extname(path).toLowerCase()];
|
|
75
|
+
const up = await post("/operator/nextx-ai/attachments/upload-url", { fileName: name, contentType: ct, fileSizeBytes: size });
|
|
76
|
+
await putFile(up.uploadUrl, body, ct);
|
|
77
|
+
return { path, name, kind, imageKey: up.storageKey, summary: `${Math.round(size / 1024)} KB` };
|
|
78
|
+
}
|
|
79
|
+
/** Text attachments and NEXTX.md are sent inline, clearly delimited, after the user's question. */
|
|
80
|
+
export function composeText(question, attachments, projectNotes) {
|
|
81
|
+
const parts = [question];
|
|
82
|
+
for (const a of attachments.filter((x) => x.kind === "text"))
|
|
83
|
+
parts.push(`\n\n--- ${tr("Tệp", "File")} ${a.name} ---\n${a.text}\n--- ${tr("Hết tệp", "End of file")} ---`);
|
|
84
|
+
if (projectNotes)
|
|
85
|
+
parts.push(`\n\n--- ${tr(`Chỉ dẫn của dự án (${brand().bin.toUpperCase()}.md)`, `Project instructions (${brand().bin.toUpperCase()}.md)`)} ---\n${projectNotes}\n---`);
|
|
86
|
+
return parts.join("");
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Sends one question and streams the answer. If the connection drops, the same Idempotency-Key is resent,
|
|
90
|
+
* so the server replays the stored answer instead of answering (and charging) twice.
|
|
91
|
+
*/
|
|
92
|
+
export async function ask(input) {
|
|
93
|
+
const key = randomUUID();
|
|
94
|
+
const body = {
|
|
95
|
+
sessionId: input.sessionId,
|
|
96
|
+
text: input.text,
|
|
97
|
+
imageKeys: input.attachments?.filter((a) => a.imageKey).map((a) => a.imageKey) ?? undefined,
|
|
98
|
+
fileKeys: input.attachments?.filter((a) => a.fileKey).map((a) => a.fileKey) ?? undefined,
|
|
99
|
+
agentCode: input.agent,
|
|
100
|
+
clientPlatform: "cli",
|
|
101
|
+
};
|
|
102
|
+
let lastErr;
|
|
103
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
104
|
+
if (attempt > 0) {
|
|
105
|
+
input.onRetry?.(attempt);
|
|
106
|
+
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
|
107
|
+
}
|
|
108
|
+
const outcome = { text: "", notices: [], activities: [], ended: "error" };
|
|
109
|
+
try {
|
|
110
|
+
const res = await requestRaw("POST", `${BASE}/ask/stream`, { body, idempotencyKey: key, signal: input.signal });
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
const data = await res.json().catch(() => undefined);
|
|
113
|
+
throw errorFor(res.status, data, res);
|
|
114
|
+
}
|
|
115
|
+
if (!res.body)
|
|
116
|
+
throw new CliError(tr("Máy chủ trả luồng rỗng.", "Empty stream from server."), Exit.general);
|
|
117
|
+
for await (const chunk of sse(res.body)) {
|
|
118
|
+
input.onChunk(chunk);
|
|
119
|
+
switch (chunk.type) {
|
|
120
|
+
case "token":
|
|
121
|
+
outcome.text += chunk.text ?? "";
|
|
122
|
+
break;
|
|
123
|
+
case "activity":
|
|
124
|
+
if (chunk.activity)
|
|
125
|
+
outcome.activities.push(chunk.activity);
|
|
126
|
+
outcome.runId ??= chunk.runId ?? undefined;
|
|
127
|
+
break;
|
|
128
|
+
case "agent":
|
|
129
|
+
outcome.agent = chunk.agent ?? undefined;
|
|
130
|
+
break;
|
|
131
|
+
case "notice":
|
|
132
|
+
outcome.notices.push({ code: chunk.code ?? "", message: chunk.message ?? "" });
|
|
133
|
+
break;
|
|
134
|
+
case "done":
|
|
135
|
+
case "error":
|
|
136
|
+
case "escalate":
|
|
137
|
+
case "delivered_to_agent":
|
|
138
|
+
outcome.ended = chunk.type;
|
|
139
|
+
outcome.done = chunk;
|
|
140
|
+
outcome.runId = chunk.runId ?? outcome.runId;
|
|
141
|
+
outcome.proposedAction = chunk.proposedAction ?? undefined;
|
|
142
|
+
if (chunk.type === "error" && chunk.text && !outcome.text)
|
|
143
|
+
outcome.text = chunk.text;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!outcome.done)
|
|
148
|
+
throw new CliError(tr("Mất kết nối giữa chừng.", "Connection lost mid-answer."), Exit.network);
|
|
149
|
+
return outcome;
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
lastErr = e;
|
|
153
|
+
const ce = e;
|
|
154
|
+
// Only transport failures are retried; 4xx/5xx with a body are final.
|
|
155
|
+
if (input.signal?.aborted || (ce instanceof CliError && ce.exitCode !== Exit.network))
|
|
156
|
+
throw e;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
throw lastErr;
|
|
160
|
+
}
|
|
161
|
+
async function* sse(stream) {
|
|
162
|
+
const decoder = new TextDecoder();
|
|
163
|
+
let buf = "";
|
|
164
|
+
for await (const part of stream) {
|
|
165
|
+
buf += decoder.decode(part, { stream: true });
|
|
166
|
+
let idx;
|
|
167
|
+
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
168
|
+
const frame = buf.slice(0, idx);
|
|
169
|
+
buf = buf.slice(idx + 2);
|
|
170
|
+
const data = frame
|
|
171
|
+
.split("\n")
|
|
172
|
+
.filter((l) => l.startsWith("data:"))
|
|
173
|
+
.map((l) => l.slice(5).trimStart())
|
|
174
|
+
.join("\n");
|
|
175
|
+
if (!data)
|
|
176
|
+
continue;
|
|
177
|
+
try {
|
|
178
|
+
yield JSON.parse(data);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// keep-alive or partial garbage
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const FINANCE = /payment|invoice|einvoice|finance|refund|payout|wallet|billing|thanh.?toan|hoa.?don|tien/i;
|
|
187
|
+
const LARGE = /delete|remove|merge|bulk|batch|mass|permission|role|group|xoa|gop|hang.?loat|campaign|broadcast/i;
|
|
188
|
+
/** Large = deletes, merges, permission changes, > 20 records or recipients. Finance never runs from the terminal. */
|
|
189
|
+
export function riskOf(a) {
|
|
190
|
+
if (FINANCE.test(a.kind))
|
|
191
|
+
return "finance";
|
|
192
|
+
if (LARGE.test(a.kind))
|
|
193
|
+
return "large";
|
|
194
|
+
if ((a.recipientCount ?? 0) > 20)
|
|
195
|
+
return "large";
|
|
196
|
+
try {
|
|
197
|
+
const p = JSON.parse(a.payloadJson);
|
|
198
|
+
const n = Object.values(p).find((v) => Array.isArray(v) && v.length > 20);
|
|
199
|
+
if (n)
|
|
200
|
+
return "large";
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// opaque payload: judged by kind only
|
|
204
|
+
}
|
|
205
|
+
return "normal";
|
|
206
|
+
}
|
|
207
|
+
/** Number the user types to confirm a large write (records/recipients), or the action label. */
|
|
208
|
+
export function confirmWord(a) {
|
|
209
|
+
if (a.recipientCount)
|
|
210
|
+
return String(a.recipientCount);
|
|
211
|
+
try {
|
|
212
|
+
const p = JSON.parse(a.payloadJson);
|
|
213
|
+
const arr = Object.values(p).find((v) => Array.isArray(v));
|
|
214
|
+
if (arr && arr.length > 1)
|
|
215
|
+
return String(arr.length);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// ignore
|
|
219
|
+
}
|
|
220
|
+
return tr("dong-y", "confirm");
|
|
221
|
+
}
|
package/dist/auth/ci.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Secretless CI login: exchange the CI provider's OIDC token (GitHub Actions, GitLab CI) for a short-lived
|
|
2
|
+
// platform token (RFC 8693). The workspace admin declares which repository/branch may act as whom.
|
|
3
|
+
import { fetch } from "undici";
|
|
4
|
+
import { brand, envVar } from "../core/brand.js";
|
|
5
|
+
import { context } from "../core/context.js";
|
|
6
|
+
import { CliError, Exit } from "../core/errors.js";
|
|
7
|
+
import { tr } from "../core/i18n.js";
|
|
8
|
+
import { errorFor, networkError, userAgent } from "../core/http.js";
|
|
9
|
+
import { sessionFrom } from "./session.js";
|
|
10
|
+
import { writeSession } from "./credentials.js";
|
|
11
|
+
import { fetchMe } from "./login.js";
|
|
12
|
+
import { updateProfile } from "../core/config.js";
|
|
13
|
+
export function inCi() {
|
|
14
|
+
return Boolean(process.env.ACTIONS_ID_TOKEN_REQUEST_URL || process.env.GITLAB_CI || envVar("ID_TOKEN"));
|
|
15
|
+
}
|
|
16
|
+
/** Gets the provider's OIDC token with our authorization server as audience. */
|
|
17
|
+
async function providerToken(audience) {
|
|
18
|
+
const own = envVar("ID_TOKEN");
|
|
19
|
+
if (own)
|
|
20
|
+
return { token: own, provider: process.env.GITLAB_CI ? "gitlab" : "custom" };
|
|
21
|
+
const url = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
22
|
+
const bearer = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
23
|
+
if (url && bearer) {
|
|
24
|
+
const u = new URL(url);
|
|
25
|
+
u.searchParams.set("audience", audience);
|
|
26
|
+
const res = await fetch(u, { headers: { Authorization: `Bearer ${bearer}`, Accept: "application/json" } });
|
|
27
|
+
if (!res.ok)
|
|
28
|
+
throw new CliError(tr("Không lấy được token OIDC của GitHub Actions. Thêm `permissions: id-token: write` vào workflow.", "Could not get the GitHub Actions OIDC token. Add `permissions: id-token: write` to the workflow."), Exit.auth);
|
|
29
|
+
return { token: (await res.json()).value, provider: "github" };
|
|
30
|
+
}
|
|
31
|
+
throw new CliError(tr(`Không thấy token OIDC. GitHub: cần \`permissions: id-token: write\`. GitLab: khai \`id_tokens: ${brand().envPrefix}_ID_TOKEN: { aud: ${audience} }\`.`, `No OIDC token found. GitHub: needs \`permissions: id-token: write\`. GitLab: declare \`id_tokens: ${brand().envPrefix}_ID_TOKEN: { aud: ${audience} }\`.`), Exit.auth);
|
|
32
|
+
}
|
|
33
|
+
export async function loginCi(opts) {
|
|
34
|
+
const ep = context().endpoints;
|
|
35
|
+
const { token, provider } = await providerToken(ep.auth);
|
|
36
|
+
const body = {
|
|
37
|
+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
38
|
+
client_id: brand().clientId,
|
|
39
|
+
subject_token: token,
|
|
40
|
+
subject_token_type: "urn:ietf:params:oauth:token-type:jwt",
|
|
41
|
+
audience: ep.api,
|
|
42
|
+
};
|
|
43
|
+
if (opts.workspace)
|
|
44
|
+
body.workspace = opts.workspace;
|
|
45
|
+
const url = `${ep.auth}/token`;
|
|
46
|
+
let res;
|
|
47
|
+
try {
|
|
48
|
+
res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": userAgent() }, body: new URLSearchParams(body).toString() });
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
throw networkError(e, url);
|
|
52
|
+
}
|
|
53
|
+
const json = (await res.json().catch(() => ({})));
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
if (json.error === "invalid_grant" || json.error === "unauthorized_client")
|
|
56
|
+
throw new CliError(tr(`Kho/nhánh này chưa được phép đăng nhập (${provider}). Quản trị khai báo ở ${ep.app} › Thiết lập › Ứng dụng & API › CI.`, `This repository/branch is not allowed to log in (${provider}). An admin can allow it at ${ep.app} › Settings › Apps & API › CI.`), Exit.auth, String(json.error_description ?? ""));
|
|
57
|
+
throw errorFor(res.status, json, res);
|
|
58
|
+
}
|
|
59
|
+
const session = sessionFrom({ ...json, refresh_token: "ci" }, "ci");
|
|
60
|
+
writeSession(context().profile, session);
|
|
61
|
+
const me = await fetchMe(session.accessToken);
|
|
62
|
+
updateProfile(context().profile, { env: context().env, workspaceId: me.workspace?.id, workspaceCode: me.workspace?.code, workspaceName: me.workspace?.name, userEmail: me.email, userName: me.fullName });
|
|
63
|
+
return { session, userName: me.fullName, userEmail: me.email, workspaceName: me.workspace?.name };
|
|
64
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Token storage: the OS keychain when available (macOS Keychain, Windows Credential Manager, libsecret),
|
|
2
|
+
// otherwise a 0600 JSON file. Model API keys (--model) are stored the same way under "model:<provider>".
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, chmodSync } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { brand } from "../core/brand.js";
|
|
7
|
+
import { credentialsFile } from "../core/paths.js";
|
|
8
|
+
import { t } from "../core/i18n.js";
|
|
9
|
+
import { warn } from "../core/output.js";
|
|
10
|
+
/**
|
|
11
|
+
* Standalone builds cannot ship the per-platform keychain addon. They talk to the OS tools instead, never
|
|
12
|
+
* putting the secret on a command line: macOS `security -i` reads commands from stdin (value base64-encoded),
|
|
13
|
+
* Linux `secret-tool store` reads the secret from stdin.
|
|
14
|
+
*/
|
|
15
|
+
function cliKeyring() {
|
|
16
|
+
// A keychain item written by another program makes macOS ask the user; never block a script on that dialog.
|
|
17
|
+
const timeout = process.stdin.isTTY ? 30_000 : 3_000;
|
|
18
|
+
const has = (cmd, args) => {
|
|
19
|
+
const r = spawnSync(cmd, args, { stdio: "ignore" });
|
|
20
|
+
return !r.error && r.status !== null;
|
|
21
|
+
};
|
|
22
|
+
if (process.platform === "darwin" && has("/usr/bin/security", ["help"])) {
|
|
23
|
+
class MacEntry {
|
|
24
|
+
s;
|
|
25
|
+
a;
|
|
26
|
+
constructor(s, a) {
|
|
27
|
+
this.s = s;
|
|
28
|
+
this.a = a;
|
|
29
|
+
}
|
|
30
|
+
getPassword() {
|
|
31
|
+
const r = spawnSync("/usr/bin/security", ["find-generic-password", "-s", this.s, "-a", this.a, "-w"], { encoding: "utf8", timeout });
|
|
32
|
+
if (r.status !== 0)
|
|
33
|
+
return null;
|
|
34
|
+
const v = r.stdout.trim();
|
|
35
|
+
return v.startsWith("b64:") ? Buffer.from(v.slice(4), "base64").toString("utf8") : v;
|
|
36
|
+
}
|
|
37
|
+
setPassword(v) {
|
|
38
|
+
const enc = "b64:" + Buffer.from(v, "utf8").toString("base64");
|
|
39
|
+
const r = spawnSync("/usr/bin/security", ["-i"], { input: `add-generic-password -U -s ${this.s} -a ${this.a} -w ${enc}\n`, encoding: "utf8", timeout });
|
|
40
|
+
if (r.status !== 0)
|
|
41
|
+
throw new Error(r.stderr || "security failed");
|
|
42
|
+
}
|
|
43
|
+
deletePassword() {
|
|
44
|
+
return spawnSync("/usr/bin/security", ["delete-generic-password", "-s", this.s, "-a", this.a], { stdio: "ignore" }).status === 0;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { Entry: MacEntry };
|
|
48
|
+
}
|
|
49
|
+
if (process.platform === "linux" && (process.env.DBUS_SESSION_BUS_ADDRESS || process.env.DISPLAY) && has("secret-tool", ["--version"])) {
|
|
50
|
+
class LinuxEntry {
|
|
51
|
+
s;
|
|
52
|
+
a;
|
|
53
|
+
constructor(s, a) {
|
|
54
|
+
this.s = s;
|
|
55
|
+
this.a = a;
|
|
56
|
+
}
|
|
57
|
+
getPassword() {
|
|
58
|
+
const r = spawnSync("secret-tool", ["lookup", "service", this.s, "account", this.a], { encoding: "utf8", timeout });
|
|
59
|
+
return r.status === 0 && r.stdout ? r.stdout : null;
|
|
60
|
+
}
|
|
61
|
+
setPassword(v) {
|
|
62
|
+
const r = spawnSync("secret-tool", ["store", "--label", `${this.s} ${this.a}`, "service", this.s, "account", this.a], { input: v, encoding: "utf8", timeout });
|
|
63
|
+
if (r.status !== 0)
|
|
64
|
+
throw new Error(r.stderr || "secret-tool failed");
|
|
65
|
+
}
|
|
66
|
+
deletePassword() {
|
|
67
|
+
return spawnSync("secret-tool", ["clear", "service", this.s, "account", this.a], { stdio: "ignore" }).status === 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { Entry: LinuxEntry };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
let keyring;
|
|
75
|
+
let warned = false;
|
|
76
|
+
function loadKeyring() {
|
|
77
|
+
if (keyring !== undefined)
|
|
78
|
+
return keyring;
|
|
79
|
+
if (process.env[`${brand().envPrefix}_NO_KEYCHAIN`] === "1")
|
|
80
|
+
return (keyring = null);
|
|
81
|
+
try {
|
|
82
|
+
const require = createRequire(import.meta.url);
|
|
83
|
+
const mod = require("@napi-rs/keyring");
|
|
84
|
+
// Probe once: headless Linux without a secret service throws here.
|
|
85
|
+
new mod.Entry(service(), "__probe__").getPassword();
|
|
86
|
+
keyring = mod;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
try {
|
|
90
|
+
keyring = cliKeyring();
|
|
91
|
+
osTool = keyring !== null;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
keyring = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return keyring;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Each backend owns its own keychain items. Sharing one item between the addon (process "node") and the OS tool
|
|
101
|
+
* (`security`) makes macOS move the item's access list to whoever wrote last, and every read by the other one
|
|
102
|
+
* then pops a password dialog (28/10/2026: four dialogs stacked on the user's screen).
|
|
103
|
+
*/
|
|
104
|
+
let osTool = false;
|
|
105
|
+
const service = () => `${brand().bin}-cli${osTool ? "-os" : ""}`;
|
|
106
|
+
function readFileStore() {
|
|
107
|
+
const f = credentialsFile();
|
|
108
|
+
if (!existsSync(f))
|
|
109
|
+
return {};
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(readFileSync(f, "utf8"));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function writeFileStore(data) {
|
|
118
|
+
const f = credentialsFile();
|
|
119
|
+
const tmp = `${f}.tmp`;
|
|
120
|
+
writeFileSync(tmp, JSON.stringify(data, null, 1), { mode: 0o600 });
|
|
121
|
+
renameSync(tmp, f);
|
|
122
|
+
try {
|
|
123
|
+
chmodSync(f, 0o600);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Windows ignores POSIX modes; the file lives under the user's profile.
|
|
127
|
+
}
|
|
128
|
+
if (!warned && process.env[`${brand().envPrefix}_NO_KEYCHAIN`] !== "1") {
|
|
129
|
+
warned = true;
|
|
130
|
+
warn(t("keychain.fallback", { file: f }));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export function storageKind() {
|
|
134
|
+
return loadKeyring() ? "keychain" : "file";
|
|
135
|
+
}
|
|
136
|
+
export function readSecret(account) {
|
|
137
|
+
const k = loadKeyring();
|
|
138
|
+
if (k) {
|
|
139
|
+
try {
|
|
140
|
+
return new k.Entry(service(), account).getPassword() ?? undefined;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return readFileStore()[account];
|
|
147
|
+
}
|
|
148
|
+
export function writeSecret(account, value) {
|
|
149
|
+
const k = loadKeyring();
|
|
150
|
+
if (k) {
|
|
151
|
+
new k.Entry(service(), account).setPassword(value);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const s = readFileStore();
|
|
155
|
+
s[account] = value;
|
|
156
|
+
writeFileStore(s);
|
|
157
|
+
}
|
|
158
|
+
export function deleteSecret(account) {
|
|
159
|
+
const k = loadKeyring();
|
|
160
|
+
if (k) {
|
|
161
|
+
try {
|
|
162
|
+
new k.Entry(service(), account).deletePassword();
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// already gone
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const s = readFileStore();
|
|
170
|
+
if (account in s) {
|
|
171
|
+
delete s[account];
|
|
172
|
+
writeFileStore(s);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export function readSession(profile) {
|
|
176
|
+
const raw = readSecret(`session:${profile}`);
|
|
177
|
+
if (!raw)
|
|
178
|
+
return undefined;
|
|
179
|
+
try {
|
|
180
|
+
const s = JSON.parse(raw);
|
|
181
|
+
return s.kind === "session" && s.accessToken && s.refreshToken ? s : undefined;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export function writeSession(profile, s) {
|
|
188
|
+
writeSecret(`session:${profile}`, JSON.stringify(s));
|
|
189
|
+
}
|
|
190
|
+
export function deleteSession(profile) {
|
|
191
|
+
deleteSecret(`session:${profile}`);
|
|
192
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// OAuth 2.0 Device Authorization Grant (RFC 8628) for machines with neither a browser nor the mobile app.
|
|
2
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
|
+
import { fetch } from "undici";
|
|
4
|
+
import { brand } from "../core/brand.js";
|
|
5
|
+
import { context } from "../core/context.js";
|
|
6
|
+
import { CliError, Exit } from "../core/errors.js";
|
|
7
|
+
import { tr } from "../core/i18n.js";
|
|
8
|
+
import { c, info } from "../core/output.js";
|
|
9
|
+
import { errorFor, networkError, userAgent } from "../core/http.js";
|
|
10
|
+
import { sessionFrom, switchWorkspace, listWorkspaces, findWorkspace } from "./session.js";
|
|
11
|
+
import { writeSession } from "./credentials.js";
|
|
12
|
+
import { fetchMe } from "./login.js";
|
|
13
|
+
import { updateProfile } from "../core/config.js";
|
|
14
|
+
async function form(url, body) {
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(url, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": userAgent() },
|
|
19
|
+
body: new URLSearchParams(body).toString(),
|
|
20
|
+
});
|
|
21
|
+
return { res, json: (await res.json().catch(() => ({}))) };
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
throw networkError(e, url);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function loginDevice(opts) {
|
|
28
|
+
const ep = context().endpoints;
|
|
29
|
+
const start = await form(`${ep.auth}/device_authorization`, { client_id: brand().clientId, scope: "openid profile email" });
|
|
30
|
+
if (start.res.status === 404)
|
|
31
|
+
throw new CliError(tr("Máy chủ chưa hỗ trợ đăng nhập bằng mã thiết bị.", "The server does not support device login yet."), Exit.general, `${brand().bin} login --qr`);
|
|
32
|
+
if (!start.res.ok)
|
|
33
|
+
throw errorFor(start.res.status, start.json, start.res);
|
|
34
|
+
const d = start.json;
|
|
35
|
+
info(tr(" Trên máy bất kỳ, mở", " On any device, open") + ` ${c.yellow(d.verification_uri)} ` + tr("và nhập mã:", "and enter the code:"));
|
|
36
|
+
info();
|
|
37
|
+
info(` ${c.bold(d.user_code)} ${c.dim(tr(`hết hạn sau ${Math.round(d.expires_in / 60)} phút`, `expires in ${Math.round(d.expires_in / 60)} minutes`))}`);
|
|
38
|
+
info();
|
|
39
|
+
if (d.verification_uri_complete)
|
|
40
|
+
info(c.dim(` ${d.verification_uri_complete}`));
|
|
41
|
+
let interval = Math.max(d.interval ?? 5, 1);
|
|
42
|
+
const deadline = Date.now() + d.expires_in * 1000;
|
|
43
|
+
while (Date.now() < deadline) {
|
|
44
|
+
await sleep(interval * 1000);
|
|
45
|
+
const r = await form(`${ep.auth}/token`, {
|
|
46
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
47
|
+
device_code: d.device_code,
|
|
48
|
+
client_id: brand().clientId,
|
|
49
|
+
});
|
|
50
|
+
if (r.res.ok) {
|
|
51
|
+
const session = sessionFrom(r.json, "device");
|
|
52
|
+
writeSession(context().profile, session);
|
|
53
|
+
const me = await fetchMe(session.accessToken);
|
|
54
|
+
updateProfile(context().profile, { env: context().env, workspaceId: me.workspace?.id, workspaceCode: me.workspace?.code, workspaceName: me.workspace?.name, userEmail: me.email, userName: me.fullName });
|
|
55
|
+
if (opts.workspace) {
|
|
56
|
+
const target = findWorkspace(await listWorkspaces(), opts.workspace);
|
|
57
|
+
if (target && target.id !== me.workspace?.id)
|
|
58
|
+
await switchWorkspace(target);
|
|
59
|
+
}
|
|
60
|
+
return { session, userName: me.fullName, userEmail: me.email, workspaceName: me.workspace?.name };
|
|
61
|
+
}
|
|
62
|
+
const err = String(r.json.error ?? "");
|
|
63
|
+
if (err === "authorization_pending")
|
|
64
|
+
continue;
|
|
65
|
+
if (err === "slow_down") {
|
|
66
|
+
interval += 5;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (err === "access_denied")
|
|
70
|
+
throw new CliError(tr("Đăng nhập bị từ chối.", "Login was denied."), Exit.auth);
|
|
71
|
+
if (err === "expired_token")
|
|
72
|
+
break;
|
|
73
|
+
throw errorFor(r.res.status, r.json, r.res);
|
|
74
|
+
}
|
|
75
|
+
throw new CliError(tr("Mã đã hết hạn. Chạy lại lệnh đăng nhập.", "The code expired. Run login again."), Exit.auth);
|
|
76
|
+
}
|