nexarch 0.13.4 → 0.13.5

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.
@@ -1,6 +1,7 @@
1
1
  import { createServer } from "http";
2
2
  import { createServer as createNetServer } from "net";
3
- import { randomBytes } from "crypto";
3
+ import { createHash, randomBytes } from "crypto";
4
+ import * as readline from "node:readline/promises";
4
5
  import { execFile } from "child_process";
5
6
  import { saveCredentials } from "../lib/credentials.js";
6
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
@@ -136,6 +137,68 @@ function waitForCallback(port, expectedState) {
136
137
  });
137
138
  });
138
139
  }
140
+ /**
141
+ * Whether the browser that approves this login can reach the CLI at
142
+ * localhost. It can't over SSH (the browser is on the person's own machine,
143
+ * the CLI on the remote one) or on a Linux machine with no display; the
144
+ * loopback callback then lands on the wrong machine and the login hangs.
145
+ * `--no-browser` forces paste mode, `--browser` forces the loopback flow.
146
+ */
147
+ export function shouldUsePasteLogin(args, env = process.env, platform = process.platform) {
148
+ if (args.includes("--browser"))
149
+ return false;
150
+ if (args.includes("--no-browser"))
151
+ return true;
152
+ if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY)
153
+ return true;
154
+ return platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY;
155
+ }
156
+ /** A random verifier the CLI keeps, and the challenge it sends: base64url(SHA-256(verifier)). */
157
+ export function createPasteChallenge() {
158
+ const verifier = randomBytes(32).toString("base64url");
159
+ return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
160
+ }
161
+ /** Mistakes worth letting someone paste again rather than starting over. */
162
+ const RETRYABLE_EXCHANGE_ERRORS = new Set(["malformed", "bad_signature"]);
163
+ async function pasteLogin(qp, verifier) {
164
+ const authUrl = `${NEXARCH_URL}/auth/cli?${qp.toString()}`;
165
+ console.log("\nThis terminal can't receive the browser's reply directly (for example over SSH),");
166
+ console.log("so you'll paste a one-time code instead.\n");
167
+ console.log(" 1. Open this link in a browser on any machine:\n");
168
+ console.log(` ${authUrl}\n`);
169
+ console.log(" 2. Sign in and authorize.");
170
+ console.log(" 3. Copy the code the page shows and paste it here.\n");
171
+ if (!process.stdin.isTTY) {
172
+ throw new Error("Paste login needs an interactive terminal to paste the code into. Run this command directly in a terminal.");
173
+ }
174
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
175
+ try {
176
+ for (let attempt = 1; attempt <= 3; attempt++) {
177
+ const code = (await rl.question("Code: ")).trim();
178
+ if (!code)
179
+ continue;
180
+ const response = await fetch(`${NEXARCH_URL}/api/auth/cli/exchange`, {
181
+ method: "POST",
182
+ headers: { "content-type": "application/json" },
183
+ body: JSON.stringify({ code, verifier }),
184
+ });
185
+ const body = (await response.json().catch(() => ({})));
186
+ if (response.ok && body.token && body.companyId) {
187
+ return { token: body.token, companyId: body.companyId, companyName: body.companyName, companyCode: body.companyCode };
188
+ }
189
+ const message = body.message ?? `Login failed (${response.status}).`;
190
+ if (body.error && RETRYABLE_EXCHANGE_ERRORS.has(body.error) && attempt < 3) {
191
+ console.log(`\n${message}\n`);
192
+ continue;
193
+ }
194
+ throw new Error(message);
195
+ }
196
+ throw new Error("No code was entered. Run the command again to get a new link.");
197
+ }
198
+ finally {
199
+ rl.close();
200
+ }
201
+ }
139
202
  function getArgValue(args, flag) {
140
203
  const idx = args.indexOf(flag);
141
204
  if (idx === -1)
@@ -148,21 +211,33 @@ function getArgValue(args, flag) {
148
211
  export async function login(args) {
149
212
  printLoginBanner();
150
213
  const state = generateState();
151
- const port = await findFreePort();
152
214
  const requestedCompany = getArgValue(args, "--company");
153
215
  const requestedProfile = getArgValue(args, "--profile");
154
216
  const isCapabilityProfile = (value) => PROFILE_CHOICES.some((c) => c.value === value);
155
217
  const profile = isCapabilityProfile(requestedProfile) ? requestedProfile : await promptCapabilityProfile();
156
- const qp = new URLSearchParams({ port: String(port), state, profile });
157
- if (requestedCompany)
158
- qp.set("company", requestedCompany);
159
- const authUrl = `${NEXARCH_URL}/auth/cli?${qp.toString()}`;
160
218
  console.log(`\nCapability profile: ${profile}`);
161
- console.log("Opening Nexarch in your browser…");
162
- console.log(`\n ${authUrl}\n`);
163
- console.log("If the browser did not open, copy the URL above and paste it in manually.\n");
164
- openBrowser(authUrl);
165
- const { token, companyId, companyName, companyCode } = await waitForCallback(port, state);
219
+ let result;
220
+ if (shouldUsePasteLogin(args)) {
221
+ const { verifier, challenge } = createPasteChallenge();
222
+ const qp = new URLSearchParams({ mode: "paste", challenge, state, profile });
223
+ if (requestedCompany)
224
+ qp.set("company", requestedCompany);
225
+ result = await pasteLogin(qp, verifier);
226
+ }
227
+ else {
228
+ const port = await findFreePort();
229
+ const qp = new URLSearchParams({ port: String(port), state, profile });
230
+ if (requestedCompany)
231
+ qp.set("company", requestedCompany);
232
+ const authUrl = `${NEXARCH_URL}/auth/cli?${qp.toString()}`;
233
+ console.log("Opening Nexarch in your browser…");
234
+ console.log(`\n ${authUrl}\n`);
235
+ console.log("If the browser did not open, copy the URL above and paste it in manually.");
236
+ console.log("If your browser is on a different machine (for example over SSH), stop this and run it again with --no-browser.\n");
237
+ openBrowser(authUrl);
238
+ result = await waitForCallback(port, state);
239
+ }
240
+ const { token, companyId, companyName, companyCode } = result;
166
241
  const expiresAt = new Date();
167
242
  expiresAt.setDate(expiresAt.getDate() + 90);
168
243
  saveCredentials({
package/dist/index.js CHANGED
@@ -107,7 +107,12 @@ nexarch — Your architecture workspace for AI delivery.
107
107
 
108
108
  Usage:
109
109
  nexarch login Authenticate in browser and store company-scoped credentials
110
- Option: --company <id>
110
+ Options: --company <id>
111
+ --no-browser paste a one-time code instead of
112
+ receiving the browser's reply
113
+ locally; automatic over SSH or on
114
+ Linux with no display
115
+ --browser always use the local browser reply
111
116
  nexarch --version Print the version and the path this copy runs from
112
117
  nexarch enroll Bootstrap a headless (browserless) agent with a one-time
113
118
  enrollment code — no login required. Writes the resulting
@@ -135,6 +140,7 @@ Usage:
135
140
  registering anything; answer 'n' to pick a different one.
136
141
  Options: --company <id|code> target a workspace directly
137
142
  --yes accept the stored workspace without asking
143
+ --no-browser log in by pasting a one-time code (see login)
138
144
  nexarch mcp-config Print MCP server config block for manual setup
139
145
  Client list is registry-managed (see 'nexarch mcp-config --client <code>')
140
146
  nexarch mcp-proxy Run as stdio MCP proxy (used by MCP clients)
package/package.json CHANGED
@@ -1,39 +1,39 @@
1
- {
2
- "name": "nexarch",
3
- "version": "0.13.4",
4
- "description": "Your architecture workspace for AI delivery.",
5
- "keywords": [
6
- "nexarch",
7
- "mcp",
8
- "architecture",
9
- "ai"
10
- ],
11
- "license": "MIT",
12
- "author": "Nexarch <hello@nexarch.ai>",
13
- "homepage": "https://nexarch.ai",
14
- "engines": {
15
- "node": ">=18"
16
- },
17
- "type": "module",
18
- "bin": {
19
- "nexarch": "dist/index.js"
20
- },
21
- "files": [
22
- "dist"
23
- ],
24
- "scripts": {
25
- "build": "tsc",
26
- "prepublishOnly": "tsc",
27
- "dev": "tsx src/index.ts",
28
- "typecheck": "tsc --noEmit",
29
- "test": "tsx scripts/test-mcp-proxy-response.ts && tsx scripts/test-trust-reattest.ts && tsx scripts/test-similar-applications.ts && tsx scripts/test-iac-tool.ts && tsx scripts/test-ansible.ts && tsx scripts/test-reference-sightings.ts && tsx scripts/test-search-icons.ts"
30
- },
31
- "devDependencies": {
32
- "@types/node": "^22",
33
- "tsx": "^4",
34
- "typescript": "^5"
35
- },
36
- "dependencies": {
37
- "yaml": "^2.9.0"
38
- }
39
- }
1
+ {
2
+ "name": "nexarch",
3
+ "version": "0.13.5",
4
+ "description": "Your architecture workspace for AI delivery.",
5
+ "keywords": [
6
+ "nexarch",
7
+ "mcp",
8
+ "architecture",
9
+ "ai"
10
+ ],
11
+ "license": "MIT",
12
+ "author": "Nexarch <hello@nexarch.ai>",
13
+ "homepage": "https://nexarch.ai",
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "type": "module",
18
+ "bin": {
19
+ "nexarch": "dist/index.js"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "prepublishOnly": "tsc",
27
+ "dev": "tsx src/index.ts",
28
+ "typecheck": "tsc --noEmit",
29
+ "test": "tsx scripts/test-mcp-proxy-response.ts && tsx scripts/test-trust-reattest.ts && tsx scripts/test-similar-applications.ts && tsx scripts/test-iac-tool.ts && tsx scripts/test-ansible.ts && tsx scripts/test-reference-sightings.ts && tsx scripts/test-search-icons.ts && tsx scripts/test-paste-login.ts"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22",
33
+ "tsx": "^4",
34
+ "typescript": "^5"
35
+ },
36
+ "dependencies": {
37
+ "yaml": "^2.9.0"
38
+ }
39
+ }