@agimon-ai/doompi-web 0.0.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +29 -0
- package/README.md +196 -0
- package/dist/_virtual/_rolldown/runtime.cjs +1 -0
- package/dist/bin/serve.cjs +3 -0
- package/dist/bin/serve.cjs.map +1 -0
- package/dist/bin/serve.d.cts +1 -0
- package/dist/bin/serve.d.mts +1 -0
- package/dist/bin/serve.mjs +3 -0
- package/dist/bin/serve.mjs.map +1 -0
- package/dist/bundler.cjs +1 -0
- package/dist/bundler.d.cts +2 -0
- package/dist/bundler.d.mts +2 -0
- package/dist/bundler.mjs +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +7 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.mjs +1 -0
- package/dist/src/adapters/authRoutes.cjs +2 -0
- package/dist/src/adapters/authRoutes.cjs.map +1 -0
- package/dist/src/adapters/authRoutes.mjs +2 -0
- package/dist/src/adapters/authRoutes.mjs.map +1 -0
- package/dist/src/adapters/bundledServer.cjs +2 -0
- package/dist/src/adapters/bundledServer.cjs.map +1 -0
- package/dist/src/adapters/bundledServer.mjs +2 -0
- package/dist/src/adapters/bundledServer.mjs.map +1 -0
- package/dist/src/adapters/directoryListing.cjs +2 -0
- package/dist/src/adapters/directoryListing.cjs.map +1 -0
- package/dist/src/adapters/directoryListing.mjs +2 -0
- package/dist/src/adapters/directoryListing.mjs.map +1 -0
- package/dist/src/adapters/directorySearch.cjs +3 -0
- package/dist/src/adapters/directorySearch.cjs.map +1 -0
- package/dist/src/adapters/directorySearch.mjs +3 -0
- package/dist/src/adapters/directorySearch.mjs.map +1 -0
- package/dist/src/adapters/gitStatus.cjs +2 -0
- package/dist/src/adapters/gitStatus.cjs.map +1 -0
- package/dist/src/adapters/gitStatus.mjs +2 -0
- package/dist/src/adapters/gitStatus.mjs.map +1 -0
- package/dist/src/adapters/httpServer.cjs +2 -0
- package/dist/src/adapters/httpServer.cjs.map +1 -0
- package/dist/src/adapters/httpServer.d.cts +14 -0
- package/dist/src/adapters/httpServer.d.cts.map +1 -0
- package/dist/src/adapters/httpServer.d.mts +14 -0
- package/dist/src/adapters/httpServer.d.mts.map +1 -0
- package/dist/src/adapters/httpServer.mjs +2 -0
- package/dist/src/adapters/httpServer.mjs.map +1 -0
- package/dist/src/adapters/hubProbe.cjs +2 -0
- package/dist/src/adapters/hubProbe.cjs.map +1 -0
- package/dist/src/adapters/hubProbe.mjs +2 -0
- package/dist/src/adapters/hubProbe.mjs.map +1 -0
- package/dist/src/adapters/packageApiProxy.cjs +2 -0
- package/dist/src/adapters/packageApiProxy.cjs.map +1 -0
- package/dist/src/adapters/packageApiProxy.mjs +2 -0
- package/dist/src/adapters/packageApiProxy.mjs.map +1 -0
- package/dist/src/adapters/piHubService.cjs +2 -0
- package/dist/src/adapters/piHubService.cjs.map +1 -0
- package/dist/src/adapters/piHubService.mjs +2 -0
- package/dist/src/adapters/piHubService.mjs.map +1 -0
- package/dist/src/adapters/piWebSocketListener.cjs +2 -0
- package/dist/src/adapters/piWebSocketListener.cjs.map +1 -0
- package/dist/src/adapters/piWebSocketListener.mjs +2 -0
- package/dist/src/adapters/piWebSocketListener.mjs.map +1 -0
- package/dist/src/adapters/providerAuth.cjs +2 -0
- package/dist/src/adapters/providerAuth.cjs.map +1 -0
- package/dist/src/adapters/providerAuth.mjs +2 -0
- package/dist/src/adapters/providerAuth.mjs.map +1 -0
- package/dist/src/adapters/registryWatcher.cjs +2 -0
- package/dist/src/adapters/registryWatcher.cjs.map +1 -0
- package/dist/src/adapters/registryWatcher.mjs +2 -0
- package/dist/src/adapters/registryWatcher.mjs.map +1 -0
- package/dist/src/adapters/serverSpawner.cjs +2 -0
- package/dist/src/adapters/serverSpawner.cjs.map +1 -0
- package/dist/src/adapters/serverSpawner.mjs +2 -0
- package/dist/src/adapters/serverSpawner.mjs.map +1 -0
- package/dist/src/adapters/sessionFiles.cjs +3 -0
- package/dist/src/adapters/sessionFiles.cjs.map +1 -0
- package/dist/src/adapters/sessionFiles.mjs +3 -0
- package/dist/src/adapters/sessionFiles.mjs.map +1 -0
- package/dist/src/adapters/sessionHub.cjs +2 -0
- package/dist/src/adapters/sessionHub.cjs.map +1 -0
- package/dist/src/adapters/sessionHub.mjs +2 -0
- package/dist/src/adapters/sessionHub.mjs.map +1 -0
- package/dist/src/adapters/sessionSocketClient.cjs +2 -0
- package/dist/src/adapters/sessionSocketClient.cjs.map +1 -0
- package/dist/src/adapters/sessionSocketClient.mjs +2 -0
- package/dist/src/adapters/sessionSocketClient.mjs.map +1 -0
- package/dist/src/adapters/syncGuard.cjs +2 -0
- package/dist/src/adapters/syncGuard.cjs.map +1 -0
- package/dist/src/adapters/syncGuard.mjs +2 -0
- package/dist/src/adapters/syncGuard.mjs.map +1 -0
- package/dist/src/adapters/threadJournals.cjs +2 -0
- package/dist/src/adapters/threadJournals.cjs.map +1 -0
- package/dist/src/adapters/threadJournals.mjs +2 -0
- package/dist/src/adapters/threadJournals.mjs.map +1 -0
- package/dist/src/adapters/webBundler.cjs +2 -0
- package/dist/src/adapters/webBundler.cjs.map +1 -0
- package/dist/src/adapters/webBundler.d.cts +28 -0
- package/dist/src/adapters/webBundler.d.cts.map +1 -0
- package/dist/src/adapters/webBundler.d.mts +28 -0
- package/dist/src/adapters/webBundler.d.mts.map +1 -0
- package/dist/src/adapters/webBundler.mjs +2 -0
- package/dist/src/adapters/webBundler.mjs.map +1 -0
- package/dist/src/adapters/webHubPluginLoader.cjs +2 -0
- package/dist/src/adapters/webHubPluginLoader.cjs.map +1 -0
- package/dist/src/adapters/webHubPluginLoader.mjs +2 -0
- package/dist/src/adapters/webHubPluginLoader.mjs.map +1 -0
- package/dist/src/adapters/webHubPlugins.generated.cjs +2 -0
- package/dist/src/adapters/webHubPlugins.generated.cjs.map +1 -0
- package/dist/src/adapters/webHubPlugins.generated.mjs +2 -0
- package/dist/src/adapters/webHubPlugins.generated.mjs.map +1 -0
- package/dist/src/adapters/webPluginGenerate.cjs +4 -0
- package/dist/src/adapters/webPluginGenerate.cjs.map +1 -0
- package/dist/src/adapters/webPluginGenerate.mjs +4 -0
- package/dist/src/adapters/webPluginGenerate.mjs.map +1 -0
- package/dist/src/adapters/webPluginScan.cjs +2 -0
- package/dist/src/adapters/webPluginScan.cjs.map +1 -0
- package/dist/src/adapters/webPluginScan.mjs +2 -0
- package/dist/src/adapters/webPluginScan.mjs.map +1 -0
- package/dist/src/adapters/webPluginVite.cjs +2 -0
- package/dist/src/adapters/webPluginVite.cjs.map +1 -0
- package/dist/src/adapters/webPluginVite.mjs +2 -0
- package/dist/src/adapters/webPluginVite.mjs.map +1 -0
- package/dist/src/services/directoryMatch.cjs +2 -0
- package/dist/src/services/directoryMatch.cjs.map +1 -0
- package/dist/src/services/directoryMatch.mjs +2 -0
- package/dist/src/services/directoryMatch.mjs.map +1 -0
- package/dist/src/services/fileMatch.cjs +2 -0
- package/dist/src/services/fileMatch.cjs.map +1 -0
- package/dist/src/services/fileMatch.mjs +2 -0
- package/dist/src/services/fileMatch.mjs.map +1 -0
- package/dist/src/services/fileMedia.cjs +2 -0
- package/dist/src/services/fileMedia.cjs.map +1 -0
- package/dist/src/services/fileMedia.mjs +2 -0
- package/dist/src/services/fileMedia.mjs.map +1 -0
- package/dist/src/services/frameRing.cjs +2 -0
- package/dist/src/services/frameRing.cjs.map +1 -0
- package/dist/src/services/frameRing.mjs +2 -0
- package/dist/src/services/frameRing.mjs.map +1 -0
- package/dist/src/services/journalTail.cjs +3 -0
- package/dist/src/services/journalTail.cjs.map +1 -0
- package/dist/src/services/journalTail.mjs +3 -0
- package/dist/src/services/journalTail.mjs.map +1 -0
- package/dist/src/services/loginFlow.cjs +2 -0
- package/dist/src/services/loginFlow.cjs.map +1 -0
- package/dist/src/services/loginFlow.mjs +2 -0
- package/dist/src/services/loginFlow.mjs.map +1 -0
- package/dist/src/services/registryStore.cjs +2 -0
- package/dist/src/services/registryStore.cjs.map +1 -0
- package/dist/src/services/registryStore.mjs +2 -0
- package/dist/src/services/registryStore.mjs.map +1 -0
- package/dist/src/services/retryPolicy.cjs +2 -0
- package/dist/src/services/retryPolicy.cjs.map +1 -0
- package/dist/src/services/retryPolicy.mjs +2 -0
- package/dist/src/services/retryPolicy.mjs.map +1 -0
- package/dist/src/services/serveOptions.cjs +2 -0
- package/dist/src/services/serveOptions.cjs.map +1 -0
- package/dist/src/services/serveOptions.d.cts +21 -0
- package/dist/src/services/serveOptions.d.cts.map +1 -0
- package/dist/src/services/serveOptions.d.mts +21 -0
- package/dist/src/services/serveOptions.d.mts.map +1 -0
- package/dist/src/services/serveOptions.mjs +2 -0
- package/dist/src/services/serveOptions.mjs.map +1 -0
- package/dist/src/services/sessionFraming.cjs +4 -0
- package/dist/src/services/sessionFraming.cjs.map +1 -0
- package/dist/src/services/sessionFraming.mjs +4 -0
- package/dist/src/services/sessionFraming.mjs.map +1 -0
- package/dist/src/services/sessionPresence.cjs +2 -0
- package/dist/src/services/sessionPresence.cjs.map +1 -0
- package/dist/src/services/sessionPresence.mjs +2 -0
- package/dist/src/services/sessionPresence.mjs.map +1 -0
- package/dist/src/services/staticAssets.cjs +2 -0
- package/dist/src/services/staticAssets.cjs.map +1 -0
- package/dist/src/services/staticAssets.mjs +2 -0
- package/dist/src/services/staticAssets.mjs.map +1 -0
- package/dist/src/services/webDevRoots.cjs +2 -0
- package/dist/src/services/webDevRoots.cjs.map +1 -0
- package/dist/src/services/webDevRoots.mjs +2 -0
- package/dist/src/services/webDevRoots.mjs.map +1 -0
- package/dist/src/services/webPluginManifest.cjs +2 -0
- package/dist/src/services/webPluginManifest.cjs.map +1 -0
- package/dist/src/services/webPluginManifest.mjs +2 -0
- package/dist/src/services/webPluginManifest.mjs.map +1 -0
- package/dist/src/types/auth.cjs +2 -0
- package/dist/src/types/auth.cjs.map +1 -0
- package/dist/src/types/auth.d.cts +94 -0
- package/dist/src/types/auth.d.cts.map +1 -0
- package/dist/src/types/auth.d.mts +94 -0
- package/dist/src/types/auth.d.mts.map +1 -0
- package/dist/src/types/auth.mjs +2 -0
- package/dist/src/types/auth.mjs.map +1 -0
- package/dist/src/types/bridge.d.cts +31 -0
- package/dist/src/types/bridge.d.cts.map +1 -0
- package/dist/src/types/bridge.d.mts +31 -0
- package/dist/src/types/bridge.d.mts.map +1 -0
- package/dist/src/types/hub.cjs +2 -0
- package/dist/src/types/hub.cjs.map +1 -0
- package/dist/src/types/hub.d.cts +58 -0
- package/dist/src/types/hub.d.cts.map +1 -0
- package/dist/src/types/hub.d.mts +58 -0
- package/dist/src/types/hub.d.mts.map +1 -0
- package/dist/src/types/hub.mjs +2 -0
- package/dist/src/types/hub.mjs.map +1 -0
- package/dist/src/types/media.cjs +2 -0
- package/dist/src/types/media.cjs.map +1 -0
- package/dist/src/types/media.mjs +2 -0
- package/dist/src/types/media.mjs.map +1 -0
- package/dist/src/types/registry.cjs +1 -0
- package/dist/src/types/registry.d.cts +2 -0
- package/dist/src/types/registry.d.mts +2 -0
- package/dist/src/types/registry.mjs +1 -0
- package/dist/src/types/session.cjs +2 -0
- package/dist/src/types/session.cjs.map +1 -0
- package/dist/src/types/session.d.cts +24 -0
- package/dist/src/types/session.d.cts.map +1 -0
- package/dist/src/types/session.d.mts +24 -0
- package/dist/src/types/session.d.mts.map +1 -0
- package/dist/src/types/session.mjs +2 -0
- package/dist/src/types/session.mjs.map +1 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-Cg52ngKv.css +2 -0
- package/dist/web/assets/index-DQO7NTIP.js +90 -0
- package/dist/web/assets/index-DQO7NTIP.js.map +1 -0
- package/dist/web/favicon-16.png +0 -0
- package/dist/web/favicon-32.png +0 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/favicon.svg +16 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/index.html +17 -0
- package/llms.txt +8 -0
- package/package.json +106 -0
- package/src/adapters/authRoutes.ts +95 -0
- package/src/adapters/bundledServer.ts +64 -0
- package/src/adapters/directoryListing.ts +127 -0
- package/src/adapters/directorySearch.ts +124 -0
- package/src/adapters/gitStatus.ts +27 -0
- package/src/adapters/httpServer.ts +556 -0
- package/src/adapters/hubProbe.ts +27 -0
- package/src/adapters/packageApiProxy.ts +60 -0
- package/src/adapters/piHubService.ts +129 -0
- package/src/adapters/piWebSocketListener.ts +98 -0
- package/src/adapters/providerAuth.ts +181 -0
- package/src/adapters/registryWatcher.ts +112 -0
- package/src/adapters/serverSpawner.ts +181 -0
- package/src/adapters/sessionFiles.ts +99 -0
- package/src/adapters/sessionHub.ts +567 -0
- package/src/adapters/sessionSocketClient.ts +105 -0
- package/src/adapters/syncGuard.ts +119 -0
- package/src/adapters/threadJournals.ts +184 -0
- package/src/adapters/webBundler.ts +83 -0
- package/src/adapters/webHubPluginLoader.ts +67 -0
- package/src/adapters/webHubPlugins.generated.ts +4 -0
- package/src/adapters/webPluginGenerate.ts +172 -0
- package/src/adapters/webPluginScan.ts +62 -0
- package/src/adapters/webPluginVite.ts +51 -0
- package/src/bin/serve.ts +64 -0
- package/src/exports/bundler.ts +1 -0
- package/src/exports/index.ts +9 -0
- package/src/services/directoryMatch.ts +71 -0
- package/src/services/fileMatch.ts +37 -0
- package/src/services/fileMedia.ts +27 -0
- package/src/services/frameRing.ts +33 -0
- package/src/services/journalTail.ts +47 -0
- package/src/services/loginFlow.ts +144 -0
- package/src/services/registryStore.ts +83 -0
- package/src/services/retryPolicy.ts +15 -0
- package/src/services/serveOptions.ts +63 -0
- package/src/services/sessionFraming.ts +32 -0
- package/src/services/sessionPresence.ts +131 -0
- package/src/services/staticAssets.ts +37 -0
- package/src/services/webDevRoots.ts +36 -0
- package/src/services/webPluginManifest.ts +149 -0
- package/src/types/auth.ts +118 -0
- package/src/types/bridge.ts +41 -0
- package/src/types/hub.ts +290 -0
- package/src/types/media.ts +38 -0
- package/src/types/registry.ts +2 -0
- package/src/types/session.ts +36 -0
- package/src/web/app/Providers.tsx +46 -0
- package/src/web/app/protocolRuntime.ts +106 -0
- package/src/web/app/sessionRuntime.ts +189 -0
- package/src/web/app/webPlugins.generated.ts +4 -0
- package/src/web/components/MascotMark.tsx +46 -0
- package/src/web/components/PluginSurface.tsx +10 -0
- package/src/web/components/ToolRendererBoundary.tsx +32 -0
- package/src/web/features/activity/ActivityDock.tsx +125 -0
- package/src/web/features/connection/RefusedCard.tsx +63 -0
- package/src/web/features/dialogs/DialogOverlay.tsx +176 -0
- package/src/web/features/leader/CommandPalette.tsx +386 -0
- package/src/web/features/selection/SelectionBar.tsx +529 -0
- package/src/web/features/session/Composer.tsx +368 -0
- package/src/web/features/session/MentionPreviews.tsx +58 -0
- package/src/web/features/session/ThreadView.tsx +35 -0
- package/src/web/features/session/Timeline.tsx +382 -0
- package/src/web/features/session/ToolCard.tsx +79 -0
- package/src/web/features/sessions/NewSessionDialog.tsx +222 -0
- package/src/web/features/sessions/SessionRail.tsx +411 -0
- package/src/web/features/settings/AppearanceSettings.tsx +88 -0
- package/src/web/features/settings/LoginFlowDialog.tsx +221 -0
- package/src/web/features/settings/PluginSettings.tsx +92 -0
- package/src/web/features/settings/ProviderSettings.tsx +238 -0
- package/src/web/features/settings/SettingsMenu.tsx +32 -0
- package/src/web/features/status/TopBar.tsx +208 -0
- package/src/web/index.html +16 -0
- package/src/web/lib/authApi.ts +90 -0
- package/src/web/lib/commands.ts +61 -0
- package/src/web/lib/composition.ts +225 -0
- package/src/web/lib/fileMentions.ts +32 -0
- package/src/web/lib/hubApi.ts +86 -0
- package/src/web/lib/leaderTree.ts +159 -0
- package/src/web/lib/piTransport.ts +61 -0
- package/src/web/lib/pluginRegistry.ts +488 -0
- package/src/web/lib/pluginSlotProps.ts +50 -0
- package/src/web/lib/promptFocus.ts +25 -0
- package/src/web/lib/protocolTimeline.ts +73 -0
- package/src/web/lib/sessionModel.ts +673 -0
- package/src/web/lib/sessionSummary.ts +58 -0
- package/src/web/lib/settingsSections.ts +21 -0
- package/src/web/lib/statusLine.ts +90 -0
- package/src/web/lib/threadRenderer.ts +25 -0
- package/src/web/lib/toolMessageProps.ts +21 -0
- package/src/web/lib/transport.ts +30 -0
- package/src/web/lib/wsClient.ts +73 -0
- package/src/web/main.tsx +17 -0
- package/src/web/public/apple-touch-icon.png +0 -0
- package/src/web/public/favicon-16.png +0 -0
- package/src/web/public/favicon-32.png +0 -0
- package/src/web/public/favicon.ico +0 -0
- package/src/web/public/favicon.svg +16 -0
- package/src/web/public/icon-512.png +0 -0
- package/src/web/routes/CockpitPage.tsx +98 -0
- package/src/web/routes/SettingsPage.tsx +60 -0
- package/src/web/routes/routeTree.tsx +49 -0
- package/src/web/stores/menuStore.ts +78 -0
- package/src/web/stores/paletteStore.ts +25 -0
- package/src/web/stores/sessionStore.ts +303 -0
- package/src/web/stores/sessionsStore.ts +155 -0
- package/src/web/stores/themeStore.ts +52 -0
- package/src/web/stores/threadStore.ts +68 -0
- package/src/web/stores/transientTabsStore.ts +63 -0
- package/src/web/stores/uiStore.ts +28 -0
- package/src/web/stores/useOpenTab.ts +19 -0
- package/src/web/stores/usePluginSlotProps.ts +22 -0
- package/src/web/styles/app.css +15 -0
- package/src/web/styles/webPluginSources.generated.css +1 -0
- package/src/web/vite-env.d.ts +1 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
type DeclaredWebPlugin,
|
|
5
|
+
declaredPluginsOf,
|
|
6
|
+
orderDeclaredPlugins,
|
|
7
|
+
WebPluginManifestError,
|
|
8
|
+
} from '../services/webPluginManifest.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reads doompiWeb declarations from explicit package roots.
|
|
12
|
+
*
|
|
13
|
+
* No workspace walking and no hardcoded plugin names: the caller says which
|
|
14
|
+
* packages are in play. At doompi-web's own build time that is just the host
|
|
15
|
+
* package (its built-in plugins); at `doompi sync` time it is the installed
|
|
16
|
+
* composition's package roots. A plugin package whose manifest is malformed
|
|
17
|
+
* or whose entry file is missing is skipped with a notice, so one broken or
|
|
18
|
+
* half-installed package never costs the rest of the cockpit; only the host's
|
|
19
|
+
* own manifest still throws, because that is a build error of this package.
|
|
20
|
+
*/
|
|
21
|
+
export function scanWebPlugins(
|
|
22
|
+
hostDir: string,
|
|
23
|
+
pluginRoots: readonly string[] = [],
|
|
24
|
+
onNotice: (message: string) => void = () => undefined,
|
|
25
|
+
): DeclaredWebPlugin[] {
|
|
26
|
+
const seen = new Set<string>();
|
|
27
|
+
const plugins: DeclaredWebPlugin[] = [];
|
|
28
|
+
const hostResolved = path.resolve(hostDir);
|
|
29
|
+
for (const root of [hostDir, ...pluginRoots]) {
|
|
30
|
+
const resolved = path.resolve(root);
|
|
31
|
+
if (seen.has(resolved)) continue;
|
|
32
|
+
seen.add(resolved);
|
|
33
|
+
try {
|
|
34
|
+
plugins.push(...declaredPluginsIn(resolved, resolved === hostResolved));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (resolved === hostResolved) throw error;
|
|
37
|
+
const reason = error instanceof WebPluginManifestError ? error.message : String(error);
|
|
38
|
+
onNotice(`web plugin package ${resolved} skipped: ${reason}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return orderDeclaredPlugins(plugins, onNotice);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function declaredPluginsIn(resolved: string, isHost: boolean): DeclaredWebPlugin[] {
|
|
45
|
+
let manifest: Record<string, unknown>;
|
|
46
|
+
try {
|
|
47
|
+
manifest = JSON.parse(fs.readFileSync(path.join(resolved, 'package.json'), 'utf8')) as Record<string, unknown>;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new WebPluginManifestError(resolved, `package.json is unreadable: ${String(error)}`);
|
|
50
|
+
}
|
|
51
|
+
const declared = declaredPluginsOf(resolved, manifest, isHost);
|
|
52
|
+
for (const plugin of declared) {
|
|
53
|
+
const entryPath = path.join(resolved, plugin.client.entry);
|
|
54
|
+
if (!fs.existsSync(entryPath)) {
|
|
55
|
+
throw new WebPluginManifestError(resolved, `client.entry '${plugin.client.entry}' does not exist.`);
|
|
56
|
+
}
|
|
57
|
+
if (plugin.hub && !fs.existsSync(path.join(resolved, plugin.hub.entry))) {
|
|
58
|
+
throw new WebPluginManifestError(resolved, `hub.entry '${plugin.hub.entry}' does not exist.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return declared;
|
|
62
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { Alias, Plugin } from 'vite';
|
|
4
|
+
import { devPluginRoots, PLUGIN_ROOTS_ENV, PLUGIN_ROOTS_FILE } from '../services/webDevRoots.ts';
|
|
5
|
+
import type { SyncGeneratedModules } from './webPluginGenerate.ts';
|
|
6
|
+
|
|
7
|
+
/** Where `doompi sync` publishes the bundle the server prefers; the roots file sits beside its assets. */
|
|
8
|
+
const SYNCED_BUNDLE_DIRECTORY = ['.doompi', 'web', 'current'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Swaps the committed builtin registry modules for generated ones. A
|
|
12
|
+
* resolveId hook rather than resolve.alias because aliases match raw import
|
|
13
|
+
* specifiers while these two are known by their resolved paths. Both the
|
|
14
|
+
* sync-time build and the dev server use it, so a plugin edit in dev is the
|
|
15
|
+
* same module the bundle would carry.
|
|
16
|
+
*/
|
|
17
|
+
export function webPluginOverridePlugin(clientRoot: string, generated: SyncGeneratedModules): Plugin {
|
|
18
|
+
const overrides = new Map([
|
|
19
|
+
[path.join(clientRoot, 'app', 'webPlugins.generated.ts'), generated.clientModulePath],
|
|
20
|
+
[path.join(clientRoot, 'styles', 'webPluginSources.generated.css'), generated.cssModulePath],
|
|
21
|
+
]);
|
|
22
|
+
return {
|
|
23
|
+
name: 'doompi-web-plugin-overrides',
|
|
24
|
+
enforce: 'pre',
|
|
25
|
+
async resolveId(source, importer, options) {
|
|
26
|
+
const resolved = await this.resolve(source, importer, { ...options, skipSelf: true });
|
|
27
|
+
if (!resolved) return null;
|
|
28
|
+
return overrides.get(resolved.id) ?? null;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The CSS sources module cannot ride the resolveId hook: Vite and Tailwind
|
|
35
|
+
* resolve CSS @import through Vite's own id resolver, which consults aliases
|
|
36
|
+
* but no plugin hooks. Without this the builtin (empty) @source list ships
|
|
37
|
+
* and every plugin-only utility class is missing.
|
|
38
|
+
*/
|
|
39
|
+
export function webPluginCssAlias(generated: SyncGeneratedModules): Alias {
|
|
40
|
+
return { find: /^\.\/webPluginSources\.generated\.css$/, replacement: generated.cssModulePath };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The plugin package roots the dev server should serve, from the environment or the last sync. */
|
|
44
|
+
export function readDevPluginRoots(environment: NodeJS.ProcessEnv, homeDirectory: string): string[] {
|
|
45
|
+
const rootsFile = path.join(homeDirectory, ...SYNCED_BUNDLE_DIRECTORY, PLUGIN_ROOTS_FILE);
|
|
46
|
+
return devPluginRoots({
|
|
47
|
+
envValue: environment[PLUGIN_ROOTS_ENV],
|
|
48
|
+
rootsFileText: fs.existsSync(rootsFile) ? fs.readFileSync(rootsFile, 'utf8') : undefined,
|
|
49
|
+
delimiter: path.delimiter,
|
|
50
|
+
});
|
|
51
|
+
}
|
package/src/bin/serve.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { hubAnswers } from '../adapters/hubProbe.ts';
|
|
5
|
+
import { serveWeb } from '../adapters/httpServer.ts';
|
|
6
|
+
import { REGISTRY_DIR_ENV, resolveRegistryDir } from '../services/registryStore.ts';
|
|
7
|
+
import { parseServeOptions } from '../services/serveOptions.ts';
|
|
8
|
+
import type { WebServer } from '../types/bridge.ts';
|
|
9
|
+
|
|
10
|
+
const ADDRESS_IN_USE = 'EADDRINUSE';
|
|
11
|
+
|
|
12
|
+
function notice(message: string): void {
|
|
13
|
+
process.stderr.write(`[doompi-web] ${message}\n`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function main(): Promise<void> {
|
|
17
|
+
const options = parseServeOptions(process.argv.slice(2));
|
|
18
|
+
const url = `http://${options.host}:${String(options.port)}`;
|
|
19
|
+
|
|
20
|
+
// One hub serves every session, so a second start is a request to use the
|
|
21
|
+
// running one, not an error worth a stack trace.
|
|
22
|
+
if (await hubAnswers(options.host, options.port)) {
|
|
23
|
+
notice(`cockpit already running at ${url}`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let server: WebServer;
|
|
28
|
+
try {
|
|
29
|
+
server = await serveWeb({
|
|
30
|
+
port: options.port,
|
|
31
|
+
host: options.host,
|
|
32
|
+
assetsDir: options.assetsDir,
|
|
33
|
+
registryDir: resolveRegistryDir({
|
|
34
|
+
flagValue: options.registryDir,
|
|
35
|
+
envValue: process.env[REGISTRY_DIR_ENV],
|
|
36
|
+
homeDir: os.homedir(),
|
|
37
|
+
}),
|
|
38
|
+
spawnCommand: options.spawnCommand,
|
|
39
|
+
onNotice: notice,
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
// Two cockpits starting together can both find the port free; the loser
|
|
43
|
+
// settles for the winner rather than reporting a clash nobody caused.
|
|
44
|
+
if ((error as NodeJS.ErrnoException).code !== ADDRESS_IN_USE) throw error;
|
|
45
|
+
if (await hubAnswers(options.host, options.port)) {
|
|
46
|
+
notice(`cockpit already running at ${url}`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`Port ${String(options.port)} is taken by something that is not a DoomPi cockpit; pass --port.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const stop = (): void => {
|
|
53
|
+
void server.close().then(() => {
|
|
54
|
+
process.exit(0);
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
process.on('SIGINT', stop);
|
|
58
|
+
process.on('SIGTERM', stop);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
main().catch((error: unknown) => {
|
|
62
|
+
notice(error instanceof Error ? error.message : String(error));
|
|
63
|
+
process.exitCode = 1;
|
|
64
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { bundleCockpitWeb, type BundleCockpitWebOptions, type BundleCockpitWebResult } from '../adapters/webBundler.ts';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { serveWeb } from '../adapters/httpServer.ts';
|
|
2
|
+
export { parseServeOptions } from '../services/serveOptions.ts';
|
|
3
|
+
export type { ServeOptions } from '../services/serveOptions.ts';
|
|
4
|
+
export type { SessionAttachment, WebServer, WebServerOptions } from '../types/bridge.ts';
|
|
5
|
+
export { HUB_PROTOCOL_VERSION, HUB_ROLE, SESSIONS_API_ROUTE } from '../types/hub.ts';
|
|
6
|
+
export type { SessionPhase, SessionSummary } from '../types/hub.ts';
|
|
7
|
+
export { SESSION_RECORD_VERSION } from '../types/registry.ts';
|
|
8
|
+
export type { SessionRecord } from '../types/registry.ts';
|
|
9
|
+
export type { BridgeState, BridgeStatusFrame, SessionFrame } from '../types/session.ts';
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/** Scores, highest first: an exact folder name beats a prefix, which beats a substring. */
|
|
4
|
+
const EXACT_NAME = 1000;
|
|
5
|
+
const NAME_PREFIX = 800;
|
|
6
|
+
const NAME_CONTAINS = 600;
|
|
7
|
+
const PATH_CONTAINS = 400;
|
|
8
|
+
/** Each level down costs this much, so a shallow answer wins a deep one of the same kind. */
|
|
9
|
+
const DEPTH_PENALTY = 12;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How well one directory answers a typed query, or undefined when it does not.
|
|
13
|
+
*
|
|
14
|
+
* The reader is naming a project, so the folder's own name is what they mean;
|
|
15
|
+
* a match further up the path still counts, because "workspace/agi" is a
|
|
16
|
+
* reasonable way to point at a folder, but it ranks below a name that matches
|
|
17
|
+
* outright. Depth is the tiebreak: given `~/work/api` and
|
|
18
|
+
* `~/work/api/node_modules/x/api`, the first is what anyone meant.
|
|
19
|
+
*/
|
|
20
|
+
export function scoreDirectory(absolutePath: string, query: string): number | undefined {
|
|
21
|
+
const needle = query.trim().toLowerCase();
|
|
22
|
+
if (needle === '') return undefined;
|
|
23
|
+
const name = path.basename(absolutePath).toLowerCase();
|
|
24
|
+
const depth = absolutePath.split(path.sep).filter(Boolean).length;
|
|
25
|
+
|
|
26
|
+
const base =
|
|
27
|
+
name === needle
|
|
28
|
+
? EXACT_NAME
|
|
29
|
+
: name.startsWith(needle)
|
|
30
|
+
? NAME_PREFIX
|
|
31
|
+
: name.includes(needle)
|
|
32
|
+
? NAME_CONTAINS
|
|
33
|
+
: absolutePath.toLowerCase().includes(needle)
|
|
34
|
+
? PATH_CONTAINS
|
|
35
|
+
: undefined;
|
|
36
|
+
if (base === undefined) return undefined;
|
|
37
|
+
return base - depth * DEPTH_PENALTY;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The best directories for a query, deduplicated and cut to the limit. Ties
|
|
42
|
+
* break alphabetically so the same query always lists the same way.
|
|
43
|
+
*/
|
|
44
|
+
export function rankDirectories(paths: Iterable<string>, query: string, limit: number): string[] {
|
|
45
|
+
const scored = new Map<string, number>();
|
|
46
|
+
for (const candidate of paths) {
|
|
47
|
+
if (scored.has(candidate)) continue;
|
|
48
|
+
const score = scoreDirectory(candidate, query);
|
|
49
|
+
if (score !== undefined) scored.set(candidate, score);
|
|
50
|
+
}
|
|
51
|
+
return [...scored.entries()]
|
|
52
|
+
.sort(
|
|
53
|
+
([leftPath, leftScore], [rightPath, rightScore]) => rightScore - leftScore || leftPath.localeCompare(rightPath),
|
|
54
|
+
)
|
|
55
|
+
.slice(0, limit)
|
|
56
|
+
.map(([candidate]) => candidate);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The part of a typed value to search for when it is not a path that exists.
|
|
61
|
+
*
|
|
62
|
+
* Someone typing a remembered path from another machine ("/home/me/work/api")
|
|
63
|
+
* still means "api", so the trailing segment is the query and the rest is
|
|
64
|
+
* discarded rather than treated as a location that must exist.
|
|
65
|
+
*/
|
|
66
|
+
export function searchTermFor(typed: string): string {
|
|
67
|
+
const trimmed = typed.trim().replace(/\/+$/u, '');
|
|
68
|
+
if (trimmed === '') return '';
|
|
69
|
+
const cut = trimmed.lastIndexOf('/');
|
|
70
|
+
return cut === -1 ? trimmed : trimmed.slice(cut + 1);
|
|
71
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranking for the composer's @ file completion: pure string policy, no
|
|
3
|
+
* filesystem, so the ordering rules stay testable on their own.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
interface RankedMatch {
|
|
7
|
+
file: string;
|
|
8
|
+
rank: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Case-insensitive substring match, ranked so what a person is reaching for
|
|
13
|
+
* surfaces first: basename prefix beats basename substring beats path
|
|
14
|
+
* substring, earlier and shorter paths break ties.
|
|
15
|
+
*/
|
|
16
|
+
export function rankFileMatches(files: readonly string[], query: string, limit: number): string[] {
|
|
17
|
+
const needle = query.toLowerCase();
|
|
18
|
+
const ranked: RankedMatch[] = [];
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
const lower = file.toLowerCase();
|
|
21
|
+
const base = lower.slice(lower.lastIndexOf('/') + 1);
|
|
22
|
+
let rank: number;
|
|
23
|
+
if (needle === '') rank = 3;
|
|
24
|
+
else if (base.startsWith(needle)) rank = 0;
|
|
25
|
+
else if (base.includes(needle)) rank = 1;
|
|
26
|
+
else if (lower.includes(needle)) rank = 2;
|
|
27
|
+
else continue;
|
|
28
|
+
ranked.push({ file, rank });
|
|
29
|
+
}
|
|
30
|
+
return ranked
|
|
31
|
+
.sort(
|
|
32
|
+
(left, right) =>
|
|
33
|
+
left.rank - right.rank || left.file.length - right.file.length || left.file.localeCompare(right.file),
|
|
34
|
+
)
|
|
35
|
+
.slice(0, limit)
|
|
36
|
+
.map((entry) => entry.file);
|
|
37
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { MEDIA_TYPES, type MediaType } from '../types/media.ts';
|
|
3
|
+
|
|
4
|
+
const OCTET_STREAM = 'application/octet-stream';
|
|
5
|
+
|
|
6
|
+
/** The preview type for a file the hub is asked to serve, or undefined for a plain download. */
|
|
7
|
+
export function mediaTypeFor(filePath: string): MediaType | undefined {
|
|
8
|
+
return MEDIA_TYPES[path.extname(filePath).slice(1).toLowerCase()];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The response headers for a mentioned file. Previewable kinds render inline;
|
|
13
|
+
* everything else downloads under its own name. SVG is the one previewable
|
|
14
|
+
* type that can carry script, so it is served sandboxed: an <img> never runs
|
|
15
|
+
* script anyway, and a direct navigation gets an inert document.
|
|
16
|
+
*/
|
|
17
|
+
export function sessionFileHeaders(filePath: string): Record<string, string> {
|
|
18
|
+
const media = mediaTypeFor(filePath);
|
|
19
|
+
const name = path.basename(filePath).replaceAll('"', '');
|
|
20
|
+
const headers: Record<string, string> = {
|
|
21
|
+
'Content-Type': media?.contentType ?? OCTET_STREAM,
|
|
22
|
+
'Content-Disposition': `${media ? 'inline' : 'attachment'}; filename="${name}"`,
|
|
23
|
+
'X-Content-Type-Options': 'nosniff',
|
|
24
|
+
};
|
|
25
|
+
if (media?.contentType === 'image/svg+xml') headers['Content-Security-Policy'] = "default-src 'none'; sandbox";
|
|
26
|
+
return headers;
|
|
27
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SessionFrame } from '../types/session.ts';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_RING_LIMIT = 512;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Bounded history of one session's frame stream.
|
|
7
|
+
*
|
|
8
|
+
* The unix-socket backlog only fills while nothing is attached, and the hub is
|
|
9
|
+
* always attached, so a page that connects later would otherwise start blind.
|
|
10
|
+
* The ring is what a subscribe replays; it mirrors the server backlog's
|
|
11
|
+
* semantics, dropping the oldest frames past the limit and counting the loss.
|
|
12
|
+
*/
|
|
13
|
+
export interface FrameRing {
|
|
14
|
+
record(frame: SessionFrame): void;
|
|
15
|
+
snapshot(): { frames: SessionFrame[]; dropped: number };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createFrameRing(limit = DEFAULT_RING_LIMIT): FrameRing {
|
|
19
|
+
const frames: SessionFrame[] = [];
|
|
20
|
+
let dropped = 0;
|
|
21
|
+
return {
|
|
22
|
+
record(frame) {
|
|
23
|
+
frames.push(frame);
|
|
24
|
+
if (frames.length > limit) {
|
|
25
|
+
frames.shift();
|
|
26
|
+
dropped += 1;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
snapshot() {
|
|
30
|
+
return { frames: [...frames], dropped };
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { SessionFrame } from '../types/session.ts';
|
|
2
|
+
|
|
3
|
+
/** The entry type a journal line must carry to render; everything else is Pi's own bookkeeping. */
|
|
4
|
+
const MESSAGE_ENTRY_TYPE = 'message';
|
|
5
|
+
/** The frame the hub already replays journal entries as, so the page folds a thread with its one reducer. */
|
|
6
|
+
const ENTRY_APPENDED_TYPE = 'entry_appended';
|
|
7
|
+
|
|
8
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** One journal line as a renderable entry, or undefined for the header, bookkeeping, or a torn line. */
|
|
13
|
+
function parseEntry(line: string): Record<string, unknown> | undefined {
|
|
14
|
+
const trimmed = line.trim();
|
|
15
|
+
if (trimmed === '') return undefined;
|
|
16
|
+
let parsed: unknown;
|
|
17
|
+
try {
|
|
18
|
+
parsed = JSON.parse(trimmed);
|
|
19
|
+
} catch {
|
|
20
|
+
// A line the writer had not finished, or one that was never JSON: nothing to render from it.
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
if (!isRecord(parsed) || parsed.type !== MESSAGE_ENTRY_TYPE) return undefined;
|
|
24
|
+
// An entry without an id is the runtime's own bookkeeping; the page would skip it too.
|
|
25
|
+
if (typeof parsed.id !== 'string' || parsed.id === '' || !isRecord(parsed.message)) return undefined;
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Folds complete lines of a Pi session journal into the frames the cockpit
|
|
31
|
+
* already renders for a live session: one entry_appended per message entry,
|
|
32
|
+
* oldest first. The header, other entry types and unparsable lines are
|
|
33
|
+
* skipped, so the page needs no second reducer for a thread.
|
|
34
|
+
*/
|
|
35
|
+
export function journalFrames(text: string): SessionFrame[] {
|
|
36
|
+
const frames: SessionFrame[] = [];
|
|
37
|
+
for (const line of text.split('\n')) {
|
|
38
|
+
const entry = parseEntry(line);
|
|
39
|
+
if (entry) frames.push({ type: ENTRY_APPENDED_TYPE, entry });
|
|
40
|
+
}
|
|
41
|
+
return frames;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The newest `limit` frames; a long journal gives up its oldest, as an attach does. */
|
|
45
|
+
export function retainNewest(frames: readonly SessionFrame[], limit: number): SessionFrame[] {
|
|
46
|
+
return frames.length <= limit ? [...frames] : frames.slice(frames.length - limit);
|
|
47
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthMethodType,
|
|
3
|
+
LoginEvent,
|
|
4
|
+
LoginFlowSnapshot,
|
|
5
|
+
LoginFlowStatus,
|
|
6
|
+
LoginInteraction,
|
|
7
|
+
LoginPrompt,
|
|
8
|
+
LoginPromptView,
|
|
9
|
+
} from '../types/auth.ts';
|
|
10
|
+
|
|
11
|
+
/** The message Pi's own login dialog uses; a provider flow may match on it. */
|
|
12
|
+
const CANCELLED_MESSAGE = 'Login cancelled';
|
|
13
|
+
const SUPERSEDED_MESSAGE = 'Superseded by a newer prompt';
|
|
14
|
+
|
|
15
|
+
interface PendingPrompt {
|
|
16
|
+
view: LoginPromptView;
|
|
17
|
+
resolve(value: string): void;
|
|
18
|
+
reject(error: Error): void;
|
|
19
|
+
detach(): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LoginFlowInput {
|
|
23
|
+
id: string;
|
|
24
|
+
providerId: string;
|
|
25
|
+
providerName: string;
|
|
26
|
+
type: AuthMethodType;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One login in progress, seen from both sides: the provider's flow talks to
|
|
31
|
+
* it through `interaction`, the page through snapshots and answers.
|
|
32
|
+
*/
|
|
33
|
+
export interface LoginFlow {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly providerId: string;
|
|
36
|
+
/** Hand this to the runtime's login; prompts and events route through the flow. */
|
|
37
|
+
readonly interaction: LoginInteraction;
|
|
38
|
+
snapshot(): LoginFlowSnapshot;
|
|
39
|
+
/** Answers the pending prompt; false when the flow is not waiting on that prompt. */
|
|
40
|
+
answer(promptId: string, value: string): boolean;
|
|
41
|
+
/** Aborts the provider's flow; a settled flow is left as it ended. */
|
|
42
|
+
cancel(): void;
|
|
43
|
+
/** Records how the runtime's login ended; a cancelled flow stays cancelled. */
|
|
44
|
+
settle(outcome: { ok: true } | { ok: false; error: string }): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function promptView(id: string, prompt: LoginPrompt): LoginPromptView {
|
|
48
|
+
const view: LoginPromptView = { id, type: prompt.type, message: prompt.message };
|
|
49
|
+
if (prompt.type === 'select') view.options = prompt.options.map((option) => ({ ...option }));
|
|
50
|
+
else if (prompt.placeholder !== undefined) view.placeholder = prompt.placeholder;
|
|
51
|
+
return view;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The state machine between a provider login and a browser that can only
|
|
56
|
+
* poll and post.
|
|
57
|
+
*
|
|
58
|
+
* A provider asks one question at a time and may withdraw it (a manual-code
|
|
59
|
+
* prompt raced against a callback server aborts when the callback wins), so
|
|
60
|
+
* the flow holds at most one pending prompt, rejects it when its signal
|
|
61
|
+
* fires, and supersedes it if the provider asks again.
|
|
62
|
+
*/
|
|
63
|
+
export function createLoginFlow(input: LoginFlowInput): LoginFlow {
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const events: LoginEvent[] = [];
|
|
66
|
+
let status: LoginFlowStatus = 'running';
|
|
67
|
+
let error: string | undefined;
|
|
68
|
+
let pending: PendingPrompt | undefined;
|
|
69
|
+
let promptSeq = 0;
|
|
70
|
+
|
|
71
|
+
const failPending = (reason: Error): void => {
|
|
72
|
+
const current = pending;
|
|
73
|
+
if (!current) return;
|
|
74
|
+
pending = undefined;
|
|
75
|
+
current.detach();
|
|
76
|
+
current.reject(reason);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const interaction: LoginInteraction = {
|
|
80
|
+
signal: controller.signal,
|
|
81
|
+
prompt(prompt) {
|
|
82
|
+
if (controller.signal.aborted || prompt.signal?.aborted) return Promise.reject(new Error(CANCELLED_MESSAGE));
|
|
83
|
+
failPending(new Error(SUPERSEDED_MESSAGE));
|
|
84
|
+
promptSeq += 1;
|
|
85
|
+
const view = promptView(String(promptSeq), prompt);
|
|
86
|
+
return new Promise<string>((resolve, reject) => {
|
|
87
|
+
const onAbort = (): void => failPending(new Error(CANCELLED_MESSAGE));
|
|
88
|
+
prompt.signal?.addEventListener('abort', onAbort, { once: true });
|
|
89
|
+
pending = {
|
|
90
|
+
view,
|
|
91
|
+
resolve,
|
|
92
|
+
reject,
|
|
93
|
+
detach: () => prompt.signal?.removeEventListener('abort', onAbort),
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
notify(event) {
|
|
98
|
+
events.push(event);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
id: input.id,
|
|
104
|
+
providerId: input.providerId,
|
|
105
|
+
interaction,
|
|
106
|
+
snapshot() {
|
|
107
|
+
const snapshot: LoginFlowSnapshot = {
|
|
108
|
+
id: input.id,
|
|
109
|
+
providerId: input.providerId,
|
|
110
|
+
providerName: input.providerName,
|
|
111
|
+
type: input.type,
|
|
112
|
+
status,
|
|
113
|
+
events: [...events],
|
|
114
|
+
};
|
|
115
|
+
if (pending) snapshot.prompt = pending.view;
|
|
116
|
+
if (error !== undefined) snapshot.error = error;
|
|
117
|
+
return snapshot;
|
|
118
|
+
},
|
|
119
|
+
answer(promptId, value) {
|
|
120
|
+
const current = pending;
|
|
121
|
+
if (!current || current.view.id !== promptId) return false;
|
|
122
|
+
pending = undefined;
|
|
123
|
+
current.detach();
|
|
124
|
+
current.resolve(value);
|
|
125
|
+
return true;
|
|
126
|
+
},
|
|
127
|
+
cancel() {
|
|
128
|
+
if (status !== 'running') return;
|
|
129
|
+
status = 'cancelled';
|
|
130
|
+
controller.abort();
|
|
131
|
+
failPending(new Error(CANCELLED_MESSAGE));
|
|
132
|
+
},
|
|
133
|
+
settle(outcome) {
|
|
134
|
+
if (status !== 'running') return;
|
|
135
|
+
if (outcome.ok) {
|
|
136
|
+
status = 'succeeded';
|
|
137
|
+
} else {
|
|
138
|
+
status = 'failed';
|
|
139
|
+
error = outcome.error;
|
|
140
|
+
}
|
|
141
|
+
failPending(new Error(CANCELLED_MESSAGE));
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { SESSION_RECORD_VERSION, type SessionRecord } from '../types/registry.ts';
|
|
2
|
+
|
|
3
|
+
const RECORD_EXTENSION = '.json';
|
|
4
|
+
const REQUIRED_STRINGS = ['id', 'cwd', 'socketPath', 'tokenFile', 'createdAt'] as const;
|
|
5
|
+
|
|
6
|
+
/** Environment variable overriding where session records live. */
|
|
7
|
+
export const REGISTRY_DIR_ENV = 'DOOMPI_RUNTIME_DIR';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_RUN_DIR_SEGMENT = '.doompi/run';
|
|
10
|
+
const SESSIONS_SEGMENT = 'sessions';
|
|
11
|
+
|
|
12
|
+
export interface RegistryDirInput {
|
|
13
|
+
/** Value of --registry-dir, when given; wins over everything else. */
|
|
14
|
+
readonly flagValue?: string;
|
|
15
|
+
/** Value of DOOMPI_RUNTIME_DIR, when set. */
|
|
16
|
+
readonly envValue?: string;
|
|
17
|
+
/** The user's home directory, supplied by the host. */
|
|
18
|
+
readonly homeDir: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Mirrors doompi-server's resolution so both sides land on the same directory. */
|
|
22
|
+
export function resolveRegistryDir(input: RegistryDirInput): string {
|
|
23
|
+
if (input.flagValue) return input.flagValue;
|
|
24
|
+
if (input.envValue) return input.envValue;
|
|
25
|
+
return `${input.homeDir}/${DEFAULT_RUN_DIR_SEGMENT}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Directory the watcher scans for record files. */
|
|
29
|
+
export function sessionRecordsDir(registryDir: string): string {
|
|
30
|
+
return `${registryDir}/${SESSIONS_SEGMENT}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sessionRecordPath(registryDir: string, sessionId: string): string {
|
|
34
|
+
return `${sessionRecordsDir(registryDir)}/${sessionId}${RECORD_EXTENSION}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isRecordFileName(name: string): boolean {
|
|
38
|
+
return name.endsWith(RECORD_EXTENSION);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validates one record file's content.
|
|
43
|
+
*
|
|
44
|
+
* Returns undefined for anything that is not a well-formed current-version
|
|
45
|
+
* record: a truncated write, a foreign file, or a future format this build
|
|
46
|
+
* does not understand. The watcher simply skips such files.
|
|
47
|
+
*/
|
|
48
|
+
export function parseSessionRecord(raw: string): SessionRecord | undefined {
|
|
49
|
+
let parsed: unknown;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(raw);
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;
|
|
56
|
+
const record = parsed as Record<string, unknown>;
|
|
57
|
+
if (record.version !== SESSION_RECORD_VERSION) return undefined;
|
|
58
|
+
for (const key of REQUIRED_STRINGS) {
|
|
59
|
+
if (typeof record[key] !== 'string' || record[key] === '') return undefined;
|
|
60
|
+
}
|
|
61
|
+
if (typeof record.name !== 'string') return undefined;
|
|
62
|
+
if (typeof record.pid !== 'number' || !Number.isInteger(record.pid) || record.pid <= 0) return undefined;
|
|
63
|
+
return {
|
|
64
|
+
version: SESSION_RECORD_VERSION,
|
|
65
|
+
id: record.id as string,
|
|
66
|
+
name: record.name,
|
|
67
|
+
cwd: record.cwd as string,
|
|
68
|
+
socketPath: record.socketPath as string,
|
|
69
|
+
tokenFile: record.tokenFile as string,
|
|
70
|
+
// Optional: a server that mounted no package API omits it, and so does an
|
|
71
|
+
// older one that predates the field.
|
|
72
|
+
...(typeof record.apiSocketPath === 'string' && record.apiSocketPath !== ''
|
|
73
|
+
? { apiSocketPath: record.apiSocketPath }
|
|
74
|
+
: {}),
|
|
75
|
+
// Also optional: a server predating the protocol socket serves only the
|
|
76
|
+
// framed one, and a client that needs the protocol says so itself.
|
|
77
|
+
...(typeof record.protocolSocketPath === 'string' && record.protocolSocketPath !== ''
|
|
78
|
+
? { protocolSocketPath: record.protocolSocketPath }
|
|
79
|
+
: {}),
|
|
80
|
+
pid: record.pid,
|
|
81
|
+
createdAt: record.createdAt as string,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const BASE_DELAY_MS = 150;
|
|
2
|
+
const MAX_DELAY_MS = 4000;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Exponential backoff for reattaching to the session socket.
|
|
6
|
+
*
|
|
7
|
+
* A browser reload closes the old socket and opens a new one, and the server
|
|
8
|
+
* only allows one client at a time, so the first retries have to be quick
|
|
9
|
+
* enough to win that handover without hammering a socket that is genuinely
|
|
10
|
+
* held by someone else.
|
|
11
|
+
*/
|
|
12
|
+
export function reattachDelayMs(attempt: number): number {
|
|
13
|
+
if (attempt <= 0) return 0;
|
|
14
|
+
return Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS);
|
|
15
|
+
}
|