From ff0b5a30be67d4b260c9b147ea12055d15204f21 Mon Sep 17 00:00:00 2001 From: Takuro Kitahara Date: Tue, 18 Nov 2025 18:20:39 +0900 Subject: [PATCH 1/5] feat: replace commander with @takojs/tako@1.1.0 --- bun.lock | 9 +- package.json | 3 +- src/cli.ts | 46 +++--- src/commands/docs/index.ts | 115 +++++++------ src/commands/request/index.ts | 91 ++++++++--- src/commands/search/index.ts | 299 +++++++++++++++++++--------------- src/commands/serve/index.ts | 176 +++++++++++--------- 7 files changed, 423 insertions(+), 316 deletions(-) diff --git a/bun.lock b/bun.lock index a77f6fd..6ca75b1 100644 --- a/bun.lock +++ b/bun.lock @@ -1,11 +1,12 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@hono/cli", "dependencies": { "@hono/node-server": "^1.19.5", - "commander": "^14.0.1", + "@takojs/tako": "^1.1.0", "esbuild": "^0.25.10", "hono": "^4.9.12", }, @@ -227,6 +228,8 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="], + "@takojs/tako": ["@takojs/tako@1.1.0", "", {}, "sha512-rRJS/KwTe3DOFvj6TFQr3JIYSb/e528BHgHg0+JnbNSxjgWXrrdiW33PtRcJogZfUis4MIdJkobDZWNAedS9HA=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="], @@ -381,7 +384,7 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "commander": ["commander@14.0.1", "", {}, "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A=="], + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "comment-parser": ["comment-parser@1.4.1", "", {}, "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg=="], @@ -1111,8 +1114,6 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], - "update-notifier/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], diff --git a/package.json b/package.json index c610499..b7cd2da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "@hono/cli", "version": "0.1.0", + "description": "CLI for Hono", "type": "module", "bin": { "hono": "dist/cli.js" @@ -34,7 +35,7 @@ "homepage": "https://hono.dev", "dependencies": { "@hono/node-server": "^1.19.5", - "commander": "^14.0.1", + "@takojs/tako": "^1.1.0", "esbuild": "^0.25.10", "hono": "^4.9.12" }, diff --git a/src/cli.ts b/src/cli.ts index 800ba72..3650688 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,31 +1,25 @@ -import { Command } from 'commander' -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { docsCommand } from './commands/docs/index.js' -import { optimizeCommand } from './commands/optimize/index.js' -import { requestCommand } from './commands/request/index.js' -import { searchCommand } from './commands/search/index.js' -import { serveCommand } from './commands/serve/index.js' +import { Tako } from '@takojs/tako' +import pkg from '../package.json' with { type: 'json' } +import { docsArgs, docsCommand, docsValidation } from './commands/docs/index.js' +// import { optimizeArgs, optimizeCommand, optimizeValidation } from './commands/optimize/index.js' // TODO: replace commander with @takojs/tako +import { requestArgs, requestCommand, requestValidation } from './commands/request/index.js' +import { searchArgs, searchCommand, searchValidation } from './commands/search/index.js' +import { serveArgs, serveCommand, serveValidation } from './commands/serve/index.js' -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) +const rootArgs = { + metadata: { + version: pkg.version, + help: pkg.description, + }, +} -// Read version from package.json -const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8')) - -const program = new Command() - -program - .name('hono') - .description('CLI for Hono') - .version(packageJson.version, '-v, --version', 'display version number') +const tako = new Tako() // Register commands -docsCommand(program) -optimizeCommand(program) -searchCommand(program) -requestCommand(program) -serveCommand(program) +tako.command('docs', docsArgs, docsValidation, docsCommand) +// tako.command("optimize", optimizeArgs, optimizeValidation, optimizeCommand) // TODO: replace commander with @takojs/tako +tako.command('request', requestArgs, requestValidation, requestCommand) +tako.command('search', searchArgs, searchValidation, searchCommand) +tako.command('serve', serveArgs, serveValidation, serveCommand) -program.parse() +tako.cli(rootArgs) diff --git a/src/commands/docs/index.ts b/src/commands/docs/index.ts index a7cfaae..42a0e4b 100644 --- a/src/commands/docs/index.ts +++ b/src/commands/docs/index.ts @@ -1,6 +1,8 @@ -import type { Command } from 'commander' +import type { Tako, TakoArgs, TakoHandler } from '@takojs/tako' +import { Buffer } from 'node:buffer' +import * as process from 'node:process' -async function fetchAndDisplayContent(url: string, fallbackUrl?: string): Promise { +async function fetchAndDisplayContent(c: Tako, url: string, fallbackUrl?: string): Promise { try { const response = await fetch(url) @@ -9,64 +11,75 @@ async function fetchAndDisplayContent(url: string, fallbackUrl?: string): Promis } const content = await response.text() - console.log('\n' + content) + c.print({ message: '\n' + content }) } catch (error) { - console.error( - 'Error fetching documentation:', - error instanceof Error ? error.message : String(error) - ) - console.log(`\nPlease visit: ${fallbackUrl || 'https://hono.dev/docs'}`) + c.print({ + message: [ + 'Error fetching documentation:', + error instanceof Error ? error.message : String(error), + ], + style: 'red', + level: 'error', + }) + c.print({ message: `\nPlease visit: ${fallbackUrl || 'https://hono.dev/docs'}` }) } } -export function docsCommand(program: Command) { - program - .command('docs') - .argument( - '[path]', - 'Documentation path (e.g., /docs/concepts/motivation, /examples/stytch-auth)', - '' - ) - .description('Display Hono documentation') - .action(async (path: string) => { - let finalPath = path +export const docsArgs: TakoArgs = { + metadata: { + help: 'Display Hono documentation', + }, +} - // If no path provided, check for stdin input - if (!path) { - // Check if stdin is piped (not a TTY) - if (!process.stdin.isTTY) { - try { - const chunks: Buffer[] = [] - for await (const chunk of process.stdin) { - chunks.push(chunk) - } - const stdinInput = Buffer.concat(chunks).toString().trim() - if (stdinInput) { - // Remove quotes if present (handles jq output without -r flag) - finalPath = stdinInput.replace(/^["'](.*)["']$/, '$1') - } - } catch (error) { - console.error('Error reading from stdin:', error) - } - } +export const docsValidation: TakoHandler = (_c, next) => { + next() +} + +export const docsCommand: TakoHandler = async (c) => { + let finalPath = c.scriptArgs.positionals[0] - // If still no path, fetch llms.txt - if (!finalPath) { - console.log('Fetching Hono documentation...') - await fetchAndDisplayContent('https://hono.dev/llms.txt') - return + // If no path provided, check for stdin input + if (!finalPath) { + // Check if stdin is piped (not a TTY) + if (!process.stdin.isTTY) { + try { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk) + } + const stdinInput = Buffer.concat(chunks).toString().trim() + if (stdinInput) { + // Remove quotes if present (handles jq output without -r flag) + finalPath = stdinInput.replace(/^["'](.*)["']$/, '$1') } + } catch (error) { + c.print({ + message: [ + 'Error reading from stdin:', + error instanceof Error ? error.message : String(error) + ], + style: 'red', + level: 'error', + }) } + } - // Ensure path starts with / - const normalizedPath = finalPath.startsWith('/') ? finalPath : `/${finalPath}` + // If still no path, fetch llms.txt + if (!finalPath) { + c.print({ message: 'Fetching Hono documentation...' }) + await fetchAndDisplayContent(c, 'https://hono.dev/llms.txt') + return + } + } - // Remove leading slash to get the GitHub path - const basePath = normalizedPath.slice(1) // Remove leading slash - const markdownUrl = `https://raw.githubusercontent.com/honojs/website/refs/heads/main/${basePath}.md` - const webUrl = `https://hono.dev${normalizedPath}` + // Ensure path starts with / + const normalizedPath = finalPath.startsWith('/') ? finalPath : `/${finalPath}` - console.log(`Fetching Hono documentation for ${finalPath}...`) - await fetchAndDisplayContent(markdownUrl, webUrl) - }) + // Remove leading slash to get the GitHub path + const basePath = normalizedPath.slice(1) // Remove leading slash + const markdownUrl = `https://raw.githubusercontent.com/honojs/website/refs/heads/main/${basePath}.md` + const webUrl = `https://hono.dev${normalizedPath}` + + c.print({ message: `Fetching Hono documentation for ${finalPath}...` }) + await fetchAndDisplayContent(c, markdownUrl, webUrl) } diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index f004b3c..e61aa06 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -1,7 +1,8 @@ -import type { Command } from 'commander' +import type { TakoArgs, TakoHandler } from '@takojs/tako' import type { Hono } from 'hono' import { existsSync, realpathSync } from 'node:fs' import { resolve } from 'node:path' +import * as process from 'node:process' import { buildAndImportApp } from '../../utils/build.js' const DEFAULT_ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.js', 'src/index.jsx'] @@ -13,30 +14,7 @@ interface RequestOptions { path?: string } -export function requestCommand(program: Command) { - program - .command('request') - .description('Send request to Hono app using app.request()') - .argument('[file]', 'Path to the Hono app file') - .option('-P, --path ', 'Request path', '/') - .option('-X, --method ', 'HTTP method', 'GET') - .option('-d, --data ', 'Request body data') - .option( - '-H, --header
', - 'Custom headers', - (value: string, previous: string[]) => { - return previous ? [...previous, value] : [value] - }, - [] as string[] - ) - .action(async (file: string | undefined, options: RequestOptions) => { - const path = options.path || '/' - const result = await executeRequest(file, path, options) - console.log(JSON.stringify(result, null, 2)) - }) -} - -export async function executeRequest( +async function executeRequest( appPath: string | undefined, requestPath: string, options: RequestOptions @@ -111,3 +89,66 @@ export async function executeRequest( headers: responseHeaders, } } + +export const requestArgs: TakoArgs = { + config: { + options: { + path: { + type: 'string', + short: 'P', + default: '/', + }, + method: { + type: 'string', + short: 'X', + default: 'GET', + }, + data: { + type: 'string', + short: 'd', + }, + header: { + type: 'string', + short: 'H', + multiple: true, + }, + }, + }, + metadata: { + help: 'Send request to Hono app using app.request()', + options: { + path: { + help: 'Request path', + placeholder: '', + }, + method: { + help: 'HTTP method', + placeholder: '', + }, + data: { + help: 'Request body data', + placeholder: '', + }, + header: { + help: 'Custom headers', + placeholder: '
', + }, + }, + }, +} + +export const requestValidation: TakoHandler = (_c, next) => { + next() +} + +export const requestCommand: TakoHandler = async (c) => { + const file = c.scriptArgs.positionals[0] + const { path, method, data, header } = c.scriptArgs.values + + const result = await executeRequest(file, path as string, { + method: method as string, + data: data as string, + header: header as string[] | undefined, + }) + c.print({ message: JSON.stringify(result, null, 2) }) +} diff --git a/src/commands/search/index.ts b/src/commands/search/index.ts index 1266749..6b4187e 100644 --- a/src/commands/search/index.ts +++ b/src/commands/search/index.ts @@ -1,4 +1,4 @@ -import type { Command } from 'commander' +import type { TakoArgs, TakoHandler } from '@takojs/tako' interface AlgoliaHit { title?: string @@ -29,142 +29,179 @@ interface AlgoliaResponse { hits: AlgoliaHit[] } -export function searchCommand(program: Command) { - program - .command('search') - .argument('', 'Search query for Hono documentation') - .option('-l, --limit ', 'Number of results to show (default: 5)', (value) => { - const parsed = parseInt(value, 10) - if (isNaN(parsed) || parsed < 1 || parsed > 20) { - console.warn('Limit must be a number between 1 and 20\n') - return 5 - } - return parsed +export const searchArgs: TakoArgs = { + config: { + options: { + limit: { + type: 'string', + short: 'l', + }, + pretty: { + type: 'boolean', + short: 'p', + }, + }, + }, + metadata: { + help: 'Search Hono documentation', + options: { + limit: { + help: 'Number of results to show (default: 5)', + placeholder: '', + }, + pretty: { + help: 'Display results in human-readable format', + }, + }, + }, +} + +export const searchValidation: TakoHandler = (c, next) => { + if (!c.scriptArgs.positionals[0]) { + c.print({ message: 'Error: Missing required argument "query"', style: 'red', level: 'error' }) + return + } + const { limit } = c.scriptArgs.values + if (limit) { + const parsed = parseInt(limit as string, 10) + if (isNaN(parsed) || parsed < 1 || parsed > 20) { + c.print({ message: 'Limit must be a number between 1 and 20\n', style: 'yellow', level: 'warn' }) + c.scriptArgs.values.limit = 5 + } else { + c.scriptArgs.values.limit = parsed + } + } + next() +} + +export const searchCommand: TakoHandler = async (c) => { + const query = c.scriptArgs.positionals[0] + const { limit, pretty } = c.scriptArgs.values + + // Search-only API key - safe to embed in public code + const ALGOLIA_APP_ID = '1GIFSU1REV' + const ALGOLIA_API_KEY = 'c6a0f86b9a9f8551654600f28317a9e9' + const ALGOLIA_INDEX = 'hono' + + const searchUrl = `https://${ALGOLIA_APP_ID}-dsn.algolia.net/1/indexes/${ALGOLIA_INDEX}/query` + + try { + if (pretty) { + c.print({ message: `Searching for "${query}"...` }) + } + + const response = await fetch(searchUrl, { + method: 'POST', + headers: { + 'X-Algolia-API-Key': ALGOLIA_API_KEY, + 'X-Algolia-Application-Id': ALGOLIA_APP_ID, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query, + hitsPerPage: (limit as number) || 5, + }), }) - .option('-p, --pretty', 'Display results in human-readable format') - .description('Search Hono documentation') - .action(async (query: string, options: { limit?: number; pretty?: boolean }) => { - // Search-only API key - safe to embed in public code - const ALGOLIA_APP_ID = '1GIFSU1REV' - const ALGOLIA_API_KEY = 'c6a0f86b9a9f8551654600f28317a9e9' - const ALGOLIA_INDEX = 'hono' - - const searchUrl = `https://${ALGOLIA_APP_ID}-dsn.algolia.net/1/indexes/${ALGOLIA_INDEX}/query` - - try { - if (options.pretty) { - console.log(`Searching for "${query}"...`) - } - const response = await fetch(searchUrl, { - method: 'POST', - headers: { - 'X-Algolia-API-Key': ALGOLIA_API_KEY, - 'X-Algolia-Application-Id': ALGOLIA_APP_ID, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query, - hitsPerPage: options.limit || 5, - }), - }) + if (!response.ok) { + throw new Error(`Search failed: ${response.status} ${response.statusText}`) + } - if (!response.ok) { - throw new Error(`Search failed: ${response.status} ${response.statusText}`) - } + const data: AlgoliaResponse = await response.json() + + if (data.hits.length === 0) { + if (pretty) { + c.print({ message: '\nNo results found.' }) + } else { + c.print({ message: JSON.stringify({ query, total: 0, results: [] }, null, 2) }) + } + return + } - const data: AlgoliaResponse = await response.json() + // Helper function to clean HTML tags completely + const cleanHighlight = (text: string) => text.replace(/<[^>]*>/g, '') - if (data.hits.length === 0) { - if (options.pretty) { - console.log('\nNo results found.') - } else { - console.log(JSON.stringify({ query, total: 0, results: [] }, null, 2)) - } - return - } + const results = data.hits.map((hit) => { + // Get title from various sources + let title = hit.title + let highlightedTitle = title + if (!title && hit._highlightResult?.hierarchy?.lvl1) { + title = cleanHighlight(hit._highlightResult.hierarchy.lvl1.value) + highlightedTitle = hit._highlightResult.hierarchy.lvl1.value + } + if (!title) { + title = hit.hierarchy?.lvl1 || hit.hierarchy?.lvl0 || 'Untitled' + highlightedTitle = title + } - // Helper function to clean HTML tags completely - const cleanHighlight = (text: string) => text.replace(/<[^>]*>/g, '') - - const results = data.hits.map((hit) => { - // Get title from various sources - let title = hit.title - let highlightedTitle = title - if (!title && hit._highlightResult?.hierarchy?.lvl1) { - title = cleanHighlight(hit._highlightResult.hierarchy.lvl1.value) - highlightedTitle = hit._highlightResult.hierarchy.lvl1.value - } - if (!title) { - title = hit.hierarchy?.lvl1 || hit.hierarchy?.lvl0 || 'Untitled' - highlightedTitle = title - } - - // Build hierarchy path - const hierarchyParts: string[] = [] - if (hit.hierarchy?.lvl0 && hit.hierarchy.lvl0 !== 'Documentation') { - hierarchyParts.push(hit.hierarchy.lvl0) - } - if (hit.hierarchy?.lvl1 && hit.hierarchy.lvl1 !== title) { - hierarchyParts.push(cleanHighlight(hit.hierarchy.lvl1)) - } - if (hit.hierarchy?.lvl2) { - hierarchyParts.push(cleanHighlight(hit.hierarchy.lvl2)) - } - - const category = hierarchyParts.length > 0 ? hierarchyParts.join(' > ') : '' - const url = hit.url - const urlPath = new URL(url).pathname - - return { - title, - highlightedTitle, - category, - url, - path: urlPath, - } - }) - - if (options.pretty) { - console.log(`\nFound ${data.hits.length} results:\n`) - - // Helper function to convert HTML highlights to terminal formatting - const formatHighlight = (text: string) => { - return text - .replace(//g, '\x1b[33m') // Yellow - .replace(/<\/span>/g, '\x1b[0m') // Reset - } - - results.forEach((result, index) => { - console.log(`${index + 1}. ${formatHighlight(result.highlightedTitle || result.title)}`) - if (result.category) { - console.log(` Category: ${result.category}`) - } - console.log(` URL: ${result.url}`) - console.log(` Command: hono docs ${result.path}`) - console.log('') - }) - } else { - // Remove highlighted title from JSON output - const jsonResults = results.map(({ highlightedTitle, ...result }) => result) - console.log( - JSON.stringify( - { - query, - total: data.hits.length, - results: jsonResults, - }, - null, - 2 - ) - ) - } - } catch (error) { - console.error( - 'Error searching documentation:', - error instanceof Error ? error.message : String(error) - ) - console.log('\nPlease visit: https://hono.dev/docs') + // Build hierarchy path + const hierarchyParts: string[] = [] + if (hit.hierarchy?.lvl0 && hit.hierarchy.lvl0 !== 'Documentation') { + hierarchyParts.push(hit.hierarchy.lvl0) + } + if (hit.hierarchy?.lvl1 && hit.hierarchy.lvl1 !== title) { + hierarchyParts.push(cleanHighlight(hit.hierarchy.lvl1)) + } + if (hit.hierarchy?.lvl2) { + hierarchyParts.push(cleanHighlight(hit.hierarchy.lvl2)) + } + + const category = hierarchyParts.length > 0 ? hierarchyParts.join(' > ') : '' + const url = hit.url + const urlPath = new URL(url).pathname + + return { + title, + highlightedTitle, + category, + url, + path: urlPath, } }) + + if (pretty) { + c.print({ message: `\nFound ${data.hits.length} results:\n` }) + + // Helper function to convert HTML highlights to terminal formatting + const formatHighlight = (text: string) => { + return text + .replace(//g, '\x1b[33m') // Yellow + .replace(/<\/span>/g, '\x1b[0m') // Reset + } + + results.forEach((result, index) => { + c.print({ message: `${index + 1}. ${formatHighlight(result.highlightedTitle || result.title)}` }) + if (result.category) { + c.print({ message: ` Category: ${result.category}` }) + } + c.print({ message: ` URL: ${result.url}` }) + c.print({ message: ` Command: hono docs ${result.path}` }) + c.print({ message: '' }) + }) + } else { + // Remove highlighted title from JSON output + const jsonResults = results.map(({ ...result }) => result) + c.print({ + message: JSON.stringify( + { + query, + total: data.hits.length, + results: jsonResults, + }, + null, + 2 + ), + }) + } + } catch (error) { + c.print({ + message: [ + 'Error searching documentation:', + error instanceof Error ? error.message : String(error), + ], + style: 'red', + level: 'error', + }) + c.print({ message: '\nPlease visit: https://hono.dev/docs' }) + } } diff --git a/src/commands/serve/index.ts b/src/commands/serve/index.ts index af432e0..a8e267e 100644 --- a/src/commands/serve/index.ts +++ b/src/commands/serve/index.ts @@ -1,10 +1,11 @@ import { serve } from '@hono/node-server' import { serveStatic } from '@hono/node-server/serve-static' -import type { Command } from 'commander' +import type { TakoArgs, TakoHandler } from '@takojs/tako' import { Hono } from 'hono' import { showRoutes } from 'hono/dev' import { existsSync, realpathSync } from 'node:fs' import { resolve } from 'node:path' +import * as process from 'node:process' import { buildAndImportApp } from '../../utils/build.js' import { builtinMap } from './builtin-map.js' @@ -15,91 +16,110 @@ import { builtinMap } from './builtin-map.js' } }) -export function serveCommand(program: Command) { - program - .command('serve') - .description('Start server') - .argument('[entry]', 'entry file') - .option('-p, --port ', 'port number') - .option('--show-routes', 'show registered routes') - .option( - '--use ', - 'use middleware', - (value, previous: string[]) => { - return previous ? [...previous, value] : [value] +export const serveArgs: TakoArgs = { + config: { + options: { + port: { + type: 'string', + short: 'p', }, - [] - ) - .action( - async ( - entry: string | undefined, - options: { port?: string; showRoutes?: boolean; use?: string[] } - ) => { - let app: Hono + 'show-routes': { + type: 'boolean', + }, + use: { + type: 'string', + multiple: true, + }, + }, + }, + metadata: { + help: 'Start server', + options: { + port: { + help: 'port number', + placeholder: '', + }, + 'show-routes': { + help: 'show registered routes', + }, + use: { + help: 'use middleware', + placeholder: '', + }, + }, + }, +} - if (!entry) { - // Create a default Hono app if no entry is provided - app = new Hono() - } else { - const appPath = resolve(process.cwd(), entry) +export const serveValidation: TakoHandler = (_c, next) => { + next() +} - if (!existsSync(appPath)) { - // Create a default Hono app if entry file doesn't exist - app = new Hono() - } else { - const appFilePath = realpathSync(appPath) - app = await buildAndImportApp(appFilePath, { - external: ['@hono/node-server'], - }) - } - } +export const serveCommand: TakoHandler = async (c) => { + const entry = c.scriptArgs.positionals[0] + const { port, 'show-routes': showRoutesOption, use: useOptions } = c.scriptArgs.values + let app: Hono - // Import all builtin functions from the builtin map - const allFunctions: Record = {} - const uniqueModules = [...new Set(Object.values(builtinMap))] + if (!entry) { + // Create a default Hono app if no entry is provided + app = new Hono() + } else { + const appPath = resolve(process.cwd(), entry) - for (const modulePath of uniqueModules) { - try { - const module = await import(modulePath) - // Add all exported functions from this module - for (const [funcName, modulePathInMap] of Object.entries(builtinMap)) { - if (modulePathInMap === modulePath && module[funcName]) { - allFunctions[funcName] = module[funcName] - } - } - } catch (error) { - // Skip modules that can't be imported (optional dependencies) - } - } + if (!existsSync(appPath)) { + // Create a default Hono app if entry file doesn't exist + app = new Hono() + } else { + const appFilePath = realpathSync(appPath) + app = await buildAndImportApp(appFilePath, { + external: ['@hono/node-server'], + }) + } + } + + // Import all builtin functions from the builtin map + const allFunctions: Record = {} + const uniqueModules = [...new Set(Object.values(builtinMap))] - const baseApp = new Hono() - // Apply middleware from --use options - for (const use of options.use || []) { - // Create function with all available functions in scope - const functionNames = Object.keys(allFunctions) - const functionValues = Object.values(allFunctions) - const func = new Function('c', 'next', ...functionNames, `return (${use})`) - baseApp.use(async (c, next) => { - const middleware = func(c, next, ...functionValues) - return typeof middleware === 'function' ? middleware(c, next) : middleware - }) + for (const modulePath of uniqueModules) { + try { + const module = await import(modulePath) + // Add all exported functions from this module + for (const [funcName, modulePathInMap] of Object.entries(builtinMap)) { + if (modulePathInMap === modulePath && module[funcName]) { + allFunctions[funcName] = module[funcName] } + } + } catch { + // Skip modules that can't be imported (optional dependencies) + } + } - baseApp.route('/', app) + const baseApp = new Hono() + // Apply middleware from --use options + for (const use of (useOptions as string[] | undefined) || []) { + // Create function with all available functions in scope + const functionNames = Object.keys(allFunctions) + const functionValues = Object.values(allFunctions) + const func = new Function('c', 'next', ...functionNames, `return (${use})`) + baseApp.use(async (c, next) => { + const middleware = func(c, next, ...functionValues) + return typeof middleware === 'function' ? middleware(c, next) : middleware + }) + } - if (options.showRoutes) { - showRoutes(baseApp) - } + baseApp.route('/', app) - serve( - { - fetch: baseApp.fetch, - port: options.port ? Number.parseInt(options.port) : 7070, - }, - (info) => { - console.log(`Listening on http://localhost:${info.port}`) - } - ) - } - ) + if (showRoutesOption) { + showRoutes(baseApp) + } + + serve( + { + fetch: baseApp.fetch, + port: port ? Number.parseInt(port as string) : 7070, + }, + (info) => { + c.print({ message: `Listening on http://localhost:${info.port}` }) + } + ) } From 7b87b67f157b0083cae4d62e5a2bd92571789774 Mon Sep 17 00:00:00 2001 From: Takuro Kitahara Date: Sat, 22 Nov 2025 22:44:22 +0900 Subject: [PATCH 2/5] test: refactor command tests for @takojs/tako@1.3.0 usage --- bun.lock | 4 +- package.json | 2 +- src/cli.ts | 3 +- src/commands/docs/index.test.ts | 39 +++++---- src/commands/request/index.test.ts | 129 ++++++++++++++++------------- src/commands/search/index.test.ts | 31 +++---- src/commands/search/index.ts | 13 +-- src/commands/serve/index.test.ts | 48 ++++++----- 8 files changed, 146 insertions(+), 123 deletions(-) diff --git a/bun.lock b/bun.lock index 6ca75b1..7211f09 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "@hono/cli", "dependencies": { "@hono/node-server": "^1.19.5", - "@takojs/tako": "^1.1.0", + "@takojs/tako": "^1.3.0", "esbuild": "^0.25.10", "hono": "^4.9.12", }, @@ -228,7 +228,7 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="], - "@takojs/tako": ["@takojs/tako@1.1.0", "", {}, "sha512-rRJS/KwTe3DOFvj6TFQr3JIYSb/e528BHgHg0+JnbNSxjgWXrrdiW33PtRcJogZfUis4MIdJkobDZWNAedS9HA=="], + "@takojs/tako": ["@takojs/tako@1.3.0", "", {}, "sha512-vefFTQykYUohsTNB3Z1jNkP4mKtpRCLodJONqKGtw+XWxt9JoGoDdGo9ZeFDzj2VFJjWS2zbuQTKMgtKPVHAMQ=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], diff --git a/package.json b/package.json index b7cd2da..328e3d3 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "homepage": "https://hono.dev", "dependencies": { "@hono/node-server": "^1.19.5", - "@takojs/tako": "^1.1.0", + "@takojs/tako": "^1.3.0", "esbuild": "^0.25.10", "hono": "^4.9.12" }, diff --git a/src/cli.ts b/src/cli.ts index 3650688..624ef0c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { serveArgs, serveCommand, serveValidation } from './commands/serve/index const rootArgs = { metadata: { + cliName: "hono", version: pkg.version, help: pkg.description, }, @@ -22,4 +23,4 @@ tako.command('request', requestArgs, requestValidation, requestCommand) tako.command('search', searchArgs, searchValidation, searchCommand) tako.command('serve', serveArgs, serveValidation, serveCommand) -tako.cli(rootArgs) +await tako.cli(rootArgs) diff --git a/src/commands/docs/index.test.ts b/src/commands/docs/index.test.ts index 6b7c99c..15ede7a 100644 --- a/src/commands/docs/index.test.ts +++ b/src/commands/docs/index.test.ts @@ -1,20 +1,21 @@ -import { Command } from 'commander' +import { Tako } from '@takojs/tako' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { Buffer } from 'node:buffer' +import * as process from 'node:process' +import { docsArgs, docsCommand, docsValidation } from './index.js' // Mock fetch -global.fetch = vi.fn() - -import { docsCommand } from './index.js' +globalThis.fetch = vi.fn() describe('docsCommand', () => { - let program: Command + let tako: Tako let consoleLogSpy: ReturnType let consoleErrorSpy: ReturnType let originalIsTTY: boolean | undefined beforeEach(() => { - program = new Command() - docsCommand(program) + tako = new Tako() + tako.command('docs', docsArgs, docsValidation, docsCommand) consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -46,7 +47,7 @@ describe('docsCommand', () => { text: () => Promise.resolve(mockContent), } as Response) - await program.parseAsync(['node', 'test', 'docs']) + await tako.cli({ config: { args: ['docs'] } }) expect(fetch).toHaveBeenCalledWith('https://hono.dev/llms.txt') expect(consoleLogSpy).toHaveBeenCalledWith('Fetching Hono documentation...') @@ -61,7 +62,7 @@ describe('docsCommand', () => { text: () => Promise.resolve(mockMarkdown), } as Response) - await program.parseAsync(['node', 'test', 'docs', '/docs/concepts/stacks']) + await tako.cli({ config: { args: ['docs', '/docs/concepts/stacks'] } }) expect(fetch).toHaveBeenCalledWith( 'https://raw.githubusercontent.com/honojs/website/refs/heads/main/docs/concepts/stacks.md' @@ -80,7 +81,7 @@ describe('docsCommand', () => { text: () => Promise.resolve(mockMarkdown), } as Response) - await program.parseAsync(['node', 'test', 'docs', '/examples/stytch-auth']) + await tako.cli({ config: { args: ['docs', '/examples/stytch-auth'] } }) expect(fetch).toHaveBeenCalledWith( 'https://raw.githubusercontent.com/honojs/website/refs/heads/main/examples/stytch-auth.md' @@ -99,7 +100,7 @@ describe('docsCommand', () => { text: () => Promise.resolve(mockMarkdown), } as Response) - await program.parseAsync(['node', 'test', 'docs', 'examples/basic']) + await tako.cli({ config: { args: ['docs', 'examples/basic'] } }) expect(fetch).toHaveBeenCalledWith( 'https://raw.githubusercontent.com/honojs/website/refs/heads/main/examples/basic.md' @@ -115,7 +116,7 @@ describe('docsCommand', () => { statusText: 'Not Found', } as Response) - await program.parseAsync(['node', 'test', 'docs']) + await tako.cli({ config: { args: ['docs'] } }) expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error fetching documentation:', @@ -131,7 +132,7 @@ describe('docsCommand', () => { statusText: 'Not Found', } as Response) - await program.parseAsync(['node', 'test', 'docs', '/docs/concepts/motivation']) + await tako.cli({ config: { args: ['docs', '/docs/concepts/motivation'] } }) expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error fetching documentation:', @@ -149,7 +150,7 @@ describe('docsCommand', () => { statusText: 'Not Found', } as Response) - await program.parseAsync(['node', 'test', 'docs', '/examples/stytch-auth']) + await tako.cli({ config: { args: ['docs', '/examples/stytch-auth'] } }) expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error fetching documentation:', @@ -164,7 +165,7 @@ describe('docsCommand', () => { const networkError = new Error('Network error') vi.mocked(fetch).mockRejectedValue(networkError) - await program.parseAsync(['node', 'test', 'docs']) + await tako.cli({ config: { args: ['docs'] } }) expect(consoleErrorSpy).toHaveBeenCalledWith('Error fetching documentation:', 'Network error') expect(consoleLogSpy).toHaveBeenCalledWith('\nPlease visit: https://hono.dev/docs') @@ -189,7 +190,8 @@ describe('docsCommand', () => { text: () => Promise.resolve(mockMarkdown), } as Response) - await program.parseAsync(['node', 'test', 'docs']) + await tako.cli({ config: { args: ['docs'] } }) + await new Promise(process.nextTick) expect(fetch).toHaveBeenCalledWith( 'https://raw.githubusercontent.com/honojs/website/refs/heads/main/docs/concepts/middleware.md' @@ -222,7 +224,8 @@ describe('docsCommand', () => { text: () => Promise.resolve(mockMarkdown), } as Response) - await program.parseAsync(['node', 'test', 'docs']) + await tako.cli({ config: { args: ['docs'] } }) + await new Promise(process.nextTick) expect(fetch).toHaveBeenCalledWith( 'https://raw.githubusercontent.com/honojs/website/refs/heads/main/docs/api/context.md' @@ -246,7 +249,7 @@ describe('docsCommand', () => { text: () => Promise.resolve(mockContent), } as Response) - await program.parseAsync(['node', 'test', 'docs']) + await tako.cli({ config: { args: ['docs'] } }) expect(fetch).toHaveBeenCalledWith('https://hono.dev/llms.txt') expect(consoleLogSpy).toHaveBeenCalledWith('Fetching Hono documentation...') diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index acb47f1..0efdfd4 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -1,6 +1,8 @@ -import { Command } from 'commander' +import { Tako } from '@takojs/tako' import { Hono } from 'hono' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import * as process from 'node:process' +import { requestArgs, requestCommand, requestValidation } from './index.js' // Mock dependencies vi.mock('node:fs', () => ({ @@ -16,10 +18,8 @@ vi.mock('../../utils/build.js', () => ({ buildAndImportApp: vi.fn(), })) -import { requestCommand } from './index.js' - describe('requestCommand', () => { - let program: Command + let tako: Tako let consoleLogSpy: ReturnType let mockModules: any let mockBuildAndImportApp: any @@ -34,8 +34,8 @@ describe('requestCommand', () => { } beforeEach(async () => { - program = new Command() - requestCommand(program) + tako = new Tako() + tako.command('request', requestArgs, requestValidation, requestCommand) consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) // Get mocked modules @@ -62,7 +62,8 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/', 'test-app.js']) + await tako.cli({ config: { args: ['request', '-P', '/', 'test-app.js'] } }) + await new Promise(process.nextTick) // Verify resolve was called with correct arguments expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'test-app.js') @@ -90,18 +91,21 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync([ - 'node', - 'test', - 'request', - '-P', - '/data', - '-X', - 'POST', - '-d', - 'test data', - 'test-app.js', - ]) + await tako.cli({ + config: { + args: [ + 'request', + '-P', + '/data', + '-X', + 'POST', + '-d', + 'test data', + 'test-app.js', + ], + }, + }) + await new Promise(process.nextTick) // Verify resolve was called with correct arguments expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'test-app.js') @@ -136,7 +140,8 @@ describe('requestCommand', () => { }) mockBuildAndImportApp.mockResolvedValue(mockApp) - await program.parseAsync(['node', 'test', 'request']) + await tako.cli({ config: { args: ['request'] } }) + await new Promise(process.nextTick) // Verify resolve was called with correct arguments for default candidates expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'src/index.ts') @@ -169,16 +174,19 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync([ - 'node', - 'test', - 'request', - '-P', - '/api/test', - '-H', - 'Authorization: Bearer token123', - 'test-app.js', - ]) + await tako.cli({ + config: { + args: [ + 'request', + '-P', + '/api/test', + '-H', + 'Authorization: Bearer token123', + 'test-app.js', + ], + }, + }) + await new Promise(process.nextTick) expect(consoleLogSpy).toHaveBeenCalledWith( JSON.stringify( @@ -205,20 +213,23 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync([ - 'node', - 'test', - 'request', - '-P', - '/api/multi', - '-H', - 'Authorization: Bearer token456', - '-H', - 'User-Agent: TestClient/1.0', - '-H', - 'X-Custom-Header: custom-value', - 'test-app.js', - ]) + await tako.cli({ + config: { + args: [ + 'request', + '-P', + '/api/multi', + '-H', + 'Authorization: Bearer token456', + '-H', + 'User-Agent: TestClient/1.0', + '-H', + 'X-Custom-Header: custom-value', + 'test-app.js', + ], + }, + }) + await new Promise(process.nextTick) expect(consoleLogSpy).toHaveBeenCalledWith( JSON.stringify( @@ -243,7 +254,8 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/api/noheader', 'test-app.js']) + await tako.cli({ config: { args: ['request', '-P', '/api/noheader', 'test-app.js'] } }) + await new Promise(process.nextTick) // Should not include any custom headers, only default ones const output = consoleLogSpy.mock.calls[0][0] as string @@ -261,18 +273,21 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync([ - 'node', - 'test', - 'request', - '-P', - '/api/malformed', - '-H', - 'MalformedHeader', // Missing colon - '-H', - 'ValidHeader: value', - 'test-app.js', - ]) + await tako.cli({ + config: { + args: [ + 'request', + '-P', + '/api/malformed', + '-H', + 'MalformedHeader', // Missing colon + '-H', + 'ValidHeader: value', + 'test-app.js', + ], + }, + }) + await new Promise(process.nextTick) // Should still work, malformed header is ignored expect(consoleLogSpy).toHaveBeenCalledWith( diff --git a/src/commands/search/index.test.ts b/src/commands/search/index.test.ts index d4f25e0..e006723 100644 --- a/src/commands/search/index.test.ts +++ b/src/commands/search/index.test.ts @@ -1,16 +1,17 @@ -import { Command } from 'commander' +import { Tako } from '@takojs/tako' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { searchCommand } from './index.js' +import { searchArgs, searchCommand, searchValidation } from './index.js' // Mock fetch globally const mockFetch = vi.fn() -global.fetch = mockFetch +globalThis.fetch = mockFetch describe('Search Command', () => { - let program: Command + let tako: Tako beforeEach(() => { - program = new Command() + tako = new Tako() + tako.command('search', searchArgs, searchValidation, searchCommand) vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -44,9 +45,8 @@ describe('Search Command', () => { }) const logSpy = vi.spyOn(console, 'log') - searchCommand(program) - await program.parseAsync(['node', 'test', 'search', 'middleware']) + await tako.cli({ config: { args: ['search', 'middleware'] } }) // Get the JSON output const jsonOutput = logSpy.mock.calls[0][0] @@ -63,12 +63,14 @@ describe('Search Command', () => { results: [ { title: 'Getting Started', + highlightedTitle: 'Getting Started', category: '', url: 'https://hono.dev/docs/getting-started', path: '/docs/getting-started', }, { title: 'Middleware', + highlightedTitle: 'Middleware', category: 'Basic Usage', url: 'https://hono.dev/docs/middleware', path: '/docs/middleware', @@ -88,9 +90,8 @@ describe('Search Command', () => { }) const logSpy = vi.spyOn(console, 'log') - searchCommand(program) - await program.parseAsync(['node', 'test', 'search', 'nonexistent']) + await tako.cli({ config: { args: ['search', 'nonexistent'] } }) const jsonOutput = logSpy.mock.calls[0][0] @@ -114,9 +115,7 @@ describe('Search Command', () => { statusText: 'Not Found', }) - searchCommand(program) - - await program.parseAsync(['node', 'test', 'search', 'test']) + await tako.cli({ config: { args: ['search', 'test'] } }) expect(errorSpy).toHaveBeenCalled() }) @@ -129,9 +128,7 @@ describe('Search Command', () => { json: async () => mockResponse, }) - searchCommand(program) - - await program.parseAsync(['node', 'test', 'search', 'test', '--limit', '3']) + await tako.cli({ config: { args: ['search', 'test', '--limit', '3'] } }) expect(mockFetch).toHaveBeenCalledWith( 'https://1GIFSU1REV-dsn.algolia.net/1/indexes/hono/query', @@ -160,9 +157,7 @@ describe('Search Command', () => { json: async () => mockResponse, }) - searchCommand(program) - - await program.parseAsync(['node', 'test', 'search', 'test', '--limit', String(limit)]) + await tako.cli({ config: { args: ['search', 'test', '--limit', String(limit)] } }) expect(warnSpy).toHaveBeenCalledWith('Limit must be a number between 1 and 20\n') diff --git a/src/commands/search/index.ts b/src/commands/search/index.ts index 6b4187e..ab005a7 100644 --- a/src/commands/search/index.ts +++ b/src/commands/search/index.ts @@ -61,14 +61,14 @@ export const searchValidation: TakoHandler = (c, next) => { c.print({ message: 'Error: Missing required argument "query"', style: 'red', level: 'error' }) return } - const { limit } = c.scriptArgs.values + const { limit } = c.scriptArgs.values as { limit?: string } if (limit) { - const parsed = parseInt(limit as string, 10) + const parsed = parseInt(limit, 10) if (isNaN(parsed) || parsed < 1 || parsed > 20) { c.print({ message: 'Limit must be a number between 1 and 20\n', style: 'yellow', level: 'warn' }) - c.scriptArgs.values.limit = 5 + c.args.values.limit = 5 } else { - c.scriptArgs.values.limit = parsed + c.args.values.limit = parsed } } next() @@ -76,7 +76,8 @@ export const searchValidation: TakoHandler = (c, next) => { export const searchCommand: TakoHandler = async (c) => { const query = c.scriptArgs.positionals[0] - const { limit, pretty } = c.scriptArgs.values + const { pretty } = c.scriptArgs.values + const { limit } = c.args.values as { limit?: number } // Search-only API key - safe to embed in public code const ALGOLIA_APP_ID = '1GIFSU1REV' @@ -99,7 +100,7 @@ export const searchCommand: TakoHandler = async (c) => { }, body: JSON.stringify({ query, - hitsPerPage: (limit as number) || 5, + hitsPerPage: limit || 5, }), }) diff --git a/src/commands/serve/index.test.ts b/src/commands/serve/index.test.ts index 1848b89..c894fd6 100644 --- a/src/commands/serve/index.test.ts +++ b/src/commands/serve/index.test.ts @@ -1,6 +1,8 @@ -import { Command } from 'commander' +import { Tako } from '@takojs/tako' import { Hono } from 'hono' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import * as process from 'node:process' +import { serveArgs, serveCommand, serveValidation } from './index.js' // Mock dependencies vi.mock('node:fs', () => ({ @@ -40,18 +42,16 @@ vi.mock('./builtin-map.js', () => ({ }, })) -import { serveCommand } from './index.js' - describe('serveCommand', () => { - let program: Command + let tako: Tako let mockModules: any let mockServe: any let mockShowRoutes: any let capturedFetchFunction: any beforeEach(async () => { - program = new Command() - serveCommand(program) + tako = new Tako() + tako.command('serve', serveArgs, serveValidation, serveCommand) // Get mocked modules mockModules = { @@ -86,7 +86,8 @@ describe('serveCommand', () => { return `${cwd}/${path}` }) - await program.parseAsync(['node', 'test', 'serve']) + await tako.cli({ config: { args: ['serve'] } }) + await new Promise((resolve) => setTimeout(resolve, 50)) // Verify serve was called with default port 7070 expect(mockServe).toHaveBeenCalledWith( @@ -104,7 +105,8 @@ describe('serveCommand', () => { return `${cwd}/${path}` }) - await program.parseAsync(['node', 'test', 'serve', '-p', '8080']) + await tako.cli({ config: { args: ['serve', '-p', '8080'] } }) + await new Promise((resolve) => setTimeout(resolve, 50)) // Verify serve was called with custom port expect(mockServe).toHaveBeenCalledWith( @@ -135,7 +137,8 @@ describe('serveCommand', () => { // Mock the import of JS file vi.doMock(absolutePath, () => ({ default: mockApp })) - await program.parseAsync(['node', 'test', 'serve', 'app.js']) + await tako.cli({ config: { args: ['serve', 'app.js'] } }) + await new Promise((resolve) => setTimeout(resolve, 50)) // Test the captured fetch function const rootRequest = new Request('http://localhost:7070/') @@ -154,7 +157,8 @@ describe('serveCommand', () => { return `${cwd}/${path}` }) - await program.parseAsync(['node', 'test', 'serve']) + await tako.cli({ config: { args: ['serve'] } }) + await new Promise((resolve) => setTimeout(resolve, 50)) // Test 404 behavior with default empty app const request = new Request('http://localhost:7070/non-existent') @@ -163,7 +167,8 @@ describe('serveCommand', () => { }) it('should create default empty app when no entry argument provided', async () => { - await program.parseAsync(['node', 'test', 'serve']) + await tako.cli({ config: { args: ['serve'] } }) + await new Promise((resolve) => setTimeout(resolve, 50)) // Verify serve was called expect(mockServe).toHaveBeenCalledWith( @@ -222,15 +227,18 @@ describe('serveCommand', () => { vi.doMock('hono/basic-auth', () => ({ basicAuth: mockBasicAuth })) vi.doMock('hono/proxy', () => ({ proxy: mockProxy })) - await program.parseAsync([ - 'node', - 'test', - 'serve', - '--use', - 'basicAuth({username: "hono", password: "hono"})', - '--use', - '(c) => proxy(`https://ramen-api.dev${new URL(c.req.url).pathname}`)', - ]) + await tako.cli({ + config: { + args: [ + 'serve', + '--use', + 'basicAuth({username: "hono", password: "hono"})', + '--use', + '(c) => proxy(`https://ramen-api.dev${new URL(c.req.url).pathname}`)', + ], + }, + }) + await new Promise((resolve) => setTimeout(resolve, 50)) // Test without auth - should get 401 const unauthorizedRequest = new Request('http://localhost:7070/shops') From 6c1ecf0493b383b75dc765f097ae02d2adeba8fa Mon Sep 17 00:00:00 2001 From: Takuro Kitahara Date: Sun, 23 Nov 2025 11:26:06 +0900 Subject: [PATCH 3/5] test: remove redundant manual async waits in CLI tests --- src/cli.ts | 2 +- src/commands/docs/index.test.ts | 2 -- src/commands/docs/index.ts | 6 +++--- src/commands/request/index.test.ts | 27 ++------------------------- src/commands/request/index.ts | 4 ++-- src/commands/search/index.ts | 14 ++++++++++---- src/commands/serve/index.test.ts | 6 ------ src/commands/serve/index.ts | 4 ++-- 8 files changed, 20 insertions(+), 45 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 624ef0c..bde31f0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,7 +8,7 @@ import { serveArgs, serveCommand, serveValidation } from './commands/serve/index const rootArgs = { metadata: { - cliName: "hono", + cliName: 'hono', version: pkg.version, help: pkg.description, }, diff --git a/src/commands/docs/index.test.ts b/src/commands/docs/index.test.ts index 15ede7a..91f27c2 100644 --- a/src/commands/docs/index.test.ts +++ b/src/commands/docs/index.test.ts @@ -191,7 +191,6 @@ describe('docsCommand', () => { } as Response) await tako.cli({ config: { args: ['docs'] } }) - await new Promise(process.nextTick) expect(fetch).toHaveBeenCalledWith( 'https://raw.githubusercontent.com/honojs/website/refs/heads/main/docs/concepts/middleware.md' @@ -225,7 +224,6 @@ describe('docsCommand', () => { } as Response) await tako.cli({ config: { args: ['docs'] } }) - await new Promise(process.nextTick) expect(fetch).toHaveBeenCalledWith( 'https://raw.githubusercontent.com/honojs/website/refs/heads/main/docs/api/context.md' diff --git a/src/commands/docs/index.ts b/src/commands/docs/index.ts index 42a0e4b..b709f5c 100644 --- a/src/commands/docs/index.ts +++ b/src/commands/docs/index.ts @@ -31,8 +31,8 @@ export const docsArgs: TakoArgs = { }, } -export const docsValidation: TakoHandler = (_c, next) => { - next() +export const docsValidation: TakoHandler = async (_c, next) => { + await next() } export const docsCommand: TakoHandler = async (c) => { @@ -56,7 +56,7 @@ export const docsCommand: TakoHandler = async (c) => { c.print({ message: [ 'Error reading from stdin:', - error instanceof Error ? error.message : String(error) + error instanceof Error ? error.message : String(error), ], style: 'red', level: 'error', diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 0efdfd4..72ea1be 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -63,7 +63,6 @@ describe('requestCommand', () => { setupBasicMocks(expectedPath, mockApp) await tako.cli({ config: { args: ['request', '-P', '/', 'test-app.js'] } }) - await new Promise(process.nextTick) // Verify resolve was called with correct arguments expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'test-app.js') @@ -93,19 +92,9 @@ describe('requestCommand', () => { await tako.cli({ config: { - args: [ - 'request', - '-P', - '/data', - '-X', - 'POST', - '-d', - 'test data', - 'test-app.js', - ], + args: ['request', '-P', '/data', '-X', 'POST', '-d', 'test data', 'test-app.js'], }, }) - await new Promise(process.nextTick) // Verify resolve was called with correct arguments expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'test-app.js') @@ -141,7 +130,6 @@ describe('requestCommand', () => { mockBuildAndImportApp.mockResolvedValue(mockApp) await tako.cli({ config: { args: ['request'] } }) - await new Promise(process.nextTick) // Verify resolve was called with correct arguments for default candidates expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'src/index.ts') @@ -176,17 +164,9 @@ describe('requestCommand', () => { await tako.cli({ config: { - args: [ - 'request', - '-P', - '/api/test', - '-H', - 'Authorization: Bearer token123', - 'test-app.js', - ], + args: ['request', '-P', '/api/test', '-H', 'Authorization: Bearer token123', 'test-app.js'], }, }) - await new Promise(process.nextTick) expect(consoleLogSpy).toHaveBeenCalledWith( JSON.stringify( @@ -229,7 +209,6 @@ describe('requestCommand', () => { ], }, }) - await new Promise(process.nextTick) expect(consoleLogSpy).toHaveBeenCalledWith( JSON.stringify( @@ -255,7 +234,6 @@ describe('requestCommand', () => { setupBasicMocks(expectedPath, mockApp) await tako.cli({ config: { args: ['request', '-P', '/api/noheader', 'test-app.js'] } }) - await new Promise(process.nextTick) // Should not include any custom headers, only default ones const output = consoleLogSpy.mock.calls[0][0] as string @@ -287,7 +265,6 @@ describe('requestCommand', () => { ], }, }) - await new Promise(process.nextTick) // Should still work, malformed header is ignored expect(consoleLogSpy).toHaveBeenCalledWith( diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index e61aa06..e7765f7 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -137,8 +137,8 @@ export const requestArgs: TakoArgs = { }, } -export const requestValidation: TakoHandler = (_c, next) => { - next() +export const requestValidation: TakoHandler = async (_c, next) => { + await next() } export const requestCommand: TakoHandler = async (c) => { diff --git a/src/commands/search/index.ts b/src/commands/search/index.ts index ab005a7..c380599 100644 --- a/src/commands/search/index.ts +++ b/src/commands/search/index.ts @@ -56,7 +56,7 @@ export const searchArgs: TakoArgs = { }, } -export const searchValidation: TakoHandler = (c, next) => { +export const searchValidation: TakoHandler = async (c, next) => { if (!c.scriptArgs.positionals[0]) { c.print({ message: 'Error: Missing required argument "query"', style: 'red', level: 'error' }) return @@ -65,13 +65,17 @@ export const searchValidation: TakoHandler = (c, next) => { if (limit) { const parsed = parseInt(limit, 10) if (isNaN(parsed) || parsed < 1 || parsed > 20) { - c.print({ message: 'Limit must be a number between 1 and 20\n', style: 'yellow', level: 'warn' }) + c.print({ + message: 'Limit must be a number between 1 and 20\n', + style: 'yellow', + level: 'warn', + }) c.args.values.limit = 5 } else { c.args.values.limit = parsed } } - next() + await next() } export const searchCommand: TakoHandler = async (c) => { @@ -171,7 +175,9 @@ export const searchCommand: TakoHandler = async (c) => { } results.forEach((result, index) => { - c.print({ message: `${index + 1}. ${formatHighlight(result.highlightedTitle || result.title)}` }) + c.print({ + message: `${index + 1}. ${formatHighlight(result.highlightedTitle || result.title)}`, + }) if (result.category) { c.print({ message: ` Category: ${result.category}` }) } diff --git a/src/commands/serve/index.test.ts b/src/commands/serve/index.test.ts index c894fd6..7d94977 100644 --- a/src/commands/serve/index.test.ts +++ b/src/commands/serve/index.test.ts @@ -87,7 +87,6 @@ describe('serveCommand', () => { }) await tako.cli({ config: { args: ['serve'] } }) - await new Promise((resolve) => setTimeout(resolve, 50)) // Verify serve was called with default port 7070 expect(mockServe).toHaveBeenCalledWith( @@ -106,7 +105,6 @@ describe('serveCommand', () => { }) await tako.cli({ config: { args: ['serve', '-p', '8080'] } }) - await new Promise((resolve) => setTimeout(resolve, 50)) // Verify serve was called with custom port expect(mockServe).toHaveBeenCalledWith( @@ -138,7 +136,6 @@ describe('serveCommand', () => { vi.doMock(absolutePath, () => ({ default: mockApp })) await tako.cli({ config: { args: ['serve', 'app.js'] } }) - await new Promise((resolve) => setTimeout(resolve, 50)) // Test the captured fetch function const rootRequest = new Request('http://localhost:7070/') @@ -158,7 +155,6 @@ describe('serveCommand', () => { }) await tako.cli({ config: { args: ['serve'] } }) - await new Promise((resolve) => setTimeout(resolve, 50)) // Test 404 behavior with default empty app const request = new Request('http://localhost:7070/non-existent') @@ -168,7 +164,6 @@ describe('serveCommand', () => { it('should create default empty app when no entry argument provided', async () => { await tako.cli({ config: { args: ['serve'] } }) - await new Promise((resolve) => setTimeout(resolve, 50)) // Verify serve was called expect(mockServe).toHaveBeenCalledWith( @@ -238,7 +233,6 @@ describe('serveCommand', () => { ], }, }) - await new Promise((resolve) => setTimeout(resolve, 50)) // Test without auth - should get 401 const unauthorizedRequest = new Request('http://localhost:7070/shops') diff --git a/src/commands/serve/index.ts b/src/commands/serve/index.ts index a8e267e..52d8f2f 100644 --- a/src/commands/serve/index.ts +++ b/src/commands/serve/index.ts @@ -50,8 +50,8 @@ export const serveArgs: TakoArgs = { }, } -export const serveValidation: TakoHandler = (_c, next) => { - next() +export const serveValidation: TakoHandler = async (_c, next) => { + await next() } export const serveCommand: TakoHandler = async (c) => { From 139625fc0c609ac0cc2c43e1b28e66c4c9b058d9 Mon Sep 17 00:00:00 2001 From: Takuro Kitahara Date: Wed, 26 Nov 2025 20:49:05 +0900 Subject: [PATCH 4/5] feat: replace commander with @takojs/tako@1.4.0 --- bun.lock | 4 +- package.json | 2 +- src/cli.ts | 4 +- src/commands/docs/index.ts | 72 ++++---- src/commands/optimize/index.test.ts | 15 +- src/commands/optimize/index.ts | 245 ++++++++++++++++------------ src/commands/request/index.ts | 119 +++++++------- src/commands/search/index.test.ts | 2 - src/commands/search/index.ts | 8 +- src/commands/serve/index.ts | 1 + src/utils/build.test.ts | 1 + src/utils/build.ts | 1 + 12 files changed, 261 insertions(+), 213 deletions(-) diff --git a/bun.lock b/bun.lock index 7211f09..8044af3 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "@hono/cli", "dependencies": { "@hono/node-server": "^1.19.5", - "@takojs/tako": "^1.3.0", + "@takojs/tako": "^1.4.0", "esbuild": "^0.25.10", "hono": "^4.9.12", }, @@ -228,7 +228,7 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="], - "@takojs/tako": ["@takojs/tako@1.3.0", "", {}, "sha512-vefFTQykYUohsTNB3Z1jNkP4mKtpRCLodJONqKGtw+XWxt9JoGoDdGo9ZeFDzj2VFJjWS2zbuQTKMgtKPVHAMQ=="], + "@takojs/tako": ["@takojs/tako@1.4.0", "", {}, "sha512-nV5Y2dSFgTA/KAvCdsXYWGKtI6yox590eBeNomyJYoajkUFYpKJqElsG6VHNxTlBPVrZr9RP93+MBgCjcXTAKg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], diff --git a/package.json b/package.json index 328e3d3..861a024 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "homepage": "https://hono.dev", "dependencies": { "@hono/node-server": "^1.19.5", - "@takojs/tako": "^1.3.0", + "@takojs/tako": "^1.4.0", "esbuild": "^0.25.10", "hono": "^4.9.12" }, diff --git a/src/cli.ts b/src/cli.ts index bde31f0..cf4de38 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ import { Tako } from '@takojs/tako' import pkg from '../package.json' with { type: 'json' } import { docsArgs, docsCommand, docsValidation } from './commands/docs/index.js' -// import { optimizeArgs, optimizeCommand, optimizeValidation } from './commands/optimize/index.js' // TODO: replace commander with @takojs/tako +import { optimizeArgs, optimizeCommand, optimizeValidation } from './commands/optimize/index.js' import { requestArgs, requestCommand, requestValidation } from './commands/request/index.js' import { searchArgs, searchCommand, searchValidation } from './commands/search/index.js' import { serveArgs, serveCommand, serveValidation } from './commands/serve/index.js' @@ -18,7 +18,7 @@ const tako = new Tako() // Register commands tako.command('docs', docsArgs, docsValidation, docsCommand) -// tako.command("optimize", optimizeArgs, optimizeValidation, optimizeCommand) // TODO: replace commander with @takojs/tako +tako.command('optimize', optimizeArgs, optimizeValidation, optimizeCommand) tako.command('request', requestArgs, requestValidation, requestCommand) tako.command('search', searchArgs, searchValidation, searchCommand) tako.command('serve', serveArgs, serveValidation, serveCommand) diff --git a/src/commands/docs/index.ts b/src/commands/docs/index.ts index b709f5c..8d25cd9 100644 --- a/src/commands/docs/index.ts +++ b/src/commands/docs/index.ts @@ -25,9 +25,46 @@ async function fetchAndDisplayContent(c: Tako, url: string, fallbackUrl?: string } } +async function getPath(c: Tako): Promise { + const pathFromArgs = c.scriptArgs.positionals[0] + if (pathFromArgs) { + return pathFromArgs + } + + // If no path provided, check for stdin input + // Check if stdin is piped (not a TTY) + if (process.stdin.isTTY) { + return + } + + try { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk) + } + const stdinInput = Buffer.concat(chunks).toString().trim() + if (!stdinInput) { + return + } + // Remove quotes if present (handles jq output without -r flag) + return stdinInput.replace(/^["'](.*)["']$/, '$1') + } catch (error) { + c.print({ + message: [ + 'Error reading from stdin:', + error instanceof Error ? error.message : String(error), + ], + style: 'red', + level: 'error', + }) + return + } +} + export const docsArgs: TakoArgs = { metadata: { help: 'Display Hono documentation', + placeholder: '[path]', }, } @@ -36,40 +73,13 @@ export const docsValidation: TakoHandler = async (_c, next) => { } export const docsCommand: TakoHandler = async (c) => { - let finalPath = c.scriptArgs.positionals[0] + const finalPath = await getPath(c) - // If no path provided, check for stdin input if (!finalPath) { - // Check if stdin is piped (not a TTY) - if (!process.stdin.isTTY) { - try { - const chunks: Buffer[] = [] - for await (const chunk of process.stdin) { - chunks.push(chunk) - } - const stdinInput = Buffer.concat(chunks).toString().trim() - if (stdinInput) { - // Remove quotes if present (handles jq output without -r flag) - finalPath = stdinInput.replace(/^["'](.*)["']$/, '$1') - } - } catch (error) { - c.print({ - message: [ - 'Error reading from stdin:', - error instanceof Error ? error.message : String(error), - ], - style: 'red', - level: 'error', - }) - } - } - // If still no path, fetch llms.txt - if (!finalPath) { - c.print({ message: 'Fetching Hono documentation...' }) - await fetchAndDisplayContent(c, 'https://hono.dev/llms.txt') - return - } + c.print({ message: 'Fetching Hono documentation...' }) + await fetchAndDisplayContent(c, 'https://hono.dev/llms.txt') + return } // Ensure path starts with / diff --git a/src/commands/optimize/index.test.ts b/src/commands/optimize/index.test.ts index b0941b0..51c8646 100644 --- a/src/commands/optimize/index.test.ts +++ b/src/commands/optimize/index.test.ts @@ -1,13 +1,14 @@ -import { Command } from 'commander' -import { describe, it, expect, beforeEach } from 'vitest' +import { Tako } from '@takojs/tako' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { execFile } from 'node:child_process' import { mkdirSync, mkdtempSync, writeFileSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { optimizeCommand } from './index' +import * as process from 'node:process' +import { optimizeArgs, optimizeCommand, optimizeValidation } from './index' -const program = new Command() -optimizeCommand(program) +const tako = new Tako() +tako.command('optimize', optimizeArgs, optimizeValidation, optimizeCommand) const npmInstall = async () => new Promise((resolve) => { @@ -27,7 +28,7 @@ describe('optimizeCommand', () => { it('should throws an error if entry file not found', async () => { await expect( - program.parseAsync(['node', 'hono', 'optimize', './non-existent-file.ts']) + tako.cli({ config: { args: ['optimize', './non-existent-file.ts'] } }) ).rejects.toThrowError() }) @@ -216,7 +217,7 @@ describe('optimizeCommand', () => { for (const file of files) { writeFileSync(join(dir, file.path), file.content) } - await program.parseAsync(['node', 'hono', 'optimize', ...(args ?? [])]) + await tako.cli({ config: { args: ['optimize', ...(args ?? [])] } }) const content = readFileSync(join(dir, result.path), 'utf-8') if (result.lineCount) { diff --git a/src/commands/optimize/index.ts b/src/commands/optimize/index.ts index 8a8dd04..58a5476 100644 --- a/src/commands/optimize/index.ts +++ b/src/commands/optimize/index.ts @@ -1,116 +1,146 @@ -import type { Command } from 'commander' +import type { TakoArgs, TakoHandler } from '@takojs/tako' import * as esbuild from 'esbuild' import type { Hono } from 'hono' import { buildInitParams, serializeInitParams } from 'hono/router/reg-exp-router' import { execFile } from 'node:child_process' import { existsSync, realpathSync, statSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' +import * as process from 'node:process' import { buildAndImportApp } from '../../utils/build.js' const DEFAULT_ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.js', 'src/index.jsx'] -export function optimizeCommand(program: Command) { - program - .command('optimize') - .description('Build optimized Hono class') - .argument('[entry]', 'entry file') - .option('-o, --outfile [outfile]', 'output file', 'dist/index.js') - .option('-m, --minify', 'minify output file') - .action(async (entry: string, options: { outfile: string; minify?: boolean }) => { - if (!entry) { - entry = - DEFAULT_ENTRY_CANDIDATES.find((entry) => existsSync(entry)) ?? DEFAULT_ENTRY_CANDIDATES[0] - } - - const appPath = resolve(process.cwd(), entry) - - if (!existsSync(appPath)) { - throw new Error(`Entry file ${entry} does not exist`) - } - - const appFilePath = realpathSync(appPath) - const app: Hono = await buildAndImportApp(appFilePath, { - external: ['@hono/node-server'], +export const optimizeArgs: TakoArgs = { + config: { + options: { + outfile: { + type: 'string', + short: 'o', + default: 'dist/index.js', + }, + minify: { + type: 'boolean', + short: 'm', + }, + }, + }, + metadata: { + help: 'Build optimized Hono class', + placeholder: '[entry]', + options: { + outfile: { + help: 'output file', + placeholder: '', + }, + minify: { + help: 'minify output file', + }, + }, + }, +} + +export const optimizeValidation: TakoHandler = async (_c, next) => { + await next() +} + +export const optimizeCommand: TakoHandler = async (c) => { + let entry = c.scriptArgs.positionals[0] + const { outfile, minify } = c.scriptArgs.values as { outfile?: string; minify?: boolean } + if (!entry) { + entry = + DEFAULT_ENTRY_CANDIDATES.find((entry) => existsSync(entry)) ?? DEFAULT_ENTRY_CANDIDATES[0] + } + + const appPath = resolve(process.cwd(), entry) + + if (!existsSync(appPath)) { + throw new Error(`Entry file ${entry} does not exist`) + } + + const appFilePath = realpathSync(appPath) + const app: Hono = await buildAndImportApp(appFilePath, { + external: ['@hono/node-server'], + }) + + let routerName + let importStatement + let assignRouterStatement + try { + const serialized = serializeInitParams( + buildInitParams({ + paths: app.routes.map(({ path }) => path), }) + ) - let routerName - let importStatement - let assignRouterStatement - try { - const serialized = serializeInitParams( - buildInitParams({ - paths: app.routes.map(({ path }) => path), - }) - ) - - const hasPreparedRegExpRouter = await new Promise((resolve) => { - const child = execFile(process.execPath, [ - '--input-type=module', - '-e', - "try { (await import('hono/router/reg-exp-router')).PreparedRegExpRouter && process.exit(0) } finally { process.exit(1) }", - ]) - child.on('exit', (code) => { - resolve(code === 0) - }) - }) + const hasPreparedRegExpRouter = await new Promise((resolve) => { + const child = execFile(process.execPath, [ + '--input-type=module', + '-e', + "try { (await import('hono/router/reg-exp-router')).PreparedRegExpRouter && process.exit(0) } finally { process.exit(1) }", + ]) + child.on('exit', (code) => { + resolve(code === 0) + }) + }) - if (hasPreparedRegExpRouter) { - routerName = 'PreparedRegExpRouter' - importStatement = "import { PreparedRegExpRouter } from 'hono/router/reg-exp-router'" - assignRouterStatement = `const routerParams = ${serialized} + if (hasPreparedRegExpRouter) { + routerName = 'PreparedRegExpRouter' + importStatement = "import { PreparedRegExpRouter } from 'hono/router/reg-exp-router'" + assignRouterStatement = `const routerParams = ${serialized} this.router = new PreparedRegExpRouter(...routerParams)` - } else { - routerName = 'RegExpRouter' - importStatement = "import { RegExpRouter } from 'hono/router/reg-exp-router'" - assignRouterStatement = 'this.router = new RegExpRouter()' - } - } catch { - // fallback to default router - routerName = 'TrieRouter' - importStatement = "import { TrieRouter } from 'hono/router/trie-router'" - assignRouterStatement = 'this.router = new TrieRouter()' - } - - console.log('[Optimized]') - console.log(` Router: ${routerName}`) - - const outfile = resolve(process.cwd(), options.outfile) - await esbuild.build({ - entryPoints: [appFilePath], - outfile, - bundle: true, - minify: options.minify, - format: 'esm', - target: 'node20', - platform: 'node', - jsx: 'automatic', - jsxImportSource: 'hono/jsx', - plugins: [ - { - name: 'hono-optimize', - setup(build) { - const honoPseudoImportPath = 'hono-optimized-pseudo-import-path' - - build.onResolve({ filter: /^hono$/ }, async (args) => { - if (!args.importer) { - // prevent recursive resolution of "hono" - return undefined - } - - // resolve original import path for "hono" - const resolved = await build.resolve(args.path, { - kind: 'import-statement', - resolveDir: args.resolveDir, - }) - - // mark "honoOptimize" to the resolved path for filtering - return { - path: join(dirname(resolved.path), honoPseudoImportPath), - } - }) - build.onLoad({ filter: new RegExp(`/${honoPseudoImportPath}$`) }, async () => { - return { - contents: ` + } else { + routerName = 'RegExpRouter' + importStatement = "import { RegExpRouter } from 'hono/router/reg-exp-router'" + assignRouterStatement = 'this.router = new RegExpRouter()' + } + } catch { + // fallback to default router + routerName = 'TrieRouter' + importStatement = "import { TrieRouter } from 'hono/router/trie-router'" + assignRouterStatement = 'this.router = new TrieRouter()' + } + + console.log('[Optimized]') + console.log(` Router: ${routerName}`) + + const outputFilename = outfile || 'dist/index.js' + const absoluteOutfile = resolve(process.cwd(), outputFilename) + await esbuild.build({ + entryPoints: [appFilePath], + outfile: absoluteOutfile, + bundle: true, + minify: minify, + format: 'esm', + target: 'node20', + platform: 'node', + jsx: 'automatic', + jsxImportSource: 'hono/jsx', + plugins: [ + { + name: 'hono-optimize', + setup(build) { + const honoPseudoImportPath = 'hono-optimized-pseudo-import-path' + + build.onResolve({ filter: /^hono$/ }, async (args) => { + if (!args.importer) { + // prevent recursive resolution of "hono" + return undefined + } + + // resolve original import path for "hono" + const resolved = await build.resolve(args.path, { + kind: 'import-statement', + resolveDir: args.resolveDir, + }) + + // mark "honoOptimize" to the resolved path for filtering + return { + path: join(dirname(resolved.path), honoPseudoImportPath), + } + }) + build.onLoad({ filter: new RegExp(`/${honoPseudoImportPath}$`) }, async () => { + return { + contents: ` import { HonoBase } from 'hono/hono-base' ${importStatement} export class Hono extends HonoBase { @@ -120,14 +150,13 @@ export class Hono extends HonoBase { } } `, - } - }) - }, - }, - ], - }) + } + }) + }, + }, + ], + }) - const outfileStat = statSync(outfile) - console.log(` Output: ${options.outfile} (${(outfileStat.size / 1024).toFixed(2)} KB)`) - }) + const outfileStat = statSync(absoluteOutfile) + console.log(` Output: ${outputFilename} (${(outfileStat.size / 1024).toFixed(2)} KB)`) } diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index e7765f7..6f8d13a 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -1,4 +1,4 @@ -import type { TakoArgs, TakoHandler } from '@takojs/tako' +import type { Tako, TakoArgs, TakoHandler } from '@takojs/tako' import type { Hono } from 'hono' import { existsSync, realpathSync } from 'node:fs' import { resolve } from 'node:path' @@ -15,10 +15,11 @@ interface RequestOptions { } async function executeRequest( - appPath: string | undefined, - requestPath: string, - options: RequestOptions -): Promise<{ status: number; body: string; headers: Record }> { + c: Tako +): Promise<{ status: number; body: string; headers: Record } | undefined> { + const appPath = c.scriptArgs.positionals[0] + const { path: requestPath, method, data, header } = c.scriptArgs.values as RequestOptions + // Determine entry file path let entry: string let resolvedAppPath: string @@ -35,58 +36,70 @@ async function executeRequest( resolvedAppPath = resolve(process.cwd(), entry) } - if (!existsSync(resolvedAppPath)) { - throw new Error(`Entry file ${entry} does not exist`) - } + try { + if (!existsSync(resolvedAppPath)) { + throw new Error(`Entry file ${entry} does not exist`) + } - const appFilePath = realpathSync(resolvedAppPath) - const app: Hono = await buildAndImportApp(appFilePath, { - external: ['@hono/node-server'], - }) + const appFilePath = realpathSync(resolvedAppPath) + const app: Hono = await buildAndImportApp(appFilePath, { + external: ['@hono/node-server'], + }) - if (!app || typeof app.request !== 'function') { - throw new Error('No valid Hono app exported from the file') - } + if (!app || typeof app.request !== 'function') { + throw new Error('No valid Hono app exported from the file') + } - // Build request - const url = new URL(requestPath, 'http://localhost') - const requestInit: RequestInit = { - method: options.method || 'GET', - } + // Build request + const url = new URL(requestPath || '/', 'http://localhost') + const requestInit: RequestInit = { + method: method || 'GET', + } - // Add request body if provided - if (options.data) { - requestInit.body = options.data - } + // Add request body if provided + if (data) { + requestInit.body = data + } - // Add headers if provided - if (options.header && options.header.length > 0) { - const headers = new Headers() - for (const header of options.header) { - const [key, value] = header.split(':', 2) - if (key && value) { - headers.set(key.trim(), value.trim()) + // Add headers if provided + if (header && header.length > 0) { + const headers = new Headers() + for (const h of header) { + const [key, value] = h.split(':', 2) + if (key && value) { + headers.set(key.trim(), value.trim()) + } } + requestInit.headers = headers } - requestInit.headers = headers - } - // Execute request - const request = new Request(url.href, requestInit) - const response = await app.request(request) + // Execute request + const request = new Request(url.href, requestInit) + const response = await app.request(request) - // Convert response to our format - const responseHeaders: Record = {} - response.headers.forEach((value, key) => { - responseHeaders[key] = value - }) + // Convert response to our format + const responseHeaders: Record = {} + response.headers.forEach((value, key) => { + responseHeaders[key] = value + }) - const body = await response.text() + const body = await response.text() - return { - status: response.status, - body, - headers: responseHeaders, + return { + status: response.status, + body, + headers: responseHeaders, + } + } catch (error) { + c.print({ + message: [ + 'Error processing request:', + error instanceof Error ? error.message : String(error), + ], + style: 'red', + level: 'error', + }) + return } } @@ -116,6 +129,7 @@ export const requestArgs: TakoArgs = { }, metadata: { help: 'Send request to Hono app using app.request()', + placeholder: '[file]', options: { path: { help: 'Request path', @@ -142,13 +156,8 @@ export const requestValidation: TakoHandler = async (_c, next) => { } export const requestCommand: TakoHandler = async (c) => { - const file = c.scriptArgs.positionals[0] - const { path, method, data, header } = c.scriptArgs.values - - const result = await executeRequest(file, path as string, { - method: method as string, - data: data as string, - header: header as string[] | undefined, - }) - c.print({ message: JSON.stringify(result, null, 2) }) + const result = await executeRequest(c) + if (result) { + c.print({ message: JSON.stringify(result, null, 2) }) + } } diff --git a/src/commands/search/index.test.ts b/src/commands/search/index.test.ts index e006723..ec13235 100644 --- a/src/commands/search/index.test.ts +++ b/src/commands/search/index.test.ts @@ -63,14 +63,12 @@ describe('Search Command', () => { results: [ { title: 'Getting Started', - highlightedTitle: 'Getting Started', category: '', url: 'https://hono.dev/docs/getting-started', path: '/docs/getting-started', }, { title: 'Middleware', - highlightedTitle: 'Middleware', category: 'Basic Usage', url: 'https://hono.dev/docs/middleware', path: '/docs/middleware', diff --git a/src/commands/search/index.ts b/src/commands/search/index.ts index c380599..7762880 100644 --- a/src/commands/search/index.ts +++ b/src/commands/search/index.ts @@ -44,6 +44,8 @@ export const searchArgs: TakoArgs = { }, metadata: { help: 'Search Hono documentation', + required: true, + placeholder: '', options: { limit: { help: 'Number of results to show (default: 5)', @@ -57,10 +59,6 @@ export const searchArgs: TakoArgs = { } export const searchValidation: TakoHandler = async (c, next) => { - if (!c.scriptArgs.positionals[0]) { - c.print({ message: 'Error: Missing required argument "query"', style: 'red', level: 'error' }) - return - } const { limit } = c.scriptArgs.values as { limit?: string } if (limit) { const parsed = parseInt(limit, 10) @@ -187,7 +185,7 @@ export const searchCommand: TakoHandler = async (c) => { }) } else { // Remove highlighted title from JSON output - const jsonResults = results.map(({ ...result }) => result) + const jsonResults = results.map(({ highlightedTitle, ...result }) => result) c.print({ message: JSON.stringify( { diff --git a/src/commands/serve/index.ts b/src/commands/serve/index.ts index 52d8f2f..58abc3d 100644 --- a/src/commands/serve/index.ts +++ b/src/commands/serve/index.ts @@ -34,6 +34,7 @@ export const serveArgs: TakoArgs = { }, metadata: { help: 'Start server', + placeholder: '[entry]', options: { port: { help: 'port number', diff --git a/src/utils/build.test.ts b/src/utils/build.test.ts index 7fd4e31..b1f9abc 100644 --- a/src/utils/build.test.ts +++ b/src/utils/build.test.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { Buffer } from 'node:buffer' // Mock dependencies vi.mock('esbuild', () => ({ diff --git a/src/utils/build.ts b/src/utils/build.ts index ce3848e..232835e 100644 --- a/src/utils/build.ts +++ b/src/utils/build.ts @@ -1,4 +1,5 @@ import * as esbuild from 'esbuild' +import { Buffer } from 'node:buffer' import { extname } from 'node:path' import { pathToFileURL } from 'node:url' From 3eba8f30fe00a6c0c581fa0263a9bfddea4f43b9 Mon Sep 17 00:00:00 2001 From: Takuro Kitahara Date: Wed, 26 Nov 2025 23:25:30 +0900 Subject: [PATCH 5/5] 0.1.1 --- package.json | 2 +- src/commands/optimize/index.ts | 3 +- src/commands/request/index.test.ts | 2 +- src/commands/request/index.ts | 142 ++++++++++++++++------------- src/commands/serve/index.test.ts | 23 ++--- src/commands/serve/index.ts | 3 +- src/utils/build.ts | 3 +- 7 files changed, 92 insertions(+), 86 deletions(-) diff --git a/package.json b/package.json index 861a024..a45185a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hono/cli", - "version": "0.1.0", + "version": "0.1.1", "description": "CLI for Hono", "type": "module", "bin": { diff --git a/src/commands/optimize/index.ts b/src/commands/optimize/index.ts index 58a5476..ce2bec2 100644 --- a/src/commands/optimize/index.ts +++ b/src/commands/optimize/index.ts @@ -58,9 +58,10 @@ export const optimizeCommand: TakoHandler = async (c) => { } const appFilePath = realpathSync(appPath) - const app: Hono = await buildAndImportApp(appFilePath, { + const buildIterator = buildAndImportApp(appFilePath, { external: ['@hono/node-server'], }) + const app: Hono = (await buildIterator.next()).value let routerName let importStatement diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index a5ace6f..98a2816 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -106,7 +106,7 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync(['node', 'test', 'request', '-w', '-P', '/', 'test-app.js']) + await tako.cli({ config: { args: ['request', '-w', '-P', '/', 'test-app.js'] } }) // Verify resolve was called with correct arguments expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'test-app.js') diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index d613c5f..2522498 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -12,15 +12,12 @@ interface RequestOptions { data?: string header?: string[] path?: string - watch: boolean } -async function executeRequest( - c: Tako -): Promise<{ status: number; body: string; headers: Record } | undefined> { - const appPath = c.scriptArgs.positionals[0] - const { path: requestPath, method, data, header } = c.scriptArgs.values as RequestOptions - +export function getBuildIterator( + appPath: string | undefined, + watch: boolean +): AsyncGenerator { // Determine entry file path let entry: string let resolvedAppPath: string @@ -37,70 +34,66 @@ async function executeRequest( resolvedAppPath = resolve(process.cwd(), entry) } - try { - if (!existsSync(resolvedAppPath)) { - throw new Error(`Entry file ${entry} does not exist`) - } + if (!existsSync(resolvedAppPath)) { + throw new Error(`Entry file ${entry} does not exist`) + } - const appFilePath = realpathSync(resolvedAppPath) - const app: Hono = await buildAndImportApp(appFilePath, { - external: ['@hono/node-server'], - }) + const appFilePath = realpathSync(resolvedAppPath) + return buildAndImportApp(appFilePath, { + external: ['@hono/node-server'], + watch, + }) +} - if (!app || typeof app.request !== 'function') { - throw new Error('No valid Hono app exported from the file') - } +async function executeRequest( + c: Tako, + app: Hono +): Promise<{ status: number; body: string; headers: Record }> { + if (!app || typeof app.request !== 'function') { + throw new Error('No valid Hono app exported from the file') + } - // Build request - const url = new URL(requestPath || '/', 'http://localhost') - const requestInit: RequestInit = { - method: method || 'GET', - } + const { path: requestPath, method, data, header } = c.scriptArgs.values as RequestOptions - // Add request body if provided - if (data) { - requestInit.body = data - } + // Build request + const url = new URL(requestPath || '/', 'http://localhost') + const requestInit: RequestInit = { + method: method || 'GET', + } + + // Add request body if provided + if (data) { + requestInit.body = data + } - // Add headers if provided - if (header && header.length > 0) { - const headers = new Headers() - for (const h of header) { - const [key, value] = h.split(':', 2) - if (key && value) { - headers.set(key.trim(), value.trim()) - } + // Add headers if provided + if (header && header.length > 0) { + const headers = new Headers() + for (const h of header) { + const [key, value] = h.split(':', 2) + if (key && value) { + headers.set(key.trim(), value.trim()) } - requestInit.headers = headers } + requestInit.headers = headers + } - // Execute request - const request = new Request(url.href, requestInit) - const response = await app.request(request) + // Execute request + const request = new Request(url.href, requestInit) + const response = await app.request(request) - // Convert response to our format - const responseHeaders: Record = {} - response.headers.forEach((value, key) => { - responseHeaders[key] = value - }) + // Convert response to our format + const responseHeaders: Record = {} + response.headers.forEach((value, key) => { + responseHeaders[key] = value + }) - const body = await response.text() + const body = await response.text() - return { - status: response.status, - body, - headers: responseHeaders, - } - } catch (error) { - c.print({ - message: [ - 'Error processing request:', - error instanceof Error ? error.message : String(error), - ], - style: 'red', - level: 'error', - }) - return + return { + status: response.status, + body, + headers: responseHeaders, } } @@ -126,6 +119,11 @@ export const requestArgs: TakoArgs = { short: 'H', multiple: true, }, + watch: { + type: 'boolean', + short: 'w', + default: false, + }, }, }, metadata: { @@ -148,6 +146,10 @@ export const requestArgs: TakoArgs = { help: 'Custom headers', placeholder: '
', }, + watch: { + help: 'Watch for changes and resend request', + placeholder: '
', + }, }, }, } @@ -157,8 +159,22 @@ export const requestValidation: TakoHandler = async (_c, next) => { } export const requestCommand: TakoHandler = async (c) => { - const result = await executeRequest(c) - if (result) { - c.print({ message: JSON.stringify(result, null, 2) }) + try { + const file = c.scriptArgs.positionals[0] + const { watch } = c.scriptArgs.values as { watch: boolean } + + const buildIterator = getBuildIterator(file, watch) + for await (const app of buildIterator) { + const result = await executeRequest(c, app) + if (result) { + c.print({ message: JSON.stringify(result, null, 2) }) + } + } + } catch (error) { + c.print({ + message: ['Error:', error instanceof Error ? error.message : String(error)], + style: 'red', + level: 'error', + }) } } diff --git a/src/commands/serve/index.test.ts b/src/commands/serve/index.test.ts index 7726610..3ddcbde 100644 --- a/src/commands/serve/index.test.ts +++ b/src/commands/serve/index.test.ts @@ -1,6 +1,9 @@ import { Tako } from '@takojs/tako' -import { Hono } from 'hono' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { execFile } from 'node:child_process' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import * as process from 'node:process' import { serveArgs, serveCommand, serveValidation } from './index.js' @@ -26,6 +29,7 @@ vi.mock('./builtin-map.js', () => ({ describe('serveCommand', () => { let tako: Tako + let mockEsbuild: any let mockModules: any let mockServe: any let mockShowRoutes: any @@ -54,11 +58,6 @@ describe('serveCommand', () => { }) it('should start server with default port', async () => { - mockModules.existsSync.mockReturnValue(false) - mockModules.resolve.mockImplementation((cwd: string, path: string) => { - return `${cwd}/${path}` - }) - await tako.cli({ config: { args: ['serve'] } }) // Verify serve was called with default port 7070 @@ -72,11 +71,6 @@ describe('serveCommand', () => { }) it('should start server with custom port', async () => { - mockModules.existsSync.mockReturnValue(false) - mockModules.resolve.mockImplementation((cwd: string, path: string) => { - return `${cwd}/${path}` - }) - await tako.cli({ config: { args: ['serve', '-p', '8080'] } }) // Verify serve was called with custom port @@ -122,7 +116,7 @@ export default app }) }) - await tako.cli({ config: { args: ['serve', 'app.js'] } }) + await tako.cli({ config: { args: ['serve', appFile] } }) // Test the captured fetch function const rootRequest = new Request('http://localhost:7070/') @@ -136,11 +130,6 @@ export default app }) it('should return 404 for non-existent routes when no app file exists', async () => { - mockModules.existsSync.mockReturnValue(false) - mockModules.resolve.mockImplementation((cwd: string, path: string) => { - return `${cwd}/${path}` - }) - await tako.cli({ config: { args: ['serve'] } }) // Test 404 behavior with default empty app diff --git a/src/commands/serve/index.ts b/src/commands/serve/index.ts index 58abc3d..a3e11de 100644 --- a/src/commands/serve/index.ts +++ b/src/commands/serve/index.ts @@ -71,9 +71,10 @@ export const serveCommand: TakoHandler = async (c) => { app = new Hono() } else { const appFilePath = realpathSync(appPath) - app = await buildAndImportApp(appFilePath, { + const buildIterator = buildAndImportApp(appFilePath, { external: ['@hono/node-server'], }) + app = (await buildIterator.next()).value } } diff --git a/src/utils/build.ts b/src/utils/build.ts index 064afa7..87e4102 100644 --- a/src/utils/build.ts +++ b/src/utils/build.ts @@ -1,7 +1,6 @@ import * as esbuild from 'esbuild' +import type { Hono } from 'hono' import { Buffer } from 'node:buffer' -import { extname } from 'node:path' -import { pathToFileURL } from 'node:url' export interface BuildOptions { external?: string[]