@index365/cli 0.1.1 → 0.2.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/README.md CHANGED
@@ -8,14 +8,12 @@ index365 runs two audits: **AI-Readiness** (how well AI agents and AI search can
8
8
 
9
9
  ```bash
10
10
  npm install -g @index365/cli
11
- # or run without installing:
12
- npx @index365/cli --help
13
11
  ```
14
12
 
15
13
  ## Quickstart
16
14
 
17
15
  ```bash
18
- index365 login # paste an i365_ key from the dashboard (Org settings -> API keys)
16
+ index365 login --web # sign in through your browser (no key to copy/paste)
19
17
  index365 doctor # verify auth, scopes, and API reachability
20
18
  index365 projects list # find a projectId
21
19
  index365 runs start --project <id> --wait # run an AI-readiness audit, wait for the score
@@ -25,6 +23,8 @@ index365 findings list --run <runId> # triage findings
25
23
  index365 findings get --run <runId> <findingId> # full detail + remediation
26
24
  ```
27
25
 
26
+ `index365 login --web` opens your browser, signs you in on the dashboard, and saves a new key automatically (loopback + PKCE, so the secret never travels through a URL). For CI or headless machines, use `index365 login` to paste an `i365_` key from the dashboard API Keys page, or set `INDEX365_API_KEY`.
27
+
28
28
  Add `--json` to any command for machine-readable output. Keys live at `~/.config/index365/config.json` (mode 0600); `INDEX365_API_KEY` overrides the file.
29
29
 
30
30
  ## Exit codes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@index365/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "index365 CLI - run AI-readiness and marketing-signal audits and read findings from your terminal, CI, or agents. Wraps the public /api/v1.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.mjs CHANGED
@@ -11,8 +11,15 @@ import {
11
11
  resolveSettings,
12
12
  writeConfigFile,
13
13
  } from "./config.mjs";
14
+ import {
15
+ defaultOpenUrl,
16
+ defaultStartLoopback,
17
+ generatePkcePair,
18
+ generateStateNonce,
19
+ webLogin,
20
+ } from "./web-login.mjs";
14
21
 
15
- export const CLI_VERSION = "0.1.1";
22
+ export const CLI_VERSION = "0.2.0";
16
23
 
17
24
  const HELP = `index365 - website audits from your terminal, CI, or agents
18
25
 
@@ -20,19 +27,19 @@ USAGE
20
27
  index365 <command> [options]
21
28
 
22
29
  COMMANDS
23
- login Save an API key (prompts; or --key / INDEX365_API_KEY)
30
+ login Sign in via browser (--web), or save an API key (prompts; or --key / INDEX365_API_KEY)
24
31
  logout Remove the saved API key
25
32
  doctor Verify auth, scopes, API reachability, contract version
26
33
  projects list List projects in your organization
27
34
  projects create Create a project (--domain <domain> [--name <name>])
28
35
  projects delete Delete a project (<projectId> --confirm <domain>)
29
- runs start Start a paid AI-readiness audit (--project <id>, optional --wait)
36
+ runs start Start a paid AI-readiness audit (--project <id>, optional --url <url> to audit a specific page, optional --wait)
30
37
  runs get Show one run (<runId>)
31
38
  findings list List findings for a run (--run <id>)
32
39
  findings get Show one finding (--run <id> <findingId>)
33
40
  reports context Compact agent report context for a run (<runId>)
34
- reports download Download the PDF report to .index365/ (<runId> [--output <file>])
35
- marketing run Start a Marketing Signal audit (--project <id>, optional --wait)
41
+ reports download Save the full report JSON (context + findings) to .index365/ (<runId> [--output <file>])
42
+ marketing run Start a Marketing Signal audit (--project <id>, optional --url <url> to audit a specific page, optional --wait)
36
43
  marketing report Latest Marketing Signal report for a project (--project <id>)
37
44
  marketing findings Marketing findings (--run <id> | --project <id>, optional --stage)
38
45
  integrations list Connected-signal providers + status (--project <id>)
@@ -47,8 +54,11 @@ EXIT CODES
47
54
  0 ok · 1 error · 2 usage · 3 auth · 4 not found · 5 quota/conflict/rate
48
55
 
49
56
  AUTH
50
- Keys are created from any active paid plan's workspace in the dashboard
51
- (Org settings -> API keys) and stored at ~/.config/index365/config.json
57
+ 'index365 login --web' opens your browser, signs you in on the dashboard,
58
+ and saves a new key automatically (loopback + PKCE; no key to copy/paste).
59
+ For CI/headless, 'index365 login' takes a key from --key / INDEX365_API_KEY,
60
+ or you can create one from the dashboard API Keys page (every plan, including
61
+ Free). Either way the key is stored at ~/.config/index365/config.json
52
62
  (0600). INDEX365_API_KEY overrides the file.
53
63
  `;
54
64
 
@@ -91,9 +101,29 @@ async function promptForKey(io) {
91
101
  EXIT.USAGE,
92
102
  );
93
103
  }
94
- const rl = createInterface({ input, output });
104
+ const rl = createInterface({ input, output, terminal: true });
105
+ // Mask the key as it is typed/pasted so it is not echoed to the terminal
106
+ // (audit #9). Masking uses readline's `_writeToOutput` echo hook (stable
107
+ // across Node 18-22 and the basis of most prompt libraries). The prompt text
108
+ // is written before muting; only the secret keystrokes are suppressed.
109
+ let muted = false;
110
+ const writeToOutput = rl._writeToOutput?.bind(rl);
111
+ if (typeof writeToOutput === "function") {
112
+ rl._writeToOutput = (chunk) => {
113
+ if (muted && chunk !== "\n" && chunk !== "\r\n") return;
114
+ writeToOutput(chunk);
115
+ };
116
+ } else {
117
+ // Never fail SILENTLY: if a runtime lacks the echo hook, tell the user the
118
+ // key will be visible so they can choose --key / INDEX365_API_KEY instead.
119
+ output.write("Note: input will be visible in this terminal. Ctrl+C to cancel and use --key.\n");
120
+ }
95
121
  try {
96
- const answer = await rl.question("Paste your API key (i365_...): ");
122
+ const pending = rl.question("Paste your API key (i365_...): ");
123
+ muted = true;
124
+ const answer = await pending;
125
+ muted = false;
126
+ output.write("\n");
97
127
  return answer.trim();
98
128
  } finally {
99
129
  rl.close();
@@ -101,7 +131,11 @@ async function promptForKey(io) {
101
131
  }
102
132
 
103
133
  async function cmdLogin(argv, io, env) {
104
- const { values } = parse(argv, { key: { type: "string" } });
134
+ const { values } = parse(argv, {
135
+ key: { type: "string" },
136
+ web: { type: "boolean", default: false },
137
+ });
138
+ if (values.web) return cmdLoginWeb(values, io, env);
105
139
  const key = (values.key ?? env.INDEX365_API_KEY ?? "").trim() || (await promptForKey(io));
106
140
  if (!key.startsWith("i365_")) {
107
141
  throw new CliError("That does not look like an index365 API key (i365_...).", EXIT.USAGE);
@@ -128,6 +162,42 @@ async function cmdLogin(argv, io, env) {
128
162
  return EXIT.OK;
129
163
  }
130
164
 
165
+ /**
166
+ * `index365 login --web` — open the browser, complete the loopback + PKCE
167
+ * consent on the dashboard, and save the minted key to the same 0600 config the
168
+ * paste flow writes. The secret only ever arrives in the exchange response body;
169
+ * it is never printed.
170
+ */
171
+ async function cmdLoginWeb(values, io, env) {
172
+ const settings = settingsFor(values, env);
173
+ const token = await webLogin({ apiUrl: settings.apiUrl, json: values.json }, io);
174
+
175
+ const config = readConfigFile(env);
176
+ config.apiKey = token.secret;
177
+ if (values["api-url"]) config.apiUrl = settings.apiUrl;
178
+ const file = writeConfigFile(config, env);
179
+
180
+ if (values.json) {
181
+ printJson(io, {
182
+ ok: true,
183
+ configPath: file,
184
+ keyName: token.keyName,
185
+ keyPrefix: token.keyPrefix,
186
+ scopes: token.scopes,
187
+ organizationId: token.organizationId,
188
+ organizationSlug: token.organizationSlug ?? null,
189
+ });
190
+ } else {
191
+ out(
192
+ io,
193
+ `Logged in as '${token.keyName}' (${token.keyPrefix}…) for org ${token.organizationSlug ?? token.organizationId}.`,
194
+ );
195
+ out(io, `Scopes: ${token.scopes.join(", ")}`);
196
+ out(io, `Saved to ${file} (0600).`);
197
+ }
198
+ return EXIT.OK;
199
+ }
200
+
131
201
  async function cmdLogout(argv, io, env) {
132
202
  const { values } = parse(argv);
133
203
  const removed = deleteConfigFile(env);
@@ -295,11 +365,15 @@ async function cmdRuns(argv, io, env) {
295
365
  if (sub === "start") {
296
366
  const { values } = parse(rest, {
297
367
  project: { type: "string" },
368
+ url: { type: "string" },
298
369
  "idempotency-key": { type: "string" },
299
370
  wait: { type: "boolean", default: false },
300
371
  });
301
372
  if (!values.project) {
302
- throw new CliError("Usage: index365 runs start --project <projectId> [--wait]", EXIT.USAGE);
373
+ throw new CliError(
374
+ "Usage: index365 runs start --project <projectId> [--url <url>] [--wait]",
375
+ EXIT.USAGE,
376
+ );
303
377
  }
304
378
  return startRunAndMaybeWait(values, io, env, "paid_ai_readiness");
305
379
  }
@@ -407,17 +481,42 @@ async function cmdReports(argv, io, env) {
407
481
  throw new CliError("Usage: index365 reports download <runId> [--output <file>]", EXIT.USAGE);
408
482
  }
409
483
  const settings = settingsFor(values, env);
410
- const link = await apiRequest(settings, "GET", `/api/v1/runs/${runId}/pdf`, {
411
- fetchImpl: io.fetch,
412
- });
413
- const target = values.output ?? `.index365/index365-audit-${runId}.pdf`;
414
- const res = await io.fetch(link.url);
415
- if (!res.ok) throw new CliError(`PDF download failed (${res.status}).`, EXIT.ERROR);
416
- const buffer = Buffer.from(await res.arrayBuffer());
484
+ // Save the self-contained report: the compact agent context PLUS every
485
+ // finding inlined, matching the dashboard's JSON export. (The legacy PDF
486
+ // download was retired 2026-06-23; the report is served as JSON/Markdown.)
487
+ let report;
488
+ try {
489
+ report = await apiRequest(settings, "GET", `/api/v1/runs/${runId}/report`, {
490
+ fetchImpl: io.fetch,
491
+ });
492
+ } catch (err) {
493
+ if (err instanceof CliError && err.detail?.code === "results_unavailable") {
494
+ throw new CliError(
495
+ `No report export for run ${runId} (e.g. Website Security runs are not served by the report API yet). Try: index365 findings list --run ${runId}`,
496
+ EXIT.ERROR,
497
+ );
498
+ }
499
+ throw err;
500
+ }
501
+ // Page through every finding so the saved file is the full superset.
502
+ const findings = [];
503
+ let cursor;
504
+ do {
505
+ const page = await apiRequest(settings, "GET", `/api/v1/runs/${runId}/findings`, {
506
+ fetchImpl: io.fetch,
507
+ query: cursor ? { cursor } : {},
508
+ });
509
+ if (Array.isArray(page?.findings)) findings.push(...page.findings);
510
+ cursor = page?.pagination?.nextCursor ?? undefined;
511
+ } while (cursor);
512
+ const data = { ...report, findings };
513
+ const body = `${JSON.stringify(data, null, 2)}\n`;
514
+ const bytes = Buffer.byteLength(body);
515
+ const target = values.output ?? `.index365/index365-report-${runId}.json`;
417
516
  mkdirSync(dirname(target), { recursive: true });
418
- writeFileSync(target, buffer);
419
- if (values.json) printJson(io, { ok: true, file: target, bytes: buffer.length });
420
- else out(io, `Saved ${target} (${buffer.length} bytes).`);
517
+ writeFileSync(target, body);
518
+ if (values.json) printJson(io, { ok: true, file: target, bytes });
519
+ else out(io, `Saved ${target} (${bytes} bytes).`);
421
520
  return EXIT.OK;
422
521
  }
423
522
  throw new CliError("Usage: index365 reports <context|download>", EXIT.USAGE);
@@ -426,8 +525,16 @@ async function cmdReports(argv, io, env) {
426
525
  /** Shared run-start + optional poll loop (AI-readiness and Marketing Signal). */
427
526
  async function startRunAndMaybeWait(values, io, env, scanMode) {
428
527
  const settings = settingsFor(values, env);
528
+ // Every scan audits exactly one URL. `--url` targets a specific same-domain
529
+ // page; omitted, the run audits the project homepage.
429
530
  let run = await apiRequest(settings, "POST", "/api/v1/runs", {
430
- body: { projectId: values.project, scanMode },
531
+ body: {
532
+ projectId: values.project,
533
+ scanMode,
534
+ // Forward an explicitly-provided url even if empty so the API rejects it
535
+ // (400), instead of silently dropping `--url ""` and running the homepage.
536
+ ...(values.url !== undefined ? { url: values.url } : {}),
537
+ },
431
538
  idempotencyKey: values["idempotency-key"],
432
539
  fetchImpl: io.fetch,
433
540
  });
@@ -461,12 +568,13 @@ async function cmdMarketing(argv, io, env) {
461
568
  if (sub === "run") {
462
569
  const { values } = parse(rest, {
463
570
  project: { type: "string" },
571
+ url: { type: "string" },
464
572
  "idempotency-key": { type: "string" },
465
573
  wait: { type: "boolean", default: false },
466
574
  });
467
575
  if (!values.project) {
468
576
  throw new CliError(
469
- "Usage: index365 marketing run --project <projectId> [--wait]",
577
+ "Usage: index365 marketing run --project <projectId> [--url <url>] [--wait]",
470
578
  EXIT.USAGE,
471
579
  );
472
580
  }
@@ -595,7 +703,10 @@ async function cmdMcp(argv, io, env) {
595
703
  out(io, "Codex / Cursor / any MCP host (JSON):");
596
704
  out(io, JSON.stringify({ mcpServers: { index365: serverConfig } }, null, 2));
597
705
  out(io, "");
598
- out(io, "The MCP server is read-only by default and calls the same /api/v1 as this CLI.");
706
+ out(
707
+ io,
708
+ "The MCP server calls the same /api/v1 as this CLI, with whatever scopes your i365_ key carries (full scope by default).",
709
+ );
599
710
  return EXIT.OK;
600
711
  }
601
712
 
@@ -655,5 +766,10 @@ export function defaultIo() {
655
766
  fetch: (...args) => fetch(...args),
656
767
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
657
768
  now: () => Date.now(),
769
+ // Browser-OAuth primitives for `login --web` (injectable so tests stay hermetic).
770
+ pkce: () => generatePkcePair(),
771
+ nonce: () => generateStateNonce(),
772
+ openUrl: (url) => defaultOpenUrl(url),
773
+ startLoopback: () => defaultStartLoopback(),
658
774
  };
659
775
  }
package/src/client.mjs CHANGED
@@ -50,8 +50,14 @@ export async function apiRequest(settings, method, apiPath, options = {}) {
50
50
 
51
51
  const headers = {
52
52
  authorization: `Bearer ${settings.apiKey}`,
53
- "user-agent": "index365-cli/0.1.0",
53
+ "user-agent": "index365-cli/0.2.0",
54
54
  };
55
+ // Optional source tag so a wrapping skill can attribute its traffic
56
+ // (e.g. INDEX365_CLIENT=skill/index365-audit-and-fix). The server validates it.
57
+ const clientTag = process.env.INDEX365_CLIENT;
58
+ if (typeof clientTag === "string" && clientTag.trim()) {
59
+ headers["x-index365-client"] = clientTag.trim();
60
+ }
55
61
  if (body !== undefined) headers["content-type"] = "application/json";
56
62
  if (idempotencyKey) headers["idempotency-key"] = idempotencyKey;
57
63
 
@@ -0,0 +1,242 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { createServer } from "node:http";
4
+ import { hostname } from "node:os";
5
+ import { CliError, EXIT, exitCodeForStatus } from "./client.mjs";
6
+
7
+ /**
8
+ * `index365 login --web` — browser OAuth for the CLI (loopback + PKCE, RFC 8252).
9
+ *
10
+ * The pure, dependency-free pieces (URL building, PKCE/state generation, the
11
+ * token exchange) live here alongside the default side-effecting primitives
12
+ * (loopback server, browser opener). Every side effect is passed in through `io`
13
+ * so the orchestration in `webLogin` stays hermetically testable.
14
+ *
15
+ * Flow: generate a PKCE verifier/challenge + state → start a loopback server on
16
+ * 127.0.0.1:<random> → open the browser to the dashboard consent screen →
17
+ * receive the single-use code on the loopback → bounce the browser to the hosted
18
+ * success page → exchange the code + verifier for a scoped key at
19
+ * /api/v1/cli/token. The secret only ever arrives in the exchange RESPONSE body,
20
+ * never in a URL.
21
+ */
22
+
23
+ /** Five minutes is plenty for sign-in + consent; after that we stop waiting. */
24
+ export const WEB_LOGIN_TIMEOUT_MS = 5 * 60_000;
25
+
26
+ /** PKCE verifier (43 base64url chars ≈ 256 bits) + its S256 challenge. */
27
+ export function generatePkcePair() {
28
+ const verifier = randomBytes(32).toString("base64url");
29
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
30
+ return { verifier, challenge };
31
+ }
32
+
33
+ /** Opaque anti-forgery nonce echoed back on the callback. */
34
+ export function generateStateNonce() {
35
+ return randomBytes(16).toString("base64url");
36
+ }
37
+
38
+ /** Human label for the minted key, e.g. "index365 CLI on Pauls-MBP". */
39
+ export function defaultClientName() {
40
+ let host = "";
41
+ try {
42
+ host = hostname();
43
+ } catch {
44
+ host = "";
45
+ }
46
+ return host ? `index365 CLI on ${host}` : "index365 CLI";
47
+ }
48
+
49
+ /** Build the dashboard consent URL the browser opens. */
50
+ export function buildAuthorizeUrl(apiUrl, { redirectUri, codeChallenge, state, clientName }) {
51
+ const url = new URL(`${apiUrl}/dashboard/cli/authorize`);
52
+ url.searchParams.set("response_type", "code");
53
+ url.searchParams.set("redirect_uri", redirectUri);
54
+ url.searchParams.set("code_challenge", codeChallenge);
55
+ url.searchParams.set("code_challenge_method", "S256");
56
+ url.searchParams.set("state", state);
57
+ if (clientName) url.searchParams.set("client_name", clientName);
58
+ return url.toString();
59
+ }
60
+
61
+ /** Swap the authorization code + PKCE verifier for a scoped credential. */
62
+ export async function exchangeCode(apiUrl, { code, codeVerifier, redirectUri, fetchImpl = fetch }) {
63
+ let response;
64
+ try {
65
+ response = await fetchImpl(`${apiUrl}/api/v1/cli/token`, {
66
+ method: "POST",
67
+ headers: { "content-type": "application/json", "user-agent": "index365-cli/0.2.0" },
68
+ body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri }),
69
+ });
70
+ } catch (err) {
71
+ throw new CliError(`Could not reach ${apiUrl}: ${err.message}`, EXIT.ERROR);
72
+ }
73
+ const text = await response.text();
74
+ let parsed = null;
75
+ try {
76
+ parsed = text ? JSON.parse(text) : null;
77
+ } catch {
78
+ parsed = null;
79
+ }
80
+ if (!response.ok) {
81
+ const errorCode = parsed?.error?.code ?? `http_${response.status}`;
82
+ const message = parsed?.error?.message ?? `Token exchange failed (status ${response.status}).`;
83
+ throw new CliError(
84
+ `${errorCode}: ${message}`,
85
+ exitCodeForStatus(response.status),
86
+ parsed?.error,
87
+ );
88
+ }
89
+ if (!parsed?.secret) {
90
+ throw new CliError("Token exchange returned no credential.", EXIT.ERROR);
91
+ }
92
+ return parsed;
93
+ }
94
+
95
+ /**
96
+ * Default loopback server: listens on 127.0.0.1:0, resolves the single-use code
97
+ * from the first `/callback` request, bounces the browser to the success page,
98
+ * and rejects on state mismatch or timeout.
99
+ */
100
+ export function defaultStartLoopback() {
101
+ return new Promise((resolve, reject) => {
102
+ const server = createServer();
103
+ server.on("error", reject);
104
+ server.listen(0, "127.0.0.1", () => {
105
+ const address = server.address();
106
+ const port = typeof address === "object" && address ? address.port : 0;
107
+ resolve({
108
+ port,
109
+ waitForCallback: ({ state, timeoutMs = WEB_LOGIN_TIMEOUT_MS, successUrl }) =>
110
+ new Promise((res, rej) => {
111
+ const timer = setTimeout(() => {
112
+ rej(new CliError("Timed out waiting for browser authorization.", EXIT.ERROR));
113
+ }, timeoutMs);
114
+ const fail = (response, message, cliError) => {
115
+ response.writeHead(400, { "content-type": "text/plain" });
116
+ response.end(`${message} You can close this window and return to your terminal.`);
117
+ rej(cliError);
118
+ };
119
+ const onRequest = (req, response) => {
120
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
121
+ if (url.pathname !== "/callback") {
122
+ response.writeHead(404);
123
+ response.end();
124
+ return;
125
+ }
126
+ clearTimeout(timer);
127
+ server.off("request", onRequest);
128
+ // Validate the state nonce BEFORE the browser is told anything
129
+ // succeeded — a forged/replayed callback must not land on the
130
+ // branded success page.
131
+ const returnedState = url.searchParams.get("state");
132
+ if (state && returnedState !== state) {
133
+ fail(
134
+ response,
135
+ "Authorization failed: state mismatch.",
136
+ new CliError("State mismatch on the authorization callback.", EXIT.AUTH),
137
+ );
138
+ return;
139
+ }
140
+ const error = url.searchParams.get("error");
141
+ if (error) {
142
+ // The user declined (or the consent screen errored): show a neutral
143
+ // close-this-window page, not the success screen.
144
+ response.writeHead(200, { "content-type": "text/plain" });
145
+ response.end(
146
+ "Authorization was not completed. You can close this window and return to your terminal.",
147
+ );
148
+ res({ error });
149
+ return;
150
+ }
151
+ const code = url.searchParams.get("code");
152
+ if (!code) {
153
+ fail(
154
+ response,
155
+ "Authorization failed: no code was returned.",
156
+ new CliError("No authorization code in the callback.", EXIT.AUTH),
157
+ );
158
+ return;
159
+ }
160
+ // Only a valid, state-matched code lands on the branded success page.
161
+ response.writeHead(302, { location: successUrl });
162
+ response.end();
163
+ res({ code });
164
+ };
165
+ server.on("request", onRequest);
166
+ }),
167
+ close: () => server.close(),
168
+ });
169
+ });
170
+ });
171
+ }
172
+
173
+ /** Default browser opener (best-effort; the caller also prints the URL). */
174
+ export function defaultOpenUrl(url) {
175
+ return new Promise((resolve) => {
176
+ const platform = process.platform;
177
+ const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
178
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
179
+ try {
180
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
181
+ child.on("error", () => resolve(false));
182
+ child.unref();
183
+ resolve(true);
184
+ } catch {
185
+ resolve(false);
186
+ }
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Run the browser-OAuth flow and return the token-exchange response
192
+ * ({ secret, keyName, keyPrefix, scopes, organizationId, organizationSlug }).
193
+ * Saving the credential to config is the caller's job (cmdLogin), so the config
194
+ * writer stays in one place. Throws CliError on failure/cancel.
195
+ */
196
+ export async function webLogin({ apiUrl, json }, io) {
197
+ const { verifier, challenge } = io.pkce();
198
+ const state = io.nonce();
199
+ const clientName = defaultClientName();
200
+ const loopback = await io.startLoopback();
201
+ const redirectUri = `http://127.0.0.1:${loopback.port}/callback`;
202
+ const authorizeUrl = buildAuthorizeUrl(apiUrl, {
203
+ redirectUri,
204
+ codeChallenge: challenge,
205
+ state,
206
+ clientName,
207
+ });
208
+ const successUrl = `${apiUrl}/dashboard/cli/success`;
209
+
210
+ // The browser opener is best-effort, so the authorize URL is ALWAYS surfaced
211
+ // for manual paste — to stderr in --json mode so stdout stays machine-readable,
212
+ // otherwise a host with no opener would hang until timeout with no way to continue.
213
+ const note = json ? io.stderr : io.stdout;
214
+ note("Opening your browser to authorize index365...");
215
+ note(`If it does not open automatically, visit:\n ${authorizeUrl}`);
216
+ try {
217
+ await io.openUrl(authorizeUrl);
218
+ } catch {
219
+ // Best-effort: the URL was printed above for manual paste.
220
+ }
221
+
222
+ let result;
223
+ try {
224
+ result = await loopback.waitForCallback({ state, timeoutMs: WEB_LOGIN_TIMEOUT_MS, successUrl });
225
+ } finally {
226
+ loopback.close();
227
+ }
228
+
229
+ if (result.error) {
230
+ if (result.error === "access_denied") {
231
+ throw new CliError("Authorization was cancelled in the browser.", EXIT.AUTH);
232
+ }
233
+ throw new CliError(`Authorization failed (${result.error}).`, EXIT.AUTH);
234
+ }
235
+
236
+ return exchangeCode(apiUrl, {
237
+ code: result.code,
238
+ codeVerifier: verifier,
239
+ redirectUri,
240
+ fetchImpl: io.fetch,
241
+ });
242
+ }