mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(cli): add orca ports to list a workspace's detected ports (#33016)
* feat(cli): add orca ports to list a workspace's detected ports Scripts and agents had no way to read the ports Orca already detects for a workspace, so finding a dev server on a random port meant redoing the lsof scan by hand. `orca ports` reuses the host-scoped workspacePorts.scanHost RPC, keeps the rows owned by the current (or --worktree) workspace, and prints port, pid, process, connect host, protocol, how the owner was matched, and the advertised URL. --all shows every listener on that host. An older runtime without scanHost gets an "update Orca" error instead of a raw method_not_found. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): resolve folder workspaces in orca ports and match rows by path Run inside a native folder, `orca ports` failed with selector_not_found (or picked an enclosing git worktree) because it only searched the worktree list. It now resolves the cwd like `agent launch`, native folders first, and shows that host's listeners. Rows are matched on the owner's path instead of its id, so every instance of a repo-backed folder workspace keeps its ports even though attribution names only the first instance as owner. `--all` adds the worktree id when two owners share a display name, and the skill guide notes that only workspace rows carry `owner` and `advertisedUrl`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): refuse orca ports for folder workspaces without --all; document the command Co-authored-by: Manuel Meurer <manuel@meurer.io> * fix(cli): recognise native folder path selectors in orca ports; note SSH scan limits Co-authored-by: Manuel Meurer <manuel@meurer.io> * fix(cli): refuse a folder path shared by several folder workspaces in orca ports Co-authored-by: Manuel Meurer <manuel@meurer.io> * fix(cli): refuse a path a worktree and a folder workspace share in orca ports Co-authored-by: Manuel Meurer <manuel@meurer.io> --------- Co-authored-by: Manuel Meurer <manuel@meurer.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Manuel Meurer
parent
e12e8bafef
commit
cedce2aa9f
@@ -133,6 +133,16 @@ Omitting a flag leaves its link unchanged. On `set`, literal `null` clears only
|
||||
|
||||
Folder-based repositories can store numeric links through these commands, but a number does not supply a provider host or project. Without existing source context or a stored remote, pasted GitLab URLs are refused and provider details or clickable links may be unavailable. These flags use fields already supported by the runtime; an older runtime that predates a field may ignore it, so check `worktree show --json` after writing to an older host.
|
||||
|
||||
## Ports
|
||||
|
||||
```bash
|
||||
orca ports --json
|
||||
orca ports --worktree branch:feature/login --json
|
||||
orca ports --all
|
||||
```
|
||||
|
||||
`ports` lists the listening ports Orca tied to a workspace, from the host the workspace runs on. It defaults to the workspace that contains the current directory. Each row has `port`, `pid`, `processName`, `connectHost`, `protocol`, how Orca matched it (`owner.confidence`: `cwd` or `command`), and `advertisedUrl` when the dev server printed one in an Orca terminal. `--all` lists every listener on that host. Native folder workspaces need `--all`, because Orca does not tie ports to them. If `unavailableReason` is set, the scan could not run on that host; SSH workspaces opened directly from the desktop report this for now, while a workspace on a managed Orca server is scanned on that server.
|
||||
|
||||
## References
|
||||
|
||||
A workspace can link multiple reviews and issues, including sources outside its repository.
|
||||
|
||||
@@ -100,6 +100,8 @@ ORCA worktree set --worktree active --pr null --gitlab-mr null --json
|
||||
ORCA worktree rm --worktree id:<repoId>::<worktreePath> --force --json
|
||||
```
|
||||
|
||||
Ports: `ORCA ports --json` lists the listening ports Orca tied to the current workspace (`--worktree <selector>` for another one, `--all` for every listener on that workspace's host). Each row has `port`, `pid`, `processName`, `connectHost`, `protocol`, and `kind` (`workspace`, `container`, or `external`). Only `workspace` rows have `owner` (with `owner.confidence`: `cwd` or `command`) and, when the dev server printed one in an Orca terminal, `advertisedUrl`. Use this to find a dev server's port instead of running `lsof` yourself. If `unavailableReason` is set, the scan could not run on that host. Native folder workspaces need `--all`, because Orca does not tie ports to them. SSH workspaces opened directly from this app report `unavailableReason` for now; a workspace on a managed Orca server is scanned on that server.
|
||||
|
||||
Use `repo set --external-worktree-visibility show` to show a repo's non-Orca worktrees.
|
||||
`hide` hides them; `inherit` clears the repo override and follows the global default.
|
||||
Per-worktree visibility rules still apply.
|
||||
|
||||
@@ -159,6 +159,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
|
||||
'linear',
|
||||
'skills',
|
||||
'search',
|
||||
'ports',
|
||||
'agent-context'
|
||||
].includes(commandPath[0])
|
||||
) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -71,6 +71,9 @@ const COMMAND_SCOPED_FLAG_HELP: Record<string, Record<string, string>> = {
|
||||
sort: '--sort <order> relevance (default) or newest',
|
||||
debug: '--debug Include the planner route the host used',
|
||||
'index-status': '--index-status Report the index instead of searching'
|
||||
},
|
||||
ports: {
|
||||
all: '--all List every listener on the workspace host, not just this workspace'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -286,5 +286,10 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
|
||||
name: 'search',
|
||||
keys: ['search'],
|
||||
load: async () => (await import('./handlers/search.js')).SEARCH_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'ports',
|
||||
keys: ['ports'],
|
||||
load: async () => (await import('./handlers/ports.js')).PORTS_HANDLERS
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
|
||||
import type { RuntimeWorktreeRecord } from '../../shared/runtime-types'
|
||||
import type { WorkspacePortHostScanResult } from '../../shared/workspace-ports'
|
||||
import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import { resolveCurrentAgentWorkspaceSelector } from '../agent-workspace-selector'
|
||||
import type { CommandHandler, HandlerContext } from '../dispatch'
|
||||
import { getOptionalStringFlag } from '../flags'
|
||||
import { printResult } from '../format'
|
||||
import { formatPortsScan } from '../ports-format'
|
||||
import {
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError,
|
||||
type RuntimeRpcSuccess
|
||||
} from '../runtime-client'
|
||||
import { normalizeWorktreeSelectorForCaller } from '../selectors'
|
||||
|
||||
type PortsTarget = { selector: string; worktreePath: string | null }
|
||||
|
||||
function isNativeFolderSelector(selector: string): boolean {
|
||||
return parseWorkspaceKey(selector.replace(/^id:/, ''))?.type === 'folder'
|
||||
}
|
||||
|
||||
async function listFoldersAtPath(
|
||||
client: Pick<HandlerContext['client'], 'call'>,
|
||||
folderPath: string
|
||||
): Promise<FolderWorkspace[]> {
|
||||
try {
|
||||
const { result } = await client.call<{ folderWorkspaces: FolderWorkspace[] }>(
|
||||
'folderWorkspace.list'
|
||||
)
|
||||
const target = normalizeRuntimePathForComparison(folderPath)
|
||||
return result.folderWorkspaces.filter(
|
||||
(row) => normalizeRuntimePathForComparison(row.folderPath) === target
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeRpcFailureError && error.code === 'method_not_found') {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function worktreeIdAtSelector(
|
||||
client: Pick<HandlerContext['client'], 'call'>,
|
||||
selector: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const shown = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
|
||||
worktree: selector
|
||||
})
|
||||
return shown.result.worktree.id
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeRpcFailureError && error.code === 'selector_not_found') {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Why: worktree path selectors never match a native folder, so a folder's path is named by its id.
|
||||
async function resolveNativeFolderPathSelector(
|
||||
selector: string,
|
||||
client: Pick<HandlerContext['client'], 'call'>
|
||||
): Promise<string> {
|
||||
if (!selector.startsWith('path:')) {
|
||||
return selector
|
||||
}
|
||||
const folders = await listFoldersAtPath(client, selector.slice('path:'.length))
|
||||
if (folders.length === 0) {
|
||||
return selector
|
||||
}
|
||||
const worktreeId = await worktreeIdAtSelector(client, selector)
|
||||
const candidates = [
|
||||
...(worktreeId ? [`id:${worktreeId}`] : []),
|
||||
...folders.map((row) => `folder:${row.id}`)
|
||||
]
|
||||
// Why refuse: workspaces on different hosts can share a path, and guessing would scan the wrong host.
|
||||
if (candidates.length > 1) {
|
||||
throw new RuntimeClientError(
|
||||
'selector_ambiguous',
|
||||
`More than one workspace has this path. Pass one of: ${candidates.join(', ')}.`
|
||||
)
|
||||
}
|
||||
return `id:${candidates[0]}`
|
||||
}
|
||||
|
||||
async function resolvePortsTarget(
|
||||
ctx: Pick<HandlerContext, 'flags' | 'cwd' | 'client'>,
|
||||
options: { needsWorktreePath: boolean }
|
||||
): Promise<PortsTarget> {
|
||||
const { flags, cwd, client } = ctx
|
||||
const explicit = getOptionalStringFlag(flags, 'worktree')
|
||||
const selector =
|
||||
!explicit || explicit === 'active' || explicit === 'current'
|
||||
? await resolveCurrentAgentWorkspaceSelector(cwd, client)
|
||||
: await resolveNativeFolderPathSelector(
|
||||
await normalizeWorktreeSelectorForCaller(explicit, cwd, client),
|
||||
client
|
||||
)
|
||||
if (!options.needsWorktreePath) {
|
||||
return { selector, worktreePath: null }
|
||||
}
|
||||
// Why refuse: Orca ties listeners only to repo-backed workspaces, and host-wide rows would hand
|
||||
// scripts another workspace's dev server.
|
||||
if (isNativeFolderSelector(selector)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Orca does not tie ports to native folder workspaces yet. Run `orca ports --all` to list every listener on the host.'
|
||||
)
|
||||
}
|
||||
const shown = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
|
||||
worktree: selector
|
||||
})
|
||||
return { selector, worktreePath: shown.result.worktree.git.path }
|
||||
}
|
||||
|
||||
// Why: an older runtime has no host-scoped scan; its raw method_not_found reads as a CLI bug.
|
||||
async function scanPortsOnWorkspaceHost(
|
||||
client: Pick<HandlerContext['client'], 'call'>,
|
||||
selector: string
|
||||
): Promise<RuntimeRpcSuccess<WorkspacePortHostScanResult>> {
|
||||
try {
|
||||
return await client.call<WorkspacePortHostScanResult>('workspacePorts.scanHost', {
|
||||
worktree: selector
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeClientError && error.code === 'method_not_found') {
|
||||
throw new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'This Orca runtime cannot list workspace ports yet. Update Orca and try again.'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Why the path, not the id: folder workspace instances share one path, and attribution names only
|
||||
// the first of them as owner.
|
||||
function selectWorkspacePorts(
|
||||
scan: WorkspacePortHostScanResult,
|
||||
worktreePath: string | null
|
||||
): WorkspacePortHostScanResult {
|
||||
const ports = scan.ports
|
||||
.filter(
|
||||
(port) =>
|
||||
worktreePath === null || (port.kind === 'workspace' && port.owner.path === worktreePath)
|
||||
)
|
||||
.sort((a, b) => a.port - b.port || (a.processName ?? '').localeCompare(b.processName ?? ''))
|
||||
return { ...scan, ports }
|
||||
}
|
||||
|
||||
export const PORTS_HANDLERS: Record<string, CommandHandler> = {
|
||||
ports: async ({ flags, client, cwd, json }) => {
|
||||
const all = flags.get('all') === true
|
||||
const target = await resolvePortsTarget({ flags, cwd, client }, { needsWorktreePath: !all })
|
||||
const response = await scanPortsOnWorkspaceHost(client, target.selector)
|
||||
const worktreePath = all ? null : target.worktreePath
|
||||
const result = selectWorkspacePorts(response.result, worktreePath)
|
||||
printResult({ ...response, result }, json, (scan) =>
|
||||
formatPortsScan(scan, { showOwner: worktreePath === null })
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspacePort } from '../shared/workspace-ports'
|
||||
|
||||
const {
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
} = vi.hoisted(() => ({
|
||||
callMock: vi.fn(),
|
||||
runtimeClientConstructorMock: vi.fn(),
|
||||
serveOrcaAppMock: vi.fn(),
|
||||
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
|
||||
addEnvironmentFromPairingCodeMock: vi.fn(),
|
||||
listEnvironmentsMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-client', async () => {
|
||||
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
|
||||
return createRuntimeClientModuleMock({
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('./runtime/environments', () => ({
|
||||
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
|
||||
listEnvironments: listEnvironmentsMock,
|
||||
removeEnvironment: vi.fn(),
|
||||
resolveEnvironment: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', async () => {
|
||||
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
|
||||
return createChildProcessModuleMock(spawnMock)
|
||||
})
|
||||
|
||||
import { main } from './index'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
|
||||
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
|
||||
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
|
||||
|
||||
const FEATURE_ID = 'repo::/tmp/repo/feature'
|
||||
|
||||
function workspacePort(
|
||||
port: number,
|
||||
worktreeId: string,
|
||||
extra: { advertisedUrl?: string; confidence?: 'cwd' | 'command' } = {}
|
||||
): WorkspacePort {
|
||||
return {
|
||||
id: `127.0.0.1:${port}:${port + 1}`,
|
||||
bindHost: '127.0.0.1',
|
||||
connectHost: 'localhost',
|
||||
port,
|
||||
pid: port + 1,
|
||||
processName: 'node',
|
||||
protocol: 'http',
|
||||
kind: 'workspace',
|
||||
owner: {
|
||||
worktreeId,
|
||||
repoId: 'repo',
|
||||
displayName: worktreeId.endsWith('feature') ? 'feature' : 'other',
|
||||
path: worktreeId.split('::')[1],
|
||||
confidence: extra.confidence ?? 'cwd'
|
||||
},
|
||||
...(extra.advertisedUrl ? { advertisedUrl: extra.advertisedUrl } : {})
|
||||
}
|
||||
}
|
||||
|
||||
const externalPort: WorkspacePort = {
|
||||
id: '0.0.0.0:5432:77',
|
||||
bindHost: '0.0.0.0',
|
||||
connectHost: 'localhost',
|
||||
port: 5432,
|
||||
pid: 77,
|
||||
processName: 'postgres',
|
||||
protocol: 'unknown',
|
||||
kind: 'external'
|
||||
}
|
||||
|
||||
const hostScan = okFixture('req_scan', {
|
||||
executionHostId: 'local',
|
||||
platform: 'darwin',
|
||||
scannedAt: 1,
|
||||
ports: [
|
||||
workspacePort(5173, FEATURE_ID, { advertisedUrl: 'http://dev.test:5173' }),
|
||||
workspacePort(3000, FEATURE_ID, { confidence: 'command' }),
|
||||
workspacePort(4000, 'repo::/tmp/repo/other'),
|
||||
externalPort
|
||||
]
|
||||
})
|
||||
|
||||
const noFolders = okFixture('req_folders', { folderWorkspaces: [] })
|
||||
|
||||
function folderCatalog(folderPath: string): ReturnType<typeof okFixture>[] {
|
||||
return [
|
||||
okFixture('req_folders', {
|
||||
folderWorkspaces: [{ id: 'f1', folderPath, projectGroupId: 'local' }]
|
||||
}),
|
||||
okFixture('req_groups', { groups: [{ id: 'local' }] }),
|
||||
okFixture('req_repos', { repos: [] })
|
||||
]
|
||||
}
|
||||
|
||||
function selectorNotFound(): RuntimeRpcFailureError {
|
||||
return new RuntimeRpcFailureError({
|
||||
id: 'req_show',
|
||||
ok: false,
|
||||
error: { code: 'selector_not_found', message: 'selector_not_found' },
|
||||
_meta: { runtimeId: 'test-runtime' }
|
||||
})
|
||||
}
|
||||
|
||||
function captureOutput(): () => string {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
return () => [...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')
|
||||
}
|
||||
|
||||
describe('orca ports', () => {
|
||||
useWorktreeAwarenessEnvironment({
|
||||
callMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
})
|
||||
|
||||
it('scans the host of the worktree containing cwd and keeps only its ports', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
noFolders,
|
||||
worktreeListFixture([
|
||||
buildWorktree('/tmp/repo', 'main'),
|
||||
buildWorktree('/tmp/repo/feature', 'feature/foo')
|
||||
]),
|
||||
okFixture('req_show', { worktree: buildWorktree('/tmp/repo/feature', 'feature/foo') }),
|
||||
hostScan
|
||||
)
|
||||
const output = captureOutput()
|
||||
|
||||
await main(['ports', '--json'], '/tmp/repo/feature/src')
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(4)
|
||||
expect(callMock).toHaveBeenNthCalledWith(3, 'worktree.show', { worktree: `id:${FEATURE_ID}` })
|
||||
expect(callMock).toHaveBeenNthCalledWith(4, 'workspacePorts.scanHost', {
|
||||
worktree: `id:${FEATURE_ID}`
|
||||
})
|
||||
const printed = JSON.parse(output())
|
||||
expect(printed.result.executionHostId).toBe('local')
|
||||
expect(printed.result.ports.map((port: WorkspacePort) => port.port)).toEqual([3000, 5173])
|
||||
expect(printed.result.ports[1].advertisedUrl).toBe('http://dev.test:5173')
|
||||
})
|
||||
|
||||
it('prints one row per port with pid, process, host, protocol, match, and url', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
noFolders,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
|
||||
okFixture('req_show', { worktree: buildWorktree('/tmp/repo/feature', 'feature/foo') }),
|
||||
hostScan
|
||||
)
|
||||
const output = captureOutput()
|
||||
|
||||
await main(['ports'], '/tmp/repo/feature')
|
||||
|
||||
expect(output()).toBe(
|
||||
[
|
||||
'3000 pid=3001 node localhost http match=command',
|
||||
'5173 pid=5174 node localhost http match=cwd url=http://dev.test:5173',
|
||||
'',
|
||||
'host=local'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves an explicit selector to its worktree id before filtering', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_show', { worktree: buildWorktree('/tmp/repo/other', 'other') }),
|
||||
hostScan
|
||||
)
|
||||
const output = captureOutput()
|
||||
|
||||
await main(['ports', '--worktree', 'branch:other', '--json'], '/tmp')
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.show', { worktree: 'branch:other' })
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'workspacePorts.scanHost', {
|
||||
worktree: 'branch:other'
|
||||
})
|
||||
expect(JSON.parse(output()).result.ports.map((port: WorkspacePort) => port.port)).toEqual([
|
||||
4000
|
||||
])
|
||||
})
|
||||
|
||||
it('lists every listener on the host with its owner under --all', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
noFolders,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
|
||||
hostScan
|
||||
)
|
||||
const output = captureOutput()
|
||||
|
||||
await main(['ports', '--all'], '/tmp/repo/feature')
|
||||
|
||||
expect(callMock).not.toHaveBeenCalledWith('worktree.show', expect.anything())
|
||||
|
||||
expect(output().split('\n').slice(0, 4)).toEqual([
|
||||
'3000 workspace=feature pid=3001 node localhost http match=command',
|
||||
'4000 workspace=other pid=4001 node localhost http match=cwd',
|
||||
'5173 workspace=feature pid=5174 node localhost http match=cwd url=http://dev.test:5173',
|
||||
'5432 workspace=(none) pid=77 postgres localhost unknown'
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a standalone native folder', '/tmp/folder', '/tmp/folder/src'],
|
||||
['a native folder nested in a git worktree', '/tmp/repo/feature/docs', '/tmp/repo/feature/docs']
|
||||
])(
|
||||
'refuses %s containing cwd unless --all asks for the host',
|
||||
async (_label, folderPath, cwd) => {
|
||||
queueFixtures(callMock, ...folderCatalog(folderPath))
|
||||
const output = captureOutput()
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(['ports', '--json'], cwd)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalledWith('workspacePorts.scanHost', expect.anything())
|
||||
expect(output()).toContain('does not tie ports to native folder workspaces')
|
||||
expect(process.exitCode).toBe(1)
|
||||
process.exitCode = priorExitCode
|
||||
|
||||
queueFixtures(callMock, ...folderCatalog(folderPath), hostScan)
|
||||
await main(['ports', '--all'], cwd)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('workspacePorts.scanHost', { worktree: 'id:folder:f1' })
|
||||
expect(callMock).not.toHaveBeenCalledWith('worktree.list', expect.anything())
|
||||
expect(output()).toContain('5432 workspace=(none) pid=77 postgres')
|
||||
}
|
||||
)
|
||||
|
||||
it('recognises a path selector naming a native folder', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_folders', {
|
||||
folderWorkspaces: [{ id: 'f1', folderPath: '/tmp/folder', projectGroupId: 'local' }]
|
||||
})
|
||||
)
|
||||
callMock.mockRejectedValueOnce(selectorNotFound())
|
||||
const output = captureOutput()
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(['ports', '--worktree', 'path:/tmp/folder'], '/tmp')
|
||||
|
||||
expect(callMock).not.toHaveBeenCalledWith('workspacePorts.scanHost', expect.anything())
|
||||
expect(output()).toContain('does not tie ports to native folder workspaces')
|
||||
expect(process.exitCode).toBe(1)
|
||||
process.exitCode = priorExitCode
|
||||
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_folders', {
|
||||
folderWorkspaces: [{ id: 'f1', folderPath: '/tmp/folder', projectGroupId: 'local' }]
|
||||
})
|
||||
)
|
||||
callMock.mockRejectedValueOnce(selectorNotFound())
|
||||
queueFixtures(callMock, hostScan)
|
||||
await main(['ports', '--worktree', 'path:/tmp/folder', '--all'], '/tmp')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('workspacePorts.scanHost', { worktree: 'id:folder:f1' })
|
||||
})
|
||||
|
||||
it('refuses a path that a worktree and a folder workspace share', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_folders', {
|
||||
folderWorkspaces: [{ id: 'f1', folderPath: '/srv/app', projectGroupId: 'ssh' }]
|
||||
}),
|
||||
okFixture('req_show', { worktree: buildWorktree('/srv/app', 'main') })
|
||||
)
|
||||
const output = captureOutput()
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(['ports', '--worktree', 'path:/srv/app', '--all'], '/tmp')
|
||||
|
||||
expect(callMock).not.toHaveBeenCalledWith('workspacePorts.scanHost', expect.anything())
|
||||
expect(output()).toContain('id:repo::/srv/app, folder:f1')
|
||||
expect(process.exitCode).toBe(1)
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('keeps the ports of a folder workspace instance that shares its path with the owner', async () => {
|
||||
const instance = {
|
||||
...buildWorktree('/tmp/repo/feature', 'feature/foo'),
|
||||
id: `${FEATURE_ID}::workspace:second`
|
||||
}
|
||||
queueFixtures(callMock, okFixture('req_show', { worktree: instance }), hostScan)
|
||||
const output = captureOutput()
|
||||
|
||||
await main(['ports', '--worktree', `id:${instance.id}`, '--json'], '/tmp')
|
||||
|
||||
expect(JSON.parse(output()).result.ports.map((port: WorkspacePort) => port.port)).toEqual([
|
||||
3000, 5173
|
||||
])
|
||||
})
|
||||
|
||||
it('adds the worktree id to owner names shared by two workspaces under --all', async () => {
|
||||
const twin = workspacePort(4000, 'repo2::/tmp/twin/feature')
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_scan', {
|
||||
executionHostId: 'local',
|
||||
platform: 'darwin',
|
||||
scannedAt: 1,
|
||||
ports: [workspacePort(3000, FEATURE_ID), twin, externalPort]
|
||||
})
|
||||
)
|
||||
const output = captureOutput()
|
||||
|
||||
await main(['ports', '--worktree', 'branch:feature', '--all'], '/tmp')
|
||||
|
||||
expect(output().split('\n').slice(0, 3)).toEqual([
|
||||
`3000 workspace=feature (${FEATURE_ID}) pid=3001 node localhost http match=cwd`,
|
||||
'4000 workspace=feature (repo2::/tmp/twin/feature) pid=4001 node localhost http match=cwd',
|
||||
'5432 workspace=(none) pid=77 postgres localhost unknown'
|
||||
])
|
||||
})
|
||||
|
||||
it('shows why the scan could not run', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_scan', {
|
||||
executionHostId: 'ssh:box',
|
||||
platform: 'unknown',
|
||||
scannedAt: 1,
|
||||
ports: [],
|
||||
unavailableReason: 'Could not scan the SSH host.'
|
||||
})
|
||||
)
|
||||
const output = captureOutput()
|
||||
|
||||
await main(['ports', '--worktree', 'folder:f1', '--all'], '/tmp')
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
expect(output()).toBe('Port scan unavailable: Could not scan the SSH host.\nhost=ssh:box')
|
||||
})
|
||||
|
||||
it('asks for an Orca update when the runtime predates the host-scoped scan', async () => {
|
||||
callMock.mockRejectedValueOnce(
|
||||
new RuntimeClientError('method_not_found', 'Unknown method: workspacePorts.scanHost')
|
||||
)
|
||||
const output = captureOutput()
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(['ports', '--worktree', 'id:repo::/tmp/repo/feature', '--all'], '/tmp')
|
||||
|
||||
expect(output()).toContain('cannot list workspace ports yet. Update Orca')
|
||||
expect(process.exitCode).toBe(1)
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
WorkspacePort,
|
||||
WorkspacePortHostScanResult,
|
||||
WorkspacePortOwner
|
||||
} from '../shared/workspace-ports'
|
||||
|
||||
function ownerName(owner: WorkspacePortOwner): string {
|
||||
return owner.displayName || owner.worktreeId
|
||||
}
|
||||
|
||||
// Why: two workspaces can share a display name; only their ids tell the rows apart.
|
||||
function findAmbiguousOwnerNames(ports: readonly WorkspacePort[]): Set<string> {
|
||||
const idsByName = new Map<string, Set<string>>()
|
||||
for (const port of ports) {
|
||||
if (port.kind === 'workspace') {
|
||||
const name = ownerName(port.owner)
|
||||
idsByName.set(name, (idsByName.get(name) ?? new Set()).add(port.owner.worktreeId))
|
||||
}
|
||||
}
|
||||
return new Set([...idsByName].filter(([, ids]) => ids.size > 1).map(([name]) => name))
|
||||
}
|
||||
|
||||
function formatPortOwner(port: WorkspacePort, ambiguousNames: ReadonlySet<string>): string {
|
||||
if (port.kind === 'workspace') {
|
||||
const name = ownerName(port.owner)
|
||||
return ambiguousNames.has(name)
|
||||
? `workspace=${name} (${port.owner.worktreeId})`
|
||||
: `workspace=${name}`
|
||||
}
|
||||
return port.kind === 'container' ? 'workspace=(container)' : 'workspace=(none)'
|
||||
}
|
||||
|
||||
function formatPortRow(port: WorkspacePort, ambiguousNames: ReadonlySet<string> | null): string {
|
||||
return [
|
||||
String(port.port),
|
||||
...(ambiguousNames ? [formatPortOwner(port, ambiguousNames)] : []),
|
||||
`pid=${port.pid ?? '?'}`,
|
||||
port.processName ?? '(unknown process)',
|
||||
port.connectHost,
|
||||
port.protocol,
|
||||
...(port.kind === 'workspace' ? [`match=${port.owner.confidence}`] : []),
|
||||
...(port.kind === 'workspace' && port.advertisedUrl ? [`url=${port.advertisedUrl}`] : [])
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
export function formatPortsScan(
|
||||
scan: WorkspacePortHostScanResult,
|
||||
options: { showOwner: boolean }
|
||||
): string {
|
||||
const host = `host=${scan.executionHostId}`
|
||||
if (scan.unavailableReason) {
|
||||
return `Port scan unavailable: ${scan.unavailableReason}\n${host}`
|
||||
}
|
||||
if (scan.ports.length === 0) {
|
||||
return `No listening ports found.\n${host}`
|
||||
}
|
||||
const ambiguousNames = options.showOwner ? findAmbiguousOwnerNames(scan.ports) : null
|
||||
const rows = scan.ports.map((port) => formatPortRow(port, ambiguousNames))
|
||||
return `${rows.join('\n')}\n\n${host}`
|
||||
}
|
||||
@@ -83,6 +83,7 @@ export const ROOT_HELP_TEXT_PRIMARY = [
|
||||
' worktree set Update Orca metadata for a worktree',
|
||||
' worktree rm Remove a worktree from Orca and git',
|
||||
' worktree ps Show a compact orchestration summary across worktrees',
|
||||
' ports List the listening ports Orca detected for a workspace',
|
||||
'',
|
||||
'Agents:',
|
||||
' agent launch Start an agent in a worktree, as a chat or terminal per your settings',
|
||||
|
||||
@@ -56,6 +56,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
|
||||
' orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--pr <number|null>] [--linear-issue <identifier-or-url|null>] [--gitlab-issue <number-or-url|null>] [--gitlab-mr <number-or-url|null>] [--comment <text>] [--workspace-status <id>] [--unread|--read] [--parent-worktree <selector>|--no-parent] [--json]',
|
||||
' orca worktree rm --worktree <selector> [--force] [--run-hooks] [--allow-failed-archive-hook] [--json]',
|
||||
' orca worktree ps [--limit <n>] [--json]',
|
||||
' orca ports [--worktree <selector>] [--all] [--json]',
|
||||
' orca file open <path> [--line <n> [--column <n>]] [--worktree <selector>] [--focus] [--json]',
|
||||
' orca file diff <path> [--staged] [--worktree <selector>] [--focus] [--json]',
|
||||
' orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--focus] [--json]',
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ARTIFACT_COMMAND_SPECS } from './artifacts'
|
||||
import { SEARCH_COMMAND_SPECS } from './search'
|
||||
import { PROFILE_STATE_COMMAND_SPECS } from './profile-state'
|
||||
import { REFERENCE_COMMAND_SPECS } from './reference'
|
||||
import { PORTS_COMMAND_SPECS } from './ports'
|
||||
|
||||
export const COMMAND_SPECS: CommandSpec[] = [
|
||||
...CORE_COMMAND_SPECS,
|
||||
@@ -54,5 +55,6 @@ export const COMMAND_SPECS: CommandSpec[] = [
|
||||
...EMULATOR_COMMAND_SPECS,
|
||||
...SKILL_COMMAND_SPECS,
|
||||
...SEARCH_COMMAND_SPECS,
|
||||
...PROFILE_STATE_COMMAND_SPECS
|
||||
...PROFILE_STATE_COMMAND_SPECS,
|
||||
...PORTS_COMMAND_SPECS
|
||||
]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
export const PORTS_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['ports'],
|
||||
summary: 'List the listening ports Orca detected for a workspace',
|
||||
usage: 'orca ports [--worktree <selector>] [--all] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'all'],
|
||||
notes: [
|
||||
'Defaults to the Orca workspace that contains the current directory.',
|
||||
'Scans the host the workspace runs on and lists the ports Orca tied to it, by process working directory (match=cwd) or command line (match=command).',
|
||||
'--all lists every listener found on that host, including other workspaces and processes Orca could not tie to one. A native folder workspace needs --all, because Orca does not tie ports to folders.',
|
||||
'url is the address the dev server printed in an Orca terminal; it is missing when Orca did not see one.'
|
||||
],
|
||||
examples: [
|
||||
'orca ports',
|
||||
'orca ports --worktree branch:feature/login --json',
|
||||
'orca ports --all',
|
||||
"orca ports --json | jq -r '.result.ports[0].advertisedUrl'"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -50,6 +50,7 @@ export const CLI_COMMAND_NAMES = [
|
||||
'open-url',
|
||||
'orchestration',
|
||||
'pdf',
|
||||
'ports',
|
||||
'profile',
|
||||
'project',
|
||||
'reference',
|
||||
|
||||
Reference in New Issue
Block a user