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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 2 additions & 47 deletions packages/next-lens/src/commands/inspector.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 <number>.`,
)
}

function isPortAvailable(port: number): Promise<boolean> {
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(
Expand Down
156 changes: 156 additions & 0 deletions packages/next-lens/src/commands/raycast.ts
Original file line number Diff line number Diff line change
@@ -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>',
'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 <number> 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 <number>.'),
'',
].join('\n'),
)
return
}

console.error(
[
'',
`${badge} ${chalk.red('Failed to start API server.')}`,
subtle((error as Error).message),
'',
].join('\n'),
)
}
2 changes: 2 additions & 0 deletions packages/next-lens/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -28,6 +29,7 @@ async function main() {
.addCommand(pageListCommand)
.addCommand(infoCommand)
.addCommand(inspectorCommand)
.addCommand(raycastCommand)
.addCommand(webBuildCommand)
.addCommand(mcpCommand)

Expand Down
58 changes: 58 additions & 0 deletions packages/next-lens/src/lib/inspector/port.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { createServer } from 'node:net'

/**
* Check if a port is available for binding
*/
export function isPortAvailable(port: number): Promise<boolean> {
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<ChoosePortResult> {
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 <number>.`,
)
}
Loading