diff --git a/.gitea/workflows/deploy-custom.yml b/.gitea/workflows/deploy-custom.yml deleted file mode 100644 index 75c7b410..00000000 --- a/.gitea/workflows/deploy-custom.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: deploy-custom -on: - push: - branches: [custom] -jobs: - deploy: - runs-on: linux-amd64 - steps: - - name: Checkout - run: | - set -euo pipefail - rm -rf $GITHUB_WORKSPACE/repo - mkdir -p $GITHUB_WORKSPACE/repo - git clone --depth 1 --branch custom gitea@giteassh.buzzbee.dev:BuzzbeeSCD/openchamber.git $GITHUB_WORKSPACE/repo - - name: Show revision - run: git -C $GITHUB_WORKSPACE/repo rev-parse --short HEAD - - name: Stage new build (atomic — live dir untouched) - run: | - set -euo pipefail - SHA=$(git -C $GITHUB_WORKSPACE/repo rev-parse --short HEAD) - STAMP=$(date +%Y%m%d-%H%M%S) - NEWDIR=/opt/app/deploy/builds/${SHA}-${STAMP} - mkdir -p "$NEWDIR" - touch "$NEWDIR/.stage-start" - tar -C $GITHUB_WORKSPACE/repo --exclude=.git -cf - . | tar -C "$NEWDIR" -xf - - echo "$NEWDIR" > $GITHUB_WORKSPACE/newdir - echo "staged $NEWDIR" - - name: Install dependencies - run: | - set -euo pipefail - NEWDIR=$(cat $GITHUB_WORKSPACE/newdir) - cd "$NEWDIR" && bun install --frozen-lockfile - - name: Build web - run: | - set -euo pipefail - NEWDIR=$(cat $GITHUB_WORKSPACE/newdir) - cd "$NEWDIR" && bun run build:web - - name: Verify dist (fresh, non-empty — stale/partial fails here) - run: | - set -euo pipefail - NEWDIR=$(cat $GITHUB_WORKSPACE/newdir) - DIST="$NEWDIR/packages/web/dist/index.html" - test -f "$DIST" - test -s "$DIST" - test "$DIST" -nt "$NEWDIR/.stage-start" - rm -f "$NEWDIR/.stage-start" - echo "dist verified: $(stat -c '%s bytes, %y' "$DIST")" - - name: Swap current symlink (single atomic op) - run: | - set -euo pipefail - NEWDIR=$(cat $GITHUB_WORKSPACE/newdir) - readlink /opt/app/deploy/current > $GITHUB_WORKSPACE/prevdir - ln -sfn "$NEWDIR" /opt/app/deploy/current - echo "swapped: $(cat $GITHUB_WORKSPACE/prevdir) -> $NEWDIR" - - name: Restart service - run: sudo systemctl restart openchamber-custom - - name: Health-check (auto-rollback on failure) - run: | - set -euo pipefail - NEWDIR=$(cat $GITHUB_WORKSPACE/newdir) - PREVDIR=$(cat $GITHUB_WORKSPACE/prevdir) - SHA=$(git -C $GITHUB_WORKSPACE/repo rev-parse HEAD) - ok=0 - for i in $(seq 1 24); do - CODE=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/ || true) - if [ "$CODE" = "200" ]; then ok=1; break; fi - sleep 5 - done - if [ "$ok" = "1" ]; then - echo "healthy on $NEWDIR — pruning old builds" - ls -dt /opt/app/deploy/builds/*/ 2>/dev/null | tail -n +3 | xargs -r rm -rf - exit 0 - fi - echo "HEALTH CHECK FAILED — rolling back to $PREVDIR" - ln -sfn "$PREVDIR" /opt/app/deploy/current - sudo systemctl restart openchamber-custom - sleep 15 - RCODE=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/ || true) - MSG="OpenChamber deploy FAILED (health $RCODE after rollback to $PREVDIR)" - if [ "$RCODE" = "200" ]; then MSG="$MSG — rollback healthy"; else MSG="$MSG — ROLLBACK ALSO UNHEALTHY, manual intervention needed"; fi - /opt/app/.local/bin/openchamber-notify-deploy.sh "$SHA" "$MSG" "deploy-bot" - exit 1 - - - name: Notify on successful deploy - run: | - SHA=$(git -C $GITHUB_WORKSPACE/repo rev-parse HEAD) - MSG=$(git -C $GITHUB_WORKSPACE/repo log -1 --format=%s) - AUTHOR=$(git -C $GITHUB_WORKSPACE/repo log -1 --format=%an) - /opt/app/.local/bin/openchamber-notify-deploy.sh "$SHA" "$MSG" "$AUTHOR" - - - name: Notify on failed deploy - if: failure() - run: | - SHA=$(git -C $GITHUB_WORKSPACE/repo rev-parse HEAD || echo unknown) - /opt/app/.local/bin/openchamber-notify-deploy.sh "$SHA" "OpenChamber deploy FAILED before health-check (see Actions log)" "deploy-bot" diff --git a/packages/ui/src/contexts/RuntimeAPIProvider.tsx b/packages/ui/src/contexts/RuntimeAPIProvider.tsx index 46ec8376..80d1635a 100644 --- a/packages/ui/src/contexts/RuntimeAPIProvider.tsx +++ b/packages/ui/src/contexts/RuntimeAPIProvider.tsx @@ -2,10 +2,38 @@ import React, { type JSX, type ReactNode } from 'react'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import type { FilesAPI, RuntimeAPIs } from '@/lib/api/types'; import { createContentCachedFiles } from '@/contexts/content-cache-owner'; +import { + resolveActiveRuntimeAPIs, + subscribeRuntimeProviderChanged, +} from '@/lib/runtime-api-registry'; type ContentCachedFiles = ReturnType; -export function RuntimeAPIProvider({ apis, children }: { apis: RuntimeAPIs; children: ReactNode }): JSX.Element { +interface RuntimeAPIProviderProps { + /** Runtime APIs to provide. Ignored when a registry provider is active. */ + apis: RuntimeAPIs; + children: ReactNode; +} + +/** + * Provides RuntimeAPIs to the React tree. When a provider is registered in + * the runtime API registry, the component automatically re-resolves the APIs + * on provider switches. Otherwise it uses the `apis` prop directly (legacy + * behaviour, fully backward compatible). + */ +export function RuntimeAPIProvider({ apis: fallbackApis, children }: RuntimeAPIProviderProps): JSX.Element { + const [registryApis, setRegistryApis] = React.useState(() => + resolveActiveRuntimeAPIs(), + ); + + React.useEffect(() => { + return subscribeRuntimeProviderChanged(() => { + setRegistryApis(resolveActiveRuntimeAPIs()); + }); + }, []); + + const apis = registryApis ?? fallbackApis; + // Effect-owned lifecycle: React Strict Mode dispose+remount must create a fresh // owner. useMemo + dispose reused a dead owner and broke text-file opens // (binaries skipped the pre-read, so they still appeared to work). diff --git a/packages/ui/src/lib/runtime-api-registry.test.ts b/packages/ui/src/lib/runtime-api-registry.test.ts new file mode 100644 index 00000000..dc71409d --- /dev/null +++ b/packages/ui/src/lib/runtime-api-registry.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; +import type { RuntimeAPIs } from '@/lib/api/types'; +import { + registerRuntimeProvider, + unregisterRuntimeProvider, + getRuntimeProvider, + getActiveRuntimeProviderName, + listRuntimeProviders, + setActiveRuntimeProvider, + resolveActiveRuntimeAPIs, + subscribeRuntimeProviderChanged, +} from './runtime-api-registry'; + +const stubApis = (platform: string): RuntimeAPIs => ({ + runtime: { platform: platform as 'web' | 'desktop' | 'vscode', isDesktop: false, isVSCode: false, label: platform }, + terminal: {} as RuntimeAPIs['terminal'], + git: {} as RuntimeAPIs['git'], + files: {} as RuntimeAPIs['files'], + settings: {} as RuntimeAPIs['settings'], + permissions: {} as RuntimeAPIs['permissions'], + notifications: {} as RuntimeAPIs['notifications'], + tools: {} as RuntimeAPIs['tools'], +}); + +// Ensure window is available for DOM event dispatch in bun test environment +const ensureWindow = () => { + if (typeof globalThis.window === 'undefined') { + (globalThis as Record).window = globalThis; + } + if (typeof globalThis.CustomEvent === 'undefined') { + (globalThis as Record).CustomEvent = class CustomEvent extends Event { + detail: T; + constructor(type: string, options?: CustomEventInit) { + super(type, options); + this.detail = options?.detail as T; + } + }; + } +}; + +describe('runtime-api-registry', () => { + beforeEach(() => { + ensureWindow(); + // Clean up all registered providers between tests + for (const name of listRuntimeProviders()) { + unregisterRuntimeProvider(name); + } + }); + + test('registerRuntimeProvider makes a provider retrievable', () => { + const factory = () => stubApis('web'); + registerRuntimeProvider('web', factory); + expect(getRuntimeProvider('web')).toBe(factory); + }); + + test('listRuntimeProviders returns all registered names', () => { + registerRuntimeProvider('a', () => stubApis('a')); + registerRuntimeProvider('b', () => stubApis('b')); + expect(listRuntimeProviders()).toEqual(expect.arrayContaining(['a', 'b'])); + }); + + test('unregisterRuntimeProvider removes a provider', () => { + registerRuntimeProvider('temp', () => stubApis('temp')); + unregisterRuntimeProvider('temp'); + expect(getRuntimeProvider('temp')).toBeUndefined(); + }); + + test('unregisterRuntimeProvider clears active when removing the active provider', () => { + registerRuntimeProvider('temp', () => stubApis('temp')); + setActiveRuntimeProvider('temp'); + expect(getActiveRuntimeProviderName()).toBe('temp'); + unregisterRuntimeProvider('temp'); + expect(getActiveRuntimeProviderName()).toBeNull(); + }); + + test('setActiveRuntimeProvider switches the active provider', () => { + registerRuntimeProvider('web', () => stubApis('web')); + registerRuntimeProvider('vscode', () => stubApis('vscode')); + setActiveRuntimeProvider('web'); + expect(getActiveRuntimeProviderName()).toBe('web'); + setActiveRuntimeProvider('vscode'); + expect(getActiveRuntimeProviderName()).toBe('vscode'); + }); + + test('setActiveRuntimeProvider throws for unknown provider', () => { + expect(() => setActiveRuntimeProvider('nonexistent')).toThrow('not registered'); + }); + + test('setActiveRuntimeProvider is idempotent for the same name', () => { + registerRuntimeProvider('web', () => stubApis('web')); + setActiveRuntimeProvider('web'); + // Second call with same name should not throw or change anything + setActiveRuntimeProvider('web'); + expect(getActiveRuntimeProviderName()).toBe('web'); + }); + + test('resolveActiveRuntimeAPIs returns the APIs from the active provider', () => { + registerRuntimeProvider('web', () => stubApis('web')); + setActiveRuntimeProvider('web'); + const apis = resolveActiveRuntimeAPIs(); + expect(apis?.runtime.label).toBe('web'); + }); + + test('resolveActiveRuntimeAPIs returns null when no provider is active', () => { + expect(resolveActiveRuntimeAPIs()).toBeNull(); + }); + + test('getRuntimeProvider without args returns the active provider', () => { + registerRuntimeProvider('web', () => stubApis('web')); + setActiveRuntimeProvider('web'); + expect(getRuntimeProvider()).toBeDefined(); + }); + + test('subscribeRuntimeProviderChanged fires on provider switch', () => { + registerRuntimeProvider('a', () => stubApis('a')); + registerRuntimeProvider('b', () => stubApis('b')); + + const callback = vi.fn(); + const unsub = subscribeRuntimeProviderChanged(callback); + + setActiveRuntimeProvider('a'); + expect(callback).toHaveBeenCalledWith({ previous: null, current: 'a' }); + + setActiveRuntimeProvider('b'); + expect(callback).toHaveBeenCalledWith({ previous: 'a', current: 'b' }); + + unsub(); + }); + + test('subscribeRuntimeProviderChanged unsubscribes correctly', () => { + registerRuntimeProvider('a', () => stubApis('a')); + registerRuntimeProvider('b', () => stubApis('b')); + + const callback = vi.fn(); + const unsub = subscribeRuntimeProviderChanged(callback); + + setActiveRuntimeProvider('a'); + expect(callback).toHaveBeenCalledTimes(1); + + unsub(); + setActiveRuntimeProvider('b'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('registerRuntimeProvider overwrites a previous factory with the same name', () => { + const factory1 = () => stubApis('web'); + const factory2 = () => stubApis('desktop'); + registerRuntimeProvider('web', factory1); + registerRuntimeProvider('web', factory2); + expect(getRuntimeProvider('web')).toBe(factory2); + }); +}); diff --git a/packages/ui/src/lib/runtime-api-registry.ts b/packages/ui/src/lib/runtime-api-registry.ts new file mode 100644 index 00000000..fbe3f7ec --- /dev/null +++ b/packages/ui/src/lib/runtime-api-registry.ts @@ -0,0 +1,108 @@ +import type { RuntimeAPIs } from '@/lib/api/types'; + +/** + * A factory that creates RuntimeAPIs for a named runtime. + * + * The factory is called each time the provider becomes active (or the + * endpoint changes while it is active), so it must be cheap and side-effect + * free beyond returning the APIs object. + */ +export type RuntimeAPIProviderFactory = () => RuntimeAPIs; + +export type RuntimeAPIProviderChangedDetail = { + previous: string | null; + current: string | null; +}; + +const RUNTIME_API_PROVIDER_CHANGED_EVENT = 'openchamber:runtime-api-provider-changed'; + +// ---- Provider registry -------------------------------------------------- + +const providers = new Map(); +let activeProviderName: string | null = null; + +/** + * Register a named provider factory. Overwrites any previous factory with + * the same name. Does NOT activate the provider — call + * `setActiveRuntimeProvider` separately. + */ +export const registerRuntimeProvider = (name: string, factory: RuntimeAPIProviderFactory): void => { + providers.set(name, factory); +}; + +/** + * Remove a provider from the registry. If it was the active provider, the + * active slot is cleared. + */ +export const unregisterRuntimeProvider = (name: string): void => { + providers.delete(name); + if (activeProviderName === name) { + activeProviderName = null; + } +}; + +/** + * Look up a registered provider by name, or the currently active provider + * when `name` is omitted. + */ +export const getRuntimeProvider = (name?: string): RuntimeAPIProviderFactory | undefined => { + const key = name ?? activeProviderName; + return key != null ? providers.get(key) : undefined; +}; + +/** + * Return the name of the currently active provider, or null when none is + * active. + */ +export const getActiveRuntimeProviderName = (): string | null => activeProviderName; + +/** + * Return the names of all registered providers. + */ +export const listRuntimeProviders = (): string[] => [...providers.keys()]; + +/** + * Switch the active provider. Throws when the name does not match a + * registered provider. Emits a DOM custom event so React subscribers + * (RuntimeAPIProvider) can re-resolve the APIs. + */ +export const setActiveRuntimeProvider = (name: string): void => { + if (!providers.has(name)) { + throw new Error(`Runtime provider "${name}" is not registered`); + } + const previous = activeProviderName; + if (previous === name) return; + activeProviderName = name; + emitProviderChanged({ previous, current: name }); +}; + +// ---- Change subscription ------------------------------------------------ + +export const subscribeRuntimeProviderChanged = ( + callback: (detail: RuntimeAPIProviderChangedDetail) => void, +): (() => void) => { + if (typeof window === 'undefined') return () => {}; + const listener = (event: Event) => { + callback((event as CustomEvent).detail); + }; + window.addEventListener(RUNTIME_API_PROVIDER_CHANGED_EVENT, listener); + return () => window.removeEventListener(RUNTIME_API_PROVIDER_CHANGED_EVENT, listener); +}; + +function emitProviderChanged(detail: RuntimeAPIProviderChangedDetail): void { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent(RUNTIME_API_PROVIDER_CHANGED_EVENT, { detail }), + ); +} + +// ---- High-level helpers ------------------------------------------------- + +/** + * Resolve the RuntimeAPIs for the currently active provider. Returns null + * when no provider is registered or active. + */ +export const resolveActiveRuntimeAPIs = (): RuntimeAPIs | null => { + const factory = getRuntimeProvider(); + return factory ? factory() : null; +}; diff --git a/packages/vscode/webview/api/index.ts b/packages/vscode/webview/api/index.ts index 98cafd35..4c8794ee 100644 --- a/packages/vscode/webview/api/index.ts +++ b/packages/vscode/webview/api/index.ts @@ -1,4 +1,5 @@ import type { RuntimeAPIs, TerminalAPI } from '@openchamber/ui/lib/api/types'; +import { registerRuntimeProvider } from '@openchamber/ui/lib/runtime-api-registry'; import { createVSCodeFilesAPI } from './files'; import { createVSCodeSettingsAPI } from './settings'; import { createVSCodePermissionsAPI } from './permissions'; @@ -38,3 +39,10 @@ export const createVSCodeAPIs = (): RuntimeAPIs => ({ editor: createVSCodeEditorAPI(), vscode: createVSCodeActionsAPI(), }); + +/** + * Register the VS Code provider with the runtime API registry. + */ +export const registerVSCodeProvider = (): void => { + registerRuntimeProvider('vscode', () => createVSCodeAPIs()); +}; diff --git a/packages/web/src/api/index.ts b/packages/web/src/api/index.ts index c438e259..e433a38b 100644 --- a/packages/web/src/api/index.ts +++ b/packages/web/src/api/index.ts @@ -5,6 +5,7 @@ import { setRuntimeUrlResolver, type RuntimeUrlResolver, } from '@openchamber/ui/lib/runtime-url'; +import { registerRuntimeProvider } from '@openchamber/ui/lib/runtime-api-registry'; import { useDirectoryStore } from '@openchamber/ui/stores/useDirectoryStore'; import { createWebTerminalAPI } from './terminal'; import { createWebGitAPI } from './git'; @@ -40,19 +41,27 @@ export const createWebAPIs = (options: WebAPIsOptions = {}): RuntimeAPIs => { const activeUrls = createActiveRuntimeUrlResolver(); return { - runtime: { platform: 'web', isDesktop: false, isVSCode: false, label: 'web' }, - terminal: createWebTerminalAPI(), - git: createWebGitAPI(), - files: createWebFilesAPI({ urls: activeUrls, getDirectory: () => useDirectoryStore.getState().currentDirectory }), - settings: createWebSettingsAPI(), - permissions: createWebPermissionsAPI(), - notifications: createWebNotificationsAPI(), - github: createWebGitHubAPI({ urls: activeUrls }), - linear: createWebLinearAPI(), - gitlab: createWebGitLabAPI({ urls: activeUrls }), - gitea: createWebGiteaAPI({ urls: activeUrls }), - push: createWebPushAPI(), - clientAuth: createWebClientAuthAPI(), - tools: createWebToolsAPI(), + runtime: { platform: 'web', isDesktop: false, isVSCode: false, label: 'web' }, + terminal: createWebTerminalAPI(), + git: createWebGitAPI(), + files: createWebFilesAPI({ urls: activeUrls, getDirectory: () => useDirectoryStore.getState().currentDirectory }), + settings: createWebSettingsAPI(), + permissions: createWebPermissionsAPI(), + notifications: createWebNotificationsAPI(), + github: createWebGitHubAPI({ urls: activeUrls }), + linear: createWebLinearAPI(), + gitlab: createWebGitLabAPI({ urls: activeUrls }), + gitea: createWebGiteaAPI({ urls: activeUrls }), + push: createWebPushAPI(), + clientAuth: createWebClientAuthAPI(), + tools: createWebToolsAPI(), }; }; + +/** + * Register the web provider with the runtime API registry. Call once during + * app bootstrap (after the URL resolver and auth are configured). + */ +export const registerWebProvider = (): void => { + registerRuntimeProvider('web', () => createWebAPIs()); +};