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:
Neil
2026-10-20 01:55:13 -07:00
committed by GitHub
parent c8962bd529
commit e0cf91c62a
33 changed files with 1242 additions and 77 deletions
@@ -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 } : {})
}
}
+17 -1
View File
@@ -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.
+16 -2
View File
@@ -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
+23 -1
View File
@@ -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": {
+23 -1
View File
@@ -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": {
+23 -1
View File
@@ -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": {
+23 -1
View File
@@ -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": {
+23 -1
View File
@@ -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": {
+23 -1
View File
@@ -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
+8 -6
View File
@@ -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
})
})
+32 -7
View File
@@ -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): {
+6
View File
@@ -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