Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions apps/api/src/lib/version.ts
Original file line number Diff line number Diff line change
@@ -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'
}
}
67 changes: 67 additions & 0 deletions apps/api/src/routes/__tests__/version.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('node:child_process')>('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'])
})
})
13 changes: 2 additions & 11 deletions apps/api/src/routes/health.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/routes/version.ts
Original file line number Diff line number Diff line change
@@ -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
23 changes: 15 additions & 8 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'

Expand Down Expand Up @@ -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<void> {
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}`)
}
Expand Down
155 changes: 155 additions & 0 deletions apps/cli/src/lib/__tests__/render-usage.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading