diff --git a/CLAUDE.md b/CLAUDE.md index 20054bd..430f346 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,8 +63,9 @@ dev-coach/ │ │ ├── claude-history.ts # cross-project stack scan of ~/.claude (projects map, manifests, activity, memories) │ │ ├── courses.ts # courses: directory + document validation over the db.ts rows (see Courses below) │ │ ├── runtime.ts # where devcoach runs from: classifyPath (channel × Node manager × OS), describeRuntime, the stderr startup line +│ │ ├── text.ts # stripControlChars(+Inline/Deep): escape sequences + control chars out of someone else's text │ ├── mcp/server.ts # McpServer: 25 tools + 11 resources + devcoach_instructions prompt -│ ├── cli/commands.ts # Commander dispatcher (35 subcommands: 27 visible + 8 hidden hooks) + term.ts (colours, tables, OSC 8 link()) + open.ts (browser) +│ ├── cli/commands.ts # Commander dispatcher (35 subcommands: 27 visible + 8 hidden hooks) + term.ts (colours, tables, displayWidth, sanitizeForTerminal, trustedLink, rule) + open.ts (browser) │ │ # install.ts (install/uninstall/doctor) + mcp-command.ts (resolveCommand, chooseServerCommand, checkServerCommand) │ └── web/app.ts # Hono app (33 routes incl. POST /shutdown) + views.ts (hono/html pages); assets/static/share.js │ # startUi returns the server; SIGINT/SIGTERM/SIGHUP → gracefulShutdown (close, 2 s drain, exit); @@ -360,6 +361,8 @@ and the docs page `website/src/pages/lesson.tsx` — whose dashboard probe (`web mixed content (bug 171934, open), so Safari/iOS get an "unsupported" state and NO fetch; Chrome 142+ gates loopback behind the `local-network-access` permission (queried before/after the fetch to tell a refusal from a dead dashboard; `targetAddressSpace: "loopback"` on the request). Hardening (from two ultrareview rounds, keep it): +imported text is stripped of escape sequences and control characters (`importSharedLesson`, `core/text.ts`) and +the CLI strips every printed lesson/course string again (`printable`, `sanitizeLine`) — only `trustedLink` emits OSC 8; markdown from lessons is always `DOMPurify.sanitize`d before `innerHTML` (dashboard) / rendered through `marked` + DOMPurify (docs page); `decodeShareCode` caps the input at `MAX_CODE_CHARS` and inflates with a byte budget (`inflateBounded`; the docs decoder streams the same way); `fetchSharedInput` diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 505295e..f84895e 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -138,7 +138,10 @@ with `devcoach set share_name`). |---|---| | `DEVCOACH_DIR` | Relocate the data directory (default `~/.devcoach`) — sandboxing, tests, a second profile | | `DEVCOACH_HOOK_DEBUG=1` | Trace every hook decision to `~/.devcoach/hook.log` | -| `NO_COLOR` | Disable coloured terminal output | +| `NO_COLOR` | Disable coloured terminal output (wins over `FORCE_COLOR`) | +| `FORCE_COLOR` | Colour even on a pipe; `0` or `false` turns colour off. `TERM=dumb` turns it off unless forced | +| `FORCE_HYPERLINK=1` | Make the dashboard URL a clickable OSC 8 link on terminals devcoach does not detect | +| `COLUMNS` | Width for rules when stdout is not a terminal (default 80, rules stop at 100) | ## Examples diff --git a/docs/usage/sharing.md b/docs/usage/sharing.md index 07041e6..5a53fbf 100644 --- a/docs/usage/sharing.md +++ b/docs/usage/sharing.md @@ -176,11 +176,11 @@ See [Privacy & security](../reference/privacy.md#sharing-is-explicit) for the fu ```text $ devcoach lessons --imported -╭───┬────────────┬────────┬─────────────────────────────────────────┬───────┬──────────────┬───────────╮ -│ │ Date │ Topic │ Title │ Level │ Categories │ Shared by │ -├───┼────────────┼────────┼─────────────────────────────────────────┼───────┼──────────────┼───────────┤ +╭───┬────────────┬────────┬──────────────────────────────────────────┬───────┬──────────────┬───────────╮ +│ │ Date │ Topic │ Title │ Level │ Categories │ Shared by │ +├───┼────────────┼────────┼──────────────────────────────────────────┼───────┼──────────────┼───────────┤ │ · │ 2026-04-20 │ sqlite │ WAL mode: readers never block writers 💡 │ mid │ sqlite, node │ Ada │ -╰───┴────────────┴────────┴─────────────────────────────────────────┴───────┴──────────────┴───────────╯ +╰───┴────────────┴────────┴──────────────────────────────────────────┴───────┴──────────────┴───────────╯ ``` - Its **topic counts as taught**, so the coach will not teach it again, and **✓/✗ feedback works** @@ -189,6 +189,9 @@ $ devcoach lessons --imported shared lesson is a gift, not a lesson delivered to you. - Importing the same share twice reports *already in your log*; the same lesson from a different sender is stored as a separate lesson. +- Its text is someone else's, so it is stored **inert**: terminal escape sequences and control + characters (a screen clear, a hidden link, a clipboard write, a right-to-left override) are removed + on import, and the CLI strips them again before it prints any lesson. ## The `.devcoach.md` format diff --git a/src/cli/commands.ts b/src/cli/commands.ts index e16498d..aba02d2 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -20,6 +20,7 @@ import { sharedLessonFilename, } from "../core/share"; import { fetchSharedInput, isHttpUrl } from "../core/share-fetch"; +import { stripControlCharsDeep } from "../core/text"; import { VERSION } from "../version"; import { cmdDoctor, cmdInstall, cmdUninstall, skillHint } from "./install"; import { openInBrowser } from "./open"; @@ -31,6 +32,8 @@ import { confidenceColor, renderTable, rule, + sanitizeForTerminal, + sanitizeLine, } from "./term"; const log = (s = ""): void => { @@ -62,6 +65,13 @@ const feedbackLabel = (fb: string | null): string => : c.dim("no feedback"); const FEEDBACK_VALUES = ["know", "understood", "dont_know", "clear"]; +/** + * A lesson (or course) with every string made inert for the terminal. A shared lesson is someone + * else's text, and a row imported before the import-time strip may still carry escape sequences. + */ +const printable = (record: T): T => stripControlCharsDeep(record); +const senderLabel = (sharedBy: string | null): string => sharedBy ?? c.dim("anonymous"); + // ── Display commands ───────────────────────────────────────────────────────── /** Render the knowledge map as a Topic/Group/Confidence/Bar table (shared by `profile` + `setup`). */ @@ -73,8 +83,8 @@ function renderProfileTable(profile: ReturnType, title: .map((e) => { const color = confidenceColor(e.confidence); return [ - e.topic, - topicGroup.get(e.topic) ?? "Other", + sanitizeLine(e.topic), + sanitizeLine(topicGroup.get(e.topic) ?? "Other"), colorize(color, `${e.confidence}/10`), colorize(color, confidenceBar(e.confidence)), ]; @@ -166,7 +176,7 @@ function cmdLessons(o: LessonsOpts): void { if (hasMeta) columns.push({ header: "Project" }, { header: "Branch" }, { header: "Commit" }); const hasShared = lessons.some((l) => l.imported); if (hasShared) columns.push({ header: "Shared by" }); - const rows = lessons.map((l) => { + const rows = lessons.map(printable).map((l) => { const row = [ l.starred ? c.yellow("★") : c.dim("·"), l.timestamp.slice(0, 10), @@ -182,7 +192,7 @@ function cmdLessons(o: LessonsOpts): void { l.commit_hash ? c.cyan(l.commit_hash.slice(0, 7)) : "", ); } - if (hasShared) row.push(l.imported ? (l.shared_by ?? c.dim("anonymous")) : ""); + if (hasShared) row.push(l.imported ? senderLabel(l.shared_by) : ""); return row; }); log(renderTable("Lessons", columns, rows)); @@ -190,11 +200,12 @@ function cmdLessons(o: LessonsOpts): void { } function cmdLesson(id: string): void { - const lesson = db.withConnection((conn) => db.getLessonById(conn, id)); - if (lesson === null) { + const stored = db.withConnection((conn) => db.getLessonById(conn, id)); + if (stored === null) { log(c.red(`Lesson '${id}' not found.`)); process.exit(1); } + const lesson = printable(stored); log(rule(c.bold(lesson.title))); log(`${c.dim("ID:")} ${lesson.id}`); log(`${c.dim("Date:")} ${lesson.timestamp.slice(0, 19).replace("T", " ")}`); @@ -205,7 +216,7 @@ function cmdLesson(id: string): void { log( `${c.dim("Star:")} ${starLabel} ${c.dim("Feedback:")} ${feedbackLabel(lesson.feedback)}`, ); - if (lesson.imported) log(`${c.dim("Shared by:")} ${lesson.shared_by ?? c.dim("anonymous")}`); + if (lesson.imported) log(`${c.dim("Shared by:")} ${senderLabel(lesson.shared_by)}`); if (lesson.task_context) log(`${c.dim("Context:")} ${lesson.task_context}`); if (lesson.project || lesson.repository || lesson.branch || lesson.commit_hash || lesson.folder) { const parts: string[] = []; @@ -254,7 +265,7 @@ const courseStatusLabel = (status: string): string => : c.cyan(status); function cmdCourses(): void { - const list = db.withConnection((conn) => courses.listCourses(conn)); + const list = db.withConnection((conn) => courses.listCourses(conn)).map(printable); if (list.length === 0) { log(c.dim("No courses yet. Start one in your agent: /devcoach:course, or say yes after a ❌.")); return; @@ -280,11 +291,12 @@ function cmdCourses(): void { } function cmdCourse(id: string): void { - const course = db.withConnection((conn) => courses.getCourse(conn, id)); - if (course === null) { + const stored = db.withConnection((conn) => courses.getCourse(conn, id)); + if (stored === null) { log(c.red(`Course '${id}' not found.`)); process.exit(1); } + const course = printable(stored); const p = courses.progress(course); log(rule(c.bold(course.title))); log(`${c.dim("ID:")} ${course.id}`); @@ -334,9 +346,9 @@ function cmdFeedback(id: string, feedback: string): void { confLabel = "feedback cleared"; } else if (result.delta !== 0 && result.confidence !== null) { const oldConf = result.confidence - result.delta; - confLabel = `${c.cyan(result.topic_id)} confidence: ${oldConf} → ${c.bold(String(result.confidence))}`; + confLabel = `${c.cyan(sanitizeLine(result.topic_id))} confidence: ${oldConf} → ${c.bold(String(result.confidence))}`; } else { - confLabel = `${c.cyan(result.topic_id)} confidence unchanged`; + confLabel = `${c.cyan(sanitizeLine(result.topic_id))} confidence unchanged`; } log(`Lesson ${c.cyan(id)} → ${feedbackLabel(feedbackValue)} (${confLabel})`); } @@ -384,6 +396,7 @@ function cmdStats(): void { ), ); + const confidenceNote = (confidence: number): string => c.dim(`(${confidence})`); const weakest = (stats.weakest_topics ?? []) as { topic: string; confidence: number }[]; const strongest = (stats.strongest_topics ?? []) as { topic: string; confidence: number }[]; if (weakest.length || strongest.length) { @@ -392,8 +405,8 @@ function cmdStats(): void { const w = weakest[i]; const s = strongest[i]; rows.push([ - w ? `${c.red(w.topic)} ${c.dim(`(${w.confidence})`)}` : "", - s ? `${c.green(s.topic)} ${c.dim(`(${s.confidence})`)}` : "", + w ? `${c.red(sanitizeLine(w.topic))} ${confidenceNote(w.confidence)}` : "", + s ? `${c.green(sanitizeLine(s.topic))} ${confidenceNote(s.confidence)}` : "", ]); } log( @@ -586,7 +599,7 @@ function cmdShare(id: string | undefined, o: ShareOpts): void { } else if (o.link) { log(renderShareLink(payload)); } else { - log(renderShareText(payload)); + log(sanitizeForTerminal(renderShareText(payload))); if (encodeShareCode(payload).length > 16_000) { note("Long lesson — prefer --file for a friendlier hand-off."); } @@ -648,11 +661,11 @@ async function cmdImport(source: string | undefined): Promise { log(`${c.green("✓")} Lessons: ${parts.join(", ")}`); return; } - const lesson = result.lesson; - if (!lesson) { + if (!result.lesson) { log(c.red("Could not store the lesson.")); process.exit(1); } + const lesson = printable(result.lesson); const who = lesson.shared_by ? `shared by ${lesson.shared_by}` : "shared anonymously"; if (result.duplicated) { log(`${c.yellow("Already in your log")} as ${c.cyan(lesson.id)} (${who})`); diff --git a/src/cli/install.ts b/src/cli/install.ts index b387b4c..8d22ae7 100644 --- a/src/cli/install.ts +++ b/src/cli/install.ts @@ -24,7 +24,7 @@ import { resolveCommand, type ServerCommand, } from "./mcp-command"; -import { c } from "./term"; +import { c, sanitizeLine } from "./term"; const log = (s = ""): void => { console.log(s); @@ -888,7 +888,7 @@ export function cmdDoctor(): void { ); const cue = db.getCueState(conn); if (cue.pending) warn(`a cue is pending since ${cue.last_cue_at} (retry threshold armed)`); - else if (cue.last_skip_reason) log(` last skip: "${cue.last_skip_reason}"`); + else if (cue.last_skip_reason) log(` last skip: "${sanitizeLine(cue.last_skip_reason)}"`); const rate = coach.checkRateLimit(conn); if (rate.allowed) ok("rate limit: allowed"); else warn(`rate limit: ${rate.reason}`); diff --git a/src/cli/term.ts b/src/cli/term.ts index 0608b3c..5117de0 100644 --- a/src/cli/term.ts +++ b/src/cli/term.ts @@ -1,9 +1,26 @@ // Zero-dependency terminal styling + table rendering (replaces Rich) using node:util styleText. import { styleText } from "node:util"; +import { stripControlChars, stripControlCharsInline } from "../core/text"; -const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; +/** + * Whether stdout gets colour. `NO_COLOR` wins; then `FORCE_COLOR` forces it on (even on a pipe) + * unless it is `0` or `false`; otherwise a TTY that is not `TERM=dumb`. + */ +export function colorEnabled( + env: NodeJS.ProcessEnv = process.env, + isTTY: boolean = Boolean(process.stdout.isTTY), +): boolean { + if (env.NO_COLOR) return false; + const forced = env.FORCE_COLOR; + if (forced !== undefined) return !["0", "false"].includes(forced); + return isTTY && env.TERM !== "dumb"; +} + +const useColor = colorEnabled(); type Fmt = Parameters[0]; -const paint = (fmt: Fmt, s: string): string => (useColor ? styleText(fmt, s) : s); +// validateStream off: the decision above is final (Node's own check lets FORCE_COLOR beat NO_COLOR). +const paint = (fmt: Fmt, s: string): string => + useColor ? styleText(fmt, s, { validateStream: false }) : s; export const c = { green: (s: string) => paint("green", s), @@ -19,6 +36,16 @@ export function colorize(name: "green" | "yellow" | "red", s: string): string { return name === "green" ? c.green(s) : name === "yellow" ? c.yellow(s) : c.red(s); } +/** + * Text from the database made inert before it is printed: no escape sequence, no control + * character (see `core/text.ts`). Newlines and tabs stay. Every string a lesson, a course or a + * sender name carries goes through this or `sanitizeLine` on its way to the terminal. + */ +export const sanitizeForTerminal = (text: string): string => stripControlChars(text); + +/** `sanitizeForTerminal` for a one-line slot (a table cell, a title): breaks become one space. */ +export const sanitizeLine = (text: string): string => stripControlCharsInline(text); + /** * OSC 8 terminal hyperlinks — the "clickable URL" escape (`ESC ] 8 ; ; url BEL text ESC ] 8 ; ; BEL`). * Emitted only for terminals known to render it: unknown ones may print the raw bytes, and every @@ -44,18 +71,65 @@ export function supportsHyperlinks( return false; } -/** `url` as a clickable terminal link (OSC 8) where supported, otherwise the plain text. */ -export function link(url: string, text = url): string { - return supportsHyperlinks() ? `\u001b]8;;${url}\u0007${text}\u001b]8;;\u0007` : text; +/** The longest URL `trustedLink` wraps; a longer one prints as text. */ +export const MAX_LINK_URL_LENGTH = 2048; +const HTTP_URL_RE = /^https?:\/\/\S+$/; + +/** A plain http(s) URL: no whitespace, no control or invisible format character, at most 2 KiB. */ +const isTrustedUrl = (url: string): boolean => + url.length <= MAX_LINK_URL_LENGTH && HTTP_URL_RE.test(url) && stripControlChars(url) === url; + +/** + * `url` as a clickable terminal link (OSC 8) where supported, otherwise the plain text. Only for + * a URL the CLI built itself (`http://127.0.0.1:/…`): anything that is not a plain http(s) + * URL, or is longer than `MAX_LINK_URL_LENGTH`, prints as text and never becomes a link. Text + * from a lesson never reaches this function. + */ +export function trustedLink(url: string, text = url): string { + const label = sanitizeLine(text); + return isTrustedUrl(url) && supportsHyperlinks() + ? `\u001b]8;;${url}\u0007${label}\u001b]8;;\u0007` + : label; } // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI/OSC escapes is intentional const ANSI_RE = /\u001b\[[0-9;]*m|\u001b\]8;;[^\u0007\u001b]*(?:\u0007|\u001b\\)/g; export const stripAnsi = (s: string): string => s.replace(ANSI_RE, ""); -const width = (s: string): number => [...stripAnsi(s)].length; -function pad(s: string, w: number, justify: "left" | "right" | "center"): string { - const gap = Math.max(0, w - width(s)); +const graphemes = new Intl.Segmenter("en", { granularity: "grapheme" }); +const PRINTABLE_ASCII_RE = /^[\x20-\x7e]*$/; +// Zero columns: a cluster that starts with a combining mark, a format character (ZWJ, ZWSP, a +// variation selector on its own) or a control. +const ZERO_WIDTH_RE = /^[\p{Mn}\p{Me}\p{Cf}\p{Cc}]/u; +// Two columns: an emoji shown as emoji (default emoji presentation, or any emoji + VS16). +const EMOJI_RE = /\p{Emoji_Presentation}|\p{Emoji}\u{fe0f}/u; +// Two columns: East Asian Wide and Fullwidth (Unicode UAX #11), by block: Hangul Jamo, CJK +// radicals to CJK compatibility, Yi, Hangul syllables, CJK compatibility ideographs, vertical and +// small forms, fullwidth forms, Tangut, Kana supplements, enclosed ideographs, CJK planes 2 and 3. +const WIDE_RE = + /^[\u{1100}-\u{115f}\u{2329}\u{232a}\u{2e80}-\u{303e}\u{3041}-\u{33ff}\u{3400}-\u{4dbf}\u{4e00}-\u{9fff}\u{a000}-\u{a4cf}\u{a960}-\u{a97f}\u{ac00}-\u{d7a3}\u{f900}-\u{faff}\u{fe10}-\u{fe19}\u{fe30}-\u{fe6f}\u{ff00}-\u{ff60}\u{ffe0}-\u{ffe6}\u{16fe0}-\u{16fe4}\u{17000}-\u{18aff}\u{1b000}-\u{1b2ff}\u{1f200}-\u{1f2ff}\u{20000}-\u{2fffd}\u{30000}-\u{3fffd}]/u; + +function graphemeWidth(cluster: string): number { + if (ZERO_WIDTH_RE.test(cluster)) return 0; + return EMOJI_RE.test(cluster) || WIDE_RE.test(cluster) ? 2 : 1; +} + +/** + * Columns `s` takes in a terminal, per grapheme: wide CJK and emoji count 2 (✅ ✨ ❌ 💡, a ZWJ + * family, a flag), combining marks and zero-width characters 0, everything else 1 (★ ✓ ─ █). + * Our own colour and link escapes count nothing. + */ +export function displayWidth(s: string): number { + const plain = stripAnsi(s); + if (PRINTABLE_ASCII_RE.test(plain)) return plain.length; + let width = 0; + for (const { segment } of graphemes.segment(plain)) width += graphemeWidth(segment); + return width; +} + +/** `s` padded with spaces to `w` display columns. */ +export function pad(s: string, w: number, justify: "left" | "right" | "center" = "left"): string { + const gap = Math.max(0, w - displayWidth(s)); if (justify === "right") return " ".repeat(gap) + s; if (justify === "center") { const l = Math.floor(gap / 2); @@ -77,7 +151,10 @@ export function renderTable( showHeader = true, ): string { const widths = columns.map((col, i) => - Math.max(showHeader ? width(col.header) : 0, ...rows.map((r) => width(r[i] ?? ""))), + Math.max( + showHeader ? displayWidth(col.header) : 0, + ...rows.map((r) => displayWidth(r[i] ?? "")), + ), ); const sep = (l: string, m: string, r: string) => l + widths.map((w) => "─".repeat(w + 2)).join(m) + r; @@ -98,13 +175,28 @@ export function renderTable( return out.join("\n"); } -export function rule(text = ""): string { - const total = 60; +/** The widest a reading layout grows on a wide terminal, unless a width is given explicitly. */ +export const MAX_READING_WIDTH = 100; + +/** Columns to lay out for: an explicit width, else the terminal's, else `COLUMNS`, else 80. */ +export function termWidth( + explicit?: number, + stdout: { columns?: number } = process.stdout, + env: NodeJS.ProcessEnv = process.env, +): number { + const candidates = [explicit, stdout.columns, Number.parseInt(env.COLUMNS ?? "", 10)]; + return candidates.find((n) => n !== undefined && Number.isInteger(n) && n > 0) ?? 80; +} + +/** `termWidth`, capped at `MAX_READING_WIDTH` unless the width was given explicitly. */ +export const readingWidth = (explicit?: number): number => + explicit === undefined ? Math.min(termWidth(), MAX_READING_WIDTH) : termWidth(explicit); + +/** A left-anchored rule `── text ───…` (a plain line without text), `total` columns wide. */ +export function rule(text = "", total = readingWidth()): string { if (!text) return "─".repeat(total); - const t = ` ${text} `; - const side = Math.max(2, total - width(t)); - const l = Math.floor(side / 2); - return "─".repeat(l) + t + "─".repeat(side - l); + const lead = `── ${text} `; + return lead + "─".repeat(Math.max(2, total - displayWidth(lead))); } export const confidenceBar = (confidence: number): string => { diff --git a/src/core/coach.ts b/src/core/coach.ts index 7b4ae1f..8fef585 100644 --- a/src/core/coach.ts +++ b/src/core/coach.ts @@ -27,9 +27,11 @@ import { isSameSharedLesson, parseSharedInput, type SharedLesson, + SharedLessonSchema, sharedLessonIdCandidates, sharedLessonToLesson, } from "./share"; +import { stripControlCharsDeep } from "./text"; export function checkRateLimit(db: DatabaseSync): RateLimitResult { try { @@ -179,8 +181,22 @@ export interface SharedImportResult { * Store a shared lesson as one of our own (flagged `imported`, named after the sender). The * sender's id is kept when free; an own lesson with the same slug gets a suffixed id instead of * being overwritten; re-importing the same share is a duplicate. Never touches pacing. + * The text is someone else's: escape sequences and control characters are stripped before + * anything is compared or stored, and a lesson left without a title, topic or id is refused. */ -export function importSharedLesson(db: DatabaseSync, payload: SharedLesson): SharedImportResult { +export function importSharedLesson(db: DatabaseSync, shared: SharedLesson): SharedImportResult { + const clean = SharedLessonSchema.safeParse(stripControlCharsDeep(shared)); + if (!clean.success) { + return { + kind: "shared", + inserted: 0, + duplicated: 0, + invalid: 1, + lesson: null, + topic_tracked: false, + }; + } + const payload = clean.data; const tracked = Object.hasOwn(getAllKnowledge(db), payload.lesson.topic_id); for (const id of sharedLessonIdCandidates(payload)) { const existing = getLessonById(db, id); @@ -225,7 +241,7 @@ export function importSharedInput(db: DatabaseSync, text: string): SharedImportR if (input.kind === "lessons") { return { kind: "lessons", - ...importLessons(db, input.records), + ...importLessons(db, input.records.map(stripControlCharsDeep)), lesson: null, topic_tracked: null, }; diff --git a/src/core/text.ts b/src/core/text.ts new file mode 100644 index 0000000..caf6130 --- /dev/null +++ b/src/core/text.ts @@ -0,0 +1,135 @@ +// Text written by someone else (a shared lesson) must never drive a terminal. One strip, used when +// a lesson is imported (so stored text is clean for every surface) and by the CLI right before it +// prints, which also covers rows stored before the import strip existed. + +const TAB = 0x09; +const NEWLINE = 0x0a; +const BEL = 0x07; +const ESC = 0x1b; +const CSI_8BIT = 0x9b; +const ST_8BIT = 0x9c; +/** 8-bit openers of a control string: DCS, SOS, OSC, PM, APC. */ +const STRING_OPENERS_8BIT = new Set([0x90, 0x98, 0x9d, 0x9e, 0x9f]); +/** What follows ESC to open a control string: OSC `]`, DCS `P`, SOS `X`, PM `^`, APC `_`. */ +const STRING_INTRODUCERS = new Set(["]", "P", "X", "^", "_"]); +/** + * The invisible characters that reorder or hide text: ALM, zero-width space, LRM/RLM, the line and + * paragraph separators, bidi embeddings and overrides, bidi isolates, and the BOM. ZWJ and ZWNJ + * stay: emoji sequences and several scripts need them, and they move nothing. + */ +const INVISIBLE_FORMAT_CHARS = new Set([ + 0x061c, 0x200b, 0x200e, 0x200f, 0x2028, 0x2029, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, + 0x2067, 0x2068, 0x2069, 0xfeff, +]); + +const inRange = (code: number, lo: number, hi: number): boolean => code >= lo && code <= hi; + +/** Every C0 control except tab and newline, DEL, every C1 control, and the invisible format chars. */ +function isStrippedChar(code: number): boolean { + if (code === TAB || code === NEWLINE) return false; + return code < 0x20 || inRange(code, 0x7f, 0x9f) || INVISIBLE_FORMAT_CHARS.has(code); +} + +function skipRange(text: string, i: number, lo: number, hi: number): number { + let j = i; + while (j < text.length && inRange(text.charCodeAt(j), lo, hi)) j++; + return j; +} + +/** CSI: parameter bytes, intermediate bytes, one final byte (`ESC [ 2 J`, `ESC [ ? 1049 h`). */ +function csiEnd(text: string, i: number): number { + const j = skipRange(text, skipRange(text, i, 0x30, 0x3f), 0x20, 0x2f); + return inRange(text.charCodeAt(j), 0x40, 0x7e) ? j + 1 : j; +} + +/** Any other ESC sequence: intermediate bytes, one final byte (`ESC c` resets the terminal). */ +function escEnd(text: string, i: number): number { + const j = skipRange(text, i, 0x20, 0x2f); + return inRange(text.charCodeAt(j), 0x30, 0x7e) ? j + 1 : j; +} + +/** + * A control string (OSC 8 link, OSC 52 clipboard write, DCS…) runs to BEL or ST. Unterminated, it + * stops at the end of its line, so a stray `ESC ]` cannot swallow the rest of a lesson. + */ +function controlStringEnd(text: string, i: number): number { + for (let j = i; j < text.length; j++) { + const code = text.charCodeAt(j); + if (code === BEL || code === ST_8BIT) return j + 1; + if (code === ESC) return text[j + 1] === "\\" ? j + 2 : j; + if (code === NEWLINE) return j; + } + return text.length; +} + +/** Where the escape sequence that starts at `i` (ESC, 8-bit CSI or a C1 string opener) ends. */ +function sequenceEnd(text: string, i: number): number { + const code = text.charCodeAt(i); + if (code === CSI_8BIT) return csiEnd(text, i + 1); + if (code !== ESC) return controlStringEnd(text, i + 1); + const next = text[i + 1] ?? ""; + if (next === "[") return csiEnd(text, i + 2); + if (STRING_INTRODUCERS.has(next)) return controlStringEnd(text, i + 2); + return escEnd(text, i + 1); +} + +/** + * `text` with every escape sequence and control character removed; newlines and tabs stay. + * ECMA-48 sequences, 7-bit (ESC …) and 8-bit (C1), go whole, so their parameters do not show up as + * text; any control character left over (a lone BEL, CR, a bidi override) is dropped on its own. + * Nothing a terminal could act on survives: no ESC, no C1, no BEL. + */ +export function stripControlChars(text: string): string { + const kept: string[] = []; + let runStart = 0; + let i = 0; + while (i < text.length) { + const code = text.charCodeAt(i); + const startsSequence = code === ESC || code === CSI_8BIT || STRING_OPENERS_8BIT.has(code); + if (!startsSequence && !isStrippedChar(code)) { + i++; + continue; + } + kept.push(text.slice(runStart, i)); + i = startsSequence ? sequenceEnd(text, i) : i + 1; + runStart = i; + } + kept.push(text.slice(runStart)); + return kept.join(""); +} + +/** `stripControlChars` for a one-line slot (a title, a name, a table cell): breaks become one space. */ +export function stripControlCharsInline(text: string): string { + return stripControlChars(text).replace(/[\t\n]+/g, " "); +} + +/** The lesson fields that are prose: they keep their line breaks. Every other string is one line. */ +const MULTILINE_KEYS = new Set(["summary", "body"]); + +/** + * A copy of `value` with every string inside it (nested objects and arrays included) stripped: + * one-line everywhere except under the `summary` and `body` keys. Made for lesson-shaped records + * (a `Lesson`, a course, a shared-lesson payload, a legacy import row); other values pass through. + */ +export function stripControlCharsDeep(value: T): T { + return stripDeep(value, ""); +} + +function stripDeep(value: T, key: string): T { + if (typeof value === "string") { + return ( + MULTILINE_KEYS.has(key) ? stripControlChars(value) : stripControlCharsInline(value) + ) as T; + } + if (Array.isArray(value)) return value.map((item) => stripDeep(item, key)) as T; + if (isPlainObject(value)) { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, stripDeep(v, k)])) as T; + } + return value; +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object") return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} diff --git a/src/web/app.ts b/src/web/app.ts index 28d60be..c40be2c 100644 --- a/src/web/app.ts +++ b/src/web/app.ts @@ -5,7 +5,7 @@ import { dirname, extname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { type ServerType, serve } from "@hono/node-server"; import { type Context, Hono } from "hono"; -import { c, link } from "../cli/term"; +import { c, trustedLink } from "../cli/term"; import * as coach from "../core/coach"; import * as courses from "../core/courses"; import * as db from "../core/db"; @@ -850,7 +850,7 @@ export async function explainListenError( return { existingUrl: url, exitCode: 0, - message: `${c.yellow("devcoach UI is already running at")} ${c.cyan(link(url))}${version}\n${c.dim( + message: `${c.yellow("devcoach UI is already running at")} ${c.cyan(trustedLink(url))}${version}\n${c.dim( "Open it there, start another with --port , or stop it with: devcoach ui --stop", )}`, }; @@ -893,7 +893,7 @@ export function startUi(port: number, opts: StartUiOptions = {}): ServerType { const app = createApp({ onShutdown: () => stop("stop requested", false) }); const server = serve({ fetch: app.fetch, port, hostname: "127.0.0.1" }, (info) => { const url = `http://localhost:${info.port}`; - console.log(`devcoach UI running at ${c.cyan(link(url))}`); + console.log(`devcoach UI running at ${c.cyan(trustedLink(url))}`); if (process.stdout.isTTY) console.log(c.dim("Press Ctrl+C to stop")); opts.onReady?.(url); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 739cb67..b52c9bb 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -4,9 +4,11 @@ import { dirname, join } from "node:path"; import { Readable } from "node:stream"; import { describe, expect, it, vi } from "vitest"; import { parseHookPayload, runCli } from "../src/cli/commands"; +import { displayWidth } from "../src/cli/term"; import * as courses from "../src/core/courses"; import * as db from "../src/core/db"; import { parseLesson } from "../src/core/models"; +import { buildSharePayload, encodeShareCode } from "../src/core/share"; import { fetchSharedInput } from "../src/core/share-fetch"; import { VERSION } from "../src/version"; @@ -1158,6 +1160,105 @@ describe("cli share / import", () => { }); }); +describe("cli prints someone else's text inert", () => { + const ESC = "\x1b"; + const BEL = "\x07"; + // biome-ignore lint/suspicious/noControlCharactersInRegex: the test looks for control characters + const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f\u{202a}-\u{202e}\u{2066}-\u{2069}]/u; + const hostile = (id: string) => + parseLesson({ + id, + timestamp: "2026-10-30T10:00:00Z", + topic_id: `docker${BEL}`, + categories: [`dev${ESC}[31mops`], + title: `Cache mounts survive rebuilds${ESC}[2J${ESC}[3J${ESC}[H`, + level: "mid", + summary: `See ${ESC}]8;;file:///etc/passwd${BEL}the BuildKit guide${ESC}]8;;${BEL}.${ESC}]52;c;ZWNobyBwd25lZA==${BEL}`, + body: `RUN --mount=type=cache\n\n${ESC}]8;;javascript:alert(1)${ESC}\\docs${ESC}]8;;${ESC}\\`, + task_context: `ctx\x9b2J`, + imported: true, + shared_by: `${ESC}]8;;javascript:alert(1)${BEL}Ada${ESC}]8;;${BEL}`, + }); + + it("a crafted share imported with `import` never reaches the terminal as escapes", async () => { + const payload = buildSharePayload(hostile("hostile-import"), { + includeContext: true, + sharedBy: hostile("x").shared_by, + }); + const imported = await run(["import", encodeShareCode(payload)]); + expect(imported.out).toContain('Imported "Cache mounts survive rebuilds" (shared by Ada)'); + expect(imported.out).not.toMatch(CONTROL); + const outputs = [ + (await run(["lessons"])).out, + (await run(["lesson", "hostile-import"])).out, + (await run(["share", "hostile-import", "--anonymous"])).out, + ]; + for (const out of outputs) expect(out).not.toMatch(CONTROL); + expect(outputs[1]).toContain("See the BuildKit guide."); + }); + + it("rows stored before the import-time strip still print inert (lessons, lesson, share, feedback, profile)", async () => { + db.withConnection((c) => db.insertLesson(c, hostile("hostile-raw"))); + const runs = [ + ["lessons"], + ["lessons", "--imported"], + ["lesson", "hostile-raw"], + ["share", "hostile-raw", "--anonymous"], + ["feedback", "hostile-raw", "know"], // creates the (hostile) topic in the knowledge map + ["profile"], + ["stats"], + ]; + for (const args of runs) { + const { out } = await run(args); + expect(out, args.join(" ")).not.toMatch(CONTROL); + } + const shown = (await run(["lesson", "hostile-raw"])).out; + expect(shown).toContain("Cache mounts survive rebuilds"); + expect(shown).toContain("Shared by: Ada"); + expect(shown).toContain("See the BuildKit guide."); + }); + + it("course text prints inert too", async () => { + const course = db.withConnection((c) => + courses.createCourse(c, { + topic_id: "docker", + title: `Cache mounts${ESC}]0;owned${BEL}`, + goal: `Know when${ESC}[2J`, + prerequisites: [{ concept: `layers${ESC}c`, known: true }], + }), + ); + expect((await run(["courses"])).out).not.toMatch(CONTROL); + const shown = (await run(["course", course.id])).out; + expect(shown).not.toMatch(CONTROL); + expect(shown).toContain("Know when"); + }); + + it("the lessons table aligns: wide glyphs (💡, CJK) take two columns", async () => { + db.withConnection((c) => { + const mk = (o: Record) => + db.insertLesson( + c, + parseLesson({ + timestamp: "2026-10-29T10:00:00Z", + topic_id: "sqlite", + categories: ["sqlite"], + level: "mid", + summary: "s", + ...o, + }), + ); + mk({ id: "wide-1", title: "WAL mode: readers never block writers", feedback: "understood" }); + mk({ id: "wide-2", title: "漢字のレッスン", feedback: "know", starred: true }); + mk({ id: "wide-3", title: "Plain title", feedback: "dont_know" }); + }); + const out = (await run(["lessons"])).out; + expect(out).toContain("💡"); + const table = out.split("\n").filter((l) => /^[╭│├╰]/.test(l)); + const widths = new Set(table.map(displayWidth)); + expect(widths.size, table.join("\n")).toBe(1); + }); +}); + describe("cli uninstall", () => { const home = () => process.env.HOME as string; diff --git a/tests/core.test.ts b/tests/core.test.ts index a4c16e1..a072ea6 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -28,6 +28,7 @@ import { import { normalizeTimestamp, parseLesson } from "../src/core/models"; import { buildPromptForLevel, formatLessonForDisplay } from "../src/core/prompts"; import { buildSharePayload, renderShareText, ShareInputError } from "../src/core/share"; +import { stripControlCharsDeep } from "../src/core/text"; import { VERSION } from "../src/version"; function freshDb(): DatabaseSync { @@ -402,6 +403,34 @@ describe("detect + git", () => { }); }); +describe("control-character strip (core/text)", () => { + it("stripControlCharsDeep walks plain objects and arrays, and leaves other values alone", () => { + const when = new Date(0); + const bare = Object.assign(Object.create(null), { name: "a\x1b[2Jb" }); + expect( + stripControlCharsDeep({ + n: 1, + ok: true, + none: null, + when, + bare, + list: ["x\x07", { body: "l1\n\x1bcl2" }], + }), + ).toEqual({ + n: 1, + ok: true, + none: null, + when, + bare: { name: "ab" }, + list: ["x", { body: "l1\nl2" }], + }); + expect(stripControlCharsDeep({ title: "a\nb", summary: "a\nb" })).toEqual({ + title: "a b", + summary: "a\nb", + }); + }); +}); + describe("lesson sharing — storage & pacing", () => { let c: DatabaseSync | undefined; afterEach(() => c?.close()); @@ -601,6 +630,57 @@ describe("lesson sharing — storage & pacing", () => { ]); }); + it("importSharedLesson strips control characters: someone else's text is stored inert", () => { + const ESC = "\x1b"; + const BEL = "\x07"; + // biome-ignore lint/suspicious/noControlCharactersInRegex: the test looks for control characters + const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f\u{202a}-\u{202e}\u{2066}-\u{2069}]/u; + c = freshDb(); + const p = payloadFor("evil"); + p.lesson.title = `Cache mounts${ESC}[2J${ESC}[3J${ESC}[H survive`; + p.lesson.summary = `See ${ESC}]8;;file:///etc/passwd${BEL}the guide${ESC}]8;;${BEL}.${ESC}]52;c;ZWNobw==${BEL}`; + p.lesson.body = `Line one\x9b2J\n\n${ESC}]8;;javascript:alert(1)${ESC}\\docs${ESC}]8;;${ESC}\\ invoice\u{202e}fdp.exe\x07\tend`; + p.lesson.topic_id = `docker${BEL}`; + p.lesson.categories = [`dev${ESC}[31mops`]; + p.lesson.task_context = `ctx${ESC}c\nhere`; + p.shared_by = `${ESC}]8;;javascript:alert(1)${BEL}Ada${ESC}]8;;${BEL}`; + p.origin.id = `evil${ESC}[2J`; + const r = coach.importSharedLesson(c, p); + expect(r).toMatchObject({ inserted: 1, topic_tracked: false }); + const stored = db.getLessonById(c, "evil"); + expect(stored).toMatchObject({ + title: "Cache mounts survive", + summary: "See the guide.", + body: "Line one\n\ndocs invoicefdp.exe\tend", + topic_id: "docker", + categories: ["devops"], + task_context: "ctx here", + shared_by: "Ada", + }); + for (const [key, value] of Object.entries(stored ?? {})) { + if (typeof value === "string") expect(value, key).not.toMatch(CONTROL); + } + // the comparison runs on the stripped text: the same hostile share again is a duplicate + expect(coach.importSharedLesson(c, p)).toMatchObject({ inserted: 0, duplicated: 1 }); + // a lesson that is nothing but escapes has no title left: refused, nothing stored + const empty = payloadFor("only-escapes"); + empty.lesson.title = `${ESC}[2J${ESC}[H`; + expect(coach.importSharedLesson(c, empty)).toMatchObject({ + inserted: 0, + invalid: 1, + lesson: null, + }); + expect(db.getLessonById(c, "only-escapes")).toBeNull(); + // the legacy JSON-array form `import` also accepts goes through the same strip + const legacy = lesson({ + id: "legacy-evil", + title: `T${ESC}[2J`, + summary: `s${ESC}]52;c;eA==${BEL}\nok`, + }); + expect(coach.importSharedInput(c, JSON.stringify([legacy])).inserted).toBe(1); + expect(db.getLessonById(c, "legacy-evil")).toMatchObject({ title: "T", summary: "s\nok" }); + }); + it("importSharedInput takes any encoding, keeps the legacy JSON array path, and rejects junk", () => { c = freshDb(); const p = payloadFor("via-text"); diff --git a/tests/term.test.ts b/tests/term.test.ts index e46d006..bee87eb 100644 --- a/tests/term.test.ts +++ b/tests/term.test.ts @@ -1,7 +1,147 @@ -import { describe, expect, it } from "vitest"; -import { link, stripAnsi, supportsHyperlinks } from "../src/cli/term"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + colorEnabled, + displayWidth, + MAX_LINK_URL_LENGTH, + MAX_READING_WIDTH, + pad, + readingWidth, + renderTable, + rule, + sanitizeForTerminal, + sanitizeLine, + stripAnsi, + supportsHyperlinks, + termWidth, + trustedLink, +} from "../src/cli/term"; + +const ESC = "\x1b"; +const BEL = "\x07"; +const ST = `${ESC}\\`; +/** Anything a terminal could act on: C0 except tab/newline, DEL, C1, and the bidi controls. */ +// biome-ignore lint/suspicious/noControlCharactersInRegex: the test looks for control characters +const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f\u{202a}-\u{202e}\u{2066}-\u{2069}]/u; +const FAMILY = "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("sanitizeForTerminal: someone else's text never drives the terminal", () => { + const cases: [string, string, string][] = [ + ["CSI clear screen + scrollback + home", `a${ESC}[2J${ESC}[3J${ESC}[Hb`, "ab"], + ["CSI colour", `${ESC}[1;31mred${ESC}[0m`, "red"], + ["CSI with private parameters", `a${ESC}[?1049hb`, "ab"], + [ + "OSC 8 javascript: link, BEL", + `${ESC}]8;;javascript:alert(1)${BEL}Ada${ESC}]8;;${BEL}`, + "Ada", + ], + ["OSC 8 file: link, ST", `${ESC}]8;;file:///etc/passwd${ST}guide${ESC}]8;;${ST}`, "guide"], + ["OSC 52 clipboard write, BEL", `x${ESC}]52;c;ZWNobyBwd25lZA+/=${BEL}y`, "xy"], + ["OSC 52 clipboard write, ST", `x${ESC}]52;c;ZWNobyBwd25lZA==${ST}y`, "xy"], + ["OSC 0 window title", `${ESC}]0;owned${BEL}text`, "text"], + ["DCS string", `a${ESC}P1$qm${ST}b`, "ab"], + ["APC / PM / SOS strings", `a${ESC}_x${ST}b${ESC}^y${ST}c${ESC}Xz${ST}d`, "abcd"], + ["8-bit CSI (C1)", "a\x9b2Jb", "ab"], + ["8-bit OSC 8 with an 8-bit ST", "a\x9d8;;javascript:x\x9cb\x9d8;;\x9c", "ab"], + ["8-bit DCS", "a\x90q\x9cb", "ab"], + ["full reset (RIS) and charset switch", `a${ESC}cb${ESC}(0c`, "abc"], + ["save / restore cursor", `a${ESC}7b${ESC}8c`, "abc"], + ["a lone ESC at the end", `abc${ESC}`, "abc"], + ["bare BEL, BS, CR, VT, FF, DEL, NUL", "a\x07b\x08c\rd\x0be\x0cf\x7fg\x00h", "abcdefgh"], + ["C1 controls outside a sequence (NEL, RI, a stray ST)", "a\x85b\x8dc\x9cd", "abcd"], + ["an 8-bit APC opener swallows its string", "a\x9fstring\x9cb", "ab"], + ["right-to-left override", "invoice\u{202e}fdp.exe", "invoicefdp.exe"], + [ + "bidi isolates and marks", + "\u{2066}a\u{2069}\u{2067}b\u{2069}\u{200e}\u{200f}\u{61c}c", + "abc", + ], + ["zero-width space, BOM, line separators", "a\u{200b}b\u{feff}c\u{2028}d\u{2029}e", "abcde"], + ]; + it.each(cases)("%s", (_name, input, expected) => { + const out = sanitizeForTerminal(input); + expect(out).toBe(expected); + expect(out).not.toMatch(CONTROL); + }); + + it("an unterminated control string stops at the end of its line, so the lesson survives", () => { + expect(sanitizeForTerminal(`a${ESC}]8;;javascript:x\nthe rest`)).toBe("a\nthe rest"); + expect(sanitizeForTerminal(`a\x9d52;c;eA==\nthe rest`)).toBe("a\nthe rest"); + }); + + it("no control character survives, alone or next to text (exhaustive over C0, DEL and C1)", () => { + for (let cp = 0; cp <= 0x9f; cp++) { + if (cp >= 0x20 && cp < 0x7f) continue; + const ch = String.fromCodePoint(cp); + for (const input of [ch, `a${ch}b`, `${ch}[2J`, `${ch}]8;;javascript:x${BEL}y`]) { + expect(sanitizeForTerminal(input), `U+${cp.toString(16)}`).not.toMatch(CONTROL); + } + } + }); + + it("keeps everything that is text: newlines, tabs, emoji sequences, CJK, accents, markdown", () => { + const text = `Line one\n\tindented ${FAMILY} 漢字 café\n\`2 * 3\` [x](https://example.com) ★ ✅`; + expect(sanitizeForTerminal(text)).toBe(text); + expect(sanitizeForTerminal("e\u{301}")).toBe("e\u{301}"); // a combining mark is text + expect(sanitizeForTerminal("a\u{200c}b")).toBe("a\u{200c}b"); // ZWNJ stays (Persian, Indic scripts) + }); + + it("is linear on a large hostile input (a share code inflates to at most 256 KiB)", () => { + const chunk = `${ESC}[2J${ESC}]8;;javascript:x${BEL}text${ESC}]52;c;eA==${ST}\x9b1m${ESC}`; + const input = chunk.repeat(Math.ceil((256 * 1024) / chunk.length)); + const started = performance.now(); + const out = sanitizeForTerminal(input); + expect(performance.now() - started).toBeLessThan(250); + expect(out).toBe("text".repeat(Math.ceil((256 * 1024) / chunk.length))); + }); + + it("sanitizeLine also folds line breaks and tabs into one space", () => { + expect(sanitizeLine("a\n\tb\r\nc")).toBe("a b c"); + expect(sanitizeLine(`Title${ESC}[2J\nsecond line`)).toBe("Title second line"); + }); +}); + +describe("trustedLink: only a plain http(s) URL the CLI built becomes OSC 8", () => { + it("wraps http and https URLs when the terminal supports links; stripAnsi removes the wrapper", () => { + vi.stubEnv("FORCE_HYPERLINK", "1"); + const url = "http://127.0.0.1:7860/lessons/busy-timeout"; + const wrapped = trustedLink(url); + expect(wrapped).toBe(`${ESC}]8;;${url}${BEL}${url}${ESC}]8;;${BEL}`); + expect(stripAnsi(wrapped)).toBe(url); + expect(stripAnsi(trustedLink(url, "dashboard"))).toBe("dashboard"); + expect(trustedLink("https://example.com/a?b=c#d")).toContain(`${ESC}]8;;https://example.com`); + }); + + it.each([ + ["javascript:", "javascript:alert(1)"], + ["file:", "file:///etc/passwd"], + ["data:", "data:text/html,"], + ["another scheme", "ftp://example.com"], + ["BEL inside the URL", `http://x${BEL}${ESC}]8;;javascript:alert(1)`], + ["ESC inside the URL", `http://x${ESC}]52;c;eA==`], + ["an 8-bit ST inside the URL", "http://x\x9cy"], + ["a space inside the URL", "http://x y"], + ["a right-to-left override inside the URL", "http://x\u{202e}y"], + ["too long", `http://x/${"a".repeat(MAX_LINK_URL_LENGTH)}`], + ])("%s prints as plain, inert text", (_name, url) => { + vi.stubEnv("FORCE_HYPERLINK", "1"); + const out = trustedLink(url); + expect(out).not.toContain(`${ESC}]8`); + expect(out).not.toMatch(CONTROL); + expect(out).toBe(sanitizeLine(url)); + }); + + it("the label is sanitised too, and without link support the URL is plain text", () => { + vi.stubEnv("FORCE_HYPERLINK", "1"); + expect(stripAnsi(trustedLink("http://ok.test", `a${ESC}[2Jb`))).toBe("ab"); + vi.stubEnv("FORCE_HYPERLINK", "0"); + vi.stubEnv("NO_COLOR", "1"); + expect(trustedLink("http://localhost:7860")).toBe("http://localhost:7860"); + }); -describe("terminal hyperlinks (OSC 8)", () => { it("detects terminals that render OSC 8, and nothing else", () => { const tty = (env: NodeJS.ProcessEnv) => supportsHyperlinks(env, true); expect(supportsHyperlinks({ TERM_PROGRAM: "iTerm.app" }, false)).toBe(false); // piped @@ -21,26 +161,148 @@ describe("terminal hyperlinks (OSC 8)", () => { false, ); }); +}); + +describe("colour: NO_COLOR wins, FORCE_COLOR forces unless 0/false, TERM=dumb disables", () => { + it.each([ + ["a TTY", {}, true, true], + ["a pipe", {}, false, false], + ["TERM=dumb", { TERM: "dumb" }, true, false], + ["TERM=dumb, forced", { TERM: "dumb", FORCE_COLOR: "1" }, true, true], + ["FORCE_COLOR=0 on a TTY", { FORCE_COLOR: "0" }, true, false], + ["FORCE_COLOR=false on a TTY", { FORCE_COLOR: "false" }, true, false], + ["FORCE_COLOR=1 on a pipe", { FORCE_COLOR: "1" }, false, true], + ["FORCE_COLOR=3 on a pipe", { FORCE_COLOR: "3" }, false, true], + ["FORCE_COLOR empty on a pipe", { FORCE_COLOR: "" }, false, true], + ["NO_COLOR on a TTY", { NO_COLOR: "1" }, true, false], + ["NO_COLOR beats FORCE_COLOR", { NO_COLOR: "1", FORCE_COLOR: "1" }, false, false], + ["an empty NO_COLOR is unset", { NO_COLOR: "" }, true, true], + ])("%s", (_name, env, isTTY, expected) => { + expect(colorEnabled(env as NodeJS.ProcessEnv, isTTY)).toBe(expected); + }); + + it("the decision reaches the styles (made once, at load)", async () => { + const bold = async (env: Record): Promise => { + vi.resetModules(); + for (const [k, v] of Object.entries(env)) vi.stubEnv(k, v); + const term = await import("../src/cli/term"); + return term.c.bold("x"); + }; + expect(await bold({ NO_COLOR: undefined, FORCE_COLOR: "0" })).toBe("x"); + expect(await bold({ NO_COLOR: undefined, FORCE_COLOR: "1" })).toBe(`${ESC}[1mx${ESC}[22m`); + expect(await bold({ NO_COLOR: "1", FORCE_COLOR: "1" })).toBe("x"); + }); +}); - it("link() wraps in OSC 8 only when supported; stripAnsi removes the wrapper", () => { - const url = "http://localhost:7860"; - const prev = process.env.FORCE_HYPERLINK; - process.env.FORCE_HYPERLINK = "1"; - try { - const wrapped = link(url); - expect(wrapped).toBe(`\u001b]8;;${url}\u0007${url}\u001b]8;;\u0007`); - expect(stripAnsi(wrapped)).toBe(url); - expect(stripAnsi(link(url, "dashboard"))).toBe("dashboard"); - } finally { - if (prev === undefined) delete process.env.FORCE_HYPERLINK; - else process.env.FORCE_HYPERLINK = prev; +describe("displayWidth: columns per grapheme", () => { + it.each([ + ["", 0], + ["plain ascii", 11], + ["💡", 2], + ["✅ ✨ ❌", 8], + ["🎓", 2], + ["★", 1], + ["✓✦✗☆─│█░○●", 10], + [FAMILY, 2], + ["\u{1f1ee}\u{1f1f9}", 2], // a flag + ["\u{1f44d}\u{1f3fd}", 2], // skin tone + ["1\u{fe0f}\u{20e3}", 2], // keycap + ["⚠\u{fe0f}", 2], // emoji presentation selector + ["⚠", 1], + ["⚠\u{fe0e}", 1], // text presentation selector + ["e\u{301}", 1], // combining acute + ["漢字", 4], + ["日本語のテキスト", 16], + ["한국어", 6], + ["AB", 4], // fullwidth forms + ["\u{3000}", 2], // ideographic space + ["\u{200b}\u{200d}", 0], + ["Cache 💡", 8], + ])("%j is %i columns", (s, w) => { + expect(displayWidth(s)).toBe(w); + }); + + it("our own colour and link escapes count nothing", () => { + expect(displayWidth(`${ESC}[1m💡${ESC}[22m`)).toBe(2); + expect(displayWidth(`${ESC}]8;;http://x${BEL}dashboard${ESC}]8;;${BEL}`)).toBe(9); + expect(displayWidth(`${ESC}]8;;http://x${ST}漢${ESC}]8;;${ST}`)).toBe(2); + }); + + it("pad() pads to display columns", () => { + expect(pad("💡", 4)).toBe("💡 "); + expect(pad("漢", 3, "right")).toBe(" 漢"); + expect(pad("✅", 6, "center")).toBe(" ✅ "); + expect(pad("too wide", 3)).toBe("too wide"); + }); +}); + +/** Display columns of every box-drawing border in a rendered line. */ +function borderColumns(line: string): number[] { + const cols: number[] = []; + let col = 0; + for (const { segment } of new Intl.Segmenter("en", { granularity: "grapheme" }).segment(line)) { + if ("│┬┼┴╭╮├┤╰╯".includes(segment)) cols.push(col); + col += displayWidth(segment); + } + return cols; +} + +describe("renderTable with wide glyphs", () => { + it("every row has the same width and its borders in the same columns", () => { + const table = renderTable( + "Lessons", + [{ header: "" }, { header: "Title" }, { header: "Level", justify: "center" }], + [ + ["★", "WAL mode: readers never block writers 💡", "mid"], + ["·", "Cache stampede ✗", "senior"], + ["·", "Answered ✅ ✨ ❌", "junior"], + ["·", "漢字のレッスン", "mid"], + ["·", `Family ${FAMILY} \u{1f1ee}\u{1f1f9}`, "mid"], + ], + ); + const [title, ...lines] = table.split("\n"); + const width = displayWidth(lines[0] ?? ""); + for (const line of lines) { + expect(displayWidth(line), line).toBe(width); + expect(borderColumns(line), line).toEqual(borderColumns(lines[0] ?? "")); } - delete process.env.FORCE_HYPERLINK; - process.env.NO_COLOR = "1"; - try { - expect(link(url)).toBe(url); - } finally { - delete process.env.NO_COLOR; + expect(displayWidth(title ?? "")).toBe(width); + }); +}); + +describe("termWidth, readingWidth and the left-anchored rule", () => { + it("termWidth: explicit > the terminal's columns > COLUMNS > 80, skipping nonsense", () => { + expect(termWidth(120, { columns: 80 }, { COLUMNS: "90" })).toBe(120); + expect(termWidth(undefined, { columns: 80 }, { COLUMNS: "90" })).toBe(80); + expect(termWidth(undefined, {}, { COLUMNS: "90" })).toBe(90); + expect(termWidth(undefined, {}, {})).toBe(80); + expect(termWidth(0, { columns: 0 }, { COLUMNS: "wide" })).toBe(80); + expect(termWidth(-5, {}, { COLUMNS: "-1" })).toBe(80); + }); + + it("readingWidth caps the detected width at 100, never an explicit one", () => { + expect(readingWidth(150)).toBe(150); + vi.stubEnv("COLUMNS", "300"); + expect(readingWidth()).toBe(Math.min(termWidth(), MAX_READING_WIDTH)); + expect(readingWidth()).toBeLessThanOrEqual(MAX_READING_WIDTH); + }); + + it("rule() is left-anchored and exactly as wide as asked, wide glyphs included", () => { + expect(rule("", 10)).toBe("─".repeat(10)); + expect(rule("Title", 30).startsWith("── Title ─")).toBe(true); + for (const text of [ + "Title", + "💡 Senior tip", + "漢字", + "🎓 devcoach", + `${ESC}[1mBold${ESC}[22m`, + ]) { + expect(displayWidth(rule(text, 30)), text).toBe(30); } + // a title wider than the rule keeps it whole and still closes with two dashes + expect(rule("a title much wider than the rule", 10)).toBe( + "── a title much wider than the rule ──", + ); + expect(displayWidth(rule())).toBe(readingWidth()); }); });