fe576ba48e
Since 1e9a12a71 (v0.20.6), a remote/cloud/ssh registry PRIMARY makes
globalRemoteActive() true, so the ambient v1 route — and with it every
unpinned Capabilities read — resolves to the remote gateway. The only
road back to this machine is an explicit connectionId:'local' pin, but
capabilityScoped() deliberately DROPPED that pin (pre-#91564, absent id
always meant the local pool), and profileScopeKey() collapsed
'local::<profile>' to the bare profile key.
Consequences on any desktop whose registry primary is remote:
- Capabilities -> MCP showed the REMOTE host's mcp_servers under every
scope; locally configured (and connected) MCP servers vanished from
the UI entirely.
- Picking 'default - This device' in the scope selector was a silent
no-op: the collapsed cache key equaled the current scope key, so
changeScope() early-returned and the selector snapped back.
Fix: capabilityScoped() forwards EVERY non-empty connection id, 'local'
included — Electron's apiRequestRegistryConnectionId/ensureRegistryBackend
already own 'local' pins (forced-local pooled child) and this is the
documented contract there. profileScopeKey() namespaces every explicit
pin ('local::<profile>') so a This-device pick and the ambient path never
share a cache row. profiles.ts profileOwnerScoped, which hand-patched
this exact hole for profile mutations, reduces to a named alias.
Live A/B (Electron + CDP, remote registry primary, local-only servers in
local config): v0.20.6 = local servers invisible, local pick no-op;
fixed = 'This device' lists local-server-alpha/beta, remote scope
unchanged.
118 lines
4.1 KiB
TypeScript
118 lines
4.1 KiB
TypeScript
import type {
|
|
ProfileCreatePayload,
|
|
ProfileDesktopOverlay,
|
|
ProfileSetupCommand,
|
|
ProfileSoul,
|
|
ProfilesResponse
|
|
} from '@/types/hermes'
|
|
|
|
import { capabilityScoped, hermesApi, type ProfileScope, STARTUP_REQUEST_TIMEOUT_MS } from './client'
|
|
|
|
export function getProfiles(): Promise<ProfilesResponse> {
|
|
return hermesApi<ProfilesResponse>({
|
|
path: '/api/profiles',
|
|
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
|
})
|
|
}
|
|
|
|
export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> {
|
|
return hermesApi<{ name: string; ok: boolean; path: string }>({
|
|
path: '/api/profiles',
|
|
method: 'POST',
|
|
body
|
|
})
|
|
}
|
|
|
|
// Explicit (connection, profile) pin for a profile that lives on a gateway
|
|
// other than the foreground one — the fleet profile rail edits a remote
|
|
// square's SOUL/name in place. capabilityScoped now forwards a `'local'` pin
|
|
// itself (it must, or a remote registry PRIMARY absorbs "This device" reads),
|
|
// so this is a plain alias kept for the call sites' self-documenting name.
|
|
function profileOwnerScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } {
|
|
return capabilityScoped(scope)
|
|
}
|
|
|
|
export function renameProfile(
|
|
name: string,
|
|
newName: string,
|
|
scope?: ProfileScope
|
|
): Promise<{ name: string; ok: boolean; path: string }> {
|
|
return hermesApi<{ name: string; ok: boolean; path: string }>({
|
|
...profileOwnerScoped(scope),
|
|
path: `/api/profiles/${encodeURIComponent(name)}`,
|
|
method: 'PATCH',
|
|
body: { new_name: newName }
|
|
})
|
|
}
|
|
|
|
export function deleteProfile(name: string, scope?: ProfileScope): Promise<{ ok: boolean; path: string }> {
|
|
const normalized = name.trim()
|
|
const scopedProfile = scope && typeof scope === 'object' ? scope.profile?.trim() : undefined
|
|
|
|
if (!normalized) {
|
|
return Promise.reject(new Error('Profile name required'))
|
|
}
|
|
|
|
if (normalized.toLowerCase() === 'default' || scopedProfile?.toLowerCase() === 'default') {
|
|
return Promise.reject(new Error('The default profile cannot be deleted.'))
|
|
}
|
|
|
|
return hermesApi<{ ok: boolean; path: string }>({
|
|
...profileOwnerScoped(scope),
|
|
path: `/api/profiles/${encodeURIComponent(normalized)}`,
|
|
method: 'DELETE'
|
|
})
|
|
}
|
|
|
|
export function getProfileSoul(name: string, scope?: ProfileScope): Promise<ProfileSoul> {
|
|
return hermesApi<ProfileSoul>({
|
|
...profileOwnerScoped(scope),
|
|
path: `/api/profiles/${encodeURIComponent(name)}/soul`
|
|
})
|
|
}
|
|
|
|
export function updateProfileSoul(name: string, content: string, scope?: ProfileScope): Promise<{ ok: boolean }> {
|
|
return hermesApi<{ ok: boolean }>({
|
|
...profileOwnerScoped(scope),
|
|
path: `/api/profiles/${encodeURIComponent(name)}/soul`,
|
|
method: 'PUT',
|
|
body: { content }
|
|
})
|
|
}
|
|
|
|
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
|
|
return hermesApi<ProfileSetupCommand>({
|
|
path: `/api/profiles/${encodeURIComponent(name)}/setup-command`
|
|
})
|
|
}
|
|
|
|
/** Export a profile to a shareable .tar.gz on the backend's filesystem.
|
|
* `extraFiles` stages extra root-level files (desktop.json — the appearance/
|
|
* interface overlay) into the archive alongside the profile's own artifacts. */
|
|
export function exportProfileArchive(
|
|
name: string,
|
|
opts: { extraFiles?: Record<string, string>; output?: string } = {}
|
|
): Promise<{ archive: string; ok: boolean }> {
|
|
return hermesApi<{ archive: string; ok: boolean }>({
|
|
path: `/api/profiles/${encodeURIComponent(name)}/export`,
|
|
method: 'POST',
|
|
body: { extra_files: opts.extraFiles ?? {}, output: opts.output ?? '' },
|
|
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
|
})
|
|
}
|
|
|
|
/** Import a profile .tar.gz as a new profile. Returns the bundled desktop
|
|
* appearance overlay too (when the archive carried one) so the caller can
|
|
* apply theme/layout without another round-trip. */
|
|
export function importProfileArchive(
|
|
archive: string,
|
|
name?: string
|
|
): Promise<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }> {
|
|
return hermesApi<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }>({
|
|
path: '/api/profiles/import',
|
|
method: 'POST',
|
|
body: { archive, name: name || null },
|
|
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
|
})
|
|
}
|