gitspace 0.2.0-rc.1
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/.claude/settings.local.json +21 -0
- package/.gitspace/bundle.json +50 -0
- package/.gitspace/select/01-status.sh +40 -0
- package/.gitspace/setup/01-install-deps.sh +12 -0
- package/.gitspace/setup/02-typecheck.sh +16 -0
- package/AGENTS.md +439 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +25 -0
- package/README.md +607 -0
- package/bin/gssh +62 -0
- package/bun.lock +647 -0
- package/docs/CONNECTION.md +623 -0
- package/docs/GATEWAY-WORKER.md +319 -0
- package/docs/GETTING-STARTED.md +448 -0
- package/docs/GITSPACE-PLATFORM.md +1819 -0
- package/docs/INFRASTRUCTURE.md +1347 -0
- package/docs/PROTOCOL.md +619 -0
- package/docs/QUICKSTART.md +174 -0
- package/docs/RELAY.md +327 -0
- package/docs/REMOTE-DESIGN.md +549 -0
- package/docs/ROADMAP.md +564 -0
- package/docs/SITE_DOCS_FIGMA_MAKE.md +1167 -0
- package/docs/STACK-DESIGN.md +588 -0
- package/docs/UNIFIED_ARCHITECTURE.md +292 -0
- package/experiments/pty-benchmark.ts +148 -0
- package/experiments/pty-latency.ts +100 -0
- package/experiments/router/client.ts +199 -0
- package/experiments/router/protocol.ts +74 -0
- package/experiments/router/router.ts +217 -0
- package/experiments/router/session.ts +180 -0
- package/experiments/router/test.ts +133 -0
- package/experiments/socket-bandwidth.ts +77 -0
- package/homebrew/gitspace.rb +45 -0
- package/landing-page/ATTRIBUTIONS.md +3 -0
- package/landing-page/README.md +11 -0
- package/landing-page/bun.lock +801 -0
- package/landing-page/guidelines/Guidelines.md +61 -0
- package/landing-page/index.html +37 -0
- package/landing-page/package.json +90 -0
- package/landing-page/postcss.config.mjs +15 -0
- package/landing-page/public/_redirects +1 -0
- package/landing-page/public/favicon.png +0 -0
- package/landing-page/src/app/App.tsx +53 -0
- package/landing-page/src/app/components/figma/ImageWithFallback.tsx +27 -0
- package/landing-page/src/app/components/ui/accordion.tsx +66 -0
- package/landing-page/src/app/components/ui/alert-dialog.tsx +157 -0
- package/landing-page/src/app/components/ui/alert.tsx +66 -0
- package/landing-page/src/app/components/ui/aspect-ratio.tsx +11 -0
- package/landing-page/src/app/components/ui/avatar.tsx +53 -0
- package/landing-page/src/app/components/ui/badge.tsx +46 -0
- package/landing-page/src/app/components/ui/breadcrumb.tsx +109 -0
- package/landing-page/src/app/components/ui/button.tsx +57 -0
- package/landing-page/src/app/components/ui/calendar.tsx +75 -0
- package/landing-page/src/app/components/ui/card.tsx +92 -0
- package/landing-page/src/app/components/ui/carousel.tsx +241 -0
- package/landing-page/src/app/components/ui/chart.tsx +353 -0
- package/landing-page/src/app/components/ui/checkbox.tsx +32 -0
- package/landing-page/src/app/components/ui/collapsible.tsx +33 -0
- package/landing-page/src/app/components/ui/command.tsx +177 -0
- package/landing-page/src/app/components/ui/context-menu.tsx +252 -0
- package/landing-page/src/app/components/ui/dialog.tsx +135 -0
- package/landing-page/src/app/components/ui/drawer.tsx +132 -0
- package/landing-page/src/app/components/ui/dropdown-menu.tsx +257 -0
- package/landing-page/src/app/components/ui/form.tsx +168 -0
- package/landing-page/src/app/components/ui/hover-card.tsx +44 -0
- package/landing-page/src/app/components/ui/input-otp.tsx +77 -0
- package/landing-page/src/app/components/ui/input.tsx +21 -0
- package/landing-page/src/app/components/ui/label.tsx +24 -0
- package/landing-page/src/app/components/ui/menubar.tsx +276 -0
- package/landing-page/src/app/components/ui/navigation-menu.tsx +168 -0
- package/landing-page/src/app/components/ui/pagination.tsx +127 -0
- package/landing-page/src/app/components/ui/popover.tsx +48 -0
- package/landing-page/src/app/components/ui/progress.tsx +31 -0
- package/landing-page/src/app/components/ui/radio-group.tsx +45 -0
- package/landing-page/src/app/components/ui/resizable.tsx +56 -0
- package/landing-page/src/app/components/ui/scroll-area.tsx +58 -0
- package/landing-page/src/app/components/ui/select.tsx +189 -0
- package/landing-page/src/app/components/ui/separator.tsx +28 -0
- package/landing-page/src/app/components/ui/sheet.tsx +139 -0
- package/landing-page/src/app/components/ui/sidebar.tsx +726 -0
- package/landing-page/src/app/components/ui/skeleton.tsx +13 -0
- package/landing-page/src/app/components/ui/slider.tsx +63 -0
- package/landing-page/src/app/components/ui/sonner.tsx +25 -0
- package/landing-page/src/app/components/ui/switch.tsx +31 -0
- package/landing-page/src/app/components/ui/table.tsx +116 -0
- package/landing-page/src/app/components/ui/tabs.tsx +66 -0
- package/landing-page/src/app/components/ui/textarea.tsx +18 -0
- package/landing-page/src/app/components/ui/toggle-group.tsx +73 -0
- package/landing-page/src/app/components/ui/toggle.tsx +47 -0
- package/landing-page/src/app/components/ui/tooltip.tsx +61 -0
- package/landing-page/src/app/components/ui/use-mobile.ts +21 -0
- package/landing-page/src/app/components/ui/utils.ts +6 -0
- package/landing-page/src/components/docs/DocsContent.tsx +718 -0
- package/landing-page/src/components/docs/DocsSidebar.tsx +84 -0
- package/landing-page/src/components/landing/CTA.tsx +59 -0
- package/landing-page/src/components/landing/Comparison.tsx +84 -0
- package/landing-page/src/components/landing/FaultyTerminal.tsx +424 -0
- package/landing-page/src/components/landing/Features.tsx +201 -0
- package/landing-page/src/components/landing/Hero.tsx +142 -0
- package/landing-page/src/components/landing/Pricing.tsx +140 -0
- package/landing-page/src/components/landing/Roadmap.tsx +86 -0
- package/landing-page/src/components/landing/Security.tsx +81 -0
- package/landing-page/src/components/landing/TerminalWindow.tsx +27 -0
- package/landing-page/src/components/landing/UseCases.tsx +55 -0
- package/landing-page/src/components/landing/Workflow.tsx +101 -0
- package/landing-page/src/components/layout/DashboardNavbar.tsx +37 -0
- package/landing-page/src/components/layout/Footer.tsx +55 -0
- package/landing-page/src/components/layout/LandingNavbar.tsx +82 -0
- package/landing-page/src/components/ui/badge.tsx +39 -0
- package/landing-page/src/components/ui/breadcrumb.tsx +115 -0
- package/landing-page/src/components/ui/button.tsx +57 -0
- package/landing-page/src/components/ui/card.tsx +79 -0
- package/landing-page/src/components/ui/mock-terminal.tsx +68 -0
- package/landing-page/src/components/ui/separator.tsx +28 -0
- package/landing-page/src/lib/utils.ts +6 -0
- package/landing-page/src/main.tsx +10 -0
- package/landing-page/src/pages/Dashboard.tsx +133 -0
- package/landing-page/src/pages/DocsPage.tsx +79 -0
- package/landing-page/src/pages/LandingPage.tsx +31 -0
- package/landing-page/src/pages/TerminalView.tsx +106 -0
- package/landing-page/src/styles/fonts.css +0 -0
- package/landing-page/src/styles/index.css +3 -0
- package/landing-page/src/styles/tailwind.css +4 -0
- package/landing-page/src/styles/theme.css +181 -0
- package/landing-page/vite.config.ts +19 -0
- package/npm/darwin-arm64/bin/gssh +0 -0
- package/npm/darwin-arm64/package.json +20 -0
- package/package.json +74 -0
- package/scripts/build.ts +284 -0
- package/scripts/release.ts +140 -0
- package/src/__tests__/test-utils.ts +298 -0
- package/src/commands/__tests__/serve-messages.test.ts +190 -0
- package/src/commands/access.ts +298 -0
- package/src/commands/add.ts +452 -0
- package/src/commands/auth.ts +364 -0
- package/src/commands/connect.ts +287 -0
- package/src/commands/directory.ts +16 -0
- package/src/commands/host.ts +396 -0
- package/src/commands/identity.ts +184 -0
- package/src/commands/list.ts +200 -0
- package/src/commands/relay.ts +315 -0
- package/src/commands/remove.ts +241 -0
- package/src/commands/serve.ts +1493 -0
- package/src/commands/share.ts +456 -0
- package/src/commands/status.ts +125 -0
- package/src/commands/switch.ts +353 -0
- package/src/commands/tmux.ts +317 -0
- package/src/core/__tests__/access.test.ts +240 -0
- package/src/core/access.ts +277 -0
- package/src/core/bundle.ts +342 -0
- package/src/core/config.ts +510 -0
- package/src/core/git.ts +317 -0
- package/src/core/github.ts +151 -0
- package/src/core/identity.ts +631 -0
- package/src/core/linear.ts +225 -0
- package/src/core/shell.ts +161 -0
- package/src/core/trusted-relays.ts +315 -0
- package/src/index.ts +821 -0
- package/src/lib/remote-session/index.ts +7 -0
- package/src/lib/remote-session/protocol.ts +267 -0
- package/src/lib/remote-session/session-handler.ts +581 -0
- package/src/lib/remote-session/workspace-scanner.ts +167 -0
- package/src/lib/tmux-lite/README.md +81 -0
- package/src/lib/tmux-lite/cli.ts +796 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +349 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +291 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +142 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +339 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +477 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +499 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +371 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +573 -0
- package/src/lib/tmux-lite/crypto/access-control.test.ts +512 -0
- package/src/lib/tmux-lite/crypto/access-control.ts +320 -0
- package/src/lib/tmux-lite/crypto/frames.test.ts +262 -0
- package/src/lib/tmux-lite/crypto/frames.ts +141 -0
- package/src/lib/tmux-lite/crypto/handshake.ts +894 -0
- package/src/lib/tmux-lite/crypto/identity.test.ts +220 -0
- package/src/lib/tmux-lite/crypto/identity.ts +286 -0
- package/src/lib/tmux-lite/crypto/index.ts +51 -0
- package/src/lib/tmux-lite/crypto/invites.test.ts +381 -0
- package/src/lib/tmux-lite/crypto/invites.ts +215 -0
- package/src/lib/tmux-lite/crypto/keyexchange.ts +435 -0
- package/src/lib/tmux-lite/crypto/keys.test.ts +58 -0
- package/src/lib/tmux-lite/crypto/keys.ts +47 -0
- package/src/lib/tmux-lite/crypto/secretbox.test.ts +169 -0
- package/src/lib/tmux-lite/crypto/secretbox.ts +124 -0
- package/src/lib/tmux-lite/handshake-handler.ts +451 -0
- package/src/lib/tmux-lite/protocol.test.ts +307 -0
- package/src/lib/tmux-lite/protocol.ts +266 -0
- package/src/lib/tmux-lite/relay-client.ts +506 -0
- package/src/lib/tmux-lite/server.ts +1250 -0
- package/src/lib/tmux-lite/shell-integration.sh +37 -0
- package/src/lib/tmux-lite/terminal-queries.test.ts +54 -0
- package/src/lib/tmux-lite/terminal-queries.ts +49 -0
- package/src/relay/__tests__/e2e-flow.test.ts +1284 -0
- package/src/relay/__tests__/helpers/auth.ts +354 -0
- package/src/relay/__tests__/helpers/ports.ts +51 -0
- package/src/relay/__tests__/protocol-validation.test.ts +265 -0
- package/src/relay/authorization.ts +303 -0
- package/src/relay/embedded-assets.generated.d.ts +15 -0
- package/src/relay/identity.ts +352 -0
- package/src/relay/index.ts +57 -0
- package/src/relay/pipes.test.ts +427 -0
- package/src/relay/pipes.ts +195 -0
- package/src/relay/protocol.ts +804 -0
- package/src/relay/registries.test.ts +437 -0
- package/src/relay/registries.ts +593 -0
- package/src/relay/server.test.ts +1323 -0
- package/src/relay/server.ts +1092 -0
- package/src/relay/signing.ts +238 -0
- package/src/relay/types.ts +69 -0
- package/src/serve/client-session-manager.ts +622 -0
- package/src/serve/daemon.ts +497 -0
- package/src/serve/pty-session.ts +236 -0
- package/src/serve/types.ts +169 -0
- package/src/shared/components/Flow.tsx +453 -0
- package/src/shared/components/Flow.tui.tsx +343 -0
- package/src/shared/components/Flow.web.tsx +442 -0
- package/src/shared/components/Inbox.tsx +446 -0
- package/src/shared/components/Inbox.tui.tsx +262 -0
- package/src/shared/components/Inbox.web.tsx +329 -0
- package/src/shared/components/MachineList.tsx +187 -0
- package/src/shared/components/MachineList.tui.tsx +161 -0
- package/src/shared/components/MachineList.web.tsx +210 -0
- package/src/shared/components/ProjectList.tsx +176 -0
- package/src/shared/components/ProjectList.tui.tsx +109 -0
- package/src/shared/components/ProjectList.web.tsx +143 -0
- package/src/shared/components/SpacesBrowser.tsx +332 -0
- package/src/shared/components/SpacesBrowser.tui.tsx +163 -0
- package/src/shared/components/SpacesBrowser.web.tsx +221 -0
- package/src/shared/components/index.ts +103 -0
- package/src/shared/hooks/index.ts +16 -0
- package/src/shared/hooks/useNavigation.ts +226 -0
- package/src/shared/index.ts +122 -0
- package/src/shared/providers/LocalMachineProvider.ts +425 -0
- package/src/shared/providers/MachineProvider.ts +165 -0
- package/src/shared/providers/RemoteMachineProvider.ts +444 -0
- package/src/shared/providers/index.ts +26 -0
- package/src/shared/types.ts +145 -0
- package/src/tui/adapters.ts +120 -0
- package/src/tui/app.tsx +1816 -0
- package/src/tui/components/Terminal.tsx +580 -0
- package/src/tui/hooks/index.ts +35 -0
- package/src/tui/hooks/useAppState.ts +314 -0
- package/src/tui/hooks/useDaemonStatus.ts +174 -0
- package/src/tui/hooks/useInboxTUI.ts +113 -0
- package/src/tui/hooks/useRemoteMachines.ts +209 -0
- package/src/tui/index.ts +24 -0
- package/src/tui/state.ts +299 -0
- package/src/tui/terminal-bracketed-paste.test.ts +45 -0
- package/src/tui/terminal-bracketed-paste.ts +47 -0
- package/src/types/bundle.ts +112 -0
- package/src/types/config.ts +89 -0
- package/src/types/errors.ts +206 -0
- package/src/types/identity.ts +284 -0
- package/src/types/workspace-fuzzy.ts +49 -0
- package/src/types/workspace.ts +151 -0
- package/src/utils/bun-socket-writer.ts +80 -0
- package/src/utils/deps.ts +127 -0
- package/src/utils/fuzzy-match.ts +125 -0
- package/src/utils/logger.ts +127 -0
- package/src/utils/markdown.ts +254 -0
- package/src/utils/onboarding.ts +229 -0
- package/src/utils/prompts.ts +114 -0
- package/src/utils/run-commands.ts +112 -0
- package/src/utils/run-scripts.ts +142 -0
- package/src/utils/sanitize.ts +98 -0
- package/src/utils/secrets.ts +122 -0
- package/src/utils/shell-escape.ts +40 -0
- package/src/utils/utf8.ts +79 -0
- package/src/utils/workspace-state.ts +47 -0
- package/src/web/README.md +73 -0
- package/src/web/bun.lock +575 -0
- package/src/web/eslint.config.js +23 -0
- package/src/web/index.html +16 -0
- package/src/web/package.json +37 -0
- package/src/web/public/vite.svg +1 -0
- package/src/web/src/App.tsx +604 -0
- package/src/web/src/assets/react.svg +1 -0
- package/src/web/src/components/Terminal.tsx +207 -0
- package/src/web/src/hooks/useRelayConnection.ts +224 -0
- package/src/web/src/hooks/useTerminal.ts +699 -0
- package/src/web/src/index.css +55 -0
- package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +1158 -0
- package/src/web/src/lib/crypto/frames.ts +205 -0
- package/src/web/src/lib/crypto/handshake.ts +396 -0
- package/src/web/src/lib/crypto/identity.ts +128 -0
- package/src/web/src/lib/crypto/keyexchange.ts +246 -0
- package/src/web/src/lib/crypto/relay-signing.ts +53 -0
- package/src/web/src/lib/invite.ts +58 -0
- package/src/web/src/lib/storage/identity-store.ts +94 -0
- package/src/web/src/main.tsx +10 -0
- package/src/web/src/types/identity.ts +45 -0
- package/src/web/tsconfig.app.json +28 -0
- package/src/web/tsconfig.json +7 -0
- package/src/web/tsconfig.node.json +26 -0
- package/src/web/vite.config.ts +31 -0
- package/todo-security.md +92 -0
- package/tsconfig.json +23 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
- package/worker/bun.lock +237 -0
- package/worker/package.json +22 -0
- package/worker/schema.sql +96 -0
- package/worker/src/handlers/auth.ts +451 -0
- package/worker/src/handlers/subdomains.ts +376 -0
- package/worker/src/handlers/user.ts +98 -0
- package/worker/src/index.ts +70 -0
- package/worker/src/middleware/auth.ts +152 -0
- package/worker/src/services/cloudflare.ts +609 -0
- package/worker/src/types.ts +96 -0
- package/worker/tsconfig.json +15 -0
- package/worker/wrangler.toml +26 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear API integration for issue management
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { LinearClient, LinearError, type Issue } from '@linear/sdk'
|
|
6
|
+
import { SpacesError } from '../types/errors.js'
|
|
7
|
+
import { logger } from '../utils/logger.js'
|
|
8
|
+
import type { LinearIssue } from '../types/workspace.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Singleton Linear client instance
|
|
12
|
+
*/
|
|
13
|
+
let clientInstance: LinearClient | null = null
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get or create Linear client instance
|
|
17
|
+
*/
|
|
18
|
+
function getLinearClient(apiKey: string): LinearClient {
|
|
19
|
+
if (!clientInstance) {
|
|
20
|
+
clientInstance = new LinearClient({ apiKey })
|
|
21
|
+
}
|
|
22
|
+
return clientInstance
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reset the Linear client (useful when API key changes)
|
|
27
|
+
*/
|
|
28
|
+
export function resetLinearClient(): void {
|
|
29
|
+
clientInstance = null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Custom error class for Linear API errors
|
|
34
|
+
*/
|
|
35
|
+
export class LinearAPIError extends SpacesError {
|
|
36
|
+
constructor(message: string, originalError?: unknown) {
|
|
37
|
+
super(message, 'SERVICE_ERROR', 3)
|
|
38
|
+
this.name = 'LinearAPIError'
|
|
39
|
+
|
|
40
|
+
if (originalError) {
|
|
41
|
+
logger.debug(`Linear API error: ${originalError}`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Retry a function with exponential backoff
|
|
48
|
+
*/
|
|
49
|
+
async function fetchWithRetry<T>(
|
|
50
|
+
fetchFn: () => Promise<T>,
|
|
51
|
+
maxRetries = 3
|
|
52
|
+
): Promise<T> {
|
|
53
|
+
let lastError: unknown
|
|
54
|
+
|
|
55
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
56
|
+
try {
|
|
57
|
+
return await fetchFn()
|
|
58
|
+
} catch (error: unknown) {
|
|
59
|
+
lastError = error
|
|
60
|
+
|
|
61
|
+
// Check if it's a retryable error (429 or 5xx)
|
|
62
|
+
let shouldRetry = false
|
|
63
|
+
let statusCode: number | undefined
|
|
64
|
+
|
|
65
|
+
if (error instanceof LinearError) {
|
|
66
|
+
// @ts-ignore - response may or may not have status
|
|
67
|
+
statusCode = error.response?.status
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (statusCode === 429 || (statusCode && statusCode >= 500)) {
|
|
71
|
+
shouldRetry = true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (shouldRetry && attempt < maxRetries - 1) {
|
|
75
|
+
// Exponential backoff: 200ms, 400ms, 800ms
|
|
76
|
+
const delay = 200 * Math.pow(2, attempt)
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
throw error
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw lastError
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Fetch all pages from a paginated Linear SDK response
|
|
90
|
+
*/
|
|
91
|
+
async function fetchAllPages<T extends { id: string }>(initialPage: {
|
|
92
|
+
nodes: T[]
|
|
93
|
+
pageInfo: { hasNextPage: boolean }
|
|
94
|
+
fetchNext?: () => Promise<{ nodes: T[]; pageInfo: { hasNextPage: boolean } }>
|
|
95
|
+
}): Promise<T[]> {
|
|
96
|
+
const allItems: T[] = []
|
|
97
|
+
const seenIds = new Set<string>()
|
|
98
|
+
let currentPage = initialPage
|
|
99
|
+
|
|
100
|
+
while (true) {
|
|
101
|
+
// Add unique items
|
|
102
|
+
for (const item of currentPage.nodes) {
|
|
103
|
+
if (!seenIds.has(item.id)) {
|
|
104
|
+
seenIds.add(item.id)
|
|
105
|
+
allItems.push(item)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!currentPage.pageInfo.hasNextPage || !currentPage.fetchNext) {
|
|
110
|
+
break
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
currentPage = await currentPage.fetchNext()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return allItems
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Fetch unstarted issues from Linear
|
|
121
|
+
* @param apiKey Linear API key
|
|
122
|
+
* @param teamKey Optional team key to filter by (e.g., "ENG")
|
|
123
|
+
* @returns Array of unstarted issues
|
|
124
|
+
*/
|
|
125
|
+
export async function fetchUnstartedIssues(
|
|
126
|
+
apiKey: string,
|
|
127
|
+
teamKey?: string
|
|
128
|
+
): Promise<LinearIssue[]> {
|
|
129
|
+
try {
|
|
130
|
+
return await fetchWithRetry(async () => {
|
|
131
|
+
const client = getLinearClient(apiKey)
|
|
132
|
+
|
|
133
|
+
// Build filter for unstarted issues
|
|
134
|
+
const filter = {
|
|
135
|
+
state: { type: { eq: 'unstarted' } },
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let linearIssues: Issue[]
|
|
139
|
+
|
|
140
|
+
if (teamKey) {
|
|
141
|
+
// Fetch team first
|
|
142
|
+
const teamsConnection = await client.teams({
|
|
143
|
+
filter: { key: { eq: teamKey } },
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const team = teamsConnection.nodes[0]
|
|
147
|
+
|
|
148
|
+
if (!team) {
|
|
149
|
+
throw new LinearAPIError(`Team with key "${teamKey}" not found`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Fetch issues for the team
|
|
153
|
+
const issuesConnection = await team.issues({ filter })
|
|
154
|
+
linearIssues = await fetchAllPages(issuesConnection)
|
|
155
|
+
} else {
|
|
156
|
+
// Fetch all unstarted issues
|
|
157
|
+
const issuesConnection = await client.issues({ filter })
|
|
158
|
+
linearIssues = await fetchAllPages(issuesConnection)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const convertedIssues: LinearIssue[] = []
|
|
162
|
+
for (let i = 0; i < linearIssues.length; i++) {
|
|
163
|
+
const issue = linearIssues[i]
|
|
164
|
+
|
|
165
|
+
// Create a lazy function for attachments (only fetched when called)
|
|
166
|
+
const attachments = async () => {
|
|
167
|
+
const attachmentsConnection = await issue.attachments()
|
|
168
|
+
const linearAttachments = await fetchAllPages(attachmentsConnection)
|
|
169
|
+
|
|
170
|
+
// Convert to our attachment format
|
|
171
|
+
return linearAttachments.map((att) => ({
|
|
172
|
+
id: att.id,
|
|
173
|
+
url: att.url,
|
|
174
|
+
title: att.title ?? null,
|
|
175
|
+
sourceType: att.sourceType ?? null,
|
|
176
|
+
createdAt: att.createdAt,
|
|
177
|
+
}))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
convertedIssues.push({
|
|
181
|
+
id: issue.id,
|
|
182
|
+
identifier: issue.identifier,
|
|
183
|
+
title: issue.title,
|
|
184
|
+
description: issue.description ?? null,
|
|
185
|
+
state: issue.state,
|
|
186
|
+
url: issue.url,
|
|
187
|
+
assignee: issue.assignee,
|
|
188
|
+
createdAt: issue.createdAt,
|
|
189
|
+
updatedAt: issue.updatedAt,
|
|
190
|
+
attachments,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return convertedIssues
|
|
195
|
+
})
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (error instanceof LinearAPIError) {
|
|
198
|
+
throw error
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (error instanceof LinearError) {
|
|
202
|
+
throw new LinearAPIError(`Linear API error: ${error.message}`, error)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
throw new LinearAPIError(
|
|
206
|
+
`Failed to fetch Linear issues: ${
|
|
207
|
+
error instanceof Error ? error.message : 'Unknown error'
|
|
208
|
+
}`,
|
|
209
|
+
error
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Validate a Linear API key
|
|
216
|
+
*/
|
|
217
|
+
export async function validateLinearApiKey(apiKey: string): Promise<boolean> {
|
|
218
|
+
try {
|
|
219
|
+
const testClient = new LinearClient({ apiKey })
|
|
220
|
+
await testClient.viewer
|
|
221
|
+
return true
|
|
222
|
+
} catch {
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell session management - spawns subshells for workspaces
|
|
3
|
+
* Uses tmux-lite for session persistence and management
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn, spawnSync } from 'child_process'
|
|
7
|
+
import { logger } from '../utils/logger.js'
|
|
8
|
+
import { hasSetupBeenRun, markSetupComplete } from '../utils/workspace-state.js'
|
|
9
|
+
import { runScriptsInTerminal, type RunScriptsOptions } from '../utils/run-scripts.js'
|
|
10
|
+
import { getScriptsPhaseDir, readProjectConfig } from './config.js'
|
|
11
|
+
import { getProjectSecrets } from '../utils/secrets.js'
|
|
12
|
+
import {
|
|
13
|
+
listSessions,
|
|
14
|
+
createSession,
|
|
15
|
+
isNested,
|
|
16
|
+
type Session,
|
|
17
|
+
} from '../lib/tmux-lite/cli.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Print a message to terminal using echo (same mechanism as scripts)
|
|
21
|
+
*/
|
|
22
|
+
function printToTerminal(message: string): void {
|
|
23
|
+
spawnSync('echo', [message], { stdio: 'inherit' })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Open a workspace in an interactive subshell
|
|
28
|
+
*
|
|
29
|
+
* Flow:
|
|
30
|
+
* 1. Determine if setup or select scripts should run
|
|
31
|
+
* 2. Run the appropriate scripts in the terminal
|
|
32
|
+
* 3. Spawn an interactive subshell in the workspace directory
|
|
33
|
+
* 4. User gets control of the shell with their environment ready
|
|
34
|
+
*
|
|
35
|
+
* @param selectOnly - If true, only run select scripts (skip setup check). Used by TUI which handles setup during creation.
|
|
36
|
+
* @param sessionName - Custom name for the tmux-lite session (required for new sessions)
|
|
37
|
+
*/
|
|
38
|
+
export async function openWorkspaceShell(
|
|
39
|
+
workspacePath: string,
|
|
40
|
+
projectName: string,
|
|
41
|
+
repository: string,
|
|
42
|
+
noSetup: boolean = false,
|
|
43
|
+
selectOnly: boolean = false,
|
|
44
|
+
sessionName?: string
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const workspaceName = workspacePath.split('/').pop() || 'workspace'
|
|
47
|
+
|
|
48
|
+
// Build script options with bundle values and secrets
|
|
49
|
+
const projectConfig = readProjectConfig(projectName)
|
|
50
|
+
const scriptOptions: RunScriptsOptions = {
|
|
51
|
+
bundleValues: projectConfig.bundleValues,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Fetch secrets from OS keychain if we have secret keys
|
|
55
|
+
if (projectConfig.bundleSecretKeys && projectConfig.bundleSecretKeys.length > 0) {
|
|
56
|
+
scriptOptions.bundleSecrets = await getProjectSecrets(projectName, projectConfig.bundleSecretKeys)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (selectOnly) {
|
|
60
|
+
// TUI mode: setup was done during creation, just run select scripts
|
|
61
|
+
const selectScriptsDir = getScriptsPhaseDir(projectName, 'select')
|
|
62
|
+
await runScriptsInTerminal(
|
|
63
|
+
selectScriptsDir,
|
|
64
|
+
workspacePath,
|
|
65
|
+
workspaceName,
|
|
66
|
+
repository,
|
|
67
|
+
scriptOptions
|
|
68
|
+
)
|
|
69
|
+
} else {
|
|
70
|
+
const setupAlreadyRun = hasSetupBeenRun(workspacePath)
|
|
71
|
+
|
|
72
|
+
// Determine which scripts to run based on setup status
|
|
73
|
+
if (setupAlreadyRun) {
|
|
74
|
+
// Setup has been run before, run select scripts
|
|
75
|
+
const selectScriptsDir = getScriptsPhaseDir(projectName, 'select')
|
|
76
|
+
await runScriptsInTerminal(
|
|
77
|
+
selectScriptsDir,
|
|
78
|
+
workspacePath,
|
|
79
|
+
workspaceName,
|
|
80
|
+
repository,
|
|
81
|
+
scriptOptions
|
|
82
|
+
)
|
|
83
|
+
} else if (!noSetup) {
|
|
84
|
+
// First time setup, run setup scripts
|
|
85
|
+
printToTerminal('Running setup scripts (first time)...')
|
|
86
|
+
const setupScriptsDir = getScriptsPhaseDir(projectName, 'setup')
|
|
87
|
+
await runScriptsInTerminal(
|
|
88
|
+
setupScriptsDir,
|
|
89
|
+
workspacePath,
|
|
90
|
+
workspaceName,
|
|
91
|
+
repository,
|
|
92
|
+
scriptOptions
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// Mark setup as complete
|
|
96
|
+
markSetupComplete(workspacePath)
|
|
97
|
+
printToTerminal('✓ Setup complete')
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
printToTerminal('')
|
|
102
|
+
printToTerminal('💡 Press Ctrl+Esc to detach and return to GitSpace TUI')
|
|
103
|
+
printToTerminal('')
|
|
104
|
+
|
|
105
|
+
// Create or attach to tmux-lite session
|
|
106
|
+
await openTmuxLiteSession(workspacePath, projectName, workspaceName, sessionName)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build a full session name from components
|
|
111
|
+
*/
|
|
112
|
+
function buildSessionName(projectName: string, workspaceName: string, sessionName: string): string {
|
|
113
|
+
return `${projectName}:${workspaceName}:${sessionName}`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Open a tmux-lite session for the workspace
|
|
118
|
+
* Creates a new session or attaches to an existing one
|
|
119
|
+
* @param sessionName - Custom name for the session (required)
|
|
120
|
+
*/
|
|
121
|
+
async function openTmuxLiteSession(
|
|
122
|
+
workspacePath: string,
|
|
123
|
+
projectName: string,
|
|
124
|
+
workspaceName: string,
|
|
125
|
+
sessionName?: string
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
// Check if we're already in a tmux-lite session
|
|
128
|
+
if (isNested()) {
|
|
129
|
+
logger.error('Already inside a tmux-lite session. Detach first with Ctrl+Esc.')
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
// Build the full session name
|
|
135
|
+
if (!sessionName) {
|
|
136
|
+
throw new Error('Session name is required')
|
|
137
|
+
}
|
|
138
|
+
const fullSessionName = buildSessionName(projectName, workspaceName, sessionName)
|
|
139
|
+
|
|
140
|
+
logger.debug(`Creating tmux-lite session: ${fullSessionName}`)
|
|
141
|
+
|
|
142
|
+
// Create new session
|
|
143
|
+
const session = await createSession(fullSessionName, workspacePath)
|
|
144
|
+
|
|
145
|
+
// Spawn the CLI attach command as a subprocess with inherited stdio
|
|
146
|
+
// This works better with TUI suspension than direct attach() call
|
|
147
|
+
const cliPath = new URL('../lib/tmux-lite/cli.ts', import.meta.url).pathname
|
|
148
|
+
const proc = spawn('bun', ['run', cliPath, 'attach', session.id, '-f'], {
|
|
149
|
+
stdio: 'inherit',
|
|
150
|
+
cwd: workspacePath,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
await new Promise<void>((resolve, reject) => {
|
|
154
|
+
proc.on('exit', () => resolve())
|
|
155
|
+
proc.on('error', (err) => reject(err))
|
|
156
|
+
})
|
|
157
|
+
} catch (error) {
|
|
158
|
+
logger.error(`Failed to open tmux-lite session: ${(error as Error).message}`)
|
|
159
|
+
throw error
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trusted relay management
|
|
3
|
+
*
|
|
4
|
+
* Manages the list of relays this machine trusts for remote connections.
|
|
5
|
+
* Relays are identified by their URL and Ed25519 public key.
|
|
6
|
+
*
|
|
7
|
+
* Storage: ~/.gitspace/.identity/trusted-relays.json
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
13
|
+
import { getIdentityDir } from "./identity.js";
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A trusted relay entry
|
|
21
|
+
*/
|
|
22
|
+
export interface TrustedRelay {
|
|
23
|
+
/** Relay WebSocket URL (e.g., wss://relay.example.com) */
|
|
24
|
+
url: string;
|
|
25
|
+
/** Ed25519 signing public key (base64) */
|
|
26
|
+
publicKey: string;
|
|
27
|
+
/** Human-readable fingerprint */
|
|
28
|
+
fingerprint: string;
|
|
29
|
+
/** Optional label from relay */
|
|
30
|
+
label?: string;
|
|
31
|
+
/** When trust was established (Unix ms) */
|
|
32
|
+
trustedAt: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Result of checking relay trust status
|
|
37
|
+
*/
|
|
38
|
+
export type RelayTrustStatus = "trusted" | "mismatch" | "unknown";
|
|
39
|
+
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// Constants
|
|
42
|
+
// ============================================================================
|
|
43
|
+
|
|
44
|
+
const TRUSTED_RELAYS_FILE = "trusted-relays.json";
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// Paths
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get path to trusted relays file
|
|
52
|
+
*/
|
|
53
|
+
function getTrustedRelaysPath(): string {
|
|
54
|
+
return join(getIdentityDir(), TRUSTED_RELAYS_FILE);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// Fingerprint Computation
|
|
59
|
+
// ============================================================================
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Compute fingerprint from relay public key
|
|
63
|
+
*
|
|
64
|
+
* Format: "Kx4f:2nB9:mP3q:vR8s" (16 chars with colons)
|
|
65
|
+
*/
|
|
66
|
+
export function computeRelayFingerprint(publicKey: string): string {
|
|
67
|
+
const keyBytes = Buffer.from(publicKey, "base64");
|
|
68
|
+
const hash = sha256(keyBytes);
|
|
69
|
+
const b64url = Buffer.from(hash).toString("base64url").substring(0, 16);
|
|
70
|
+
return b64url.match(/.{1,4}/g)?.join(":") || b64url;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// URL Normalization
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Normalize a relay URL for consistent comparison
|
|
79
|
+
*
|
|
80
|
+
* Removes trailing slashes and normalizes protocol
|
|
81
|
+
*/
|
|
82
|
+
function normalizeUrl(url: string): string {
|
|
83
|
+
// Normalize the URL
|
|
84
|
+
let normalized = url.toLowerCase().trim();
|
|
85
|
+
|
|
86
|
+
// Remove trailing slashes
|
|
87
|
+
while (normalized.endsWith("/")) {
|
|
88
|
+
normalized = normalized.slice(0, -1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Normalize ws:// to wss:// for non-localhost
|
|
92
|
+
if (normalized.startsWith("ws://") && !isLocalhostUrl(normalized)) {
|
|
93
|
+
normalized = "wss://" + normalized.slice(5);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return normalized;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if URL is localhost
|
|
101
|
+
*/
|
|
102
|
+
function isLocalhostUrl(url: string): boolean {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = new URL(url);
|
|
105
|
+
const host = parsed.hostname.toLowerCase();
|
|
106
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ============================================================================
|
|
113
|
+
// Storage Operations
|
|
114
|
+
// ============================================================================
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Load trusted relays from disk
|
|
118
|
+
*/
|
|
119
|
+
export function getTrustedRelays(): TrustedRelay[] {
|
|
120
|
+
const path = getTrustedRelaysPath();
|
|
121
|
+
|
|
122
|
+
if (!existsSync(path)) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const content = readFileSync(path, "utf-8");
|
|
128
|
+
return JSON.parse(content) as TrustedRelay[];
|
|
129
|
+
} catch {
|
|
130
|
+
console.warn("[trusted-relays] Failed to parse trusted relays file");
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Save trusted relays to disk
|
|
137
|
+
*/
|
|
138
|
+
function saveTrustedRelays(relays: TrustedRelay[]): void {
|
|
139
|
+
const identityDir = getIdentityDir();
|
|
140
|
+
|
|
141
|
+
// Create directory if needed (should already exist from identity setup)
|
|
142
|
+
if (!existsSync(identityDir)) {
|
|
143
|
+
const { mkdirSync } = require("node:fs");
|
|
144
|
+
mkdirSync(identityDir, { recursive: true, mode: 0o700 });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
writeFileSync(
|
|
148
|
+
getTrustedRelaysPath(),
|
|
149
|
+
JSON.stringify(relays, null, 2),
|
|
150
|
+
{ encoding: "utf-8", mode: 0o600 }
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ============================================================================
|
|
155
|
+
// Trust Management
|
|
156
|
+
// ============================================================================
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Add a relay to the trusted list
|
|
160
|
+
*
|
|
161
|
+
* If the URL already exists, updates the public key and label.
|
|
162
|
+
*
|
|
163
|
+
* @param url - Relay WebSocket URL
|
|
164
|
+
* @param publicKey - Ed25519 public key (base64)
|
|
165
|
+
* @param label - Optional relay label
|
|
166
|
+
* @returns The created/updated entry
|
|
167
|
+
*/
|
|
168
|
+
export function addTrustedRelay(
|
|
169
|
+
url: string,
|
|
170
|
+
publicKey: string,
|
|
171
|
+
label?: string
|
|
172
|
+
): TrustedRelay {
|
|
173
|
+
const relays = getTrustedRelays();
|
|
174
|
+
const normalizedUrl = normalizeUrl(url);
|
|
175
|
+
|
|
176
|
+
// Check if already exists by URL
|
|
177
|
+
const existing = relays.find((r) => normalizeUrl(r.url) === normalizedUrl);
|
|
178
|
+
|
|
179
|
+
if (existing) {
|
|
180
|
+
// Update existing entry
|
|
181
|
+
existing.publicKey = publicKey;
|
|
182
|
+
existing.fingerprint = computeRelayFingerprint(publicKey);
|
|
183
|
+
existing.label = label ?? existing.label;
|
|
184
|
+
existing.trustedAt = Date.now();
|
|
185
|
+
saveTrustedRelays(relays);
|
|
186
|
+
return existing;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Create new entry
|
|
190
|
+
const entry: TrustedRelay = {
|
|
191
|
+
url: normalizedUrl,
|
|
192
|
+
publicKey,
|
|
193
|
+
fingerprint: computeRelayFingerprint(publicKey),
|
|
194
|
+
label,
|
|
195
|
+
trustedAt: Date.now(),
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
relays.push(entry);
|
|
199
|
+
saveTrustedRelays(relays);
|
|
200
|
+
|
|
201
|
+
return entry;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Remove a relay from the trusted list
|
|
206
|
+
*
|
|
207
|
+
* @param urlOrFingerprint - URL or fingerprint (or prefix) to match
|
|
208
|
+
* @returns The removed entry, or null if not found
|
|
209
|
+
*/
|
|
210
|
+
export function removeTrustedRelay(
|
|
211
|
+
urlOrFingerprint: string
|
|
212
|
+
): TrustedRelay | null {
|
|
213
|
+
const relays = getTrustedRelays();
|
|
214
|
+
const searchLower = urlOrFingerprint.toLowerCase();
|
|
215
|
+
|
|
216
|
+
const index = relays.findIndex((r) => {
|
|
217
|
+
const urlLower = normalizeUrl(r.url);
|
|
218
|
+
const fingerprintLower = r.fingerprint.toLowerCase();
|
|
219
|
+
const labelLower = r.label?.toLowerCase();
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
urlLower === searchLower ||
|
|
223
|
+
urlLower.includes(searchLower) ||
|
|
224
|
+
fingerprintLower === searchLower ||
|
|
225
|
+
fingerprintLower.startsWith(searchLower) ||
|
|
226
|
+
labelLower === searchLower
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (index === -1) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const [removed] = relays.splice(index, 1);
|
|
235
|
+
saveTrustedRelays(relays);
|
|
236
|
+
|
|
237
|
+
return removed;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get a trusted relay by URL
|
|
242
|
+
*
|
|
243
|
+
* @param url - Relay WebSocket URL
|
|
244
|
+
* @returns The relay entry if found, null otherwise
|
|
245
|
+
*/
|
|
246
|
+
export function getTrustedRelay(url: string): TrustedRelay | null {
|
|
247
|
+
const relays = getTrustedRelays();
|
|
248
|
+
const normalizedUrl = normalizeUrl(url);
|
|
249
|
+
|
|
250
|
+
return relays.find((r) => normalizeUrl(r.url) === normalizedUrl) ?? null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Find a trusted relay by fingerprint or label
|
|
255
|
+
*
|
|
256
|
+
* @param fingerprintOrLabel - Fingerprint (or prefix) or label to match
|
|
257
|
+
* @returns The relay entry if found, null otherwise
|
|
258
|
+
*/
|
|
259
|
+
export function findTrustedRelay(
|
|
260
|
+
fingerprintOrLabel: string
|
|
261
|
+
): TrustedRelay | null {
|
|
262
|
+
const relays = getTrustedRelays();
|
|
263
|
+
const searchLower = fingerprintOrLabel.toLowerCase();
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
relays.find((r) => {
|
|
267
|
+
const fingerprintLower = r.fingerprint.toLowerCase();
|
|
268
|
+
const labelLower = r.label?.toLowerCase();
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
fingerprintLower === searchLower ||
|
|
272
|
+
fingerprintLower.startsWith(searchLower) ||
|
|
273
|
+
labelLower === searchLower
|
|
274
|
+
);
|
|
275
|
+
}) ?? null
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Check if a relay is trusted
|
|
281
|
+
*
|
|
282
|
+
* @param url - Relay WebSocket URL
|
|
283
|
+
* @param publicKey - Ed25519 public key (base64) from relay
|
|
284
|
+
* @returns Trust status: 'trusted', 'mismatch', or 'unknown'
|
|
285
|
+
*/
|
|
286
|
+
export function isRelayTrusted(
|
|
287
|
+
url: string,
|
|
288
|
+
publicKey: string
|
|
289
|
+
): RelayTrustStatus {
|
|
290
|
+
// Localhost is always auto-trusted
|
|
291
|
+
if (isLocalhostUrl(url)) {
|
|
292
|
+
return "trusted";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const trustedRelay = getTrustedRelay(url);
|
|
296
|
+
|
|
297
|
+
if (!trustedRelay) {
|
|
298
|
+
return "unknown";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Check if public key matches
|
|
302
|
+
if (trustedRelay.publicKey === publicKey) {
|
|
303
|
+
return "trusted";
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// URL known but key doesn't match - SECURITY WARNING
|
|
307
|
+
return "mismatch";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Check if a URL is localhost (for auto-trust)
|
|
312
|
+
*/
|
|
313
|
+
export function isLocalhost(url: string): boolean {
|
|
314
|
+
return isLocalhostUrl(url);
|
|
315
|
+
}
|