mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat: support multiple workspace roots (#584)
This commit is contained in:
@@ -38,7 +38,7 @@ export type SessionBootstrapResult = {
|
||||
workingDirectory: string
|
||||
}
|
||||
|
||||
export function buildMachineMetadata(options?: { workspaceRoot?: string }): MachineMetadata {
|
||||
export function buildMachineMetadata(options?: { workspaceRoots?: string[] }): MachineMetadata {
|
||||
return {
|
||||
host: process.env.HAPI_HOSTNAME || os.hostname(),
|
||||
platform: os.platform(),
|
||||
@@ -46,7 +46,7 @@ export function buildMachineMetadata(options?: { workspaceRoot?: string }): Mach
|
||||
homeDir: os.homedir(),
|
||||
happyHomeDir: configuration.happyHomeDir,
|
||||
happyLibDir: runtimePath(),
|
||||
workspaceRoot: options?.workspaceRoot
|
||||
workspaceRoots: options?.workspaceRoots
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -142,7 +142,7 @@ export class ApiClient {
|
||||
return new ApiSessionClient(this.token, session)
|
||||
}
|
||||
|
||||
machineSyncClient(machine: Machine, options?: { workspaceRoot?: string }): ApiMachineClient {
|
||||
return new ApiMachineClient(this.token, machine, options?.workspaceRoot)
|
||||
machineSyncClient(machine: Machine, options?: { workspaceRoots?: string[] }): ApiMachineClient {
|
||||
return new ApiMachineClient(this.token, machine, options?.workspaceRoots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,12 +62,12 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => {
|
||||
|
||||
it('rejects cwd outside the workspace root with the standard error shape', async () => {
|
||||
const machine = makeMachine('machine-1')
|
||||
const client = new ApiMachineClient('cli-token', machine, workspaceRoot)
|
||||
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
|
||||
|
||||
const outsideCwd = mkdtempSync(join(tmpdir(), 'hapi-outside-'))
|
||||
try {
|
||||
const result = await callListOpencodeModels(client, machine.id, outsideCwd)
|
||||
expect(result).toEqual({ success: false, error: 'Path is outside workspace root' })
|
||||
expect(result).toEqual({ success: false, error: 'Path is outside workspace roots' })
|
||||
expect(listOpencodeModelsForCwdMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
rmSync(outsideCwd, { recursive: true, force: true })
|
||||
@@ -77,7 +77,7 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => {
|
||||
|
||||
it('rejects empty cwd with cwd-required error', async () => {
|
||||
const machine = makeMachine('machine-2')
|
||||
const client = new ApiMachineClient('cli-token', machine, workspaceRoot)
|
||||
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
|
||||
|
||||
try {
|
||||
const result = await callListOpencodeModels(client, machine.id, '')
|
||||
@@ -90,7 +90,7 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => {
|
||||
|
||||
it('forwards a workspace-internal cwd to listOpencodeModelsForCwd', async () => {
|
||||
const machine = makeMachine('machine-3')
|
||||
const client = new ApiMachineClient('cli-token', machine, workspaceRoot)
|
||||
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
|
||||
|
||||
const innerDir = join(workspaceRoot, 'inner-project')
|
||||
mkdirSync(innerDir)
|
||||
@@ -115,4 +115,29 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => {
|
||||
client.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts cwd inside any configured workspace root', async () => {
|
||||
const machine = makeMachine('machine-4')
|
||||
const secondWorkspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-machine-ws-2-'))
|
||||
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot, secondWorkspaceRoot])
|
||||
|
||||
listOpencodeModelsForCwdMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
availableModels: [{ modelId: 'x/y' }],
|
||||
currentModelId: 'x/y'
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await callListOpencodeModels(client, machine.id, secondWorkspaceRoot)
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
availableModels: [{ modelId: 'x/y' }],
|
||||
currentModelId: 'x/y'
|
||||
})
|
||||
expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(secondWorkspaceRoot)
|
||||
} finally {
|
||||
rmSync(secondWorkspaceRoot, { recursive: true, force: true })
|
||||
client.shutdown()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+65
-40
@@ -90,31 +90,52 @@ interface ListMachineDirectoryResponse {
|
||||
error?: string
|
||||
}
|
||||
|
||||
function normalizeWorkspaceRoots(paths?: string[]): string[] | undefined {
|
||||
if (!paths?.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalized = Array.from(new Set(paths.map((path) => {
|
||||
try {
|
||||
return realpathSync(path)
|
||||
} catch {
|
||||
return resolvePath(path)
|
||||
}
|
||||
})))
|
||||
|
||||
return normalized.length > 0 ? normalized : undefined
|
||||
}
|
||||
|
||||
function workspaceRootsEqual(left?: string[], right?: string[]): boolean {
|
||||
const normalizedLeft = left ?? []
|
||||
const normalizedRight = right ?? []
|
||||
if (normalizedLeft.length !== normalizedRight.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return normalizedLeft.every((value, index) => value === normalizedRight[index])
|
||||
}
|
||||
|
||||
function formatWorkspaceRoots(paths?: string[]): string {
|
||||
return paths?.length ? paths.join(', ') : '(none)'
|
||||
}
|
||||
|
||||
export class ApiMachineClient {
|
||||
private socket!: Socket<ServerToRunnerEvents, RunnerToServerEvents>
|
||||
private keepAliveInterval: NodeJS.Timeout | null = null
|
||||
private rpcHandlerManager: RpcHandlerManager
|
||||
|
||||
private readonly normalizedWorkspaceRoot: string | undefined
|
||||
private readonly normalizedWorkspaceRoots: string[] | undefined
|
||||
|
||||
constructor(
|
||||
private readonly token: string,
|
||||
private readonly machine: Machine,
|
||||
private readonly workspaceRoot?: string
|
||||
private readonly workspaceRoots?: string[]
|
||||
) {
|
||||
// realpath the root once so all subsequent comparisons are against
|
||||
// the canonical, symlink-resolved path. Falls back to a lexical
|
||||
// resolve if realpath fails (e.g. unusual permission setup) so we
|
||||
// still get *some* protection rather than skipping the check.
|
||||
if (workspaceRoot) {
|
||||
try {
|
||||
this.normalizedWorkspaceRoot = realpathSync(workspaceRoot)
|
||||
} catch {
|
||||
this.normalizedWorkspaceRoot = resolvePath(workspaceRoot)
|
||||
}
|
||||
} else {
|
||||
this.normalizedWorkspaceRoot = undefined
|
||||
}
|
||||
// Realpath roots once so all subsequent comparisons are against
|
||||
// canonical, symlink-resolved locations. Falls back to lexical
|
||||
// resolution if realpath fails so we still get protection.
|
||||
this.normalizedWorkspaceRoots = normalizeWorkspaceRoots(workspaceRoots)
|
||||
|
||||
this.rpcHandlerManager = new RpcHandlerManager({
|
||||
scopePrefix: this.machine.id,
|
||||
@@ -143,7 +164,7 @@ export class ApiMachineClient {
|
||||
})
|
||||
|
||||
this.rpcHandlerManager.registerHandler<ListMachineDirectoryRequest, ListMachineDirectoryResponse>('list-directory', async (params) => {
|
||||
if (!this.normalizedWorkspaceRoot) {
|
||||
if (!this.normalizedWorkspaceRoots?.length) {
|
||||
return { success: false, error: 'Workspace browsing is not enabled for this machine' }
|
||||
}
|
||||
|
||||
@@ -153,8 +174,8 @@ export class ApiMachineClient {
|
||||
}
|
||||
|
||||
const targetPath = await this.resolveForWorkspaceCheck(rawPath)
|
||||
if (!this.isWithinWorkspaceRoot(targetPath)) {
|
||||
return { success: false, error: 'Path is outside workspace root' }
|
||||
if (!this.isWithinWorkspaceRoots(targetPath)) {
|
||||
return { success: false, error: 'Path is outside workspace roots' }
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -228,8 +249,8 @@ export class ApiMachineClient {
|
||||
}
|
||||
|
||||
const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd)
|
||||
if (!this.isWithinWorkspaceRoot(resolvedCwd)) {
|
||||
return { success: false, error: 'Path is outside workspace root' }
|
||||
if (!this.isWithinWorkspaceRoots(resolvedCwd)) {
|
||||
return { success: false, error: 'Path is outside workspace roots' }
|
||||
}
|
||||
|
||||
return await listOpencodeModelsForCwd(resolvedCwd)
|
||||
@@ -237,10 +258,12 @@ export class ApiMachineClient {
|
||||
)
|
||||
}
|
||||
|
||||
private isWithinWorkspaceRoot(absolutePath: string): boolean {
|
||||
if (!this.normalizedWorkspaceRoot) return true
|
||||
const rel = relative(this.normalizedWorkspaceRoot, absolutePath)
|
||||
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
|
||||
private isWithinWorkspaceRoots(absolutePath: string): boolean {
|
||||
if (!this.normalizedWorkspaceRoots?.length) return true
|
||||
return this.normalizedWorkspaceRoots.some((workspaceRoot) => {
|
||||
const rel = relative(workspaceRoot, absolutePath)
|
||||
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,8 +306,8 @@ export class ApiMachineClient {
|
||||
}
|
||||
|
||||
const resolvedDirectory = await this.resolveForWorkspaceCheck(directory)
|
||||
if (!this.isWithinWorkspaceRoot(resolvedDirectory)) {
|
||||
return { type: 'error', errorMessage: 'Directory is outside this machine\'s workspace root' }
|
||||
if (!this.isWithinWorkspaceRoots(resolvedDirectory)) {
|
||||
return { type: 'error', errorMessage: 'Directory is outside this machine\'s workspace roots' }
|
||||
}
|
||||
|
||||
const result = await spawnSession({
|
||||
@@ -428,31 +451,33 @@ export class ApiMachineClient {
|
||||
logger.debug('[API MACHINE] Failed to update runner state on connect', error)
|
||||
})
|
||||
|
||||
const hubWorkspaceRoot = this.machine.metadata?.workspaceRoot
|
||||
const desiredWorkspaceRoot = this.workspaceRoot
|
||||
if (desiredWorkspaceRoot !== hubWorkspaceRoot) {
|
||||
if (desiredWorkspaceRoot) {
|
||||
console.log(`[HAPI] Syncing workspace root to hub: ${desiredWorkspaceRoot} (current hub value: ${hubWorkspaceRoot ?? 'none'})`)
|
||||
const hubWorkspaceRoots = this.machine.metadata?.workspaceRoots
|
||||
const desiredWorkspaceRoots = this.workspaceRoots
|
||||
if (!workspaceRootsEqual(desiredWorkspaceRoots, hubWorkspaceRoots)) {
|
||||
if (desiredWorkspaceRoots?.length) {
|
||||
console.log(`[HAPI] Syncing workspace roots to hub: ${formatWorkspaceRoots(desiredWorkspaceRoots)} (current hub value: ${formatWorkspaceRoots(hubWorkspaceRoots)})`)
|
||||
} else {
|
||||
console.log(`[HAPI] Clearing workspace root on hub (was: ${hubWorkspaceRoot})`)
|
||||
console.log(`[HAPI] Clearing workspace roots on hub (was: ${formatWorkspaceRoots(hubWorkspaceRoots)})`)
|
||||
}
|
||||
this.updateMachineMetadata((current) => {
|
||||
const base = current ?? this.machine.metadata
|
||||
if (!base) {
|
||||
return { workspaceRoot: desiredWorkspaceRoot } as MachineMetadata
|
||||
return { workspaceRoots: desiredWorkspaceRoots } as MachineMetadata
|
||||
}
|
||||
if (desiredWorkspaceRoot) {
|
||||
return { ...base, workspaceRoot: desiredWorkspaceRoot }
|
||||
if (desiredWorkspaceRoots?.length) {
|
||||
return { ...base, workspaceRoots: desiredWorkspaceRoots }
|
||||
}
|
||||
const { workspaceRoot: _legacyWorkspaceRoot, workspaceRoots: _workspaceRoots, ...rest } = base as MachineMetadata & {
|
||||
workspaceRoot?: string
|
||||
}
|
||||
const { workspaceRoot: _omit, ...rest } = base
|
||||
return rest as MachineMetadata
|
||||
}).then(() => {
|
||||
console.log(`[HAPI] Workspace root synced: ${this.machine.metadata?.workspaceRoot ?? '(none)'}`)
|
||||
console.log(`[HAPI] Workspace roots synced: ${formatWorkspaceRoots(this.machine.metadata?.workspaceRoots)}`)
|
||||
}).catch((error) => {
|
||||
console.error('[HAPI] Failed to sync workspace root:', error instanceof Error ? error.message : error)
|
||||
console.error('[HAPI] Failed to sync workspace roots:', error instanceof Error ? error.message : error)
|
||||
})
|
||||
} else if (desiredWorkspaceRoot) {
|
||||
console.log(`[HAPI] Workspace root already up to date on hub: ${desiredWorkspaceRoot}`)
|
||||
} else if (desiredWorkspaceRoots?.length) {
|
||||
console.log(`[HAPI] Workspace roots already up to date on hub: ${formatWorkspaceRoots(desiredWorkspaceRoots)}`)
|
||||
}
|
||||
|
||||
this.startKeepAlive()
|
||||
|
||||
+15
-1
@@ -37,7 +37,21 @@ export const MachineMetadataSchema = z.object({
|
||||
homeDir: z.string(),
|
||||
happyHomeDir: z.string(),
|
||||
happyLibDir: z.string(),
|
||||
workspaceRoot: z.string().optional()
|
||||
workspaceRoot: z.string().optional(),
|
||||
workspaceRoots: z.array(z.string()).optional()
|
||||
}).transform(({ workspaceRoot, workspaceRoots, ...rest }) => {
|
||||
const normalizedWorkspaceRoots = Array.from(new Set(
|
||||
Array.isArray(workspaceRoots)
|
||||
? workspaceRoots.filter((path): path is string => typeof path === 'string' && path.trim().length > 0)
|
||||
: workspaceRoot
|
||||
? [workspaceRoot]
|
||||
: []
|
||||
))
|
||||
|
||||
return {
|
||||
...rest,
|
||||
workspaceRoots: normalizedWorkspaceRoots.length > 0 ? normalizedWorkspaceRoots : undefined
|
||||
}
|
||||
})
|
||||
|
||||
export type MachineMetadata = z.infer<typeof MachineMetadataSchema>
|
||||
|
||||
+26
-14
@@ -16,15 +16,18 @@ import { initializeToken } from '@/ui/tokenInit'
|
||||
import type { CommandDefinition } from './types'
|
||||
|
||||
/**
|
||||
* Parses `--workspace-root <path>` / `--workspace-root=<path>` from the
|
||||
* runner's positional args. Returns the resolved absolute path or exits
|
||||
* Parses repeated `--workspace-root <path>` / `--workspace-root=<path>` from
|
||||
* the runner's positional args. Returns resolved absolute paths or exits
|
||||
* the process with a clear error. Mutates `args` to remove the consumed
|
||||
* entries so subcommand dispatch still works.
|
||||
*/
|
||||
function extractWorkspaceRootArg(args: string[]): string | undefined {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
function extractWorkspaceRootArgs(args: string[]): string[] | undefined {
|
||||
const workspaceRoots: string[] = []
|
||||
|
||||
for (let i = 0; i < args.length;) {
|
||||
const arg = args[i]
|
||||
let value: string | undefined
|
||||
let consumed = 0
|
||||
if (arg === '--workspace-root') {
|
||||
const next = args[i + 1]
|
||||
if (next === undefined || next.startsWith('--')) {
|
||||
@@ -32,12 +35,15 @@ function extractWorkspaceRootArg(args: string[]): string | undefined {
|
||||
process.exit(1)
|
||||
}
|
||||
value = next
|
||||
args.splice(i, 2)
|
||||
consumed = 2
|
||||
} else if (arg?.startsWith('--workspace-root=')) {
|
||||
value = arg.slice('--workspace-root='.length)
|
||||
args.splice(i, 1)
|
||||
consumed = 1
|
||||
}
|
||||
if (value === undefined) {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (value === undefined) continue
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
@@ -56,9 +62,12 @@ function extractWorkspaceRootArg(args: string[]): string | undefined {
|
||||
console.error(`--workspace-root path does not exist or is not a directory: ${absolute}`)
|
||||
process.exit(1)
|
||||
}
|
||||
return absolute
|
||||
workspaceRoots.push(absolute)
|
||||
args.splice(i, consumed)
|
||||
}
|
||||
return undefined
|
||||
|
||||
const uniqueWorkspaceRoots = Array.from(new Set(workspaceRoots))
|
||||
return uniqueWorkspaceRoots.length > 0 ? uniqueWorkspaceRoots : undefined
|
||||
}
|
||||
|
||||
export const runnerCommand: CommandDefinition = {
|
||||
@@ -66,7 +75,7 @@ export const runnerCommand: CommandDefinition = {
|
||||
requiresRuntimeAssets: true,
|
||||
run: async ({ commandArgs }) => {
|
||||
const mutableArgs = [...commandArgs]
|
||||
const workspaceRoot = extractWorkspaceRootArg(mutableArgs)
|
||||
const workspaceRoots = extractWorkspaceRootArgs(mutableArgs)
|
||||
const runnerSubcommand = mutableArgs[0]
|
||||
|
||||
if (runnerSubcommand === 'list') {
|
||||
@@ -103,8 +112,10 @@ export const runnerCommand: CommandDefinition = {
|
||||
|
||||
if (runnerSubcommand === 'start') {
|
||||
const childArgs = ['runner', 'start-sync']
|
||||
if (workspaceRoot) {
|
||||
childArgs.push('--workspace-root', workspaceRoot)
|
||||
if (workspaceRoots?.length) {
|
||||
for (const workspaceRoot of workspaceRoots) {
|
||||
childArgs.push('--workspace-root', workspaceRoot)
|
||||
}
|
||||
}
|
||||
const child = spawnHappyCLI(childArgs, {
|
||||
detached: true,
|
||||
@@ -133,7 +144,7 @@ export const runnerCommand: CommandDefinition = {
|
||||
|
||||
if (runnerSubcommand === 'start-sync') {
|
||||
await initializeToken()
|
||||
await startRunner({ workspaceRoot })
|
||||
await startRunner({ workspaceRoots })
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
@@ -168,7 +179,8 @@ ${chalk.bold('Usage:')}
|
||||
|
||||
${chalk.bold('Options:')}
|
||||
--workspace-root <path> Restrict the runner to this directory.
|
||||
Browse & spawn will reject paths outside it.
|
||||
Repeat to allow multiple directories/drives.
|
||||
Browse & spawn reject paths outside them.
|
||||
Supports \`~\` / \`~/foo\` expansion.
|
||||
Omit to leave browsing off (legacy mode).
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@ import { startRunnerControlServer } from './controlServer';
|
||||
import { createWorktree, removeWorktree, type WorktreeInfo } from './worktree';
|
||||
import { join } from 'path';
|
||||
import { buildMachineMetadata } from '@/agent/sessionFactory';
|
||||
import { resolveWorkspaceRoot } from '@/utils/workspaceRoot';
|
||||
import { resolveWorkspaceRoots } from '@/utils/workspaceRoot';
|
||||
import { hashRunnerCliApiToken } from './runnerIdentity';
|
||||
|
||||
export async function startRunner(options: { workspaceRoot?: string } = {}): Promise<void> {
|
||||
export async function startRunner(options: { workspaceRoots?: string[] } = {}): Promise<void> {
|
||||
// We don't have cleanup function at the time of server construction
|
||||
// Control flow is:
|
||||
// 1. Create promise that will resolve when shutdown is requested
|
||||
@@ -685,14 +685,14 @@ export async function startRunner(options: { workspaceRoot?: string } = {}): Pro
|
||||
// Create API client
|
||||
const api = await ApiClient.create();
|
||||
|
||||
const workspaceRoot = resolveWorkspaceRoot(options.workspaceRoot);
|
||||
logger.debug(`[RUNNER RUN] Workspace root: ${workspaceRoot ?? '(not set)'}`);
|
||||
const workspaceRoots = resolveWorkspaceRoots(options.workspaceRoots);
|
||||
logger.debug(`[RUNNER RUN] Workspace roots: ${workspaceRoots?.join(', ') ?? '(not set)'}`);
|
||||
|
||||
// Get or create machine (with retry for transient connection errors)
|
||||
const machine = await withRetry(
|
||||
() => api.getOrCreateMachine({
|
||||
machineId,
|
||||
metadata: buildMachineMetadata({ workspaceRoot }),
|
||||
metadata: buildMachineMetadata({ workspaceRoots }),
|
||||
runnerState: initialRunnerState
|
||||
}),
|
||||
{
|
||||
@@ -709,7 +709,7 @@ export async function startRunner(options: { workspaceRoot?: string } = {}): Pro
|
||||
logger.debug(`[RUNNER RUN] Machine registered: ${machine.id}`);
|
||||
|
||||
// Create realtime machine session
|
||||
const apiMachine = api.machineSyncClient(machine, { workspaceRoot });
|
||||
const apiMachine = api.machineSyncClient(machine, { workspaceRoots });
|
||||
|
||||
// Set RPC handlers
|
||||
apiMachine.setRPCHandlers({
|
||||
@@ -725,7 +725,7 @@ export async function startRunner(options: { workspaceRoot?: string } = {}): Pro
|
||||
// regardless of the verbose/quiet logger setting.
|
||||
console.log('');
|
||||
console.log('Hapi runner started.');
|
||||
console.log(` Workspace root: ${workspaceRoot ?? '(not set — browse disabled; pass --workspace-root to enable)'}`);
|
||||
console.log(` Workspace roots: ${workspaceRoots?.join(', ') ?? '(not set — browse disabled; pass --workspace-root to enable)'}`);
|
||||
console.log(` Hub URL: ${configuration.apiUrl}`);
|
||||
console.log(` Machine ID: ${machine.id}`);
|
||||
console.log(` Control port: ${controlPort}`);
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { isAbsolute } from 'node:path'
|
||||
|
||||
/**
|
||||
* Resolves the runner's workspace root — the directory tree the runner is
|
||||
* Resolves the runner's workspace roots — the directory trees the runner is
|
||||
* allowed to browse and spawn sessions in. Returns `undefined` when the
|
||||
* user hasn't explicitly opted in; in that case the runner behaves like
|
||||
* the legacy hapi (no scoping, no /browse feature surfaced in the web UI).
|
||||
* legacy hapi (no scoping, no /browse feature surfaced in the web UI).
|
||||
*
|
||||
* The only signal is the `explicit` argument — typically the resolved
|
||||
* `--workspace-root` flag. Non-absolute values are ignored.
|
||||
* `--workspace-root` flag values. Non-absolute values are ignored.
|
||||
*/
|
||||
export function resolveWorkspaceRoot(explicit?: string): string | undefined {
|
||||
if (explicit && isAbsolute(explicit)) {
|
||||
return explicit
|
||||
export function resolveWorkspaceRoots(explicit?: string[]): string[] | undefined {
|
||||
if (!explicit?.length) {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
|
||||
const uniqueRoots = Array.from(
|
||||
new Set(explicit.filter((path): path is string => typeof path === 'string' && isAbsolute(path)))
|
||||
)
|
||||
|
||||
return uniqueRoots.length > 0 ? uniqueRoots : undefined
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user