diff --git a/packages/next-lens/src/commands/inspector.ts b/packages/next-lens/src/commands/inspector.ts index 1ba53ce..d777f21 100644 --- a/packages/next-lens/src/commands/inspector.ts +++ b/packages/next-lens/src/commands/inspector.ts @@ -1,9 +1,8 @@ -import { createServer } from 'node:net' - import chalk from 'chalk' import { Command } from 'commander' import open from 'open' +import { chooseAvailablePort } from '@/lib/inspector/port' import { startInspectorServer } from '@/lib/inspector/server' import { ensureDirectory, resolveTargetDirectory } from '@/lib/utils' @@ -55,7 +54,7 @@ export const inspectorCommand = new Command('web') process.exit(1) } - const { port, conflictPort } = await chooseInspectorPort(requestedPort) + const { port, conflictPort } = await chooseAvailablePort(requestedPort) if (conflictPort) { printPortReassignment(conflictPort, port) @@ -148,50 +147,6 @@ function formatRow(label: string, value: string): string { return `${accent('›')} ${subtle(padded)} ${chalk.white(value)}` } -async function chooseInspectorPort( - preferredPort: number, -): Promise<{ port: number; conflictPort: number | null }> { - let port = preferredPort - let conflictPort: number | null = null - - for (let attempt = 0; attempt < 5; attempt += 1) { - const available = await isPortAvailable(port) - if (available) { - return { port, conflictPort } - } - - if (conflictPort === null) { - conflictPort = port - } - - port += 1 - } - - throw new Error( - `Unable to find an open port starting at ${preferredPort}. Try --port .`, - ) -} - -function isPortAvailable(port: number): Promise { - return new Promise((resolve, reject) => { - const server = createServer() - - server.once('error', (error: NodeJS.ErrnoException) => { - if (error.code === 'EADDRINUSE') { - resolve(false) - } else { - reject(error) - } - }) - - server.once('listening', () => { - server.close(() => resolve(true)) - }) - - server.listen(port) - }) -} - function printPortReassignment(conflictPort: number, fallbackPort: number) { const badge = chalk.bgYellow.black(' PORT ') const message = chalk.yellow( diff --git a/packages/next-lens/src/commands/raycast.ts b/packages/next-lens/src/commands/raycast.ts new file mode 100644 index 0000000..07f9957 --- /dev/null +++ b/packages/next-lens/src/commands/raycast.ts @@ -0,0 +1,156 @@ +import chalk from 'chalk' +import { Command } from 'commander' +import open from 'open' + +import { chooseAvailablePort } from '@/lib/inspector/port' +import { startInspectorServer } from '@/lib/inspector/server' +import { ensureDirectory, resolveTargetDirectory } from '@/lib/utils' + +const DEFAULT_PORT = 9453 +const RAYCAST_EXTENSION_DEEPLINK = + 'raycast://extensions/1weiho/next-lens/list-api-routes' +const RAYCAST_STORE_URL = 'https://www.raycast.com/1weiho/next-lens' + +const primary = chalk.cyanBright +const accent = chalk.greenBright +const subtle = chalk.dim + +export const raycastCommand = new Command('raycast') + .description( + 'Launch the inspector API server for Raycast (no UI, absolute paths)', + ) + .argument( + '[target-directory]', + 'Path to the Next.js project (defaults to the current working directory)', + ) + .option( + '-p, --port ', + 'Port to run the API server on', + String(DEFAULT_PORT), + ) + .action(async (targetDirectory, options) => { + try { + const resolvedTarget = resolveTargetDirectory(targetDirectory ?? null) + await ensureDirectory(resolvedTarget) + + const requestedPort = parseInt(options.port, 10) + if (isNaN(requestedPort) || requestedPort < 1 || requestedPort > 65535) { + console.error(chalk.red('Invalid port number')) + process.exit(1) + } + + const { port, conflictPort } = await chooseAvailablePort(requestedPort) + + if (conflictPort) { + printPortReassignment(conflictPort, port) + } + + printIntro({ + target: resolvedTarget, + port, + }) + + await startInspectorServer({ + targetDirectory: resolvedTarget, + port, + uiMode: 'none', + pathFormatForLists: 'absolute', + }) + + printReady({ port }) + + // Auto-open Raycast extension + await open(RAYCAST_EXTENSION_DEEPLINK) + } catch (error: unknown) { + printStartupError(error) + process.exit(1) + } + }) + +export default raycastCommand + +type IntroOptions = { + target: string + port: number +} + +function printIntro({ target, port }: IntroOptions) { + const divider = chalk.dim('─'.repeat(50)) + const badge = chalk.bgMagenta.black(' NEXT LENS RAYCAST ') + + console.log( + [ + '', + divider, + `${badge} ${primary.bold('API Server')}`, + divider, + formatRow('Target', target), + formatRow('Port', `${port}`), + divider, + subtle('Starting server...'), + ].join('\n'), + ) +} + +function printReady({ port }: { port: number }) { + const raycastHint = `${subtle('Install Raycast extension:')} ${chalk.underline(RAYCAST_STORE_URL)}` + + console.log( + [ + '', + accent(`http://localhost:${port}/api`), + '', + raycastHint, + '', + subtle('Press Ctrl+C to stop'), + '', + ].join('\n'), + ) +} + +function formatRow(label: string, value: string): string { + const padded = label.padEnd(7) + return `${accent('›')} ${subtle(padded)} ${chalk.white(value)}` +} + +function printPortReassignment(conflictPort: number, fallbackPort: number) { + const badge = chalk.bgYellow.black(' PORT ') + const message = chalk.yellow( + `Port ${conflictPort} is busy. Switched to ${fallbackPort}.`, + ) + + console.log( + [ + '', + `${badge} ${message}`, + subtle('Use --port to pick a custom port.'), + '', + ].join('\n'), + ) +} + +function printStartupError(error: unknown) { + const err = error as NodeJS.ErrnoException + const badge = chalk.bgRed.black(' ERROR ') + + if (err?.code === 'EADDRINUSE') { + console.error( + [ + '', + `${badge} ${chalk.red('Port is already in use.')}`, + subtle('Try another port with --port .'), + '', + ].join('\n'), + ) + return + } + + console.error( + [ + '', + `${badge} ${chalk.red('Failed to start API server.')}`, + subtle((error as Error).message), + '', + ].join('\n'), + ) +} diff --git a/packages/next-lens/src/index.ts b/packages/next-lens/src/index.ts index ca48a0e..0f6acb6 100644 --- a/packages/next-lens/src/index.ts +++ b/packages/next-lens/src/index.ts @@ -8,6 +8,7 @@ import infoCommand from '@/commands/info' import inspectorCommand from '@/commands/inspector' import mcpCommand from '@/commands/mcp' import pageListCommand from '@/commands/page-list' +import raycastCommand from '@/commands/raycast' import webBuildCommand from '@/commands/web-build' import packageJson from '../package.json' @@ -28,6 +29,7 @@ async function main() { .addCommand(pageListCommand) .addCommand(infoCommand) .addCommand(inspectorCommand) + .addCommand(raycastCommand) .addCommand(webBuildCommand) .addCommand(mcpCommand) diff --git a/packages/next-lens/src/lib/inspector/port.ts b/packages/next-lens/src/lib/inspector/port.ts new file mode 100644 index 0000000..27e691b --- /dev/null +++ b/packages/next-lens/src/lib/inspector/port.ts @@ -0,0 +1,58 @@ +import { createServer } from 'node:net' + +/** + * Check if a port is available for binding + */ +export function isPortAvailable(port: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer() + + server.once('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + resolve(false) + } else { + reject(error) + } + }) + + server.once('listening', () => { + server.close(() => resolve(true)) + }) + + server.listen(port) + }) +} + +export interface ChoosePortResult { + port: number + conflictPort: number | null +} + +/** + * Find an available port starting from the preferred port. + * Will try up to `maxAttempts` ports (default: 5) before throwing. + */ +export async function chooseAvailablePort( + preferredPort: number, + maxAttempts = 5, +): Promise { + let port = preferredPort + let conflictPort: number | null = null + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const available = await isPortAvailable(port) + if (available) { + return { port, conflictPort } + } + + if (conflictPort === null) { + conflictPort = port + } + + port += 1 + } + + throw new Error( + `Unable to find an open port starting at ${preferredPort}. Try --port .`, + ) +} diff --git a/packages/next-lens/src/lib/inspector/routes.ts b/packages/next-lens/src/lib/inspector/routes.ts index 164247c..78da7cb 100644 --- a/packages/next-lens/src/lib/inspector/routes.ts +++ b/packages/next-lens/src/lib/inspector/routes.ts @@ -3,8 +3,8 @@ import path from 'path' import { Hono } from 'hono' -import { getApiRoutes } from '../api-routes' -import { getPageRoutes } from '../page-routes' +import { getApiRoutes, RouteInfo } from '../api-routes' +import { getPageRoutes, PageInfo } from '../page-routes' import { addHttpMethod, createErrorFile, @@ -16,6 +16,15 @@ import { } from './file-operations' import { openInIDE } from './ide' +export interface ApiRouterOptions { + /** + * Format for file paths in list endpoints (GET /routes, GET /pages). + * - 'relative': paths relative to targetDirectory (default, for web UI) + * - 'absolute': absolute file system paths (for raycast/external tools) + */ + pathFormatForLists?: 'relative' | 'absolute' +} + /** * Validates that a path (after resolving symlinks) is within the target root. * Returns null if the path escapes the target root. @@ -28,10 +37,53 @@ async function isPathWithinRoot( return !normalized.startsWith('..') && !path.isAbsolute(normalized) } +/** + * Convert a relative path to absolute path based on target root + */ +function toAbsolutePath(relativePath: string, targetRoot: string): string { + return path.resolve(targetRoot, ...relativePath.split('/')) +} + +/** + * Transform routes to use absolute paths + */ +function transformRoutesToAbsolute( + routes: RouteInfo[], + targetRoot: string, +): RouteInfo[] { + return routes.map((route) => ({ + ...route, + file: toAbsolutePath(route.file, targetRoot), + })) +} + +/** + * Transform pages to use absolute paths + */ +function transformPagesToAbsolute( + pages: PageInfo[], + targetRoot: string, +): PageInfo[] { + return pages.map((page) => ({ + ...page, + file: toAbsolutePath(page.file, targetRoot), + loadingPath: page.loadingPath + ? toAbsolutePath(page.loadingPath, targetRoot) + : undefined, + errorPath: page.errorPath + ? toAbsolutePath(page.errorPath, targetRoot) + : undefined, + })) +} + /** * Create API router for the inspector */ -export function createApiRouter(targetDirectory: string) { +export function createApiRouter( + targetDirectory: string, + options: ApiRouterOptions = {}, +) { + const { pathFormatForLists = 'relative' } = options const targetRoot = path.resolve(targetDirectory) /** @@ -80,7 +132,11 @@ export function createApiRouter(targetDirectory: string) { api.get('/routes', async (c) => { try { const routes = await getApiRoutes(targetDirectory) - return c.json(routes) + const result = + pathFormatForLists === 'absolute' + ? transformRoutesToAbsolute(routes, targetRoot) + : routes + return c.json(result) } catch (error) { return c.json({ error: (error as Error).message }, 500) } @@ -90,7 +146,11 @@ export function createApiRouter(targetDirectory: string) { api.get('/pages', async (c) => { try { const pages = await getPageRoutes(targetDirectory) - return c.json(pages) + const result = + pathFormatForLists === 'absolute' + ? transformPagesToAbsolute(pages, targetRoot) + : pages + return c.json(result) } catch (error) { return c.json({ error: (error as Error).message }, 500) } diff --git a/packages/next-lens/src/lib/inspector/server.ts b/packages/next-lens/src/lib/inspector/server.ts index 55e5d96..4af2e2c 100644 --- a/packages/next-lens/src/lib/inspector/server.ts +++ b/packages/next-lens/src/lib/inspector/server.ts @@ -9,15 +9,46 @@ import { serveStatic } from '@hono/node-server/serve-static' import { Hono } from 'hono' import { cors } from 'hono/cors' -import { createApiRouter } from './routes' +import { createApiRouter, ApiRouterOptions } from './routes' const __dirname = path.dirname(fileURLToPath(import.meta.url)) +export type UiMode = 'static' | 'proxy' | 'none' + +export interface InspectorAppOptions { + targetDirectory: string + /** + * UI mode: + * - 'static': Serve bundled inspector UI (production) + * - 'proxy': Proxy to Vite dev server (development) + * - 'none': API only, no UI (for raycast/external tools) + */ + uiMode: UiMode + /** + * Vite dev server port (used when uiMode is 'proxy') + */ + vitePort?: number + /** + * Format for file paths in list endpoints (GET /routes, GET /pages) + */ + pathFormatForLists?: ApiRouterOptions['pathFormatForLists'] +} + export interface InspectorServerOptions { targetDirectory: string port: number devMode?: boolean vitePort?: number + /** + * UI mode override. If not specified: + * - devMode=true → 'proxy' + * - devMode=false → 'static' + */ + uiMode?: UiMode + /** + * Format for file paths in list endpoints + */ + pathFormatForLists?: ApiRouterOptions['pathFormatForLists'] } /** @@ -80,16 +111,23 @@ async function proxyToVite( } /** - * Start the inspector HTTP server + * Create the inspector Hono app with configurable UI mode and path format. + * This is the shared core used by both `web` and `raycast` commands. */ -export async function startInspectorServer( - options: InspectorServerOptions, -): Promise { - const { targetDirectory, port, devMode = false, vitePort = 5173 } = options +export async function createInspectorApp( + options: InspectorAppOptions, +): Promise { + const { + targetDirectory, + uiMode, + vitePort = 5173, + pathFormatForLists = 'relative', + } = options const app = new Hono() - if (devMode) { + // Enable CORS for proxy mode (dev) + if (uiMode === 'proxy') { const allowedDevOrigins = new Set([ `http://localhost:${vitePort}`, `http://127.0.0.1:${vitePort}`, @@ -106,16 +144,17 @@ export async function startInspectorServer( ) } - // Mount API routes - const api = createApiRouter(targetDirectory) + // Mount API routes with path format option + const api = createApiRouter(targetDirectory, { pathFormatForLists }) app.route('/api', api) - // Dev mode: proxy all non-API requests to Vite dev server - if (devMode) { + // Configure UI based on mode + if (uiMode === 'proxy') { + // Dev mode: proxy all non-API requests to Vite dev server app.all('*', async (c) => { return proxyToVite(c.req.raw, vitePort) }) - } else { + } else if (uiMode === 'static') { // Production mode: serve static files const staticDir = await findStaticDir() @@ -172,6 +211,34 @@ export async function startInspectorServer( }) } } + // uiMode === 'none': No UI routes, only API (for raycast) + + return app +} + +/** + * Start the inspector HTTP server + */ +export async function startInspectorServer( + options: InspectorServerOptions, +): Promise { + const { + targetDirectory, + port, + devMode = false, + vitePort = 5173, + pathFormatForLists = 'relative', + } = options + + // Determine UI mode: explicit override or derive from devMode + const uiMode: UiMode = options.uiMode ?? (devMode ? 'proxy' : 'static') + + const app = await createInspectorApp({ + targetDirectory, + uiMode, + vitePort, + pathFormatForLists, + }) return new Promise((resolve, reject) => { const server = serve( diff --git a/packages/next-lens/tests/inspector/raycast-mode.test.ts b/packages/next-lens/tests/inspector/raycast-mode.test.ts new file mode 100644 index 0000000..f17062f --- /dev/null +++ b/packages/next-lens/tests/inspector/raycast-mode.test.ts @@ -0,0 +1,162 @@ +import path from 'path' +import { fileURLToPath } from 'url' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createApiRouter } from '@/lib/inspector/routes' +import { createInspectorApp } from '@/lib/inspector/server' +import * as fileOperations from '@/lib/inspector/file-operations' +import type { RouteInfo } from '@/lib/api-routes' +import type { PageInfo } from '@/lib/page-routes' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const fixtureRoot = path.join(__dirname, '..', 'fixtures', 'mock-next-app') + +describe('createApiRouter with absolute path format', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns absolute paths for GET /routes when pathFormatForLists is absolute', async () => { + const api = createApiRouter(fixtureRoot, { pathFormatForLists: 'absolute' }) + + const response = await api.request('/routes') + expect(response.status).toBe(200) + + const routes = (await response.json()) as RouteInfo[] + expect(routes.length).toBeGreaterThan(0) + + // All file paths should be absolute + for (const route of routes) { + expect(path.isAbsolute(route.file)).toBe(true) + expect(route.file.startsWith(fixtureRoot)).toBe(true) + } + }) + + it('returns absolute paths for GET /pages when pathFormatForLists is absolute', async () => { + const api = createApiRouter(fixtureRoot, { pathFormatForLists: 'absolute' }) + + const response = await api.request('/pages') + expect(response.status).toBe(200) + + const pages = (await response.json()) as PageInfo[] + expect(pages.length).toBeGreaterThan(0) + + // All file paths should be absolute + for (const page of pages) { + expect(path.isAbsolute(page.file)).toBe(true) + expect(page.file.startsWith(fixtureRoot)).toBe(true) + + // loadingPath and errorPath should also be absolute if present + if (page.loadingPath) { + expect(path.isAbsolute(page.loadingPath)).toBe(true) + expect(page.loadingPath.startsWith(fixtureRoot)).toBe(true) + } + if (page.errorPath) { + expect(path.isAbsolute(page.errorPath)).toBe(true) + expect(page.errorPath.startsWith(fixtureRoot)).toBe(true) + } + } + }) + + it('returns relative paths for GET /routes when pathFormatForLists is relative (default)', async () => { + const api = createApiRouter(fixtureRoot) + + const response = await api.request('/routes') + expect(response.status).toBe(200) + + const routes = (await response.json()) as RouteInfo[] + expect(routes.length).toBeGreaterThan(0) + + // All file paths should be relative + for (const route of routes) { + expect(path.isAbsolute(route.file)).toBe(false) + } + }) + + it('returns relative paths for GET /pages when pathFormatForLists is relative (default)', async () => { + const api = createApiRouter(fixtureRoot) + + const response = await api.request('/pages') + expect(response.status).toBe(200) + + const pages = (await response.json()) as PageInfo[] + expect(pages.length).toBeGreaterThan(0) + + // All file paths should be relative + for (const page of pages) { + expect(path.isAbsolute(page.file)).toBe(false) + } + }) + + it('mutation responses still use relative paths regardless of pathFormatForLists', async () => { + const api = createApiRouter(fixtureRoot, { pathFormatForLists: 'absolute' }) + const jsonHeaders = { 'content-type': 'application/json' } + + // Mock createLoadingFile to return an absolute path + const mockCreatedPath = path.join(fixtureRoot, 'app/blog/loading.tsx') + vi.spyOn(fileOperations, 'createLoadingFile').mockResolvedValue( + mockCreatedPath, + ) + + const response = await api.request('/pages/loading', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ file: 'app/blog/page.tsx' }), + }) + + expect(response.status).toBe(200) + const result = (await response.json()) as { success: boolean; file: string } + expect(result.success).toBe(true) + + // The returned file path should be relative (not affected by pathFormatForLists) + expect(path.isAbsolute(result.file)).toBe(false) + expect(result.file).toBe('app/blog/loading.tsx') + }) +}) + +describe('createInspectorApp with uiMode none', () => { + it('returns 404 for root path when uiMode is none', async () => { + const app = await createInspectorApp({ + targetDirectory: fixtureRoot, + uiMode: 'none', + }) + + const response = await app.request('/') + expect(response.status).toBe(404) + }) + + it('still serves API routes when uiMode is none', async () => { + const app = await createInspectorApp({ + targetDirectory: fixtureRoot, + uiMode: 'none', + }) + + const routesResponse = await app.request('/api/routes') + expect(routesResponse.status).toBe(200) + + const pagesResponse = await app.request('/api/pages') + expect(pagesResponse.status).toBe(200) + }) + + it('combines uiMode none with pathFormatForLists absolute', async () => { + const app = await createInspectorApp({ + targetDirectory: fixtureRoot, + uiMode: 'none', + pathFormatForLists: 'absolute', + }) + + // UI should be disabled + const rootResponse = await app.request('/') + expect(rootResponse.status).toBe(404) + + // API should return absolute paths + const routesResponse = await app.request('/api/routes') + expect(routesResponse.status).toBe(200) + const routes = (await routesResponse.json()) as RouteInfo[] + expect(routes.length).toBeGreaterThan(0) + for (const route of routes) { + expect(path.isAbsolute(route.file)).toBe(true) + } + }) +})