@nextxai/cli 0.0.0-stage → 0.1.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +53 -2
  4. package/dist/ai/client.js +221 -0
  5. package/dist/auth/ci.js +64 -0
  6. package/dist/auth/credentials.js +192 -0
  7. package/dist/auth/device.js +76 -0
  8. package/dist/auth/login.js +145 -0
  9. package/dist/auth/loopback.js +98 -0
  10. package/dist/auth/mcp-oauth.js +174 -0
  11. package/dist/auth/session.js +205 -0
  12. package/dist/bin/nextx.js +5 -0
  13. package/dist/commands/ai.js +514 -0
  14. package/dist/commands/api.js +87 -0
  15. package/dist/commands/auth.js +165 -0
  16. package/dist/commands/docs.js +55 -0
  17. package/dist/commands/help.js +48 -0
  18. package/dist/commands/keys.js +85 -0
  19. package/dist/commands/mcp.js +202 -0
  20. package/dist/commands/tools.js +186 -0
  21. package/dist/commands/util.js +224 -0
  22. package/dist/commands/webhooks.js +249 -0
  23. package/dist/commands/workspace.js +44 -0
  24. package/dist/core/brand.js +51 -0
  25. package/dist/core/config.js +52 -0
  26. package/dist/core/context.js +42 -0
  27. package/dist/core/errors.js +28 -0
  28. package/dist/core/http.js +170 -0
  29. package/dist/core/i18n.js +90 -0
  30. package/dist/core/lock.js +44 -0
  31. package/dist/core/output.js +74 -0
  32. package/dist/core/paths.js +29 -0
  33. package/dist/core/prompt.js +137 -0
  34. package/dist/core/version.js +13 -0
  35. package/dist/main.js +38 -0
  36. package/dist/mcp/client.js +114 -0
  37. package/dist/program.js +102 -0
  38. package/dist/repl/App.js +771 -0
  39. package/dist/repl/components.js +66 -0
  40. package/dist/repl/index.js +12 -0
  41. package/dist/repl/markdown.js +57 -0
  42. package/dist/repl/slash.js +53 -0
  43. package/package.json +62 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ - x64 builds (Intel Mac, Linux, Windows) now target every x64 CPU, including older Intel Macs without AVX2.
6
+ - Browser login exits as soon as the browser returns; `login --device` works with the new device-code sign-in page.
7
+
8
+ ## 0.1.0
9
+
10
+ First release.
11
+
12
+ - Chat with the assistant in the terminal (`nextx`), with slash commands, file attachments, permission modes and confirmed writes.
13
+ - Login: browser (PKCE), QR via the mobile app, device code, API key, CI OIDC.
14
+ - `-p` / `--json` / `--output-format stream-json` for scripts and agents.
15
+ - `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
- # Temporary Holding Version
1
+ # NextX CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ }
@@ -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
+ }