diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d03ed32e..16d6e1ec 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -110,6 +110,7 @@ import uploadsRoutes from './routes/uploads.js' import { adminInvitationRoutes, publicInvitationRoutes } from './routes/user-invitations.js' import userLookupRoutes from './routes/user-lookup.js' import usersRoutes from './routes/users.js' +import versionRoutes from './routes/version.js' // Provider CLIs are installed at runtime under A2WAVE_CLI_INSTALL_ROOT; make sure // that root is on PATH before anything spawns a CLI. The image bakes in the @@ -200,6 +201,8 @@ app.get('/api/openapi.json', (c) => c.json(openApiSpec)) // --- Public routes (no auth) --- app.route('/api/health', healthRoutes) +// Public on purpose: the login footer renders the version before any session exists. +app.route('/api/version', versionRoutes) app.route('/api/changelog', changelogRoutes) app.route('/api/gateway', gatewayRoutes) app.route('/api/oauth', oauthGatewayRoutes) diff --git a/apps/api/src/lib/version.ts b/apps/api/src/lib/version.ts new file mode 100644 index 00000000..372a4332 --- /dev/null +++ b/apps/api/src/lib/version.ts @@ -0,0 +1,18 @@ +import { execSync } from 'node:child_process' + +/** + * The running build's version. + * + * `APP_VERSION` is what container images set at build time; a source checkout + * has no such env, so fall back to the tag `git describe` reports. Neither + * available (an unpacked tarball, say) degrades to 'dev' rather than throwing — + * callers render this string, they do not depend on it. + */ +export function getVersion(): string { + if (process.env.APP_VERSION) return process.env.APP_VERSION + try { + return execSync('git describe --tags --always').toString().trim() + } catch { + return 'dev' + } +} diff --git a/apps/api/src/routes/__tests__/version.test.ts b/apps/api/src/routes/__tests__/version.test.ts new file mode 100644 index 00000000..d131a32b --- /dev/null +++ b/apps/api/src/routes/__tests__/version.test.ts @@ -0,0 +1,67 @@ +import { Hono } from 'hono' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const execSyncMock = vi.fn() +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process') + return { + ...actual, + execSync: (...args: unknown[]) => execSyncMock(...args), + } +}) + +import version from '../version.js' + +beforeEach(() => { + execSyncMock.mockReset() + delete process.env.APP_VERSION +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +function buildApp() { + return new Hono().route('/version', version) +} + +describe('routes/version', () => { + it('returns the version resolved from `git describe`', async () => { + execSyncMock.mockReturnValue(Buffer.from('v1.2.3\n')) + + const res = await buildApp().request('/version') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ version: 'v1.2.3' }) + }) + + it('prefers APP_VERSION env over `git describe`', async () => { + process.env.APP_VERSION = '9.9.9' + + const res = await buildApp().request('/version') + expect(await res.json()).toEqual({ version: '9.9.9' }) + expect(execSyncMock).not.toHaveBeenCalled() + }) + + it('falls back to "dev" outside a git checkout', async () => { + execSyncMock.mockImplementation(() => { + throw new Error('not a git repo') + }) + + const res = await buildApp().request('/version') + expect(await res.json()).toEqual({ version: 'dev' }) + }) + + /** + * The login page is unauthenticated, so this endpoint must answer without a + * session — and must stay a bare version string. Anything heavier (DB, disk, + * engine probes) belongs on /health, which is what this route exists to avoid + * dragging into a public page load. + */ + it('answers without credentials and exposes nothing but the version', async () => { + execSyncMock.mockReturnValue(Buffer.from('v1.2.3\n')) + + const res = await buildApp().request('/version') + expect(res.status).toBe(200) + expect(Object.keys((await res.json()) as object)).toEqual(['version']) + }) +}) diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts index c4ac1f4b..4804b1ff 100644 --- a/apps/api/src/routes/health.ts +++ b/apps/api/src/routes/health.ts @@ -1,5 +1,4 @@ -import { execSync } from 'node:child_process' -import { constants, accessSync } from 'node:fs' +import { accessSync, constants } from 'node:fs' import { dirname } from 'node:path' import { count } from 'drizzle-orm' import { Hono } from 'hono' @@ -9,15 +8,7 @@ import { runs } from '../db/schema.js' import { engineRegistry } from '../engine/index.js' import { env } from '../env.js' import { isReady } from '../lib/readiness.js' - -function getVersion(): string { - if (process.env.APP_VERSION) return process.env.APP_VERSION - try { - return execSync('git describe --tags --always').toString().trim() - } catch { - return 'dev' - } -} +import { getVersion } from '../lib/version.js' async function checkDatabase(): Promise<{ ok: boolean; error?: string; tables?: number }> { try { diff --git a/apps/api/src/routes/version.ts b/apps/api/src/routes/version.ts new file mode 100644 index 00000000..5b5dcaf4 --- /dev/null +++ b/apps/api/src/routes/version.ts @@ -0,0 +1,17 @@ +import { Hono } from 'hono' +import { getVersion } from '../lib/version.js' + +const app = new Hono() + +/** + * GET /api/version — the running build's version, and nothing else. + * + * Split out from `/health` because the version is displayed on unauthenticated + * surfaces (the login footer, the About dialog). `/health` answers the same + * question, but only after probing the database, both data directories and + * every execution engine — far too much work, and far too much detail, for + * rendering a string on a public page load. + */ +app.get('/', (c) => c.json({ version: getVersion() })) + +export default app diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 2744f4c3..124f2ea6 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { defineCommand, runCommand, showUsage } from 'citty' +import { defineCommand, runCommand } from 'citty' import { agentsCommand } from './commands/agents.js' import { apiCommand } from './commands/api.js' import { channelsCommand } from './commands/channels.js' @@ -25,6 +25,7 @@ import { updateCommand } from './commands/update.js' import { whoamiCommand } from './commands/whoami.js' import { CliError, toErrorEnvelope } from './errors.js' import { readAgentMeta } from './lib/agent-meta.js' +import { renderUsage } from './lib/render-usage.js' import { setRootCommand } from './lib/root-registry.js' import { getVersion } from './version.js' @@ -186,16 +187,22 @@ function resolveForUsage(root: CommandNode, rawArgs: string[]): [CommandNode, Co } /** - * citty's usage, followed by the node's risk label. + * The usage page, followed by the node's risk label. * - * Appended rather than woven in: `renderUsage` is citty's, and reimplementing - * it to insert one line would put the whole usage layout under our maintenance - * for the sake of a suffix. Only leaves carry a label — a group node does no - * work of its own, so a risk there would have to be the max of its children, - * which is a number nobody maintains. + * `renderUsage` is ours rather than citty's: citty padded the last column to + * the widest description, so a 250-character `api` description forced every + * row of `a2wave --help` out to 332 columns and each one wrapped into a + * blank-looking second line. See src/lib/render-usage.ts. + * + * Printed with `console.log` rather than through consola, which suppresses + * output whenever it believes it is under test. + * + * Only leaves carry a risk label — a group node does no work of its own, so a + * risk there would have to be the max of its children, which is a number + * nobody maintains. */ async function showUsageWithRisk(cmd: CommandNode, parent?: CommandNode): Promise { - await showUsage(cmd as never, parent as never) + console.log(`${await renderUsage(cmd, parent)}\n`) const risk = readAgentMeta(cmd)?.risk if (risk) console.log(`Risk: ${risk}`) } diff --git a/apps/cli/src/lib/__tests__/render-usage.test.ts b/apps/cli/src/lib/__tests__/render-usage.test.ts new file mode 100644 index 00000000..18338077 --- /dev/null +++ b/apps/cli/src/lib/__tests__/render-usage.test.ts @@ -0,0 +1,155 @@ +import { defineCommand } from 'citty' +import { describe, expect, it } from 'vitest' +import { renderUsage } from '../render-usage.js' + +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escapes requires ESC. +const ANSI = /\u001b\[[0-9;]*m/g +const strip = (value: string) => value.replace(ANSI, '') + +const leaf = defineCommand({ + meta: { name: 'whoami', description: 'Show the identity and instance' }, + run() {}, +}) + +const root = defineCommand({ + meta: { name: 'a2wave', version: '0.7.3', description: 'a2wave command-line tool' }, + subCommands: { + whoami: leaf, + setup: defineCommand({ + meta: { + name: 'setup', + description: + 'Install a local a2wave platform: generate .env + docker-compose.yml, start the container, and wait until healthy. Use --upgrade to move an existing image, or --down to uninstall.', + }, + run() {}, + }), + hidden: defineCommand({ + meta: { name: 'hidden', description: 'Not for humans', hidden: true }, + run() {}, + }), + }, +}) + +const withArgs = defineCommand({ + meta: { name: 'list', description: 'List runs' }, + args: { + agent: { type: 'string', description: 'Filter by Agent' }, + limit: { type: 'string', description: 'Rows per page', default: '20' }, + json: { type: 'boolean', description: 'Emit the raw payload as compact JSON' }, + id: { type: 'positional', description: 'The run id' }, + }, + run() {}, +}) + +describe('renderUsage', () => { + it('never emits trailing whitespace on any line', async () => { + // The defect this whole module exists for: citty padded the last column to + // the widest description, wrapping every row into a blank-looking line. + const out = await renderUsage(root, undefined, 100) + for (const line of out.split('\n')) { + expect(strip(line)).toBe(strip(line).trimEnd()) + } + }) + + it('never emits a line wider than the given width', async () => { + for (const width of [60, 80, 100]) { + const out = await renderUsage(root, undefined, width) + for (const line of out.split('\n')) { + expect(strip(line).length).toBeLessThanOrEqual(width) + } + } + }) + + it('lists subcommands under a COMMANDS heading, left-aligned', async () => { + const out = strip(await renderUsage(root, undefined, 100)) + expect(out).toContain('COMMANDS') + expect(out).toMatch(/^ {2}whoami {2,}Show the identity and instance$/m) + }) + + it('keeps the full text of a long description rather than truncating it', async () => { + const out = strip(await renderUsage(root, undefined, 100)) + expect(out).toContain('Install a local a2wave platform') + expect(out).toContain('--down to uninstall.') + }) + + it('omits hidden subcommands', async () => { + const out = strip(await renderUsage(root, undefined, 100)) + expect(out).not.toContain('Not for humans') + }) + + it('renders the description and version header', async () => { + const out = strip(await renderUsage(root, undefined, 100)) + expect(out).toContain('a2wave command-line tool') + expect(out).toContain('v0.7.3') + }) + + it('prefixes the usage line with the parent command name', async () => { + const out = strip(await renderUsage(leaf, root, 100)) + expect(out).toContain('USAGE a2wave whoami') + }) + + it('never leaks an absolute script path into the usage line', async () => { + // citty falls back to process.argv[1] when meta.name is missing, which + // printed the published binary's full path on every --help. + const nameless = defineCommand({ meta: { description: 'no name' }, run() {} }) + const out = strip(await renderUsage(nameless, undefined, 100)) + expect(out).not.toContain('/') + }) + + it('renders OPTIONS and ARGUMENTS with their hints', async () => { + const out = strip(await renderUsage(withArgs, root, 100)) + expect(out).toContain('ARGUMENTS') + expect(out).toContain('OPTIONS') + expect(out).toContain('--agent') + expect(out).toContain('(Default: 20)') + expect(out).toMatch(/ID\b/) + }) + + it('lists the subcommand names on the USAGE line', async () => { + const out = strip(await renderUsage(root, undefined, 100)) + expect(out).toContain('whoami') + expect(out).not.toContain('hidden|') + }) + + it('wraps a long pipe-joined subcommand list, which carries no spaces to break on', async () => { + // The real root has 24 subcommands joined by `|` — one 165-character + // "word" that word-wrapping alone cannot split, so it ran off the edge. + const many = defineCommand({ + meta: { name: 'a2wave', description: 'many' }, + subCommands: Object.fromEntries( + Array.from({ length: 24 }, (_, i) => [ + `command-number-${i}`, + defineCommand({ meta: { name: `command-number-${i}`, description: 'x' }, run() {} }), + ]), + ), + }) + for (const width of [60, 80, 100]) { + const lines = strip(await renderUsage(many, undefined, width)).split('\n') + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(width) + } + // Every name still appears, and the separator is not lost in the break. + const usage = lines.join('') + for (let i = 0; i < 24; i++) expect(usage).toContain(`command-number-${i}`) + } + }) + + it('wraps the multi-paragraph root description without mangling its blank lines', async () => { + const framed = defineCommand({ + meta: { + name: 'a2wave', + description: 'a2wave command-line tool\n\nAGENT QUICKSTART\n 1. a2wave schema list paths', + }, + subCommands: { whoami: leaf }, + }) + const out = strip(await renderUsage(framed, undefined, 100)) + expect(out).toContain('AGENT QUICKSTART') + // The preformatted block keeps its own indentation. + expect(out).toMatch(/^ {2}1\. a2wave schema {2}list paths$/m) + }) + + it('ends without a trailing blank run', async () => { + const out = await renderUsage(root, undefined, 100) + expect(out.endsWith('\n')).toBe(false) + }) +}) diff --git a/apps/cli/src/lib/__tests__/usage.test.ts b/apps/cli/src/lib/__tests__/usage.test.ts new file mode 100644 index 00000000..96ea8c99 --- /dev/null +++ b/apps/cli/src/lib/__tests__/usage.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest' +import { formatColumns, HELP_FALLBACK_WIDTH, helpWidth, wrapText } from '../usage.js' + +describe('wrapText', () => { + it('returns a single line when the text fits', () => { + expect(wrapText('short enough', 40)).toEqual(['short enough']) + }) + + it('breaks on word boundaries, never mid-word', () => { + const lines = wrapText('alpha beta gamma delta', 12) + expect(lines).toEqual(['alpha beta', 'gamma delta']) + }) + + it('never exceeds the requested width', () => { + const text = + 'Install a local a2wave platform: generate .env + docker-compose.yml, start the container, and wait until healthy.' + for (const line of wrapText(text, 48)) { + expect(line.length).toBeLessThanOrEqual(48) + } + }) + + it('keeps a word longer than the width on its own line rather than truncating it', () => { + // A URL or a flag spelling can exceed the column; losing characters would + // make the help lie, so an over-long word overflows instead. + expect(wrapText('see https://example.com/a/very/long/path now', 10)).toEqual([ + 'see', + 'https://example.com/a/very/long/path', + 'now', + ]) + }) + + it('collapses the runs of whitespace a description may carry', () => { + expect(wrapText('alpha beta\n\ngamma', 40)).toEqual(['alpha beta gamma']) + }) + + it('returns no lines for empty text, so a description-less row prints bare', () => { + expect(wrapText(' ', 40)).toEqual([]) + }) +}) + +describe('formatColumns', () => { + it('left-aligns names and separates them from descriptions by a single gutter', () => { + const out = formatColumns([['schema', 'Machine-readable spec']], 80) + expect(out).toBe(' schema Machine-readable spec') + }) + + it('pads names to a common width so descriptions line up', () => { + const out = formatColumns( + [ + ['docs', 'Print the agent guide'], + ['skill-groups', 'Manage Skill Groups'], + ], + 80, + ) + expect(out.split('\n')).toEqual([ + ' docs Print the agent guide', + ' skill-groups Manage Skill Groups', + ]) + }) + + it('emits no trailing whitespace — the defect that made every row wrap', () => { + // citty padded the LAST column to the widest description, so a 250-char + // description forced every row out to 332 columns and the terminal wrapped + // the padding into a blank-looking second line. + const out = formatColumns( + [ + ['a', 'short'], + ['b', 'a considerably longer description than the other row carries'], + ], + 120, + ) + for (const line of out.split('\n')) { + expect(line).toBe(line.trimEnd()) + } + }) + + it('wraps a long description with a hanging indent under the description column', () => { + const words = 'alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi' + const lines = formatColumns([['setup', words]], 50).split('\n') + + expect(lines.length).toBeGreaterThan(1) + // First line carries the name; every continuation is indented to sit under + // the description column, so the block reads as one paragraph. + expect(lines[0]).toMatch(/^ {2}setup {2}alpha/) + const descColumn = lines[0].indexOf('alpha') + for (const line of lines.slice(1)) { + expect(line.slice(0, descColumn)).toBe(' '.repeat(descColumn)) + expect(line[descColumn]).not.toBe(' ') + } + // Every word survives the wrap, in order. + expect(lines.join(' ').split(/\s+/).filter(Boolean).slice(1).join(' ')).toBe(words) + }) + + it('never emits a line wider than the terminal', () => { + const rows: Array<[string, string]> = [ + [ + 'api', + 'Call any a2wave API endpoint directly (raw HTTP escape hatch). Prefer the typed command when one exists: it validates parameters, resolves names to ids, and applies the right risk label.', + ], + ['kb', 'Manage knowledge base documents (KB Document)'], + ] + for (const width of [60, 80, 100, 120]) { + for (const line of formatColumns(rows, width).split('\n')) { + expect(line.length).toBeLessThanOrEqual(width) + } + } + }) + + it('measures the name column by its visible text, ignoring ANSI colour', () => { + // Names are colourised before they reach the formatter; padding on the + // escape-bearing string would over-count and break the alignment. + const out = formatColumns([['\u001b[36mdocs\u001b[39m', 'Print the agent guide']], 80) + // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escapes requires ESC. + expect(out.replace(/\u001b\[[0-9;]*m/g, '')).toBe(' docs Print the agent guide') + }) + + it('keeps the description on one line per row when it fits', () => { + const out = formatColumns([['runs', 'Manage runs']], 80) + expect(out.split('\n')).toHaveLength(1) + }) + + it('stops widening the name column when one name is pathologically long', () => { + // A single long name must not push every description off the right edge; + // it takes its own line instead. + const out = formatColumns( + [ + ['a-very-long-command-name-that-eats-the-line', 'does a thing'], + ['ok', 'fine'], + ], + 50, + ) + for (const line of out.split('\n')) { + expect(line.length).toBeLessThanOrEqual(50) + } + expect(out).toContain('does a thing') + }) +}) + +describe('helpWidth', () => { + it('falls back to a fixed width when stdout is not a TTY, so agent output is stable', () => { + expect(helpWidth({ isTTY: false, columns: undefined })).toBe(HELP_FALLBACK_WIDTH) + expect(HELP_FALLBACK_WIDTH).toBe(100) + }) + + it('uses the terminal width when attached to a narrower TTY', () => { + expect(helpWidth({ isTTY: true, columns: 72 })).toBe(72) + }) + + it('caps a very wide terminal, since a full-width line is unreadable prose', () => { + expect(helpWidth({ isTTY: true, columns: 400 })).toBe(HELP_FALLBACK_WIDTH) + }) + + it('never returns a width so narrow that the layout collapses', () => { + expect(helpWidth({ isTTY: true, columns: 10 })).toBeGreaterThanOrEqual(40) + }) +}) diff --git a/apps/cli/src/lib/render-usage.ts b/apps/cli/src/lib/render-usage.ts new file mode 100644 index 00000000..5049d0f1 --- /dev/null +++ b/apps/cli/src/lib/render-usage.ts @@ -0,0 +1,227 @@ +/** + * The `--help` page, replacing citty's `renderUsage`. + * + * citty's version had three defects that compounded into an unreadable page: + * it padded the LAST column to the widest description (a 250-character `api` + * description forced all 24 rows out to 332 columns, so every terminal + * narrower than that wrapped the trailing whitespace into a blank-looking + * second line — the list read as double-spaced), it never consulted the + * terminal width (long descriptions broke mid-sentence at column 0, losing the + * column alignment entirely), and it right-aligned command names so they + * stepped in and out raggedly. + * + * Reimplemented rather than post-processed: the padding is applied inside + * citty's `formatLineColumns`, so there is no seam to patch from outside, and + * "strip the trailing spaces afterwards" cannot recover text that was already + * wrapped at the wrong width. The layout matches what every mainstream CLI + * uses (claude, codex, lark-cli): two-space indent, left-aligned names, one + * gutter, and descriptions wrapped with a hanging indent. + * + * citty's own `resolveArgs` / `resolveValue` are internal — absent from the + * CJS bundle's exports, so importing them typechecks and then throws at + * runtime (see apps/cli/CLAUDE.md). They are reproduced here. + */ + +import { formatColumns, helpWidth, wrapText } from './usage.js' + +const colors = { + cyan: (s: string) => `${s}`, + gray: (s: string) => `${s}`, + bold: (s: string) => `${s}`, + underline: (s: string) => `${s}`, +} + +/** citty's own heading style: bold + underlined. */ +function heading(text: string): string { + return colors.underline(colors.bold(text)) +} + +type ArgDef = { + type?: string + description?: string + alias?: string | string[] + default?: unknown + required?: boolean + valueHint?: string + options?: string[] + negativeDescription?: string +} + +type Meta = { + name?: string + description?: string + version?: string + hidden?: boolean + alias?: string | string[] +} + +/** citty's own `Resolvable`: a value, a promise of one, or a thunk returning either. */ +type Resolvable = T | Promise | (() => T) | (() => Promise) + +export type UsageNode = { + meta?: Resolvable + args?: Resolvable> + subCommands?: Resolvable>> +} + +async function resolveValue(input: Resolvable | undefined): Promise { + return typeof input === 'function' ? await (input as () => T | Promise)() : await input +} + +function toArray(value: string | string[] | undefined): string[] { + if (Array.isArray(value)) return value + return value === undefined ? [] : [value] +} + +/** citty parses `--no-x` as a negation of `x`, so `no-`-prefixed names are not re-listed. */ +const NEGATIVE_PREFIX = /^no[-A-Z]/ + +function snakeCase(name: string): string { + return name + .replace(/-/g, '_') + .replace(/([a-z])([A-Z])/g, '$1_$2') + .toLowerCase() +} + +function renderValueHint(arg: ArgDef & { name: string }): string { + const valueHint = arg.valueHint ? `=<${arg.valueHint}>` : '' + if (!arg.type || arg.type === 'positional' || arg.type === 'boolean') return valueHint + if (arg.type === 'enum' && arg.options?.length) return `=<${arg.options.join('|')}>` + return valueHint || `=<${snakeCase(arg.name)}>` +} + +function renderDescription(arg: ArgDef, required: boolean): string { + return [ + arg.description, + required ? '(Required)' : '', + arg.default === undefined ? '' : `(Default: ${arg.default})`, + ] + .filter(Boolean) + .join(' ') +} + +/** + * A meta description may be a hand-formatted block (the root command's AGENT + * QUICKSTART is an indented, numbered list). Reflowing that would destroy its + * alignment, so a line that is indented or already fits is passed through + * verbatim; only long flush-left prose is wrapped. + */ +function renderBlock(text: string, width: number): string[] { + return text.split('\n').flatMap((line) => { + if (line.trim() === '') return [''] + if (line.length <= width) return [line] + if (/^\s/.test(line)) return [line] + return wrapText(line, width) + }) +} + +/** + * Render the help page for `cmd`. + * + * `width` is injectable so tests can pin the layout without depending on the + * terminal running them. + */ +export async function renderUsage( + cmd: UsageNode, + parent?: UsageNode, + width: number = helpWidth(), +): Promise { + const cmdMeta = (await resolveValue(cmd.meta)) || {} + const parentMeta = (await resolveValue(parent?.meta)) || {} + const argsDef = (await resolveValue(cmd.args)) || {} + + // Never fall back to process.argv[1] the way citty does — that printed the + // published binary's absolute path into the usage line of every command + // whose meta.name was missing. + const commandName = [parentMeta.name, cmdMeta.name].filter(Boolean).join(' ') + + const argLines: Array<[string, string]> = [] + const posLines: Array<[string, string]> = [] + const usageLine: string[] = [] + + for (const [name, argDef] of Object.entries(argsDef)) { + const arg = { ...argDef, name } + if (arg.type === 'positional') { + const label = name.toUpperCase() + const isRequired = arg.required !== false && arg.default === undefined + posLines.push([colors.cyan(label + renderValueHint(arg)), renderDescription(arg, isRequired)]) + usageLine.push(isRequired ? `<${label}>` : `[${label}]`) + continue + } + const isRequired = arg.required === true && arg.default === undefined + const spelling = + [...toArray(arg.alias).map((a) => `-${a}`), `--${name}`].join(', ') + renderValueHint(arg) + argLines.push([colors.cyan(spelling), renderDescription(arg, isRequired)]) + + // A boolean that defaults on can only be turned off through --no-, + // so that spelling is listed too — otherwise it is undiscoverable. + if ( + arg.type === 'boolean' && + (arg.default === true || arg.negativeDescription) && + !NEGATIVE_PREFIX.test(name) + ) { + const negative = [...toArray(arg.alias).map((a) => `--no-${a}`), `--no-${name}`].join(', ') + argLines.push([ + colors.cyan(negative), + [arg.negativeDescription, isRequired ? '(Required)' : ''].filter(Boolean).join(' '), + ]) + } + if (isRequired) usageLine.push(`--${name}${renderValueHint(arg)}`) + } + + const commandLines: Array<[string, string]> = [] + if (cmd.subCommands) { + const names: string[] = [] + for (const [name, sub] of Object.entries((await resolveValue(cmd.subCommands)) ?? {})) { + const meta = (await resolveValue((await resolveValue(sub))?.meta)) || {} + if (meta.hidden) continue + const aliases = toArray(meta.alias) + commandLines.push([colors.cyan([name, ...aliases].join(', ')), meta.description || '']) + names.push(name, ...aliases) + } + if (names.length > 0) usageLine.push(names.join('|')) + } + + const out: string[] = [] + const version = cmdMeta.version || parentMeta.version + if (cmdMeta.description) { + const label = [commandName, version ? `v${version}` : ''].filter(Boolean).join(' ') + out.push(...renderBlock(cmdMeta.description, width).map((l) => colors.gray(l))) + if (label) out.push(colors.gray(`(${label})`)) + out.push('') + } + + const hasOptions = argLines.length > 0 || posLines.length > 0 + const invocation = [commandName, hasOptions ? '[OPTIONS]' : '', usageLine.join(' ')] + .filter(Boolean) + .join(' ') + // The subcommand list can be far wider than the terminal, and being + // pipe-joined it is a single space-free "word" that word-wrapping alone + // cannot split — the root's 24 commands made one 165-character token. Give + // `wrapText` a break opportunity after each separator, then put it back. + const usagePrefix = 'USAGE ' + const usageBody = wrapText( + invocation.replace(/\|/g, '| '), + Math.max(20, width - usagePrefix.length), + ).map((line) => line.replace(/\| /g, '|')) + out.push(`${heading('USAGE')} ${colors.cyan(usageBody[0] ?? '')}`) + for (const line of usageBody.slice(1)) { + out.push(`${' '.repeat(usagePrefix.length)}${colors.cyan(line)}`) + } + out.push('') + + if (posLines.length > 0) { + out.push(heading('ARGUMENTS'), '', formatColumns(posLines, width), '') + } + if (argLines.length > 0) { + out.push(heading('OPTIONS'), '', formatColumns(argLines, width), '') + } + if (commandLines.length > 0) { + out.push(heading('COMMANDS'), '', formatColumns(commandLines, width), '') + out.push(`Use ${colors.cyan(`${commandName} --help`)} for more information.`) + } + + // Trim trailing blanks so callers control the final spacing. + while (out.length > 0 && out[out.length - 1] === '') out.pop() + return out.join('\n') +} diff --git a/apps/cli/src/lib/usage.ts b/apps/cli/src/lib/usage.ts new file mode 100644 index 00000000..0606cfcb --- /dev/null +++ b/apps/cli/src/lib/usage.ts @@ -0,0 +1,134 @@ +/** + * Help-page layout primitives. + * + * citty's own `formatLineColumns` pads *every* column to the widest entry — + * including the last one. With a 250-character description in the set (`api`), + * all 24 rows of `a2wave --help` were padded out to 332 columns, and every + * terminal narrower than that wrapped the trailing whitespace into a + * blank-looking second line. The list read as double-spaced and mid-sentence + * broken. It also right-aligned the name column, so command names stepped in + * and out raggedly. + * + * These replace that with the layout every mainstream CLI uses (claude, codex, + * lark-cli): two-space indent, left-aligned names, one gutter, descriptions + * wrapped to the terminal with a hanging indent, and no trailing whitespace on + * any line. + */ + +/** + * Width used when stdout is not a TTY. + * + * The primary consumer of this page is an agent reading piped output, which + * reports no columns. A fixed number keeps that output byte-identical between + * runs and machines — a terminal-dependent width would make help text a + * flaky snapshot. It doubles as the cap for very wide terminals: a 400-column + * line of prose is unreadable no matter how much room there is. + */ +export const HELP_FALLBACK_WIDTH = 100 + +/** Below this the two-column layout has no room left for words. */ +const MIN_WIDTH = 40 + +/** Spaces before the name column, and between the two columns. */ +const INDENT = 2 +const GUTTER = 2 + +/** + * The widest a name column may grow. Past this, one outlier command name + * would squeeze every description on the page into a sliver. + */ +const MAX_NAME_WIDTH = 24 + +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escapes requires ESC. +const ANSI = /\[[0-9;]*m/g + +/** Visible length, ignoring the colour escapes citty wraps names in. */ +function visibleLength(value: string): number { + return value.replace(ANSI, '').length +} + +type Stream = { isTTY?: boolean; columns?: number } + +/** + * The column budget for a help page. + * + * Clamped at both ends: `MIN_WIDTH` because the layout stops making sense + * below it, `HELP_FALLBACK_WIDTH` because long lines of prose get hard to + * track back to the next line's start. + */ +export function helpWidth(stream: Stream = process.stdout): number { + const columns = stream.isTTY ? stream.columns : undefined + if (!columns) return HELP_FALLBACK_WIDTH + return Math.max(MIN_WIDTH, Math.min(columns, HELP_FALLBACK_WIDTH)) +} + +/** + * Break `text` into lines of at most `width` visible characters. + * + * A word longer than the width (a URL, a long flag spelling) is emitted on its + * own line and allowed to overflow rather than being cut: truncating it would + * make the help text wrong, which is worse than a ragged right edge. + */ +export function wrapText(text: string, width: number): string[] { + const words = text.trim().split(/\s+/).filter(Boolean) + if (words.length === 0) return [] + + const lines: string[] = [] + let line = '' + for (const word of words) { + if (line === '') { + line = word + continue + } + if (visibleLength(line) + 1 + visibleLength(word) <= width) { + line += ` ${word}` + } else { + lines.push(line) + line = word + } + } + lines.push(line) + return lines +} + +/** + * Render `[name, description]` rows as two aligned columns. + * + * Names are padded to a shared width so descriptions line up; a name wider + * than `MAX_NAME_WIDTH` (or wider than the budget leaves room for) takes its + * own line and its description starts on the next one, rather than dragging + * the whole column right. + */ +export function formatColumns(rows: Array<[string, string]>, width: number): string { + if (rows.length === 0) return '' + + const nameWidth = Math.min(MAX_NAME_WIDTH, Math.max(...rows.map(([name]) => visibleLength(name)))) + const descColumn = INDENT + nameWidth + GUTTER + // Always leave a usable description column, even in a narrow terminal. + const descWidth = Math.max(MIN_WIDTH - descColumn, width - descColumn) + const hangingIndent = ' '.repeat(descColumn) + + const out: string[] = [] + for (const [name, description] of rows) { + const nameLength = visibleLength(name) + const lines = wrapText(description, descWidth) + + if (nameLength > nameWidth) { + // Outlier name: give it a line of its own, then indent the description + // under the shared column so the page still scans as one list. + out.push(`${' '.repeat(INDENT)}${name}`) + for (const line of lines) out.push(`${hangingIndent}${line}`) + continue + } + + const padded = `${' '.repeat(INDENT)}${name}${' '.repeat(nameWidth - nameLength)}` + if (lines.length === 0) { + // No description: emit the bare name with no trailing gutter. + out.push(padded.trimEnd()) + continue + } + out.push(`${padded}${' '.repeat(GUTTER)}${lines[0]}`) + for (const line of lines.slice(1)) out.push(`${hangingIndent}${line}`) + } + return out.join('\n') +} diff --git a/apps/web/src/components/about-dialog.tsx b/apps/web/src/components/about-dialog.tsx index 930a2844..4ee935b2 100644 --- a/apps/web/src/components/about-dialog.tsx +++ b/apps/web/src/components/about-dialog.tsx @@ -1,3 +1,6 @@ +import { Github, ScrollText } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' import { BrandMark } from '@/components/brand-mark' import { Button } from '@/components/ui/button' import { @@ -7,10 +10,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' -import { useQuery } from '@tanstack/react-query' -import { Github, ScrollText } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import { Link } from 'react-router-dom' +import { useVersion } from '@/hooks/use-version' /** 开源仓库地址;仓库迁移时同步修改。 */ export const GITHUB_REPO_URL = 'https://github.com/LilithGames/a2wave' @@ -22,17 +22,7 @@ interface AboutDialogProps { export function AboutDialog({ open, onOpenChange }: AboutDialogProps) { const { t } = useTranslation() - const { data: version } = useQuery({ - queryKey: ['health', 'version'], - queryFn: async () => { - const res = await fetch('/api/health', { credentials: 'include' }) - if (!res.ok) throw new Error('Failed to fetch health') - const data = (await res.json()) as { version?: string } - return data.version ?? null - }, - staleTime: Number.POSITIVE_INFINITY, - enabled: open, - }) + const { data: version } = useVersion() return ( diff --git a/apps/web/src/hooks/__tests__/use-version.test.tsx b/apps/web/src/hooks/__tests__/use-version.test.tsx new file mode 100644 index 00000000..fcff369e --- /dev/null +++ b/apps/web/src/hooks/__tests__/use-version.test.tsx @@ -0,0 +1,54 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useVersion } from '@/hooks/use-version' + +const fetchMock = vi.fn() + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return {children} +} + +describe('useVersion', () => { + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + it('reads the version from the lightweight /api/version endpoint', async () => { + fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ version: 'v0.7.3' }) }) + + const { result } = renderHook(() => useVersion(), { wrapper }) + + await waitFor(() => expect(result.current.data).toBe('v0.7.3')) + expect(fetchMock).toHaveBeenCalledWith('/api/version', expect.anything()) + }) + + /** + * Every surface showing the version is decorative — a failed fetch must + * degrade to "no version shown", never to an error state that the login page + * would have to render. + */ + it('resolves to null rather than throwing when the request fails', async () => { + fetchMock.mockResolvedValue({ ok: false, json: () => Promise.resolve({}) }) + + const { result } = renderHook(() => useVersion(), { wrapper }) + + await waitFor(() => expect(result.current.isFetching).toBe(false)) + expect(result.current.data).toBeNull() + expect(result.current.isError).toBe(false) + }) + + it('resolves to null when the endpoint answers without a version field', async () => { + fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }) + + const { result } = renderHook(() => useVersion(), { wrapper }) + + await waitFor(() => expect(result.current.isFetching).toBe(false)) + expect(result.current.data).toBeNull() + }) +}) diff --git a/apps/web/src/hooks/use-version.ts b/apps/web/src/hooks/use-version.ts new file mode 100644 index 00000000..ba3c9fe8 --- /dev/null +++ b/apps/web/src/hooks/use-version.ts @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query' + +/** + * The running server's version, or null when it cannot be determined. + * + * Every surface showing the version is decorative, so a failure resolves to + * null instead of rejecting — the login footer and the About dialog then simply + * omit it rather than having to render an error. The value cannot change + * without a server restart, hence the infinite staleTime and no retry. + */ +export function useVersion() { + return useQuery({ + queryKey: ['version'], + queryFn: async (): Promise => { + try { + const res = await fetch('/api/version', { credentials: 'include' }) + if (!res.ok) return null + const data = (await res.json()) as { version?: string } + return data.version ?? null + } catch { + return null + } + }, + staleTime: Number.POSITIVE_INFINITY, + retry: false, + }) +} diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json index 1d4663c1..10272f54 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -3,7 +3,8 @@ "name": "A2WAVE", "subtitle": "Agent Workflow", "tagline": "Build enterprise agents on mature agent CLIs.", - "copyright": "© 2026 Lilith Games. All rights reserved." + "copyright": "© 2026 Lilith Games. All rights reserved.", + "copyrightWithVersion": "{{copyright}} · {{version}}" }, "about": { "menuItem": "About", diff --git a/apps/web/src/locales/zh.json b/apps/web/src/locales/zh.json index ac5fc88c..442c7025 100644 --- a/apps/web/src/locales/zh.json +++ b/apps/web/src/locales/zh.json @@ -3,7 +3,8 @@ "name": "A2WAVE", "subtitle": "Agent 工作流", "tagline": "基于成熟 Agent CLI,构建企业 Agent。", - "copyright": "© 2026 Lilith Games 版权所有" + "copyright": "© 2026 Lilith Games 版权所有", + "copyrightWithVersion": "{{copyright}} · {{version}}" }, "about": { "menuItem": "关于", diff --git a/apps/web/src/pages/__tests__/login-version.test.tsx b/apps/web/src/pages/__tests__/login-version.test.tsx new file mode 100644 index 00000000..2bc2ec9b --- /dev/null +++ b/apps/web/src/pages/__tests__/login-version.test.tsx @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import i18n from '@/i18n' +import { renderWithProviders, screen, waitFor } from '@/test/render' +import { LoginPage } from '../login' + +vi.mock('@/hooks/use-auth', () => ({ + useAuthStatus: () => ({ data: { needSetup: false }, isLoading: false }), + useOauthConfig: () => ({ data: { enabled: false }, isLoading: false, isError: false }), + useLogin: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + +const { useVersionMock } = vi.hoisted(() => ({ useVersionMock: vi.fn() })) +vi.mock('@/hooks/use-version', () => ({ useVersion: () => useVersionMock() })) + +const copyright = i18n.t('app.copyright') + +describe('LoginPage version footer', () => { + beforeEach(() => { + useVersionMock.mockReset() + }) + + it('shows the version next to the copyright once loaded', async () => { + useVersionMock.mockReturnValue({ data: 'v0.7.3' }) + + renderWithProviders() + + const footer = await screen.findByTestId('login-footer') + // Same line as the copyright — the footer is the page's meta-info zone. + expect(footer).toHaveTextContent(copyright) + expect(footer).toHaveTextContent('v0.7.3') + }) + + /** + * The version is decorative: while it loads, or if the endpoint is + * unreachable, the copyright must still render cleanly — with no orphaned + * separator left dangling after it. + */ + it('renders the copyright alone when no version is available', async () => { + useVersionMock.mockReturnValue({ data: null }) + + renderWithProviders() + + const footer = await screen.findByTestId('login-footer') + expect(footer).toHaveTextContent(copyright) + await waitFor(() => { + expect(footer.textContent?.trim()).toBe(copyright) + }) + }) +}) diff --git a/apps/web/src/pages/login.tsx b/apps/web/src/pages/login.tsx index 0e2a6c9f..bfa8fbbc 100644 --- a/apps/web/src/pages/login.tsx +++ b/apps/web/src/pages/login.tsx @@ -1,15 +1,16 @@ +import type { InputRef } from 'antd' +import { Input } from 'antd' +import { Loader2, Waves } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Navigate, useNavigate, useSearchParams } from 'react-router-dom' import oidcIconUrl from '@/assets/sso-icons/oidc.svg' import samlIconUrl from '@/assets/sso-icons/saml.svg' import { BrandWaveField } from '@/components/brand-wave-field' import { Button } from '@/components/ui/button' import { type SsoLoginMethod, useAuthStatus, useLogin, useOauthConfig } from '@/hooks/use-auth' +import { useVersion } from '@/hooks/use-version' import { formatApiError } from '@/lib/api-error' -import { Input } from 'antd' -import type { InputRef } from 'antd' -import { Loader2, Waves } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { Navigate, useNavigate, useSearchParams } from 'react-router-dom' /** * 'login'(默认)换 a2wave 登录态;'bind' 把 SSO 身份钉到当前用户; @@ -144,6 +145,9 @@ export function LoginPage() { isError: oauthConfigFailed, } = useOauthConfig() const usernameRef = useRef(null) + // Called before the early returns below so hook order stays stable across the + // loading / needSetup branches. + const { data: version } = useVersion() const login = useLogin() const [username, setUsername] = useState('') const [password, setPassword] = useState('') @@ -370,8 +374,14 @@ export function LoginPage() { - {/* Footer */} -

{t('app.copyright')}

+ {/* Footer — copyright plus, once known, the running server's version. + The version is appended rather than given its own line: it is a + short meta string and the footer is already this page's meta zone. */} +

+ {version + ? t('app.copyrightWithVersion', { copyright: t('app.copyright'), version }) + : t('app.copyright')} +

)