🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@index365/cli

Package Overview
Dependencies
Maintainers
1
Versions
11
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@index365/cli - npm Package Compare versions

Comparing version
1.0.0
to
1.1.0
+275
src/resolve.mjs
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;
}
/**
* 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 });
}
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` |

@@ -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;
}

@@ -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