feat(desktop): gate the Hermes Cloud gateway selector behind a BETA env flag

The Hermes Cloud ModeCard in Settings → Gateway now only appears when the BETA
env var is truthy (1/true/yes/on, case-insensitive); absent/empty/false/0 hides
it. While the feature is in beta, non-beta users see only Local + Remote.

- main.cjs: betaFeaturesEnabled() reads process.env.BETA; exposed via new IPC
  hermes☁️beta-enabled (the sandboxed renderer can't read process.env, and
  runtime IPC means the same build honors BETA per-launch with no rebuild).
- preload.cjs / global.d.ts: cloud.betaEnabled() bridge + type.
- gateway-settings.tsx: fetch the flag on mount (default false so it never
  flashes in for non-beta users), conditionally render the Cloud ModeCard, and
  flip the grid sm:grid-cols-3 → sm:grid-cols-2 when hidden.

Gates the SELECTOR only — an already-saved cloud connection keeps working if
BETA is later turned off; only newly selecting cloud is hidden.

tsc + eslint clean; 57 node --test + 16 vitest pass; env parsing unit-checked
across 9 cases; gate verified in the packaged bundle.

cloud-auto-discovery beta gating.
This commit is contained in:
Ben 2026-07-01 13:07:46 +10:00
parent 7e1e9d62c4
commit 34d7d3fe2e
4 changed files with 52 additions and 9 deletions

View file

@ -6619,6 +6619,17 @@ ipcMain.handle('hermes:cloud:status', async () => ({
portalBaseUrl: resolvePortalBaseUrl(),
signedIn: await hasLivePortalSession()
}))
// Whether the Hermes Cloud gateway selector is enabled — gated behind a BETA
// env flag while the feature is in beta. Truthy values: '1', 'true', 'yes',
// 'on' (case-insensitive). Absent, empty, 'false', '0', etc. → disabled, so the
// renderer hides the Cloud ModeCard entirely.
function betaFeaturesEnabled() {
const raw = String(process.env.BETA ?? '').trim().toLowerCase()
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'
}
ipcMain.handle('hermes:cloud:beta-enabled', async () => betaFeaturesEnabled())
ipcMain.handle('hermes:cloud:login', async () => {
await openPortalLoginWindow()
return { ok: true, signedIn: await hasLivePortalSession() }

View file

@ -45,6 +45,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
// (cloud-auto-discovery Phase 3).
cloud: {
status: () => ipcRenderer.invoke('hermes:cloud:status'),
betaEnabled: () => ipcRenderer.invoke('hermes:cloud:beta-enabled'),
login: () => ipcRenderer.invoke('hermes:cloud:login'),
logout: () => ipcRenderer.invoke('hermes:cloud:logout'),
discover: org => ipcRenderer.invoke('hermes:cloud:discover', org),

View file

@ -129,6 +129,33 @@ export function GatewaySettings() {
// not yet chosen / single-org user).
const [cloudOrgs, setCloudOrgs] = useState<DesktopCloudOrg[]>([])
const [cloudOrg, setCloudOrg] = useState<null | string>(null)
// Hermes Cloud is beta-gated: the selector ModeCard only appears when the main
// process reports the BETA env flag is enabled. Default hidden until the async
// check resolves, so it never flashes in for non-beta users.
const [cloudBetaEnabled, setCloudBetaEnabled] = useState(false)
useEffect(() => {
const desktop = window.hermesDesktop
if (!desktop?.cloud?.betaEnabled) {
return
}
let cancelled = false
desktop.cloud
.betaEnabled()
.then(enabled => {
if (!cancelled) {
setCloudBetaEnabled(Boolean(enabled))
}
})
.catch(() => {
if (!cancelled) {
setCloudBetaEnabled(false)
}
})
return () => void (cancelled = true)
}, [])
// Connection scope: null = the global/default connection (the original
// behavior); a profile name = that profile's per-profile remote override, so
@ -722,7 +749,7 @@ export function GatewaySettings() {
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-3">
<div className={cn('grid gap-3', cloudBetaEnabled ? 'sm:grid-cols-3' : 'sm:grid-cols-2')}>
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
@ -731,14 +758,16 @@ export function GatewaySettings() {
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
title={g.localTitle}
/>
<ModeCard
active={state.mode === 'cloud'}
description={g.cloudDesc}
disabled={state.envOverride}
icon={Cloud}
onSelect={() => setState(current => ({ ...current, mode: 'cloud' }))}
title={g.cloudTitle}
/>
{cloudBetaEnabled ? (
<ModeCard
active={state.mode === 'cloud'}
description={g.cloudDesc}
disabled={state.envOverride}
icon={Cloud}
onSelect={() => setState(current => ({ ...current, mode: 'cloud' }))}
title={g.cloudTitle}
/>
) : null}
<ModeCard
active={state.mode === 'remote'}
description={g.remoteDesc}

View file

@ -59,6 +59,8 @@ declare global {
// sign-in (cloud-auto-discovery Phase 3).
cloud: {
status: () => Promise<DesktopCloudStatus>
// Whether the BETA env flag enables the Hermes Cloud gateway selector.
betaEnabled: () => Promise<boolean>
login: () => Promise<DesktopCloudStatus & { ok: boolean }>
logout: () => Promise<DesktopCloudStatus & { ok: boolean }>
discover: (org?: string) => Promise<DesktopCloudDiscoverResult>