@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 +3 -3
- package/package.json +1 -1
- package/src/cli.mjs +140 -24
- package/src/client.mjs +7 -1
- package/src/web-login.mjs +242 -0
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
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, {
|
|
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(
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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,
|
|
419
|
-
if (values.json) printJson(io, { ok: true, file: target, bytes
|
|
420
|
-
else out(io, `Saved ${target} (${
|
|
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: {
|
|
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(
|
|
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.
|
|
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
|
+
}
|