mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(ssh): one-click Node.js mirror when neither machine can download Node (#31069)
* feat(ssh): offer a Node.js mirror when neither machine can download Node * fix(ssh): put the mirror action beside Details on the host row * fix(ssh): offer the mirror only where this computer's setting applies, and word main's error by mirror state * test(ssh): type the refusal case * test(ssh): one checked settings fixture for the mirror dialog
This commit is contained in:
@@ -14,6 +14,7 @@ import { uploadRelayDirectory } from './remote-install-transfers'
|
||||
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
|
||||
import { pinnedNodeRuntimeAsset } from '../../../shared/node-runtime-pin'
|
||||
import { DesktopDownloadUnreachableError } from '../../node-runtime/pinned-runtime-materializer'
|
||||
import { setNodeDistMirrorSettingReader } from '../../node-runtime/node-dist-mirror-setting'
|
||||
import {
|
||||
HOST_NODE_DOWNLOAD_FAILED,
|
||||
HOST_NODE_DOWNLOAD_OK,
|
||||
@@ -103,9 +104,35 @@ describe('pinned Node when this computer cannot download it', () => {
|
||||
expect(message).toContain(OFFLINE_PATH)
|
||||
expect(message).toContain('Settings > Advanced > Network > Node.js download mirror')
|
||||
expect(message).toContain("NODEJS_ORG_MIRROR in this computer's environment")
|
||||
expect(failure).toMatchObject({ mirror: 'unset' })
|
||||
expect(uploadRelayDirectory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records that both attempts went through the configured mirror', async () => {
|
||||
freshHost(`${HOST_NODE_DOWNLOAD_FAILED}\nmirror: curl exit 7`)
|
||||
setNodeDistMirrorSettingReader(() => 'https://npmmirror.com/mirrors/node')
|
||||
try {
|
||||
const failure = await ensure(refused).catch((error: unknown) => error)
|
||||
expect(failure).toMatchObject({ name: 'NodeRuntimeUnreachableError', mirror: 'tried' })
|
||||
expect(String(failure)).toContain('check the mirror of nodejs.org/dist in Settings')
|
||||
} finally {
|
||||
setNodeDistMirrorSettingReader(null)
|
||||
}
|
||||
})
|
||||
|
||||
it('offers no mirror for a build nodejs.org/dist does not carry', async () => {
|
||||
freshHost(`${HOST_NODE_DOWNLOAD_FAILED}\nnodejs.org: curl exit 7`)
|
||||
const failure = await ensureRemoteOrcadNodeRuntime({
|
||||
conn,
|
||||
host: getRemoteHostPlatform('linux-arm64'),
|
||||
slotDir: '/home/me/.orca-remote/orcad-0.1.0+abc',
|
||||
target: 'linux-arm64-musl',
|
||||
archivePath: refused
|
||||
}).catch((error: unknown) => error)
|
||||
expect(failure).toMatchObject({ name: 'NodeRuntimeUnreachableError', mirror: 'unsupported' })
|
||||
expect(String(failure)).not.toContain('Node.js download mirror')
|
||||
})
|
||||
|
||||
it("quotes only this computer's short reason, not its standalone advice", async () => {
|
||||
freshHost(`${HOST_NODE_DOWNLOAD_FAILED}\nnodejs.org: curl exit 7`)
|
||||
const desktop = () =>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { copyFile, link, mkdtemp } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { nodeDistMirrorUrl } from '../../../shared/node-dist-mirror'
|
||||
import { nodeDistMirrorUrl, type NodeDistMirrorAttempt } from '../../../shared/node-dist-mirror'
|
||||
import {
|
||||
NODE_RUNTIME_PIN,
|
||||
nodeRuntimeReleaseUrl,
|
||||
@@ -131,7 +131,8 @@ export class NodeRuntimeUnreachableError extends Error {
|
||||
readonly hostReport: string,
|
||||
readonly offlineArchivePath: string | undefined,
|
||||
cause: unknown,
|
||||
mirrorable = true
|
||||
/** Whether both attempts tried a mirror; the setup failure reads this, never the text. */
|
||||
readonly mirror: NodeDistMirrorAttempt = 'unset'
|
||||
) {
|
||||
const desktop =
|
||||
cause instanceof DesktopDownloadUnreachableError
|
||||
@@ -139,17 +140,19 @@ export class NodeRuntimeUnreachableError extends Error {
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: String(cause)
|
||||
const mirror = mirrorable
|
||||
? ', set a mirror of nodejs.org/dist in Settings > Advanced > Network > Node.js download mirror ' +
|
||||
"(or NODEJS_ORG_MIRROR in this computer's environment)"
|
||||
: ''
|
||||
const mirrorAdvice =
|
||||
mirror === 'unsupported'
|
||||
? ''
|
||||
: `, ${mirror === 'tried' ? 'check the' : 'set a'} mirror of nodejs.org/dist in Settings > ` +
|
||||
'Advanced > Network > Node.js download mirror ' +
|
||||
"(or NODEJS_ORG_MIRROR in this computer's environment)"
|
||||
const offline = offlineArchivePath
|
||||
? `, or download that file on another machine and copy it to ${offlineArchivePath} on this computer.`
|
||||
: '.'
|
||||
super(
|
||||
`Neither this computer nor the host could download Node from ${url}. ` +
|
||||
`This computer: ${desktop}. The host: ${hostReport}. ` +
|
||||
`Check the network or proxy on either machine and connect again${mirror}${offline}`,
|
||||
`Check the network or proxy on either machine and connect again${mirrorAdvice}${offline}`,
|
||||
{ cause }
|
||||
)
|
||||
this.name = 'NodeRuntimeUnreachableError'
|
||||
@@ -170,11 +173,12 @@ export async function downloadNodeRuntimeOnHost(args: {
|
||||
timeoutMs: number
|
||||
signal?: AbortSignal
|
||||
}): Promise<string> {
|
||||
const mirror = configuredNodeDistMirror()
|
||||
const output = await args.exec(
|
||||
hostDownloadNodeRuntimeCommand(args.host, {
|
||||
stageDir: args.stageDir,
|
||||
target: args.target,
|
||||
mirror: configuredNodeDistMirror()
|
||||
mirror
|
||||
}),
|
||||
{ signal: args.signal, timeoutMs: args.timeoutMs }
|
||||
)
|
||||
@@ -185,7 +189,11 @@ export async function downloadNodeRuntimeOnHost(args: {
|
||||
result.report,
|
||||
args.offlineArchivePath,
|
||||
args.desktopError,
|
||||
pinnedNodeRuntimeAsset(args.target).source === 'official'
|
||||
pinnedNodeRuntimeAsset(args.target).source !== 'official'
|
||||
? 'unsupported'
|
||||
: mirror
|
||||
? 'tried'
|
||||
: 'unset'
|
||||
)
|
||||
}
|
||||
console.warn(
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* A setup that couldn't download Node on either machine carries a structured flag the renderer
|
||||
* reads to offer a mirror; nothing parses the message text.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import { resolveHostServerOnConnect } from './ssh-host-server-on-connect'
|
||||
import { hostServerDepsStub } from './ssh-host-server-on-connect-test-deps'
|
||||
import { unservedHostServerMessage, unservedServerStatus } from './ssh-host-unserved-status'
|
||||
import { sftpSessionClosedError } from './ssh-file-transfer-abort'
|
||||
import { NodeRuntimeUnreachableError } from '../managed-server/install/remote-node-runtime-host-download'
|
||||
import { admitSshManagedServerStatus } from '../../shared/ssh-managed-server-status-admission'
|
||||
|
||||
const target: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' }
|
||||
|
||||
function unreachable(mirror?: 'unset' | 'tried' | 'unsupported'): NodeRuntimeUnreachableError {
|
||||
return new NodeRuntimeUnreachableError(
|
||||
'https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz',
|
||||
'its downloads failed (curl: (7) Failed to connect)',
|
||||
'/cache/node/archives/node.tar.gz',
|
||||
new Error('net::ERR_CONNECTION_REFUSED'),
|
||||
mirror
|
||||
)
|
||||
}
|
||||
|
||||
async function deployFailingWith(error: unknown) {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
try {
|
||||
const result = await resolveHostServerOnConnect(
|
||||
target,
|
||||
hostServerDepsStub({
|
||||
isEmptyHost: () => true,
|
||||
deploy: async () => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
)
|
||||
if (result.route !== 'unserved') {
|
||||
throw new Error('expected unserved')
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
describe('a managed-server setup that could not download Node', () => {
|
||||
it.each(['unset', 'tried', 'unsupported'] as const)(
|
||||
'publishes the blocked download with mirror %s',
|
||||
async (mirror) => {
|
||||
const result = await deployFailingWith(unreachable(mirror))
|
||||
expect(result).toMatchObject({
|
||||
reason: 'failed',
|
||||
nodeDownloadBlocked: { mirror }
|
||||
})
|
||||
expect(result.detail).toContain('Neither this computer nor the host could download Node')
|
||||
expect(admitSshManagedServerStatus(unservedServerStatus(result))).toEqual({
|
||||
managedServer: expect.objectContaining({
|
||||
kind: 'relay',
|
||||
reason: 'failed',
|
||||
nodeDownloadBlocked: { mirror }
|
||||
})
|
||||
})
|
||||
const message = unservedHostServerMessage(result)
|
||||
expect(message).toContain('Neither this computer nor this host could download Node.js')
|
||||
if (mirror === 'unsupported') {
|
||||
expect(message).not.toContain('mirror')
|
||||
} else {
|
||||
expect(message).toContain(mirror === 'tried' ? 'Check the mirror' : 'Set a Node.js')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('reads the flag through a wrapping install error', async () => {
|
||||
const wrapped = new Error('Installing the runtime failed', { cause: unreachable('unset') })
|
||||
await expect(deployFailingWith(wrapped)).resolves.toMatchObject({
|
||||
nodeDownloadBlocked: { mirror: 'unset' }
|
||||
})
|
||||
})
|
||||
|
||||
it('never reads the flag from message text', async () => {
|
||||
const lookalike = new Error(unreachable('unset').message)
|
||||
const result = await deployFailingWith(lookalike)
|
||||
expect(result).not.toHaveProperty('nodeDownloadBlocked')
|
||||
expect(unservedHostServerMessage(result)).toContain('Try connecting again')
|
||||
})
|
||||
|
||||
it('lets lost contact win, since nothing proves the host failed', async () => {
|
||||
const lost = new Error('upload failed', { cause: sftpSessionClosedError() })
|
||||
const result = await deployFailingWith(lost)
|
||||
expect(result).toMatchObject({ contactLost: true })
|
||||
expect(result).not.toHaveProperty('nodeDownloadBlocked')
|
||||
})
|
||||
|
||||
it('drops an unknown mirror state from a newer build at admission', () => {
|
||||
expect(
|
||||
admitSshManagedServerStatus({
|
||||
kind: 'relay',
|
||||
reason: 'failed',
|
||||
nodeDownloadBlocked: { mirror: 'regional' }
|
||||
})
|
||||
).toEqual({ managedServer: { kind: 'relay', reason: 'failed' } })
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
import type {
|
||||
SshManagedServerRelayReason,
|
||||
SshManagedServerLinkedProject,
|
||||
SshManagedServerNodeDownloadBlocked,
|
||||
SshManagedServerServingNote,
|
||||
SshManagedServerUpdateNote,
|
||||
SshTarget
|
||||
@@ -68,6 +69,7 @@ export type HostServerOnConnectResult =
|
||||
held?: true
|
||||
/** Setup lost contact with the host, so it is not known to have failed there. */
|
||||
contactLost?: true
|
||||
nodeDownloadBlocked?: SshManagedServerNodeDownloadBlocked
|
||||
/** COMPAT(legacy-relay-bridge): the connect stays up so the bridge serves the terminals. */
|
||||
bridged?: true
|
||||
}
|
||||
@@ -344,6 +346,7 @@ async function unfinished(
|
||||
reason,
|
||||
detail: boundedDetail(failure.detail),
|
||||
...(failure.contactLost ? { contactLost: true as const } : {}),
|
||||
...(failure.nodeDownloadBlocked ? { nodeDownloadBlocked: failure.nodeDownloadBlocked } : {}),
|
||||
...(held ? { held: true as const } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** What a managed-server setup that stopped short shows under its SSH host. */
|
||||
import type { SshManagedServerNodeDownloadBlocked } from '../../shared/ssh-types'
|
||||
import { isNodeDistMirrorAttempt } from '../../shared/node-dist-mirror'
|
||||
import { isSftpSessionClosedError } from './ssh-file-transfer-abort'
|
||||
|
||||
// Bounds a failure's detail; the host log tail inside it is already capped.
|
||||
@@ -8,12 +10,14 @@ export type SetupFailure = {
|
||||
detail: string
|
||||
/** Contact was lost mid-setup: nothing proves the host failed, so it is never called a failure. */
|
||||
contactLost?: true
|
||||
nodeDownloadBlocked?: SshManagedServerNodeDownloadBlocked
|
||||
}
|
||||
|
||||
export function setupFailure(failure: unknown): SetupFailure {
|
||||
return {
|
||||
detail: failure instanceof Error ? failure.message : String(failure),
|
||||
...(lostContact(failure) ? { contactLost: true as const } : {})
|
||||
...(lostContact(failure) ? { contactLost: true as const } : {}),
|
||||
...nodeDownloadBlocked(failure)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +25,18 @@ export function boundedDetail(text: string): string {
|
||||
return text.length > MAX_FAILURE_DETAIL_CHARS ? `…${text.slice(-MAX_FAILURE_DETAIL_CHARS)}` : text
|
||||
}
|
||||
|
||||
// Why by name (as the host-unavailable classifier does) and the chain: no download code here.
|
||||
function nodeDownloadBlocked(
|
||||
failure: unknown,
|
||||
depth = 0
|
||||
): { nodeDownloadBlocked?: SshManagedServerNodeDownloadBlocked } {
|
||||
if (failure instanceof Error && failure.name === 'NodeRuntimeUnreachableError') {
|
||||
const mirror = 'mirror' in failure ? failure.mirror : undefined
|
||||
return { nodeDownloadBlocked: { mirror: isNodeDistMirrorAttempt(mirror) ? mirror : 'tried' } }
|
||||
}
|
||||
return depth < 4 && failure instanceof Error ? nodeDownloadBlocked(failure.cause, depth + 1) : {}
|
||||
}
|
||||
|
||||
// Why only a closed session or an unanswered channel open: other unconfirmed results (a host's
|
||||
// busy or deadline answer) came from a host that answered. Why the cause chain: install steps wrap
|
||||
// the transport error they hit.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** What a connect publishes when the managed server doesn't serve the host; there is no fallback. */
|
||||
import type { NodeDistMirrorAttempt } from '../../shared/node-dist-mirror'
|
||||
import type { SshManagedServerStatus } from '../../shared/ssh-types'
|
||||
import type { HostServerOnConnectResult } from './ssh-host-server-on-connect'
|
||||
|
||||
@@ -17,6 +18,7 @@ export function unservedServerStatus(
|
||||
...(decision.canLeaveBehind ? { canLeaveBehind: true } : {}),
|
||||
...(decision.held ? { held: true } : {}),
|
||||
...(decision.contactLost ? { contactLost: true } : {}),
|
||||
...(decision.nodeDownloadBlocked ? { nodeDownloadBlocked: decision.nodeDownloadBlocked } : {}),
|
||||
...(decision.bridged ? { bridged: true } : {})
|
||||
}
|
||||
}
|
||||
@@ -30,6 +32,15 @@ const NOEXEC_MESSAGE =
|
||||
const CONTACT_LOST_MESSAGE =
|
||||
'Orca lost contact with this host while setting up its server. Nothing on the host was stopped. Connect again to continue.'
|
||||
|
||||
const NODE_DOWNLOAD_BLOCKED_MESSAGES: Record<NodeDistMirrorAttempt, string> = {
|
||||
unset:
|
||||
'Neither this computer nor this host could download Node.js, which Orca’s server needs. Set a Node.js download mirror in Settings > Advanced > Network, or check the network on both machines, then connect again.',
|
||||
tried:
|
||||
'Neither this computer nor this host could download Node.js, including from the Node.js download mirror. Check the mirror in Settings > Advanced > Network, then connect again.',
|
||||
unsupported:
|
||||
'Neither this computer nor this host could download Node.js, which Orca’s server needs. Check the network or proxy on both machines, then connect again.'
|
||||
}
|
||||
|
||||
/** Untranslated twin of the renderer's status line, for logs and callers without the catalog. */
|
||||
export function unservedHostServerMessage(decision: UnservedDecision): string {
|
||||
if (decision.held && (decision.reason === 'failed' || decision.reason === 'deferred')) {
|
||||
@@ -49,8 +60,11 @@ export function unservedHostServerMessage(decision: UnservedDecision): string {
|
||||
return 'An older version of Orca changed this host’s projects. Move those changes to its managed server in SSH Hosts settings to continue.'
|
||||
case 'deferred':
|
||||
case 'failed':
|
||||
return decision.contactLost
|
||||
? CONTACT_LOST_MESSAGE
|
||||
if (decision.contactLost) {
|
||||
return CONTACT_LOST_MESSAGE
|
||||
}
|
||||
return decision.nodeDownloadBlocked
|
||||
? NODE_DOWNLOAD_BLOCKED_MESSAGES[decision.nodeDownloadBlocked.mirror]
|
||||
: 'Orca couldn’t start its server on this host. Try connecting again.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ const AddProjectFromFolderDialog = lazy(
|
||||
)
|
||||
const DiagramViewerModal = lazy(() => import('../components/editor/DiagramViewerModal'))
|
||||
const ProjectAddedDialog = lazy(() => import('../components/sidebar/ProjectAddedDialog'))
|
||||
const NodeDistMirrorDialog = lazy(() => import('../components/settings/NodeDistMirrorDialog'))
|
||||
const AiVaultSessionHistoryDialog = lazy(
|
||||
() => import('../components/right-sidebar/AiVaultSessionHistoryDialog')
|
||||
)
|
||||
@@ -230,6 +231,12 @@ export function AppRootSurfaces(props: {
|
||||
<AiVaultSessionHistoryDialog />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
{/* Root-hosted: a toast, the SSH host row and a terminal pane all open it. */}
|
||||
{activeModal === 'node-dist-mirror' ? (
|
||||
<ModalBoundary boundaryId="modal.node-dist-mirror" resetKey>
|
||||
<NodeDistMirrorDialog />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
{/* Why: chat rows are virtualized, so the viewer must outlive the message that opened it. */}
|
||||
{activeModal === 'diagram-viewer' ? (
|
||||
<ModalBoundary boundaryId="modal.diagram-viewer" resetKey>
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { SshManagedServerStatus } from '../../../../shared/ssh-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { resetSshConnectInFlightForTests } from '@/ssh/ssh-connect-in-flight'
|
||||
|
||||
const toastError = vi.hoisted(() => vi.fn())
|
||||
vi.mock('sonner', () => ({ toast: { error: toastError } }))
|
||||
|
||||
import NodeDistMirrorDialog from './NodeDistMirrorDialog'
|
||||
import { SshHostNodeMirrorAction } from './SshHostNodeMirrorAction'
|
||||
import { toastSshConnectFailure } from '@/ssh/ssh-connect-failure-toast'
|
||||
|
||||
const blocked: SshManagedServerStatus = {
|
||||
kind: 'relay',
|
||||
reason: 'failed',
|
||||
detail: 'Neither this computer nor the host could download Node.',
|
||||
nodeDownloadBlocked: { mirror: 'unset' }
|
||||
}
|
||||
|
||||
const settingsSet = vi.fn(async (updates: Partial<GlobalSettings>) => ({
|
||||
...useAppStore.getState().settings,
|
||||
...updates
|
||||
}))
|
||||
const connect = vi.fn(async () => ({ targetId: 'ssh-1', status: 'connected' }))
|
||||
|
||||
function settingsWithMirror(nodeDistMirrorUrl: string): GlobalSettings {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these components read only nodeDistMirrorUrl.
|
||||
return { nodeDistMirrorUrl } as GlobalSettings
|
||||
}
|
||||
|
||||
function Host({ status }: { status: SshManagedServerStatus }) {
|
||||
const open = useAppStore((s) => s.activeModal === 'node-dist-mirror')
|
||||
return (
|
||||
<>
|
||||
<SshHostNodeMirrorAction targetId="ssh-1" status={status} />
|
||||
{open ? <NodeDistMirrorDialog /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
settingsSet.mockClear()
|
||||
connect.mockClear()
|
||||
toastError.mockClear()
|
||||
Object.assign(window, {
|
||||
api: { ...window.api, settings: { set: settingsSet }, ssh: { connect } }
|
||||
})
|
||||
useAppStore.setState({ settings: settingsWithMirror('') })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
act(() => useAppStore.getState().closeModal())
|
||||
resetSshConnectInFlightForTests()
|
||||
})
|
||||
|
||||
function openDialog(): HTMLInputElement {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use a Node.js mirror…' }))
|
||||
return screen.getByLabelText('Mirror URL')
|
||||
}
|
||||
|
||||
describe('Use a Node.js mirror', () => {
|
||||
it('opens from the host row with the suggested mirror, focused and editable', () => {
|
||||
render(<Host status={blocked} />)
|
||||
const input = openDialog()
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Use a Node.js mirror' })).toBeInTheDocument()
|
||||
expect(input).toHaveValue('https://npmmirror.com/mirrors/node/')
|
||||
expect(input).toHaveFocus()
|
||||
expect(screen.getByText(/checked against the checksum Orca pins/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves to the mirror setting, then retries the connect and closes', async () => {
|
||||
render(<Host status={blocked} />)
|
||||
openDialog()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save and retry' }))
|
||||
|
||||
await waitFor(() => expect(connect).toHaveBeenCalledWith({ targetId: 'ssh-1' }))
|
||||
expect(settingsSet).toHaveBeenCalledWith({
|
||||
nodeDistMirrorUrl: 'https://npmmirror.com/mirrors/node'
|
||||
})
|
||||
expect(settingsSet.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
connect.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(useAppStore.getState().settings?.nodeDistMirrorUrl).toBe(
|
||||
'https://npmmirror.com/mirrors/node'
|
||||
)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('submits with Enter and refuses a URL the setting would refuse', async () => {
|
||||
render(<Host status={blocked} />)
|
||||
const input = openDialog()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'https://me:secret@mirror.example/node' } })
|
||||
fireEvent.submit(input.closest('form')!)
|
||||
|
||||
expect(
|
||||
await screen.findByText('Mirror URL cannot contain a user name or password.')
|
||||
).toBeInTheDocument()
|
||||
expect(input).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(settingsSet).not.toHaveBeenCalled()
|
||||
expect(connect).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'https://mirror.example/node/' } })
|
||||
expect(input).not.toHaveAttribute('aria-invalid')
|
||||
fireEvent.submit(input.closest('form')!)
|
||||
await waitFor(() =>
|
||||
expect(settingsSet).toHaveBeenCalledWith({ nodeDistMirrorUrl: 'https://mirror.example/node' })
|
||||
)
|
||||
})
|
||||
|
||||
it('cancels without saving, also with Escape', async () => {
|
||||
render(<Host status={blocked} />)
|
||||
openDialog()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
|
||||
const input = openDialog()
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(settingsSet).not.toHaveBeenCalled()
|
||||
expect(connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows no offer once a mirror was tried, only the setting link', () => {
|
||||
const tried: SshManagedServerStatus = { ...blocked, nodeDownloadBlocked: { mirror: 'tried' } }
|
||||
useAppStore.getState().setSshConnectionState('ssh-1', {
|
||||
targetId: 'ssh-1',
|
||||
status: 'error',
|
||||
error: 'x',
|
||||
reconnectAttempt: 0,
|
||||
managedServer: tried
|
||||
})
|
||||
render(<Host status={tried} />)
|
||||
expect(screen.queryByRole('button', { name: 'Use a Node.js mirror…' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open mirror setting' }))
|
||||
const state = useAppStore.getState()
|
||||
expect(state.activeView).toBe('settings')
|
||||
expect(state.settingsNavigationTarget).toMatchObject({ pane: 'advanced' })
|
||||
expect(state.settingsSearchInputQuery).toBe('mirror')
|
||||
expect(state.activeModal).toBe('none')
|
||||
})
|
||||
|
||||
it('offers nothing in a paired browser, whose settings are not this computer’s', () => {
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
|
||||
try {
|
||||
render(<Host status={blocked} />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('opens the setting instead when a mirror was set after the toast appeared', () => {
|
||||
useAppStore.getState().setSshConnectionState('ssh-1', {
|
||||
targetId: 'ssh-1',
|
||||
status: 'error',
|
||||
error: 'x',
|
||||
reconnectAttempt: 0,
|
||||
managedServer: blocked
|
||||
})
|
||||
toastSshConnectFailure('ssh-1', new Error('x'), 'Connection failed')
|
||||
const [, options] = toastError.mock.calls[0]
|
||||
useAppStore.setState({
|
||||
settings: settingsWithMirror('https://m.example/node')
|
||||
})
|
||||
act(() => options.action.onClick())
|
||||
expect(useAppStore.getState().activeModal).toBe('none')
|
||||
expect(useAppStore.getState().activeView).toBe('settings')
|
||||
})
|
||||
|
||||
it('puts the same recovery on the connect-failure toast for this computer’s targets', () => {
|
||||
useAppStore.getState().setSshConnectionState('ssh-1', {
|
||||
targetId: 'ssh-1',
|
||||
status: 'error',
|
||||
error: 'x',
|
||||
reconnectAttempt: 0,
|
||||
managedServer: blocked
|
||||
})
|
||||
toastSshConnectFailure('ssh-1', new Error('x'), 'Connection failed')
|
||||
const [text, options] = toastError.mock.calls[0]
|
||||
expect(text).toContain('Use a Node.js mirror')
|
||||
act(() => options.action.onClick())
|
||||
expect(useAppStore.getState().activeModal).toBe('node-dist-mirror')
|
||||
expect(useAppStore.getState().modalData).toEqual({ targetId: 'ssh-1' })
|
||||
|
||||
toastError.mockClear()
|
||||
toastSshConnectFailure('ssh-1', new Error('x'), 'Connection failed', 'server-env')
|
||||
expect(toastError.mock.calls[0]).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { nodeDistMirrorRefusalText } from '@/ssh/ssh-host-node-download-copy'
|
||||
import {
|
||||
NODE_DIST_MIRROR_SUGGESTION,
|
||||
readNodeDistMirrorDialogTarget,
|
||||
saveNodeDistMirrorAndRetry
|
||||
} from '@/ssh/node-dist-mirror-recovery'
|
||||
import { toastSshConnectFailure } from '@/ssh/ssh-connect-failure-toast'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
|
||||
const INPUT_ID = 'node-dist-mirror-dialog-url'
|
||||
const HINT_ID = 'node-dist-mirror-dialog-hint'
|
||||
|
||||
/** "Use a Node.js mirror" for an SSH host whose setup couldn't download Node on either machine. */
|
||||
export default function NodeDistMirrorDialog(): React.JSX.Element | null {
|
||||
const targetId = useAppStore((s) => readNodeDistMirrorDialogTarget(s.modalData))
|
||||
const closeModal = useAppStore((s) => s.closeModal)
|
||||
const mountedRef = useMountedRef()
|
||||
const [text, setText] = useState(NODE_DIST_MIRROR_SUGGESTION)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// Why: the modal slot has no trigger element for the dialog to hand focus back to.
|
||||
const [returnFocusTo] = useState(() =>
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
)
|
||||
if (!targetId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const saveAndRetry = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
const result = await saveNodeDistMirrorAndRetry(targetId, text)
|
||||
if (result.outcome === 'retrying') {
|
||||
// Why before the mounted check: another modal may have replaced this one mid-save.
|
||||
result.retry.catch((cause: unknown) =>
|
||||
toastSshConnectFailure(
|
||||
targetId,
|
||||
cause,
|
||||
translate(
|
||||
'auto.components.settings.nodeDistMirrorDialog.retryFailed',
|
||||
'Connection failed'
|
||||
)
|
||||
)
|
||||
)
|
||||
if (mountedRef.current) {
|
||||
closeModal()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setBusy(false)
|
||||
setError(
|
||||
result.outcome === 'refused' ? nodeDistMirrorRefusalText(result.reason) : result.message
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && !busy && closeModal()}>
|
||||
<DialogContent
|
||||
surface="security"
|
||||
showCloseButton={!busy}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (returnFocusTo?.isConnected) {
|
||||
event.preventDefault()
|
||||
returnFocusTo.focus({ preventScroll: true })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!busy) {
|
||||
void saveAndRetry()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate(
|
||||
'auto.components.settings.nodeDistMirrorDialog.title',
|
||||
'Use a Node.js mirror'
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{translate(
|
||||
'auto.components.settings.nodeDistMirrorDialog.description',
|
||||
'Orca’s server needs Node.js, and neither this computer nor this host could download it from nodejs.org. A mirror is a copy of nodejs.org/dist that Orca tries first on both machines.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={INPUT_ID}>
|
||||
{translate('auto.components.settings.nodeDistMirrorDialog.urlLabel', 'Mirror URL')}
|
||||
</Label>
|
||||
<Input
|
||||
id={INPUT_ID}
|
||||
value={text}
|
||||
onChange={(event) => {
|
||||
setText(event.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={busy}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={HINT_ID}
|
||||
/>
|
||||
{error ? (
|
||||
<p id={HINT_ID} role="alert" className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<p id={HINT_ID} className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.nodeDistMirrorDialog.checksum',
|
||||
'Each download is still checked against the checksum Orca pins for this Node.js release. Saved to Settings > Advanced > Network.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={closeModal} disabled={busy}>
|
||||
{translate('auto.components.settings.nodeDistMirrorDialog.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={busy}>
|
||||
{translate('auto.components.settings.nodeDistMirrorDialog.save', 'Save and retry')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { normalizeNodeDistMirrorUrl } from '../../../../shared/node-dist-mirror'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { nodeDistMirrorRefusalText } from '@/ssh/ssh-host-node-download-copy'
|
||||
|
||||
type MirrorDraft = { source: string; text: string; error: string | null }
|
||||
|
||||
@@ -29,7 +30,7 @@ export function NodeDistMirrorSettingField({
|
||||
const commit = (): void => {
|
||||
const normalized = normalizeNodeDistMirrorUrl(current.text)
|
||||
if (!normalized.ok) {
|
||||
setDraft({ ...current, error: normalized.message })
|
||||
setDraft({ ...current, error: nodeDistMirrorRefusalText(normalized.reason) })
|
||||
return
|
||||
}
|
||||
setDraft({ source, text: normalized.value, error: null })
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { SshManagedServerStatus } from '../../../../shared/ssh-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { nodeMirrorRecoveryLabel } from '@/ssh/ssh-host-node-download-copy'
|
||||
import {
|
||||
openNodeMirrorRecovery,
|
||||
thisComputerNodeMirrorRecovery
|
||||
} from '@/ssh/node-dist-mirror-recovery'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
/** The way out of a setup that couldn't download Node.js: set a mirror, or check the one set. */
|
||||
export function SshHostNodeMirrorAction({
|
||||
targetId,
|
||||
status,
|
||||
ownerEnvironmentId = null
|
||||
}: {
|
||||
targetId: string
|
||||
status: SshManagedServerStatus | undefined
|
||||
ownerEnvironmentId?: string | null
|
||||
}): React.JSX.Element | null {
|
||||
const configuredMirror = useAppStore((s) => s.settings?.nodeDistMirrorUrl)
|
||||
const recovery = thisComputerNodeMirrorRecovery(status, ownerEnvironmentId, configuredMirror)
|
||||
if (!recovery) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => openNodeMirrorRecovery(targetId, recovery)}
|
||||
>
|
||||
{nodeMirrorRecoveryLabel(recovery)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { toast } from 'sonner'
|
||||
import { Plus, Upload } from 'lucide-react'
|
||||
import type { SshTarget } from '../../../../shared/ssh-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { sshTargetConnectFailureText } from '@/ssh/ssh-host-server-status-copy'
|
||||
import { toastSshConnectFailure } from '@/ssh/ssh-connect-failure-toast'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { Button } from '../ui/button'
|
||||
import { SshTargetCard } from './SshTargetCard'
|
||||
@@ -201,12 +201,10 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem
|
||||
await window.api.ssh.connect({ targetId })
|
||||
recordFeatureInteraction('ssh')
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
sshTargetConnectFailureText(
|
||||
targetId,
|
||||
err,
|
||||
translate('auto.components.settings.SshPane.e95d5ae10e', 'Connection failed')
|
||||
)
|
||||
toastSshConnectFailure(
|
||||
targetId,
|
||||
err,
|
||||
translate('auto.components.settings.SshPane.e95d5ae10e', 'Connection failed')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from '../ui/button'
|
||||
import { sshHostServerStatusLine } from '@/ssh/ssh-host-server-status-copy'
|
||||
import { SshHostChangedActions } from './SshHostChangedActions'
|
||||
import { SshHostMoveAnywayAction } from './SshHostMoveAnywayAction'
|
||||
import { SshHostNodeMirrorAction } from './SshHostNodeMirrorAction'
|
||||
import { SshHostRestartServerAction } from './SshHostRestartServerAction'
|
||||
|
||||
/** Whether this SSH host's managed Orca server serves it, and why not when it doesn't. */
|
||||
@@ -23,6 +24,9 @@ export function SshTargetServerStatus({
|
||||
if (!line) {
|
||||
return null
|
||||
}
|
||||
const mirrorAction = (
|
||||
<SshHostNodeMirrorAction targetId={target.id} status={state?.managedServer} />
|
||||
)
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p
|
||||
@@ -37,19 +41,25 @@ export function SshTargetServerStatus({
|
||||
</p>
|
||||
{line.detail ? (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="xs" className="group">
|
||||
{translate('auto.components.settings.sshHostServer.failureDetails', 'Details')}
|
||||
<ChevronDown className="transition-transform group-data-[state=open]:rotate-180" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
{/* Why one row: the recovery sits beside Details, so it reads as part of this failure. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pr-1">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="xs" className="group">
|
||||
{translate('auto.components.settings.sshHostServer.failureDetails', 'Details')}
|
||||
<ChevronDown className="transition-transform group-data-[state=open]:rotate-180" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
{mirrorAction}
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<pre className="scrollbar-sleek max-h-72 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
|
||||
{line.detail}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="flex justify-end px-1 empty:hidden">{mirrorAction}</div>
|
||||
)}
|
||||
<SshHostChangedActions target={target} onChanged={onChanged} />
|
||||
<SshHostRestartServerAction status={state?.managedServer} onChanged={onChanged} />
|
||||
<SshHostMoveAnywayAction
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback } from 'react'
|
||||
import { Loader2, Server, ServerOff } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { sshTargetConnectFailureText } from '@/ssh/ssh-host-server-status-copy'
|
||||
import { toastSshConnectFailure } from '@/ssh/ssh-connect-failure-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -110,15 +109,14 @@ export function WorktreeCardSshHostControl({
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
sshTargetConnectFailureText(
|
||||
targetId,
|
||||
err,
|
||||
translate(
|
||||
'auto.components.sidebar.WorktreeCardSshHostControl.connectFailed',
|
||||
'SSH connection failed'
|
||||
)
|
||||
)
|
||||
toastSshConnectFailure(
|
||||
targetId,
|
||||
err,
|
||||
translate(
|
||||
'auto.components.sidebar.WorktreeCardSshHostControl.connectFailed',
|
||||
'SSH connection failed'
|
||||
),
|
||||
sshOwnerEnvironmentId
|
||||
)
|
||||
// Why: a failed connect usually means the renderer's target metadata is stale
|
||||
// (target removed, or re-added under a new id). Resync so the control converges to
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { sshTargetConnectFailureText } from '@/ssh/ssh-host-server-status-copy'
|
||||
import { toastSshConnectFailure } from '@/ssh/ssh-connect-failure-toast'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { useAppStore } from '../../store'
|
||||
@@ -42,12 +42,10 @@ export function SshTargetStatusRow({
|
||||
await window.api.ssh.connect({ targetId })
|
||||
recordFeatureInteraction('ssh')
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
sshTargetConnectFailureText(
|
||||
targetId,
|
||||
err,
|
||||
translate('auto.components.status.bar.SshStatusSegment.2c29e2de68', 'Connection failed')
|
||||
)
|
||||
toastSshConnectFailure(
|
||||
targetId,
|
||||
err,
|
||||
translate('auto.components.status.bar.SshStatusSegment.2c29e2de68', 'Connection failed')
|
||||
)
|
||||
} finally {
|
||||
endSshConnect(targetId)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback } from 'react'
|
||||
import { Loader2, Server, ServerOff } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { sshTargetConnectFailureText } from '@/ssh/ssh-host-server-status-copy'
|
||||
import { toastSshConnectFailure } from '@/ssh/ssh-connect-failure-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SshHostNodeMirrorAction } from '@/components/settings/SshHostNodeMirrorAction'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
|
||||
import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
@@ -86,6 +86,9 @@ export function TerminalSshReconnectOverlay({
|
||||
sshOwnerEnvironmentId = null
|
||||
}: TerminalSshReconnectOverlayProps): React.JSX.Element {
|
||||
const setSshConnectionState = useAppStore((store) => store.setSshConnectionState)
|
||||
const managedServer = useAppStore(
|
||||
(store) => store.sshConnectionStates.get(targetId)?.managedServer
|
||||
)
|
||||
// Why: shared registry, not local state — the sidebar card control can dial the same
|
||||
// target, and the store status lags a click by one IPC hop.
|
||||
const connecting = useSshConnectInFlight(targetId)
|
||||
@@ -122,15 +125,14 @@ export function TerminalSshReconnectOverlay({
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
sshTargetConnectFailureText(
|
||||
targetId,
|
||||
err,
|
||||
translate(
|
||||
'auto.components.terminal.pane.TerminalSshReconnectOverlay.connectFailed',
|
||||
'SSH connection failed'
|
||||
)
|
||||
)
|
||||
toastSshConnectFailure(
|
||||
targetId,
|
||||
err,
|
||||
translate(
|
||||
'auto.components.terminal.pane.TerminalSshReconnectOverlay.connectFailed',
|
||||
'SSH connection failed'
|
||||
),
|
||||
sshOwnerEnvironmentId
|
||||
)
|
||||
// Why: a failed connect usually means the renderer's target metadata is
|
||||
// stale (target removed, or re-added under a new id). Resync it so the
|
||||
@@ -201,6 +203,15 @@ export function TerminalSshReconnectOverlay({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{!targetRemoved ? (
|
||||
<div className="mt-2 empty:hidden">
|
||||
<SshHostNodeMirrorAction
|
||||
targetId={targetId}
|
||||
status={managedServer}
|
||||
ownerEnvironmentId={sshOwnerEnvironmentId}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{targetRemoved ? (
|
||||
<Button
|
||||
|
||||
@@ -12859,7 +12859,10 @@
|
||||
"behindMigrating": "Runs a managed Orca server that updates after its project move finishes.",
|
||||
"behindCheckFailed": "Runs a managed Orca server; Orca couldn’t check whether it is behind this app.",
|
||||
"runtimeNoexec": "This host doesn’t allow running programs from ~/.orca-remote, so Orca can’t start its server there. This usually means the home folder is mounted with noexec. Ask the host’s administrator to allow running programs there, then connect again.",
|
||||
"setupContactLost": "Orca lost contact with this host while setting up its server. Nothing on the host was stopped. Connect again to continue."
|
||||
"setupContactLost": "Orca lost contact with this host while setting up its server. Nothing on the host was stopped. Connect again to continue.",
|
||||
"nodeDownloadBlocked": "Neither this computer nor this host could download Node.js, which Orca’s server needs. Use a Node.js mirror, or check the network on both machines.",
|
||||
"nodeDownloadBlockedNoMirror": "Neither this computer nor this host could download Node.js, which Orca’s server needs. Check the network or proxy on both machines, then connect again.",
|
||||
"nodeDownloadBlockedMirrorTried": "Neither this computer nor this host could download Node.js, including from the Node.js download mirror. Check the mirror setting, then connect again."
|
||||
},
|
||||
"ZcodeCliSetupSection": {
|
||||
"title": "Use GLM with ZCode",
|
||||
@@ -13008,6 +13011,25 @@
|
||||
},
|
||||
"useUsageHostSettings": {
|
||||
"updateFailed": "Could not update the setting on {{host}}."
|
||||
},
|
||||
"nodeDistMirrorDialog": {
|
||||
"action": "Use a Node.js mirror…",
|
||||
"openSetting": "Open mirror setting",
|
||||
"title": "Use a Node.js mirror",
|
||||
"description": "Orca’s server needs Node.js, and neither this computer nor this host could download it from nodejs.org. A mirror is a copy of nodejs.org/dist that Orca tries first on both machines.",
|
||||
"urlLabel": "Mirror URL",
|
||||
"checksum": "Each download is still checked against the checksum Orca pins for this Node.js release. Saved to Settings > Advanced > Network.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save and retry",
|
||||
"saveFailed": "Orca couldn’t save the mirror.",
|
||||
"retryFailed": "Connection failed",
|
||||
"refusal": {
|
||||
"tooLong": "Mirror URL is too long.",
|
||||
"protocol": "Use an http or https mirror URL.",
|
||||
"credentials": "Mirror URL cannot contain a user name or password.",
|
||||
"query": "Mirror URL cannot have a query or fragment.",
|
||||
"invalid": "Enter a valid mirror URL."
|
||||
}
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -12615,7 +12615,10 @@
|
||||
"linkedProjects_one": "El servidor Orca de este host ya tenía {{paths}}, así que este escritorio ahora usa la copia del servidor. Las pestañas de este escritorio y {{count}} automatización allí no se movieron, y los worktrees que creó allí aparecen como worktrees externos.",
|
||||
"linkedProjects_other": "El servidor Orca de este host ya tenía {{paths}}, así que este escritorio ahora usa la copia del servidor. Las pestañas de este escritorio y {{count}} automatizaciones allí no se movieron, y los worktrees que creó allí aparecen como worktrees externos.",
|
||||
"linkedProjects": "El servidor Orca de este host ya tenía {{paths}}, así que este escritorio ahora usa la copia del servidor. Las pestañas de este escritorio y {{count}} automatizaciones allí no se movieron, y los worktrees que creó allí aparecen como worktrees externos.",
|
||||
"linkedProjectsNoAutomations": "El servidor Orca de este host ya tenía {{paths}}, así que este escritorio ahora usa la copia del servidor. Las pestañas de este escritorio allí no se movieron, y los worktrees que creó allí aparecen como worktrees externos."
|
||||
"linkedProjectsNoAutomations": "El servidor Orca de este host ya tenía {{paths}}, así que este escritorio ahora usa la copia del servidor. Las pestañas de este escritorio allí no se movieron, y los worktrees que creó allí aparecen como worktrees externos.",
|
||||
"nodeDownloadBlocked": "Ni este equipo ni este host pudieron descargar Node.js, que el servidor de Orca necesita. Usa un mirror de Node.js o revisa la red en ambas máquinas.",
|
||||
"nodeDownloadBlockedNoMirror": "Ni este equipo ni este host pudieron descargar Node.js, que el servidor de Orca necesita. Revisa la red o el proxy en ambas máquinas y vuelve a conectar.",
|
||||
"nodeDownloadBlockedMirrorTried": "Ni este equipo ni este host pudieron descargar Node.js, tampoco desde el mirror de descarga de Node.js. Revisa el ajuste del mirror y vuelve a conectar."
|
||||
},
|
||||
"deltaMove": {
|
||||
"action": "Mover los proyectos nuevos…",
|
||||
@@ -12704,6 +12707,25 @@
|
||||
},
|
||||
"useUsageHostSettings": {
|
||||
"updateFailed": "No se pudo actualizar el ajuste en {{host}}."
|
||||
},
|
||||
"nodeDistMirrorDialog": {
|
||||
"action": "Usar un mirror de Node.js…",
|
||||
"openSetting": "Abrir ajuste del mirror",
|
||||
"title": "Usar un mirror de Node.js",
|
||||
"description": "El servidor de Orca necesita Node.js, y ni este equipo ni este host pudieron descargarlo de nodejs.org. Un mirror es una copia de nodejs.org/dist que Orca prueba primero en ambas máquinas.",
|
||||
"urlLabel": "URL del mirror",
|
||||
"checksum": "Cada descarga se sigue comprobando con el checksum que Orca fija para esta versión de Node.js. Se guarda en Ajustes > Avanzado > Red.",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar y reintentar",
|
||||
"saveFailed": "Orca no pudo guardar el mirror.",
|
||||
"retryFailed": "Error de conexión",
|
||||
"refusal": {
|
||||
"tooLong": "La URL del mirror es demasiado larga.",
|
||||
"protocol": "Usa una URL de mirror http o https.",
|
||||
"credentials": "La URL del mirror no puede incluir un nombre de usuario ni una contraseña.",
|
||||
"query": "La URL del mirror no puede tener una consulta ni un fragmento.",
|
||||
"invalid": "Introduce una URL de mirror válida."
|
||||
}
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -12728,7 +12728,10 @@
|
||||
"linkedProjects_one": "Le serveur Orca de cet hôte avait déjà {{paths}} ; ce bureau utilise donc maintenant la copie du serveur. Les onglets de ce bureau et {{count}} automatisation à cet endroit n’ont pas été déplacés, et les worktrees qu’il y a créés apparaissent comme worktrees externes.",
|
||||
"linkedProjects_other": "Le serveur Orca de cet hôte avait déjà {{paths}} ; ce bureau utilise donc maintenant la copie du serveur. Les onglets de ce bureau et {{count}} automatisations à cet endroit n’ont pas été déplacés, et les worktrees qu’il y a créés apparaissent comme worktrees externes.",
|
||||
"linkedProjects": "Le serveur Orca de cet hôte avait déjà {{paths}} ; ce bureau utilise donc maintenant la copie du serveur. Les onglets de ce bureau et {{count}} automatisations à cet endroit n’ont pas été déplacés, et les worktrees qu’il y a créés apparaissent comme worktrees externes.",
|
||||
"linkedProjectsNoAutomations": "Le serveur Orca de cet hôte avait déjà {{paths}} ; ce bureau utilise donc maintenant la copie du serveur. Les onglets de ce bureau à cet endroit n’ont pas été déplacés, et les worktrees qu’il y a créés apparaissent comme worktrees externes."
|
||||
"linkedProjectsNoAutomations": "Le serveur Orca de cet hôte avait déjà {{paths}} ; ce bureau utilise donc maintenant la copie du serveur. Les onglets de ce bureau à cet endroit n’ont pas été déplacés, et les worktrees qu’il y a créés apparaissent comme worktrees externes.",
|
||||
"nodeDownloadBlocked": "Ni cet ordinateur ni cet hôte n’ont pu télécharger Node.js, dont le serveur Orca a besoin. Utilisez un miroir Node.js ou vérifiez le réseau sur les deux machines.",
|
||||
"nodeDownloadBlockedNoMirror": "Ni cet ordinateur ni cet hôte n’ont pu télécharger Node.js, dont le serveur Orca a besoin. Vérifiez le réseau ou le proxy sur les deux machines, puis reconnectez-vous.",
|
||||
"nodeDownloadBlockedMirrorTried": "Ni cet ordinateur ni cet hôte n’ont pu télécharger Node.js, y compris depuis le miroir de téléchargement Node.js. Vérifiez le paramètre du miroir, puis reconnectez-vous."
|
||||
},
|
||||
"deltaMove": {
|
||||
"action": "Déplacer les nouveaux projets…",
|
||||
@@ -12817,6 +12820,25 @@
|
||||
},
|
||||
"useUsageHostSettings": {
|
||||
"updateFailed": "Impossible de mettre à jour le réglage sur {{host}}."
|
||||
},
|
||||
"nodeDistMirrorDialog": {
|
||||
"action": "Utiliser un miroir Node.js…",
|
||||
"openSetting": "Ouvrir le paramètre du miroir",
|
||||
"title": "Utiliser un miroir Node.js",
|
||||
"description": "Le serveur Orca a besoin de Node.js, et ni cet ordinateur ni cet hôte n’ont pu le télécharger depuis nodejs.org. Un miroir est une copie de nodejs.org/dist qu’Orca essaie en premier sur les deux machines.",
|
||||
"urlLabel": "URL du miroir",
|
||||
"checksum": "Chaque téléchargement reste vérifié avec la somme de contrôle qu’Orca fixe pour cette version de Node.js. Enregistré dans Paramètres > Avancé > Réseau.",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer et réessayer",
|
||||
"saveFailed": "Orca n’a pas pu enregistrer le miroir.",
|
||||
"retryFailed": "Échec de la connexion",
|
||||
"refusal": {
|
||||
"tooLong": "L’URL du miroir est trop longue.",
|
||||
"protocol": "Utilisez une URL de miroir http ou https.",
|
||||
"credentials": "L’URL du miroir ne peut pas contenir de nom d’utilisateur ni de mot de passe.",
|
||||
"query": "L’URL du miroir ne peut pas contenir de requête ni de fragment.",
|
||||
"invalid": "Saisissez une URL de miroir valide."
|
||||
}
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -12620,7 +12620,10 @@
|
||||
"linkedProjects_one": "このホストの Orca サーバーには {{paths}} が既にあったため、このデスクトップはサーバーのコピーを使用します。このデスクトップのタブと {{count}} 件のオートメーションは移動されず、ここで作成したワークツリーは外部ワークツリーとして表示されます。",
|
||||
"linkedProjects_other": "このホストの Orca サーバーには {{paths}} が既にあったため、このデスクトップはサーバーのコピーを使用します。このデスクトップのタブと {{count}} 件のオートメーションは移動されず、ここで作成したワークツリーは外部ワークツリーとして表示されます。",
|
||||
"linkedProjects": "このホストの Orca サーバーには {{paths}} が既にあったため、このデスクトップはサーバーのコピーを使用します。このデスクトップのタブと {{count}} 件のオートメーションは移動されず、ここで作成したワークツリーは外部ワークツリーとして表示されます。",
|
||||
"linkedProjectsNoAutomations": "このホストの Orca サーバーには {{paths}} が既にあったため、このデスクトップはサーバーのコピーを使用します。このデスクトップのタブは移動されず、ここで作成したワークツリーは外部ワークツリーとして表示されます。"
|
||||
"linkedProjectsNoAutomations": "このホストの Orca サーバーには {{paths}} が既にあったため、このデスクトップはサーバーのコピーを使用します。このデスクトップのタブは移動されず、ここで作成したワークツリーは外部ワークツリーとして表示されます。",
|
||||
"nodeDownloadBlocked": "Orca のサーバーに必要な Node.js を、このコンピュータとこのホストのどちらもダウンロードできませんでした。Node.js のミラーを使うか、両方のマシンのネットワークを確認してください。",
|
||||
"nodeDownloadBlockedNoMirror": "Orca のサーバーに必要な Node.js を、このコンピュータとこのホストのどちらもダウンロードできませんでした。両方のマシンのネットワークまたはプロキシを確認してから、もう一度接続してください。",
|
||||
"nodeDownloadBlockedMirrorTried": "このコンピュータとこのホストのどちらも、Node.js のダウンロードミラーを含めて Node.js をダウンロードできませんでした。ミラーの設定を確認してから、もう一度接続してください。"
|
||||
},
|
||||
"deltaMove": {
|
||||
"action": "新規プロジェクトを移動…",
|
||||
@@ -12709,6 +12712,25 @@
|
||||
},
|
||||
"useUsageHostSettings": {
|
||||
"updateFailed": "{{host}} の設定を更新できませんでした。"
|
||||
},
|
||||
"nodeDistMirrorDialog": {
|
||||
"action": "Node.js のミラーを使う…",
|
||||
"openSetting": "ミラーの設定を開く",
|
||||
"title": "Node.js のミラーを使う",
|
||||
"description": "Orca のサーバーには Node.js が必要ですが、このコンピュータとこのホストのどちらも nodejs.org からダウンロードできませんでした。ミラーは nodejs.org/dist のコピーで、Orca は両方のマシンでまずミラーを試します。",
|
||||
"urlLabel": "ミラーの URL",
|
||||
"checksum": "ダウンロードしたファイルは、引き続き Orca がこの Node.js リリースに固定しているチェックサムで検証されます。設定 > 詳細設定 > ネットワーク に保存されます。",
|
||||
"cancel": "キャンセル",
|
||||
"save": "保存して再試行",
|
||||
"saveFailed": "Orca はミラーを保存できませんでした。",
|
||||
"retryFailed": "接続に失敗しました",
|
||||
"refusal": {
|
||||
"tooLong": "ミラーの URL が長すぎます。",
|
||||
"protocol": "http または https のミラー URL を使ってください。",
|
||||
"credentials": "ミラーの URL にユーザー名やパスワードは含められません。",
|
||||
"query": "ミラーの URL にクエリやフラグメントは含められません。",
|
||||
"invalid": "有効なミラーの URL を入力してください。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -12620,7 +12620,10 @@
|
||||
"linkedProjects_one": "이 호스트의 Orca 서버에 {{paths}}이(가) 이미 있어 이 데스크톱은 이제 서버의 사본을 사용합니다. 이 데스크톱의 탭과 자동화 {{count}}개는 이동되지 않았으며, 여기서 만든 워크트리는 외부 워크트리로 표시됩니다.",
|
||||
"linkedProjects_other": "이 호스트의 Orca 서버에 {{paths}}이(가) 이미 있어 이 데스크톱은 이제 서버의 사본을 사용합니다. 이 데스크톱의 탭과 자동화 {{count}}개는 이동되지 않았으며, 여기서 만든 워크트리는 외부 워크트리로 표시됩니다.",
|
||||
"linkedProjects": "이 호스트의 Orca 서버에 {{paths}}이(가) 이미 있어 이 데스크톱은 이제 서버의 사본을 사용합니다. 이 데스크톱의 탭과 자동화 {{count}}개는 이동되지 않았으며, 여기서 만든 워크트리는 외부 워크트리로 표시됩니다.",
|
||||
"linkedProjectsNoAutomations": "이 호스트의 Orca 서버에 {{paths}}이(가) 이미 있어 이 데스크톱은 이제 서버의 사본을 사용합니다. 이 데스크톱의 탭은 이동되지 않았으며, 여기서 만든 워크트리는 외부 워크트리로 표시됩니다."
|
||||
"linkedProjectsNoAutomations": "이 호스트의 Orca 서버에 {{paths}}이(가) 이미 있어 이 데스크톱은 이제 서버의 사본을 사용합니다. 이 데스크톱의 탭은 이동되지 않았으며, 여기서 만든 워크트리는 외부 워크트리로 표시됩니다.",
|
||||
"nodeDownloadBlocked": "이 컴퓨터와 이 호스트 모두 Orca 서버에 필요한 Node.js를 다운로드하지 못했습니다. Node.js 미러를 사용하거나 두 컴퓨터의 네트워크를 확인하세요.",
|
||||
"nodeDownloadBlockedNoMirror": "이 컴퓨터와 이 호스트 모두 Orca 서버에 필요한 Node.js를 다운로드하지 못했습니다. 두 컴퓨터의 네트워크나 프록시를 확인한 뒤 다시 연결하세요.",
|
||||
"nodeDownloadBlockedMirrorTried": "이 컴퓨터와 이 호스트 모두 Node.js 다운로드 미러를 포함해 Node.js를 다운로드하지 못했습니다. 미러 설정을 확인한 뒤 다시 연결하세요."
|
||||
},
|
||||
"deltaMove": {
|
||||
"action": "새 프로젝트 이동…",
|
||||
@@ -12709,6 +12712,25 @@
|
||||
},
|
||||
"useUsageHostSettings": {
|
||||
"updateFailed": "{{host}}의 설정을 업데이트하지 못했습니다."
|
||||
},
|
||||
"nodeDistMirrorDialog": {
|
||||
"action": "Node.js 미러 사용…",
|
||||
"openSetting": "미러 설정 열기",
|
||||
"title": "Node.js 미러 사용",
|
||||
"description": "Orca 서버에는 Node.js가 필요한데, 이 컴퓨터와 이 호스트 모두 nodejs.org에서 다운로드하지 못했습니다. 미러는 nodejs.org/dist의 사본으로, Orca가 두 컴퓨터에서 먼저 시도합니다.",
|
||||
"urlLabel": "미러 URL",
|
||||
"checksum": "모든 다운로드는 여전히 Orca가 이 Node.js 릴리스에 고정한 체크섬으로 검증됩니다. 설정 > 고급 > 네트워크에 저장됩니다.",
|
||||
"cancel": "취소",
|
||||
"save": "저장 후 다시 시도",
|
||||
"saveFailed": "Orca가 미러를 저장하지 못했습니다.",
|
||||
"retryFailed": "연결 실패",
|
||||
"refusal": {
|
||||
"tooLong": "미러 URL이 너무 깁니다.",
|
||||
"protocol": "http 또는 https 미러 URL을 사용하세요.",
|
||||
"credentials": "미러 URL에는 사용자 이름이나 비밀번호를 넣을 수 없습니다.",
|
||||
"query": "미러 URL에는 쿼리나 프래그먼트를 넣을 수 없습니다.",
|
||||
"invalid": "올바른 미러 URL을 입력하세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -12634,7 +12634,10 @@
|
||||
"linkedProjects_one": "此主机的 Orca 服务器已有 {{paths}},因此此桌面现在使用服务器的副本。此桌面在那里的标签页和 {{count}} 个自动化未被迁移,它在那里创建的工作树会显示为外部工作树。",
|
||||
"linkedProjects_other": "此主机的 Orca 服务器已有 {{paths}},因此此桌面现在使用服务器的副本。此桌面在那里的标签页和 {{count}} 个自动化未被迁移,它在那里创建的工作树会显示为外部工作树。",
|
||||
"linkedProjects": "此主机的 Orca 服务器已有 {{paths}},因此此桌面现在使用服务器的副本。此桌面在那里的标签页和 {{count}} 个自动化未被迁移,它在那里创建的工作树会显示为外部工作树。",
|
||||
"linkedProjectsNoAutomations": "此主机的 Orca 服务器已有 {{paths}},因此此桌面现在使用服务器的副本。此桌面在那里的标签页未被迁移,它在那里创建的工作树会显示为外部工作树。"
|
||||
"linkedProjectsNoAutomations": "此主机的 Orca 服务器已有 {{paths}},因此此桌面现在使用服务器的副本。此桌面在那里的标签页未被迁移,它在那里创建的工作树会显示为外部工作树。",
|
||||
"nodeDownloadBlocked": "这台电脑和这台主机都无法下载 Orca 服务器所需的 Node.js。请使用 Node.js 镜像,或检查两台机器的网络。",
|
||||
"nodeDownloadBlockedNoMirror": "这台电脑和这台主机都无法下载 Orca 服务器所需的 Node.js。请检查两台机器的网络或代理,然后重新连接。",
|
||||
"nodeDownloadBlockedMirrorTried": "这台电脑和这台主机都无法下载 Node.js,包括从 Node.js 下载镜像下载。请检查镜像设置,然后重新连接。"
|
||||
},
|
||||
"deltaMove": {
|
||||
"action": "迁移新项目…",
|
||||
@@ -12709,6 +12712,25 @@
|
||||
},
|
||||
"useUsageHostSettings": {
|
||||
"updateFailed": "无法更新 {{host}} 上的设置。"
|
||||
},
|
||||
"nodeDistMirrorDialog": {
|
||||
"action": "使用 Node.js 镜像…",
|
||||
"openSetting": "打开镜像设置",
|
||||
"title": "使用 Node.js 镜像",
|
||||
"description": "Orca 服务器需要 Node.js,但这台电脑和这台主机都无法从 nodejs.org 下载。镜像是 nodejs.org/dist 的副本,Orca 会在两台机器上优先尝试镜像。",
|
||||
"urlLabel": "镜像 URL",
|
||||
"checksum": "每次下载仍会按 Orca 为此 Node.js 版本固定的校验和进行验证。保存在 设置 > 高级 > 网络 中。",
|
||||
"cancel": "取消",
|
||||
"save": "保存并重试",
|
||||
"saveFailed": "Orca 无法保存镜像。",
|
||||
"retryFailed": "连接失败",
|
||||
"refusal": {
|
||||
"tooLong": "镜像 URL 过长。",
|
||||
"protocol": "请使用 http 或 https 镜像 URL。",
|
||||
"credentials": "镜像 URL 不能包含用户名或密码。",
|
||||
"query": "镜像 URL 不能包含查询或片段。",
|
||||
"invalid": "请输入有效的镜像 URL。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
NODE_DIST_MIRROR_SUGGESTION,
|
||||
saveNodeDistMirrorAndRetry
|
||||
} from './node-dist-mirror-recovery'
|
||||
import {
|
||||
beginSshConnect,
|
||||
isSshConnectInFlight,
|
||||
resetSshConnectInFlightForTests
|
||||
} from './ssh-connect-in-flight'
|
||||
|
||||
function deps() {
|
||||
const calls: string[] = []
|
||||
let finishConnect: () => void = () => {}
|
||||
return {
|
||||
calls,
|
||||
finishConnect: () => finishConnect(),
|
||||
save: vi.fn(async (url: string) => {
|
||||
calls.push(`save ${url}`)
|
||||
}),
|
||||
connect: vi.fn((targetId: string) => {
|
||||
calls.push(`connect ${targetId}`)
|
||||
return new Promise<void>((resolve) => {
|
||||
finishConnect = resolve
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => resetSshConnectInFlightForTests())
|
||||
|
||||
describe('save a Node.js mirror and retry', () => {
|
||||
it('saves the normalized mirror first, then retries the connect as an in-flight attempt', async () => {
|
||||
const d = deps()
|
||||
const result = await saveNodeDistMirrorAndRetry('ssh-1', NODE_DIST_MIRROR_SUGGESTION, d)
|
||||
|
||||
expect(result.outcome).toBe('retrying')
|
||||
expect(d.calls).toEqual(['save https://npmmirror.com/mirrors/node', 'connect ssh-1'])
|
||||
expect(isSshConnectInFlight('ssh-1')).toBe(true)
|
||||
d.finishConnect()
|
||||
if (result.outcome === 'retrying') {
|
||||
await result.retry
|
||||
}
|
||||
await Promise.resolve()
|
||||
expect(isSshConnectInFlight('ssh-1')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['', 'invalid'],
|
||||
[' ', 'invalid'],
|
||||
['not a url', 'invalid'],
|
||||
['ftp://mirror.example/node', 'protocol'],
|
||||
['https://me:secret@mirror.example/node', 'credentials'],
|
||||
['https://mirror.example/node?token=1', 'query']
|
||||
])('refuses %j with the setting’s validation, saving nothing', async (text, reason) => {
|
||||
const d = deps()
|
||||
await expect(saveNodeDistMirrorAndRetry('ssh-1', text, d)).resolves.toEqual({
|
||||
outcome: 'refused',
|
||||
reason
|
||||
})
|
||||
expect(d.save).not.toHaveBeenCalled()
|
||||
expect(d.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves but leaves a connect already dialing to finish on its own', async () => {
|
||||
const d = deps()
|
||||
beginSshConnect('ssh-1')
|
||||
const result = await saveNodeDistMirrorAndRetry('ssh-1', 'https://mirror.example/node', d)
|
||||
expect(result.outcome).toBe('retrying')
|
||||
expect(d.save).toHaveBeenCalled()
|
||||
expect(d.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not retry when the setting could not be saved', async () => {
|
||||
const d = deps()
|
||||
d.save.mockRejectedValueOnce(new Error('settings store is read-only'))
|
||||
await expect(
|
||||
saveNodeDistMirrorAndRetry('ssh-1', 'https://mirror.example/node', d)
|
||||
).resolves.toEqual({ outcome: 'save-failed', message: 'settings store is read-only' })
|
||||
expect(d.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
/** Opening, saving and retrying the Node.js mirror recovery for an SSH host's failed setup. */
|
||||
import {
|
||||
normalizeNodeDistMirrorUrl,
|
||||
type NodeDistMirrorRefusal
|
||||
} from '../../../shared/node-dist-mirror'
|
||||
import type { SshManagedServerStatus } from '../../../shared/ssh-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isSshConnectInFlight, trackSshConnect } from './ssh-connect-in-flight'
|
||||
import { nodeMirrorRecovery, type NodeMirrorRecovery } from './ssh-host-node-download-copy'
|
||||
|
||||
export const NODE_DIST_MIRROR_SUGGESTION = 'https://npmmirror.com/mirrors/node/'
|
||||
|
||||
/**
|
||||
* The recovery this window can carry out. Only this desktop's own SSH targets qualify: the mirror
|
||||
* setting lives in its main process, not on another server or in a paired browser.
|
||||
*/
|
||||
export function thisComputerNodeMirrorRecovery(
|
||||
status: SshManagedServerStatus | undefined,
|
||||
ownerEnvironmentId: string | null,
|
||||
configuredMirror = useAppStore.getState().settings?.nodeDistMirrorUrl
|
||||
): NodeMirrorRecovery | null {
|
||||
if (ownerEnvironmentId || isPairedWebClientWindow()) {
|
||||
return null
|
||||
}
|
||||
return nodeMirrorRecovery(status, configuredMirror)
|
||||
}
|
||||
|
||||
/** Decided again on click: a toast's action can outlive a mirror set in Settings meanwhile. */
|
||||
export function runNodeMirrorRecovery(targetId: string): void {
|
||||
const { sshConnectionStates } = useAppStore.getState()
|
||||
const recovery = thisComputerNodeMirrorRecovery(
|
||||
sshConnectionStates.get(targetId)?.managedServer,
|
||||
null
|
||||
)
|
||||
if (recovery) {
|
||||
openNodeMirrorRecovery(targetId, recovery)
|
||||
}
|
||||
}
|
||||
|
||||
export function openNodeMirrorRecovery(targetId: string, recovery: NodeMirrorRecovery): void {
|
||||
if (recovery === 'offer') {
|
||||
useAppStore.getState().openModal('node-dist-mirror', { targetId })
|
||||
} else {
|
||||
openNodeDistMirrorSettings()
|
||||
}
|
||||
}
|
||||
|
||||
export function readNodeDistMirrorDialogTarget(data: Record<string, unknown>): string | null {
|
||||
return typeof data.targetId === 'string' && data.targetId ? data.targetId : null
|
||||
}
|
||||
|
||||
// Why a search, not a scroll target: it also opens the collapsed fields when only the environment
|
||||
// variable set the mirror.
|
||||
function openNodeDistMirrorSettings(): void {
|
||||
const { openSettingsPage, openSettingsTarget, setSettingsSearchQuery } = useAppStore.getState()
|
||||
openSettingsPage()
|
||||
openSettingsTarget({ pane: 'advanced', repoId: null })
|
||||
setSettingsSearchQuery(
|
||||
translate('auto.components.settings.general.search.nodeDistMirror', 'mirror')
|
||||
)
|
||||
}
|
||||
|
||||
export type SaveNodeDistMirrorResult =
|
||||
| { outcome: 'retrying'; retry: Promise<unknown> }
|
||||
| { outcome: 'refused'; reason: NodeDistMirrorRefusal }
|
||||
| { outcome: 'save-failed'; message: string }
|
||||
|
||||
const defaultDeps = {
|
||||
save: (nodeDistMirrorUrl: string) =>
|
||||
useAppStore.getState().updateSettingsOrThrow({ nodeDistMirrorUrl }),
|
||||
connect: (targetId: string) => window.api.ssh.connect({ targetId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves through the same setting and IPC as Settings > Advanced > Network, then connects again.
|
||||
* Why save first: main reads the setting when the connect downloads Node.
|
||||
*/
|
||||
export async function saveNodeDistMirrorAndRetry(
|
||||
targetId: string,
|
||||
text: string,
|
||||
deps: {
|
||||
save: (nodeDistMirrorUrl: string) => Promise<void>
|
||||
connect: (targetId: string) => Promise<unknown>
|
||||
} = defaultDeps
|
||||
): Promise<SaveNodeDistMirrorResult> {
|
||||
const normalized = normalizeNodeDistMirrorUrl(text)
|
||||
if (!normalized.ok) {
|
||||
return { outcome: 'refused', reason: normalized.reason }
|
||||
}
|
||||
// Why: an empty field would clear the setting, which is no mirror at all.
|
||||
if (!normalized.value) {
|
||||
return { outcome: 'refused', reason: 'invalid' }
|
||||
}
|
||||
try {
|
||||
await deps.save(normalized.value)
|
||||
} catch (error) {
|
||||
return {
|
||||
outcome: 'save-failed',
|
||||
message:
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.components.settings.nodeDistMirrorDialog.saveFailed',
|
||||
'Orca couldn’t save the mirror.'
|
||||
)
|
||||
}
|
||||
}
|
||||
// Why: a connect already dialing keeps its own attempt; a second one would race it.
|
||||
if (isSshConnectInFlight(targetId)) {
|
||||
return { outcome: 'retrying', retry: Promise.resolve() }
|
||||
}
|
||||
return { outcome: 'retrying', retry: trackSshConnect(targetId, deps.connect(targetId)) }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { sshTargetConnectFailureText } from './ssh-host-server-status-copy'
|
||||
import { nodeMirrorRecoveryLabel } from './ssh-host-node-download-copy'
|
||||
import { runNodeMirrorRecovery, thisComputerNodeMirrorRecovery } from './node-dist-mirror-recovery'
|
||||
|
||||
/** A failed connect's toast, with the Node.js mirror recovery when the setup couldn't get Node. */
|
||||
export function toastSshConnectFailure(
|
||||
targetId: string,
|
||||
error: unknown,
|
||||
fallback: string,
|
||||
ownerEnvironmentId: string | null = null
|
||||
): void {
|
||||
const text = sshTargetConnectFailureText(targetId, error, fallback)
|
||||
const recovery = thisComputerNodeMirrorRecovery(
|
||||
useAppStore.getState().sshConnectionStates.get(targetId)?.managedServer,
|
||||
ownerEnvironmentId
|
||||
)
|
||||
if (!recovery) {
|
||||
toast.error(text)
|
||||
return
|
||||
}
|
||||
toast.error(text, {
|
||||
action: {
|
||||
label: nodeMirrorRecoveryLabel(recovery),
|
||||
onClick: () => runNodeMirrorRecovery(targetId)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SshManagedServerStatus } from '../../../shared/ssh-types'
|
||||
import { nodeMirrorRecovery } from './ssh-host-node-download-copy'
|
||||
import { sshConnectFailureText, sshHostServerStatusLine } from './ssh-host-server-status-copy'
|
||||
|
||||
const DETAIL =
|
||||
'Neither this computer nor the host could download Node from https://nodejs.org/dist/x.'
|
||||
|
||||
function blocked(
|
||||
mirror: 'unset' | 'tried' | 'unsupported',
|
||||
extra: Partial<Extract<SshManagedServerStatus, { kind: 'relay' }>> = {}
|
||||
): SshManagedServerStatus {
|
||||
return {
|
||||
kind: 'relay',
|
||||
reason: 'failed',
|
||||
detail: DETAIL,
|
||||
nodeDownloadBlocked: { mirror },
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
describe('Node.js mirror recovery for a setup that could not download Node', () => {
|
||||
it('offers a mirror only when none was in the failed attempts or is set now', () => {
|
||||
expect(nodeMirrorRecovery(blocked('unset'), '')).toBe('offer')
|
||||
expect(nodeMirrorRecovery(blocked('unset'), undefined)).toBe('offer')
|
||||
expect(nodeMirrorRecovery(blocked('unset'), 'https://mirror.example/node')).toBe('settings')
|
||||
expect(nodeMirrorRecovery(blocked('tried'), '')).toBe('settings')
|
||||
})
|
||||
|
||||
it.each<[string, SshManagedServerStatus | undefined]>([
|
||||
['no status', undefined],
|
||||
['a build no mirror carries', blocked('unsupported')],
|
||||
['a plain failure', { kind: 'relay', reason: 'failed', detail: DETAIL }],
|
||||
['lost contact', blocked('unset', { contactLost: true })],
|
||||
['a held move', blocked('unset', { held: true })],
|
||||
['a refusal', blocked('unset', { reason: 'refused' })],
|
||||
['a managed server', { kind: 'managed', environmentId: 'env-1' }]
|
||||
])('offers nothing for %s', (_label, status) => {
|
||||
expect(nodeMirrorRecovery(status, '')).toBeNull()
|
||||
})
|
||||
|
||||
it('never reads the failure from its text', () => {
|
||||
expect(nodeMirrorRecovery({ kind: 'relay', reason: 'failed', detail: DETAIL }, '')).toBeNull()
|
||||
})
|
||||
|
||||
it('names the blocked download on the host row and in the connect toast', () => {
|
||||
const line = sshHostServerStatusLine({}, { managedServer: blocked('unset') })
|
||||
expect(line).toEqual({
|
||||
tone: 'destructive',
|
||||
text: expect.stringContaining('Use a Node.js mirror'),
|
||||
detail: DETAIL
|
||||
})
|
||||
expect(sshConnectFailureText({ managedServer: blocked('tried') }, 'fallback')).toContain(
|
||||
'Check the mirror setting'
|
||||
)
|
||||
expect(
|
||||
sshHostServerStatusLine({}, { managedServer: blocked('unsupported') })?.text
|
||||
).not.toContain('mirror')
|
||||
})
|
||||
|
||||
it('keeps the held-move and lost-contact lines ahead of the download', () => {
|
||||
expect(
|
||||
sshHostServerStatusLine({}, { managedServer: blocked('unset', { held: true }) })?.text
|
||||
).toContain('paused moving')
|
||||
expect(
|
||||
sshHostServerStatusLine({}, { managedServer: blocked('unset', { contactLost: true }) })?.text
|
||||
).toContain('lost contact')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
/** Words and the one recovery for a managed-server setup that couldn't download Node.js. */
|
||||
import type {
|
||||
SshManagedServerNodeDownloadBlocked,
|
||||
SshManagedServerStatus
|
||||
} from '../../../shared/ssh-types'
|
||||
import type { NodeDistMirrorRefusal } from '../../../shared/node-dist-mirror'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/** `offer`: set a mirror now. `settings`: a mirror was tried, so only its setting is left. */
|
||||
export type NodeMirrorRecovery = 'offer' | 'settings'
|
||||
|
||||
/**
|
||||
* Read from main's structured flag only. `configuredMirror` is this computer's setting now: once it
|
||||
* holds a mirror, offering another would only overwrite it.
|
||||
*/
|
||||
export function nodeMirrorRecovery(
|
||||
status: SshManagedServerStatus | undefined,
|
||||
configuredMirror: string | undefined
|
||||
): NodeMirrorRecovery | null {
|
||||
const blocked = blockedNodeDownload(status)
|
||||
if (!blocked || blocked.mirror === 'unsupported') {
|
||||
return null
|
||||
}
|
||||
return blocked.mirror === 'unset' && !configuredMirror?.trim() ? 'offer' : 'settings'
|
||||
}
|
||||
|
||||
/** The blocked download a failed setup reports; a held move or lost contact says something else. */
|
||||
export function blockedNodeDownload(
|
||||
status: SshManagedServerStatus | undefined
|
||||
): SshManagedServerNodeDownloadBlocked | null {
|
||||
if (
|
||||
status?.kind !== 'relay' ||
|
||||
status.reason !== 'failed' ||
|
||||
status.held ||
|
||||
status.contactLost ||
|
||||
!status.nodeDownloadBlocked
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return status.nodeDownloadBlocked
|
||||
}
|
||||
|
||||
export function nodeDownloadBlockedText(blocked: SshManagedServerNodeDownloadBlocked): string {
|
||||
switch (blocked.mirror) {
|
||||
case 'unset':
|
||||
return translate(
|
||||
'auto.components.settings.sshHostServer.nodeDownloadBlocked',
|
||||
'Neither this computer nor this host could download Node.js, which Orca’s server needs. Use a Node.js mirror, or check the network on both machines.'
|
||||
)
|
||||
case 'unsupported':
|
||||
return translate(
|
||||
'auto.components.settings.sshHostServer.nodeDownloadBlockedNoMirror',
|
||||
'Neither this computer nor this host could download Node.js, which Orca’s server needs. Check the network or proxy on both machines, then connect again.'
|
||||
)
|
||||
case 'tried':
|
||||
return translate(
|
||||
'auto.components.settings.sshHostServer.nodeDownloadBlockedMirrorTried',
|
||||
'Neither this computer nor this host could download Node.js, including from the Node.js download mirror. Check the mirror setting, then connect again.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function nodeMirrorRecoveryLabel(recovery: NodeMirrorRecovery): string {
|
||||
return recovery === 'offer'
|
||||
? translate('auto.components.settings.nodeDistMirrorDialog.action', 'Use a Node.js mirror…')
|
||||
: translate('auto.components.settings.nodeDistMirrorDialog.openSetting', 'Open mirror setting')
|
||||
}
|
||||
|
||||
/** The shared validator's refusal, in the user's language. */
|
||||
export function nodeDistMirrorRefusalText(reason: NodeDistMirrorRefusal): string {
|
||||
switch (reason) {
|
||||
case 'too-long':
|
||||
return translate(
|
||||
'auto.components.settings.nodeDistMirrorDialog.refusal.tooLong',
|
||||
'Mirror URL is too long.'
|
||||
)
|
||||
case 'protocol':
|
||||
return translate(
|
||||
'auto.components.settings.nodeDistMirrorDialog.refusal.protocol',
|
||||
'Use an http or https mirror URL.'
|
||||
)
|
||||
case 'credentials':
|
||||
return translate(
|
||||
'auto.components.settings.nodeDistMirrorDialog.refusal.credentials',
|
||||
'Mirror URL cannot contain a user name or password.'
|
||||
)
|
||||
case 'query':
|
||||
return translate(
|
||||
'auto.components.settings.nodeDistMirrorDialog.refusal.query',
|
||||
'Mirror URL cannot have a query or fragment.'
|
||||
)
|
||||
case 'invalid':
|
||||
return translate(
|
||||
'auto.components.settings.nodeDistMirrorDialog.refusal.invalid',
|
||||
'Enter a valid mirror URL.'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { useAppStore } from '@/store'
|
||||
import { launchSessionEndedLine } from './ssh-host-launch-session-copy'
|
||||
import { bridgedTerminalsLine } from './ssh-host-bridged-terminals-copy'
|
||||
import { managedUpdateLine, withDetail } from './ssh-host-server-update-copy'
|
||||
import { blockedNodeDownload, nodeDownloadBlockedText } from './ssh-host-node-download-copy'
|
||||
|
||||
export type SshHostServerStatusLine = {
|
||||
text: string
|
||||
@@ -304,10 +305,18 @@ function unservedLine(
|
||||
...withDetail(status.detail)
|
||||
}
|
||||
case 'deferred':
|
||||
case 'failed':
|
||||
case 'failed': {
|
||||
const blocked = blockedNodeDownload(status)
|
||||
return {
|
||||
...(status.held ? heldMoveLine() : status.contactLost ? contactLostLine() : retryLine()),
|
||||
...(blocked
|
||||
? { tone: 'destructive' as const, text: nodeDownloadBlockedText(blocked) }
|
||||
: status.held
|
||||
? heldMoveLine()
|
||||
: status.contactLost
|
||||
? contactLostLine()
|
||||
: retryLine()),
|
||||
...withDetail(status.detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export type UISliceContextual = {
|
||||
| 'confirm-orca-yaml-hooks'
|
||||
| 'diagram-viewer'
|
||||
| 'agent-session-history'
|
||||
| 'node-dist-mirror'
|
||||
modalData: Record<string, unknown>
|
||||
openModal: (modal: UISliceContextual['activeModal'], data?: Record<string, unknown>) => void
|
||||
closeModal: () => void
|
||||
|
||||
@@ -21,14 +21,16 @@ describe('Node dist mirror URL', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['ftp://mirror.example.com/node', 'http or https'],
|
||||
['https://user:secret@mirror.example.com/node', 'user name or password'],
|
||||
['https://mirror.example.com/node?token=1', 'query'],
|
||||
['not a url', 'valid mirror URL']
|
||||
])('refuses %s', (value, message) => {
|
||||
['ftp://mirror.example.com/node', 'http or https', 'protocol'],
|
||||
['https://user:secret@mirror.example.com/node', 'user name or password', 'credentials'],
|
||||
['https://mirror.example.com/node?token=1', 'query', 'query'],
|
||||
['not a url', 'valid mirror URL', 'invalid'],
|
||||
[`https://m.example/${'a'.repeat(2048)}`, 'too long', 'too-long']
|
||||
])('refuses %s', (value, message, reason) => {
|
||||
expect(normalizeNodeDistMirrorUrl(value)).toMatchObject({
|
||||
ok: false,
|
||||
message: expect.stringContaining(message)
|
||||
message: expect.stringContaining(message),
|
||||
reason
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
/** The variables Node tooling already reads for the same mirror; read on this computer only. */
|
||||
export const NODE_DIST_MIRROR_ENV_KEYS = ['NODEJS_ORG_MIRROR', 'NVM_NODEJS_ORG_MIRROR'] as const
|
||||
|
||||
export type NodeDistMirrorRefusal = 'too-long' | 'invalid' | 'protocol' | 'credentials' | 'query'
|
||||
|
||||
/** `message` is the English text; `reason` lets the renderer word it in the user's language. */
|
||||
export type NodeDistMirrorValidationResult =
|
||||
| { ok: true; value: string; message?: undefined }
|
||||
| { ok: false; value: ''; message: string }
|
||||
| { ok: true; value: string; message?: undefined; reason?: undefined }
|
||||
| { ok: false; value: ''; message: string; reason: NodeDistMirrorRefusal }
|
||||
|
||||
const NODE_DIST_MIRROR_MAX_LENGTH = 2048
|
||||
|
||||
@@ -19,23 +22,38 @@ export function normalizeNodeDistMirrorUrl(value: unknown): NodeDistMirrorValida
|
||||
return { ok: true, value: '' }
|
||||
}
|
||||
if (trimmed.length > NODE_DIST_MIRROR_MAX_LENGTH) {
|
||||
return { ok: false, value: '', message: 'Mirror URL is too long.' }
|
||||
return { ok: false, value: '', message: 'Mirror URL is too long.', reason: 'too-long' }
|
||||
}
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(trimmed)
|
||||
} catch {
|
||||
return { ok: false, value: '', message: 'Enter a valid mirror URL.' }
|
||||
return { ok: false, value: '', message: 'Enter a valid mirror URL.', reason: 'invalid' }
|
||||
}
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
return { ok: false, value: '', message: 'Use an http or https mirror URL.' }
|
||||
return {
|
||||
ok: false,
|
||||
value: '',
|
||||
message: 'Use an http or https mirror URL.',
|
||||
reason: 'protocol'
|
||||
}
|
||||
}
|
||||
// Why: the URL reaches SSH hosts and error text; credentials belong in the proxy setting.
|
||||
if (parsed.username || parsed.password) {
|
||||
return { ok: false, value: '', message: 'Mirror URL cannot contain a user name or password.' }
|
||||
return {
|
||||
ok: false,
|
||||
value: '',
|
||||
message: 'Mirror URL cannot contain a user name or password.',
|
||||
reason: 'credentials'
|
||||
}
|
||||
}
|
||||
if (parsed.search || parsed.hash) {
|
||||
return { ok: false, value: '', message: 'Mirror URL cannot have a query or fragment.' }
|
||||
return {
|
||||
ok: false,
|
||||
value: '',
|
||||
message: 'Mirror URL cannot have a query or fragment.',
|
||||
reason: 'query'
|
||||
}
|
||||
}
|
||||
// Why origin + pathname: href keeps an empty `?` or `#`, which would swallow the archive path.
|
||||
return { ok: true, value: `${parsed.origin}${parsed.pathname}`.replace(/\/+$/u, '') }
|
||||
@@ -55,6 +73,13 @@ export function resolveNodeDistMirror(
|
||||
return null
|
||||
}
|
||||
|
||||
/** Whether a failed Node download went through a mirror; `unsupported`: this build has none. */
|
||||
export type NodeDistMirrorAttempt = 'unset' | 'tried' | 'unsupported'
|
||||
|
||||
export function isNodeDistMirrorAttempt(value: unknown): value is NodeDistMirrorAttempt {
|
||||
return value === 'unset' || value === 'tried' || value === 'unsupported'
|
||||
}
|
||||
|
||||
export function nodeDistMirrorUrl(mirror: string, version: string, file: string): string {
|
||||
return `${mirror}/v${version}/${file}`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** The managed-server status a retained SSH state carries, admitted field by field. */
|
||||
import type {
|
||||
SshManagedServerLinkedProject,
|
||||
SshManagedServerNodeDownloadBlocked,
|
||||
SshManagedServerStatus,
|
||||
SshManagedServerUpdateNote
|
||||
} from './ssh-types'
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES
|
||||
} from './ssh-retained-payload-limits'
|
||||
import { clampUtf8TextPrefix } from './utf8-byte-limits'
|
||||
import { isNodeDistMirrorAttempt } from './node-dist-mirror'
|
||||
|
||||
// Why field-by-field like plainSsh: an unknown kind from a newer host drops alone.
|
||||
export function admitSshManagedServerStatus(value: unknown): {
|
||||
@@ -84,11 +86,26 @@ export function admitSshManagedServerStatus(value: unknown): {
|
||||
: {}),
|
||||
...('held' in value && value.held === true ? { held: true } : {}),
|
||||
...('contactLost' in value && value.contactLost === true ? { contactLost: true } : {}),
|
||||
...admitNodeDownloadBlocked(
|
||||
'nodeDownloadBlocked' in value ? value.nodeDownloadBlocked : null
|
||||
),
|
||||
...('bridged' in value && value.bridged === true ? { bridged: true } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why alone: an unknown mirror state from a newer build drops to the plain try-again line.
|
||||
function admitNodeDownloadBlocked(value: unknown): {
|
||||
nodeDownloadBlocked?: SshManagedServerNodeDownloadBlocked
|
||||
} {
|
||||
if (!value || typeof value !== 'object' || !('mirror' in value)) {
|
||||
return {}
|
||||
}
|
||||
return isNodeDistMirrorAttempt(value.mirror)
|
||||
? { nodeDownloadBlocked: { mirror: value.mirror } }
|
||||
: {}
|
||||
}
|
||||
|
||||
const SSH_MANAGED_SERVER_LINKED_MAX_ENTRIES = 64
|
||||
|
||||
function admitSshManagedServerLinkedProjects(value: unknown): {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SshPendingPtyKill } from './ssh-pending-pty-kill'
|
||||
import type { OrcadSshProvisioningIntent } from './orcad-ssh-provisioning'
|
||||
import type { NodeDistMirrorAttempt } from './node-dist-mirror'
|
||||
|
||||
// ─── SSH Connection Types ───────────────────────────────────────────
|
||||
|
||||
@@ -354,10 +355,15 @@ export type SshManagedServerStatus =
|
||||
held?: true
|
||||
/** Setup lost contact with the host, so nothing proves it failed there. */
|
||||
contactLost?: true
|
||||
/** Neither this computer nor the host could download the pinned Node runtime. */
|
||||
nodeDownloadBlocked?: SshManagedServerNodeDownloadBlocked
|
||||
/** COMPAT(legacy-relay-bridge): the connect stayed up so the bridge serves those terminals. */
|
||||
bridged?: true
|
||||
}
|
||||
|
||||
/** `mirror`: whether that download went through a mirror; admission drops an unknown value. */
|
||||
export type SshManagedServerNodeDownloadBlocked = { mirror: NodeDistMirrorAttempt }
|
||||
|
||||
/** Plain SSH terminals and SFTP browsing only; `reason` is the ladder's classified cause. */
|
||||
export type SshPlainSshMode = {
|
||||
reason: string
|
||||
|
||||
Reference in New Issue
Block a user