@index365/cli
Advanced tools
+275
| import { EXIT, apiRequest } from "./client.mjs"; | ||
| import { cliError } from "./ui/errors.mjs"; | ||
| /** | ||
| * The latest-run resolution chain — IDs are never the user's job: | ||
| * | ||
| * 1. an explicit run id (full UUID, or an unambiguous prefix) wins | ||
| * 2. a domain/url argument resolves the project by domain | ||
| * 3. nothing given: the org's most recent completed run | ||
| * | ||
| * Every command that resolves a run echoes what resolved | ||
| * (`example.com · scanned 2m ago`) and, under `--json`, carries a | ||
| * `resolved` block so agents keep determinism. | ||
| */ | ||
| const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; | ||
| /** Bounded page walks: prefix matching and product filtering never page forever. */ | ||
| const MAX_RESOLVE_PROJECT_PAGES = 20; | ||
| const MAX_RESOLVE_RUNS = 200; | ||
| export function isUuid(value) { | ||
| return UUID_RE.test(String(value)); | ||
| } | ||
| /** A pasted fragment of a run UUID: hex/dash, at least 4 chars, not a domain. */ | ||
| export function isRunIdPrefix(value) { | ||
| const v = String(value); | ||
| return /^[0-9a-f][0-9a-f-]{3,35}$/i.test(v) && !UUID_RE.test(v) && !v.includes("."); | ||
| } | ||
| /** A domain or URL argument (never a UUID, never an ordinal). */ | ||
| export function looksLikeDomain(value) { | ||
| const v = String(value); | ||
| if (isUuid(v) || /^\d+$/.test(v)) return false; | ||
| return v.includes(".") || /^[a-z][a-z0-9+.-]*:\/\//i.test(v) || v === "localhost"; | ||
| } | ||
| /** Canonical host for domain matching: lowercase, leading www. stripped. */ | ||
| export function normalizeHost(host) { | ||
| return String(host) | ||
| .toLowerCase() | ||
| .replace(/^www\./, ""); | ||
| } | ||
| /** Host of a stored run URL, normalized; null when unparseable. */ | ||
| export function hostOfUrl(url) { | ||
| try { | ||
| return normalizeHost(new URL(String(url)).hostname); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| /** Human "how fresh": `2m ago`, `3h ago`, `just now`. */ | ||
| export function agoLabel(iso, nowMs) { | ||
| const then = Date.parse(String(iso)); | ||
| if (!Number.isFinite(then)) return null; | ||
| const seconds = Math.max(0, Math.floor((nowMs - then) / 1000)); | ||
| if (seconds < 45) return "just now"; | ||
| const minutes = Math.round(seconds / 60); | ||
| if (minutes < 60) return `${Math.max(minutes, 1)}m ago`; | ||
| const hours = Math.round(minutes / 60); | ||
| if (hours < 24) return `${hours}h ago`; | ||
| const days = Math.round(hours / 24); | ||
| return `${days}d ago`; | ||
| } | ||
| /** Product key (either spelling) → its display name. Never a raw enum. */ | ||
| export function productLabel(product) { | ||
| const key = String(product ?? "").replace(/-/g, "_"); | ||
| if (key === "marketing_signal") return "Marketing Signal"; | ||
| if (key === "website_security") return "Website Security"; | ||
| if (key === "ai_readiness" || key === "") return "AI-Readiness"; | ||
| return String(product); | ||
| } | ||
| /** Plan enum → its display name (`pro_plus` → `Pro+`, never the raw enum). */ | ||
| export function planLabel(plan) { | ||
| const key = String(plan ?? "").toLowerCase(); | ||
| if (!key) return null; | ||
| if (key === "pro_plus") return "Pro+"; | ||
| return key | ||
| .split("_") | ||
| .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) | ||
| .join(" "); | ||
| } | ||
| /** | ||
| * Find the project whose domain covers `domain` (apex or any subdomain; the | ||
| * most specific match wins). Returns null when nothing matches — the caller | ||
| * decides between the one create-and-scan confirm (TTY) and the scripted | ||
| * error path. | ||
| */ | ||
| export async function findProjectForDomain(settings, io, domain) { | ||
| let cursor; | ||
| let best = null; | ||
| // Bounded like every other page walk here: an unbounded loop on a large | ||
| // org turns one command into an unbounded number of API calls. | ||
| let pages = 0; | ||
| do { | ||
| const data = await apiRequest(settings, "GET", "/api/v1/projects", { | ||
| query: cursor ? { cursor } : {}, | ||
| fetchImpl: io.fetch, | ||
| }); | ||
| for (const project of data.projects ?? []) { | ||
| const host = normalizeHost(project.domain); | ||
| if (host !== domain && !domain.endsWith(`.${host}`)) continue; | ||
| if (!best || host.length > best.host.length) best = { host, projectId: project.projectId }; | ||
| } | ||
| cursor = data.pagination?.nextCursor ?? undefined; | ||
| pages += 1; | ||
| } while (cursor && pages < MAX_RESOLVE_PROJECT_PAGES); | ||
| return best; | ||
| } | ||
| function noRunsError(scope) { | ||
| return cliError(`No completed scans${scope ? ` for ${scope}` : ""} yet.`, EXIT.NOT_FOUND, { | ||
| code: "no_completed_runs", | ||
| why: "A finished scan is the unit everything else reads: findings, reports, and scores all come from one.", | ||
| next: "index365 scan yoursite.com", | ||
| nextLabel: "your first scan takes about 30 seconds", | ||
| }); | ||
| } | ||
| function resolutionFromListItem(run, how) { | ||
| return { | ||
| runId: run.runId, | ||
| projectId: run.projectId ?? null, | ||
| domain: hostOfUrl(run.url), | ||
| url: run.url ?? null, | ||
| product: run.product ?? null, | ||
| status: run.status ?? null, | ||
| score: typeof run.score === "number" ? run.score : null, | ||
| finishedAt: run.finishedAt ?? run.completedAt ?? null, | ||
| how, | ||
| }; | ||
| } | ||
| /** First page-walked run matching `product` (bounded), else null. */ | ||
| async function latestListedRun(settings, io, { projectId, product } = {}) { | ||
| let cursor; | ||
| let seen = 0; | ||
| let pages = 0; | ||
| do { | ||
| pages += 1; | ||
| const data = await apiRequest(settings, "GET", "/api/v1/runs", { | ||
| query: { | ||
| status: "completed", | ||
| ...(projectId ? { projectId } : {}), | ||
| ...(cursor ? { cursor } : {}), | ||
| limit: product ? "100" : "1", | ||
| }, | ||
| fetchImpl: io.fetch, | ||
| }); | ||
| for (const run of data.runs ?? []) { | ||
| seen += 1; | ||
| if (!product || run.product === product) return run; | ||
| if (seen >= MAX_RESOLVE_RUNS) return null; | ||
| } | ||
| cursor = data.pagination?.nextCursor ?? undefined; | ||
| // A page of zero matching rows never advances `seen`, so a server that | ||
| // keeps returning cursors would walk forever without this page cap. | ||
| } while (cursor && pages < MAX_RESOLVE_PROJECT_PAGES); | ||
| return null; | ||
| } | ||
| /** | ||
| * Resolve `ref` (a run UUID, an unambiguous UUID prefix, a domain/url, or | ||
| * nothing = latest) to one run. Returns the resolution record every consumer | ||
| * echoes and every `--json` payload carries. | ||
| */ | ||
| export async function resolveRun(settings, io, ref, { product } = {}) { | ||
| // 1. Explicit run id: a full UUID, or any value that classifies as neither | ||
| // a prefix nor a domain (legacy scripts may pass odd shapes; the server is | ||
| // the authority and 404s cleanly). | ||
| const explicit = | ||
| ref && ref !== "latest" && (isUuid(ref) || (!isRunIdPrefix(ref) && !looksLikeDomain(ref))); | ||
| if (explicit) { | ||
| const run = await apiRequest(settings, "GET", `/api/v1/runs/${ref}`, { fetchImpl: io.fetch }); | ||
| return { | ||
| runId: run.runId, | ||
| projectId: run.projectId ?? null, | ||
| domain: hostOfUrl(run.url), | ||
| url: run.url ?? null, | ||
| product: run.product ?? null, | ||
| status: run.status ?? null, | ||
| score: typeof run.score === "number" ? run.score : null, | ||
| finishedAt: run.completedAt ?? null, | ||
| how: "explicit", | ||
| run, | ||
| }; | ||
| } | ||
| // 2. An unambiguous pasted prefix (git/docker muscle memory). | ||
| if (ref && isRunIdPrefix(ref)) { | ||
| const prefix = String(ref).toLowerCase(); | ||
| const matches = []; | ||
| let cursor; | ||
| let seen = 0; | ||
| do { | ||
| const data = await apiRequest(settings, "GET", "/api/v1/runs", { | ||
| query: { limit: "100", ...(cursor ? { cursor } : {}) }, | ||
| fetchImpl: io.fetch, | ||
| }); | ||
| for (const run of data.runs ?? []) { | ||
| seen += 1; | ||
| if (String(run.runId).toLowerCase().startsWith(prefix)) matches.push(run); | ||
| } | ||
| cursor = data.pagination?.nextCursor ?? undefined; | ||
| } while (cursor && seen < MAX_RESOLVE_RUNS && matches.length < 2); | ||
| if (matches.length === 1) return resolutionFromListItem(matches[0], "explicit"); | ||
| if (matches.length > 1) { | ||
| throw cliError(`Run id prefix '${ref}' matches ${matches.length} runs.`, EXIT.USAGE, { | ||
| code: "ambiguous_run_prefix", | ||
| why: "Add a few more characters until the prefix is unique.", | ||
| next: "index365 results", | ||
| nextLabel: "see recent scans and their ids", | ||
| }); | ||
| } | ||
| throw cliError(`No run matches '${ref}'.`, EXIT.NOT_FOUND, { | ||
| code: "run_not_found", | ||
| why: "It may belong to another org, or the id was mistyped.", | ||
| next: "index365 results", | ||
| nextLabel: "see recent scans and their ids", | ||
| }); | ||
| } | ||
| // 3. A domain/url argument → that project's latest completed run. | ||
| if (ref && looksLikeDomain(ref)) { | ||
| const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(ref) ? ref : `https://${ref}`; | ||
| let domain; | ||
| try { | ||
| domain = normalizeHost(new URL(withScheme).hostname); | ||
| } catch { | ||
| throw cliError(`'${ref}' is not a valid domain or URL.`, EXIT.USAGE, { code: "usage" }); | ||
| } | ||
| const project = await findProjectForDomain(settings, io, domain); | ||
| if (!project) { | ||
| throw cliError(`No project covers ${domain}.`, EXIT.NOT_FOUND, { | ||
| code: "no_project", | ||
| why: "Projects anchor scan history to a domain; scanning creates one.", | ||
| next: `index365 scan ${domain}`, | ||
| nextLabel: "create the project and scan it", | ||
| }); | ||
| } | ||
| const run = await latestListedRun(settings, io, { projectId: project.projectId, product }); | ||
| if (!run) throw noRunsError(domain); | ||
| return resolutionFromListItem(run, "domain"); | ||
| } | ||
| // 4. Nothing given: the org's most recent completed run. | ||
| const run = await latestListedRun(settings, io, { product }); | ||
| if (!run) throw noRunsError(""); | ||
| return resolutionFromListItem(run, "latest"); | ||
| } | ||
| /** The `resolved` block `--json` consumers get wherever resolution happened. */ | ||
| export function resolvedJson(res) { | ||
| return { | ||
| runId: res.runId, | ||
| projectId: res.projectId, | ||
| domain: res.domain, | ||
| how: res.how, | ||
| finishedAt: res.finishedAt, | ||
| }; | ||
| } | ||
| /** The human echo: `example.com · scanned 2m ago` (dim, always printed). */ | ||
| export function resolvedLabel(res, nowMs) { | ||
| const who = res.domain ?? `run ${String(res.runId).slice(0, 8)}`; | ||
| if (res.status && res.status !== "completed") return `${who} · ${res.status}`; | ||
| const ago = res.finishedAt ? agoLabel(res.finishedAt, nowMs) : null; | ||
| return ago ? `${who} · scanned ${ago}` : who; | ||
| } |
+160
| /** | ||
| * The 4-tier color ladder (hand-rolled, zero deps). | ||
| * | ||
| * Tier detection happens per stream at style-construction time: | ||
| * | ||
| * truecolor COLORTERM=truecolor|24bit ember renders exact #ff5a1f | ||
| * 256 TTY (default; TERM unset or 256) ember renders 38;5;202 | ||
| * 16 TTY with a TERM that lacks 256 ember drops to bold | ||
| * none piped / NO_COLOR / TERM=dumb every helper is identity | ||
| * | ||
| * Semantics are strict: ember = brand + next-action ONLY, green/yellow/red = | ||
| * pass/caution/fail, dim = metadata. NO_COLOR (non-empty) disables ALL styling | ||
| * and wins over FORCE_COLOR; FORCE_COLOR/CLICOLOR_FORCE (non-empty, not "0") | ||
| * re-enable styling when piped (agents that render ANSI, demo recordings). | ||
| */ | ||
| const RESET = "\x1b[0m"; | ||
| const BOLD = "\x1b[1m"; | ||
| const DIM = "\x1b[2m"; | ||
| const RED = "\x1b[31m"; | ||
| const GREEN = "\x1b[32m"; | ||
| const YELLOW = "\x1b[33m"; | ||
| const EMBER_TRUECOLOR = "\x1b[38;2;255;90;31m"; // brand #ff5a1f, exact | ||
| const EMBER_256 = "\x1b[38;5;202m"; // closest 256-color cell (shipped fallback) | ||
| // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping SGR escapes is this regex's whole job | ||
| const ANSI_RE = /\x1b\[[0-9;]*m/g; | ||
| /** Remove every SGR escape (the only escapes this CLI emits). */ | ||
| export function stripAnsi(text) { | ||
| return String(text).replace(ANSI_RE, ""); | ||
| } | ||
| /** | ||
| * Printable width of a styled line. Every glyph this CLI uses (✓ ✗ ● · » box | ||
| * drawing, blocks, braille) is single-cell, so stripped `.length` is exact. | ||
| */ | ||
| export function visibleWidth(text) { | ||
| return stripAnsi(text).length; | ||
| } | ||
| /** True when FORCE_COLOR/CLICOLOR_FORCE ask for color even without a TTY. */ | ||
| function forceColor(env) { | ||
| const force = env.FORCE_COLOR ?? env.CLICOLOR_FORCE; | ||
| return typeof force === "string" && force !== "" && force !== "0"; | ||
| } | ||
| /** Whether ANY styling may render on this stream. */ | ||
| export function styleEnabled(streamIsTTY, env) { | ||
| if (env.NO_COLOR) return false; // the user's explicit no wins over everything | ||
| if (env.TERM === "dumb") return false; | ||
| // FORCE_COLOR=0 / CLICOLOR_FORCE=0 is the ecosystem's explicit "off", and | ||
| // it must win on a TTY too - forceColor() only treats it as "not forcing | ||
| // on", which left color enabled where the user asked for none. | ||
| const force = env.FORCE_COLOR ?? env.CLICOLOR_FORCE; | ||
| if (force === "0") return false; | ||
| if (forceColor(env)) return true; | ||
| // NOTE (recorded deviation from design 1.1): FORCE_COLOR=1 on a PIPE does | ||
| // force color codes on, but it does not switch piped commands to the human | ||
| // LAYOUT - pipes keep the TSV/JSON agent contract. CI environments export | ||
| // FORCE_COLOR globally to colorize test output; flipping their layout on | ||
| // that signal would silently break every agent pipeline that parses TSV. | ||
| // Recorders wanting the human layout have ptys (script(1), asciinema). | ||
| return Boolean(streamIsTTY); | ||
| } | ||
| /** Which ladder tier a styled stream renders at. */ | ||
| export function colorTier(env) { | ||
| const colorterm = String(env.COLORTERM ?? "").toLowerCase(); | ||
| if (colorterm.includes("truecolor") || colorterm.includes("24bit")) return "truecolor"; | ||
| const term = String(env.TERM ?? ""); | ||
| // No TERM (tests, some CI wrappers) keeps the shipped 256 default; a TERM | ||
| // that names itself without 256-color support drops ember to bold-only. | ||
| if (term && !/256color|truecolor|direct|kitty|ghostty|alacritty|wezterm/i.test(term)) { | ||
| return "16"; | ||
| } | ||
| return "256"; | ||
| } | ||
| /** | ||
| * Build the semantic style set for one stream. Off, every helper is the | ||
| * identity function so piped and agent output stays byte-clean. | ||
| * | ||
| * `io` needs `stdoutIsTTY`/`stderrIsTTY` (falls back to `isTTY`). | ||
| */ | ||
| export function stylesFor(io, env, stream = "stdout") { | ||
| const streamIsTTY = | ||
| stream === "stderr" ? (io.stderrIsTTY ?? io.isTTY) : (io.stdoutIsTTY ?? io.isTTY); | ||
| const on = styleEnabled(streamIsTTY, env); | ||
| const tier = on ? colorTier(env) : "none"; | ||
| const wrap = (codes) => (on ? (text) => `${codes}${text}${RESET}` : (text) => String(text)); | ||
| const emberCode = | ||
| tier === "truecolor" ? EMBER_TRUECOLOR : tier === "256" ? EMBER_256 : tier === "16" ? BOLD : ""; | ||
| const bold = wrap(BOLD); | ||
| const dim = wrap(DIM); | ||
| const red = wrap(RED); | ||
| const green = wrap(GREEN); | ||
| const yellow = wrap(YELLOW); | ||
| const redBold = wrap(`${BOLD}${RED}`); | ||
| const greenBold = wrap(`${BOLD}${GREEN}`); | ||
| const yellowBold = wrap(`${BOLD}${YELLOW}`); | ||
| return { | ||
| on, | ||
| tier, | ||
| bold, | ||
| dim, | ||
| red, | ||
| green, | ||
| yellow, | ||
| redBold, | ||
| greenBold, | ||
| yellowBold, | ||
| ember: wrap(emberCode || BOLD), | ||
| emberBold: wrap(emberCode === BOLD || emberCode === "" ? BOLD : `${BOLD}${emberCode}`), | ||
| /** | ||
| * Severity → its color. critical/high = red, medium = yellow, low = | ||
| * default, info = dim. `bold` upgrades the colored tiers. | ||
| */ | ||
| sev(severity, { bold: wantBold = false } = {}) { | ||
| if (severity === "critical" || severity === "high") return wantBold ? redBold : red; | ||
| if (severity === "medium") return wantBold ? yellowBold : yellow; | ||
| if (severity === "info") return dim; | ||
| return wantBold ? bold : (text) => String(text); | ||
| }, | ||
| /** Score band → its color: 90+ green, 50-89 yellow, below red. */ | ||
| band(score, { bold: wantBold = false } = {}) { | ||
| // A missing/NaN score is unknown, not failing - without this it fell | ||
| // through to red and a pending run looked like a broken one. | ||
| if (typeof score !== "number" || !Number.isFinite(score)) return dim; | ||
| if (score >= 90) return wantBold ? greenBold : green; | ||
| if (score >= 50) return wantBold ? yellowBold : yellow; | ||
| return wantBold ? redBold : red; | ||
| }, | ||
| }; | ||
| } | ||
| /** Band label for a score: the UPPERCASE word next to the big digits. */ | ||
| export function bandLabel(score) { | ||
| // Mirror band(): a missing/NaN score is unknown, never "POOR". | ||
| if (typeof score !== "number" || !Number.isFinite(score)) return "PENDING"; | ||
| if (score >= 90) return "EXCELLENT"; | ||
| if (score >= 75) return "GOOD"; | ||
| if (score >= 50) return "NEEDS WORK"; | ||
| return "POOR"; | ||
| } | ||
| /** ANSI-aware right-pad to `width` visible cells (no-op when already wider). */ | ||
| export function padEndVisible(text, width) { | ||
| const pad = width - visibleWidth(text); | ||
| return pad > 0 ? `${text}${" ".repeat(pad)}` : text; | ||
| } | ||
| /** ANSI-aware left-pad to `width` visible cells. */ | ||
| export function padStartVisible(text, width) { | ||
| const pad = width - visibleWidth(text); | ||
| return pad > 0 ? `${" ".repeat(pad)}${text}` : text; | ||
| } |
| import { padEndVisible, visibleWidth } from "./ansi.mjs"; | ||
| /** | ||
| * The rounded box (╭─╮│╰─╯) — used in exactly two places by design: the score | ||
| * card and the fix prompt ("this is a payload, not prose"). Border dim; width | ||
| * capped at 80 columns; below 76 columns the borders drop and the content | ||
| * renders as plain indented lines. | ||
| */ | ||
| /** Outer box width for a terminal: min(columns, 80) - 4 (2-space margins). */ | ||
| export function boxWidth(columns) { | ||
| return Math.min(columns ?? 80, 80) - 4; | ||
| } | ||
| /** | ||
| * Render `contentLines` inside a rounded box. Returns finished lines including | ||
| * the 2-space left margin. `title` renders inside the top border: | ||
| * `╭─ TITLE · hint ─────╮` (caller styles the title text). | ||
| * | ||
| * Content lines are padded (ANSI-aware) to the inner width; the caller | ||
| * provides its own inner indentation (the mockups use 3 spaces). | ||
| */ | ||
| export function renderBox(contentLines, { columns = 80, title = "", dim = (t) => t } = {}) { | ||
| const width = boxWidth(columns); | ||
| if (columns < 76) { | ||
| // Narrow terminals: same content, no borders. | ||
| return contentLines.map((line) => ` ${line}`.replace(/\s+$/, "")); | ||
| } | ||
| const inner = width - 2; | ||
| const lines = []; | ||
| if (title) { | ||
| const label = ` ${title} `; | ||
| const tail = Math.max(inner - 1 - visibleWidth(label), 0); | ||
| lines.push(` ${dim("╭─")}${label}${dim("─".repeat(tail))}${dim("╮")}`); | ||
| } else { | ||
| lines.push(` ${dim(`╭${"─".repeat(inner)}╮`)}`); | ||
| } | ||
| for (const line of contentLines) { | ||
| lines.push(` ${dim("│")}${padEndVisible(line, inner)}${dim("│")}`); | ||
| } | ||
| lines.push(` ${dim(`╰${"─".repeat(inner)}╯`)}`); | ||
| return lines; | ||
| } |
| /** | ||
| * Seven-segment block digits: the single moment of flash in the whole CLI. | ||
| * 5 rows, 5-cell glyphs, 2-cell gaps — every cell is `█` or space, so width | ||
| * math stays plain `.length`. | ||
| */ | ||
| const F = "█████"; | ||
| const L = "█ "; | ||
| const R = " █"; | ||
| const B = "█ █"; | ||
| const FONT = { | ||
| 0: [F, B, B, B, F], | ||
| 1: [R, R, R, R, R], | ||
| 2: [F, R, F, L, F], | ||
| 3: [F, R, F, R, F], | ||
| 4: [B, B, F, R, R], | ||
| 5: [F, L, F, R, F], | ||
| 6: [F, L, F, B, F], | ||
| 7: [F, R, R, R, R], | ||
| 8: [F, B, F, B, F], | ||
| 9: [F, B, F, R, F], | ||
| }; | ||
| export const DIGIT_ROWS = 5; | ||
| /** Render a non-negative integer as 5 rows of block-digit cells (unstyled). */ | ||
| export function blockDigits(value) { | ||
| const digits = String(Math.max(0, Math.trunc(value))).split(""); | ||
| const rows = []; | ||
| for (let row = 0; row < DIGIT_ROWS; row += 1) { | ||
| rows.push(digits.map((d) => FONT[d][row]).join(" ")); | ||
| } | ||
| return rows; | ||
| } |
| import { CliError, EXIT } from "../client.mjs"; | ||
| import { stylesFor } from "./ansi.mjs"; | ||
| /** | ||
| * Error anatomy — every error, no exceptions: what happened (red ✗, one | ||
| * line) · why (one or two lines) · the exact next command (ember »). The | ||
| * most important information lands LAST. In `--json` mode the same three | ||
| * parts become one JSON object on stderr, so agents get the identical | ||
| * recovery path as humans. | ||
| */ | ||
| /** Build a CliError carrying the full anatomy. */ | ||
| export function cliError(message, exitCode, { code, why, next, nextLabel, detail } = {}) { | ||
| const err = new CliError(message, exitCode, detail ?? null); | ||
| if (code) err.code = code; | ||
| if (why) err.why = why; | ||
| if (next) err.next = next; | ||
| if (nextLabel) err.nextLabel = nextLabel; | ||
| return err; | ||
| } | ||
| /** Fallback next-action for errors that were thrown without one. */ | ||
| export function defaultNextFor(exitCode) { | ||
| if (exitCode === EXIT.AUTH) { | ||
| return { next: "index365 login", nextLabel: "sign in in the browser" }; | ||
| } | ||
| if (exitCode === EXIT.USAGE) { | ||
| return { next: "index365 --help", nextLabel: "command reference" }; | ||
| } | ||
| return { next: "index365 doctor", nextLabel: "check your setup" }; | ||
| } | ||
| /** `code: message` prefixes come from the API client; strip for JSON. */ | ||
| function splitCodeMessage(err) { | ||
| if (err.code) { | ||
| const prefix = `${err.code}: `; | ||
| const message = err.message.startsWith(prefix) ? err.message.slice(prefix.length) : err.message; | ||
| return { code: err.code, message }; | ||
| } | ||
| const match = /^([a-z][a-z0-9_]+): (.+)$/s.exec(err.message); | ||
| if (match) return { code: match[1], message: match[2] }; | ||
| return { code: err.exitCode === EXIT.USAGE ? "usage" : "error", message: err.message }; | ||
| } | ||
| /** | ||
| * Render a CliError to stderr. Human mode prints the three-part anatomy; | ||
| * `--json` mode prints a single JSON error object instead. | ||
| */ | ||
| export function renderCliError(io, env, err, { json = false } = {}) { | ||
| const { code, message } = splitCodeMessage(err); | ||
| const fallback = defaultNextFor(err.exitCode); | ||
| const next = err.next ?? fallback.next; | ||
| const nextLabel = err.nextLabel ?? (err.next ? "" : fallback.nextLabel); | ||
| if (json) { | ||
| io.stderr( | ||
| JSON.stringify({ | ||
| error: { code, message, ...(err.why ? { why: err.why } : {}), next }, | ||
| }), | ||
| ); | ||
| return; | ||
| } | ||
| const s = stylesFor(io, env, "stderr"); | ||
| io.stderr(` ${s.red("✗")} ${message}`); | ||
| if (err.why) { | ||
| io.stderr(""); | ||
| io.stderr(` ${err.why}`); | ||
| } | ||
| io.stderr(""); | ||
| const isUrl = /^https?:\/\//.test(next); | ||
| const hint = nextLabel ? ` ${s.dim(nextLabel)}` : ""; | ||
| io.stderr(` ${s.ember("»")} ${isUrl ? s.dim(next) : next}${hint}`); | ||
| } |
| /** | ||
| * The `█`/`░` meter. Filled cells carry the band color (caller styles); | ||
| * the remainder renders dim. 99/100 leaves exactly one visibly unfilled cell. | ||
| */ | ||
| /** Split a 0-`max` value across `cells`: how many render filled. */ | ||
| export function meterFill(value, max, cells) { | ||
| if (max <= 0) return 0; | ||
| const clamped = Math.min(Math.max(value, 0), max); | ||
| if (clamped === max) return cells; | ||
| if (clamped === 0) return 0; | ||
| // floor, but a non-zero value always shows at least one filled cell and a | ||
| // non-max value always leaves at least one unfilled (near-perfection visible). | ||
| const filled = Math.floor((clamped / max) * cells); | ||
| return Math.min(Math.max(filled, 1), cells - 1); | ||
| } | ||
| /** Render the meter: filled part styled by `fill`, remainder by `rest`. */ | ||
| export function renderMeter(value, max, cells, { fill = (t) => t, rest = (t) => t } = {}) { | ||
| const filled = meterFill(value, max, cells); | ||
| const empty = cells - filled; | ||
| return `${filled > 0 ? fill("█".repeat(filled)) : ""}${empty > 0 ? rest("░".repeat(empty)) : ""}`; | ||
| } |
| import { CliError, EXIT } from "../client.mjs"; | ||
| /** | ||
| * The single `[Y/n]` reader — the only prompt style in the product. Enter, | ||
| * `y`, `yes` → true; `n`, `no` → false; anything else re-asks once, then | ||
| * aborts with exit 2. EOF/Ctrl-D cancels cleanly. Callers must TTY-guard: | ||
| * non-TTY code paths never reach a prompt. | ||
| */ | ||
| export async function confirmYesNo(io, question, { cancelMessage = "Cancelled." } = {}) { | ||
| for (let attempt = 0; attempt < 2; attempt += 1) { | ||
| let raw; | ||
| try { | ||
| raw = await io.prompt(question); | ||
| } catch (err) { | ||
| if (err instanceof CliError) throw err; | ||
| // EOF / Ctrl-D is a decline, not an error: the human walked away | ||
| // from a question about spending money. Cancelled confirm exits 0 | ||
| // (design 7.3); reserving exit 2 for a genuinely unusable answer. | ||
| return false; | ||
| } | ||
| const answer = String(raw ?? "") | ||
| .trim() | ||
| .toLowerCase(); | ||
| if (answer === "" || answer === "y" || answer === "yes") return true; | ||
| if (answer === "n" || answer === "no") return false; | ||
| } | ||
| throw new CliError("Answer y or n.", EXIT.USAGE); | ||
| } |
| import { bandLabel, padEndVisible, visibleWidth } from "./ansi.mjs"; | ||
| import { boxWidth, renderBox } from "./box.mjs"; | ||
| import { blockDigits } from "./digits.mjs"; | ||
| import { renderMeter } from "./meter.mjs"; | ||
| /** | ||
| * The score card: rounded box, 5-row block digits colored by band, meter, | ||
| * severity strip with zero counts dimmed. The card template is | ||
| * product-agnostic: header, big score, product widget, severity strip. | ||
| * Marketing Signal swaps the wide meter for five stage mini-meters. | ||
| */ | ||
| export const SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"]; | ||
| /** `critical 0 · high 0 · medium 1 · ...` — the eye finds the problem. */ | ||
| export function severityStrip(s, counts) { | ||
| return SEVERITY_ORDER.map((severity) => { | ||
| const count = counts?.[severity] ?? 0; | ||
| if (count === 0) return s.dim(`${severity} 0`); | ||
| return s.sev(severity, { bold: true })(`${severity} ${count}`); | ||
| }).join(` ${s.dim("·")} `); | ||
| } | ||
| /** Title-cased band word for the inline (narrow / degraded) score line. */ | ||
| export function scoreWord(score) { | ||
| const label = bandLabel(score); | ||
| return label.charAt(0) + label.slice(1).toLowerCase().replace(" w", " W"); | ||
| } | ||
| /** Right-aligned header line: product · url on the left, run tag on the right. */ | ||
| function headerLine(s, inner, product, url, runId) { | ||
| const left = ` ${s.emberBold(product)} ${s.dim("·")} ${url}`; | ||
| const right = runId ? s.dim(`run ${String(runId).slice(0, 8)}`) : ""; | ||
| const pad = inner - 3 - visibleWidth(left) - visibleWidth(right); | ||
| return pad > 0 ? `${left}${" ".repeat(pad)}${right}` : `${left} ${right}`; | ||
| } | ||
| /** The 5 block-digit rows; the middle row carries `/ 100` and the band label. */ | ||
| function digitLines(s, score) { | ||
| const band = s.band(score, { bold: false }); | ||
| const label = s.band(score, { bold: true })(bandLabel(score)); | ||
| return blockDigits(score).map((row, i) => { | ||
| const digits = band(row); | ||
| if (i === 2) return ` ${digits} ${s.dim("/ 100")} ${label}`; | ||
| return ` ${digits}`; | ||
| }); | ||
| } | ||
| /** | ||
| * Render the full score card as finished lines (2-space margin included). | ||
| * | ||
| * opts: { s, columns, product, url, runId, score, severityCounts, stageScores } | ||
| * `stageScores` (Marketing Signal): [{ stage, score }] renders five mini-meters | ||
| * in place of the wide meter. | ||
| */ | ||
| export function renderScoreCard(opts) { | ||
| const { s, columns = 80, product, url, runId, score, severityCounts, stageScores } = opts; | ||
| // Boundary guard: a report without a numeric score (edge: terminal-state | ||
| // envelope missing results) renders an explicit pending card, never | ||
| // blockDigits(undefined) or a fake 0/100. | ||
| if (typeof score !== "number" || !Number.isFinite(score)) { | ||
| const lines = [ | ||
| `${s.emberBold(product)} ${s.dim("·")} ${url}`, | ||
| s.dim("Score pending. Run 'index365 check' for status."), | ||
| severityStrip(s, severityCounts ?? {}), | ||
| ]; | ||
| return renderBox(lines, { columns, dim: s.dim }); | ||
| } | ||
| if (columns < 76) { | ||
| // Narrow degrade: same content, no box, inline bold score. | ||
| const lines = [ | ||
| `${s.emberBold(product)} ${s.dim("·")} ${url}`, | ||
| s.band(score, { bold: true })(`${score}/100 ${scoreWord(score)}`), | ||
| ]; | ||
| if (Array.isArray(stageScores) && stageScores.length > 0) { | ||
| for (const stage of stageScores) { | ||
| lines.push( | ||
| `${padEndVisible(stage.stage, 10)}${renderMeter(stage.score, 100, 12, { | ||
| fill: s.band(stage.score), | ||
| rest: s.dim, | ||
| })} ${s.bold(String(stage.score))}`, | ||
| ); | ||
| } | ||
| } | ||
| lines.push(severityStrip(s, severityCounts)); | ||
| return renderBox(lines, { columns, dim: s.dim }); | ||
| } | ||
| const inner = boxWidth(columns) - 2; | ||
| const content = ["", headerLine(s, inner, product, url, runId), "", ...digitLines(s, score), ""]; | ||
| if (Array.isArray(stageScores) && stageScores.length > 0) { | ||
| // Marketing Signal: five stage mini-meters, 22 cells, per-stage band color. | ||
| for (const stage of stageScores) { | ||
| const meter = renderMeter(stage.score, 100, 22, { | ||
| fill: s.band(stage.score), | ||
| rest: s.dim, | ||
| }); | ||
| content.push(` ${padEndVisible(stage.stage, 10)}${meter} ${s.bold(String(stage.score))}`); | ||
| } | ||
| } else { | ||
| // AI-Readiness (and Security when it lands): the wide 67-cell meter. | ||
| content.push(` ${renderMeter(score, 100, 67, { fill: s.band(score), rest: s.dim })}`); | ||
| } | ||
| content.push("", ` ${severityStrip(s, severityCounts)}`, ""); | ||
| return renderBox(content, { columns, dim: s.dim }); | ||
| } |
+113
| import { stylesFor, visibleWidth } from "./ansi.mjs"; | ||
| /** | ||
| * The braille spinner + persistent stage lines. TTY-only (its stream's TTY); | ||
| * everywhere else the controller is inert and the caller prints plain | ||
| * `stage=... elapsed=...` lines instead. | ||
| * | ||
| * One live line rewrites in place via `\r\x1b[2K`; when a stage completes it | ||
| * is REPLACED by a persistent `✓` line and the spinner continues on the next | ||
| * stage. The transcript keeps history; no multi-line cursor gymnastics. | ||
| */ | ||
| export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; | ||
| export const SPINNER_INTERVAL_MS = 80; | ||
| const HIDE_CURSOR = "\x1b[?25l"; | ||
| const SHOW_CURSOR = "\x1b[?25h"; | ||
| const CLEAR_LINE = "\r\x1b[2K"; | ||
| /** One spinner frame line: ` ⠸ Analyzing · 14s` (frame ember, elapsed dim). */ | ||
| export function spinnerLine(s, frame, text, elapsedSeconds) { | ||
| const elapsed = | ||
| typeof elapsedSeconds === "number" ? ` ${s.dim(`· ${Math.floor(elapsedSeconds)}s`)}` : ""; | ||
| return ` ${s.ember(frame)} ${text}${elapsed}`; | ||
| } | ||
| /** | ||
| * A persistent stage line: ` ✓ Crawled example.com` with the elapsed dim, | ||
| * right-aligned toward column `width - 6` (the mockups' elapsed column). | ||
| */ | ||
| export function stageLine(s, label, elapsedSeconds, { width = 80, glyph = "✓", color } = {}) { | ||
| const mark = (color ?? s.green)(glyph); | ||
| const left = ` ${mark} ${label}`; | ||
| if (typeof elapsedSeconds !== "number") return left; | ||
| const elapsed = s.dim(`${elapsedSeconds.toFixed(1)}s`); | ||
| const target = Math.max(width - 6, 20); | ||
| const pad = target - visibleWidth(left) - visibleWidth(elapsed); | ||
| return pad > 0 ? `${left}${" ".repeat(pad)}${elapsed}` : `${left} ${elapsed}`; | ||
| } | ||
| /** | ||
| * Create the spinner controller for a run's wait loop. Active only when the | ||
| * stderr stream is a TTY (spinner chrome always lives on stderr so stdout | ||
| * stays pure for pipes); NO_COLOR keeps the frames, drops the color. | ||
| */ | ||
| export function createSpinner(io, env) { | ||
| const active = Boolean(io.stderrIsTTY ?? io.isTTY) && typeof io.stderrWrite === "function"; | ||
| const s = stylesFor(io, env, "stderr"); | ||
| let timer = null; | ||
| let frameIndex = 0; | ||
| let text = ""; | ||
| let startedAt = 0; | ||
| let sigintHandler = null; | ||
| const draw = () => { | ||
| const elapsed = (Date.now() - startedAt) / 1000; | ||
| const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length]; | ||
| frameIndex += 1; | ||
| io.stderrWrite(`${CLEAR_LINE}${spinnerLine(s, frame, text, elapsed)}`); | ||
| }; | ||
| const clear = () => { | ||
| if (active) io.stderrWrite(CLEAR_LINE); | ||
| }; | ||
| return { | ||
| active, | ||
| /** Begin (or retarget) the live line. */ | ||
| start(nextText) { | ||
| text = nextText; | ||
| if (!active) return; | ||
| if (timer) return void draw(); | ||
| startedAt = Date.now(); | ||
| io.stderrWrite(HIDE_CURSOR); | ||
| sigintHandler = () => { | ||
| io.stderrWrite(`${CLEAR_LINE}${SHOW_CURSOR}`); | ||
| process.exit(130); | ||
| }; | ||
| process.once("SIGINT", sigintHandler); | ||
| draw(); | ||
| timer = setInterval(draw, SPINNER_INTERVAL_MS); | ||
| timer.unref?.(); | ||
| }, | ||
| /** Update the live line's text (elapsed keeps ticking). */ | ||
| update(nextText) { | ||
| text = nextText; | ||
| if (active && timer) draw(); | ||
| }, | ||
| /** Replace the live line with a persistent line; keep spinning after. */ | ||
| promote(persistentLine) { | ||
| if (!active) return; | ||
| clear(); | ||
| io.stderrWrite(`${persistentLine}\n`); | ||
| // Deliberately no immediate draw(): the interval repaints the live | ||
| // line within one frame anyway, and an eager draw here leaves a | ||
| // stray spinner frame in the promote->stop window at the END of a | ||
| // run. Live terminals erase it; recordings (script(1), asciinema, | ||
| // the marketing captures) keep it forever - and the score card is | ||
| // the social asset. | ||
| }, | ||
| /** Stop and erase the live line, restoring the cursor. */ | ||
| stop() { | ||
| if (!active) return; | ||
| if (timer) clearInterval(timer); | ||
| timer = null; | ||
| if (sigintHandler) { | ||
| process.removeListener("SIGINT", sigintHandler); | ||
| sigintHandler = null; | ||
| } | ||
| io.stderrWrite(`${CLEAR_LINE}${SHOW_CURSOR}`); | ||
| }, | ||
| }; | ||
| } |
| import { padEndVisible, visibleWidth } from "./ansi.mjs"; | ||
| /** | ||
| * Column-aligned tables for human mode; tab-separated rows for pipes. | ||
| * Alignment does the work: 2-space gutters, no vertical rules, no zebra. | ||
| * Cells may carry ANSI (padding is visible-width aware). | ||
| */ | ||
| /** | ||
| * Render rows (arrays of styled cells) as aligned lines. The caller indents. | ||
| * `align` per column: "left" (default) or "right". | ||
| */ | ||
| export function padTable(rows, { gutter = 2, align = [] } = {}) { | ||
| const widths = []; | ||
| for (const row of rows) { | ||
| row.forEach((cell, i) => { | ||
| widths[i] = Math.max(widths[i] ?? 0, visibleWidth(cell)); | ||
| }); | ||
| } | ||
| const sep = " ".repeat(gutter); | ||
| return rows.map((row) => | ||
| row | ||
| .map((cell, i) => { | ||
| // The last cell never gets trailing padding. | ||
| if (i === row.length - 1 && align[i] !== "right") return String(cell); | ||
| if (align[i] === "right") { | ||
| const pad = widths[i] - visibleWidth(cell); | ||
| return pad > 0 ? `${" ".repeat(pad)}${cell}` : String(cell); | ||
| } | ||
| return padEndVisible(cell, widths[i]); | ||
| }) | ||
| .join(sep) | ||
| .replace(/\s+$/, ""), | ||
| ); | ||
| } | ||
| /** gh-mirror pipe mode: tab-joined, no header, no truncation, no ANSI. */ | ||
| export function tsv(rows) { | ||
| return rows.map((row) => row.map((cell) => String(cell ?? "")).join("\t")); | ||
| } | ||
| /** Greedy word-wrap to `width` visible cells (plain text only). */ | ||
| export function wrapText(text, rawWidth) { | ||
| // Callers pass `columns - N`; a tiny terminal can make that zero or | ||
| // negative, and the hard-break loop below would then never consume `rest`. | ||
| const width = !Number.isFinite(rawWidth) || rawWidth < 1 ? 1 : rawWidth; | ||
| const lines = []; | ||
| for (const paragraph of String(text).split("\n")) { | ||
| if (paragraph.trim() === "") { | ||
| lines.push(""); | ||
| continue; | ||
| } | ||
| let line = ""; | ||
| for (const word of paragraph.split(/\s+/)) { | ||
| // A single token wider than the column (a long URL, a fix-prompt | ||
| // path) has to be hard-broken. Greedy wrapping alone emits it whole | ||
| // and the line overruns the box border it is being wrapped INTO. | ||
| if (visibleWidth(word) > width) { | ||
| if (line !== "") { | ||
| lines.push(line); | ||
| line = ""; | ||
| } | ||
| let rest = word; | ||
| while (visibleWidth(rest) > width) { | ||
| lines.push(rest.slice(0, width)); | ||
| rest = rest.slice(width); | ||
| } | ||
| line = rest; | ||
| continue; | ||
| } | ||
| if (line === "") { | ||
| line = word; | ||
| } else if (visibleWidth(line) + 1 + visibleWidth(word) <= width) { | ||
| line += ` ${word}`; | ||
| } else { | ||
| lines.push(line); | ||
| line = word; | ||
| } | ||
| } | ||
| if (line !== "") lines.push(line); | ||
| } | ||
| return lines; | ||
| } |
+2
-2
| { | ||
| "name": "@index365/cli", | ||
| "version": "1.0.0", | ||
| "version": "1.1.0", | ||
| "description": "index365 CLI. Website findings your coding agent can use. Scan a URL, then read the score, findings, and fix paths from your terminal, CI, or agents. Wraps the public /api/v1.", | ||
@@ -34,3 +34,3 @@ "type": "module", | ||
| "devDependencies": { | ||
| "@types/node": "^22.20.0", | ||
| "@types/node": "^22.20.1", | ||
| "typescript": "^5.6.3", | ||
@@ -37,0 +37,0 @@ "vitest": "^3.2.6" |
+24
-21
@@ -20,35 +20,38 @@ # @index365/cli | ||
| ```bash | ||
| index365 login # sign in (pick Browser or API key) | ||
| index365 scan https://acme.com # resolve the project from the domain, wait, print the score | ||
| index365 scan https://acme.com --product marketing-signal | ||
| index365 scan https://acme.com --no-wait # queue and print the runId (CI, async) | ||
| index365 runs list --project <id> # runs, newest first | ||
| index365 report <runId> # compact agent-ready report (always JSON) | ||
| index365 report <runId> --save report.json # full report: context plus every finding | ||
| index365 report --project <id> # latest completed report for a project | ||
| index365 findings list --run <runId> # triage findings | ||
| index365 findings get --run <runId> <findingId> # full detail plus a copy-pasteable fix prompt | ||
| index365 scan local http://localhost:3000/ --project <id> # score a local page before deploy | ||
| index365 --status # auth, org, plan, and credits at a glance | ||
| index365 login # opens your browser; the key saves itself | ||
| index365 scan yoursite.com # resolve the project from the domain, wait, render the score card | ||
| index365 scan yoursite.com --product marketing-signal | ||
| index365 scan yoursite.com --no-wait # queue and print the run id (CI, async) | ||
| index365 findings # findings from your latest run - no ids needed | ||
| index365 findings get 1 # first finding, full detail plus a fix prompt | ||
| index365 findings get 1 --prompt | pbcopy # just the raw fix prompt, for your agent | ||
| index365 report # score card for your latest run (JSON when piped) | ||
| index365 report yoursite.com --save report.json # full report: context plus every finding | ||
| index365 results # your scan results, newest first, with score and age | ||
| index365 results yoursite.com # that site's score over time | ||
| index365 check # is a scan done yet? (pairs with scan --no-wait) | ||
| index365 scan local http://localhost:3000/ # score a local page before deploy | ||
| index365 --status # auth, plan, credits, and your latest run | ||
| ``` | ||
| `scan` resolves the project from the url's domain automatically. No project for that domain yet? The error names the exact fix: `index365 projects create --domain <domain>`. Pass `--project <id>` to skip resolution. A scan waits by default and prints the final score; `--no-wait` queues it and prints the runId. | ||
| No command ever requires a project or run id: every noun defaults to your latest completed run and echoes back what it resolved (`yoursite.com · scanned 2m ago`). A pasted run id may be shortened to any unique prefix, and a domain always works where a run id would. | ||
| `scan` resolves the project from the url's domain automatically. A new domain asks exactly one question, `<domain> is new. Create the project and scan it for N credits? [Y/n]` (the cost and your balance come from your plan), and `--yes` answers it for scripts. Non-interactive shells never prompt. A scan waits by default and renders the score card; `--no-wait` queues it and prints the run id. | ||
| ## Signing in | ||
| `index365 login` asks how you want to sign in: | ||
| `index365 login` opens your browser; you authorize on the dashboard and the key is saved automatically (loopback + PKCE, so the secret never travels through a URL). Nothing to copy or paste. | ||
| - **Browser** (default, recommended): opens your browser, you authorize on the dashboard, and the key is saved automatically (loopback + PKCE, so the secret never travels through a URL). Nothing to copy or paste. | ||
| - **API key**: paste a key created on the dashboard API Keys page (available on every plan, including Free). | ||
| For CI or headless machines, set `INDEX365_API_KEY` (preferred; it keeps the key out of shell history and process lists) or pass `index365 login --key <key>`. | ||
| Skip the menu with `index365 login --web` to go straight to the browser flow. For CI or headless machines, set `INDEX365_API_KEY` (preferred; it keeps the key out of shell history and process lists) or pass `index365 login --key <key>`. | ||
| Add `--json` to any command for machine-readable output; run-scoped responses carry a `resolved` block naming the run that "latest" resolved to. Keys live at `~/.config/index365/config.json` (mode 0600); `INDEX365_API_KEY` overrides the file. | ||
| Add `--json` to any command for machine-readable output. Keys live at `~/.config/index365/config.json` (mode 0600); `INDEX365_API_KEY` overrides the file. | ||
| ## Migrating from 0.x | ||
| CLI 1.0 unified the command grammar around `scan`, `runs`, `findings`, and `report`. Every old spelling keeps working as a hidden alias for at least 90 days; it prints a one-line redirect note on stderr and then runs the new path. | ||
| CLI 1.0 unified the grammar around `scan` and id-free reads; 1.1 renamed the read commands to `results` and `check`. Every old spelling keeps working as a hidden alias for at least 90 days; it prints a one-line redirect note on stderr and then runs the new path. | ||
| | Old (0.x) | New (1.0) | | ||
| | Old | New (1.1) | | ||
| | --------- | --------- | | ||
| | `runs list` | `results` (scan history; a domain narrows it) | | ||
| | `runs get [runId \| domain]` | `check [runId \| domain]` (is a scan done yet?) | | ||
| | `runs start --project <id> [--url <url>] [--wait]` | `scan <url>` (waits by default; `--no-wait` to queue) | | ||
@@ -55,0 +58,0 @@ | `marketing run --project <id> [--wait]` | `scan <url> --product marketing-signal` | |
+18
-1
@@ -89,5 +89,22 @@ /** | ||
| const message = parsed?.error?.message ?? `Request failed with status ${response.status}.`; | ||
| throw new CliError(`${code}: ${message}`, exitCodeForStatus(response.status), parsed?.error); | ||
| const err = new CliError( | ||
| `${code}: ${message}`, | ||
| exitCodeForStatus(response.status), | ||
| parsed?.error, | ||
| ); | ||
| // Structured error anatomy (what/why/next) for the renderer: humans get | ||
| // the three-part block, --json gets the identical recovery path. | ||
| err.code = code; | ||
| if (code === "insufficient_credits" || code === "no_active_subscription") { | ||
| err.why = "Credits reset each billing cycle. Your plan sets the per-scan cost."; | ||
| err.next = "https://index365.co/dashboard/billing"; | ||
| err.nextLabel = "top up or change plan"; | ||
| } else if (response.status === 401 || response.status === 403) { | ||
| err.why ??= "The API rejected this key (revoked, expired, or missing a scope)."; | ||
| err.next = "index365 login"; | ||
| err.nextLabel = "sign in again in the browser"; | ||
| } | ||
| throw err; | ||
| } | ||
| return parsed; | ||
| } |
+1
-1
@@ -14,3 +14,3 @@ /** | ||
| */ | ||
| export const CLI_VERSION = "1.0.0"; | ||
| export const CLI_VERSION = "1.1.0"; | ||
@@ -17,0 +17,0 @@ /** |
@@ -197,3 +197,3 @@ import { spawn } from "node:child_process"; | ||
| */ | ||
| export async function webLogin({ apiUrl, json }, io) { | ||
| export async function webLogin({ apiUrl, json, onWait }, io) { | ||
| const { verifier, challenge } = io.pkce(); | ||
@@ -216,3 +216,3 @@ const state = io.nonce(); | ||
| const note = json ? io.stderr : io.stdout; | ||
| note("Opening your browser to authorize index365..."); | ||
| note("Opening your browser to sign in"); | ||
| note(`If it does not open automatically, visit:\n ${authorizeUrl}`); | ||
@@ -225,2 +225,5 @@ try { | ||
| // The caller may hold the wait visually (spinner on a TTY). | ||
| onWait?.(); | ||
| let result; | ||
@@ -227,0 +230,0 @@ try { |
Sorry, the diff of this file is too big to display
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
169759
61.16%20
100%4457
63.38%87
3.57%47
27.03%