diff --git a/docs/content/getting-started/connect-your-application/android.mdx b/docs/content/getting-started/connect-your-application/android.mdx index f713f83b0d..0d092543f2 100644 --- a/docs/content/getting-started/connect-your-application/android.mdx +++ b/docs/content/getting-started/connect-your-application/android.mdx @@ -289,19 +289,7 @@ You should see the sign-in form. Enter your test user credentials and tap **Subm ## What's Next -
- -
-
Upgrade the sign-in flow
-
Add MFA, passkeys, or social login to your flow, no app code changes required.
-
Flow Designer →
-
-
- -
-
Android SDK Reference
-
Explore the full API reference for the Android SDK.
-
API Reference →
-
-
-
+ + + + diff --git a/docs/content/getting-started/connect-your-application/browser.mdx b/docs/content/getting-started/connect-your-application/browser.mdx index 18098b3491..a9441ea1cb 100644 --- a/docs/content/getting-started/connect-your-application/browser.mdx +++ b/docs/content/getting-started/connect-your-application/browser.mdx @@ -253,24 +253,7 @@ You should see the sign-in button. Click it to be redirected to the - - -
-
Upgrade the Sign-In Flow
-
Add MFA, passkeys, or social login to your flow. No app code changes required.
-
Flow Designer →
-
-
- - -
-
Browser SDK Reference
-
Explore the full API reference for the Browser SDK.
-
API Reference →
-
-
- - - - + + + + diff --git a/docs/content/getting-started/connect-your-application/express.mdx b/docs/content/getting-started/connect-your-application/express.mdx index e86254adfd..69f8222aa9 100644 --- a/docs/content/getting-started/connect-your-application/express.mdx +++ b/docs/content/getting-started/connect-your-application/express.mdx @@ -207,23 +207,7 @@ You should see the sign-in link. Click it to be redirected to the - - -
-
Upgrade the sign-in flow
-
Add MFA, passkeys, or social login to your flow without changing your route wiring.
-
Flow Designer →
-
-
- - -
-
Express SDK Reference
-
Explore the full API reference for the Express SDK.
-
API Reference →
-
-
- - - + + + + diff --git a/docs/content/getting-started/connect-your-application/flutter.mdx b/docs/content/getting-started/connect-your-application/flutter.mdx index e4db9048d4..f3c87d6b95 100644 --- a/docs/content/getting-started/connect-your-application/flutter.mdx +++ b/docs/content/getting-started/connect-your-application/flutter.mdx @@ -288,22 +288,7 @@ You should see the sign-in form. Enter your test user credentials and tap **Subm ## What's Next -
- - -
-
Upgrade the sign-in flow
-
Add MFA, passkeys, or social login to your flow, no app code changes required.
-
Flow Designer →
-
-
- - -
-
Flutter SDK Reference
-
Explore the full API reference for the Flutter SDK.
-
API Reference →
-
-
- -
+ + + + diff --git a/docs/content/getting-started/connect-your-application/ios.mdx b/docs/content/getting-started/connect-your-application/ios.mdx index 4a821231d2..db40acb755 100644 --- a/docs/content/getting-started/connect-your-application/ios.mdx +++ b/docs/content/getting-started/connect-your-application/ios.mdx @@ -224,22 +224,7 @@ You should see the sign-in form. Enter your test user credentials and tap **Subm ## What's Next -
- - -
-
Upgrade the sign-in flow
-
Add MFA, passkeys, or social login to your flow, no app code changes required.
-
Flow Designer →
-
-
- - -
-
iOS SDK Reference
-
Explore the full API reference for the iOS SDK.
-
API Reference →
-
-
- -
+ + + + diff --git a/docs/content/getting-started/connect-your-application/nextjs.mdx b/docs/content/getting-started/connect-your-application/nextjs.mdx index d8d06dc67c..79d013c504 100644 --- a/docs/content/getting-started/connect-your-application/nextjs.mdx +++ b/docs/content/getting-started/connect-your-application/nextjs.mdx @@ -293,20 +293,7 @@ You should see the sign-in button. Click it, you'll land on your application's o ## What's Next -
- -
-
Upgrade the Sign-In Flow
-
Add MFA, passkeys, or social login to your flow. No app code changes required.
-
Flow Designer →
-
-
- -
-
Next.js SDK Reference
-
Explore the full API reference for the Next.js SDK.
-
API Reference →
-
-
-
- + + + + diff --git a/docs/content/getting-started/connect-your-application/node.mdx b/docs/content/getting-started/connect-your-application/node.mdx index 303de9635b..44ec2767c9 100644 --- a/docs/content/getting-started/connect-your-application/node.mdx +++ b/docs/content/getting-started/connect-your-application/node.mdx @@ -208,23 +208,7 @@ The script authenticates once, prints the scope it received, then calls `getStoc ## What's Next -
- - -
-
Agent Authentication
-
Request specific scopes, narrow tokens to a resource server, and troubleshoot the token endpoint directly.
-
Read the Guide →
-
-
- - -
-
Node.js SDK Reference
-
Explore the full API reference for the Node.js SDK.
-
API Reference →
-
-
- -
- + + + + diff --git a/docs/content/getting-started/connect-your-application/nuxt.mdx b/docs/content/getting-started/connect-your-application/nuxt.mdx index 0fa9a25a3b..d25bed3c19 100644 --- a/docs/content/getting-started/connect-your-application/nuxt.mdx +++ b/docs/content/getting-started/connect-your-application/nuxt.mdx @@ -245,20 +245,7 @@ You should see the `` button. Click it, you'll land on ## What's Next -
- -
-
Upgrade the Sign-In Flow
-
Add MFA, passkeys, or social login to your flow: no app code changes required.
-
Flow Designer →
-
-
- -
-
Nuxt SDK Reference
-
Explore the full API reference for the Nuxt SDK.
-
API Reference →
-
-
-
- + + + + diff --git a/docs/content/getting-started/connect-your-application/react.mdx b/docs/content/getting-started/connect-your-application/react.mdx index 7fdaafbbfa..7aae6a4df0 100644 --- a/docs/content/getting-started/connect-your-application/react.mdx +++ b/docs/content/getting-started/connect-your-application/react.mdx @@ -128,7 +128,7 @@ Update the `main.jsx` file with the following: Replace `` with the Client ID you obtained when creating the application in . ::: -```jsx title="src/main.jsx" {3,9-12,14} showLineNumbers> +```jsx title="src/main.jsx" {3,9-12,14} showLineNumbers import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { ThunderIDProvider } from '@thunderid/react' @@ -224,20 +224,7 @@ You should see the ``. Click it, you'll be redirected to the - -
-
Upgrade the sign-in flow
-
Add MFA, passkeys, or social login to your flow, no app code changes required.
-
Flow Designer →
-
-
- -
-
React SDK Reference
-
Explore the full API reference for the React SDK.
-
API Reference →
-
-
- - + + + + diff --git a/docs/content/getting-started/connect-your-application/vue.mdx b/docs/content/getting-started/connect-your-application/vue.mdx index a5c79c576a..876ec16b61 100644 --- a/docs/content/getting-started/connect-your-application/vue.mdx +++ b/docs/content/getting-started/connect-your-application/vue.mdx @@ -202,20 +202,7 @@ You should see the ``. Click it to be redirected to the - -
-
Upgrade the Sign-In Flow
-
Add MFA, passkeys, or social login to your flow: no app code changes required.
-
Flow Designer →
-
-
- -
-
Vue SDK Reference
-
Explore the full API reference for the Vue SDK.
-
API Reference →
-
-
- - + + + + diff --git a/docs/plugins/docusaurus-plugin-markdown-export/mdxProcessor.js b/docs/plugins/docusaurus-plugin-markdown-export/mdxProcessor.js index 028657eb83..5d1b0e3feb 100644 --- a/docs/plugins/docusaurus-plugin-markdown-export/mdxProcessor.js +++ b/docs/plugins/docusaurus-plugin-markdown-export/mdxProcessor.js @@ -18,14 +18,36 @@ async function processMarkdownFile(content, _constants, _sourceDir, linkContext) result = removeImports(result); const { frontmatter, body } = extractFrontmatter(result); result = body; - result = replaceThunderIDComponents(result); + result = replacePlaceholders(result); + // Admonitions run as a whole-document pass, before anything else: a + // `:::type ... :::` block's body often contains inline code spans, and + // splitting on those first would cut the block in half so the regex never + // sees a complete ":::...:::" span to convert. result = processAdmonitions(result); - result = stripRemainingJsx(result); + // convertJsxToMarkdown runs as a single pass over the whole document (with + // fenced/inline code protected internally, see its own code-span + // handling) rather than over pre-split segments: a wrapper element like + // `` can contain dozens of inline code spans between its open and + // close tags, so splitting the document on every code span first would + // scatter one element's open/close tags across many independent chunks, + // each losing the others' tag-stack state. + result = convertJsxToMarkdown(result); + result = transformOutsideCodeSpans(result, stripStrayExpressions); result = rewriteRelativeDocLinks(result, linkContext); result = cleanupWhitespace(result); if (frontmatter.title) { - result = `# ${frontmatter.title}\n\n${result}`; + // Docusaurus resolves `{{ProductName}}`-style placeholders in `title` during its + // own build; this export path bypasses that, so it must resolve them the same way + // it already does for the body, or the exported heading is left with raw `{{...}}`. + const resolvedTitle = replacePlaceholders(frontmatter.title); + // Frontmatter `title` is plain text, not live JSX; if it looks like a + // component name (e.g. ``), render it as inline + // code rather than emitting a raw, unescaped tag into the heading. + const heading = /[<>]/.test(resolvedTitle) ? `\`${resolvedTitle}\`` : resolvedTitle; + if (!result.trimStart().startsWith(`# ${heading}`)) { + result = `# ${heading}\n\n${result}`; + } } return result; @@ -46,98 +68,373 @@ function removeImports(content) { return content.replace(/^import\s+[\s\S]*?from\s+['"][^'"]+['"];?\s*$/gm, ''); } -/** Replace ThunderID-specific MDX components with readable text equivalents. */ -function replaceThunderIDComponents(content) { +/** Replace `{{...}}` frontmatter-style placeholders that may have leaked into the body. */ +function replacePlaceholders(content) { let result = content; - - // Self-closing component tags - result = result.replace(//g, PRODUCT_NAME); - result = result.replace(//g, CONSOLE_URL); - result = result.replace(//g, WAYFINDER_SAMPLE_URL); - result = result.replace(//g, WAYFINDER_MAIL_URL); - - // {{...}} frontmatter-style placeholders that may have leaked into body result = result.replace(/\{\{ProductName\}\}/g, PRODUCT_NAME); result = result.replace(/\{\{productSlug\}\}/g, PRODUCT_NAME.toLowerCase()); result = result.replace(/\{\{ConsoleUrl\}\}/g, CONSOLE_URL); result = result.replace(/\{\{WayFinderSampleUrl\}\}/g, WAYFINDER_SAMPLE_URL); result = result.replace(/\{\{WayFinderMailUrl\}\}/g, WAYFINDER_MAIL_URL); - - // Stepper: keep inner content, remove wrapper - result = result.replace(/]*>([\s\S]*?)<\/Stepper>/g, '$1'); - - // Tabs / TabItem: strip wrappers, keep content - result = result.replace(/]*>/g, ''); - result = result.replace(/<\/Tabs>/g, ''); - result = result.replace(/]*label="([^"]*)"[^>]*>/g, '\n**$1**\n\n'); - result = result.replace(/<\/TabItem>/g, ''); - - // NextSteps / NextStepsCard: convert to bullet list. - // Attributes may appear in any order and span multiple lines. - result = result.replace(//g, (_match, attrs) => { - const get = (name) => { const m = attrs.match(new RegExp(`${name}="([^"]*)"`)); return m ? m[1] : ''; }; - const title = get('title'); - const href = get('href'); - const description = get('description'); - if (!title || !href) return ''; - return description ? `- [${title}](${href}) — ${description}` : `- [${title}](${href})`; - }); - result = result.replace(//g, ''); - result = result.replace(/<\/NextSteps>/g, ''); - - // SampleDownload: just remove (binary download, not useful in LLM context) - result = result.replace(//g, ''); - - // WayfinderCast / WayfinderArchitecture / WayfinderOrganization: descriptive fallback - result = result.replace(//g, '_[Cast diagram: Wayfinder sample users]_'); - result = result.replace( - //g, - '_[Architecture diagram: Wayfinder components]_', - ); - result = result.replace( - //g, - '_[Organization diagram: Wayfinder team structure]_', - ); - - // B2C / identity journey components: remove (interactive, not useful as text) - result = result.replace(//g, ''); - result = result.replace(/]*>[\s\S]*?<\/B2C\w+>/g, ''); - result = result.replace(//g, ''); - return result; } -/** Convert Docusaurus admonitions (:::note, :::tip, etc.) to blockquotes. */ +// Docusaurus admonition types have no "important"/"caution" equivalent split +// the way GitHub does; map each onto the closest GitHub alert type. +const ADMONITION_TO_GITHUB_ALERT = { + note: 'NOTE', + info: 'NOTE', + tip: 'TIP', + important: 'IMPORTANT', + warning: 'WARNING', + danger: 'CAUTION', + caution: 'CAUTION', +}; + +/** + * Convert Docusaurus admonitions (:::note, :::tip[, title], etc.) to GitHub + * alert blockquotes (`> [!NOTE]`), which render natively on GitHub and most + * Markdown viewers without needing custom CSS/JS. + * + * Docusaurus supports both `:::type` and `:::type Custom Title` (this repo + * uses the latter); GitHub alerts have no title slot, so a custom title is + * kept as a bold first line inside the blockquote. + */ function processAdmonitions(content) { return content.replace( - /:::(\w+)(?:\[([^\]]*)\])?\n([\s\S]*?):::/g, - (match, type, title, body) => { - const label = title || type.charAt(0).toUpperCase() + type.slice(1); - const lines = body - .trim() + /^([ \t]*):::(\w+)[ \t]*(.*)\n([\s\S]*?)\n\1:::[ \t]*$/gm, + (match, indent, type, rawTitle, body) => { + const alertType = ADMONITION_TO_GITHUB_ALERT[type.toLowerCase()] || 'NOTE'; + const title = rawTitle.trim(); + const bodyLines = body .split('\n') - .map((l) => '> ' + l); - return `> **${label}**\n>\n${lines.join('\n')}\n`; + .map((l) => (l.startsWith(indent) ? l.slice(indent.length) : l.replace(/^[ \t]+/, ''))); + while (bodyLines.length && !bodyLines[0].trim()) bodyLines.shift(); + while (bodyLines.length && !bodyLines[bodyLines.length - 1].trim()) bodyLines.pop(); + const lines = title ? [`**${title}**`, '', ...bodyLines] : bodyLines; + const quoted = lines.map((l) => (l ? `${indent}> ${l}` : `${indent}>`)).join('\n'); + return `${indent}> [!${alertType}]\n${quoted}`; }, ); } -/** Strip any remaining JSX/HTML tags that weren't handled above. */ -function stripRemainingJsx(content) { - let result = content; +/** + * Run `transform` only over the parts of `content` that are not fenced code + * blocks or inline code spans (backtick or tilde fences of any length, and + * backtick spans of any width — see findCodeSpan), leaving those verbatim so + * sample code (which often contains JSX that looks like our own components, + * e.g. ``) is never rewritten. + */ +function transformOutsideCodeSpans(content, transform) { + let result = ''; + let plainStart = 0; + let i = 0; + while (i < content.length) { + if (content[i] === '`' || content[i] === '~') { + const end = findCodeSpan(content, i); + if (end !== -1) { + result += transform(content.slice(plainStart, i)) + content.slice(i, end); + i = end; + plainStart = i; + continue; + } + } + i++; + } + result += transform(content.slice(plainStart)); + return result; +} - // Self-closing tags - result = result.replace(/<[A-Z][A-Za-z0-9]*[^>]*\/>/g, ''); - // Opening/closing pairs for known wrappers — keep inner text - result = result.replace(/<[A-Z][A-Za-z0-9]*[^>]*>([\s\S]*?)<\/[A-Z][A-Za-z0-9]*>/g, '$1'); - // Remove leftover HTML div/span wrappers - result = result.replace(/]*>([\s\S]*?)<\/div>/g, '$1'); - result = result.replace(/]*>([\s\S]*?)<\/span>/g, '$1'); - // Strip JSX expression blocks {/* comment */} and simple {expr} - result = result.replace(/\{\/\*[\s\S]*?\*\/\}/g, ''); - result = result.replace(/\{[^}\n]{0,200}\}/g, ''); +/** + * Find the tag starting at `pos` (content[pos] === '<'), respecting quoted + * attribute values and brace-delimited JSX expressions (which may themselves + * contain nested tags, e.g. `icon={}`), so the true end of the tag + * is found instead of stopping at the first stray '>' inside an attribute. + */ +function parseTag(content, pos) { + let i = pos + 1; + let isClosing = false; + if (content[i] === '/') { + isClosing = true; + i++; + } - return result; + const nameStart = i; + while (i < content.length && /[A-Za-z0-9]/.test(content[i])) i++; + if (i === nameStart) return null; + const tagName = content.slice(nameStart, i); + + let braceDepth = 0; + let quoteChar = null; + const attrsStart = i; + + while (i < content.length) { + const ch = content[i]; + if (quoteChar) { + if (ch === quoteChar) quoteChar = null; + i++; + continue; + } + if (ch === '"' || ch === "'") { + quoteChar = ch; + i++; + continue; + } + if (ch === '{') { + braceDepth++; + i++; + continue; + } + if (ch === '}') { + braceDepth = Math.max(0, braceDepth - 1); + i++; + continue; + } + if (braceDepth > 0) { + i++; + continue; + } + if (ch === '>') { + const selfClosing = content[i - 1] === '/'; + const attrsEnd = selfClosing ? i - 1 : i; + return { + tagName, + isClosing, + selfClosing, + attrsRaw: content.slice(attrsStart, attrsEnd), + end: i + 1, + }; + } + i++; + } + + return null; // unterminated tag; treat '<' as plain text +} + +function getAttr(attrsRaw, name) { + const match = attrsRaw.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`)); + return match ? match[1] : undefined; +} + +function dedent(text) { + const trimmed = text.replace(/^\n+/, '').replace(/\s+$/, ''); + const lines = trimmed.split('\n'); + const indents = lines.filter((l) => l.trim()).map((l) => l.match(/^[ \t]*/)[0].length); + const min = indents.length ? Math.min(...indents) : 0; + return lines.map((l) => l.slice(min)).join('\n').trim(); +} + +// Block-level wrappers: always sit on their own line, so unwrapping trims +// their inner whitespace and re-adds exactly one blank line around the +// content, instead of compounding with whatever blank lines already +// surrounded the wrapper tag in the source. +const BLOCK_UNWRAP_TAGS = new Set(['TutorialHero', 'Stepper', 'CodeGroup', 'Tabs', 'NextSteps', 'section', 'Box']); + +// Inline wrappers: appear mid-sentence, so unwrapping must not inject +// newlines around the content. +const INLINE_UNWRAP_TAGS = new Set(['span', 'Fragment']); + +const REMOVE_TAGS = new Set(['SampleDownload', 'UseCaseBranchCards']); + +/** Render a closed (or self-closing) element into its Markdown equivalent. */ +function renderElement(tagName, attrsRaw, inner) { + if (/^B2C/.test(tagName)) return ''; + if (REMOVE_TAGS.has(tagName)) return ''; + if (BLOCK_UNWRAP_TAGS.has(tagName)) return `\n${inner.trim()}\n`; + if (INLINE_UNWRAP_TAGS.has(tagName)) return inner; + + switch (tagName) { + case 'ProductName': + return PRODUCT_NAME; + case 'ConsoleUrl': + return CONSOLE_URL + (getAttr(attrsRaw, 'path') || ''); + case 'WayFinderSampleUrl': + return WAYFINDER_SAMPLE_URL + (getAttr(attrsRaw, 'path') || ''); + case 'WayFinderMailUrl': + return WAYFINDER_MAIL_URL + (getAttr(attrsRaw, 'path') || ''); + case 'RunThunderID': + return '```bash\nnpx thunderid\n```\n'; + case 'TutorialHeroItem': + return `\n- ${inner.trim()}`; + case 'TabItem': { + const label = getAttr(attrsRaw, 'label'); + return label ? `\n**${label}**\n\n${inner}` : inner; + } + case 'CodeBlock': { + const lang = getAttr(attrsRaw, 'lang') || 'text'; + const label = getAttr(attrsRaw, 'label'); + const code = dedent(inner); + return `${label ? `**${label}**\n\n` : ''}\`\`\`${lang}\n${code}\n\`\`\`\n\n`; + } + case 'code': + return `\`${inner.trim()}\``; + case 'a': { + const href = getAttr(attrsRaw, 'href') || ''; + const text = inner.replace(/\s+/g, ' ').trim(); + return `\n[${text}](${href})\n`; + } + case 'div': + return `\n${inner.trim()}\n`; + case 'NextStepsCard': { + const title = getAttr(attrsRaw, 'title'); + const href = getAttr(attrsRaw, 'href'); + const description = getAttr(attrsRaw, 'description'); + if (!title || !href) return ''; + return description ? `- [${title}](${href}), ${description}\n` : `- [${title}](${href})\n`; + } + case 'WayfinderCast': + return '_[Cast diagram: Wayfinder sample users]_'; + case 'WayfinderArchitecture': + return '_[Architecture diagram: Wayfinder components]_'; + case 'WayfinderOrganization': + return '_[Organization diagram: Wayfinder team structure]_'; + default: + // Unknown component: best-effort fallback, keep the inner text. + return inner; + } +} + +/** + * Walk the content, tracking open JSX/HTML elements on a stack so nested + * tags (including same-named siblings) close against their own matching + * pair rather than the nearest closing tag of any element. + */ +// Per CommonMark, a code fence must start at the beginning of a line (with +// up to 3 leading spaces); a "```" appearing mid-sentence (e.g. prose giving +// ```toml``` as an example) is not a fence and must not be treated as one. +function isAtLineStart(content, i) { + let j = i - 1; + while (j >= 0 && (content[j] === ' ' || content[j] === '\t')) j--; + return j < 0 || content[j] === '\n'; +} + +function runLength(content, i, ch) { + let j = i; + while (content[j] === ch) j++; + return j - i; +} + +/** + * If `content[i]` begins a fenced code block or inline code span, returns its + * exclusive end index; otherwise returns -1. Recognizes backtick *and* tilde + * fences of any length >= 3 (closed only by a same-character run of at least + * that length, per CommonMark) — not just a hardcoded "```" — and inline spans + * delimited by one or more backticks, closed only by a run of exactly that + * length, so e.g. `` `code` `` (a literal backtick inside) round-trips intact + * instead of being cut short at the first single backtick. + */ +function findCodeSpan(content, i) { + const ch = content[i]; + if (ch !== '`' && ch !== '~') return -1; + const len = runLength(content, i, ch); + + if (isAtLineStart(content, i) && len >= 3) { + // Fence: scan line by line (starting after the opening line, which may carry + // an info string like "```yaml") for a line holding only a same-character + // run of at least `len`. + let pos = content.indexOf('\n', i + len); + if (pos === -1) return content.length; // unterminated fence: rest of doc is code + pos += 1; + while (pos <= content.length) { + const lineEnd = content.indexOf('\n', pos); + const line = content.slice(pos, lineEnd === -1 ? content.length : lineEnd); + const m = /^[ \t]{0,3}(`+|~+)[ \t]*$/.exec(line); + if (m && m[1][0] === ch && m[1].length >= len) { + return lineEnd === -1 ? content.length : lineEnd + 1; + } + if (lineEnd === -1) return content.length; + pos = lineEnd + 1; + } + return content.length; + } + if (ch === '~') return -1; // tildes only ever open a fence, never inline code + + // Inline code span: closed only by a run of exactly `len` backticks, and only + // within the rest of this line (this codebase's inline spans never wrap). + const lineEnd = content.indexOf('\n', i); + const searchEnd = lineEnd === -1 ? content.length : lineEnd; + let j = i + len; + while (j < searchEnd) { + if (content[j] === '`') { + const closeLen = runLength(content, j, '`'); + if (closeLen === len) return j + len; + j += closeLen; + } else { + j++; + } + } + return -1; +} + +function convertJsxToMarkdown(content) { + const root = { tagName: null, attrsRaw: '', buffer: '' }; + const stack = [root]; + let i = 0; + + while (i < content.length) { + // Fenced code blocks and inline code spans are copied verbatim (no tag + // parsing inside), but unlike a pre-split, this keeps the tag stack + // intact across them, so a wrapper whose open/close tags straddle one + // or more code spans still closes correctly. + if (content[i] === '`' || content[i] === '~') { + const end = findCodeSpan(content, i); + if (end !== -1) { + stack[stack.length - 1].buffer += content.slice(i, end); + i = end; + continue; + } + } + if (content[i] === '<') { + const tag = parseTag(content, i); + if (tag) { + if (tag.isClosing) { + let frameIndex = -1; + for (let d = stack.length - 1; d >= 1; d--) { + if (stack[d].tagName === tag.tagName) { + frameIndex = d; + break; + } + } + if (frameIndex !== -1) { + // A mismatched close (e.g. `text`) matches a + // frame below the stack top: pop and render every intervening frame into its + // parent first, so their buffered content isn't discarded along with them. + while (stack.length > frameIndex) { + const frame = stack.pop(); + const rendered = renderElement(frame.tagName, frame.attrsRaw, frame.buffer); + stack[stack.length - 1].buffer += rendered; + } + } + i = tag.end; + continue; + } + if (tag.selfClosing) { + stack[stack.length - 1].buffer += renderElement(tag.tagName, tag.attrsRaw, ''); + i = tag.end; + continue; + } + stack.push({ tagName: tag.tagName, attrsRaw: tag.attrsRaw, buffer: '' }); + i = tag.end; + continue; + } + } + stack[stack.length - 1].buffer += content[i]; + i++; + } + + // Unwind any unclosed frames (malformed/truncated JSX) by unwrapping them. + while (stack.length > 1) { + const frame = stack.pop(); + const rendered = renderElement(frame.tagName, frame.attrsRaw, frame.buffer); + stack[stack.length - 1].buffer += rendered; + } + + return root.buffer; +} + +/** Remove stray `{expr}` JS expressions that weren't part of a recognized tag. */ +function stripStrayExpressions(content) { + return content.replace(/\{\/\*[\s\S]*?\*\/\}/g, '').replace(/\{[^}\n]{0,200}\}/g, ''); } /** Rewrite relative doc links to absolute .md URLs. */ @@ -213,7 +510,13 @@ function resolvePath(p) { function cleanupWhitespace(content) { let result = content; - result = result.replace(/\n{4,}/g, '\n\n\n'); + // Drop incidental JSX-formatting indentation (1-3 spaces, as opposed to a + // deliberate 4-space Markdown nesting indent) before synthetic bold labels + // and bullet links, e.g. ` ` renders as `- [...]` + // still carrying its source indentation, which would otherwise read as an + // unintended nested list item. + result = result.replace(/^[ \t]{1,3}(\*\*[^\n*]+\*\*|- \[.*)$/gm, '$1'); + result = result.replace(/\n{3,}/g, '\n\n'); result = result.replace(/[ \t]+$/gm, ''); result = result.trim() + '\n'; return result; diff --git a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/android.mdx b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/android.mdx index f713f83b0d..0d092543f2 100644 --- a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/android.mdx +++ b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/android.mdx @@ -289,19 +289,7 @@ You should see the sign-in form. Enter your test user credentials and tap **Subm ## What's Next - + + + + diff --git a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/browser.mdx b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/browser.mdx index 92da0e4e71..a9441ea1cb 100644 --- a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/browser.mdx +++ b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/browser.mdx @@ -253,23 +253,7 @@ You should see the sign-in button. Click it to be redirected to the - - -
-
Upgrade the Sign-In Flow
-
Add MFA, passkeys, or social login to your flow. No app code changes required.
-
Flow Designer →
-
-
- - -
-
Browser SDK Reference
-
Explore the full API reference for the Browser SDK.
-
API Reference →
-
-
- - - + + + + diff --git a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/express.mdx b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/express.mdx index cff74dad13..69f8222aa9 100644 --- a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/express.mdx +++ b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/express.mdx @@ -207,22 +207,7 @@ You should see the sign-in link. Click it to be redirected to the - - -
-
Upgrade the sign-in flow
-
Add MFA, passkeys, or social login to your flow without changing your route wiring.
-
Flow Designer →
-
-
- - -
-
Express SDK Reference
-
Explore the full API reference for the Express SDK.
-
API Reference →
-
-
- - + + + + diff --git a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/flutter.mdx b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/flutter.mdx index e4db9048d4..f3c87d6b95 100644 --- a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/flutter.mdx +++ b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/flutter.mdx @@ -288,22 +288,7 @@ You should see the sign-in form. Enter your test user credentials and tap **Subm ## What's Next - + + + + diff --git a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/ios.mdx b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/ios.mdx index 4a821231d2..db40acb755 100644 --- a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/ios.mdx +++ b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/ios.mdx @@ -224,22 +224,7 @@ You should see the sign-in form. Enter your test user credentials and tap **Subm ## What's Next - + + + + diff --git a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/nextjs.mdx b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/nextjs.mdx index 9cb33b8f76..79d013c504 100644 --- a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/nextjs.mdx +++ b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/nextjs.mdx @@ -293,19 +293,7 @@ You should see the sign-in button. Click it, you'll land on your application's o ## What's Next - + + + + diff --git a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/node.mdx b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/node.mdx index a73f3db8bd..44ec2767c9 100644 --- a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/node.mdx +++ b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/node.mdx @@ -208,22 +208,7 @@ The script authenticates once, prints the scope it received, then calls `getStoc ## What's Next - + + + + diff --git a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/nuxt.mdx b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/nuxt.mdx index 8ddfef4b81..d25bed3c19 100644 --- a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/nuxt.mdx +++ b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/nuxt.mdx @@ -245,19 +245,7 @@ You should see the `` button. Click it, you'll land on ## What's Next - + + + + diff --git a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/react.mdx b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/react.mdx index 79e28f0eb7..7aae6a4df0 100644 --- a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/react.mdx +++ b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/react.mdx @@ -128,7 +128,7 @@ Update the `main.jsx` file with the following: Replace `` with the Client ID you obtained when creating the application in . ::: -```jsx title="src/main.jsx" {3,9-12,14} showLineNumbers> +```jsx title="src/main.jsx" {3,9-12,14} showLineNumbers import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { ThunderIDProvider } from '@thunderid/react' @@ -224,19 +224,7 @@ You should see the ``. Click it, you'll be redirected to the - -
-
Upgrade the sign-in flow
-
Add MFA, passkeys, or social login to your flow, no app code changes required.
-
Flow Designer →
-
-
- -
-
React SDK Reference
-
Explore the full API reference for the React SDK.
-
API Reference →
-
-
- + + + + diff --git a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/vue.mdx b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/vue.mdx index b3a7939a52..876ec16b61 100644 --- a/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/vue.mdx +++ b/docs/versioned_docs/version-v1.0.x/getting-started/connect-your-application/vue.mdx @@ -202,19 +202,7 @@ You should see the ``. Click it to be redirected to the - -
-
Upgrade the Sign-In Flow
-
Add MFA, passkeys, or social login to your flow: no app code changes required.
-
Flow Designer →
-
-
- -
-
Vue SDK Reference
-
Explore the full API reference for the Vue SDK.
-
API Reference →
-
-
- + + + + diff --git a/tools/cli/cmd/thunderid/main.go b/tools/cli/cmd/thunderid/main.go index a7b8cbd096..35cd2d7a04 100644 --- a/tools/cli/cmd/thunderid/main.go +++ b/tools/cli/cmd/thunderid/main.go @@ -22,9 +22,13 @@ func main() { // upgrade — stop the running version, install the latest, restart on the same port. if len(args) > 0 && args[0] == "upgrade" { verbose, _ := parseFlags(args[1:]) - if _, err := upgrade.Run(cli.BaseDir(), upgrade.Opts{Verbose: verbose}); err != nil { + _, notice, err := upgrade.Run(cli.BaseDir(), upgrade.Opts{Verbose: verbose}) + if err != nil { os.Exit(1) } + if notice != "" { + fmt.Println(" " + notice) + } return } diff --git a/tools/cli/go.mod b/tools/cli/go.mod index 9295f58044..68cd140d39 100644 --- a/tools/cli/go.mod +++ b/tools/cli/go.mod @@ -5,36 +5,47 @@ go 1.26.1 require ( charm.land/bubbles/v2 v2.1.1 charm.land/bubbletea/v2 v2.0.8 + charm.land/glamour/v2 v2.0.1 charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.5 + github.com/charmbracelet/x/term v0.2.2 github.com/stretchr/testify v1.11.1 ) require ( + github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.17 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect + golang.org/x/net v0.39.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.24.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/tools/cli/go.sum b/tools/cli/go.sum index c10adea039..78e47e51b0 100644 --- a/tools/cli/go.sum +++ b/tools/cli/go.sum @@ -2,16 +2,26 @@ charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60= charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo= charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c= +charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= @@ -30,6 +40,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6g github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -48,14 +60,22 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -70,12 +90,21 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA= +github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/tools/cli/internal/cli/root.go b/tools/cli/internal/cli/root.go index e1c02a612d..dbc6b0fe99 100644 --- a/tools/cli/internal/cli/root.go +++ b/tools/cli/internal/cli/root.go @@ -203,36 +203,45 @@ func Run(verbose, forceSetup bool) { // replLoop runs the REPL repeatedly, re-entering after no-op upgrades or version switches. // An actual upgrade or normal exit breaks the loop. func replLoop(version, installPath string, proc *exec.Cmd, verbose, isFirstRun bool, newVersion, nodeWarning string, port int, creds *setup.AdminCredentials) { + notice := "" for { - upgradeRequested, switchRequested, err := ui.RunREPL(version, proc, installPath, verbose, isFirstRun, newVersion, nodeWarning, port, creds) + upgradeRequested, switchRequested, err := ui.RunREPL(version, proc, installPath, verbose, isFirstRun, newVersion, nodeWarning, port, creds, notice) if err != nil { fmt.Fprintf(os.Stderr, "\nREPL error: %v\n", err) os.Exit(1) } isFirstRun = false - newVersion = "" + notice = "" if upgradeRequested { - upgraded, err := upgrade.Run(BaseDir(), upgrade.Opts{Verbose: verbose, Port: port}) + upgraded, upgradeNotice, err := upgrade.Run(BaseDir(), upgrade.Opts{Verbose: verbose, Port: port}) if err != nil { + fmt.Fprintf(os.Stderr, "\nUpgrade failed: %v\n", err) os.Exit(1) } if upgraded { return // upgrade ran its own REPL internally } - // Already latest or cancelled — ThunderID is still running; reattach. + // Already latest or cancelled — reattach to the still-running instance, + // surfacing why inside the REPL rather than printing to a screen the + // next alternate-screen redraw would immediately hide. + notice = upgradeNotice continue } if switchRequested { - switched, err := upgrade.Switch(BaseDir(), version, port, verbose) + switched, switchNotice, err := upgrade.Switch(BaseDir(), version, port, verbose, newVersion) if err != nil { + fmt.Fprintf(os.Stderr, "\nSwitch failed: %v\n", err) os.Exit(1) } if switched { return // Switch ran its own REPL internally } - // Cancelled — reattach to the still-running instance. + // Cancelled or unavailable — reattach to the still-running instance, + // surfacing why inside the REPL rather than printing to a screen the + // next alternate-screen redraw would immediately hide. + notice = switchNotice continue } diff --git a/tools/cli/internal/commands/integrate/integrate.go b/tools/cli/internal/commands/integrate/integrate.go index dd26cfe883..9b9d850292 100644 --- a/tools/cli/internal/commands/integrate/integrate.go +++ b/tools/cli/internal/commands/integrate/integrate.go @@ -1,393 +1,29 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -// Package integrate provides step definitions for the in-REPL integration guides. +// Package integrate lists the platforms the CLI's /integrate-* commands cover. +// Each platform's actual guide content lives on thunderid.dev (indexed at +// https://thunderid.dev/llms.txt), fetched on demand via internal/services/docs — +// adding a platform here is the only change needed to add its /integrate- command. package integrate -// Step is one pane of the integration guide. -// Code lines may contain {{.KEY}} placeholders that are substituted with -// collected values at render time (e.g. {{.ClientID}}). -type Step struct { - Title string - Body []string // description lines shown above the code block - CodeFile string // display label: "src/main.jsx", "terminal", etc. - CodeLang string // "bash" or "jsx" — shown as a hint in the border - Code []string // code lines; {{.KEY}} is replaced at render time - CollectKey string // if non-empty, pause to collect this value before showing Code - CollectLabel string // prompt header shown while collecting - CollectHint string // textinput placeholder - CollectURL string // optional URL shown in the collect prompt for the user to open +// Platform is one /integrate- command. +type Platform struct { + Key string // command suffix, e.g. "nextjs" for "/integrate-nextjs" + Label string // display label, e.g. "Next.js" + Slug string // docs filename under product.DocsBaseURL, e.g. "node" for Node.js } -// VueSteps returns the ordered integration steps for adding @thunderid/vue -// to an existing Vue 3 app. -func VueSteps(baseURL string) []Step { - return []Step{ - { - Title: "Install @thunderid/vue", - Body: []string{"Install the ThunderID Vue SDK in your project:"}, - CodeFile: "terminal", - CodeLang: "bash", - Code: []string{"npm install @thunderid/vue"}, - }, - { - Title: "Register the Plugin", - Body: []string{"Register ThunderIDPlugin in src/main.js:"}, - CodeFile: "src/main.js", - CodeLang: "js", - Code: []string{ - `import { createApp } from 'vue'`, - `import { ThunderIDPlugin } from '@thunderid/vue'`, - `import App from './App.vue'`, - `import './style.css'`, - ``, - `const app = createApp(App)`, - `app.use(ThunderIDPlugin)`, - `app.mount('#app')`, - }, - }, - { - Title: "Add ThunderIDProvider", - Body: []string{"Wrap your app in src/App.vue with ThunderIDProvider:"}, - CollectKey: "ClientID", - CollectLabel: "Your Client ID", - CollectHint: "Console → Applications → your app → Client ID", - CodeFile: "src/App.vue", - CodeLang: "vue", - Code: []string{ - ``, - ``, - ``, - }, - }, - { - Title: "Start Your App", - Body: []string{"Start the development server:"}, - CodeFile: "terminal", - CodeLang: "bash", - Code: []string{"npm run dev"}, - }, - } -} - -// NextJSSteps returns the ordered integration steps for adding @thunderid/nextjs -// to an existing Next.js app. -func NextJSSteps(baseURL string) []Step { - return []Step{ - { - Title: "Install @thunderid/nextjs", - Body: []string{"Install the ThunderID Next.js SDK in your project:"}, - CodeFile: "terminal", - CodeLang: "bash", - Code: []string{"npm install @thunderid/nextjs"}, - }, - { - Title: "Set Environment Variables", - Body: []string{"Create .env.local with your credentials:"}, - CollectKey: "ClientID", - CollectLabel: "Your Client ID", - CollectHint: "Console → Applications → your app → Client ID", - CodeFile: ".env.local", - CodeLang: "dotenv", - Code: []string{ - `NEXT_PUBLIC_THUNDERID_BASE_URL=` + baseURL, - `NEXT_PUBLIC_THUNDERID_CLIENT_ID={{.ClientID}}`, - `THUNDERID_CLIENT_SECRET=`, - `THUNDERID_SECRET=`, - `# Remove in production:`, - `NODE_TLS_REJECT_UNAUTHORIZED=0`, - }, - }, - { - Title: "Add ThunderIDProvider to Layout", - Body: []string{"Wrap your root layout in app/layout.tsx:"}, - CodeFile: "app/layout.tsx", - CodeLang: "tsx", - Code: []string{ - `import { ThunderIDProvider }`, - ` from '@thunderid/nextjs/server'`, - ``, - `export default function RootLayout({ children }) {`, - ` return (`, - ` `, - ` `, - ` `, - ` {children}`, - ` `, - ` `, - ` `, - ` )`, - `}`, - }, - }, - { - Title: "Add the ThunderID Proxy", - Body: []string{"Create proxy.ts to handle auth routing:"}, - CodeFile: "proxy.ts", - CodeLang: "ts", - Code: []string{ - `import {`, - ` thunderIDProxy,`, - ` createRouteMatcher,`, - `} from '@thunderid/nextjs/server'`, - ``, - `const isProtected = createRouteMatcher([])`, - ``, - `export default thunderIDProxy(`, - ` async (thunderid, request) => {`, - ` if (isProtected(request))`, - ` await thunderid.protectRoute()`, - ` }`, - `)`, - ``, - `export const config = {`, - ` matcher: [`, - ` '/((?!_next/static|_next/image|favicon.ico).*)',`, - ` ],`, - `}`, - }, - }, - { - Title: "Build with ThunderID Components", - Body: []string{"Update app/page.tsx with auth components:"}, - CodeFile: "app/page.tsx", - CodeLang: "tsx", - Code: []string{ - `import {`, - ` SignedIn, SignedOut,`, - ` SignInButton, UserDropdown,`, - `} from "@thunderid/nextjs"`, - ``, - `export default function Home() {`, - ` return (`, - `
`, - ` `, - ` `, - ` `, - ` `, - ` Sign In`, - ` `, - `
`, - ` )`, - `}`, - }, - }, - { - Title: "Start Your App", - Body: []string{"Start the development server:"}, - CodeFile: "terminal", - CodeLang: "bash", - Code: []string{"npm run dev"}, - }, - } -} - -// NuxtSteps returns the ordered integration steps for adding @thunderid/nuxt -// to an existing Nuxt 3 app. -func NuxtSteps(baseURL string) []Step { - return []Step{ - { - Title: "Install @thunderid/nuxt", - Body: []string{"Install the ThunderID Nuxt module:"}, - CodeFile: "terminal", - CodeLang: "bash", - Code: []string{"npm install @thunderid/nuxt"}, - }, - { - Title: "Register the Module", - Body: []string{"Add @thunderid/nuxt to nuxt.config.ts:"}, - CodeFile: "nuxt.config.ts", - CodeLang: "ts", - Code: []string{ - `export default defineNuxtConfig({`, - ` modules: ['@thunderid/nuxt'],`, - `})`, - }, - }, - { - Title: "Set Up Environment Variables", - Body: []string{"Create .env with your credentials:"}, - CollectKey: "ClientID", - CollectLabel: "Your Client ID", - CollectHint: "Console → Applications → your app → Client ID", - CodeFile: ".env", - CodeLang: "dotenv", - Code: []string{ - `NUXT_PUBLIC_THUNDERID_BASE_URL=` + baseURL, - `NUXT_PUBLIC_THUNDERID_CLIENT_ID={{.ClientID}}`, - `THUNDERID_CLIENT_SECRET=`, - `THUNDERID_SESSION_SECRET=`, - }, - }, - { - Title: "Wrap App with ThunderIDRoot", - Body: []string{"Update app.vue to use ThunderIDRoot:"}, - CodeFile: "app.vue", - CodeLang: "vue", - Code: []string{ - ``, - }, - }, - { - Title: "Add Sign-In and Sign-Out", - Body: []string{"Create pages/index.vue with auth components:"}, - CodeFile: "pages/index.vue", - CodeLang: "vue", - Code: []string{ - ``, - }, - }, - { - Title: "Start Your App", - Body: []string{"Start the development server:"}, - CodeFile: "terminal", - CodeLang: "bash", - Code: []string{"npm run dev"}, - }, - } -} - -// ReactSteps returns the ordered integration steps for adding @thunderid/react -// to an existing React app. baseURL is the running ThunderID instance URL and -// is embedded directly into the ThunderIDProvider code snippet. -func ReactSteps(baseURL string) []Step { - return []Step{ - { - Title: "Get your Client ID", - CollectKey: "ClientID", - CollectLabel: "Your Client ID", - CollectHint: "Console → Applications → your app → Client ID", - CollectURL: baseURL + "/console", - }, - { - Title: "Install @thunderid/react", - Body: []string{"Install the ThunderID React SDK in your project:"}, - CodeFile: "terminal", - CodeLang: "bash", - Code: []string{"npm install @thunderid/react"}, - }, - { - Title: "Add ThunderIDProvider", - Body: []string{"Wrap your root component with ThunderIDProvider in src/main.jsx:"}, - CodeFile: "src/main.jsx", - CodeLang: "jsx", - Code: []string{ - `import { StrictMode } from 'react'`, - `import { createRoot } from 'react-dom/client'`, - `import { ThunderIDProvider } from '@thunderid/react'`, - `import App from './App.jsx'`, - `import './index.css'`, - ``, - `createRoot(document.getElementById('root')).render(`, - ` `, - ` `, - ` `, - `
`, - ` `, - `)`, - }, - }, - { - Title: "Add Sign-In and Sign-Out", - Body: []string{"Update src/App.jsx to add auth components:"}, - CodeFile: "src/App.jsx", - CodeLang: "jsx", - Code: []string{ - `import {`, - ` SignedIn, SignedOut,`, - ` SignInButton, SignOutButton, Loading`, - `} from '@thunderid/react'`, - ``, - `function App() {`, - ` return (`, - ` <>`, - ` `, - `
Loading authentication...
`, - `
`, - ` `, - ` Sign In`, - ` `, - ` `, - ` Sign Out`, - ` `, - ` `, - ` )`, - `}`, - }, - }, - { - Title: "Display User Profile", - Body: []string{"Use the User component to show profile info in src/App.jsx:"}, - CodeFile: "src/App.jsx", - CodeLang: "jsx", - Code: []string{ - `import {`, - ` SignedIn, SignedOut,`, - ` SignInButton, SignOutButton, Loading, User`, - `} from '@thunderid/react'`, - ``, - `function App() {`, - ` return (`, - ` <>`, - `
Loading...
`, - ` Sign In`, - ` `, - ` Sign Out`, - ` `, - ` {(user) => user && (`, - `
`, - `

Welcome, {user.name}!

`, - `

{user.email}

`, - `
`, - ` )}`, - `
`, - `
`, - ` `, - ` )`, - `}`, - }, - }, - { - Title: "Start Your App", - Body: []string{ - "Start the development server:", - }, - CodeFile: "terminal", - CodeLang: "bash", - Code: []string{"npm run dev"}, - }, - } +// Platforms lists every platform with an /integrate-* command, in display order. +var Platforms = []Platform{ + {Key: "react", Label: "React", Slug: "react"}, + {Key: "nextjs", Label: "Next.js", Slug: "nextjs"}, + {Key: "express", Label: "Express", Slug: "express"}, + {Key: "vue", Label: "Vue", Slug: "vue"}, + {Key: "nuxt", Label: "Nuxt", Slug: "nuxt"}, + {Key: "nodejs", Label: "Node.js", Slug: "node"}, + {Key: "javascript", Label: "JavaScript", Slug: "browser"}, + {Key: "ios", Label: "iOS", Slug: "ios"}, + {Key: "android", Label: "Android", Slug: "android"}, + {Key: "flutter", Label: "Flutter", Slug: "flutter"}, } diff --git a/tools/cli/internal/commands/integrate/integrate_test.go b/tools/cli/internal/commands/integrate/integrate_test.go new file mode 100644 index 0000000000..b03b84130e --- /dev/null +++ b/tools/cli/internal/commands/integrate/integrate_test.go @@ -0,0 +1,34 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package integrate_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/thunder-id/thunderid/tools/cli/internal/commands/integrate" +) + +func TestPlatforms_CoversExpectedKeysAndSlugs(t *testing.T) { + want := map[string]string{ + "react": "react", + "nextjs": "nextjs", + "express": "express", + "vue": "vue", + "nuxt": "nuxt", + "nodejs": "node", + "javascript": "browser", + "ios": "ios", + "android": "android", + "flutter": "flutter", + } + + got := map[string]string{} + for _, p := range integrate.Platforms { + got[p.Key] = p.Slug + assert.NotEmpty(t, p.Label, "platform %q must have a display label", p.Key) + } + assert.Equal(t, want, got) +} diff --git a/tools/cli/internal/commands/sample/sample.go b/tools/cli/internal/commands/sample/sample.go index f81ec70a55..e77b8fbb8d 100644 --- a/tools/cli/internal/commands/sample/sample.go +++ b/tools/cli/internal/commands/sample/sample.go @@ -781,15 +781,14 @@ func printSummary(sampleName, baseURL, sampleURL string, features []string) { fmt.Println(" Try these walkthroughs:") fmt.Println() if hasFeature(Options{Features: features}, "ai") { - fmt.Println(" AI Concierge → click the chat bubble and ask about flights") - fmt.Println(" Book via Agent → ask the concierge to book a flight — approve the consent prompt") - fmt.Println(" Agent Identity → open " + sampleURL + "/signin-as-agent") + fmt.Println(" Protect the Agent → sign in as john.doe, then jane.smith, and compare chat access") + fmt.Println(" Browse with Agent → ask the concierge about flights (M2M, no consent prompt)") + fmt.Println(" Book on Behalf → ask the concierge to book a flight — approve the consent prompt") } else { - fmt.Println(" Login → sign in as john.doe / john.doe") + fmt.Println(" Sign-In → sign in as john.doe / john.doe, then open the Profile tab") fmt.Println(" Self Sign-Up → create a new account at the frontend") - fmt.Println(" View Profile → sign in, open the Profile tab") fmt.Println(" Account Recovery → click \"Forgot password?\" (requires SMTP in deployment.yaml)") - fmt.Println(" Onboard Users → sign in as alex.carter / alex.carter (Admin)") + fmt.Println(" Staff Sign-Up → invite a staff member from the ThunderID Console") } } fmt.Println() diff --git a/tools/cli/internal/commands/upgrade/upgrade.go b/tools/cli/internal/commands/upgrade/upgrade.go index 4a5f758f41..f8f0ce01ac 100644 --- a/tools/cli/internal/commands/upgrade/upgrade.go +++ b/tools/cli/internal/commands/upgrade/upgrade.go @@ -45,23 +45,24 @@ func resolveLivePort(opts Opts, activeVersion string) int { } // Run executes the upgrade workflow. baseDir is the parent thunderid directory (e.g. "./thunderid"). -// Returns (upgraded, err): upgraded is false when already on the latest version or the user cancelled. -func Run(baseDir string, opts Opts) (bool, error) { +// +// When upgraded is false, notice (if non-empty) explains why, styled for display as the +// first message in the REPL the caller reattaches to — printing it directly here would be +// hidden the moment that REPL's alternate screen takes over the terminal again. +func Run(baseDir string, opts Opts) (upgraded bool, notice string, err error) { fmt.Print(ui.Dim(" Fetching latest " + product.Name + " release...")) latestVersion, err := release.FetchLatestVersion() if err != nil { // Not being able to check for updates is not a broken upgrade: report it and // leave the running instance alone, so an offline /upgrade does not end the session. fmt.Print("\r\033[2K") - ui.Warn("Could not check for a newer " + product.Name + " release.\n" + err.Error()) - return false, nil + return false, ui.Yellow("⚠") + " Could not check for a newer " + product.Name + " release.\n" + err.Error(), nil } fmt.Printf("\r\033[2K %s Latest %s release: v%s\n\n", ui.Green("✓"), product.Name, latestVersion) activeVersion := config.ReadActiveVersion() if activeVersion == latestVersion { - ui.Success(product.Name + " v" + latestVersion + " is already the latest version.") - return false, nil + return false, ui.Green("✓") + " " + product.Name + " v" + latestVersion + " is already the latest version.", nil } if activeVersion != "" { @@ -74,18 +75,21 @@ func Run(baseDir string, opts Opts) (bool, error) { livePort := resolveLivePort(opts, activeVersion) - return true, runUpgrade(baseDir, activeVersion, latestVersion, opts.Verbose, livePort) + return true, "", runUpgrade(baseDir, activeVersion, latestVersion, opts.Verbose, livePort) } // Switch stops the running ThunderID instance and starts the selected installed version // on the same port. It shows an interactive version picker and returns false if the user // cancels or no other versions are installed. On success it starts the new instance and // runs a REPL for it. -func Switch(baseDir, currentVersion string, livePort int, verbose bool) (bool, error) { +// +// When switched is false, notice (if non-empty) explains why, styled for display as the +// first message in the REPL the caller reattaches to — printing it directly here would be +// hidden the moment that REPL's alternate screen takes over the terminal again. +func Switch(baseDir, currentVersion string, livePort int, verbose bool, pendingUpgrade string) (switched bool, notice string, err error) { versions := config.ListInstalledVersions(currentVersion) if len(versions) == 0 { - ui.Warn("No other installed versions found. Use /upgrade to install a new version.") - return false, nil + return false, ui.Yellow("⚠") + " No other installed versions found. Use " + ui.Cyan("/upgrade") + " to install a new version.", nil } options := make([]huh.Option[string], len(versions)) @@ -99,19 +103,17 @@ func Switch(baseDir, currentVersion string, livePort int, verbose bool) (bool, e Options(options...). Value(&selected). Run(); err != nil { - return false, nil // cancelled + return false, ui.Dim("Switch cancelled."), nil } installPath := config.ReadInstallPath(selected) if installPath == "" { - ui.Fatal("Install path not found for v" + selected + ". Re-run setup to restore it.") - return false, nil + return false, ui.Red("✗") + " Install path not found for v" + selected + ". Re-run setup to restore it.", nil } // Validate the install is launchable before touching the running instance. if _, err := setup.FindThunderRoot(installPath); err != nil { - ui.Fatal(fmt.Sprintf("v%s is not usable (%s). The install may have been moved or deleted.", selected, err)) - return false, nil + return false, ui.Red("✗") + fmt.Sprintf(" v%s is not usable (%s). The install may have been moved or deleted.", selected, err), nil } if livePort <= 0 { @@ -122,7 +124,7 @@ func Switch(baseDir, currentVersion string, livePort int, verbose bool) (bool, e if err := setup.FreePort(livePort, 10*time.Second); err != nil { fmt.Println() ui.Fatal("Could not stop v" + currentVersion + ": " + err.Error()) - return false, err + return false, "", err } fmt.Printf("\r\033[2K %s Stopped v%s\n", ui.Green("✓"), currentVersion) @@ -131,23 +133,23 @@ func Switch(baseDir, currentVersion string, livePort int, verbose bool) (bool, e if err != nil { fmt.Println() ui.Fatal("Failed to start v" + selected + ": " + err.Error()) - return false, err + return false, "", err } if err := health.WaitReady(livePort, startupTimeout); err != nil { stopStartedProcess(proc, livePort) fmt.Println() ui.Fatal(fmt.Sprintf("Failed to start v%s: %s", selected, startupFailure(installPath, err))) - return false, fmt.Errorf("failed to start v%s: %w", selected, err) + return false, "", fmt.Errorf("failed to start v%s: %w", selected, err) } // Persist the active version only once the new instance is up, so a failed // start does not leave the recorded version pointing at nothing. if err := config.WriteActiveVersion(selected); err != nil { - return false, fmt.Errorf("failed to update active version: %w", err) + return false, "", fmt.Errorf("failed to update active version: %w", err) } fmt.Printf("\r\033[2K %s Switched to %s v%s %s\n", ui.Green("✓"), product.Name, selected, ui.Dim("logs: "+setup.LogDir(installPath))) - _, _, err = ui.RunREPL(selected, proc, installPath, verbose, false, "", "", livePort, nil) - return true, err + _, _, err = ui.RunREPL(selected, proc, installPath, verbose, false, pendingUpgrade, "", livePort, nil, "") + return true, "", err } func runUpgrade(baseDir, activeVersion, newVersion string, verbose bool, livePort int) error { @@ -204,7 +206,7 @@ func runUpgrade(baseDir, activeVersion, newVersion string, verbose bool, livePor } fmt.Printf("\r\033[2K %s %s v%s started %s\n", ui.Green("✓"), product.Name, newVersion, ui.Dim("logs: "+setup.LogDir(newPath))) - _, _, err = ui.RunREPL(newVersion, proc, newPath, verbose, false, "", "", livePort, creds) + _, _, err = ui.RunREPL(newVersion, proc, newPath, verbose, false, "", "", livePort, creds, "") return err } diff --git a/tools/cli/internal/product/product.go b/tools/cli/internal/product/product.go index e165767c94..8d6c997b5a 100644 --- a/tools/cli/internal/product/product.go +++ b/tools/cli/internal/product/product.go @@ -17,6 +17,15 @@ const ( GitHubArchiveURL = "https://codeload.github.com/thunder-id/thunderid/zip/refs/heads/main" ) +// DocsVersionURL is the root of the current versioned docs tree, indexed at +// https://thunderid.dev/llms.txt. Bump this when the docs site cuts a new version. +const DocsVersionURL = "https://thunderid.dev/docs/v1.0.x" + +// DocsBaseURL is the canonical per-platform "connect your application" guide directory. +// Appending "/.md" fetches the raw markdown guide; appending "/" (no +// extension) is the human-facing HTML page, for "open in browser" links. +const DocsBaseURL = DocsVersionURL + "/getting-started/connect-your-application" + // Brand colors. const ( ColorDeepNavy = "#05213F" // primary brand — logo text and dark backgrounds diff --git a/tools/cli/internal/services/docs/docs.go b/tools/cli/internal/services/docs/docs.go new file mode 100644 index 0000000000..f80e84c555 --- /dev/null +++ b/tools/cli/internal/services/docs/docs.go @@ -0,0 +1,53 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package docs fetches per-platform integration guides from the ThunderID docs site. +package docs + +import ( + "fmt" + "io" + "net/http" + "time" + + "github.com/thunder-id/thunderid/tools/cli/internal/product" +) + +var client = &http.Client{Timeout: 10 * time.Second} + +// baseURL is a var (not product.DocsBaseURL directly) so tests can point it at +// an httptest.Server instead of the live docs site. +var baseURL = product.DocsBaseURL + +// FetchGuide downloads the raw markdown "connect your application" guide for slug +// (e.g. "react", "node", "browser") from product.DocsBaseURL. +func FetchGuide(slug string) (string, error) { + url := baseURL + "/" + slug + ".md" + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", product.Slug+"-cli") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d for %s", resp.StatusCode, url) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(body), nil +} + +// SiteURL returns the human-facing HTML page for slug, for "open in browser" links. +func SiteURL(slug string) string { + return product.DocsBaseURL + "/" + slug +} diff --git a/tools/cli/internal/services/docs/docs_test.go b/tools/cli/internal/services/docs/docs_test.go new file mode 100644 index 0000000000..1f0ae23410 --- /dev/null +++ b/tools/cli/internal/services/docs/docs_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package docs + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func withTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + orig := baseURL + baseURL = srv.URL + t.Cleanup(func() { baseURL = orig }) + return srv +} + +func TestFetchGuide_ReturnsBody(t *testing.T) { + withTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/react.md", r.URL.Path) + assert.NotEmpty(t, r.Header.Get("User-Agent")) + _, _ = w.Write([]byte("# React Quickstart")) + }) + + got, err := FetchGuide("react") + require.NoError(t, err) + assert.Equal(t, "# React Quickstart", got) +} + +func TestFetchGuide_ErrorsOnNon200(t *testing.T) { + withTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + _, err := FetchGuide("does-not-exist") + assert.Error(t, err) +} + +func TestSiteURL_NoMarkdownExtension(t *testing.T) { + assert.Equal(t, + "https://thunderid.dev/docs/v1.0.x/getting-started/connect-your-application/react", + SiteURL("react"), + ) +} diff --git a/tools/cli/internal/ui/banner.go b/tools/cli/internal/ui/banner.go index 0acc9566db..48a30de18d 100644 --- a/tools/cli/internal/ui/banner.go +++ b/tools/cli/internal/ui/banner.go @@ -71,6 +71,10 @@ var ( redStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorRed)) boldStyle = lipgloss.NewStyle().Bold(true) + // highlightStyle emphasizes copy-pasteable values (credentials, scopes) so they + // stand out from the surrounding walkthrough prose. + highlightStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorBrandBlue)) + introBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(colorBrandBlue)). @@ -126,14 +130,30 @@ var idLines = []string{ // slogan is shown under the product name in the banner. const slogan = "Auth for Modern Apps and Agents" +// BannerWidth returns the outer rendered width of BannerString, so other boxes +// and dividers in the REPL header can be sized to match it. It mirrors +// BannerString's own full-art-vs-compact decision, since the two layouts are +// sized by different styles. +func BannerWidth() int { + logoWidth := 2 + len(thunderLines[0]) + len(idLines[0]) + if terminalWidth() < logoWidth+introChrome { + return boxWidth(noteChrome) + noteChrome + } + return logoWidth + introBoxStyle.GetHorizontalFrameSize() +} + // BannerString returns the styled ASCII art banner, or a compact variant when the // terminal is too narrow for the art and its box: the art is a fixed width, so on a -// small terminal the border would wrap and every line would break. -func BannerString() string { +// small terminal the border would wrap and every line would break. When version is +// non-empty, a "⚡ ThunderID vX.Y.Z" line is rendered inside the box below the slogan. +func BannerString(version string) string { logoWidth := 2 + len(thunderLines[0]) + len(idLines[0]) if terminalWidth() < logoWidth+introChrome { - return fitBox(noteBoxStyle, noteChrome, - brandStyle.Render("⚡ "+product.Name)+"\n"+greyStyle.Render(slogan)) + compact := brandStyle.Render("⚡ "+product.Name) + "\n" + greyStyle.Render(slogan) + if version != "" { + compact += "\n" + boldStyle.Render("v"+version) + } + return fitBox(noteBoxStyle, noteChrome, compact) } var lines []string @@ -143,18 +163,20 @@ func BannerString() string { } banner := strings.Join(lines, "\n") - centeredSlogan := lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorGrey)). - Width(logoWidth). - Align(lipgloss.Center). - Render(slogan) + centered := lipgloss.NewStyle().Width(logoWidth).Align(lipgloss.Center) + centeredSlogan := centered.Foreground(lipgloss.Color(colorGrey)).Render(slogan) + + content := banner + "\n\n" + centeredSlogan + if version != "" { + content += "\n\n" + centered.Render(boldStyle.Render("⚡ "+product.Name+" v"+version)) + } - return introBoxStyle.Render(banner + "\n\n" + centeredSlogan) + return introBoxStyle.Render(content) } // PrintBanner writes the styled banner to stdout. func PrintBanner() { - fmt.Println(BannerString()) + fmt.Println(BannerString("")) } // StatusBoxString returns a bordered box showing the backend and console URLs @@ -172,6 +194,7 @@ func StatusBoxString(baseURL string) string { Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(colorGreen)). Padding(0, 1). + Width(BannerWidth()). Render(rows) } @@ -259,6 +282,11 @@ func Red(s string) string { return redStyle.Render(s) } +// Highlight emphasizes a copy-pasteable value (credential, scope) in bold brand color. +func Highlight(s string) string { + return highlightStyle.Render(s) +} + // UpgradeChoice represents the user's response to the upgrade prompt. type UpgradeChoice int diff --git a/tools/cli/internal/ui/banner_internal_test.go b/tools/cli/internal/ui/banner_internal_test.go index 7c944c6f12..5cbbe49f71 100644 --- a/tools/cli/internal/ui/banner_internal_test.go +++ b/tools/cli/internal/ui/banner_internal_test.go @@ -27,7 +27,7 @@ func withTerminalWidth(t *testing.T, width int) { func TestBannerString_CompactOnNarrowTerminal(t *testing.T) { withTerminalWidth(t, 40) - out := BannerString() + out := BannerString("") if strings.Contains(out, `|_ _| |`) { t.Fatalf("expected the compact banner on a 40-column terminal:\n%s", out) @@ -40,7 +40,7 @@ func TestBannerString_CompactOnNarrowTerminal(t *testing.T) { func TestBannerString_ArtOnWideTerminal(t *testing.T) { withTerminalWidth(t, 120) - out := BannerString() + out := BannerString("") if !strings.Contains(out, `|_ _| |`) { t.Fatalf("expected the full banner art on a wide terminal:\n%s", out) diff --git a/tools/cli/internal/ui/onboarding.go b/tools/cli/internal/ui/onboarding.go index 9d09c5c91d..1f60cfe9c2 100644 --- a/tools/cli/internal/ui/onboarding.go +++ b/tools/cli/internal/ui/onboarding.go @@ -6,6 +6,7 @@ package ui import ( "fmt" "io" + "strings" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" @@ -44,19 +45,32 @@ func (d onboardingDelegate) Render(w io.Writer, m list.Model, index int, item li isSelected := index == m.Index() + // Emoji glyphs render at inconsistent cell widths across terminals/fonts, so the + // description line pads to the title's actual rendered width instead of a fixed + // column count — otherwise it drifts out of alignment depending on which emoji + // a given item uses. + var prefix string + switch { + case i.comingSoon, !isSelected: + prefix = " " + i.emoji + " " + default: + prefix = " ❯ " + i.emoji + " " + } + descIndent := strings.Repeat(" ", lipgloss.Width(prefix)) + if i.comingSoon { - fmt.Fprintln(w, " "+Dim(i.emoji+" "+i.title)+" "+Dim("· Coming Soon")) //nolint:errcheck - fmt.Fprint(w, " "+Dim(i.description)) //nolint:errcheck + fmt.Fprintln(w, Dim(prefix+i.title)+" "+Dim("· Coming Soon")) //nolint:errcheck + fmt.Fprint(w, descIndent+Dim(i.description)) //nolint:errcheck return } if isSelected { //nolint:errcheck fmt.Fprintln(w, " "+brandStyle.Render("❯ ")+Bold(brandStyle.Render(i.emoji+" "+i.title))) - fmt.Fprint(w, " "+i.description) //nolint:errcheck + fmt.Fprint(w, descIndent+i.description) //nolint:errcheck } else { - fmt.Fprintln(w, " "+i.emoji+" "+i.title) //nolint:errcheck - fmt.Fprint(w, " "+Dim(i.description)) //nolint:errcheck + fmt.Fprintln(w, prefix+i.title) //nolint:errcheck + fmt.Fprint(w, descIndent+Dim(i.description)) //nolint:errcheck } } diff --git a/tools/cli/internal/ui/repl.go b/tools/cli/internal/ui/repl.go index eda2c3bdf8..fbbb323ff6 100644 --- a/tools/cli/internal/ui/repl.go +++ b/tools/cli/internal/ui/repl.go @@ -18,11 +18,13 @@ import ( "charm.land/bubbles/v2/textinput" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" + "charm.land/glamour/v2" "charm.land/lipgloss/v2" "github.com/thunder-id/thunderid/tools/cli/internal/commands/integrate" "github.com/thunder-id/thunderid/tools/cli/internal/commands/sample" "github.com/thunder-id/thunderid/tools/cli/internal/product" + "github.com/thunder-id/thunderid/tools/cli/internal/services/docs" "github.com/thunder-id/thunderid/tools/cli/internal/services/health" "github.com/thunder-id/thunderid/tools/cli/internal/services/setup" "github.com/thunder-id/thunderid/tools/cli/internal/utils" @@ -90,6 +92,11 @@ var defaultCommands = []SlashCommand{ }, } +// defaultInputWidth is the fallback textinput width used before the first +// tea.WindowSizeMsg arrives. Without an explicit width, bubbles' textinput +// truncates its placeholder to a single character. +const defaultInputWidth = 76 + // --- bubbletea messages --- type healthCheckMsg struct{ ready bool } @@ -129,9 +136,15 @@ type sampleDoneMsg struct { // sampleErrMsg signals that the try-* operation failed. type sampleErrMsg struct{ err error } -// integrateFrameworkMsg triggers the step-by-step integration guide for a framework. +// integrateFrameworkMsg triggers fetching and displaying a platform's integration guide. type integrateFrameworkMsg struct{ framework string } +// guideLoadedMsg carries the result of fetching an integration guide's markdown. +type guideLoadedMsg struct { + markdown string + err error +} + // usecaseConfigRequestMsg is sent when a use case requires additional config before starting. type usecaseConfigRequestMsg struct { sampleName string @@ -155,103 +168,156 @@ type walkthroughPane struct { URL string // opened with 'o' } -func b2cWalkthroughPanes(sampleURL string) []walkthroughPane { +// mailInboxURL is the SMTP test-inbox UI shipped with the Wayfinder sample. +const mailInboxURL = "http://localhost:8788" + +// b2cWalkthroughPanes mirrors the Console's Secured Web Application tryout +// scenarios (welcome.applicationTryout.scenarios.* in the frontend i18n +// locale) so the CLI and Console walk users through the same journeys. +func b2cWalkthroughPanes(sampleURL, consoleURL string) []walkthroughPane { return []walkthroughPane{ { - Title: "Log In", + Title: "Sign-In", URL: sampleURL, Lines: []string{ - "Sign in with the demo consumer account.", + "Sign in with the test user account to explore " + product.Name + " Sign in experience.", "", - " 1 Open the Wayfinder app at " + Cyan(sampleURL), - " 2 Click Sign in and enter:", + " 1 Open the Wayfinder app at " + Cyan(sampleURL) + ".", + " 2 Click Sign in and use the credentials below.", "", - " username " + Bold("john.doe"), - " password " + Bold("john.doe"), + " " + Dim("username") + " " + Highlight("john.doe"), + " " + Dim("password") + " " + Highlight("john.doe"), + "", + " " + Bold("View Profile"), + " " + Dim("Explore the self-service profile page - view account details, edit attributes, and change your password."), + "", + " 3 Click the username in the top-right corner and select Profile.", + " 4 View account details, edit profile attributes, or change your password. The page calls " + product.Name + " directly with your session token.", }, }, { Title: "Self Sign-Up", URL: sampleURL, Lines: []string{ - "Create a new account via self-registration.", + "Register a new customer account and see " + product.Name + " assign the Traveler role automatically on completion.", "", - " 1 Open " + Cyan(sampleURL), - " 2 Click Sign in → Register.", - " 3 Fill in your details and submit.", - }, - }, - { - Title: "View Profile", - URL: sampleURL, - Lines: []string{ - "Explore the user profile page.", + " 1 Open " + Cyan(sampleURL) + " and click Sign in.", + " 2 On the " + product.Name + " page, click Sign up.", + " 3 Fill in below sample details and click Continue.", + "", + " " + Dim("username") + " " + Highlight("emma.wilson"), + " " + Dim("password") + " " + Highlight("emma.wilson"), "", - " 1 Sign in as " + Bold("john.doe") + " / " + Bold("john.doe"), - " 2 Click your name in the top-right corner.", - " 3 Select Profile.", + " 4 Fill in the registration form using these sample details and click Continue.", + "", + " " + Dim("email ") + " " + Highlight("emma.wilson@example.com"), + " " + Dim("first name ") + " " + Highlight("Emma"), + " " + Dim("last name ") + " " + Highlight("Wilson"), + " " + Dim("mobile number ") + " " + Highlight("+15550148812"), + "", + " 5 " + product.Name + " will create a Customer user and assign the Traveler role. The browser returns to the Wayfinder app signed in as the new user.", }, }, { Title: "Account Recovery", URL: sampleURL, Lines: []string{ - "Trigger the forgot-password flow.", + "Walk through the password recovery flow - John forgets his password and resets it via email.", "", " 1 Open " + Cyan(sampleURL) + " and click Sign in.", - " 2 Click Forgot password?", - " 3 Enter your email and follow the instructions.", - "", - Dim(" Requires SMTP configured in deployment.yaml."), + " 2 On the " + product.Name + " sign-in page, click Forgot password?", + " 3 Enter " + Bold("john.doe") + " as the username and submit.", + " 4 " + product.Name + " sends a recovery email to John's registered address. Open it from the inbox at " + Cyan(mailInboxURL) + ".", + " 5 Click the reset link in the email and set a new password.", + " 6 Sign in again with the new credentials.", }, }, { - Title: "Onboard Staff", - URL: sampleURL, + Title: "Staff Sign-Up", + URL: consoleURL, Lines: []string{ - "Admin-invite a new internal user.", + "Invite and onboard two new staff members entirely from the " + product.Name + " Console: Sam Rivera (Support) and Maya Patel (DestinationsAdmin). The admin picks the staff role and sends the invitation, and the matching role is attached automatically when the invitee completes their profile.", "", - " 1 Sign in as " + Bold("alex.carter") + " / " + Bold("alex.carter") + Dim(" (Admin)"), - " 2 Open the Admin panel.", - " 3 Invite a new user by email.", + " 1 Sign in to the " + product.Name + " Console at " + Cyan(consoleURL) + " as your admin user.", + " 2 Navigate to Users and select Add User.", + " 3 Select Staff as the user type.", + " 4 Pick Support as the role, enter Sam Rivera's email (" + Bold("sam.rivera@example.com") + "), and click Send invitation. An invite link is emailed to Sam.", + " 5 Open Sam's invitation email from the inbox at " + Cyan(mailInboxURL) + " and open the link. The browser opens a Complete Your Profile page.", + " 6 Fill in the additional attributes and submit. Sam's account is now active with the Support role attached.", + " 7 Repeat the flow for Maya Patel (email " + Bold("maya.patel@example.com") + "), picking DestinationsAdmin as the role.", }, }, } } +// agentWalkthroughPanes mirrors the Console's Secured AI Agent tryout +// scenarios (welcome.aiAgentsTryout.scenarios.* in the frontend i18n +// locale) so the CLI and Console walk users through the same journeys. func agentWalkthroughPanes(sampleURL string) []walkthroughPane { return []walkthroughPane{ { - Title: "AI Concierge", + Title: "Protect the Agent", URL: sampleURL, Lines: []string{ - "Chat with the AI travel concierge.", + "See scope-based access control in action - John can use the AI concierge, but Jane cannot.", + "", + " 1 Open " + Cyan(sampleURL) + " and sign in as John Doe.", + "", + " " + Green("✓") + " John has access to chat with the Wayfinder chat agent", "", - " 1 Open the Wayfinder app at " + Cyan(sampleURL), - " 2 Click the chat bubble in the bottom-right corner.", - " 3 Ask about available flights.", + " " + Dim("username") + " " + Highlight("john.doe"), + " " + Dim("password") + " " + Highlight("john.doe"), + "", + " 2 Open the chat widget (bottom-right corner) and send any message. The concierge responds — John's token carries the " + Bold("agent:access") + " scope.", + " 3 Sign out and sign in as Jane Smith.", + "", + " " + Red("✗") + " Jane does not have access to chat with the Wayfinder chat agent", + "", + " " + Dim("username") + " " + Highlight("jane.smith"), + " " + Dim("password") + " " + Highlight("jane.smith"), + "", + " 4 Open the chat. Since Jane does not have the Wayfinder Chat User role, the chat agent will not be accessible and the widget will show an error message instead.", }, }, { - Title: "Book via Agent", + Title: "Browse with Agent", URL: sampleURL, Lines: []string{ - "Let the agent book a flight on your behalf.", + "Watch the agent use its own Machine-to-Machine (M2M) token to call read-only tools - no user consent popup required.", + "", + " 1 Sign in as John at " + Cyan(sampleURL) + " and open the chat widget.", + "", + " " + Dim("username") + " " + Highlight("john.doe"), + " " + Dim("password") + " " + Highlight("john.doe"), "", - " 1 Open the chat and ask the concierge to book a flight.", - " 2 The agent requests user consent — approve the prompt.", - " 3 The booking is created in your name.", + " 2 Ask a browsing question in the chat:", + "", + " " + Dim(`"What flights are there from Colombo to Singapore?"`), + "", + " 3 The agent calls the Wayfinder MCP server with its own M2M token (client_credentials grant). No popup appears.", + " 4 You can also try asking for flight deals — the agent calls the recommend_bookings tool, which requires the " + Bold("booking:recommend") + " scope, granted to the Wayfinder Concierge via its Recommender role.", + "", + " " + Dim(`"Suggest a few flight deals."`), }, }, { - Title: "Agent Identity", - URL: sampleURL + "/signin-as-agent", + Title: "Book on Behalf", + URL: sampleURL, Lines: []string{ - "Sign in as the AI agent directly.", + "Trigger the on-behalf-of consent flow - the agent pauses, asks for your permission, and only proceeds after you approve.", + "", + " 1 Sign in as John at " + Cyan(sampleURL) + " and open the chat widget.", + "", + " " + Dim("username") + " " + Highlight("john.doe"), + " " + Dim("password") + " " + Highlight("john.doe"), + "", + " 2 Ask the agent to book something, for example:", "", - " 1 Open " + Cyan(sampleURL+"/signin-as-agent"), - " 2 The gate shows the Agent ID / Secret form.", - " 3 Enter the agent credentials to authenticate.", + " " + Dim(`"Book flight 2"`), + "", + " 3 The agent returns a consent request. A popup opens - sign in as John and select which booking permissions to grant (" + Bold("booking:read") + ", " + Bold("booking:create") + ", " + Bold("booking:cancel") + ").", + " 4 Click Authorize. The agent retries the action using John's context token, and the booking confirmation appears in the chat shortly after.", + " 5 To see the rejection path, repeat the flow but deny " + Bold("booking:create") + " in the consent screen. The agent returns a 403.", }, }, } @@ -328,13 +394,19 @@ type ReplModel struct { showOnboarding bool onboardingList list.Model - onboardingCmdMode bool // true while the slash-command input overlay is active - checkPort int // non-zero overrides health.DefaultPort for health checks - upgradeRequested bool // set when the /upgrade command is executed - switchRequested bool // set when the /use command is executed - newVersion string + onboardingCmdMode bool // true while the slash-command input overlay is active + checkPort int // non-zero overrides health.DefaultPort for health checks + upgradeRequested bool // set when the /upgrade command is executed + switchRequested bool // set when the /use command is executed + newVersion string // non-empty shows a persistent upgrade-available notice below the banner + nodeWarning string // non-empty shows a persistent Node.js version notice below the banner + // showAllOnEmpty shows the full command list on an empty prompt for + // returning users (who skip the first-run onboarding picker). It clears + // the first time the user types anything. + showAllOnEmpty bool + showWalkthrough bool walkthroughPanes []walkthroughPane walkthroughTab int @@ -347,6 +419,12 @@ type ReplModel struct { pcOpts sample.Options pcStop bool // highlighted answer: true stops the holders, false cancels + // showNotice displays noticeMessage as a standalone dismissible page — used + // to surface why a /switch or /upgrade attempt didn't go through, since it + // would otherwise be a plain print lost the instant the REPL redraws. + showNotice bool + noticeMessage string + // Generic use-case config collection — active when showUsecaseConfig is true. showUsecaseConfig bool ucInputs []ConfigInput @@ -359,14 +437,13 @@ type ReplModel struct { ucFeatures []string ucLaunch func(values map[string]string) (string, sample.Options) - // Step-by-step integration guide — active when showIntegrate is true. - showIntegrate bool - integrateFramework string // display label, e.g. "React", "Vue" - integrateSteps []integrate.Step - integrateStepIdx int - integrateValues map[string]string - integrateInput textinput.Model - integrateCollecting bool + // Integration guide viewer — active when showGuide is true. Content is the + // platform's thunderid.dev quickstart, fetched on demand and glamour-rendered. + showGuide bool + guideLoading bool + guideLabel string // display label, e.g. "React" + guideDocURL string // human-facing page, opened with 'o' + guideViewport viewport.Model } // NewReplModel initializes the REPL model. @@ -375,6 +452,7 @@ func NewReplModel(version string, proc *exec.Cmd, installPath string, verbose bo ti.Placeholder = "Starting " + product.Name + "..." ti.Prompt = "> " ti.CharLimit = 256 + ti.SetWidth(defaultInputWidth) s := spinner.New() s.Spinner = spinner.Dot @@ -425,23 +503,14 @@ func NewReplModel(version string, proc *exec.Cmd, installPath string, verbose bo }) } } - for _, it := range []struct { - name string - framework string - label string - }{ - {"/integrate-react", "react", "React"}, - {"/integrate-vue", "vue", "Vue"}, - {"/integrate-nextjs", "nextjs", "Next.js"}, - {"/integrate-nuxt", "nuxt", "Nuxt"}, - } { - it := it + for _, p := range integrate.Platforms { + p := p commands = append(commands, SlashCommand{ - Name: it.name, - Description: "Add ThunderID auth to your " + it.label + " app", + Name: "/integrate-" + p.Key, + Description: "Add " + product.Name + " auth to your " + p.Label + " app", Section: "Integrate", AsyncAction: func(_ string) tea.Cmd { - return func() tea.Msg { return integrateFrameworkMsg{framework: it.framework} } + return func() tea.Msg { return integrateFrameworkMsg{framework: p.Key} } }, }) } @@ -468,10 +537,6 @@ func NewReplModel(version string, proc *exec.Cmd, installPath string, verbose bo commands = append(commands, logCmd) commands = append(commands, defaultCommands...) - ii := textinput.New() - ii.Prompt = "> " - ii.CharLimit = 256 - return ReplModel{ input: ti, spinner: s, @@ -483,10 +548,11 @@ func NewReplModel(version string, proc *exec.Cmd, installPath string, verbose bo status: statusStarting, proc: proc, width: 80, + height: 24, showOnboarding: isFirstRun, onboardingList: newOnboardingList(80), - integrateInput: ii, body: newOutputViewport(), + guideViewport: viewport.New(), } } @@ -676,6 +742,7 @@ func (m *ReplModel) initUCStep() { ti.Placeholder = "enter value…" ti.Prompt = " > " ti.CharLimit = 512 + ti.SetWidth(defaultInputWidth) if inp.Secret { ti.EchoMode = textinput.EchoPassword } @@ -717,6 +784,14 @@ func (m ReplModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:cyclop,fu m.width = msg.Width m.height = msg.Height m.onboardingList.SetSize(msg.Width, onboardingListHeight) + inputWidth := clamp(msg.Width-4, 20, 200) + m.input.SetWidth(inputWidth) + m.ucText.SetWidth(inputWidth) + // Reserve 3 rows for the header/separator/hint chrome renderGuide draws around + // the viewport. render() gives the guide the whole terminal (no shared body/footer + // split), so this is the only reservation needed to fit within msg.Height. + m.guideViewport.SetWidth(msg.Width) + m.guideViewport.SetHeight(clamp(msg.Height-3, 5, 1000)) case tea.MouseWheelMsg: var vpCmd tea.Cmd @@ -732,8 +807,6 @@ func (m ReplModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:cyclop,fu switch { case m.showUsecaseConfig && m.ucStep < len(m.ucInputs) && len(m.ucInputs[m.ucStep].Choices) == 0: m.ucText, tiCmd = m.ucText.Update(msg) - case m.integrateCollecting: - m.integrateInput, tiCmd = m.integrateInput.Update(msg) } cmds = append(cmds, tiCmd) @@ -777,7 +850,23 @@ func (m ReplModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:cyclop,fu return m, tea.Batch(cmds...) } - if m.showOnboarding && m.status == statusReady { + if m.showNotice { + // ── Standalone notice page ────────────────────────────────────────── + switch msg.String() { + case "esc": + m.showNotice = false + m.noticeMessage = "" + m.input.Focus() + case "/": + m.showNotice = false + m.noticeMessage = "" + m.input.Focus() + m.input.SetValue("/") + m.input.CursorEnd() + m.updateCompletions() + return m, tea.Batch(cmds...) + } + } else if m.showOnboarding && m.status == statusReady { if m.onboardingCmdMode { // ── Slash-command overlay ────────────────────────────────────── switch msg.String() { @@ -898,54 +987,27 @@ func (m ReplModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:cyclop,fu m.updateCompletions() return m, tea.Batch(cmds...) } - } else if m.showIntegrate { - // ── Integration guide navigation ─────────────────────────────────── - if m.integrateCollecting { - switch msg.String() { - case "enter": - val := strings.TrimSpace(m.integrateInput.Value()) - m.integrateValues[m.integrateSteps[m.integrateStepIdx].CollectKey] = val - m.integrateCollecting = false - m.integrateInput.Blur() - if len(m.integrateSteps[m.integrateStepIdx].Code) == 0 && m.integrateStepIdx < len(m.integrateSteps)-1 { - m.integrateStepIdx++ - } - case "esc": - m.integrateCollecting = false - m.integrateInput.Blur() - if len(m.integrateSteps[m.integrateStepIdx].Code) == 0 && m.integrateStepIdx < len(m.integrateSteps)-1 { - m.integrateStepIdx++ - } - default: - var tiCmd tea.Cmd - m.integrateInput, tiCmd = m.integrateInput.Update(msg) - cmds = append(cmds, tiCmd) - } - } else { - switch msg.String() { - case "enter": - step := m.integrateSteps[m.integrateStepIdx] - if step.CollectKey != "" && m.integrateValues[step.CollectKey] == "" { - m.integrateCollecting = true - m.integrateInput.SetValue("") - m.integrateInput.Placeholder = step.CollectHint - m.integrateInput.Focus() - } else if m.integrateStepIdx < len(m.integrateSteps)-1 { - m.integrateStepIdx++ - } else { - m.showIntegrate = false - m.walkthroughPanes = integrateWalkthroughPanes(m.integrateFramework, m.baseURL) - m.walkthroughTab = 0 - m.showWalkthrough = true - } - case "left": - if m.integrateStepIdx > 0 { - m.integrateStepIdx-- - } - case "esc": - m.showIntegrate = false - m.input.Focus() + } else if m.showGuide { + // ── Integration guide viewer ──────────────────────────────────────── + switch msg.String() { + case "o": + if m.guideDocURL != "" { + utils.OpenBrowser(m.guideDocURL) //nolint:errcheck } + case "esc": + m.showGuide = false + m.input.Focus() + case "/": + m.showGuide = false + m.input.Focus() + m.input.SetValue("/") + m.input.CursorEnd() + m.updateCompletions() + return m, tea.Batch(cmds...) + default: + var vpCmd tea.Cmd + m.guideViewport, vpCmd = m.guideViewport.Update(msg) + cmds = append(cmds, vpCmd) } } else { // ── Regular REPL ─────────────────────────────────────────────────── @@ -955,12 +1017,13 @@ func (m ReplModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:cyclop,fu break } val := strings.TrimSpace(m.input.Value()) - if val == "" { - break - } if m.showCompletions && len(m.completions) > 0 { val = m.completions[m.selectedComp].Name } + if val == "" { + break + } + m.showAllOnEmpty = false m.messages = append(m.messages, "> "+val) m.input.SetValue("") m.showCompletions = false @@ -1028,35 +1091,47 @@ func (m ReplModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:cyclop,fu } case integrateFrameworkMsg: - stepsFns := map[string]func(string) []integrate.Step{ - "react": integrate.ReactSteps, - "vue": integrate.VueSteps, - "nextjs": integrate.NextJSSteps, - "nuxt": integrate.NuxtSteps, + for _, p := range integrate.Platforms { + if p.Key != msg.framework { + continue + } + // runCommand latches tryingOut for every AsyncAction, but that flag + // exists to block input during a sample launch, not a guide fetch. + m.tryingOut = false + m.guideLoading = true + m.guideLabel = p.Label + m.guideDocURL = docs.SiteURL(p.Slug) + m.input.Blur() + slug := p.Slug + cmds = append(cmds, func() tea.Msg { + markdown, err := docs.FetchGuide(slug) + return guideLoadedMsg{markdown: markdown, err: err} + }) + break } - labels := map[string]string{ - "react": "React", - "vue": "Vue", - "nextjs": "Next.js", - "nuxt": "Nuxt", + + case guideLoadedMsg: + m.guideLoading = false + if msg.err != nil { + m.messages = append(m.messages, + Red("✗")+" Could not load the "+m.guideLabel+" guide: "+msg.err.Error(), + Dim(" Open it directly: ")+Cyan(m.guideDocURL), + ) + m.input.Focus() + break } - if fn, ok := stepsFns[msg.framework]; ok { - m.integrateSteps = fn(m.baseURL) - m.integrateFramework = labels[msg.framework] - m.integrateStepIdx = 0 - m.integrateValues = map[string]string{} - m.showIntegrate = true - m.input.Blur() - first := m.integrateSteps[0] - if first.CollectKey != "" && len(first.Code) == 0 { - m.integrateCollecting = true - m.integrateInput.SetValue("") - m.integrateInput.Placeholder = first.CollectHint - m.integrateInput.Focus() - } else { - m.integrateCollecting = false + rendered := msg.markdown + if r, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(clamp(m.width-4, 20, 100)), + ); err == nil { + if out, err := r.Render(msg.markdown); err == nil { + rendered = out } } + m.guideViewport.SetContent(rendered) + m.guideViewport.GotoTop() + m.showGuide = true case healthCheckMsg: if msg.ready { @@ -1073,16 +1148,11 @@ func (m ReplModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:cyclop,fu m.baseURL = fmt.Sprintf("http://localhost:%d", port) } m.status = statusReady - if m.showOnboarding { - // Input stays blurred; user enters command mode explicitly with / or ? - } else { + m.input.Placeholder = "Type / for commands, Ctrl+C to exit" + m.showAllOnEmpty = true + if !m.showOnboarding && !m.showNotice { + // Onboarding and the notice page own input focus until dismissed. m.input.Focus() - m.input.Placeholder = "Type / for commands, Ctrl+C to exit" - } - if m.newVersion != "" { - m.messages = append(m.messages, - Yellow("✦")+" "+Bold(product.Name+" v"+m.newVersion+" is available")+" — type "+Cyan("/upgrade")+" to upgrade", - ) } } // Always keep polling so we can detect crashes via health check. @@ -1173,7 +1243,7 @@ func (m ReplModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:cyclop,fu if hasAI { m.walkthroughPanes = agentWalkthroughPanes(msg.sampleURL) } else { - m.walkthroughPanes = b2cWalkthroughPanes(msg.sampleURL) + m.walkthroughPanes = b2cWalkthroughPanes(msg.sampleURL, m.baseURL) } m.walkthroughTab = 0 m.showWalkthrough = true @@ -1220,6 +1290,19 @@ func (m ReplModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:cyclop,fu func (m *ReplModel) updateCompletions() { val := m.input.Value() + if val == "" { + m.completions = nil + m.showCompletions = false + if m.showAllOnEmpty { + m.completions = m.commands + m.showCompletions = true + if m.selectedComp >= len(m.completions) { + m.selectedComp = 0 + } + } + return + } + m.showAllOnEmpty = false if val == "/" { m.completions = m.commands m.showCompletions = true @@ -1335,22 +1418,45 @@ var ( stopPort = func(port int) error { return setup.FreePort(port, stopTimeout) } ) -func renderCompletions(m ReplModel) string { +// minCompletionRows is the floor for the scrollable completion window, even +// on a very short terminal. +const minCompletionRows = 6 + +// completionRow is one rendered line of the completion list. itemIndex is the +// index into m.completions for a command row, or -1 for headers/spacers — +// only command rows count toward keeping the selection in view. +type completionRow struct { + text string + itemIndex int +} + +// countLines returns the number of terminal rows a rendered block occupies. +func countLines(s string) int { + if s == "" { + return 0 + } + return strings.Count(s, "\n") +} + +// renderCompletions draws the / command list, scrolling the window so the +// selected item stays visible when the list is taller than the terminal. +// available is the number of terminal rows left for this block (including +// its own separators and scroll indicators), as computed by the caller from +// what has already been rendered above it. +func renderCompletions(m ReplModel, available int) string { if !m.showCompletions || len(m.completions) == 0 { return "" } - var b strings.Builder - separator := Dim(strings.Repeat("─", clamp(m.width-2, 20, 80))) - b.WriteString(separator + "\n") const nameW = 24 + var rows []completionRow lastSection := "" for i, c := range m.completions { if c.Section != lastSection { if i > 0 { - b.WriteString("\n") + rows = append(rows, completionRow{itemIndex: -1}) } if c.Section != "" { - b.WriteString(" " + Dim(c.Section) + "\n") + rows = append(rows, completionRow{text: " " + Dim(c.Section), itemIndex: -1}) } lastSection = c.Section } @@ -1367,12 +1473,64 @@ func renderCompletions(m ReplModel) string { namePart = Dim(fmt.Sprintf("%-*s", nameW, c.Name)) descPart = Dim(c.Description) } - b.WriteString(" " + indicator + namePart + " " + descPart + "\n") + rows = append(rows, completionRow{text: " " + indicator + namePart + " " + descPart, itemIndex: i}) + } + + selectedRow := 0 + for idx, r := range rows { + if r.itemIndex == m.selectedComp { + selectedRow = idx + break + } + } + + maxRows := clamp(available-2, minCompletionRows, len(rows)) + if len(rows) > maxRows { + // Reserve room for the "more above"/"more below" indicator lines. + maxRows = clamp(maxRows-2, minCompletionRows, len(rows)) + } + start := 0 + if len(rows) > maxRows { + start = selectedRow - maxRows/2 + if start < 0 { + start = 0 + } + if start+maxRows > len(rows) { + start = len(rows) - maxRows + } + } + end := start + maxRows + if end > len(rows) { + end = len(rows) + } + + var b strings.Builder + separator := Dim(strings.Repeat("─", BannerWidth())) + b.WriteString(separator + "\n") + if start > 0 { + b.WriteString(" " + Dim("↑ more above") + "\n") + } + for _, r := range rows[start:end] { + b.WriteString(r.text + "\n") + } + if end < len(rows) { + b.WriteString(" " + Dim("↓ more below") + "\n") } b.WriteString(separator + "\n") return b.String() } +// renderNotice draws a standalone page for m.noticeMessage — used instead of +// dropping it into the scrolling message log so it can't be missed or pushed +// off-screen, with its own dismiss hint. +func renderNotice(m ReplModel) string { + var b strings.Builder + b.WriteString(Dim(strings.Repeat("─", clamp(m.width-4, 20, 76))) + "\n\n") + b.WriteString(" " + m.noticeMessage + "\n\n") + b.WriteString(Dim(" esc dismiss • / for commands") + "\n") + return b.String() +} + func renderWalkthrough(m ReplModel) string { if len(m.walkthroughPanes) == 0 { return "" @@ -1410,155 +1568,16 @@ func renderWalkthrough(m ReplModel) string { return b.String() } -func integrateWalkthroughPanes(framework, baseURL string) []walkthroughPane { - return []walkthroughPane{ - { - Title: "What's Next", - Lines: []string{ - Green("✓") + " Your " + framework + " app is wired to ThunderID.", - "", - " " + Cyan("Flow Designer") + " " + Dim("— add MFA, passkeys, or social login"), - " " + Cyan(framework+" SDK Docs") + " " + Dim("— full API reference"), - "", - Dim(" Open the console to get started →"), - }, - URL: baseURL + "/console", - }, - { - Title: "Find Your Client ID", - Lines: []string{ - " Open the ThunderID Console and navigate to:", - "", - " " + Bold("Applications") + " → " + Bold("your app") + " → " + Bold("Client ID"), - }, - URL: baseURL + "/console", - }, - } -} - -// substituteCodeLine replaces {{.KEY}} tokens in a code line with styled values. -// If a key has a collected value it is rendered in brand blue; otherwise a dim -// placeholder is shown so the code block still makes sense before collection. -func substituteCodeLine(line string, values map[string]string) string { - codeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) - blueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorBrandBlue)) - dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorGrey)) - - var result strings.Builder - remaining := line - for { - start := strings.Index(remaining, "{{.") - if start == -1 { - result.WriteString(codeStyle.Render(remaining)) - break - } - if start > 0 { - result.WriteString(codeStyle.Render(remaining[:start])) - } - rest := remaining[start+3:] - end := strings.Index(rest, "}}") - if end == -1 { - result.WriteString(codeStyle.Render(remaining[start:])) - break - } - key := rest[:end] - if val, ok := values[key]; ok && val != "" { - result.WriteString(blueStyle.Render(val)) - } else { - result.WriteString(dimStyle.Render("")) - } - remaining = rest[end+2:] - } - return result.String() -} - -// renderCodeBlock draws a bordered box containing syntax-highlighted code lines. -// Lines with {{.KEY}} tokens are substituted from values. -func renderCodeBlock(codeFile string, lines []string, values map[string]string, boxWidth int) string { - borderStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorGrey)) - codeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) - - innerWidth := boxWidth - 4 // subtract "│ " and " │" - - // Top border with filename label. - labelPart := "─ " + codeFile + " " - dashCount := clamp(innerWidth-len(labelPart), 0, innerWidth) - top := "┌" + labelPart + strings.Repeat("─", dashCount) + "─┐" - +// renderGuide draws the fetched integration guide (glamour-rendered markdown) +// inside a scrollable viewport, with a header naming the platform and doc URL. +// It replaces the REPL's entire screen (see render()), so its chrome is exactly +// the 3 rows guideViewport's height is sized against: header, separator, hint. +func renderGuide(m ReplModel) string { var b strings.Builder - b.WriteString(" " + borderStyle.Render(top) + "\n") - - for _, line := range lines { - var rendered string - if strings.Contains(line, "{{.") { - rendered = substituteCodeLine(line, values) - } else { - rendered = codeStyle.Render(line) - } - visWidth := lipgloss.Width(rendered) - padding := clamp(innerWidth-visWidth, 0, innerWidth) - b.WriteString(" " + borderStyle.Render("│") + " " + rendered + strings.Repeat(" ", padding) + " " + borderStyle.Render("│") + "\n") - } - - bottom := "└" + strings.Repeat("─", innerWidth+2) + "┘" - b.WriteString(" " + borderStyle.Render(bottom) + "\n") - return b.String() -} - -func renderIntegrate(m ReplModel) string { - if len(m.integrateSteps) == 0 { - return "" - } - var b strings.Builder - - total := len(m.integrateSteps) - idx := m.integrateStepIdx - step := m.integrateSteps[idx] - - // Progress header. - progress := fmt.Sprintf("Step %d of %d", idx+1, total) - b.WriteString(" " + Dim(m.integrateFramework+" Integration") + " " + Dim("·") + " " + Bold(progress) + "\n") - b.WriteString(" " + Dim(strings.Repeat("─", clamp(m.width-4, 20, 76))) + "\n\n") - - // Step title and body. - b.WriteString(" " + Bold(step.Title) + "\n\n") - for _, line := range step.Body { - b.WriteString(" " + Dim(line) + "\n") - } - if len(step.Body) > 0 { - b.WriteString("\n") - } - - if m.integrateCollecting { - // Collect prompt — mirrors showUsecaseConfig style. - b.WriteString(" " + Bold(step.CollectLabel) + "\n\n") - if step.CollectURL != "" { - b.WriteString(" " + Cyan(step.CollectURL) + "\n\n") - } - b.WriteString(" " + Dim(step.CollectHint) + "\n") - b.WriteString(" " + Dim("(press Esc to skip — you can set it in src/main.jsx later)") + "\n\n") - b.WriteString(m.integrateInput.View() + "\n") - b.WriteString("\n" + Dim(" Enter to continue")) - } else { - // Code block. - boxWidth := clamp(m.width-6, 50, 78) - b.WriteString(renderCodeBlock(step.CodeFile, step.Code, m.integrateValues, boxWidth)) - b.WriteString("\n") - - // Key hints. - hint := "" - if step.CollectKey != "" && m.integrateValues[step.CollectKey] == "" { - hint = Dim(" Enter to set Client ID • ") - } else { - hint = Dim(" Enter to continue • ") - } - if idx > 0 { - hint += Dim("← back • ") - } - hint += Dim("esc dismiss") - b.WriteString(hint + "\n") - } - + b.WriteString(" " + Dim(m.guideLabel+" Integration Guide") + " " + Dim("·") + " " + Cyan(m.guideDocURL) + "\n") + b.WriteString(" " + Dim(strings.Repeat("─", clamp(m.width-4, 20, 76))) + "\n") + b.WriteString(m.guideViewport.View() + "\n") + b.WriteString(Dim(" ↑/↓ scroll • o open in browser • esc back • / for commands")) return b.String() } @@ -1643,6 +1662,12 @@ func (m ReplModel) render() string { if m.quitting { return Dim("Stopping " + product.Name + "...\n") } + // The guide viewer takes the whole terminal instead of sharing it with the + // scrollable body: splitting the height between the two would force the body + // below its minimum on short terminals, overflowing the screen. + if m.showGuide { + return renderGuide(m) + } footer := m.footer() if !m.bodySized { // No window size yet (first frame): render flat rather than guessing a height. @@ -1656,13 +1681,16 @@ func (m ReplModel) render() string { func (m ReplModel) bodyContent() string { var b strings.Builder - b.WriteString(BannerString() + "\n") + b.WriteString(BannerString(m.version) + "\n\n") if m.nodeWarning != "" { b.WriteString(fitBox(noteBoxStyle, noteChrome, Yellow("⚠ "+m.nodeWarning)) + "\n\n") } - b.WriteString(Bold("⚡ "+product.Name+" v"+m.version) + "\n") + if m.newVersion != "" && m.status == statusReady { + b.WriteString(Yellow("✦") + " " + Bold(product.Name+" v"+m.newVersion+" is available") + " — type " + Cyan("/upgrade") + " to upgrade\n\n") + } + switch m.status { case statusStarting: b.WriteString(m.spinner.View() + " Starting...\n") @@ -1674,7 +1702,12 @@ func (m ReplModel) bodyContent() string { if box := m.credentialsBox(); box != "" { b.WriteString(box + "\n") } - b.WriteString(Dim(strings.Repeat("─", clamp(m.width-2, 20, 80))) + "\n\n") + b.WriteString(Dim(strings.Repeat("─", BannerWidth())) + "\n\n") + + if m.showNotice { + b.WriteString(renderNotice(m)) + return b.String() + } if m.showOnboarding && m.status == statusReady { if !m.onboardingCmdMode { @@ -1707,10 +1740,9 @@ func (m ReplModel) bodyContent() string { b.WriteString("\n") } - // The guides carry their own navigation hints, so they scroll with the output. - if m.showIntegrate { - b.WriteString(renderIntegrate(m)) - } else if m.showWalkthrough { + // The walkthrough carries its own navigation hints, so it scrolls with the output. + // The guide is rendered in the footer instead, since it scrolls in its own viewport. + if m.showWalkthrough { b.WriteString(renderWalkthrough(m)) } return b.String() @@ -1721,9 +1753,15 @@ func (m ReplModel) bodyContent() string { func (m ReplModel) footer() string { var b strings.Builder + if m.showNotice { + return "" + } + if m.showOnboarding && m.status == statusReady { if m.onboardingCmdMode { - b.WriteString(renderCompletions(m)) + // Reserve 3 rows below for the input line and the trailing hint. + available := m.height - countLines(b.String()) - 3 + b.WriteString(renderCompletions(m, available)) b.WriteString(m.input.View()) b.WriteString("\n\n" + Dim(" esc back to use-case picker")) } else { @@ -1752,20 +1790,33 @@ func (m ReplModel) footer() string { return b.String() } - if m.showIntegrate || m.showWalkthrough { + if m.guideLoading { + b.WriteString(m.spinner.View() + Dim(" Fetching "+m.guideLabel+" guide…")) + return b.String() + } + + if m.showWalkthrough { return Dim(" " + scrollHint) } - b.WriteString(renderCompletions(m)) + // While a try-* sample is downloading/starting, its progress replaces the command + // menu and input line entirely rather than being appended below them. + if m.tryingOut { + if m.trySampleStatus != "" { + b.WriteString(m.spinner.View() + " " + m.trySampleStatus) + } else { + b.WriteString(m.spinner.View() + Dim(" Please wait… (Ctrl+C to abort)")) + } + return b.String() + } + + // Reserve 2 rows below for the input/spinner line. + available := m.height - countLines(b.String()) - 2 + b.WriteString(renderCompletions(m, available)) - switch { - case m.tryingOut && m.trySampleStatus != "": - b.WriteString(m.spinner.View() + " " + m.trySampleStatus) - case m.tryingOut: - b.WriteString(m.spinner.View() + Dim(" Please wait… (Ctrl+C to abort)")) - case m.status == statusStarting: + if m.status == statusStarting { b.WriteString(m.spinner.View() + Dim(" Starting "+product.Name+"…")) - default: + } else { b.WriteString(m.input.View()) b.WriteString("\n" + Dim(" "+scrollHint)) } @@ -1789,14 +1840,23 @@ func clamp(v, min, max int) int { // newVersion, if non-empty, causes a banner to appear prompting the user to /upgrade. // nodeWarning, if non-empty, is shown below the banner for the life of the session. // port overrides the default health-check port when non-zero. +// notice, if non-empty, is shown as the first message — used to surface why a prior +// /switch or /upgrade attempt in this same process didn't go through, since printing +// it directly would be hidden the instant this REPL's alternate screen takes over. // Returns upgradeRequested=true when the user ran /upgrade, switchRequested=true when /use. func RunREPL( version string, proc *exec.Cmd, installPath string, verbose, isFirstRun bool, newVersion, nodeWarning string, port int, creds *setup.AdminCredentials, + notice string, ) (upgradeRequested, switchRequested bool, err error) { m := NewReplModel(version, proc, installPath, verbose, isFirstRun, creds) m.newVersion = newVersion m.nodeWarning = nodeWarning + if notice != "" { + m.showNotice = true + m.noticeMessage = notice + m.input.Blur() + } if port > 0 { m.checkPort = port } diff --git a/tools/cli/internal/ui/usecases.go b/tools/cli/internal/ui/usecases.go index 61c35acadf..64d6edf105 100644 --- a/tools/cli/internal/ui/usecases.go +++ b/tools/cli/internal/ui/usecases.go @@ -37,15 +37,15 @@ type Usecase struct { var Usecases = []Usecase{ { Emoji: "👤", - Title: "Consumer Login (B2C)", - Description: "Sign in with email, social providers, passkeys and MFA", + Title: "Secured Web Application", + Description: "Tryout user journeys of a secured web application", SampleName: "wayfinder", Command: "/try-consumer", }, { Emoji: "🤖", - Title: "Agent Login (AgentID)", - Description: "Secure access for AI agents and automated workflows acting on behalf of users", + Title: "Secured AI Agent", + Description: "Tryout identity security patterns for AI agents and tools", SampleName: "wayfinder", Command: "/try-agentid", RequiredConfigs: []ConfigInput{