@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +10 -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
@@ -0,0 +1,145 @@
1
+ // Login flows. Web: OAuth 2.1 authorization code + PKCE with a loopback redirect (RFC 8252) — the CLI is a
2
+ // public client, no secret. QR: the mobile app approves this machine (cross-device login), then the CLI
3
+ // picks a workspace with the short-lived pre-auth token.
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { fetch } from "undici";
6
+ import QRCode from "qrcode";
7
+ import { brand } from "../core/brand.js";
8
+ import { context } from "../core/context.js";
9
+ import { CliError, Exit } from "../core/errors.js";
10
+ import { t, tr } from "../core/i18n.js";
11
+ import { c, info, warn } from "../core/output.js";
12
+ import { hasTty, select } from "../core/prompt.js";
13
+ import { request, userAgent, errorFor, networkError } from "../core/http.js";
14
+ import { pkce, waitForCode, obtainCode } from "./loopback.js";
15
+ import { saveLogin, sessionFrom, findWorkspace } from "./session.js";
16
+ import { writeSession } from "./credentials.js";
17
+ import { updateProfile } from "../core/config.js";
18
+ // ── Web ──────────────────────────────────────────────────────────────────────────────────────────────
19
+ export async function loginWeb(opts) {
20
+ const ep = context().endpoints;
21
+ const p = pkce();
22
+ const waiter = await waitForCode(p.state);
23
+ const url = new URL(`${ep.auth}/authorize`);
24
+ url.search = new URLSearchParams({
25
+ response_type: "code",
26
+ client_id: brand().clientId,
27
+ redirect_uri: waiter.redirectUri,
28
+ scope: "openid profile email",
29
+ state: p.state,
30
+ nonce: p.nonce,
31
+ code_challenge: p.challenge,
32
+ code_challenge_method: "S256",
33
+ prompt: "select_account",
34
+ }).toString();
35
+ info(`${c.cyan("◆")} ${brand().name} ${tr("Mở trình duyệt để đăng nhập…", "Opening your browser to log in…")}`);
36
+ info(` ${tr("Trình duyệt không mở? Dán link này:", "Browser did not open? Paste this link:")} ${c.yellow(url.toString())}`);
37
+ info(` ${tr("Hoặc đăng nhập bằng điện thoại:", "Or log in with your phone:")} ${c.dim(`${brand().bin} login --qr`)}`);
38
+ info(c.dim(` ${tr("Đang chờ… (Ctrl+C để huỷ, tự huỷ sau 5 phút)", "Waiting… (Ctrl+C to cancel, times out in 5 minutes)")}`));
39
+ const authCode = await obtainCode(url.toString(), waiter, p.state, { openBrowser: !opts.noBrowser, manual: opts.manual === true || opts.noBrowser === true });
40
+ let res;
41
+ const tokenUrl = `${ep.auth}/token`;
42
+ try {
43
+ res = await fetch(tokenUrl, {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": userAgent() },
46
+ body: new URLSearchParams({
47
+ grant_type: "authorization_code",
48
+ code: authCode,
49
+ redirect_uri: waiter.redirectUri,
50
+ client_id: brand().clientId,
51
+ code_verifier: p.verifier,
52
+ }).toString(),
53
+ });
54
+ }
55
+ catch (e) {
56
+ throw networkError(e, tokenUrl);
57
+ }
58
+ const body = (await res.json().catch(() => ({})));
59
+ if (!res.ok) {
60
+ if (body.error === "invalid_client")
61
+ throw new CliError(tr("Máy chủ chưa nhận đăng nhập trình duyệt cho CLI.", "The server does not accept browser login for the CLI yet."), Exit.auth, `${brand().bin} login --qr`);
62
+ throw errorFor(res.status, body, res);
63
+ }
64
+ const session = sessionFrom(body, "web");
65
+ writeSession(context().profile, session);
66
+ const me = await fetchMe(session.accessToken);
67
+ updateProfile(context().profile, {
68
+ env: context().env,
69
+ workspaceId: me.workspace?.id ?? session.workspaceId,
70
+ workspaceCode: me.workspace?.code,
71
+ workspaceName: me.workspace?.name,
72
+ userEmail: me.email,
73
+ userName: me.fullName,
74
+ });
75
+ // A --workspace flag wins over the one chosen in the browser.
76
+ if (opts.workspace && me.workspace?.code !== opts.workspace && me.workspace?.id !== opts.workspace) {
77
+ const { listWorkspaces, switchWorkspace } = await import("./session.js");
78
+ const target = findWorkspace(await listWorkspaces(), opts.workspace);
79
+ if (target)
80
+ await switchWorkspace(target);
81
+ }
82
+ return { session, userName: me.fullName, userEmail: me.email, workspaceName: me.workspace?.name };
83
+ }
84
+ export async function fetchMe(bearer) {
85
+ const r = await request("GET", "/iam/auth/me", bearer ? { bearer } : {});
86
+ return r.data;
87
+ }
88
+ export async function loginQr(opts) {
89
+ const rounds = opts.maxRounds ?? 3;
90
+ for (let round = 1; round <= rounds; round++) {
91
+ const q = (await request("POST", "/iam/auth/qr/create", { anonymous: true })).data;
92
+ info(tr(` Mở app ${c.bold(brand().mobileApp)} trên điện thoại › biểu tượng quét › quét mã:`, ` Open the ${c.bold(brand().mobileApp)} app on your phone › scan icon › scan this code:`));
93
+ info();
94
+ info((await QRCode.toString(q.qrPayload, { type: "terminal", small: true })).replace(/^/gm, " "));
95
+ const deadline = Date.now() + q.expiresIn * 1000;
96
+ let scannedShown = false;
97
+ while (Date.now() < deadline) {
98
+ await sleep(2000);
99
+ const s = (await request("GET", "/iam/auth/qr/status", {
100
+ anonymous: true,
101
+ query: { token: q.token },
102
+ headers: { "X-QR-Poll-Key": q.pollKey },
103
+ allow: [401, 404, 410],
104
+ })).data;
105
+ const status = (s?.status ?? "").toUpperCase();
106
+ if (status === "SCANNED" && !scannedShown) {
107
+ scannedShown = true;
108
+ info(c.dim(tr(" ⏺ Đã quét — chờ bấm Duyệt trên điện thoại…", " ⏺ Scanned — waiting for Approve on the phone…")));
109
+ }
110
+ if (status === "REJECTED")
111
+ throw new CliError(tr("Đăng nhập bị từ chối trên điện thoại.", "Login was rejected on the phone."), Exit.auth);
112
+ if (status === "APPROVED" && s.preAuthToken)
113
+ return finishPreAuth(s.preAuthToken, s.workspaces ?? [], opts.workspace, "qr");
114
+ if (status === "EXPIRED")
115
+ break;
116
+ }
117
+ if (round < rounds)
118
+ info(c.dim(tr(" Mã đã hết hạn, tạo mã mới…", " Code expired, creating a new one…")));
119
+ }
120
+ throw new CliError(tr("Hết thời gian chờ quét mã.", "Timed out waiting for the scan."), Exit.auth);
121
+ }
122
+ /**
123
+ * Pre-auth token lives 5 minutes: choose the workspace right away and exchange it for a full session.
124
+ * Used by QR login (and by internal QA scripts that log in with a password).
125
+ */
126
+ export async function finishPreAuth(preAuth, workspaces, wanted, via) {
127
+ if (workspaces.length === 0)
128
+ throw new CliError(t("ws.none"), Exit.auth, t("ws.none.hint", { app: context().endpoints.app }));
129
+ let target = wanted ? findWorkspace(workspaces, wanted) : undefined;
130
+ if (wanted && !target)
131
+ warn(tr(`Không thấy workspace "${wanted}", chọn trong danh sách.`, `Workspace "${wanted}" not found, pick one below.`));
132
+ if (!target) {
133
+ if (workspaces.length === 1 || !hasTty())
134
+ target = workspaces.find((w) => w.isDefault) ?? workspaces[0];
135
+ else {
136
+ target = await select(c.green("✓ ") + tr(`Đã duyệt. Bạn có ${workspaces.length} workspace:`, `Approved. You have ${workspaces.length} workspaces:`), workspaces.map((w) => ({ label: w.name, hint: [w.code, w.primaryGroupName].filter(Boolean).join(" · "), value: w })));
137
+ if (!target)
138
+ throw new CliError(t("interrupted"), Exit.interrupted);
139
+ }
140
+ }
141
+ const r = await request("POST", "/iam/auth/select-workspace", { bearer: preAuth, body: { workspaceId: target.id } });
142
+ const body = r.data;
143
+ const session = saveLogin(body, via);
144
+ return { session, userName: body.user?.fullName, userEmail: body.user?.email, workspaceName: body.workspace?.name ?? target.name };
145
+ }
@@ -0,0 +1,98 @@
1
+ // Authorization-code + PKCE over a loopback redirect (RFC 8252). Shared by browser login and MCP consent.
2
+ // Over SSH the browser runs elsewhere and cannot reach 127.0.0.1 here; the user pastes the address the
3
+ // browser stopped on (it carries the code) and we read the code from it.
4
+ import { createServer } from "node:http";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { brand } from "../core/brand.js";
7
+ import { CliError, Exit } from "../core/errors.js";
8
+ import { tr } from "../core/i18n.js";
9
+ import { c, info } from "../core/output.js";
10
+ import { ask, hasTty } from "../core/prompt.js";
11
+ export function pkce() {
12
+ const verifier = randomBytes(32).toString("base64url");
13
+ return {
14
+ verifier,
15
+ challenge: createHash("sha256").update(verifier).digest("base64url"),
16
+ state: randomBytes(16).toString("base64url"),
17
+ nonce: randomBytes(16).toString("base64url"),
18
+ };
19
+ }
20
+ const page = (title, text) => `<!doctype html><meta charset="utf-8"><title>${brand().name} CLI</title><body style="font:16px system-ui;margin:15vh auto;max-width:520px;text-align:center;padding:0 16px"><h2>${title}</h2><p>${text}</p></body>`;
21
+ function codeFromParams(p, state) {
22
+ const err = p.get("error");
23
+ if (err)
24
+ throw new CliError(err === "access_denied" ? tr("Bạn đã từ chối cấp quyền.", "You declined.") : (p.get("error_description") ?? err), Exit.auth);
25
+ if (p.get("state") !== state)
26
+ throw new CliError(tr("state không khớp, có thể bị chặn giữa đường. Chạy lại lệnh.", "State mismatch; the redirect may have been tampered with. Run the command again."), Exit.auth);
27
+ const code = p.get("code");
28
+ if (!code)
29
+ throw new CliError(tr("Không thấy mã trong địa chỉ.", "No code in that address."), Exit.auth);
30
+ return code;
31
+ }
32
+ export async function waitForCode(state, opts = {}) {
33
+ const path = opts.path ?? "/callback";
34
+ let resolve;
35
+ let reject;
36
+ const code = new Promise((res, rej) => {
37
+ resolve = res;
38
+ reject = rej;
39
+ });
40
+ const server = createServer((req, res) => {
41
+ const u = new URL(req.url ?? "/", "http://127.0.0.1");
42
+ if (u.pathname !== path)
43
+ return void res.writeHead(404).end();
44
+ // Connection: close — otherwise the browser keeps the socket alive and the CLI never exits after login.
45
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", Connection: "close" });
46
+ try {
47
+ const got = codeFromParams(u.searchParams, state);
48
+ res.end(page(tr("Xong", "Done"), tr("Quay lại terminal để tiếp tục. Có thể đóng tab này.", "Return to your terminal. You can close this tab.")));
49
+ resolve(got);
50
+ }
51
+ catch (e) {
52
+ res.end(page(tr("Chưa xong", "Not completed"), e.message));
53
+ reject(e);
54
+ }
55
+ });
56
+ await new Promise((ok, fail) => {
57
+ server.once("error", fail);
58
+ server.listen(0, "127.0.0.1", () => ok());
59
+ });
60
+ const addr = server.address();
61
+ const port = typeof addr === "object" && addr ? addr.port : 0;
62
+ const timer = setTimeout(() => reject(new CliError(tr("Hết thời gian chờ.", "Timed out."), Exit.auth)), opts.timeoutMs ?? 5 * 60_000);
63
+ code.finally(() => clearTimeout(timer)).catch(() => undefined);
64
+ const close = () => {
65
+ server.close();
66
+ server.closeAllConnections();
67
+ };
68
+ return { redirectUri: `http://127.0.0.1:${port}${path}`, code, close };
69
+ }
70
+ /**
71
+ * Opens (or prints) the authorize URL and returns the code. With `manual`, also accepts the pasted
72
+ * address from the browser — whichever arrives first wins.
73
+ */
74
+ export async function obtainCode(authorizeUrl, waiter, state, opts) {
75
+ if (opts.openBrowser) {
76
+ const open = (await import("open")).default;
77
+ await open(authorizeUrl).catch(() => undefined);
78
+ }
79
+ if (!opts.manual || !hasTty()) {
80
+ try {
81
+ return await waiter.code;
82
+ }
83
+ finally {
84
+ waiter.close();
85
+ }
86
+ }
87
+ info(c.dim(tr(" Trình duyệt ở máy khác? Sau khi đồng ý, trang sẽ báo lỗi kết nối — dán địa chỉ trên thanh địa chỉ vào đây:", " Browser on another machine? After you approve, the page fails to load — paste its address here:")));
88
+ const stop = new AbortController();
89
+ const pasted = ask(" › ", stop.signal).then((line) => codeFromParams(new URL(line.trim()).searchParams, state));
90
+ pasted.catch(() => undefined);
91
+ try {
92
+ return await Promise.race([waiter.code, pasted]);
93
+ }
94
+ finally {
95
+ stop.abort();
96
+ waiter.close();
97
+ }
98
+ }
@@ -0,0 +1,174 @@
1
+ // Delegated OAuth for the hosted MCP server. Tool catalog and tool calls only accept tokens issued for the
2
+ // MCP resource (RFC 8707), so the CLI registers itself like any MCP client: dynamic client registration
3
+ // (public, PKCE, loopback), one consent in the browser, then refresh tokens.
4
+ import { hostname } from "node:os";
5
+ import { fetch } from "undici";
6
+ import { brand } from "../core/brand.js";
7
+ import { context } from "../core/context.js";
8
+ import { CliError, Exit } from "../core/errors.js";
9
+ import { tr } from "../core/i18n.js";
10
+ import { c, info } from "../core/output.js";
11
+ import { errorFor, networkError, userAgent } from "../core/http.js";
12
+ import { withFileLock } from "../core/lock.js";
13
+ import { lockFile } from "../core/paths.js";
14
+ import { readSecret, writeSecret, deleteSecret } from "./credentials.js";
15
+ import { pkce, waitForCode, obtainCode } from "./loopback.js";
16
+ const account = () => `mcp:${context().profile}:${context().env}`;
17
+ export function readGrant() {
18
+ const raw = readSecret(account());
19
+ if (!raw)
20
+ return undefined;
21
+ try {
22
+ return JSON.parse(raw);
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ export const deleteGrant = () => deleteSecret(account());
29
+ async function json(url, init) {
30
+ try {
31
+ const res = await fetch(url, { ...init, headers: { "User-Agent": userAgent(), ...init?.headers } });
32
+ return { res, body: (await res.json().catch(() => ({}))) };
33
+ }
34
+ catch (e) {
35
+ throw networkError(e, url);
36
+ }
37
+ }
38
+ async function discover() {
39
+ const mcp = new URL(context().endpoints.mcp);
40
+ const r = await json(`${mcp.origin}/.well-known/oauth-protected-resource${mcp.pathname}`);
41
+ if (!r.res.ok || !r.body.authorization_servers?.length)
42
+ throw errorFor(r.res.status, r.body, r.res);
43
+ const asUrl = r.body.authorization_servers[0];
44
+ const a = await json(`${asUrl}/.well-known/oauth-authorization-server`);
45
+ if (!a.res.ok)
46
+ throw errorFor(a.res.status, a.body, a.res);
47
+ return { resource: r.body, as: a.body, asUrl };
48
+ }
49
+ /** Scopes to ask for: everything the server offers, limited to the brand's modules. */
50
+ function wantedScopes(offered) {
51
+ const mods = brand().modules;
52
+ return offered.filter((s) => !mods || mods.includes(s.split(":")[0])).join(" ");
53
+ }
54
+ export async function authorizeMcp(opts = {}) {
55
+ const { resource, as, asUrl } = await discover();
56
+ if (!as.registration_endpoint)
57
+ throw new CliError(tr("Máy chủ không cho đăng ký client.", "The server does not allow client registration."), Exit.general);
58
+ const p = pkce();
59
+ const waiter = await waitForCode(p.state);
60
+ const prev = readGrant();
61
+ let clientId = prev?.authServer === asUrl ? prev.clientId : undefined;
62
+ if (!clientId) {
63
+ const reg = await json(as.registration_endpoint, {
64
+ method: "POST",
65
+ headers: { "Content-Type": "application/json" },
66
+ body: JSON.stringify({
67
+ client_name: `${brand().name} CLI (${hostname()})`,
68
+ client_uri: context().endpoints.docs + "/docs/cli",
69
+ redirect_uris: ["http://127.0.0.1/callback"],
70
+ grant_types: ["authorization_code", "refresh_token"],
71
+ token_endpoint_auth_method: "none",
72
+ }),
73
+ });
74
+ if (!reg.res.ok)
75
+ throw errorFor(reg.res.status, reg.body, reg.res);
76
+ clientId = reg.body.client_id;
77
+ }
78
+ const scope = wantedScopes(resource.scopes_supported ?? []);
79
+ const url = new URL(as.authorization_endpoint);
80
+ url.search = new URLSearchParams({
81
+ response_type: "code",
82
+ client_id: clientId,
83
+ redirect_uri: waiter.redirectUri,
84
+ scope,
85
+ state: p.state,
86
+ code_challenge: p.challenge,
87
+ code_challenge_method: "S256",
88
+ resource: resource.resource,
89
+ }).toString();
90
+ info(`${c.cyan("◆")} ${tr(`Cấp quyền công cụ AI cho ${brand().name} CLI (một lần). Mở trình duyệt…`, `Grant AI tool access to the ${brand().name} CLI (once). Opening your browser…`)}`);
91
+ info(` ${c.yellow(url.toString())}`);
92
+ const code = await obtainCode(url.toString(), waiter, p.state, { openBrowser: opts.openBrowser !== false, manual: opts.manual === true });
93
+ const tok = await json(as.token_endpoint, {
94
+ method: "POST",
95
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
96
+ body: new URLSearchParams({
97
+ grant_type: "authorization_code",
98
+ code,
99
+ redirect_uri: waiter.redirectUri,
100
+ client_id: clientId,
101
+ code_verifier: p.verifier,
102
+ resource: resource.resource,
103
+ }).toString(),
104
+ });
105
+ if (!tok.res.ok)
106
+ throw errorFor(tok.res.status, tok.body, tok.res);
107
+ const grant = {
108
+ clientId,
109
+ accessToken: String(tok.body.access_token),
110
+ refreshToken: tok.body.refresh_token ? String(tok.body.refresh_token) : undefined,
111
+ expiresAt: Date.now() + Number(tok.body.expires_in ?? 900) * 1000,
112
+ scope: String(tok.body.scope ?? scope),
113
+ resource: resource.resource,
114
+ authServer: asUrl,
115
+ };
116
+ writeSecret(account(), JSON.stringify(grant));
117
+ return grant;
118
+ }
119
+ async function refreshGrant(g) {
120
+ if (!g.refreshToken)
121
+ return undefined;
122
+ return withFileLock(lockFile(`${context().profile}-mcp`), async () => {
123
+ const cur = readGrant();
124
+ if (cur && cur.accessToken !== g.accessToken && cur.expiresAt - Date.now() > 60_000)
125
+ return cur;
126
+ const a = await json(`${g.authServer}/.well-known/oauth-authorization-server`);
127
+ const tok = await json(a.body.token_endpoint, {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
130
+ body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: g.refreshToken, client_id: g.clientId, resource: g.resource }).toString(),
131
+ });
132
+ if (!tok.res.ok)
133
+ return undefined;
134
+ const next = {
135
+ ...g,
136
+ accessToken: String(tok.body.access_token),
137
+ refreshToken: tok.body.refresh_token ? String(tok.body.refresh_token) : g.refreshToken,
138
+ expiresAt: Date.now() + Number(tok.body.expires_in ?? 900) * 1000,
139
+ scope: String(tok.body.scope ?? g.scope),
140
+ };
141
+ writeSecret(account(), JSON.stringify(next));
142
+ return next;
143
+ });
144
+ }
145
+ /** A valid MCP token, refreshing or asking for consent when needed (consent only with a TTY). */
146
+ export async function mcpToken(opts) {
147
+ let g = readGrant();
148
+ if (g && g.expiresAt - Date.now() < 60_000)
149
+ g = await refreshGrant(g);
150
+ if (g)
151
+ return g.accessToken;
152
+ if (!opts.interactive)
153
+ throw new CliError(tr("CLI chưa được cấp quyền công cụ AI trên máy này.", "The CLI has not been granted AI tool access on this machine."), Exit.auth, tr(`Chạy một lần: ${brand().bin} tools login`, `Run once: ${brand().bin} tools login`));
154
+ const { browserAvailable } = await import("../commands/auth.js");
155
+ return (await authorizeMcp({ openBrowser: browserAvailable(), manual: !browserAvailable() })).accessToken;
156
+ }
157
+ export async function revokeGrant() {
158
+ const g = readGrant();
159
+ if (!g)
160
+ return;
161
+ try {
162
+ const a = await json(`${g.authServer}/.well-known/oauth-authorization-server`);
163
+ if (a.body.revocation_endpoint)
164
+ await fetch(a.body.revocation_endpoint, {
165
+ method: "POST",
166
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
167
+ body: new URLSearchParams({ token: g.refreshToken ?? g.accessToken, client_id: g.clientId }).toString(),
168
+ });
169
+ }
170
+ catch {
171
+ // local removal still happens
172
+ }
173
+ deleteGrant();
174
+ }
@@ -0,0 +1,205 @@
1
+ // Identity resolution and session lifecycle.
2
+ // Order: <PREFIX>_API_KEY → <PREFIX>_TOKEN → stored session of the profile. `status` prints which one is used.
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 { t } from "../core/i18n.js";
8
+ import { withFileLock } from "../core/lock.js";
9
+ import { lockFile } from "../core/paths.js";
10
+ import { getProfile, updateProfile } from "../core/config.js";
11
+ import { readSession, writeSession, deleteSession } from "./credentials.js";
12
+ /** Refresh this long before expiry so a request never leaves with a token about to die. */
13
+ const EARLY_MS = 60_000;
14
+ export function decodeJwt(token) {
15
+ const part = token.split(".")[1];
16
+ if (!part)
17
+ return {};
18
+ try {
19
+ return JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
20
+ }
21
+ catch {
22
+ return {};
23
+ }
24
+ }
25
+ export function authSource() {
26
+ if (envVar("API_KEY"))
27
+ return "apiKey";
28
+ if (envVar("TOKEN"))
29
+ return "token";
30
+ return readSession(context().profile) ? "session" : "none";
31
+ }
32
+ export async function currentAuth(opts = {}) {
33
+ const key = envVar("API_KEY");
34
+ if (key)
35
+ return { kind: "apiKey", key };
36
+ const token = envVar("TOKEN");
37
+ if (token)
38
+ return { kind: "token", token };
39
+ const s = readSession(context().profile);
40
+ if (!s) {
41
+ if (opts.required)
42
+ throw notLoggedIn();
43
+ return undefined;
44
+ }
45
+ if (s.expiresAt - Date.now() < EARLY_MS)
46
+ return refreshSession(s.accessToken);
47
+ return { kind: "session", token: s.accessToken, session: s };
48
+ }
49
+ export function notLoggedIn() {
50
+ return new CliError(t("auth.notLoggedIn"), Exit.auth, t("auth.notLoggedIn.hint"));
51
+ }
52
+ /** Throws unless the identity is a person (chat, workspace switch, write confirmation need one). */
53
+ export async function requirePerson() {
54
+ const a = await currentAuth({ required: true });
55
+ if (a.kind === "apiKey")
56
+ throw new CliError(t("auth.apiKeyNoChat"), Exit.forbidden, t("auth.apiKeyNoChat.hint"));
57
+ return a;
58
+ }
59
+ export function sessionFrom(body, via, prev) {
60
+ const accessToken = body.accessToken ?? body.access_token;
61
+ const refreshToken = body.refreshToken ?? body.refresh_token ?? prev?.refreshToken;
62
+ if (!accessToken || !refreshToken)
63
+ throw new CliError(t("auth.expired"), Exit.auth, t("auth.expired.hint"));
64
+ const claims = decodeJwt(accessToken);
65
+ const expiresIn = body.expiresIn ?? body.expires_in ?? 900;
66
+ // Trust our own clock for expiry; the server clock only matters for skew detection (doctor).
67
+ return {
68
+ kind: "session",
69
+ accessToken,
70
+ refreshToken,
71
+ expiresAt: Date.now() + expiresIn * 1000,
72
+ userId: String(claims.sub ?? prev?.userId ?? ""),
73
+ workspaceId: String(claims.wid ?? claims.workspace_id ?? prev?.workspaceId ?? ""),
74
+ via,
75
+ sessionId: prev?.sessionId,
76
+ };
77
+ }
78
+ /**
79
+ * Refreshes the stored session. `staleAccess` is the token the caller saw: if another process already
80
+ * replaced it while we waited for the lock, its result is reused instead of refreshing again.
81
+ */
82
+ export async function refreshSession(staleAccess) {
83
+ const profile = context().profile;
84
+ return withFileLock(lockFile(profile), async () => {
85
+ const s = readSession(profile);
86
+ if (!s)
87
+ throw notLoggedIn();
88
+ if (staleAccess && s.accessToken !== staleAccess && s.expiresAt - Date.now() > EARLY_MS)
89
+ return { kind: "session", token: s.accessToken, session: s };
90
+ if (s.via === "ci") {
91
+ // CI tokens have no refresh token: exchange the provider's OIDC token again.
92
+ const { loginCi } = await import("./ci.js");
93
+ const r = await loginCi({});
94
+ return { kind: "session", token: r.session.accessToken, session: r.session };
95
+ }
96
+ const url = `${context().endpoints.api}/api/v1/iam/auth/refresh-token`;
97
+ let res;
98
+ try {
99
+ res = await fetch(url, {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json", "X-Client-Type": "cli", "User-Agent": `${brand().bin}-cli` },
102
+ body: JSON.stringify({ refreshToken: s.refreshToken }),
103
+ });
104
+ }
105
+ catch (e) {
106
+ const { networkError } = await import("../core/http.js");
107
+ throw networkError(e, url);
108
+ }
109
+ const body = (await res.json().catch(() => ({})));
110
+ if (res.status === 401 || res.status === 403) {
111
+ // A parallel process may have rotated meanwhile; only give up if the store still holds our token.
112
+ const again = readSession(profile);
113
+ if (again && again.refreshToken !== s.refreshToken)
114
+ return { kind: "session", token: again.accessToken, session: again };
115
+ deleteSession(profile);
116
+ throw new CliError(res.status === 403 ? t("ws.gone") : t("auth.expired"), res.status === 403 ? Exit.forbidden : Exit.auth, t("auth.expired.hint"));
117
+ }
118
+ if (!res.ok) {
119
+ const { errorFor } = await import("../core/http.js");
120
+ throw errorFor(res.status, body, res);
121
+ }
122
+ const next = sessionFrom(body, s.via, s);
123
+ writeSession(profile, next);
124
+ return { kind: "session", token: next.accessToken, session: next };
125
+ });
126
+ }
127
+ export function saveLogin(body, via) {
128
+ const profile = context().profile;
129
+ const s = sessionFrom(body, via);
130
+ writeSession(profile, s);
131
+ updateProfile(profile, {
132
+ env: context().env,
133
+ workspaceId: body.workspace?.id ?? s.workspaceId,
134
+ workspaceCode: body.workspace?.code,
135
+ workspaceName: body.workspace?.name,
136
+ userEmail: body.user?.email,
137
+ userName: body.user?.fullName,
138
+ });
139
+ return s;
140
+ }
141
+ /** Switches the stored session to another workspace. The server ends the old session and opens a new one. */
142
+ export async function switchWorkspace(target) {
143
+ const { request } = await import("../core/http.js");
144
+ const r = await request("POST", "/iam/workspaces/switch", { body: { workspaceId: target.id } });
145
+ const profile = context().profile;
146
+ const prev = readSession(profile);
147
+ const s = sessionFrom(r.data, prev?.via ?? "password", prev);
148
+ s.workspaceId = target.id;
149
+ writeSession(profile, s);
150
+ updateProfile(profile, {
151
+ workspaceId: target.id,
152
+ workspaceCode: r.data.workspace?.code ?? target.code,
153
+ workspaceName: r.data.workspace?.name ?? target.name,
154
+ lastConversation: undefined,
155
+ });
156
+ return s;
157
+ }
158
+ export async function listWorkspaces() {
159
+ const { get } = await import("../core/http.js");
160
+ return get("/iam/workspaces");
161
+ }
162
+ export function findWorkspace(list, key) {
163
+ const k = key.toLowerCase();
164
+ return list.find((w) => w.id === key || w.code?.toLowerCase() === k) ?? list.find((w) => w.name.toLowerCase() === k);
165
+ }
166
+ /**
167
+ * -w for a single command: switch, run, switch back. Each step stores the new tokens, so a crash in the
168
+ * middle leaves a valid session (on the other workspace) rather than a dead one.
169
+ */
170
+ export async function withWorkspace(key, fn) {
171
+ if (!key || authSource() !== "session")
172
+ return fn();
173
+ const profile = context().profile;
174
+ const current = getProfile(profile);
175
+ const list = await listWorkspaces();
176
+ const target = findWorkspace(list, key);
177
+ if (!target)
178
+ throw new CliError(t("ws.gone"), Exit.notFound, t("ws.gone.hint"));
179
+ if (target.id === current?.workspaceId)
180
+ return fn();
181
+ const original = list.find((w) => w.id === current?.workspaceId);
182
+ await switchWorkspace(target);
183
+ try {
184
+ return await fn();
185
+ }
186
+ finally {
187
+ if (original)
188
+ await switchWorkspace(original).catch(() => undefined);
189
+ }
190
+ }
191
+ export async function logoutServer() {
192
+ const s = readSession(context().profile);
193
+ if (!s)
194
+ return false;
195
+ const { request } = await import("../core/http.js");
196
+ try {
197
+ // Logout finds the server session by the access token's jti, so make sure it is still valid.
198
+ const auth = await currentAuth();
199
+ await request("POST", "/iam/auth/logout", { bearer: auth?.kind === "session" ? auth.token : s.accessToken, body: {}, allow: [401] });
200
+ return true;
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ }
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { setBrand } from "../core/brand.js";
3
+ setBrand("nextx");
4
+ const { main } = await import("../main.js");
5
+ await main(process.argv);