mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
* Accept host aliases and infer the host for a single-setup project `orca worktree create --project scalp-it` failed with "Project is not set up on the selected host" even though the project has exactly one ready setup, and `--host trader-local` was rejected as an invalid value even though `orca host list` prints that name. Three separate causes: - `--project` was matched against the stored `projectId` only, which is a provider-scoped string (`github:owner/repo`). The display name shown by `orca project list` never matched, so the lookup found nothing and the failure was reported as a missing host. - With no `--host`, the host fallback existed but was unreachable, because the project lookup above had already filtered to zero candidates. - `parseExecutionHostId` accepts only `local`, `ssh:<id>` and `runtime:<id>`, so a bare alias could not parse at all. `ssh:<label>` already resolved labels; only the unprefixed spelling was missing. Now: - `--project` takes the project id or the display name, resolving through `project.list` and falling back to the setup's own display name for hosts that predate that method. Two projects sharing a name are refused rather than guessed. - `--host` is only required when the project is set up on more than one host. A single setup is used directly; an ambiguous one fails with every host and the flag to select it. - `--host <alias>` resolves against both axes: paired servers by name before routing, SSH targets by label once a client exists. An alias that names a machine on both is refused with the prefixed flag for each. Errors now echo the flag the caller typed instead of an `ssh:` spelling they did not use, and carry the hosts to retry with in `nextSteps`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Refuse a host alias that collides with duplicated SSH labels assertAliasNamesOneHost asked findSshTargetByName whether the alias also named an SSH target, but that function returns undefined for a label two targets share — deliberately, so an ambiguous label never resolves. Read as a boolean it says "no SSH target by that name", so an alias naming one paired server and two same-labelled SSH targets looked conflict-free and resolved to the server. The opposite direction was already covered: a unique SSH target against two servers sharing a name fails through assertEnvironmentNameUnambiguous. Fall back to ambiguousSshTargets so both axes refuse, and list every colliding target in nextSteps rather than only the first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Only fall back to setup display names on a server without project.list findProjectsByName caught every error from project.list and returned an empty list, so a network failure, a permission error, or a genuine server fault was indistinguishable from "no project by that name" — and the caller was then told `Project is not set up on the selected host` for a project that exists and is set up. Narrow the catch to method_not_found, the only code that means the weaker fallback is warranted, matching callProjectHostSetup and listSshTargets in the same layer. The function now returns null for that version gap and an empty array for a real miss, so the two cases stay distinguishable at the call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Refuse a legacy display-name match that spans two projects The pre-project.list fallback matches ProjectHostSetup.displayName, which is the repo's folder name rather than the project's name, so two projects holding repos with the same folder name both match. selectSetupOnHost would then have taken the first candidate — targeting a project the caller never named. Reject when the matched setups span more than one projectId and name each one, the same rule the project.list path already applies to duplicate display names. A single project with several setups is unaffected; the host selection below still decides between them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Spell the --host placeholder as <host> in the project setup usage lines The worktree create usage was updated to <host> when aliases became valid, but the four project setup-* lines kept <host-id> in both the command specs and the root help text they mirror. Those commands accept an alias too, so the narrower placeholder hid it from anyone reading help. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Document host names and the optional --host in the CLI docs The published CLI reference described `--host` as taking only `local`, `ssh:<target-id>` or `runtime:<environment-id>`, and the automations page showed `--host <hostId>` as required alongside `--project`. Both now understate what the commands accept, which is how the original report started: `orca host list` prints a name, the docs never said you could type it, so the generated id looked mandatory. Spell out that `--host` takes the bare name from `host list` on either axis, that a name resolves only when unique and is otherwise refused with the prefixed flag for each candidate, and that `--host` is unnecessary when the project has a single setup. Also note `--project` accepts the display name from `orca project list`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Name the runtime host id as the paired-server tie-breaker in the docs The host-selection page told readers to fall back to ids when a name is ambiguous but only ever showed `--environment <server-name>` for paired servers — and that selector is exactly the one that cannot separate two servers sharing a name. Spell out the pair the resolver actually emits in its nextSteps: `--host ssh:<target-id>` and `--host runtime:<environment-id>`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Spell the automations create usage as --project <id-or-name> [--host <host>] `automations create` resolves --project and --host through the same alias-capable resolver as `worktree create`, and its own notes already say --project takes a display name and --host takes a bare host name. The usage line still read `--project <id> [--host <id>]`, the last spot left on the old spelling (flagged by CodeRabbit after the main merge). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Give the fake runtime client cast in the host alias tests its SAFETY note Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): accept every name host list and project list print, everywhere --project/--host is taken Extends the alias work to the commands it did not reach and the names it missed: - `project setups --project <display name>` filtered by projectId only. - `project setup-existing-folder|setup-clone|setup-create --project <name>` now send the project id that name resolves to (pass-through for ids and for servers without project.list). - `--host "this machine"` (the local row's name) resolves to local, and is refused if an SSH target or paired server shares it. - One pair of wrapping quotes is ignored, since cmd.exe passes single quotes through literally. - With --environment/--pairing-code, a bare --host name is a machine the routed server reaches, not a different locally paired server. - An unknown project is reported as a project miss, not a host miss. Co-authored-by: Younghwan Chae <chyohw97@gmail.com> * fix(cli): keep exact project ids over names, and refuse an unverifiable host name An exact --project id whose only setup is not ready no longer falls through to a project whose display name equals that id. A bare --host name that matches a paired server now routes there only once this machine's SSH targets were actually read, and under --environment/--pairing-code a bare name always means a machine the routed server reaches. Co-authored-by: Younghwan Chae <chyohw97@gmail.com> --------- Co-authored-by: younghwan <chyohw97@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
493 lines
18 KiB
TypeScript
493 lines
18 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
const { listEnvironmentsMock, resolveEnvironmentMock, getDefaultUserDataPathMock } = vi.hoisted(
|
|
() => ({
|
|
listEnvironmentsMock: vi.fn(),
|
|
resolveEnvironmentMock: vi.fn(),
|
|
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data')
|
|
})
|
|
)
|
|
|
|
vi.mock('./runtime/environments', () => ({
|
|
addEnvironmentFromPairingCode: vi.fn(),
|
|
listEnvironments: listEnvironmentsMock,
|
|
removeEnvironment: vi.fn(),
|
|
resolveEnvironment: resolveEnvironmentMock
|
|
}))
|
|
|
|
vi.mock('./runtime-client', () => ({
|
|
getDefaultUserDataPath: getDefaultUserDataPathMock
|
|
}))
|
|
|
|
import {
|
|
assertEnvironmentSelectorResolvable,
|
|
hostFilterMatchesHostId,
|
|
parseHostFlag,
|
|
resolveHostFlagEnvironmentId,
|
|
resolveHostFlagTarget
|
|
} from './execution-host-flag'
|
|
import { parseExecutionHostId } from '../shared/execution-host'
|
|
import type { RuntimeClient } from './runtime-client'
|
|
|
|
const listSshTargetsMock = vi.fn(async () => [] as { id: string; label: string }[])
|
|
const NO_SELECTION = {
|
|
listSshTargets: listSshTargetsMock,
|
|
verifySshTargets: listSshTargetsMock,
|
|
pairingCode: null,
|
|
environmentSelector: null
|
|
}
|
|
|
|
function flags(entries: Record<string, string | boolean>): Map<string, string | boolean> {
|
|
return new Map(Object.entries(entries))
|
|
}
|
|
|
|
function environment(id: string, name = id) {
|
|
return { id, name }
|
|
}
|
|
|
|
describe('parseHostFlag', () => {
|
|
it('returns undefined when --host is absent', () => {
|
|
expect(parseHostFlag(flags({}))).toBeUndefined()
|
|
})
|
|
|
|
it('rejects a --host flag with no value', () => {
|
|
expect(() => parseHostFlag(flags({ host: true }))).toThrow('Missing value for --host')
|
|
})
|
|
|
|
// A value that claims a kind but does not parse is malformed, not a name someone could have
|
|
// meant — only an unprefixed value can be a host alias.
|
|
it.each(['runtime:', 'ssh:', 'ssh:a|b', ' '])('rejects the unparseable host id %s', (value) => {
|
|
expect(() => parseHostFlag(flags({ host: value }))).toThrow(`Invalid --host value: ${value}`)
|
|
})
|
|
|
|
it('parses the three supported host kinds', () => {
|
|
expect(parseHostFlag(flags({ host: 'local' }))?.kind).toBe('local')
|
|
expect(parseHostFlag(flags({ host: 'ssh:box-1' }))?.kind).toBe('ssh')
|
|
expect(parseHostFlag(flags({ host: 'runtime:env-1' }))).toEqual({
|
|
kind: 'runtime',
|
|
id: 'runtime:env-1',
|
|
environmentId: 'env-1'
|
|
})
|
|
})
|
|
|
|
// Why: `orca host list` prints the alias, so the alias is what gets typed. Rejecting it sent
|
|
// people hunting for a machine-generated id the listing showed in a different column.
|
|
it('carries an unprefixed host name through as an alias', () => {
|
|
expect(parseHostFlag(flags({ host: 'trader-local' }))).toEqual({
|
|
kind: 'alias',
|
|
alias: 'trader-local'
|
|
})
|
|
expect(parseHostFlag(flags({ host: ' trader-local ' }))).toEqual({
|
|
kind: 'alias',
|
|
alias: 'trader-local'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('resolveHostFlagEnvironmentId', () => {
|
|
it('keeps the current connection for local and ssh hosts', async () => {
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'local' }), NO_SELECTION)
|
|
).resolves.toBe(null)
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'ssh:box-1' }), NO_SELECTION)
|
|
).resolves.toBe(null)
|
|
expect(listEnvironmentsMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('routes a paired runtime host to that environment', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
|
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), NO_SELECTION)
|
|
).resolves.toBe('env-1')
|
|
})
|
|
|
|
it('rejects a runtime host id that no paired environment owns', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
|
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-missing' }), NO_SELECTION)
|
|
).rejects.toThrow('no paired Orca server is named or has id env-missing')
|
|
})
|
|
|
|
// Why: the name is what a person or agent actually knows; requiring the raw uuid made the
|
|
// obvious spelling fail against a server that is sitting right there.
|
|
it('accepts the environment name as well as its id', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
|
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'runtime:gpu' }), NO_SELECTION)
|
|
).resolves.toBe('env-1')
|
|
})
|
|
|
|
it('decodes percent-encoded environment ids', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env one')])
|
|
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env%20one' }), NO_SELECTION)
|
|
).resolves.toBe('env one')
|
|
})
|
|
|
|
it('refuses a runtime host alongside --pairing-code', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1')])
|
|
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
|
|
listSshTargets: listSshTargetsMock,
|
|
verifySshTargets: listSshTargetsMock,
|
|
pairingCode: 'orca://pair?x',
|
|
environmentSelector: null
|
|
})
|
|
).rejects.toThrow('not both')
|
|
})
|
|
|
|
it('refuses a runtime host that disagrees with --environment', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1')])
|
|
resolveEnvironmentMock.mockReturnValue(environment('env-2'))
|
|
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
|
|
listSshTargets: listSshTargetsMock,
|
|
verifySshTargets: listSshTargetsMock,
|
|
pairingCode: null,
|
|
environmentSelector: { value: 'other', label: '--environment' }
|
|
})
|
|
).rejects.toThrow('name different Orca servers')
|
|
})
|
|
|
|
it('names the ambient variable when ORCA_ENVIRONMENT is the conflicting selector', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1')])
|
|
resolveEnvironmentMock.mockReturnValue(environment('env-2'))
|
|
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
|
|
listSshTargets: listSshTargetsMock,
|
|
verifySshTargets: listSshTargetsMock,
|
|
pairingCode: null,
|
|
environmentSelector: { value: 'staging', label: 'ORCA_ENVIRONMENT' }
|
|
})
|
|
).rejects.toThrow('ORCA_ENVIRONMENT staging name different Orca servers')
|
|
})
|
|
|
|
it('hands an agent the known environment ids to retry with', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
|
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'runtime:missing' }), NO_SELECTION)
|
|
).rejects.toMatchObject({
|
|
code: 'invalid_argument',
|
|
data: { knownEnvironments: [{ id: 'env-1', name: 'gpu' }] }
|
|
})
|
|
})
|
|
|
|
it('accepts --environment naming the same server as the runtime host', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
|
resolveEnvironmentMock.mockReturnValue(environment('env-1', 'gpu'))
|
|
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
|
|
listSshTargets: listSshTargetsMock,
|
|
verifySshTargets: listSshTargetsMock,
|
|
pairingCode: null,
|
|
environmentSelector: { value: 'gpu', label: '--environment' }
|
|
})
|
|
).resolves.toBe('env-1')
|
|
})
|
|
})
|
|
|
|
// Why: the reported failure. `orca host list` shows `trader-local`, so `--host trader-local` is
|
|
// what gets typed, and only the machine-generated `ssh:ssh-1783599077486-fyasux` was accepted.
|
|
describe('unprefixed --host aliases', () => {
|
|
function clientWithTargets(targets: { id: string; label: string }[]): RuntimeClient {
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SSH target resolution reads only client.call.
|
|
return {
|
|
call: vi.fn(async () => ({ result: { targets } }))
|
|
} as unknown as RuntimeClient
|
|
}
|
|
|
|
it('routes an alias that names a paired server to that environment', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
|
listSshTargetsMock.mockResolvedValue([])
|
|
|
|
await expect(resolveHostFlagEnvironmentId(flags({ host: 'gpu' }), NO_SELECTION)).resolves.toBe(
|
|
'env-1'
|
|
)
|
|
})
|
|
|
|
// The SSH axis lives on the connected runtime, so a miss here is not yet a failure.
|
|
it('leaves an alias that names no paired server to the SSH axis', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
|
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'trader-local' }), NO_SELECTION)
|
|
).resolves.toBe(null)
|
|
})
|
|
|
|
it('resolves an alias to the SSH target that carries that label', async () => {
|
|
listEnvironmentsMock.mockReturnValue([])
|
|
const client = clientWithTargets([{ id: 'ssh-1783599077486-fyasux', label: 'trader-local' }])
|
|
|
|
await expect(resolveHostFlagTarget(flags({ host: 'trader-local' }), client)).resolves.toEqual({
|
|
kind: 'ssh',
|
|
id: 'ssh:ssh-1783599077486-fyasux',
|
|
targetId: 'ssh-1783599077486-fyasux'
|
|
})
|
|
})
|
|
|
|
it('names the alias the caller typed rather than an ssh: spelling they did not use', async () => {
|
|
listEnvironmentsMock.mockReturnValue([])
|
|
|
|
await expect(
|
|
resolveHostFlagTarget(flags({ host: 'trader-local' }), clientWithTargets([]))
|
|
).rejects.toThrow('Unknown host in --host trader-local')
|
|
})
|
|
|
|
// Why: a paired server and an SSH target are different machines. Picking either would run the
|
|
// command somewhere the caller never named.
|
|
it('refuses an alias that names a machine on both axes', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1', 'awin')])
|
|
listSshTargetsMock.mockResolvedValue([{ id: 'ssh-1-a', label: 'awin' }])
|
|
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'awin' }), NO_SELECTION)
|
|
).rejects.toMatchObject({
|
|
code: 'invalid_argument',
|
|
data: {
|
|
nextSteps: expect.arrayContaining([
|
|
expect.stringContaining('--host runtime:env-1'),
|
|
expect.stringContaining('--host ssh:ssh-1-a')
|
|
])
|
|
}
|
|
})
|
|
})
|
|
|
|
// Why: findSshTargetByName returns undefined for a duplicated label, which reads the same as
|
|
// "no SSH target by that name" — so this case used to look conflict-free and resolve to the
|
|
// paired server, the one direction that still guessed.
|
|
it('refuses an alias naming a server and two SSH targets sharing a label', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1', 'awin')])
|
|
listSshTargetsMock.mockResolvedValue([
|
|
{ id: 'ssh-1-a', label: 'awin' },
|
|
{ id: 'ssh-2-b', label: 'awin' }
|
|
])
|
|
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'awin' }), NO_SELECTION)
|
|
).rejects.toMatchObject({
|
|
code: 'invalid_argument',
|
|
data: {
|
|
nextSteps: expect.arrayContaining([
|
|
expect.stringContaining('--host runtime:env-1'),
|
|
expect.stringContaining('--host ssh:ssh-1-a'),
|
|
expect.stringContaining('--host ssh:ssh-2-b')
|
|
])
|
|
}
|
|
})
|
|
})
|
|
|
|
it('refuses an alias two paired servers answer to', async () => {
|
|
listEnvironmentsMock.mockReturnValue([
|
|
environment('env-1', 'awin'),
|
|
environment('env-2', 'awin')
|
|
])
|
|
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'awin' }), NO_SELECTION)
|
|
).rejects.toThrow('Ambiguous Orca server in --host awin')
|
|
})
|
|
|
|
it('refuses to route an alias to a paired server when SSH targets cannot be read', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1', 'awin')])
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'awin' }), {
|
|
...NO_SELECTION,
|
|
verifySshTargets: async () => {
|
|
throw new Error('runtime unreachable')
|
|
}
|
|
})
|
|
).rejects.toMatchObject({
|
|
message: expect.stringContaining('Cannot verify --host awin'),
|
|
data: { nextSteps: [expect.stringContaining('--host runtime:env-1')] }
|
|
})
|
|
})
|
|
|
|
// Why: `orca host list` prints `this machine` as the local row's name, with a space.
|
|
it('resolves the local row name to --host local', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'This Machine' }), NO_SELECTION)
|
|
).resolves.toBe(null)
|
|
await expect(
|
|
resolveHostFlagTarget(flags({ host: 'this machine' }), clientWithTargets([]))
|
|
).resolves.toEqual({ kind: 'local', id: 'local' })
|
|
})
|
|
|
|
it('refuses the local row name when an SSH target carries it too', async () => {
|
|
listEnvironmentsMock.mockReturnValue([])
|
|
await expect(
|
|
resolveHostFlagTarget(
|
|
flags({ host: 'this machine' }),
|
|
clientWithTargets([{ id: 'ssh-1-a', label: 'this machine' }])
|
|
)
|
|
).rejects.toMatchObject({
|
|
message: expect.stringContaining('Ambiguous --host this machine'),
|
|
data: {
|
|
nextSteps: expect.arrayContaining([
|
|
'Use --host local for this machine.',
|
|
expect.stringContaining('--host ssh:ssh-1-a')
|
|
])
|
|
}
|
|
})
|
|
})
|
|
|
|
it('refuses the local row name when a paired server carries it too', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1', 'this machine')])
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'this machine' }), NO_SELECTION)
|
|
).rejects.toThrow('Ambiguous --host this machine')
|
|
})
|
|
|
|
it('resolves an SSH target label that contains spaces', async () => {
|
|
listEnvironmentsMock.mockReturnValue([])
|
|
const client = clientWithTargets([{ id: 'ssh-9', label: 'Build Box 2' }])
|
|
await expect(resolveHostFlagTarget(flags({ host: 'build box 2' }), client)).resolves.toEqual({
|
|
kind: 'ssh',
|
|
id: 'ssh:ssh-9',
|
|
targetId: 'ssh-9'
|
|
})
|
|
})
|
|
|
|
// Why: cmd.exe does not strip single quotes, so `--host 'gpu'` arrives with them.
|
|
it('ignores one pair of wrapping quotes around a host name', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
|
listSshTargetsMock.mockResolvedValue([])
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: "'gpu'" }), NO_SELECTION)
|
|
).resolves.toBe('env-1')
|
|
expect(parseHostFlag(flags({ host: '"local"' }))).toEqual({ kind: 'local', id: 'local' })
|
|
})
|
|
|
|
// Why: with --environment the command runs on that server, so a bare name is a machine it
|
|
// reaches; a locally paired server sharing the name, even the routed one, is not a host there.
|
|
it('leaves an alias to the routed server SSH targets when --environment picks the server', async () => {
|
|
listEnvironmentsMock.mockReturnValue([
|
|
environment('env-1', 'openclaw'),
|
|
environment('env-2', 'omarchy')
|
|
])
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'omarchy' }), {
|
|
...NO_SELECTION,
|
|
environmentSelector: { value: 'openclaw', label: '--environment' }
|
|
})
|
|
).resolves.toBe(null)
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'openclaw' }), {
|
|
...NO_SELECTION,
|
|
environmentSelector: { value: 'openclaw', label: '--environment' }
|
|
})
|
|
).resolves.toBe(null)
|
|
})
|
|
})
|
|
|
|
describe('hostFilterMatchesHostId', () => {
|
|
const runtimeHost = parseExecutionHostId('runtime:env-1')!
|
|
const localHost = parseExecutionHostId('local')!
|
|
const sshHost = parseExecutionHostId('ssh:box-1')!
|
|
|
|
it('matches the identical host id', () => {
|
|
expect(hostFilterMatchesHostId(runtimeHost, 'runtime:env-1')).toBe(true)
|
|
expect(hostFilterMatchesHostId(localHost, 'local')).toBe(true)
|
|
expect(hostFilterMatchesHostId(sshHost, 'ssh:box-1')).toBe(true)
|
|
})
|
|
|
|
it('treats the routed runtime own local rows as that runtime', () => {
|
|
expect(hostFilterMatchesHostId(runtimeHost, 'local')).toBe(true)
|
|
})
|
|
|
|
it('does not widen local or ssh filters', () => {
|
|
expect(hostFilterMatchesHostId(localHost, 'runtime:env-1')).toBe(false)
|
|
expect(hostFilterMatchesHostId(sshHost, 'local')).toBe(false)
|
|
})
|
|
|
|
it('does not match a different runtime host', () => {
|
|
expect(hostFilterMatchesHostId(runtimeHost, 'runtime:env-2')).toBe(false)
|
|
expect(hostFilterMatchesHostId(runtimeHost, null)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('ambiguous environment names', () => {
|
|
it('refuses --host runtime:<name> when two servers share that name', async () => {
|
|
listEnvironmentsMock.mockReturnValue([
|
|
environment('env-1', 'awin'),
|
|
environment('env-2', 'awin')
|
|
])
|
|
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'runtime:awin' }), NO_SELECTION)
|
|
).rejects.toThrow('2 paired servers are named awin')
|
|
})
|
|
|
|
it('refuses --environment <name> when two servers share that name', async () => {
|
|
listEnvironmentsMock.mockReturnValue([
|
|
environment('env-1', 'awin'),
|
|
environment('env-2', 'awin')
|
|
])
|
|
|
|
await expect(assertEnvironmentSelectorResolvable('awin', listSshTargetsMock)).rejects.toThrow(
|
|
'2 paired servers are named awin'
|
|
)
|
|
})
|
|
|
|
it('still routes an exact id past a colliding name', async () => {
|
|
listEnvironmentsMock.mockReturnValue([
|
|
environment('env-1', 'awin'),
|
|
environment('env-2', 'awin')
|
|
])
|
|
|
|
await expect(
|
|
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-2' }), NO_SELECTION)
|
|
).resolves.toBe('env-2')
|
|
})
|
|
})
|
|
|
|
describe('assertEnvironmentSelectorResolvable', () => {
|
|
it('accepts a paired server by name or id', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1', 'awin')])
|
|
listSshTargetsMock.mockClear()
|
|
|
|
await expect(
|
|
assertEnvironmentSelectorResolvable('awin', listSshTargetsMock)
|
|
).resolves.toBeUndefined()
|
|
await expect(
|
|
assertEnvironmentSelectorResolvable('env-1', listSshTargetsMock)
|
|
).resolves.toBeUndefined()
|
|
expect(listSshTargetsMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// Why: the inverse of the --host case, and the one the report actually hit — a name that is
|
|
// an SSH target dead-ended with a bare "unknown environment".
|
|
it('names the SSH target when the selector is one', async () => {
|
|
listEnvironmentsMock.mockReturnValue([environment('env-1', 'awin')])
|
|
listSshTargetsMock.mockResolvedValue([{ id: 'ssh-1-a', label: 'openclaw' }])
|
|
|
|
await expect(
|
|
assertEnvironmentSelectorResolvable('openclaw', listSshTargetsMock)
|
|
).rejects.toMatchObject({
|
|
code: 'invalid_argument',
|
|
data: {
|
|
nextSteps: expect.arrayContaining([expect.stringContaining('--host ssh:ssh-1-a')])
|
|
}
|
|
})
|
|
})
|
|
|
|
it('still points at host list when the name is on neither axis', async () => {
|
|
listEnvironmentsMock.mockReturnValue([])
|
|
listSshTargetsMock.mockResolvedValue([])
|
|
|
|
await expect(
|
|
assertEnvironmentSelectorResolvable('nowhere', listSshTargetsMock)
|
|
).rejects.toThrow('no paired Orca server is named or has id nowhere')
|
|
})
|
|
})
|