From 8efe1581b09b9e0472bdce94f950c42d58323ff9 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Fri, 10 Oct 2025 11:38:11 -0500 Subject: [PATCH 1/6] move routes and pages into shadcn so we can distribute them swap to symlinks for the docs api and pages so we can distribute them get the registry with the new pages and files get deps right for page installs too swap to importable class for github callback endpoint stub out a class for handling the success callback in a few lines --- .../api/code-storage/github/callback/route.ts | 71 --------- apps/docs/app/code-storage | 1 + apps/docs/public/r/git-platform-sync.json | 42 ++++- apps/docs/public/r/registry.json | 27 +++- apps/docs/registry.json | 32 +++- bun.lock | 3 - package.json | 3 - .../api/code-storage/github/callback/route.ts | 14 ++ .../github/callback/success-callback.ts | 150 ++++++++++++++++++ .../github/installations/route.ts | 0 .../api/code-storage/repo/route.ts | 0 .../pages}/code-storage/success/page.tsx | 0 12 files changed, 253 insertions(+), 90 deletions(-) delete mode 100644 apps/docs/app/api/code-storage/github/callback/route.ts create mode 120000 apps/docs/app/code-storage create mode 100644 packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/route.ts create mode 100644 packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/success-callback.ts rename {apps/docs/app => packages/sync-ui-shadcn/blocks/git-platform-sync}/api/code-storage/github/installations/route.ts (100%) rename {apps/docs/app => packages/sync-ui-shadcn/blocks/git-platform-sync}/api/code-storage/repo/route.ts (100%) rename {apps/docs/app => packages/sync-ui-shadcn/blocks/git-platform-sync/pages}/code-storage/success/page.tsx (100%) diff --git a/apps/docs/app/api/code-storage/github/callback/route.ts b/apps/docs/app/api/code-storage/github/callback/route.ts deleted file mode 100644 index 46a256b95..000000000 --- a/apps/docs/app/api/code-storage/github/callback/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server'; - -export async function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams; - const code = searchParams.get('code'); - const installationId = searchParams.get('installation_id'); - const setupAction = searchParams.get('setup_action'); - const state = searchParams.get('state'); - - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (!code) { - return NextResponse.json({ error: 'No code provided' }, { status: 400 }); - } - - try { - const tokenResponse = await fetch( - 'https://github.com/login/oauth/access_token', - { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - client_id: process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID, - client_secret: process.env.GITHUB_CLIENT_SECRET, - code, - }), - } - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const tokenData: any = await tokenResponse.json(); - - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (tokenData.error) { - throw new Error(tokenData.error_description ?? tokenData.error); - } - - const successUrl = new URL('/code-storage/success', request.url); - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (setupAction) { - successUrl.searchParams.set('setup_action', setupAction); - } - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (installationId) { - successUrl.searchParams.set('installation_id', installationId); - } - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (state) { - successUrl.searchParams.set('state', state); - } - const response = NextResponse.redirect(successUrl); - - // Store only the user's OAuth token - it works across all installations - response.cookies.set('github_token', tokenData.access_token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 30, - }); - - return response; - } catch (error) { - console.error('OAuth error:', error); - return NextResponse.json( - { error: 'Failed to exchange code for token' }, - { status: 500 } - ); - } -} diff --git a/apps/docs/app/code-storage b/apps/docs/app/code-storage new file mode 120000 index 000000000..6298ddb19 --- /dev/null +++ b/apps/docs/app/code-storage @@ -0,0 +1 @@ +../../../packages/sync-ui-shadcn/blocks/git-platform-sync/pages/code-storage \ No newline at end of file diff --git a/apps/docs/public/r/git-platform-sync.json b/apps/docs/public/r/git-platform-sync.json index ffad050c9..2b2796c6e 100644 --- a/apps/docs/public/r/git-platform-sync.json +++ b/apps/docs/public/r/git-platform-sync.json @@ -1,9 +1,13 @@ { "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "git-platform-sync", - "type": "registry:block", + "type": "registry:component", "title": "Git Platform Sync", "description": "A component that implements repo syncing between code.storage and git platforms like GitHub.", + "dependencies": [ + "@octokit/openapi-types", + "@pierre/storage" + ], "registryDependencies": [ "button", "command", @@ -16,12 +20,12 @@ "files": [ { "path": "registry/new-york/blocks/git-platform-sync/git-platform-sync.tsx", - "content": "import { Button } from '@/components/ui/button';\nimport { Field, FieldLabel } from '@/components/ui/field';\nimport { Input } from '@/components/ui/input';\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from '@/components/ui/popover';\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from '@/components/ui/tooltip';\nimport { cn } from '@/lib/utils';\nimport type * as PopoverPrimitive from '@radix-ui/react-popover';\nimport { AlertCircle, BookOpen, ChevronDown, Loader2 } from 'lucide-react';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\n\nimport { ComboBox } from './combobox';\nimport {\n type GitHubConnectionStatus,\n useGitHubAppConnection,\n} from './github/github-app-connect';\nimport { GitHubIcon } from './github/icon';\nimport { generateOwnerOptions, useOwners } from './github/owners';\n\n// TODO: determine if this is the canonical way to import other components inside of a block\n\nexport type Step = 'welcome' | 'create' | 'manage';\n\nexport type RepositoryData = {\n /**\n * @description The owner of the repository, also referred to as the 'scope' - usually\n * the username of the user or an organization they belong to.\n */\n owner?: string;\n /**\n * @description The name of the repository, this is a 'slug' style name.\n */\n name?: string;\n /**\n * @description The branch of the repository, this is the branch that will be used to sync\n */\n branch?: string;\n};\n\nexport type SyncedRepo = {\n url: string;\n repository: {\n owner: string;\n name: string;\n defaultBranch: string;\n };\n};\n\nexport type GitPlatformSyncStatus =\n | 'disconnected'\n | 'connected'\n | 'connected-syncing'\n | 'connected-warning';\n\n/**\n * @description Platforms that code.storage supports\n */\nexport type SupportedGitPlatform = 'github';\nexport type PlatformConfig_GitHub = {\n platform: 'github';\n slug: string;\n redirectUrl?: string;\n};\n// Currently only github configs are allowed, but in the future more\n// may be supported\nexport type PlatformConfigObject = PlatformConfig_GitHub;\n\nexport type GitPlatformSyncProps = {\n /**\n * @description List of supported platforms that you want to offer to the user. We recommend\n * not setting this until we support more platforms.\n */\n platforms: PlatformConfigObject[];\n\n /**\n * @default 'icon-only'\n * @description Variant display of the button that opens the sync popover. The\n * `icon-grow` variant will appear as the `icon` variant until hovered or focused,\n * and then grow to appear as the `full` variant.\n */\n variant?: 'icon-only' | 'icon-grow' | 'full';\n\n /**\n * @default true\n * @description Whether to show the sync indicator in the button, e.g. the little colored dot\n */\n showSyncIndicator?: boolean;\n\n /**\n * @default 'end'\n * @description The alignment of the popover content\n */\n align?: React.ComponentProps['align'];\n\n /**\n * @default 'auto'\n * @description This controls the status dot that appears in the button. If `auto` is set, then\n * the component will determine either `disconnected` or `connected`. However, an implementor may\n * choose to override this as `connected-syncing` or `connected-warning`. Note that the component\n * will not verify that the status is valid, it will faithfully render the status you provide.\n */\n status?: 'auto' | GitPlatformSyncStatus;\n\n /**\n * @description Control the open state of the popover\n */\n open?: boolean;\n\n /**\n * @description This directly sets the name of the repository that will be created. Setting this\n * will take precedence over the `defaultName` option. Users will not be able to change from this\n * name.\n */\n repoName?: string;\n\n /**\n * @description If you'd like to suggest a name for the repository, but allow the user to customize it,\n * this is the initial value of the repository name field. This will be ignored if the `name` option is\n * set.\n */\n repoDefaultName?: string;\n\n /**\n * @default 'unique-repo-name…'\n * @description The placeholder text for the repository name field. This will be invisible if the `name`\n * or `defaultName` option is set, but if the user erases the defaultName it will be shown.\n */\n repoNamePlaceholder?: string;\n\n /**\n * @default 'main'\n * @description The branch that will be used to sync within the repository. `main` is used if this is not\n * provided.\n */\n repoDefaultBranch?: string;\n\n /**\n * @description Callback for controlled usage of the the repository name input. Fires\n * the same as the 'onChange' event of the input.\n */\n onRepoNameChange?: (repoName: string) => void;\n\n /**\n * @description callback for the change event of the repo owner combobox. Since this\n * input cannot be controlled, this callback is just for being informed of changes.\n */\n onOwnerChange?: (owner: string) => void;\n\n /**\n * @description Callback when a user clicks the 'Create Repository' button with valid\n * data selected. This is where you will hook into the the code storage endpoints in your app.\n */\n onRepoCreateAction?: (repoData: RepositoryData) => void;\n\n /**\n *\n * @description Callback when the user clicks the 'Help me get started' button. If this callback is\n * not provided, the 'Help me get started' button will not be shown.\n */\n onHelpAction?: () => void;\n\n /**\n * @description Callback when the popover is opened.\n */\n onOpenChange?: (isOpen: boolean) => void;\n\n /**\n * @description The repository that has been synced to code.storage. This is used to\n * display the repository information in the popover. If this is provided, the user will\n * immediately see the syncing page, rather than the welcome or connection page.\n * Set this as a result of the `onRepoCreateAction` callback.\n */\n codeStorageRepo?: SyncedRepo;\n\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n};\n\nexport function GitPlatformSync({\n platforms,\n variant = 'icon-only',\n align = 'end',\n status: statusProp = 'auto',\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch = 'main',\n onHelpAction,\n onRepoCreateAction,\n onOpenChange,\n onRepoNameChange,\n onOwnerChange,\n codeStorageRepo,\n open,\n __container,\n}: GitPlatformSyncProps) {\n const [isPopoverOpen, setIsPopoverOpen] = useState(open ?? false);\n const [isTooltipOpen, setIsTooltipOpen] = useState(false);\n const codeStorageRepoExists = codeStorageRepo != null;\n\n const status = useMemo(() => {\n if (statusProp === 'auto') {\n if (codeStorageRepoExists) {\n return 'connected';\n } else {\n return 'disconnected';\n }\n }\n return statusProp;\n }, [statusProp, codeStorageRepoExists]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any =\n __container != null ? { container: __container } : {};\n\n let platformName: string | undefined;\n let platformConfig: PlatformConfigObject | undefined;\n\n const handleOpenChange = useCallback(\n (isOpen: boolean) => {\n setIsPopoverOpen(isOpen);\n if (isOpen) {\n onOpenChange?.(true);\n } else {\n onOpenChange?.(false);\n }\n },\n [onOpenChange]\n );\n\n if (platforms.length === 0) {\n throw new Error('No platforms provided to GitPlatformSync');\n }\n\n if (platforms.length === 1 && platforms[0].platform === 'github') {\n platformName = 'GitHub';\n platformConfig = platforms[0];\n } else {\n throw new Error(\n 'Currently GitPlatformSync only supports a single GitHub platform'\n );\n }\n\n const {\n handleConnect,\n status: connectionStatus,\n fetchInstallationStatus,\n destroy: destroyGitHubAppConnection,\n } = useGitHubAppConnection({\n slug: platformConfig.slug,\n redirectUrl: platformConfig.redirectUrl,\n });\n\n useEffect(() => {\n void fetchInstallationStatus();\n }, [fetchInstallationStatus]);\n\n useEffect(() => {\n return () => {\n destroyGitHubAppConnection();\n };\n }, [destroyGitHubAppConnection]);\n\n const labelText = `Sync to ${platformName}`;\n\n // If we don't have any label inside the button we should set an aria-label\n // that describes what that button does.\n const buttonAriaLabelProp =\n variant === 'icon-only'\n ? {\n 'aria-label': labelText,\n }\n : {};\n\n const PopoverConductorProps: PopoverConductorProps = {\n align,\n __container,\n onHelpAction,\n onRepoCreateAction,\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch,\n onRepoNameChange,\n onOwnerChange,\n handleConnect,\n connectionStatus,\n codeStorageRepo,\n };\n\n // TODO: fix full button, and disable tooltip on open popover\n return (\n \n {variant === 'icon-only' ? (\n <>\n \n \n \n \n \n \n {labelText}\n \n \n \n ) : (\n <>\n \n \n \n {labelText}\n \n \n \n \n \n \n )}\n \n );\n}\n\nfunction LilDotGuy({ status }: { status?: GitPlatformSyncStatus }) {\n if (status == null || status === 'disconnected') {\n return null;\n }\n return (\n \n );\n}\n\nfunction BaseSyncButton({\n children,\n className,\n status,\n ...props\n}: React.ComponentProps & {\n status?: GitPlatformSyncStatus;\n}) {\n return (\n \n \n \n \n \n {children}\n \n );\n}\n\ntype PopoverConductorProps = Pick<\n GitPlatformSyncProps,\n | 'align'\n | '__container'\n | 'onHelpAction'\n | 'onRepoCreateAction'\n | 'repoName'\n | 'repoDefaultName'\n | 'repoNamePlaceholder'\n | 'repoDefaultBranch'\n | 'onRepoNameChange'\n | 'onOwnerChange'\n | 'codeStorageRepo'\n> & {\n handleConnect: ({ onSuccess }: { onSuccess?: () => void }) => void;\n connectionStatus: GitHubConnectionStatus;\n};\n\nfunction PopoverConductor({\n align,\n __container,\n onHelpAction,\n onRepoCreateAction,\n onRepoNameChange,\n onOwnerChange,\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch,\n handleConnect,\n codeStorageRepo,\n connectionStatus,\n}: PopoverConductorProps) {\n let initialStep: Step = 'welcome';\n if (codeStorageRepo != null) {\n initialStep = 'manage';\n } else if (connectionStatus === 'installed') {\n initialStep = 'create';\n }\n\n const [step, setStep] = useState(initialStep);\n\n useEffect(() => {\n if (codeStorageRepo != null) {\n setStep('manage');\n } else if (connectionStatus === 'installed') {\n setStep('create');\n }\n }, [codeStorageRepo, connectionStatus]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any =\n __container != null ? { container: __container } : {};\n\n return (\n \n {step === 'welcome' ? (\n setStep('create')}\n onHelpAction={onHelpAction}\n handleConnect={handleConnect}\n connectionStatus={connectionStatus}\n />\n ) : null}\n {step === 'create' ? (\n \n ) : null}\n {step === 'manage' ? (\n \n ) : null}\n \n );\n}\n\ntype StepManageProps = {\n codeStorageRepo: SyncedRepo;\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n};\n\nfunction StatusDot({ status }: { status: GitPlatformSyncStatus }) {\n return (\n \n );\n}\n\nfunction StepManage({ codeStorageRepo, __container }: StepManageProps) {\n const { owners, getOwnerByName } = useOwners();\n const [selectedOwnerId, setSelectedOwnerId] = useState(null);\n\n // TODO: This is inelegant. Since it'll necessarily need an extra render pass. We could move\n // to an uncontrolled combobox and compute the value in the single pass, but idk.\n useEffect(() => {\n if (owners.length > 0) {\n const owner = getOwnerByName(codeStorageRepo.repository.owner);\n console.log(\n 'found owner by name',\n codeStorageRepo.repository.owner,\n owner\n );\n setSelectedOwnerId(owner?.id ?? null);\n }\n }, [owners, codeStorageRepo.repository.owner, getOwnerByName]);\n\n const ownerOptions = useMemo(() => {\n return generateOwnerOptions(owners);\n }, [owners]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any =\n __container != null ? { __container: __container } : {};\n return (\n
\n
\n \n Connected to GitHub\n
\n
\n
\n \n \n Owner\n \n \n \n \n /\n
\n \n \n Repository\n \n \n \n
\n
\n \n \n Active Branch\n \n \n \n
\n
\n Changes are being automatically synced to this branch.\n
\n
\n \n );\n}\n\ntype StepCreateProps = {\n onOwnerChange?: (owner: string) => void;\n onRepoNameChange?: (repoName: string) => void;\n // onBranchChange?: (branch: string) => void;\n connectionStatus: GitHubConnectionStatus;\n onRepoCreateAction?: (repoData: RepositoryData) => void;\n handleConnect: ({ onSuccess }: { onSuccess?: () => void }) => void;\n repoName?: string;\n repoDefaultName?: string;\n repoNamePlaceholder?: string;\n repoDefaultBranch?: string;\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n};\n\nfunction StepCreate({\n onOwnerChange,\n onRepoNameChange,\n onRepoCreateAction,\n handleConnect,\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch,\n __container,\n}: StepCreateProps) {\n const { owners, status, getOwnerById, refresh } = useOwners();\n const [selectedOwnerId, setSelectedOwnerId] = useState(null);\n\n // TODO: This is inelegant. Since it'll necessarily need an extra render pass. We could move\n // to an uncontrolled combobox and compute the value in the single pass, but idk.\n useEffect(() => {\n if (owners.length > 0) {\n setSelectedOwnerId(owners[0]?.id ?? null);\n }\n }, [owners]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any =\n __container != null ? { __container: __container } : {};\n\n const repoInputProps: React.ComponentProps = useMemo(() => {\n const rip: React.ComponentProps = {};\n if ((repoName ?? '').trim() !== '') {\n rip.defaultValue = repoName;\n } else if ((repoDefaultName ?? '').trim() !== '') {\n rip.defaultValue = repoDefaultName;\n }\n\n const defaultPlaceholder = 'unique-repo-name…';\n if ((repoNamePlaceholder ?? '').trim() !== '') {\n rip.placeholder = repoNamePlaceholder ?? defaultPlaceholder;\n } else {\n rip.placeholder = defaultPlaceholder;\n }\n return rip;\n }, [repoName, repoDefaultName, repoNamePlaceholder]);\n\n const handleSubmit = useCallback(\n (e: React.FormEvent) => {\n e.preventDefault();\n const formData = new FormData(e.currentTarget);\n const repoName = formData.get('repo-name') as string;\n // TODO: show errors in UI\n if (selectedOwnerId == null || selectedOwnerId.trim() === '') {\n console.warn('no selectedOwnerId');\n return;\n }\n\n const owner = getOwnerById(selectedOwnerId);\n\n if (owner == null) {\n console.warn('no owner found for selectedOwnerId', selectedOwnerId);\n return;\n }\n\n const ownerLogin = owner.login;\n\n if (ownerLogin == null || ownerLogin.trim() === '') {\n console.warn(\n 'no ownerLogin found for selectedOwnerId',\n selectedOwnerId,\n owner\n );\n return;\n }\n\n if (onRepoCreateAction == null) {\n console.warn('no onRepoCreateAction provided');\n return;\n }\n\n onRepoCreateAction({\n owner: ownerLogin,\n name: repoName,\n branch: repoDefaultBranch,\n });\n },\n [selectedOwnerId, getOwnerById, onRepoCreateAction, repoDefaultBranch]\n );\n\n const ownerOptions = useMemo(() => {\n return generateOwnerOptions(owners);\n }, [owners]);\n\n return (\n
\n
\n

Sync to GitHub

\n

\n Create a new repository or choose an existing one to sync your\n changes. We'll push changes with each new prompt you send.\n

\n
\n {status === 'loading' ? (\n
\n \n
\n ) : null}\n {status === 'error' ? (\n
\n \n

\n Error loading GitHub accounts. Please try again.\n

\n
\n ) : null}\n {status === 'success' && owners.length === 0 ? (\n
\n

\n No GitHub accounts found. Please check the app permissions in your\n GitHub settings.\n

\n
\n ) : null}\n {status === 'success' && owners.length > 0 ? (\n
\n
\n
\n \n \n Owner\n \n {\n console.log('owner value changed', value);\n setSelectedOwnerId(value);\n onOwnerChange?.(value);\n }}\n onAddItem={() => {\n handleConnect({\n onSuccess: () => {\n refresh();\n },\n });\n }}\n addItemLabel=\"Add GitHub account…\"\n options={ownerOptions}\n />\n \n \n /\n
\n \n \n Repository\n \n onRepoNameChange?.(e.target.value)}\n />\n \n
\n \n
\n \n ) : null}\n \n );\n}\n\ntype StepWelcomeProps = {\n onAppInstalled?: () => void;\n onHelpAction?: () => void;\n handleConnect: ({ onSuccess }: { onSuccess?: () => void }) => void;\n connectionStatus: GitHubConnectionStatus;\n};\n\nfunction StepWelcome({\n onAppInstalled,\n onHelpAction,\n connectionStatus,\n handleConnect,\n}: StepWelcomeProps) {\n const isPendingConnection = connectionStatus === 'pending';\n const hasError = connectionStatus === 'error';\n\n // TODO: remove this\n if (connectionStatus === 'installed') {\n console.error(\n 'welcome step rendered with installed status, which shouldnt happen'\n );\n }\n\n return (\n <>\n
\n
\n

Connect to GitHub

\n

\n Sync your changes to GitHub to backup your code at every snapshot by\n installing our app on your personal account or organization.\n

\n {hasError ? (\n

\n There was an error connecting to GitHub. Please try again.\n

\n ) : null}\n
\n
\n {\n handleConnect({\n onSuccess: onAppInstalled,\n });\n }\n }\n size=\"lg\"\n className={cn(\n 'w-full',\n isPendingConnection && 'opacity-80 pointer-events-none'\n )}\n >\n {' '}\n {isPendingConnection\n ? 'Connecting to GitHub…'\n : 'Install GitHub App'}\n \n {onHelpAction != null ? (\n \n Help me get started\n \n ) : null}\n
\n
\n \n );\n}\n", + "content": "import { Button } from '@/components/ui/button';\nimport { Field, FieldLabel } from '@/components/ui/field';\nimport { Input } from '@/components/ui/input';\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from '@/components/ui/popover';\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from '@/components/ui/tooltip';\nimport { cn } from '@/lib/utils';\nimport * as PopoverPrimitive from '@radix-ui/react-popover';\nimport { AlertCircle, BookOpen, ChevronDown, Loader2 } from 'lucide-react';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\n\nimport { ComboBox } from './combobox';\nimport {\n type GitHubConnectionStatus,\n useGitHubAppConnection,\n} from './github/github-app-connect';\nimport { GitHubIcon } from './github/icon';\nimport { generateOwnerOptions, useOwners } from './github/owners';\n\n// TODO: determine if this is the canonical way to import other components inside of a block\n\nexport type Step = 'welcome' | 'create' | 'manage';\n\nexport type RepositoryData = {\n /**\n * @description The owner of the repository, also referred to as the 'scope' - usually\n * the username of the user or an organization they belong to.\n */\n owner?: string;\n /**\n * @description The name of the repository, this is a 'slug' style name.\n */\n name?: string;\n /**\n * @description The branch of the repository, this is the branch that will be used to sync\n */\n branch?: string;\n};\n\nexport type SyncedRepo = {\n url: string;\n repository: {\n owner: string;\n name: string;\n defaultBranch: string;\n };\n};\n\nexport type GitPlatformSyncStatus =\n | 'disconnected'\n | 'connected'\n | 'connected-syncing'\n | 'connected-warning';\n\n/**\n * @description Platforms that code.storage supports\n */\nexport type SupportedGitPlatform = 'github';\nexport type PlatformConfig_GitHub = {\n platform: 'github';\n slug: string;\n redirectUrl?: string;\n};\n// Currently only github configs are allowed, but in the future more\n// may be supported\nexport type PlatformConfigObject = PlatformConfig_GitHub;\n\nexport type GitPlatformSyncProps = {\n /**\n * @description List of supported platforms that you want to offer to the user. We recommend\n * not setting this until we support more platforms.\n */\n platforms: PlatformConfigObject[];\n\n /**\n * @default 'icon-only'\n * @description Variant display of the button that opens the sync popover. The\n * `icon-grow` variant will appear as the `icon` variant until hovered or focused,\n * and then grow to appear as the `full` variant.\n */\n variant?: 'icon-only' | 'icon-grow' | 'full';\n\n /**\n * @default true\n * @description Whether to show the sync indicator in the button, e.g. the little colored dot\n */\n showSyncIndicator?: boolean;\n\n /**\n * @default 'end'\n * @description The alignment of the popover content\n */\n align?: React.ComponentProps['align'];\n\n /**\n * @default 'auto'\n * @description This controls the status dot that appears in the button. If `auto` is set, then\n * the component will determine either `disconnected` or `connected`. However, an implementor may\n * choose to override this as `connected-syncing` or `connected-warning`. Note that the component\n * will not verify that the status is valid, it will faithfully render the status you provide.\n */\n status?: 'auto' | GitPlatformSyncStatus;\n\n /**\n * @description Control the open state of the popover\n */\n open?: boolean;\n\n /**\n * @description This directly sets the name of the repository that will be created. Setting this\n * will take precedence over the `defaultName` option. Users will not be able to change from this\n * name.\n */\n repoName?: string;\n\n /**\n * @description If you'd like to suggest a name for the repository, but allow the user to customize it,\n * this is the initial value of the repository name field. This will be ignored if the `name` option is\n * set.\n */\n repoDefaultName?: string;\n\n /**\n * @default 'unique-repo-name…'\n * @description The placeholder text for the repository name field. This will be invisible if the `name`\n * or `defaultName` option is set, but if the user erases the defaultName it will be shown.\n */\n repoNamePlaceholder?: string;\n\n /**\n * @default 'main'\n * @description The branch that will be used to sync within the repository. `main` is used if this is not\n * provided.\n */\n repoDefaultBranch?: string;\n\n /**\n * @description Callback for controlled usage of the the repository name input. Fires\n * the same as the 'onChange' event of the input.\n */\n onRepoNameChange?: (repoName: string) => void;\n\n /**\n * @description callback for the change event of the repo owner combobox. Since this\n * input cannot be controlled, this callback is just for being informed of changes.\n */\n onOwnerChange?: (owner: string) => void;\n\n /**\n * @description Callback when a user clicks the 'Create Repository' button with valid\n * data selected. This is where you will hook into the the code storage endpoints in your app.\n */\n onRepoCreateAction?: (repoData: RepositoryData) => void;\n\n /**\n *\n * @description Callback when the user clicks the 'Help me get started' button. If this callback is\n * not provided, the 'Help me get started' button will not be shown.\n */\n onHelpAction?: () => void;\n\n /**\n * @description Callback when the popover is opened.\n */\n onOpenChange?: (isOpen: boolean) => void;\n\n /**\n * @description The repository that has been synced to code.storage. This is used to\n * display the repository information in the popover. If this is provided, the user will\n * immediately see the syncing page, rather than the welcome or connection page.\n * Set this as a result of the `onRepoCreateAction` callback.\n */\n codeStorageRepo?: SyncedRepo;\n\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n};\n\nexport function GitPlatformSync({\n platforms,\n variant = 'icon-only',\n align = 'end',\n status: statusProp = 'auto',\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch = 'main',\n onHelpAction,\n onRepoCreateAction,\n onOpenChange,\n onRepoNameChange,\n onOwnerChange,\n codeStorageRepo,\n open,\n __container,\n}: GitPlatformSyncProps) {\n const [isPopoverOpen, setIsPopoverOpen] = useState(open ?? false);\n const [isTooltipOpen, setIsTooltipOpen] = useState(false);\n const codeStorageRepoExists = !!codeStorageRepo;\n\n const status = useMemo(() => {\n if (statusProp === 'auto') {\n if (codeStorageRepoExists) {\n return 'connected';\n } else {\n return 'disconnected';\n }\n }\n return statusProp;\n }, [statusProp, codeStorageRepoExists]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any = __container ? { container: __container } : {};\n\n let platformName: string | undefined;\n let platformConfig: PlatformConfigObject | undefined;\n\n const handleOpenChange = useCallback(\n (isOpen: boolean) => {\n setIsPopoverOpen(isOpen);\n if (isOpen) {\n onOpenChange?.(true);\n } else {\n onOpenChange?.(false);\n }\n },\n [onOpenChange]\n );\n\n if (platforms.length === 0) {\n throw new Error('No platforms provided to GitPlatformSync');\n }\n\n if (platforms.length === 1 && platforms[0].platform === 'github') {\n platformName = 'GitHub';\n platformConfig = platforms[0];\n } else {\n throw new Error(\n 'Currently GitPlatformSync only supports a single GitHub platform'\n );\n }\n\n const {\n handleConnect,\n status: connectionStatus,\n fetchInstallationStatus,\n destroy: destroyGitHubAppConnection,\n } = useGitHubAppConnection({\n slug: platformConfig.slug,\n redirectUrl: platformConfig.redirectUrl,\n });\n\n useEffect(() => {\n fetchInstallationStatus();\n }, [fetchInstallationStatus]);\n\n useEffect(() => {\n return () => {\n destroyGitHubAppConnection();\n };\n }, [destroyGitHubAppConnection]);\n\n const labelText = `Sync to ${platformName}`;\n\n // If we don't have any label inside the button we should set an aria-label\n // that describes what that button does.\n const buttonAriaLabelProp =\n variant === 'icon-only'\n ? {\n 'aria-label': labelText,\n }\n : {};\n\n const PopoverConductorProps: PopoverConductorProps = {\n align,\n __container,\n onHelpAction,\n onRepoCreateAction,\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch,\n onRepoNameChange,\n onOwnerChange,\n handleConnect,\n connectionStatus,\n codeStorageRepo,\n };\n\n // TODO: fix full button, and disable tooltip on open popover\n return (\n \n {variant === 'icon-only' ? (\n <>\n \n \n \n \n \n \n {labelText}\n \n \n \n ) : (\n <>\n \n \n \n {labelText}\n \n \n \n \n \n \n )}\n \n );\n}\n\nfunction LilDotGuy({ status }: { status?: GitPlatformSyncStatus }) {\n if (!status || status === 'disconnected') {\n return null;\n }\n return (\n \n );\n}\n\nfunction BaseSyncButton({\n children,\n className,\n status,\n ...props\n}: React.ComponentProps & {\n status?: GitPlatformSyncStatus;\n}) {\n return (\n \n \n \n \n \n {children}\n \n );\n}\n\ntype PopoverConductorProps = Pick<\n GitPlatformSyncProps,\n | 'align'\n | '__container'\n | 'onHelpAction'\n | 'onRepoCreateAction'\n | 'repoName'\n | 'repoDefaultName'\n | 'repoNamePlaceholder'\n | 'repoDefaultBranch'\n | 'onRepoNameChange'\n | 'onOwnerChange'\n | 'codeStorageRepo'\n> & {\n handleConnect: ({ onSuccess }: { onSuccess?: () => void }) => void;\n connectionStatus: GitHubConnectionStatus;\n};\n\nfunction PopoverConductor({\n align,\n __container,\n onHelpAction,\n onRepoCreateAction,\n onRepoNameChange,\n onOwnerChange,\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch,\n handleConnect,\n codeStorageRepo,\n connectionStatus,\n}: PopoverConductorProps) {\n let initialStep: Step = 'welcome';\n if (codeStorageRepo) {\n initialStep = 'manage';\n } else if (connectionStatus === 'installed') {\n initialStep = 'create';\n }\n\n const [step, setStep] = useState(initialStep);\n\n useEffect(() => {\n if (codeStorageRepo) {\n setStep('manage');\n } else if (connectionStatus === 'installed') {\n setStep('create');\n }\n }, [codeStorageRepo, connectionStatus]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any = __container ? { container: __container } : {};\n\n return (\n \n {step === 'welcome' ? (\n setStep('create')}\n onHelpAction={onHelpAction}\n handleConnect={handleConnect}\n connectionStatus={connectionStatus}\n />\n ) : null}\n {step === 'create' ? (\n \n ) : null}\n {step === 'manage' ? (\n \n ) : null}\n \n );\n}\n\ntype StepManageProps = {\n codeStorageRepo: SyncedRepo;\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n};\n\nfunction StatusDot({ status }: { status: GitPlatformSyncStatus }) {\n return (\n \n );\n}\n\nfunction StepManage({ codeStorageRepo, __container }: StepManageProps) {\n const { owners, getOwnerByName } = useOwners();\n const [selectedOwnerId, setSelectedOwnerId] = useState(null);\n\n // TODO: This is inelegant. Since it'll necessarily need an extra render pass. We could move\n // to an uncontrolled combobox and compute the value in the single pass, but idk.\n useEffect(() => {\n if (owners.length > 0) {\n const owner = getOwnerByName(codeStorageRepo.repository.owner);\n console.log(\n 'found owner by name',\n codeStorageRepo.repository.owner,\n owner\n );\n setSelectedOwnerId(owner?.id ?? null);\n }\n }, [owners, codeStorageRepo.repository.owner, getOwnerByName]);\n\n const ownerOptions = useMemo(() => {\n return generateOwnerOptions(owners);\n }, [owners]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any = __container ? { __container: __container } : {};\n return (\n
\n
\n \n Connected to GitHub\n
\n
\n
\n \n \n Owner\n \n \n \n \n /\n
\n \n \n Repository\n \n \n \n
\n
\n \n \n Active Branch\n \n \n \n
\n
\n Changes are being automatically synced to this branch.\n
\n
\n \n );\n}\n\ntype StepCreateProps = {\n onOwnerChange?: (owner: string) => void;\n onRepoNameChange?: (repoName: string) => void;\n // onBranchChange?: (branch: string) => void;\n connectionStatus: GitHubConnectionStatus;\n onRepoCreateAction?: (repoData: RepositoryData) => void;\n handleConnect: ({ onSuccess }: { onSuccess?: () => void }) => void;\n repoName?: string;\n repoDefaultName?: string;\n repoNamePlaceholder?: string;\n repoDefaultBranch?: string;\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n};\n\nfunction StepCreate({\n onOwnerChange,\n onRepoNameChange,\n onRepoCreateAction,\n handleConnect,\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch,\n __container,\n}: StepCreateProps) {\n const { owners, status, getOwnerById, refresh } = useOwners();\n const [selectedOwnerId, setSelectedOwnerId] = useState(null);\n\n // TODO: This is inelegant. Since it'll necessarily need an extra render pass. We could move\n // to an uncontrolled combobox and compute the value in the single pass, but idk.\n useEffect(() => {\n if (owners.length > 0) {\n setSelectedOwnerId(owners[0]?.id ?? null);\n }\n }, [owners]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any = __container ? { __container: __container } : {};\n\n const repoInputProps: React.ComponentProps = useMemo(() => {\n const rip: React.ComponentProps = {};\n if (repoName) {\n rip.defaultValue = repoName;\n } else if (repoDefaultName) {\n rip.defaultValue = repoDefaultName;\n }\n\n const defaultPlaceholder = 'unique-repo-name…';\n if (repoNamePlaceholder) {\n rip.placeholder = repoNamePlaceholder ?? defaultPlaceholder;\n } else {\n rip.placeholder = defaultPlaceholder;\n }\n return rip;\n }, [repoName, repoDefaultName, repoNamePlaceholder]);\n\n const handleSubmit = useCallback(\n (e: React.FormEvent) => {\n e.preventDefault();\n const formData = new FormData(e.currentTarget);\n const repoName = formData.get('repo-name') as string;\n // TODO: show errors in UI\n if (!selectedOwnerId) {\n console.warn('no selectedOwnerId');\n return;\n }\n\n const owner = getOwnerById(selectedOwnerId);\n\n if (!owner) {\n console.warn('no owner found for selectedOwnerId', selectedOwnerId);\n return;\n }\n\n const ownerLogin = owner.login;\n\n if (!ownerLogin) {\n console.warn(\n 'no ownerLogin found for selectedOwnerId',\n selectedOwnerId,\n owner\n );\n return;\n }\n\n if (!onRepoCreateAction) {\n console.warn('no onRepoCreateAction provided');\n return;\n }\n\n onRepoCreateAction({\n owner: ownerLogin,\n name: repoName,\n branch: repoDefaultBranch,\n });\n },\n [selectedOwnerId, getOwnerById, onRepoCreateAction, repoDefaultBranch]\n );\n\n const ownerOptions = useMemo(() => {\n return generateOwnerOptions(owners);\n }, [owners]);\n\n return (\n
\n
\n

Sync to GitHub

\n

\n Create a new repository or choose an existing one to sync your\n changes. We'll push changes with each new prompt you send.\n

\n
\n {status === 'loading' ? (\n
\n \n
\n ) : null}\n {status === 'error' ? (\n
\n \n

\n Error loading GitHub accounts. Please try again.\n

\n
\n ) : null}\n {status === 'success' && owners.length === 0 ? (\n
\n

\n No GitHub accounts found. Please check the app permissions in your\n GitHub settings.\n

\n
\n ) : null}\n {status === 'success' && owners.length > 0 ? (\n
\n
\n
\n \n \n Owner\n \n {\n console.log('owner value changed', value);\n setSelectedOwnerId(value);\n onOwnerChange?.(value);\n }}\n onAddItem={() => {\n handleConnect({\n onSuccess: () => {\n refresh();\n },\n });\n }}\n addItemLabel=\"Add GitHub account…\"\n options={ownerOptions}\n />\n \n \n /\n
\n \n \n Repository\n \n onRepoNameChange?.(e.target.value)}\n />\n \n
\n \n
\n \n ) : null}\n \n );\n}\n\ntype StepWelcomeProps = {\n onAppInstalled?: () => void;\n onHelpAction?: () => void;\n handleConnect: ({ onSuccess }: { onSuccess?: () => void }) => void;\n connectionStatus: GitHubConnectionStatus;\n};\n\nfunction StepWelcome({\n onAppInstalled,\n onHelpAction,\n connectionStatus,\n handleConnect,\n}: StepWelcomeProps) {\n const isPendingConnection = connectionStatus === 'pending';\n const hasError = connectionStatus === 'error';\n\n // TODO: remove this\n if (connectionStatus === 'installed') {\n console.error(\n 'welcome step rendered with installed status, which shouldnt happen'\n );\n }\n\n return (\n <>\n
\n
\n

Connect to GitHub

\n

\n Sync your changes to GitHub to backup your code at every snapshot by\n installing our app on your personal account or organization.\n

\n {hasError ? (\n

\n There was an error connecting to GitHub. Please try again.\n

\n ) : null}\n
\n
\n {\n handleConnect({\n onSuccess: onAppInstalled,\n });\n }\n }\n size=\"lg\"\n className={cn(\n 'w-full',\n isPendingConnection && 'opacity-80 pointer-events-none'\n )}\n >\n {' '}\n {isPendingConnection\n ? 'Connecting to GitHub…'\n : 'Install GitHub App'}\n \n {onHelpAction ? (\n \n Help me get started\n \n ) : null}\n
\n
\n \n );\n}\n", "type": "registry:component" }, { "path": "registry/new-york/blocks/git-platform-sync/combobox.tsx", - "content": "'use client';\n\nimport { Button } from '@/components/ui/button';\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n} from '@/components/ui/command';\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from '@/components/ui/popover';\nimport { cn } from '@/lib/utils';\nimport type * as PopoverPrimitive from '@radix-ui/react-popover';\nimport { CheckIcon, ChevronsUpDownIcon, PlusIcon } from 'lucide-react';\nimport * as React from 'react';\nimport { useState } from 'react';\n\nexport type ComboBoxProps = {\n options: {\n value: string;\n label: string;\n image?: string;\n }[];\n className?: string;\n initialValue?: string;\n /**\n * Controlled value. When provided, the component operates in controlled mode.\n */\n value?: string;\n /**\n * Callback fired when the value changes. Receives the option's value (not label).\n */\n onValueChange?: (value: string) => void;\n /**\n * @default 'fit'\n * @description Whether to combobox expands to its container, or fits to the width of the content\n */\n width?: 'fit' | 'full';\n /**\n * @default 'Add item…'\n * @description The label to display for the \"Add item\" action\n */\n addItemLabel?: string;\n /**\n * Callback function to run when \"Add item\" is selected. If this is not defined,\n * then the \"Add item\" action will not be shown.\n */\n onAddItem?: () => void;\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n} & Omit, 'value' | 'onValueChange'>;\n\nexport function ComboBox({\n options,\n className,\n width = 'fit',\n __container,\n initialValue,\n value: controlledValue,\n onAddItem,\n addItemLabel,\n onValueChange,\n ...props\n}: ComboBoxProps) {\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any =\n __container != null ? { container: __container } : {};\n const [open, setOpen] = useState(false);\n const [internalValue, setInternalValue] = useState(\n initialValue ?? options[0]?.value ?? null\n );\n\n // Use controlled value if provided, otherwise use internal state\n const isControlled = controlledValue !== undefined;\n const value = isControlled ? controlledValue : internalValue;\n\n const selectedOption =\n value.trim() !== ''\n ? options.find((option) => {\n return option.value === value;\n })\n : null;\n\n return (\n \n \n \n {selectedOption != null ? (\n \n {(selectedOption.image ?? '').trim() !== '' ? (\n \n ) : null}\n \n {selectedOption.label}\n \n \n ) : null}\n \n \n \n \n \n {/* TODO: search is silly when there are only 1-5 options */}\n \n \n No results\n \n {options.map((option) => (\n {\n const newValue = currentValue;\n\n // Update internal state if uncontrolled\n if (!isControlled) {\n setInternalValue(newValue);\n }\n\n // Always call onValueChange if provided\n if (newValue !== value) {\n onValueChange?.(newValue);\n }\n\n setOpen(false);\n }}\n >\n {(option.image ?? '').trim() !== '' ? (\n \n ) : null}\n \n {option.label}\n \n \n \n ))}\n \n {onAddItem != null ? (\n <>\n \n \n {\n setOpen(false);\n onAddItem?.();\n return false;\n }}\n >\n \n \n {addItemLabel ?? 'Add item…'}\n \n \n \n \n ) : null}\n \n \n \n \n );\n}\n", + "content": "'use client';\n\nimport { Button } from '@/components/ui/button';\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n} from '@/components/ui/command';\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from '@/components/ui/popover';\nimport { cn } from '@/lib/utils';\nimport * as PopoverPrimitive from '@radix-ui/react-popover';\nimport { CheckIcon, ChevronsUpDownIcon, PlusIcon } from 'lucide-react';\nimport * as React from 'react';\nimport { useState } from 'react';\n\nexport type ComboBoxProps = {\n options: {\n value: string;\n label: string;\n image?: string;\n }[];\n className?: string;\n initialValue?: string;\n /**\n * Controlled value. When provided, the component operates in controlled mode.\n */\n value?: string;\n /**\n * Callback fired when the value changes. Receives the option's value (not label).\n */\n onValueChange?: (value: string) => void;\n /**\n * @default 'fit'\n * @description Whether to combobox expands to its container, or fits to the width of the content\n */\n width?: 'fit' | 'full';\n /**\n * @default 'Add item…'\n * @description The label to display for the \"Add item\" action\n */\n addItemLabel?: string;\n /**\n * Callback function to run when \"Add item\" is selected. If this is not defined,\n * then the \"Add item\" action will not be shown.\n */\n onAddItem?: () => void;\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n} & Omit, 'value' | 'onValueChange'>;\n\nexport function ComboBox({\n options,\n className,\n width = 'fit',\n __container,\n initialValue,\n value: controlledValue,\n onAddItem,\n addItemLabel,\n onValueChange,\n ...props\n}: ComboBoxProps) {\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any = __container ? { container: __container } : {};\n const [open, setOpen] = useState(false);\n const [internalValue, setInternalValue] = useState(\n initialValue ?? options[0]?.value ?? null\n );\n\n // Use controlled value if provided, otherwise use internal state\n const isControlled = controlledValue !== undefined;\n const value = isControlled ? controlledValue : internalValue;\n\n const selectedOption = value\n ? options.find((option) => {\n return option.value === value;\n })\n : null;\n\n return (\n \n \n \n {selectedOption ? (\n \n {selectedOption.image ? (\n \n ) : null}\n \n {selectedOption.label}\n \n \n ) : null}\n \n \n \n \n \n {/* TODO: search is silly when there are only 1-5 options */}\n \n \n No results\n \n {options.map((option) => (\n {\n const newValue = currentValue;\n\n // Update internal state if uncontrolled\n if (!isControlled) {\n setInternalValue(newValue);\n }\n\n // Always call onValueChange if provided\n if (newValue !== value) {\n onValueChange?.(newValue);\n }\n\n setOpen(false);\n }}\n >\n {option.image ? (\n \n ) : null}\n \n {option.label}\n \n \n \n ))}\n \n {onAddItem ? (\n <>\n \n \n {\n setOpen(false);\n onAddItem?.();\n return false;\n }}\n >\n \n \n {addItemLabel ?? 'Add item…'}\n \n \n \n \n ) : null}\n \n \n \n \n );\n}\n", "type": "registry:component" }, { @@ -31,13 +35,37 @@ }, { "path": "registry/new-york/blocks/git-platform-sync/github/github-app-connect.ts", - "content": "'use client';\n\nimport { useCallback, useEffect, useMemo, useState } from 'react';\n\nexport interface GitHubAppConnectProps {\n /**\n * @description The slug of the GitHub app to connect to, this is different\n * than the app id or the client id. It's what goes into the installation URL.\n */\n slug: string;\n /**\n * @default '${window.location.origin}/api/code-storage/github/callback'\n * @description The URL to redirect to after the GitHub app is installed.\n * If not provided, we wil use `window.location.origin` as the base url and\n * '/api/code-storage/github/callback' as the path.\n */\n redirectUrl?: string;\n /**\n * @default '/api/code-storage/github/installations'\n * @description The URL to fetch GitHub app installations.\n * If not provided, we will use `/api/code-storage/github/installations` as the path\n * on the same origin that the current window is in.\n */\n installationsUrl?: string;\n}\n\nexport type Owner = {\n id: string;\n login: string;\n type: string | null;\n avatar_url: string | null;\n};\n\ntype InstallationsResponse = {\n installations: Array;\n owners: Owner[];\n};\n\n// Cache configuration\nconst CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes\nlet cachedInstallationsData: {\n data: InstallationsResponse;\n timestamp: number;\n} | null = null;\n\n// Track in-flight requests to prevent duplicate simultaneous requests\nlet pendingFetchInstallations: Promise | null = null;\n\nfunction isCacheValid(): boolean {\n if (cachedInstallationsData == null) return false;\n return Date.now() - cachedInstallationsData.timestamp < CACHE_TTL_MS;\n}\n\n/**\n * Manually clear the cached installations data.\n * Useful when you know the data has changed.\n */\nexport function clearInstallationsCache(): void {\n cachedInstallationsData = null;\n}\n\nexport async function fetchInstallations(\n url = '/api/code-storage/github/installations',\n signal?: AbortSignal\n): Promise {\n // Return cached data if still valid\n if (isCacheValid() && cachedInstallationsData != null) {\n return cachedInstallationsData.data;\n }\n\n // If there's already a pending request, wait for it and return its result\n if (pendingFetchInstallations != null) {\n return pendingFetchInstallations;\n }\n\n // Create the request promise\n const requestPromise = (async () => {\n try {\n const response = await fetch(url, {\n method: 'GET',\n headers: {\n 'Content-Type': 'application/json',\n },\n signal,\n });\n\n // TODO: handle error cases better, especially if we can begin to pass more\n // info about the error from the server\n if (response.ok) {\n const jsonResult = await response.json();\n const data = jsonResult?.data;\n\n if (data != null && 'installations' in data) {\n const result: InstallationsResponse = {\n installations: Array.isArray(data.installations)\n ? data.installations\n : [],\n owners: Array.isArray(data.owners) ? data.owners : [],\n };\n\n // Cache the successful response\n cachedInstallationsData = {\n data: result,\n timestamp: Date.now(),\n };\n\n return result;\n } else {\n console.warn(\n 'Warning: fetching installations - response has unexpected shape, falling back to empty array.'\n );\n return { installations: [], owners: [] };\n }\n } else {\n throw new Error('installations endpoint not ok');\n }\n } finally {\n // Clear the pending request when done (success or error)\n pendingFetchInstallations = null;\n }\n })();\n\n pendingFetchInstallations = requestPromise;\n return requestPromise;\n}\n\nfunction hasInstallation(data: InstallationsResponse): boolean {\n return data.installations.length > 0;\n}\n\nexport type GitHubConnectionStatus =\n | 'uninitialized'\n | 'pending'\n | 'installed'\n | 'error';\n\nexport type GitHubAppConnectionHandlerProps = {\n onSuccess?: () => void;\n};\n\n/**\n * Encapsulates all imperative logic for managing GitHub App installation flow.\n * This class handles popup windows, message listeners, and polling in a way that\n * avoids React closure/stale reference issues.\n */\nclass GitHubAppConnector {\n private popup: Window | null = null;\n private checkInterval: ReturnType | null = null;\n private abortController = new AbortController();\n private connectionStatus: GitHubConnectionStatus = 'uninitialized';\n private stableId = crypto.randomUUID();\n\n constructor(\n private config: {\n slug: string;\n origin: string;\n redirectUrl: string;\n installationsUrl: string;\n },\n private onStatusChange: (status: GitHubConnectionStatus) => void\n ) {}\n\n private setStatus(status: GitHubConnectionStatus) {\n this.connectionStatus = status;\n this.onStatusChange(status);\n }\n\n private handleMessage = async (event: MessageEvent) => {\n if (event.origin !== this.config.origin) {\n return;\n }\n\n // If we're getting the right event type, and the unique\n // state id matches this instance, we can proceed\n if (\n event.data.type === 'git-platform-sync-app-installed--github' &&\n event.data.state === this.stableId\n ) {\n try {\n const data = await fetchInstallations(\n this.config.installationsUrl,\n this.abortController.signal\n );\n\n if (hasInstallation(data)) {\n this.setStatus('installed');\n } else {\n this.setStatus('uninitialized');\n }\n } catch {\n this.setStatus('error');\n }\n }\n };\n\n // eslint-disable-next-line @typescript-eslint/require-await\n async connect(props?: GitHubAppConnectionHandlerProps) {\n const onSuccess = props?.onSuccess;\n const width = 600;\n const height = 700;\n const left = window.screen.width / 2 - width / 2;\n const top = window.screen.height / 2 - height / 2;\n\n // Build the installation URL with redirect\n const baseUrl = `https://github.com/apps/${this.config.slug}/installations/new`;\n const url = `${baseUrl}?redirect_uri=${encodeURIComponent(this.config.redirectUrl)}&state=${encodeURIComponent(this.stableId)}`;\n\n this.setStatus('pending');\n\n this.popup = window.open(\n url,\n 'code-storage-github-app-install',\n `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no`\n );\n\n // eslint-disable-next-line @typescript-eslint/no-misused-promises\n this.checkInterval = setInterval(async () => {\n if (this.connectionStatus === 'installed') {\n this.clearInterval();\n onSuccess?.();\n } else if (this.popup == null || this.popup.closed) {\n // Nothing new can happen if the pop-up is gone, so lets clear the interval\n this.clearInterval();\n\n // If the connection was closed while still in the 'pending' state,\n // the user likely closed the pop-up before the handleMessage listener\n // was able to receive a message (regardless of whether one was sent).\n if (this.connectionStatus === 'pending') {\n try {\n // We need to determine if the app was installed or not.\n const data = await fetchInstallations(\n this.config.installationsUrl,\n this.abortController.signal\n );\n\n if (hasInstallation(data)) {\n this.setStatus('installed');\n onSuccess?.();\n } else {\n this.setStatus('uninitialized');\n }\n } catch {\n this.setStatus('error');\n }\n }\n }\n }, 1000);\n\n // Use AbortController for automatic cleanup\n // eslint-disable-next-line @typescript-eslint/no-misused-promises\n window.addEventListener('message', this.handleMessage, {\n signal: this.abortController.signal,\n });\n }\n\n async fetchInstallationStatus() {\n this.setStatus('pending');\n\n try {\n const data = await fetchInstallations(\n this.config.installationsUrl,\n this.abortController.signal\n );\n if (hasInstallation(data)) {\n this.setStatus('installed');\n } else {\n this.setStatus('uninitialized');\n }\n } catch {\n this.setStatus('error');\n }\n }\n\n private clearInterval() {\n if (this.checkInterval != null) {\n clearInterval(this.checkInterval);\n this.checkInterval = null;\n }\n }\n\n destroy() {\n // AbortController automatically removes all listeners and aborts any in-flight fetches\n this.abortController.abort();\n this.clearInterval();\n this.popup?.close();\n this.popup = null;\n }\n}\n\nexport function useGitHubAppConnection({\n slug,\n redirectUrl: redirectUrlProp,\n installationsUrl,\n}: GitHubAppConnectProps) {\n const [status, setStatus] = useState('uninitialized');\n\n const origin = typeof window !== 'undefined' ? window.location.origin : '';\n const redirectUrl =\n redirectUrlProp ?? `${origin}/api/code-storage/github/callback`;\n const installationsUrlResolved =\n installationsUrl ?? '/api/code-storage/github/installations';\n\n // Create the connector once - all config is captured at creation time.\n // We intentionally create this only once to avoid recreating listeners and intervals.\n // Config changes during the component lifecycle are not supported by design.\n const connector = useMemo(() => {\n return new GitHubAppConnector(\n {\n slug,\n origin,\n redirectUrl,\n installationsUrl: installationsUrlResolved,\n },\n setStatus\n );\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n return () => connector.destroy();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Return stable callbacks that delegate to the connector\n const handleConnect = useCallback(\n (props?: GitHubAppConnectionHandlerProps) => {\n void connector.connect(props);\n },\n [connector]\n );\n\n const fetchInstallationStatus = useCallback(async () => {\n await connector.fetchInstallationStatus();\n }, [connector]);\n\n const destroy = useCallback(() => {\n connector.destroy();\n }, [connector]);\n\n return {\n status,\n handleConnect,\n fetchInstallationStatus,\n destroy,\n };\n}\n", - "type": "registry:component" + "content": "'use client';\n\nimport { useCallback, useEffect, useMemo, useState } from 'react';\n\nexport interface GitHubAppConnectProps {\n /**\n * @description The slug of the GitHub app to connect to, this is different\n * than the app id or the client id. It's what goes into the installation URL.\n */\n slug: string;\n /**\n * @default '${window.location.origin}/api/code-storage/github/callback'\n * @description The URL to redirect to after the GitHub app is installed.\n * If not provided, we wil use `window.location.origin` as the base url and\n * '/api/code-storage/github/callback' as the path.\n */\n redirectUrl?: string;\n /**\n * @default '/api/code-storage/github/installations'\n * @description The URL to fetch GitHub app installations.\n * If not provided, we will use `/api/code-storage/github/installations` as the path\n * on the same origin that the current window is in.\n */\n installationsUrl?: string;\n}\n\nexport type Owner = {\n id: string;\n login: string;\n type: string | null;\n avatar_url: string | null;\n};\n\ntype InstallationsResponse = {\n installations: Array;\n owners: Owner[];\n};\n\n// Cache configuration\nconst CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes\nlet cachedInstallationsData: {\n data: InstallationsResponse;\n timestamp: number;\n} | null = null;\n\n// Track in-flight requests to prevent duplicate simultaneous requests\nlet pendingFetchInstallations: Promise | null = null;\n\nfunction isCacheValid(): boolean {\n if (!cachedInstallationsData) return false;\n return Date.now() - cachedInstallationsData.timestamp < CACHE_TTL_MS;\n}\n\n/**\n * Manually clear the cached installations data.\n * Useful when you know the data has changed.\n */\nexport function clearInstallationsCache(): void {\n cachedInstallationsData = null;\n}\n\nexport async function fetchInstallations(\n url = '/api/code-storage/github/installations',\n signal?: AbortSignal\n): Promise {\n // Return cached data if still valid\n if (isCacheValid() && cachedInstallationsData) {\n return cachedInstallationsData.data;\n }\n\n // If there's already a pending request, wait for it and return its result\n if (pendingFetchInstallations) {\n return pendingFetchInstallations;\n }\n\n // Create the request promise\n const requestPromise = (async () => {\n try {\n const response = await fetch(url, {\n method: 'GET',\n headers: {\n 'Content-Type': 'application/json',\n },\n signal,\n });\n\n // TODO: handle error cases better, especially if we can begin to pass more\n // info about the error from the server\n if (response.ok) {\n const jsonResult = await response.json();\n const data = jsonResult?.data;\n\n if (data && 'installations' in data) {\n const result: InstallationsResponse = {\n installations: Array.isArray(data.installations)\n ? data.installations\n : [],\n owners: Array.isArray(data.owners) ? data.owners : [],\n };\n\n // Cache the successful response\n cachedInstallationsData = {\n data: result,\n timestamp: Date.now(),\n };\n\n return result;\n } else {\n console.warn(\n 'Warning: fetching installations - response has unexpected shape, falling back to empty array.'\n );\n return { installations: [], owners: [] };\n }\n } else {\n throw new Error('installations endpoint not ok');\n }\n } finally {\n // Clear the pending request when done (success or error)\n pendingFetchInstallations = null;\n }\n })();\n\n pendingFetchInstallations = requestPromise;\n return requestPromise;\n}\n\nfunction hasInstallation(data: InstallationsResponse): boolean {\n return data.installations.length > 0;\n}\n\nexport type GitHubConnectionStatus =\n | 'uninitialized'\n | 'pending'\n | 'installed'\n | 'error';\n\nexport type GitHubAppConnectionHandlerProps = {\n onSuccess?: () => void;\n};\n\n/**\n * Encapsulates all imperative logic for managing GitHub App installation flow.\n * This class handles popup windows, message listeners, and polling in a way that\n * avoids React closure/stale reference issues.\n */\nclass GitHubAppConnector {\n private popup: Window | null = null;\n private checkInterval: ReturnType | null = null;\n private abortController = new AbortController();\n private connectionStatus: GitHubConnectionStatus = 'uninitialized';\n private stableId = crypto.randomUUID();\n\n constructor(\n private config: {\n slug: string;\n origin: string;\n redirectUrl: string;\n installationsUrl: string;\n },\n private onStatusChange: (status: GitHubConnectionStatus) => void\n ) {}\n\n private setStatus(status: GitHubConnectionStatus) {\n this.connectionStatus = status;\n this.onStatusChange(status);\n }\n\n private handleMessage = async (event: MessageEvent) => {\n if (event.origin !== this.config.origin) {\n return;\n }\n\n // If we're getting the right event type, and the unique\n // state id matches this instance, we can proceed\n if (\n event.data.type === 'git-platform-sync-app-installed--github' &&\n event.data.state === this.stableId\n ) {\n try {\n const data = await fetchInstallations(\n this.config.installationsUrl,\n this.abortController.signal\n );\n\n if (hasInstallation(data)) {\n this.setStatus('installed');\n } else {\n this.setStatus('uninitialized');\n }\n } catch {\n this.setStatus('error');\n }\n }\n };\n\n async connect(props?: GitHubAppConnectionHandlerProps) {\n const onSuccess = props?.onSuccess;\n const width = 600;\n const height = 700;\n const left = window.screen.width / 2 - width / 2;\n const top = window.screen.height / 2 - height / 2;\n\n // Build the installation URL with redirect\n const baseUrl = `https://github.com/apps/${this.config.slug}/installations/new`;\n const url = `${baseUrl}?redirect_uri=${encodeURIComponent(this.config.redirectUrl)}&state=${encodeURIComponent(this.stableId)}`;\n\n this.setStatus('pending');\n\n this.popup = window.open(\n url,\n 'code-storage-github-app-install',\n `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no`\n );\n\n this.checkInterval = setInterval(async () => {\n if (this.connectionStatus === 'installed') {\n this.clearInterval();\n onSuccess?.();\n } else if (!this.popup || this.popup.closed) {\n // Nothing new can happen if the pop-up is gone, so lets clear the interval\n this.clearInterval();\n\n // If the connection was closed while still in the 'pending' state,\n // the user likely closed the pop-up before the handleMessage listener\n // was able to receive a message (regardless of whether one was sent).\n if (this.connectionStatus === 'pending') {\n try {\n // We need to determine if the app was installed or not.\n const data = await fetchInstallations(\n this.config.installationsUrl,\n this.abortController.signal\n );\n\n if (hasInstallation(data)) {\n this.setStatus('installed');\n onSuccess?.();\n } else {\n this.setStatus('uninitialized');\n }\n } catch {\n this.setStatus('error');\n }\n }\n }\n }, 1000);\n\n // Use AbortController for automatic cleanup\n window.addEventListener('message', this.handleMessage, {\n signal: this.abortController.signal,\n });\n }\n\n async fetchInstallationStatus() {\n this.setStatus('pending');\n\n try {\n const data = await fetchInstallations(\n this.config.installationsUrl,\n this.abortController.signal\n );\n if (hasInstallation(data)) {\n this.setStatus('installed');\n } else {\n this.setStatus('uninitialized');\n }\n } catch {\n this.setStatus('error');\n }\n }\n\n private clearInterval() {\n if (this.checkInterval) {\n clearInterval(this.checkInterval);\n this.checkInterval = null;\n }\n }\n\n destroy() {\n // AbortController automatically removes all listeners and aborts any in-flight fetches\n this.abortController.abort();\n this.clearInterval();\n this.popup?.close();\n this.popup = null;\n }\n}\n\nexport function useGitHubAppConnection({\n slug,\n redirectUrl: redirectUrlProp,\n installationsUrl,\n}: GitHubAppConnectProps) {\n const [status, setStatus] = useState('uninitialized');\n\n const origin = typeof window !== 'undefined' ? window.location.origin : '';\n const redirectUrl =\n redirectUrlProp ?? `${origin}/api/code-storage/github/callback`;\n const installationsUrlResolved =\n installationsUrl ?? '/api/code-storage/github/installations';\n\n // Create the connector once - all config is captured at creation time.\n // We intentionally create this only once to avoid recreating listeners and intervals.\n // Config changes during the component lifecycle are not supported by design.\n const connector = useMemo(() => {\n return new GitHubAppConnector(\n {\n slug,\n origin,\n redirectUrl,\n installationsUrl: installationsUrlResolved,\n },\n setStatus\n );\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n return () => connector.destroy();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Return stable callbacks that delegate to the connector\n const handleConnect = useCallback(\n (props?: GitHubAppConnectionHandlerProps) => {\n connector.connect(props);\n },\n [connector]\n );\n\n const fetchInstallationStatus = useCallback(async () => {\n await connector.fetchInstallationStatus();\n }, [connector]);\n\n const destroy = useCallback(() => {\n connector.destroy();\n }, [connector]);\n\n return {\n status,\n handleConnect,\n fetchInstallationStatus,\n destroy,\n };\n}\n", + "type": "registry:lib" }, { "path": "registry/new-york/blocks/git-platform-sync/github/owners.ts", - "content": "import { useEffect, useMemo, useState } from 'react';\n\nimport {\n type Owner,\n clearInstallationsCache,\n fetchInstallations,\n} from './github-app-connect';\n\nexport type { Owner };\n\nexport type OwnersFetchStatus = 'loading' | 'error' | 'success';\n\ntype FetchOwnersResult = {\n error: Error | null;\n data:\n | {\n owners: Owner[];\n }\n | undefined;\n};\n\nasync function fetchOwners(signal?: AbortSignal): Promise {\n try {\n const response = await fetchInstallations(\n '/api/code-storage/github/installations',\n signal\n );\n return {\n error: null,\n data: { owners: response.owners },\n };\n } catch (e) {\n // Don't set error state if the request was aborted\n if (e instanceof Error && e.name === 'AbortError') {\n return { error: null, data: undefined };\n }\n return {\n error: new Error('Failed to fetch owners'),\n data: undefined,\n };\n }\n}\n\n/**\n * Manually clear the cached owners data.\n * Useful when you know the data has changed (e.g., after adding a new repository).\n */\nexport function clearOwnersCache(): void {\n clearInstallationsCache();\n}\n\nexport function useOwners() {\n const [owners, setOwners] = useState([]);\n const [status, setStatus] = useState('loading');\n const [bustVersion, setBustVersion] = useState(0);\n\n useEffect(() => {\n const abortController = new AbortController();\n\n const fetchEffect = async () => {\n setStatus('loading');\n const { error, data } = await fetchOwners(abortController.signal);\n\n // Don't update state if the component was unmounted\n if (abortController.signal.aborted) {\n return;\n }\n\n if (error != null || data === null) {\n setStatus('error');\n } else if (data != null) {\n setOwners(data.owners);\n setStatus('success');\n }\n };\n\n void fetchEffect();\n\n return () => {\n abortController.abort();\n };\n }, [bustVersion]);\n\n const ownerMap = useMemo(() => {\n const map = new Map();\n for (const owner of owners) {\n map.set(owner.id, owner);\n }\n return map;\n }, [owners]);\n\n return {\n owners,\n status,\n getOwnerById: (id: string) => {\n return ownerMap.get(id);\n },\n getOwnerByName: (name: string) => {\n return owners.find((owner) => owner.login === name);\n },\n refresh: () => {\n clearOwnersCache();\n setBustVersion(bustVersion + 1);\n },\n };\n}\n\nexport function generateOwnerOptions(owners: Owner[]) {\n return owners.map((owner) => ({\n value: owner.id,\n label: owner.login,\n image: owner.avatar_url,\n }));\n}\n", - "type": "registry:component" + "content": "import { useEffect, useMemo, useState } from 'react';\n\nimport {\n type Owner,\n clearInstallationsCache,\n fetchInstallations,\n} from './github-app-connect';\n\nexport type { Owner };\n\nexport type OwnersFetchStatus = 'loading' | 'error' | 'success';\n\ntype FetchOwnersResult = {\n error: Error | null;\n data:\n | {\n owners: Owner[];\n }\n | undefined;\n};\n\nasync function fetchOwners(signal?: AbortSignal): Promise {\n try {\n const response = await fetchInstallations(\n '/api/code-storage/github/installations',\n signal\n );\n return {\n error: null,\n data: { owners: response.owners },\n };\n } catch (e) {\n // Don't set error state if the request was aborted\n if (e instanceof Error && e.name === 'AbortError') {\n return { error: null, data: undefined };\n }\n return {\n error: new Error('Failed to fetch owners'),\n data: undefined,\n };\n }\n}\n\n/**\n * Manually clear the cached owners data.\n * Useful when you know the data has changed (e.g., after adding a new repository).\n */\nexport function clearOwnersCache(): void {\n clearInstallationsCache();\n}\n\nexport function useOwners() {\n const [owners, setOwners] = useState([]);\n const [status, setStatus] = useState('loading');\n const [bustVersion, setBustVersion] = useState(0);\n\n useEffect(() => {\n const abortController = new AbortController();\n\n const fetchEffect = async () => {\n setStatus('loading');\n const { error, data } = await fetchOwners(abortController.signal);\n\n // Don't update state if the component was unmounted\n if (abortController.signal.aborted) {\n return;\n }\n\n if (error || data === null) {\n setStatus('error');\n } else if (data) {\n setOwners(data.owners);\n setStatus('success');\n }\n };\n\n fetchEffect();\n\n return () => {\n abortController.abort();\n };\n }, [bustVersion]);\n\n const ownerMap = useMemo(() => {\n const map = new Map();\n for (const owner of owners) {\n map.set(owner.id, owner);\n }\n return map;\n }, [owners]);\n\n return {\n owners,\n status,\n getOwnerById: (id: string) => {\n return ownerMap.get(id);\n },\n getOwnerByName: (name: string) => {\n return owners.find((owner) => owner.login === name);\n },\n refresh: () => {\n clearOwnersCache();\n setBustVersion(bustVersion + 1);\n },\n };\n}\n\nexport function generateOwnerOptions(owners: Owner[]) {\n return owners.map((owner) => ({\n value: owner.id,\n label: owner.login,\n image: owner.avatar_url,\n }));\n}\n", + "type": "registry:lib" + }, + { + "path": "registry/new-york/blocks/git-platform-sync/pages/code-storage/success/page.tsx", + "content": "'use client';\n\nimport { Button } from '@/components/ui/button';\nimport { CheckCircle } from 'lucide-react';\nimport { useSearchParams } from 'next/navigation';\nimport { Suspense, useEffect } from 'react';\n\nconst appInstallType = 'git-platform-sync-app-installed--github';\n\nfunction SuccessPageContent() {\n const searchParams = useSearchParams();\n const installationId = searchParams.get('installation_id');\n const setupAction = searchParams.get('setup_action');\n const state = searchParams.get('state');\n\n useEffect(() => {\n if (window.opener) {\n window.opener.postMessage(\n {\n type: appInstallType,\n installationId,\n setupAction,\n state,\n },\n window.opener.origin\n );\n\n setTimeout(() => {\n window.close();\n }, 2000);\n }\n }, [installationId, setupAction, state]);\n\n return (\n
\n
\n
\n \n

\n Installation Successful!\n

\n

\n Your GitHub App has been successfully{' '}\n {setupAction === 'install' ? 'installed' : 'updated'}.\n

\n {installationId && (\n

\n Installation ID: {installationId}\n

\n )}\n
\n

\n This window will close automatically…\n

\n {\n if (window.opener) {\n window.opener.postMessage(\n {\n type: appInstallType,\n installationId,\n setupAction,\n state,\n },\n window.opener.origin\n );\n\n setTimeout(() => {\n window.close();\n }, 0);\n } else {\n window.location.href = '/';\n }\n }}\n className=\"w-full\"\n >\n Close Window\n \n
\n
\n
\n
\n );\n}\n\nexport default function SuccessPage() {\n return (\n \n \n \n );\n}\n", + "type": "registry:page", + "target": "app/code-storage/success/page.tsx" + }, + { + "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/github/callback/route.ts", + "content": "import { NextRequest, NextResponse } from 'next/server';\n\nexport async function GET(request: NextRequest) {\n const searchParams = request.nextUrl.searchParams;\n const code = searchParams.get('code');\n const installationId = searchParams.get('installation_id');\n const setupAction = searchParams.get('setup_action');\n const state = searchParams.get('state');\n\n if (!code) {\n return NextResponse.json({ error: 'No code provided' }, { status: 400 });\n }\n\n try {\n const tokenResponse = await fetch(\n 'https://github.com/login/oauth/access_token',\n {\n method: 'POST',\n headers: {\n Accept: 'application/json',\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n client_id: process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID,\n client_secret: process.env.GITHUB_CLIENT_SECRET,\n code,\n }),\n }\n );\n\n const tokenData = await tokenResponse.json();\n\n if (tokenData.error) {\n throw new Error(tokenData.error_description || tokenData.error);\n }\n\n const successUrl = new URL('/code-storage/success', request.url);\n if (setupAction) {\n successUrl.searchParams.set('setup_action', setupAction);\n }\n if (installationId) {\n successUrl.searchParams.set('installation_id', installationId);\n }\n if (state) {\n successUrl.searchParams.set('state', state);\n }\n const response = NextResponse.redirect(successUrl);\n\n // Store only the user's OAuth token - it works across all installations\n response.cookies.set('github_token', tokenData.access_token, {\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n maxAge: 60 * 60 * 24 * 30,\n });\n\n return response;\n } catch (error) {\n console.error('OAuth error:', error);\n return NextResponse.json(\n { error: 'Failed to exchange code for token' },\n { status: 500 }\n );\n }\n}\n", + "type": "registry:page", + "target": "app/api/code-storage/github/callback/route.ts" + }, + { + "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/github/installations/route.ts", + "content": "import type { components } from '@octokit/openapi-types';\nimport { NextRequest, NextResponse } from 'next/server';\n\ntype Installation = components['schemas']['installation'];\n\ntype Owner = components['schemas']['simple-user'] &\n components['schemas']['enterprise'];\n\ntype InstallationResponse = {\n installations: Installation[];\n};\n\ntype FilteredInstallation = {\n id: string;\n app_id: string;\n app_slug: string;\n permissions: Record;\n owner_id: string | null;\n events: string[];\n type: string | null;\n};\n\ntype FilteredOwner = {\n id: string;\n login: string;\n type: string | null;\n avatar_url: string | null;\n};\n\nfunction filterInstallations(installations: Installation[]) {\n return installations.map((installation) => {\n return {\n id: `${installation.id}`,\n app_id: `${installation.app_id}`,\n app_slug: installation.app_slug,\n owner_id:\n installation.account &&\n ('id' in installation.account ? `${installation.account.id}` : null),\n permissions: installation.permissions,\n events: installation.events,\n type:\n installation.account &&\n ('type' in installation.account ? installation.account.type : null),\n } satisfies FilteredInstallation;\n });\n}\n\nfunction filterOwners(owners: Owner[]) {\n return owners.map((owner) => {\n return {\n id: `${owner.id}`,\n login: owner.login,\n type: owner.type,\n avatar_url: owner.avatar_url,\n } satisfies FilteredOwner;\n });\n}\n\nfunction getOwnersFromInstallations(installations: Installation[]) {\n return installations\n .map((installation) => installation.account)\n .filter((account): account is Owner => account !== null);\n}\n\nexport async function GET(request: NextRequest) {\n const token = request.cookies.get('github_token')?.value;\n\n if (!token) {\n return NextResponse.json({ data: { installations: [], owners: [] } });\n }\n\n try {\n const response = await fetch(`https://api.github.com/user/installations`, {\n headers: {\n Authorization: `Bearer ${token}`,\n Accept: 'application/vnd.github.v3+json',\n },\n });\n\n if (!response.ok) {\n return NextResponse.json(\n { error: 'Failed to fetch installations' },\n { status: 400 }\n );\n }\n\n const data = (await response.json()) as InstallationResponse;\n\n if (!data?.installations?.length) {\n return NextResponse.json({\n data: {\n installations: [],\n owners: [],\n },\n });\n }\n\n const filteredInstallations = filterInstallations(data.installations);\n\n const filteredOwners = filterOwners(\n getOwnersFromInstallations(data.installations)\n );\n\n return NextResponse.json({\n data: {\n installations: filteredInstallations,\n owners: filteredOwners,\n },\n // for debugging this can be nice\n // _raw: data.installations,\n });\n } catch (error) {\n console.error('Error fetching installations:', error);\n return NextResponse.json(\n {\n error: 'Failed to fetch installations',\n errorMessage: error instanceof Error ? error.message : 'Unknown error',\n },\n { status: 500 }\n );\n }\n}\n", + "type": "registry:page", + "target": "app/api/code-storage/github/installations/route.ts" + }, + { + "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/repo/route.ts", + "content": "import { GitStorage } from '@pierre/storage';\nimport { NextRequest, NextResponse } from 'next/server';\n\nconst store = new GitStorage({\n name: 'pierre',\n key: process.env.CODE_STORAGE_SYNC_PRIVATE_KEY || '',\n});\n\nexport async function POST(request: NextRequest) {\n try {\n const body = await request.json();\n const { owner, name, defaultBranch } = body;\n\n if (!owner || !name) {\n return NextResponse.json(\n { success: false, error: 'Repository owner and name are required' },\n { status: 400 }\n );\n }\n\n // TODO: Authenticate the user for this operation\n\n const repo = await store.createRepo({\n baseRepo: {\n owner,\n name,\n defaultBranch: defaultBranch || 'main', // Optional, defaults to 'main'\n },\n defaultBranch: defaultBranch || 'main', // Optional, defaults to 'main' for the Git Storage repo\n });\n\n const ciUrl = await repo.getRemoteURL({\n permissions: ['git:read', 'git:write'],\n ttl: 86400, // 24 hours\n });\n\n return NextResponse.json({\n success: true,\n url: ciUrl,\n repository: {\n owner,\n name,\n defaultBranch: defaultBranch || 'main',\n },\n });\n } catch (error) {\n console.error('Error syncing storage:', error);\n return NextResponse.json(\n { error: 'Failed to sync storage' },\n { status: 500 }\n );\n }\n}\n", + "type": "registry:page", + "target": "app/api/code-storage/repo/route.ts" } ] } \ No newline at end of file diff --git a/apps/docs/public/r/registry.json b/apps/docs/public/r/registry.json index 58c0d99c9..a8f0204a8 100644 --- a/apps/docs/public/r/registry.json +++ b/apps/docs/public/r/registry.json @@ -5,7 +5,7 @@ "items": [ { "name": "git-platform-sync", - "type": "registry:block", + "type": "registry:component", "title": "Git Platform Sync", "description": "A component that implements repo syncing between code.storage and git platforms like GitHub.", "registryDependencies": [ @@ -17,6 +17,7 @@ "select", "tooltip" ], + "dependencies": ["@octokit/openapi-types", "@pierre/storage"], "files": [ { "path": "registry/new-york/blocks/git-platform-sync/git-platform-sync.tsx", @@ -32,11 +33,31 @@ }, { "path": "registry/new-york/blocks/git-platform-sync/github/github-app-connect.ts", - "type": "registry:component" + "type": "registry:lib" }, { "path": "registry/new-york/blocks/git-platform-sync/github/owners.ts", - "type": "registry:component" + "type": "registry:lib" + }, + { + "path": "registry/new-york/blocks/git-platform-sync/pages/code-storage/success/page.tsx", + "type": "registry:page", + "target": "app/code-storage/success/page.tsx" + }, + { + "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/github/callback/route.ts", + "type": "registry:page", + "target": "app/api/code-storage/github/callback/route.ts" + }, + { + "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/github/installations/route.ts", + "type": "registry:page", + "target": "app/api/code-storage/github/installations/route.ts" + }, + { + "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/repo/route.ts", + "type": "registry:page", + "target": "app/api/code-storage/repo/route.ts" } ] } diff --git a/apps/docs/registry.json b/apps/docs/registry.json index 58c0d99c9..28ed3ad7b 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -5,7 +5,7 @@ "items": [ { "name": "git-platform-sync", - "type": "registry:block", + "type": "registry:component", "title": "Git Platform Sync", "description": "A component that implements repo syncing between code.storage and git platforms like GitHub.", "registryDependencies": [ @@ -17,6 +17,7 @@ "select", "tooltip" ], + "dependencies": ["@octokit/openapi-types", "@pierre/storage"], "files": [ { "path": "registry/new-york/blocks/git-platform-sync/git-platform-sync.tsx", @@ -32,11 +33,36 @@ }, { "path": "registry/new-york/blocks/git-platform-sync/github/github-app-connect.ts", - "type": "registry:component" + "type": "registry:lib" }, { "path": "registry/new-york/blocks/git-platform-sync/github/owners.ts", - "type": "registry:component" + "type": "registry:lib" + }, + { + "path": "registry/new-york/blocks/git-platform-sync/pages/code-storage/success/page.tsx", + "type": "registry:page", + "target": "app/code-storage/success/page.tsx" + }, + { + "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/github/callback/route.ts", + "type": "registry:page", + "target": "app/api/code-storage/github/callback/route.ts" + }, + { + "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/github/callback/success-callback.ts", + "type": "registry:page", + "target": "app/api/code-storage/github/callback/success-callback.ts" + }, + { + "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/github/installations/route.ts", + "type": "registry:page", + "target": "app/api/code-storage/github/installations/route.ts" + }, + { + "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/repo/route.ts", + "type": "registry:page", + "target": "app/api/code-storage/repo/route.ts" } ] } diff --git a/bun.lock b/bun.lock index 0b4086c52..03764773f 100644 --- a/bun.lock +++ b/bun.lock @@ -142,10 +142,7 @@ "catalog": { "@eslint/eslintrc": "3.3.1", "@eslint/js": "9.36.0", - "@octokit/app": "16.1.1", - "@octokit/auth-app": "8.1.1", "@octokit/openapi-types": "26.0.0", - "@octokit/rest": "22.0.0", "@pierre/storage": "0.0.10", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-dialog": "1.1.15", diff --git a/package.json b/package.json index f8f4239df..82680df4b 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,7 @@ "catalog": { "@eslint/eslintrc": "3.3.1", "@eslint/js": "9.36.0", - "@octokit/app": "16.1.1", - "@octokit/auth-app": "8.1.1", "@octokit/openapi-types": "26.0.0", - "@octokit/rest": "22.0.0", "@pierre/storage": "0.0.10", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-dialog": "1.1.15", diff --git a/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/route.ts b/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/route.ts new file mode 100644 index 000000000..8e3457759 --- /dev/null +++ b/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from 'next/server'; + +import { CodeStorageSuccessCallback } from './success-callback'; + +const successCallback = new CodeStorageSuccessCallback({ + clientId: process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + platform: 'github', + env: process.env.NODE_ENV === 'production' ? 'production' : 'development', +}); + +export async function GET(request: NextRequest) { + return successCallback.handleRequest(request); +} diff --git a/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/success-callback.ts b/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/success-callback.ts new file mode 100644 index 000000000..5f92f4f84 --- /dev/null +++ b/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/success-callback.ts @@ -0,0 +1,150 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export type GitHubOAuthTokenResponse = + | { + access_token: string; + token_type: 'bearer'; + scope: string; + } + | { + error: string; + error_description: string; + error_uri: string; + }; + +export type AvailablePlatform = 'github'; +export type CodeStorageSuccessCallbackOptions = { + clientId: string; + clientSecret: string; + platform: AvailablePlatform; + redirectUrl?: string; + env: 'production' | (string & {}); +}; + +export class CodeStorageSuccessCallback { + private clientId: string; + private clientSecret: string; + private redirectUrl?: string; + private env: 'production' | (string & {}) = 'production'; + + constructor(options: CodeStorageSuccessCallbackOptions) { + if (options.platform !== 'github') { + throw new Error( + `CodeStorageSuccessCallback Error: Invalid platform: ${options.platform}` + ); + } + this.clientId = options.clientId; + this.clientSecret = options.clientSecret; + this.redirectUrl = options.redirectUrl; + this.env = options.env; + } + + private getParams(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + + const code = searchParams.get('code'); + const installationId = searchParams.get('installation_id'); + const setupAction = searchParams.get('setup_action'); + const state = searchParams.get('state'); + + if (!code) { + throw new Error('CodeStorageSuccessCallback Error: No code provided'); + } else if (!installationId) { + throw new Error( + 'CodeStorageSuccessCallback Error: No installation ID provided' + ); + } else if (!setupAction) { + throw new Error( + 'CodeStorageSuccessCallback Error: No setup action provided' + ); + } else if (!state) { + throw new Error('CodeStorageSuccessCallback Error: No state provided'); + } + + return { code, installationId, setupAction, state }; + } + + private generateRedirectUrl( + request: NextRequest, + { + installationId, + setupAction, + state, + }: { installationId: string; setupAction?: string; state: string } + ) { + if (this.redirectUrl) { + return this.redirectUrl; + } + + const successUrl = new URL('/code-storage/success', request.url); + + if (setupAction) { + successUrl.searchParams.set('setup_action', setupAction); + } + if (installationId) { + successUrl.searchParams.set('installation_id', installationId); + } + if (state) { + successUrl.searchParams.set('state', state); + } + return successUrl; + } + + async handleRequest(request: NextRequest) { + try { + const { code, installationId, setupAction, state } = + this.getParams(request); + + const tokenResponse = await fetch( + 'https://github.com/login/oauth/access_token', + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: this.clientId, + client_secret: this.clientSecret, + code, + }), + } + ); + + const tokenData = + (await tokenResponse.json()) as unknown as GitHubOAuthTokenResponse; + + if ('error' in tokenData) { + throw new Error(tokenData.error_description || tokenData.error); + } + + const successUrl = this.generateRedirectUrl(request, { + installationId, + setupAction, + state, + }); + + const response = NextResponse.redirect(successUrl); + + response.cookies.set('github_token', tokenData.access_token, { + httpOnly: true, + secure: this.env === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, + }); + + return response; + } catch (error) { + console.error('CodeStorageSuccessCallback Error:', error); + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : 'CodeStorageSuccessCallback Error', + }, + { status: 400 } + ); + } + } +} diff --git a/apps/docs/app/api/code-storage/github/installations/route.ts b/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/installations/route.ts similarity index 100% rename from apps/docs/app/api/code-storage/github/installations/route.ts rename to packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/installations/route.ts diff --git a/apps/docs/app/api/code-storage/repo/route.ts b/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/repo/route.ts similarity index 100% rename from apps/docs/app/api/code-storage/repo/route.ts rename to packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/repo/route.ts diff --git a/apps/docs/app/code-storage/success/page.tsx b/packages/sync-ui-shadcn/blocks/git-platform-sync/pages/code-storage/success/page.tsx similarity index 100% rename from apps/docs/app/code-storage/success/page.tsx rename to packages/sync-ui-shadcn/blocks/git-platform-sync/pages/code-storage/success/page.tsx From ff0dd0ee9ea6a12f337e10226c879e327c0d7645 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Mon, 13 Oct 2025 13:11:17 -0500 Subject: [PATCH 2/6] rebuild registry items --- apps/docs/public/r/git-platform-sync.json | 22 ++++++++++++++-------- apps/docs/public/r/registry.json | 5 +++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/docs/public/r/git-platform-sync.json b/apps/docs/public/r/git-platform-sync.json index 2b2796c6e..81145b28c 100644 --- a/apps/docs/public/r/git-platform-sync.json +++ b/apps/docs/public/r/git-platform-sync.json @@ -20,12 +20,12 @@ "files": [ { "path": "registry/new-york/blocks/git-platform-sync/git-platform-sync.tsx", - "content": "import { Button } from '@/components/ui/button';\nimport { Field, FieldLabel } from '@/components/ui/field';\nimport { Input } from '@/components/ui/input';\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from '@/components/ui/popover';\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from '@/components/ui/tooltip';\nimport { cn } from '@/lib/utils';\nimport * as PopoverPrimitive from '@radix-ui/react-popover';\nimport { AlertCircle, BookOpen, ChevronDown, Loader2 } from 'lucide-react';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\n\nimport { ComboBox } from './combobox';\nimport {\n type GitHubConnectionStatus,\n useGitHubAppConnection,\n} from './github/github-app-connect';\nimport { GitHubIcon } from './github/icon';\nimport { generateOwnerOptions, useOwners } from './github/owners';\n\n// TODO: determine if this is the canonical way to import other components inside of a block\n\nexport type Step = 'welcome' | 'create' | 'manage';\n\nexport type RepositoryData = {\n /**\n * @description The owner of the repository, also referred to as the 'scope' - usually\n * the username of the user or an organization they belong to.\n */\n owner?: string;\n /**\n * @description The name of the repository, this is a 'slug' style name.\n */\n name?: string;\n /**\n * @description The branch of the repository, this is the branch that will be used to sync\n */\n branch?: string;\n};\n\nexport type SyncedRepo = {\n url: string;\n repository: {\n owner: string;\n name: string;\n defaultBranch: string;\n };\n};\n\nexport type GitPlatformSyncStatus =\n | 'disconnected'\n | 'connected'\n | 'connected-syncing'\n | 'connected-warning';\n\n/**\n * @description Platforms that code.storage supports\n */\nexport type SupportedGitPlatform = 'github';\nexport type PlatformConfig_GitHub = {\n platform: 'github';\n slug: string;\n redirectUrl?: string;\n};\n// Currently only github configs are allowed, but in the future more\n// may be supported\nexport type PlatformConfigObject = PlatformConfig_GitHub;\n\nexport type GitPlatformSyncProps = {\n /**\n * @description List of supported platforms that you want to offer to the user. We recommend\n * not setting this until we support more platforms.\n */\n platforms: PlatformConfigObject[];\n\n /**\n * @default 'icon-only'\n * @description Variant display of the button that opens the sync popover. The\n * `icon-grow` variant will appear as the `icon` variant until hovered or focused,\n * and then grow to appear as the `full` variant.\n */\n variant?: 'icon-only' | 'icon-grow' | 'full';\n\n /**\n * @default true\n * @description Whether to show the sync indicator in the button, e.g. the little colored dot\n */\n showSyncIndicator?: boolean;\n\n /**\n * @default 'end'\n * @description The alignment of the popover content\n */\n align?: React.ComponentProps['align'];\n\n /**\n * @default 'auto'\n * @description This controls the status dot that appears in the button. If `auto` is set, then\n * the component will determine either `disconnected` or `connected`. However, an implementor may\n * choose to override this as `connected-syncing` or `connected-warning`. Note that the component\n * will not verify that the status is valid, it will faithfully render the status you provide.\n */\n status?: 'auto' | GitPlatformSyncStatus;\n\n /**\n * @description Control the open state of the popover\n */\n open?: boolean;\n\n /**\n * @description This directly sets the name of the repository that will be created. Setting this\n * will take precedence over the `defaultName` option. Users will not be able to change from this\n * name.\n */\n repoName?: string;\n\n /**\n * @description If you'd like to suggest a name for the repository, but allow the user to customize it,\n * this is the initial value of the repository name field. This will be ignored if the `name` option is\n * set.\n */\n repoDefaultName?: string;\n\n /**\n * @default 'unique-repo-name…'\n * @description The placeholder text for the repository name field. This will be invisible if the `name`\n * or `defaultName` option is set, but if the user erases the defaultName it will be shown.\n */\n repoNamePlaceholder?: string;\n\n /**\n * @default 'main'\n * @description The branch that will be used to sync within the repository. `main` is used if this is not\n * provided.\n */\n repoDefaultBranch?: string;\n\n /**\n * @description Callback for controlled usage of the the repository name input. Fires\n * the same as the 'onChange' event of the input.\n */\n onRepoNameChange?: (repoName: string) => void;\n\n /**\n * @description callback for the change event of the repo owner combobox. Since this\n * input cannot be controlled, this callback is just for being informed of changes.\n */\n onOwnerChange?: (owner: string) => void;\n\n /**\n * @description Callback when a user clicks the 'Create Repository' button with valid\n * data selected. This is where you will hook into the the code storage endpoints in your app.\n */\n onRepoCreateAction?: (repoData: RepositoryData) => void;\n\n /**\n *\n * @description Callback when the user clicks the 'Help me get started' button. If this callback is\n * not provided, the 'Help me get started' button will not be shown.\n */\n onHelpAction?: () => void;\n\n /**\n * @description Callback when the popover is opened.\n */\n onOpenChange?: (isOpen: boolean) => void;\n\n /**\n * @description The repository that has been synced to code.storage. This is used to\n * display the repository information in the popover. If this is provided, the user will\n * immediately see the syncing page, rather than the welcome or connection page.\n * Set this as a result of the `onRepoCreateAction` callback.\n */\n codeStorageRepo?: SyncedRepo;\n\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n};\n\nexport function GitPlatformSync({\n platforms,\n variant = 'icon-only',\n align = 'end',\n status: statusProp = 'auto',\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch = 'main',\n onHelpAction,\n onRepoCreateAction,\n onOpenChange,\n onRepoNameChange,\n onOwnerChange,\n codeStorageRepo,\n open,\n __container,\n}: GitPlatformSyncProps) {\n const [isPopoverOpen, setIsPopoverOpen] = useState(open ?? false);\n const [isTooltipOpen, setIsTooltipOpen] = useState(false);\n const codeStorageRepoExists = !!codeStorageRepo;\n\n const status = useMemo(() => {\n if (statusProp === 'auto') {\n if (codeStorageRepoExists) {\n return 'connected';\n } else {\n return 'disconnected';\n }\n }\n return statusProp;\n }, [statusProp, codeStorageRepoExists]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any = __container ? { container: __container } : {};\n\n let platformName: string | undefined;\n let platformConfig: PlatformConfigObject | undefined;\n\n const handleOpenChange = useCallback(\n (isOpen: boolean) => {\n setIsPopoverOpen(isOpen);\n if (isOpen) {\n onOpenChange?.(true);\n } else {\n onOpenChange?.(false);\n }\n },\n [onOpenChange]\n );\n\n if (platforms.length === 0) {\n throw new Error('No platforms provided to GitPlatformSync');\n }\n\n if (platforms.length === 1 && platforms[0].platform === 'github') {\n platformName = 'GitHub';\n platformConfig = platforms[0];\n } else {\n throw new Error(\n 'Currently GitPlatformSync only supports a single GitHub platform'\n );\n }\n\n const {\n handleConnect,\n status: connectionStatus,\n fetchInstallationStatus,\n destroy: destroyGitHubAppConnection,\n } = useGitHubAppConnection({\n slug: platformConfig.slug,\n redirectUrl: platformConfig.redirectUrl,\n });\n\n useEffect(() => {\n fetchInstallationStatus();\n }, [fetchInstallationStatus]);\n\n useEffect(() => {\n return () => {\n destroyGitHubAppConnection();\n };\n }, [destroyGitHubAppConnection]);\n\n const labelText = `Sync to ${platformName}`;\n\n // If we don't have any label inside the button we should set an aria-label\n // that describes what that button does.\n const buttonAriaLabelProp =\n variant === 'icon-only'\n ? {\n 'aria-label': labelText,\n }\n : {};\n\n const PopoverConductorProps: PopoverConductorProps = {\n align,\n __container,\n onHelpAction,\n onRepoCreateAction,\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch,\n onRepoNameChange,\n onOwnerChange,\n handleConnect,\n connectionStatus,\n codeStorageRepo,\n };\n\n // TODO: fix full button, and disable tooltip on open popover\n return (\n \n {variant === 'icon-only' ? (\n <>\n \n \n \n \n \n \n {labelText}\n \n \n \n ) : (\n <>\n \n \n \n {labelText}\n \n \n \n \n \n \n )}\n \n );\n}\n\nfunction LilDotGuy({ status }: { status?: GitPlatformSyncStatus }) {\n if (!status || status === 'disconnected') {\n return null;\n }\n return (\n \n );\n}\n\nfunction BaseSyncButton({\n children,\n className,\n status,\n ...props\n}: React.ComponentProps & {\n status?: GitPlatformSyncStatus;\n}) {\n return (\n \n \n \n \n \n {children}\n \n );\n}\n\ntype PopoverConductorProps = Pick<\n GitPlatformSyncProps,\n | 'align'\n | '__container'\n | 'onHelpAction'\n | 'onRepoCreateAction'\n | 'repoName'\n | 'repoDefaultName'\n | 'repoNamePlaceholder'\n | 'repoDefaultBranch'\n | 'onRepoNameChange'\n | 'onOwnerChange'\n | 'codeStorageRepo'\n> & {\n handleConnect: ({ onSuccess }: { onSuccess?: () => void }) => void;\n connectionStatus: GitHubConnectionStatus;\n};\n\nfunction PopoverConductor({\n align,\n __container,\n onHelpAction,\n onRepoCreateAction,\n onRepoNameChange,\n onOwnerChange,\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch,\n handleConnect,\n codeStorageRepo,\n connectionStatus,\n}: PopoverConductorProps) {\n let initialStep: Step = 'welcome';\n if (codeStorageRepo) {\n initialStep = 'manage';\n } else if (connectionStatus === 'installed') {\n initialStep = 'create';\n }\n\n const [step, setStep] = useState(initialStep);\n\n useEffect(() => {\n if (codeStorageRepo) {\n setStep('manage');\n } else if (connectionStatus === 'installed') {\n setStep('create');\n }\n }, [codeStorageRepo, connectionStatus]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any = __container ? { container: __container } : {};\n\n return (\n \n {step === 'welcome' ? (\n setStep('create')}\n onHelpAction={onHelpAction}\n handleConnect={handleConnect}\n connectionStatus={connectionStatus}\n />\n ) : null}\n {step === 'create' ? (\n \n ) : null}\n {step === 'manage' ? (\n \n ) : null}\n \n );\n}\n\ntype StepManageProps = {\n codeStorageRepo: SyncedRepo;\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n};\n\nfunction StatusDot({ status }: { status: GitPlatformSyncStatus }) {\n return (\n \n );\n}\n\nfunction StepManage({ codeStorageRepo, __container }: StepManageProps) {\n const { owners, getOwnerByName } = useOwners();\n const [selectedOwnerId, setSelectedOwnerId] = useState(null);\n\n // TODO: This is inelegant. Since it'll necessarily need an extra render pass. We could move\n // to an uncontrolled combobox and compute the value in the single pass, but idk.\n useEffect(() => {\n if (owners.length > 0) {\n const owner = getOwnerByName(codeStorageRepo.repository.owner);\n console.log(\n 'found owner by name',\n codeStorageRepo.repository.owner,\n owner\n );\n setSelectedOwnerId(owner?.id ?? null);\n }\n }, [owners, codeStorageRepo.repository.owner, getOwnerByName]);\n\n const ownerOptions = useMemo(() => {\n return generateOwnerOptions(owners);\n }, [owners]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any = __container ? { __container: __container } : {};\n return (\n
\n
\n \n Connected to GitHub\n
\n
\n
\n \n \n Owner\n \n \n \n \n /\n
\n \n \n Repository\n \n \n \n
\n
\n \n \n Active Branch\n \n \n \n
\n
\n Changes are being automatically synced to this branch.\n
\n
\n \n );\n}\n\ntype StepCreateProps = {\n onOwnerChange?: (owner: string) => void;\n onRepoNameChange?: (repoName: string) => void;\n // onBranchChange?: (branch: string) => void;\n connectionStatus: GitHubConnectionStatus;\n onRepoCreateAction?: (repoData: RepositoryData) => void;\n handleConnect: ({ onSuccess }: { onSuccess?: () => void }) => void;\n repoName?: string;\n repoDefaultName?: string;\n repoNamePlaceholder?: string;\n repoDefaultBranch?: string;\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n};\n\nfunction StepCreate({\n onOwnerChange,\n onRepoNameChange,\n onRepoCreateAction,\n handleConnect,\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch,\n __container,\n}: StepCreateProps) {\n const { owners, status, getOwnerById, refresh } = useOwners();\n const [selectedOwnerId, setSelectedOwnerId] = useState(null);\n\n // TODO: This is inelegant. Since it'll necessarily need an extra render pass. We could move\n // to an uncontrolled combobox and compute the value in the single pass, but idk.\n useEffect(() => {\n if (owners.length > 0) {\n setSelectedOwnerId(owners[0]?.id ?? null);\n }\n }, [owners]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any = __container ? { __container: __container } : {};\n\n const repoInputProps: React.ComponentProps = useMemo(() => {\n const rip: React.ComponentProps = {};\n if (repoName) {\n rip.defaultValue = repoName;\n } else if (repoDefaultName) {\n rip.defaultValue = repoDefaultName;\n }\n\n const defaultPlaceholder = 'unique-repo-name…';\n if (repoNamePlaceholder) {\n rip.placeholder = repoNamePlaceholder ?? defaultPlaceholder;\n } else {\n rip.placeholder = defaultPlaceholder;\n }\n return rip;\n }, [repoName, repoDefaultName, repoNamePlaceholder]);\n\n const handleSubmit = useCallback(\n (e: React.FormEvent) => {\n e.preventDefault();\n const formData = new FormData(e.currentTarget);\n const repoName = formData.get('repo-name') as string;\n // TODO: show errors in UI\n if (!selectedOwnerId) {\n console.warn('no selectedOwnerId');\n return;\n }\n\n const owner = getOwnerById(selectedOwnerId);\n\n if (!owner) {\n console.warn('no owner found for selectedOwnerId', selectedOwnerId);\n return;\n }\n\n const ownerLogin = owner.login;\n\n if (!ownerLogin) {\n console.warn(\n 'no ownerLogin found for selectedOwnerId',\n selectedOwnerId,\n owner\n );\n return;\n }\n\n if (!onRepoCreateAction) {\n console.warn('no onRepoCreateAction provided');\n return;\n }\n\n onRepoCreateAction({\n owner: ownerLogin,\n name: repoName,\n branch: repoDefaultBranch,\n });\n },\n [selectedOwnerId, getOwnerById, onRepoCreateAction, repoDefaultBranch]\n );\n\n const ownerOptions = useMemo(() => {\n return generateOwnerOptions(owners);\n }, [owners]);\n\n return (\n
\n
\n

Sync to GitHub

\n

\n Create a new repository or choose an existing one to sync your\n changes. We'll push changes with each new prompt you send.\n

\n
\n {status === 'loading' ? (\n
\n \n
\n ) : null}\n {status === 'error' ? (\n
\n \n

\n Error loading GitHub accounts. Please try again.\n

\n
\n ) : null}\n {status === 'success' && owners.length === 0 ? (\n
\n

\n No GitHub accounts found. Please check the app permissions in your\n GitHub settings.\n

\n
\n ) : null}\n {status === 'success' && owners.length > 0 ? (\n
\n
\n
\n \n \n Owner\n \n {\n console.log('owner value changed', value);\n setSelectedOwnerId(value);\n onOwnerChange?.(value);\n }}\n onAddItem={() => {\n handleConnect({\n onSuccess: () => {\n refresh();\n },\n });\n }}\n addItemLabel=\"Add GitHub account…\"\n options={ownerOptions}\n />\n \n \n /\n
\n \n \n Repository\n \n onRepoNameChange?.(e.target.value)}\n />\n \n
\n \n
\n \n ) : null}\n \n );\n}\n\ntype StepWelcomeProps = {\n onAppInstalled?: () => void;\n onHelpAction?: () => void;\n handleConnect: ({ onSuccess }: { onSuccess?: () => void }) => void;\n connectionStatus: GitHubConnectionStatus;\n};\n\nfunction StepWelcome({\n onAppInstalled,\n onHelpAction,\n connectionStatus,\n handleConnect,\n}: StepWelcomeProps) {\n const isPendingConnection = connectionStatus === 'pending';\n const hasError = connectionStatus === 'error';\n\n // TODO: remove this\n if (connectionStatus === 'installed') {\n console.error(\n 'welcome step rendered with installed status, which shouldnt happen'\n );\n }\n\n return (\n <>\n
\n
\n

Connect to GitHub

\n

\n Sync your changes to GitHub to backup your code at every snapshot by\n installing our app on your personal account or organization.\n

\n {hasError ? (\n

\n There was an error connecting to GitHub. Please try again.\n

\n ) : null}\n
\n
\n {\n handleConnect({\n onSuccess: onAppInstalled,\n });\n }\n }\n size=\"lg\"\n className={cn(\n 'w-full',\n isPendingConnection && 'opacity-80 pointer-events-none'\n )}\n >\n {' '}\n {isPendingConnection\n ? 'Connecting to GitHub…'\n : 'Install GitHub App'}\n \n {onHelpAction ? (\n \n Help me get started\n \n ) : null}\n
\n
\n \n );\n}\n", + "content": "import { Button } from '@/components/ui/button';\nimport { Field, FieldLabel } from '@/components/ui/field';\nimport { Input } from '@/components/ui/input';\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from '@/components/ui/popover';\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from '@/components/ui/tooltip';\nimport { cn } from '@/lib/utils';\nimport type * as PopoverPrimitive from '@radix-ui/react-popover';\nimport { AlertCircle, BookOpen, ChevronDown, Loader2 } from 'lucide-react';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\n\nimport { ComboBox } from './combobox';\nimport {\n type GitHubConnectionStatus,\n useGitHubAppConnection,\n} from './github/github-app-connect';\nimport { GitHubIcon } from './github/icon';\nimport { generateOwnerOptions, useOwners } from './github/owners';\n\n// TODO: determine if this is the canonical way to import other components inside of a block\n\nexport type Step = 'welcome' | 'create' | 'manage';\n\nexport type RepositoryData = {\n /**\n * @description The owner of the repository, also referred to as the 'scope' - usually\n * the username of the user or an organization they belong to.\n */\n owner?: string;\n /**\n * @description The name of the repository, this is a 'slug' style name.\n */\n name?: string;\n /**\n * @description The branch of the repository, this is the branch that will be used to sync\n */\n branch?: string;\n};\n\nexport type SyncedRepo = {\n url: string;\n repository: {\n owner: string;\n name: string;\n defaultBranch: string;\n };\n};\n\nexport type GitPlatformSyncStatus =\n | 'disconnected'\n | 'connected'\n | 'connected-syncing'\n | 'connected-warning';\n\n/**\n * @description Platforms that code.storage supports\n */\nexport type SupportedGitPlatform = 'github';\nexport type PlatformConfig_GitHub = {\n platform: 'github';\n slug: string;\n redirectUrl?: string;\n};\n// Currently only github configs are allowed, but in the future more\n// may be supported\nexport type PlatformConfigObject = PlatformConfig_GitHub;\n\nexport type GitPlatformSyncProps = {\n /**\n * @description List of supported platforms that you want to offer to the user. We recommend\n * not setting this until we support more platforms.\n */\n platforms: PlatformConfigObject[];\n\n /**\n * @default 'icon-only'\n * @description Variant display of the button that opens the sync popover. The\n * `icon-grow` variant will appear as the `icon` variant until hovered or focused,\n * and then grow to appear as the `full` variant.\n */\n variant?: 'icon-only' | 'icon-grow' | 'full';\n\n /**\n * @default true\n * @description Whether to show the sync indicator in the button, e.g. the little colored dot\n */\n showSyncIndicator?: boolean;\n\n /**\n * @default 'end'\n * @description The alignment of the popover content\n */\n align?: React.ComponentProps['align'];\n\n /**\n * @default 'auto'\n * @description This controls the status dot that appears in the button. If `auto` is set, then\n * the component will determine either `disconnected` or `connected`. However, an implementor may\n * choose to override this as `connected-syncing` or `connected-warning`. Note that the component\n * will not verify that the status is valid, it will faithfully render the status you provide.\n */\n status?: 'auto' | GitPlatformSyncStatus;\n\n /**\n * @description Control the open state of the popover\n */\n open?: boolean;\n\n /**\n * @description This directly sets the name of the repository that will be created. Setting this\n * will take precedence over the `defaultName` option. Users will not be able to change from this\n * name.\n */\n repoName?: string;\n\n /**\n * @description If you'd like to suggest a name for the repository, but allow the user to customize it,\n * this is the initial value of the repository name field. This will be ignored if the `name` option is\n * set.\n */\n repoDefaultName?: string;\n\n /**\n * @default 'unique-repo-name…'\n * @description The placeholder text for the repository name field. This will be invisible if the `name`\n * or `defaultName` option is set, but if the user erases the defaultName it will be shown.\n */\n repoNamePlaceholder?: string;\n\n /**\n * @default 'main'\n * @description The branch that will be used to sync within the repository. `main` is used if this is not\n * provided.\n */\n repoDefaultBranch?: string;\n\n /**\n * @description Callback for controlled usage of the the repository name input. Fires\n * the same as the 'onChange' event of the input.\n */\n onRepoNameChange?: (repoName: string) => void;\n\n /**\n * @description callback for the change event of the repo owner combobox. Since this\n * input cannot be controlled, this callback is just for being informed of changes.\n */\n onOwnerChange?: (owner: string) => void;\n\n /**\n * @description Callback when a user clicks the 'Create Repository' button with valid\n * data selected. This is where you will hook into the the code storage endpoints in your app.\n */\n onRepoCreateAction?: (repoData: RepositoryData) => void;\n\n /**\n *\n * @description Callback when the user clicks the 'Help me get started' button. If this callback is\n * not provided, the 'Help me get started' button will not be shown.\n */\n onHelpAction?: () => void;\n\n /**\n * @description Callback when the popover is opened.\n */\n onOpenChange?: (isOpen: boolean) => void;\n\n /**\n * @description The repository that has been synced to code.storage. This is used to\n * display the repository information in the popover. If this is provided, the user will\n * immediately see the syncing page, rather than the welcome or connection page.\n * Set this as a result of the `onRepoCreateAction` callback.\n */\n codeStorageRepo?: SyncedRepo;\n\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n};\n\nexport function GitPlatformSync({\n platforms,\n variant = 'icon-only',\n align = 'end',\n status: statusProp = 'auto',\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch = 'main',\n onHelpAction,\n onRepoCreateAction,\n onOpenChange,\n onRepoNameChange,\n onOwnerChange,\n codeStorageRepo,\n open,\n __container,\n}: GitPlatformSyncProps) {\n const [isPopoverOpen, setIsPopoverOpen] = useState(open ?? false);\n const [isTooltipOpen, setIsTooltipOpen] = useState(false);\n const codeStorageRepoExists = codeStorageRepo != null;\n\n const status = useMemo(() => {\n if (statusProp === 'auto') {\n if (codeStorageRepoExists) {\n return 'connected';\n } else {\n return 'disconnected';\n }\n }\n return statusProp;\n }, [statusProp, codeStorageRepoExists]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any =\n __container != null ? { container: __container } : {};\n\n let platformName: string | undefined;\n let platformConfig: PlatformConfigObject | undefined;\n\n const handleOpenChange = useCallback(\n (isOpen: boolean) => {\n setIsPopoverOpen(isOpen);\n if (isOpen) {\n onOpenChange?.(true);\n } else {\n onOpenChange?.(false);\n }\n },\n [onOpenChange]\n );\n\n if (platforms.length === 0) {\n throw new Error('No platforms provided to GitPlatformSync');\n }\n\n if (platforms.length === 1 && platforms[0].platform === 'github') {\n platformName = 'GitHub';\n platformConfig = platforms[0];\n } else {\n throw new Error(\n 'Currently GitPlatformSync only supports a single GitHub platform'\n );\n }\n\n const {\n handleConnect,\n status: connectionStatus,\n fetchInstallationStatus,\n destroy: destroyGitHubAppConnection,\n } = useGitHubAppConnection({\n slug: platformConfig.slug,\n redirectUrl: platformConfig.redirectUrl,\n });\n\n useEffect(() => {\n void fetchInstallationStatus();\n }, [fetchInstallationStatus]);\n\n useEffect(() => {\n return () => {\n destroyGitHubAppConnection();\n };\n }, [destroyGitHubAppConnection]);\n\n const labelText = `Sync to ${platformName}`;\n\n // If we don't have any label inside the button we should set an aria-label\n // that describes what that button does.\n const buttonAriaLabelProp =\n variant === 'icon-only'\n ? {\n 'aria-label': labelText,\n }\n : {};\n\n const PopoverConductorProps: PopoverConductorProps = {\n align,\n __container,\n onHelpAction,\n onRepoCreateAction,\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch,\n onRepoNameChange,\n onOwnerChange,\n handleConnect,\n connectionStatus,\n codeStorageRepo,\n };\n\n // TODO: fix full button, and disable tooltip on open popover\n return (\n \n {variant === 'icon-only' ? (\n <>\n \n \n \n \n \n \n {labelText}\n \n \n \n ) : (\n <>\n \n \n \n {labelText}\n \n \n \n \n \n \n )}\n \n );\n}\n\nfunction LilDotGuy({ status }: { status?: GitPlatformSyncStatus }) {\n if (status == null || status === 'disconnected') {\n return null;\n }\n return (\n \n );\n}\n\nfunction BaseSyncButton({\n children,\n className,\n status,\n ...props\n}: React.ComponentProps & {\n status?: GitPlatformSyncStatus;\n}) {\n return (\n \n \n \n \n \n {children}\n \n );\n}\n\ntype PopoverConductorProps = Pick<\n GitPlatformSyncProps,\n | 'align'\n | '__container'\n | 'onHelpAction'\n | 'onRepoCreateAction'\n | 'repoName'\n | 'repoDefaultName'\n | 'repoNamePlaceholder'\n | 'repoDefaultBranch'\n | 'onRepoNameChange'\n | 'onOwnerChange'\n | 'codeStorageRepo'\n> & {\n handleConnect: ({ onSuccess }: { onSuccess?: () => void }) => void;\n connectionStatus: GitHubConnectionStatus;\n};\n\nfunction PopoverConductor({\n align,\n __container,\n onHelpAction,\n onRepoCreateAction,\n onRepoNameChange,\n onOwnerChange,\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch,\n handleConnect,\n codeStorageRepo,\n connectionStatus,\n}: PopoverConductorProps) {\n let initialStep: Step = 'welcome';\n if (codeStorageRepo != null) {\n initialStep = 'manage';\n } else if (connectionStatus === 'installed') {\n initialStep = 'create';\n }\n\n const [step, setStep] = useState(initialStep);\n\n useEffect(() => {\n if (codeStorageRepo != null) {\n setStep('manage');\n } else if (connectionStatus === 'installed') {\n setStep('create');\n }\n }, [codeStorageRepo, connectionStatus]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any =\n __container != null ? { container: __container } : {};\n\n return (\n \n {step === 'welcome' ? (\n setStep('create')}\n onHelpAction={onHelpAction}\n handleConnect={handleConnect}\n connectionStatus={connectionStatus}\n />\n ) : null}\n {step === 'create' ? (\n \n ) : null}\n {step === 'manage' ? (\n \n ) : null}\n \n );\n}\n\ntype StepManageProps = {\n codeStorageRepo: SyncedRepo;\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n};\n\nfunction StatusDot({ status }: { status: GitPlatformSyncStatus }) {\n return (\n \n );\n}\n\nfunction StepManage({ codeStorageRepo, __container }: StepManageProps) {\n const { owners, getOwnerByName } = useOwners();\n const [selectedOwnerId, setSelectedOwnerId] = useState(null);\n\n // TODO: This is inelegant. Since it'll necessarily need an extra render pass. We could move\n // to an uncontrolled combobox and compute the value in the single pass, but idk.\n useEffect(() => {\n if (owners.length > 0) {\n const owner = getOwnerByName(codeStorageRepo.repository.owner);\n console.log(\n 'found owner by name',\n codeStorageRepo.repository.owner,\n owner\n );\n setSelectedOwnerId(owner?.id ?? null);\n }\n }, [owners, codeStorageRepo.repository.owner, getOwnerByName]);\n\n const ownerOptions = useMemo(() => {\n return generateOwnerOptions(owners);\n }, [owners]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any =\n __container != null ? { __container: __container } : {};\n return (\n
\n
\n \n Connected to GitHub\n
\n
\n
\n \n \n Owner\n \n \n \n \n /\n
\n \n \n Repository\n \n \n \n
\n
\n \n \n Active Branch\n \n \n \n
\n
\n Changes are being automatically synced to this branch.\n
\n
\n \n );\n}\n\ntype StepCreateProps = {\n onOwnerChange?: (owner: string) => void;\n onRepoNameChange?: (repoName: string) => void;\n // onBranchChange?: (branch: string) => void;\n connectionStatus: GitHubConnectionStatus;\n onRepoCreateAction?: (repoData: RepositoryData) => void;\n handleConnect: ({ onSuccess }: { onSuccess?: () => void }) => void;\n repoName?: string;\n repoDefaultName?: string;\n repoNamePlaceholder?: string;\n repoDefaultBranch?: string;\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n};\n\nfunction StepCreate({\n onOwnerChange,\n onRepoNameChange,\n onRepoCreateAction,\n handleConnect,\n repoName,\n repoDefaultName,\n repoNamePlaceholder,\n repoDefaultBranch,\n __container,\n}: StepCreateProps) {\n const { owners, status, getOwnerById, refresh } = useOwners();\n const [selectedOwnerId, setSelectedOwnerId] = useState(null);\n\n // TODO: This is inelegant. Since it'll necessarily need an extra render pass. We could move\n // to an uncontrolled combobox and compute the value in the single pass, but idk.\n useEffect(() => {\n if (owners.length > 0) {\n setSelectedOwnerId(owners[0]?.id ?? null);\n }\n }, [owners]);\n\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any =\n __container != null ? { __container: __container } : {};\n\n const repoInputProps: React.ComponentProps = useMemo(() => {\n const rip: React.ComponentProps = {};\n if ((repoName ?? '').trim() !== '') {\n rip.defaultValue = repoName;\n } else if ((repoDefaultName ?? '').trim() !== '') {\n rip.defaultValue = repoDefaultName;\n }\n\n const defaultPlaceholder = 'unique-repo-name…';\n if ((repoNamePlaceholder ?? '').trim() !== '') {\n rip.placeholder = repoNamePlaceholder ?? defaultPlaceholder;\n } else {\n rip.placeholder = defaultPlaceholder;\n }\n return rip;\n }, [repoName, repoDefaultName, repoNamePlaceholder]);\n\n const handleSubmit = useCallback(\n (e: React.FormEvent) => {\n e.preventDefault();\n const formData = new FormData(e.currentTarget);\n const repoName = formData.get('repo-name') as string;\n // TODO: show errors in UI\n if (selectedOwnerId == null || selectedOwnerId.trim() === '') {\n console.warn('no selectedOwnerId');\n return;\n }\n\n const owner = getOwnerById(selectedOwnerId);\n\n if (owner == null) {\n console.warn('no owner found for selectedOwnerId', selectedOwnerId);\n return;\n }\n\n const ownerLogin = owner.login;\n\n if (ownerLogin == null || ownerLogin.trim() === '') {\n console.warn(\n 'no ownerLogin found for selectedOwnerId',\n selectedOwnerId,\n owner\n );\n return;\n }\n\n if (onRepoCreateAction == null) {\n console.warn('no onRepoCreateAction provided');\n return;\n }\n\n onRepoCreateAction({\n owner: ownerLogin,\n name: repoName,\n branch: repoDefaultBranch,\n });\n },\n [selectedOwnerId, getOwnerById, onRepoCreateAction, repoDefaultBranch]\n );\n\n const ownerOptions = useMemo(() => {\n return generateOwnerOptions(owners);\n }, [owners]);\n\n return (\n
\n
\n

Sync to GitHub

\n

\n Create a new repository or choose an existing one to sync your\n changes. We'll push changes with each new prompt you send.\n

\n
\n {status === 'loading' ? (\n
\n \n
\n ) : null}\n {status === 'error' ? (\n
\n \n

\n Error loading GitHub accounts. Please try again.\n

\n
\n ) : null}\n {status === 'success' && owners.length === 0 ? (\n
\n

\n No GitHub accounts found. Please check the app permissions in your\n GitHub settings.\n

\n
\n ) : null}\n {status === 'success' && owners.length > 0 ? (\n
\n
\n
\n \n \n Owner\n \n {\n console.log('owner value changed', value);\n setSelectedOwnerId(value);\n onOwnerChange?.(value);\n }}\n onAddItem={() => {\n handleConnect({\n onSuccess: () => {\n refresh();\n },\n });\n }}\n addItemLabel=\"Add GitHub account…\"\n options={ownerOptions}\n />\n \n \n /\n
\n \n \n Repository\n \n onRepoNameChange?.(e.target.value)}\n />\n \n
\n \n
\n \n ) : null}\n \n );\n}\n\ntype StepWelcomeProps = {\n onAppInstalled?: () => void;\n onHelpAction?: () => void;\n handleConnect: ({ onSuccess }: { onSuccess?: () => void }) => void;\n connectionStatus: GitHubConnectionStatus;\n};\n\nfunction StepWelcome({\n onAppInstalled,\n onHelpAction,\n connectionStatus,\n handleConnect,\n}: StepWelcomeProps) {\n const isPendingConnection = connectionStatus === 'pending';\n const hasError = connectionStatus === 'error';\n\n // TODO: remove this\n if (connectionStatus === 'installed') {\n console.error(\n 'welcome step rendered with installed status, which shouldnt happen'\n );\n }\n\n return (\n <>\n
\n
\n

Connect to GitHub

\n

\n Sync your changes to GitHub to backup your code at every snapshot by\n installing our app on your personal account or organization.\n

\n {hasError ? (\n

\n There was an error connecting to GitHub. Please try again.\n

\n ) : null}\n
\n
\n {\n handleConnect({\n onSuccess: onAppInstalled,\n });\n }\n }\n size=\"lg\"\n className={cn(\n 'w-full',\n isPendingConnection && 'opacity-80 pointer-events-none'\n )}\n >\n {' '}\n {isPendingConnection\n ? 'Connecting to GitHub…'\n : 'Install GitHub App'}\n \n {onHelpAction != null ? (\n \n Help me get started\n \n ) : null}\n
\n
\n \n );\n}\n", "type": "registry:component" }, { "path": "registry/new-york/blocks/git-platform-sync/combobox.tsx", - "content": "'use client';\n\nimport { Button } from '@/components/ui/button';\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n} from '@/components/ui/command';\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from '@/components/ui/popover';\nimport { cn } from '@/lib/utils';\nimport * as PopoverPrimitive from '@radix-ui/react-popover';\nimport { CheckIcon, ChevronsUpDownIcon, PlusIcon } from 'lucide-react';\nimport * as React from 'react';\nimport { useState } from 'react';\n\nexport type ComboBoxProps = {\n options: {\n value: string;\n label: string;\n image?: string;\n }[];\n className?: string;\n initialValue?: string;\n /**\n * Controlled value. When provided, the component operates in controlled mode.\n */\n value?: string;\n /**\n * Callback fired when the value changes. Receives the option's value (not label).\n */\n onValueChange?: (value: string) => void;\n /**\n * @default 'fit'\n * @description Whether to combobox expands to its container, or fits to the width of the content\n */\n width?: 'fit' | 'full';\n /**\n * @default 'Add item…'\n * @description The label to display for the \"Add item\" action\n */\n addItemLabel?: string;\n /**\n * Callback function to run when \"Add item\" is selected. If this is not defined,\n * then the \"Add item\" action will not be shown.\n */\n onAddItem?: () => void;\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n} & Omit, 'value' | 'onValueChange'>;\n\nexport function ComboBox({\n options,\n className,\n width = 'fit',\n __container,\n initialValue,\n value: controlledValue,\n onAddItem,\n addItemLabel,\n onValueChange,\n ...props\n}: ComboBoxProps) {\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any = __container ? { container: __container } : {};\n const [open, setOpen] = useState(false);\n const [internalValue, setInternalValue] = useState(\n initialValue ?? options[0]?.value ?? null\n );\n\n // Use controlled value if provided, otherwise use internal state\n const isControlled = controlledValue !== undefined;\n const value = isControlled ? controlledValue : internalValue;\n\n const selectedOption = value\n ? options.find((option) => {\n return option.value === value;\n })\n : null;\n\n return (\n \n \n \n {selectedOption ? (\n \n {selectedOption.image ? (\n \n ) : null}\n \n {selectedOption.label}\n \n \n ) : null}\n \n \n \n \n \n {/* TODO: search is silly when there are only 1-5 options */}\n \n \n No results\n \n {options.map((option) => (\n {\n const newValue = currentValue;\n\n // Update internal state if uncontrolled\n if (!isControlled) {\n setInternalValue(newValue);\n }\n\n // Always call onValueChange if provided\n if (newValue !== value) {\n onValueChange?.(newValue);\n }\n\n setOpen(false);\n }}\n >\n {option.image ? (\n \n ) : null}\n \n {option.label}\n \n \n \n ))}\n \n {onAddItem ? (\n <>\n \n \n {\n setOpen(false);\n onAddItem?.();\n return false;\n }}\n >\n \n \n {addItemLabel ?? 'Add item…'}\n \n \n \n \n ) : null}\n \n \n \n \n );\n}\n", + "content": "'use client';\n\nimport { Button } from '@/components/ui/button';\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n} from '@/components/ui/command';\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from '@/components/ui/popover';\nimport { cn } from '@/lib/utils';\nimport type * as PopoverPrimitive from '@radix-ui/react-popover';\nimport { CheckIcon, ChevronsUpDownIcon, PlusIcon } from 'lucide-react';\nimport * as React from 'react';\nimport { useState } from 'react';\n\nexport type ComboBoxProps = {\n options: {\n value: string;\n label: string;\n image?: string;\n }[];\n className?: string;\n initialValue?: string;\n /**\n * Controlled value. When provided, the component operates in controlled mode.\n */\n value?: string;\n /**\n * Callback fired when the value changes. Receives the option's value (not label).\n */\n onValueChange?: (value: string) => void;\n /**\n * @default 'fit'\n * @description Whether to combobox expands to its container, or fits to the width of the content\n */\n width?: 'fit' | 'full';\n /**\n * @default 'Add item…'\n * @description The label to display for the \"Add item\" action\n */\n addItemLabel?: string;\n /**\n * Callback function to run when \"Add item\" is selected. If this is not defined,\n * then the \"Add item\" action will not be shown.\n */\n onAddItem?: () => void;\n /**\n * @deprecated Internal use only, not guaranteed to be supported in the future\n * @description The container to render the popover portal in, only used for docs. This requires\n * modifying the shadcn Popover component to accept a container prop for the portal\n */\n __container?: React.ComponentProps<\n typeof PopoverPrimitive.Portal\n >['container'];\n} & Omit, 'value' | 'onValueChange'>;\n\nexport function ComboBox({\n options,\n className,\n width = 'fit',\n __container,\n initialValue,\n value: controlledValue,\n onAddItem,\n addItemLabel,\n onValueChange,\n ...props\n}: ComboBoxProps) {\n // We want to make sure the container internal stuff doesn't blow up anyone's types\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const containerProp: any =\n __container != null ? { container: __container } : {};\n const [open, setOpen] = useState(false);\n const [internalValue, setInternalValue] = useState(\n initialValue ?? options[0]?.value ?? null\n );\n\n // Use controlled value if provided, otherwise use internal state\n const isControlled = controlledValue !== undefined;\n const value = isControlled ? controlledValue : internalValue;\n\n const selectedOption =\n value.trim() !== ''\n ? options.find((option) => {\n return option.value === value;\n })\n : null;\n\n return (\n \n \n \n {selectedOption != null ? (\n \n {(selectedOption.image ?? '').trim() !== '' ? (\n \n ) : null}\n \n {selectedOption.label}\n \n \n ) : null}\n \n \n \n \n \n {/* TODO: search is silly when there are only 1-5 options */}\n \n \n No results\n \n {options.map((option) => (\n {\n const newValue = currentValue;\n\n // Update internal state if uncontrolled\n if (!isControlled) {\n setInternalValue(newValue);\n }\n\n // Always call onValueChange if provided\n if (newValue !== value) {\n onValueChange?.(newValue);\n }\n\n setOpen(false);\n }}\n >\n {(option.image ?? '').trim() !== '' ? (\n \n ) : null}\n \n {option.label}\n \n \n \n ))}\n \n {onAddItem != null ? (\n <>\n \n \n {\n setOpen(false);\n onAddItem?.();\n return false;\n }}\n >\n \n \n {addItemLabel ?? 'Add item…'}\n \n \n \n \n ) : null}\n \n \n \n \n );\n}\n", "type": "registry:component" }, { @@ -35,35 +35,41 @@ }, { "path": "registry/new-york/blocks/git-platform-sync/github/github-app-connect.ts", - "content": "'use client';\n\nimport { useCallback, useEffect, useMemo, useState } from 'react';\n\nexport interface GitHubAppConnectProps {\n /**\n * @description The slug of the GitHub app to connect to, this is different\n * than the app id or the client id. It's what goes into the installation URL.\n */\n slug: string;\n /**\n * @default '${window.location.origin}/api/code-storage/github/callback'\n * @description The URL to redirect to after the GitHub app is installed.\n * If not provided, we wil use `window.location.origin` as the base url and\n * '/api/code-storage/github/callback' as the path.\n */\n redirectUrl?: string;\n /**\n * @default '/api/code-storage/github/installations'\n * @description The URL to fetch GitHub app installations.\n * If not provided, we will use `/api/code-storage/github/installations` as the path\n * on the same origin that the current window is in.\n */\n installationsUrl?: string;\n}\n\nexport type Owner = {\n id: string;\n login: string;\n type: string | null;\n avatar_url: string | null;\n};\n\ntype InstallationsResponse = {\n installations: Array;\n owners: Owner[];\n};\n\n// Cache configuration\nconst CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes\nlet cachedInstallationsData: {\n data: InstallationsResponse;\n timestamp: number;\n} | null = null;\n\n// Track in-flight requests to prevent duplicate simultaneous requests\nlet pendingFetchInstallations: Promise | null = null;\n\nfunction isCacheValid(): boolean {\n if (!cachedInstallationsData) return false;\n return Date.now() - cachedInstallationsData.timestamp < CACHE_TTL_MS;\n}\n\n/**\n * Manually clear the cached installations data.\n * Useful when you know the data has changed.\n */\nexport function clearInstallationsCache(): void {\n cachedInstallationsData = null;\n}\n\nexport async function fetchInstallations(\n url = '/api/code-storage/github/installations',\n signal?: AbortSignal\n): Promise {\n // Return cached data if still valid\n if (isCacheValid() && cachedInstallationsData) {\n return cachedInstallationsData.data;\n }\n\n // If there's already a pending request, wait for it and return its result\n if (pendingFetchInstallations) {\n return pendingFetchInstallations;\n }\n\n // Create the request promise\n const requestPromise = (async () => {\n try {\n const response = await fetch(url, {\n method: 'GET',\n headers: {\n 'Content-Type': 'application/json',\n },\n signal,\n });\n\n // TODO: handle error cases better, especially if we can begin to pass more\n // info about the error from the server\n if (response.ok) {\n const jsonResult = await response.json();\n const data = jsonResult?.data;\n\n if (data && 'installations' in data) {\n const result: InstallationsResponse = {\n installations: Array.isArray(data.installations)\n ? data.installations\n : [],\n owners: Array.isArray(data.owners) ? data.owners : [],\n };\n\n // Cache the successful response\n cachedInstallationsData = {\n data: result,\n timestamp: Date.now(),\n };\n\n return result;\n } else {\n console.warn(\n 'Warning: fetching installations - response has unexpected shape, falling back to empty array.'\n );\n return { installations: [], owners: [] };\n }\n } else {\n throw new Error('installations endpoint not ok');\n }\n } finally {\n // Clear the pending request when done (success or error)\n pendingFetchInstallations = null;\n }\n })();\n\n pendingFetchInstallations = requestPromise;\n return requestPromise;\n}\n\nfunction hasInstallation(data: InstallationsResponse): boolean {\n return data.installations.length > 0;\n}\n\nexport type GitHubConnectionStatus =\n | 'uninitialized'\n | 'pending'\n | 'installed'\n | 'error';\n\nexport type GitHubAppConnectionHandlerProps = {\n onSuccess?: () => void;\n};\n\n/**\n * Encapsulates all imperative logic for managing GitHub App installation flow.\n * This class handles popup windows, message listeners, and polling in a way that\n * avoids React closure/stale reference issues.\n */\nclass GitHubAppConnector {\n private popup: Window | null = null;\n private checkInterval: ReturnType | null = null;\n private abortController = new AbortController();\n private connectionStatus: GitHubConnectionStatus = 'uninitialized';\n private stableId = crypto.randomUUID();\n\n constructor(\n private config: {\n slug: string;\n origin: string;\n redirectUrl: string;\n installationsUrl: string;\n },\n private onStatusChange: (status: GitHubConnectionStatus) => void\n ) {}\n\n private setStatus(status: GitHubConnectionStatus) {\n this.connectionStatus = status;\n this.onStatusChange(status);\n }\n\n private handleMessage = async (event: MessageEvent) => {\n if (event.origin !== this.config.origin) {\n return;\n }\n\n // If we're getting the right event type, and the unique\n // state id matches this instance, we can proceed\n if (\n event.data.type === 'git-platform-sync-app-installed--github' &&\n event.data.state === this.stableId\n ) {\n try {\n const data = await fetchInstallations(\n this.config.installationsUrl,\n this.abortController.signal\n );\n\n if (hasInstallation(data)) {\n this.setStatus('installed');\n } else {\n this.setStatus('uninitialized');\n }\n } catch {\n this.setStatus('error');\n }\n }\n };\n\n async connect(props?: GitHubAppConnectionHandlerProps) {\n const onSuccess = props?.onSuccess;\n const width = 600;\n const height = 700;\n const left = window.screen.width / 2 - width / 2;\n const top = window.screen.height / 2 - height / 2;\n\n // Build the installation URL with redirect\n const baseUrl = `https://github.com/apps/${this.config.slug}/installations/new`;\n const url = `${baseUrl}?redirect_uri=${encodeURIComponent(this.config.redirectUrl)}&state=${encodeURIComponent(this.stableId)}`;\n\n this.setStatus('pending');\n\n this.popup = window.open(\n url,\n 'code-storage-github-app-install',\n `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no`\n );\n\n this.checkInterval = setInterval(async () => {\n if (this.connectionStatus === 'installed') {\n this.clearInterval();\n onSuccess?.();\n } else if (!this.popup || this.popup.closed) {\n // Nothing new can happen if the pop-up is gone, so lets clear the interval\n this.clearInterval();\n\n // If the connection was closed while still in the 'pending' state,\n // the user likely closed the pop-up before the handleMessage listener\n // was able to receive a message (regardless of whether one was sent).\n if (this.connectionStatus === 'pending') {\n try {\n // We need to determine if the app was installed or not.\n const data = await fetchInstallations(\n this.config.installationsUrl,\n this.abortController.signal\n );\n\n if (hasInstallation(data)) {\n this.setStatus('installed');\n onSuccess?.();\n } else {\n this.setStatus('uninitialized');\n }\n } catch {\n this.setStatus('error');\n }\n }\n }\n }, 1000);\n\n // Use AbortController for automatic cleanup\n window.addEventListener('message', this.handleMessage, {\n signal: this.abortController.signal,\n });\n }\n\n async fetchInstallationStatus() {\n this.setStatus('pending');\n\n try {\n const data = await fetchInstallations(\n this.config.installationsUrl,\n this.abortController.signal\n );\n if (hasInstallation(data)) {\n this.setStatus('installed');\n } else {\n this.setStatus('uninitialized');\n }\n } catch {\n this.setStatus('error');\n }\n }\n\n private clearInterval() {\n if (this.checkInterval) {\n clearInterval(this.checkInterval);\n this.checkInterval = null;\n }\n }\n\n destroy() {\n // AbortController automatically removes all listeners and aborts any in-flight fetches\n this.abortController.abort();\n this.clearInterval();\n this.popup?.close();\n this.popup = null;\n }\n}\n\nexport function useGitHubAppConnection({\n slug,\n redirectUrl: redirectUrlProp,\n installationsUrl,\n}: GitHubAppConnectProps) {\n const [status, setStatus] = useState('uninitialized');\n\n const origin = typeof window !== 'undefined' ? window.location.origin : '';\n const redirectUrl =\n redirectUrlProp ?? `${origin}/api/code-storage/github/callback`;\n const installationsUrlResolved =\n installationsUrl ?? '/api/code-storage/github/installations';\n\n // Create the connector once - all config is captured at creation time.\n // We intentionally create this only once to avoid recreating listeners and intervals.\n // Config changes during the component lifecycle are not supported by design.\n const connector = useMemo(() => {\n return new GitHubAppConnector(\n {\n slug,\n origin,\n redirectUrl,\n installationsUrl: installationsUrlResolved,\n },\n setStatus\n );\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n return () => connector.destroy();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Return stable callbacks that delegate to the connector\n const handleConnect = useCallback(\n (props?: GitHubAppConnectionHandlerProps) => {\n connector.connect(props);\n },\n [connector]\n );\n\n const fetchInstallationStatus = useCallback(async () => {\n await connector.fetchInstallationStatus();\n }, [connector]);\n\n const destroy = useCallback(() => {\n connector.destroy();\n }, [connector]);\n\n return {\n status,\n handleConnect,\n fetchInstallationStatus,\n destroy,\n };\n}\n", + "content": "'use client';\n\nimport { useCallback, useEffect, useMemo, useState } from 'react';\n\nexport interface GitHubAppConnectProps {\n /**\n * @description The slug of the GitHub app to connect to, this is different\n * than the app id or the client id. It's what goes into the installation URL.\n */\n slug: string;\n /**\n * @default '${window.location.origin}/api/code-storage/github/callback'\n * @description The URL to redirect to after the GitHub app is installed.\n * If not provided, we wil use `window.location.origin` as the base url and\n * '/api/code-storage/github/callback' as the path.\n */\n redirectUrl?: string;\n /**\n * @default '/api/code-storage/github/installations'\n * @description The URL to fetch GitHub app installations.\n * If not provided, we will use `/api/code-storage/github/installations` as the path\n * on the same origin that the current window is in.\n */\n installationsUrl?: string;\n}\n\nexport type Owner = {\n id: string;\n login: string;\n type: string | null;\n avatar_url: string | null;\n};\n\ntype InstallationsResponse = {\n installations: Array;\n owners: Owner[];\n};\n\n// Cache configuration\nconst CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes\nlet cachedInstallationsData: {\n data: InstallationsResponse;\n timestamp: number;\n} | null = null;\n\n// Track in-flight requests to prevent duplicate simultaneous requests\nlet pendingFetchInstallations: Promise | null = null;\n\nfunction isCacheValid(): boolean {\n if (cachedInstallationsData == null) return false;\n return Date.now() - cachedInstallationsData.timestamp < CACHE_TTL_MS;\n}\n\n/**\n * Manually clear the cached installations data.\n * Useful when you know the data has changed.\n */\nexport function clearInstallationsCache(): void {\n cachedInstallationsData = null;\n}\n\nexport async function fetchInstallations(\n url = '/api/code-storage/github/installations',\n signal?: AbortSignal\n): Promise {\n // Return cached data if still valid\n if (isCacheValid() && cachedInstallationsData != null) {\n return cachedInstallationsData.data;\n }\n\n // If there's already a pending request, wait for it and return its result\n if (pendingFetchInstallations != null) {\n return pendingFetchInstallations;\n }\n\n // Create the request promise\n const requestPromise = (async () => {\n try {\n const response = await fetch(url, {\n method: 'GET',\n headers: {\n 'Content-Type': 'application/json',\n },\n signal,\n });\n\n // TODO: handle error cases better, especially if we can begin to pass more\n // info about the error from the server\n if (response.ok) {\n const jsonResult = await response.json();\n const data = jsonResult?.data;\n\n if (data != null && 'installations' in data) {\n const result: InstallationsResponse = {\n installations: Array.isArray(data.installations)\n ? data.installations\n : [],\n owners: Array.isArray(data.owners) ? data.owners : [],\n };\n\n // Cache the successful response\n cachedInstallationsData = {\n data: result,\n timestamp: Date.now(),\n };\n\n return result;\n } else {\n console.warn(\n 'Warning: fetching installations - response has unexpected shape, falling back to empty array.'\n );\n return { installations: [], owners: [] };\n }\n } else {\n throw new Error('installations endpoint not ok');\n }\n } finally {\n // Clear the pending request when done (success or error)\n pendingFetchInstallations = null;\n }\n })();\n\n pendingFetchInstallations = requestPromise;\n return requestPromise;\n}\n\nfunction hasInstallation(data: InstallationsResponse): boolean {\n return data.installations.length > 0;\n}\n\nexport type GitHubConnectionStatus =\n | 'uninitialized'\n | 'pending'\n | 'installed'\n | 'error';\n\nexport type GitHubAppConnectionHandlerProps = {\n onSuccess?: () => void;\n};\n\n/**\n * Encapsulates all imperative logic for managing GitHub App installation flow.\n * This class handles popup windows, message listeners, and polling in a way that\n * avoids React closure/stale reference issues.\n */\nclass GitHubAppConnector {\n private popup: Window | null = null;\n private checkInterval: ReturnType | null = null;\n private abortController = new AbortController();\n private connectionStatus: GitHubConnectionStatus = 'uninitialized';\n private stableId = crypto.randomUUID();\n\n constructor(\n private config: {\n slug: string;\n origin: string;\n redirectUrl: string;\n installationsUrl: string;\n },\n private onStatusChange: (status: GitHubConnectionStatus) => void\n ) {}\n\n private setStatus(status: GitHubConnectionStatus) {\n this.connectionStatus = status;\n this.onStatusChange(status);\n }\n\n private handleMessage = async (event: MessageEvent) => {\n if (event.origin !== this.config.origin) {\n return;\n }\n\n // If we're getting the right event type, and the unique\n // state id matches this instance, we can proceed\n if (\n event.data.type === 'git-platform-sync-app-installed--github' &&\n event.data.state === this.stableId\n ) {\n try {\n const data = await fetchInstallations(\n this.config.installationsUrl,\n this.abortController.signal\n );\n\n if (hasInstallation(data)) {\n this.setStatus('installed');\n } else {\n this.setStatus('uninitialized');\n }\n } catch {\n this.setStatus('error');\n }\n }\n };\n\n // eslint-disable-next-line @typescript-eslint/require-await\n async connect(props?: GitHubAppConnectionHandlerProps) {\n const onSuccess = props?.onSuccess;\n const width = 600;\n const height = 700;\n const left = window.screen.width / 2 - width / 2;\n const top = window.screen.height / 2 - height / 2;\n\n // Build the installation URL with redirect\n const baseUrl = `https://github.com/apps/${this.config.slug}/installations/new`;\n const url = `${baseUrl}?redirect_uri=${encodeURIComponent(this.config.redirectUrl)}&state=${encodeURIComponent(this.stableId)}`;\n\n this.setStatus('pending');\n\n this.popup = window.open(\n url,\n 'code-storage-github-app-install',\n `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no`\n );\n\n // eslint-disable-next-line @typescript-eslint/no-misused-promises\n this.checkInterval = setInterval(async () => {\n if (this.connectionStatus === 'installed') {\n this.clearInterval();\n onSuccess?.();\n } else if (this.popup == null || this.popup.closed) {\n // Nothing new can happen if the pop-up is gone, so lets clear the interval\n this.clearInterval();\n\n // If the connection was closed while still in the 'pending' state,\n // the user likely closed the pop-up before the handleMessage listener\n // was able to receive a message (regardless of whether one was sent).\n if (this.connectionStatus === 'pending') {\n try {\n // We need to determine if the app was installed or not.\n const data = await fetchInstallations(\n this.config.installationsUrl,\n this.abortController.signal\n );\n\n if (hasInstallation(data)) {\n this.setStatus('installed');\n onSuccess?.();\n } else {\n this.setStatus('uninitialized');\n }\n } catch {\n this.setStatus('error');\n }\n }\n }\n }, 1000);\n\n // Use AbortController for automatic cleanup\n // eslint-disable-next-line @typescript-eslint/no-misused-promises\n window.addEventListener('message', this.handleMessage, {\n signal: this.abortController.signal,\n });\n }\n\n async fetchInstallationStatus() {\n this.setStatus('pending');\n\n try {\n const data = await fetchInstallations(\n this.config.installationsUrl,\n this.abortController.signal\n );\n if (hasInstallation(data)) {\n this.setStatus('installed');\n } else {\n this.setStatus('uninitialized');\n }\n } catch {\n this.setStatus('error');\n }\n }\n\n private clearInterval() {\n if (this.checkInterval != null) {\n clearInterval(this.checkInterval);\n this.checkInterval = null;\n }\n }\n\n destroy() {\n // AbortController automatically removes all listeners and aborts any in-flight fetches\n this.abortController.abort();\n this.clearInterval();\n this.popup?.close();\n this.popup = null;\n }\n}\n\nexport function useGitHubAppConnection({\n slug,\n redirectUrl: redirectUrlProp,\n installationsUrl,\n}: GitHubAppConnectProps) {\n const [status, setStatus] = useState('uninitialized');\n\n const origin = typeof window !== 'undefined' ? window.location.origin : '';\n const redirectUrl =\n redirectUrlProp ?? `${origin}/api/code-storage/github/callback`;\n const installationsUrlResolved =\n installationsUrl ?? '/api/code-storage/github/installations';\n\n // Create the connector once - all config is captured at creation time.\n // We intentionally create this only once to avoid recreating listeners and intervals.\n // Config changes during the component lifecycle are not supported by design.\n const connector = useMemo(() => {\n return new GitHubAppConnector(\n {\n slug,\n origin,\n redirectUrl,\n installationsUrl: installationsUrlResolved,\n },\n setStatus\n );\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n return () => connector.destroy();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Return stable callbacks that delegate to the connector\n const handleConnect = useCallback(\n (props?: GitHubAppConnectionHandlerProps) => {\n void connector.connect(props);\n },\n [connector]\n );\n\n const fetchInstallationStatus = useCallback(async () => {\n await connector.fetchInstallationStatus();\n }, [connector]);\n\n const destroy = useCallback(() => {\n connector.destroy();\n }, [connector]);\n\n return {\n status,\n handleConnect,\n fetchInstallationStatus,\n destroy,\n };\n}\n", "type": "registry:lib" }, { "path": "registry/new-york/blocks/git-platform-sync/github/owners.ts", - "content": "import { useEffect, useMemo, useState } from 'react';\n\nimport {\n type Owner,\n clearInstallationsCache,\n fetchInstallations,\n} from './github-app-connect';\n\nexport type { Owner };\n\nexport type OwnersFetchStatus = 'loading' | 'error' | 'success';\n\ntype FetchOwnersResult = {\n error: Error | null;\n data:\n | {\n owners: Owner[];\n }\n | undefined;\n};\n\nasync function fetchOwners(signal?: AbortSignal): Promise {\n try {\n const response = await fetchInstallations(\n '/api/code-storage/github/installations',\n signal\n );\n return {\n error: null,\n data: { owners: response.owners },\n };\n } catch (e) {\n // Don't set error state if the request was aborted\n if (e instanceof Error && e.name === 'AbortError') {\n return { error: null, data: undefined };\n }\n return {\n error: new Error('Failed to fetch owners'),\n data: undefined,\n };\n }\n}\n\n/**\n * Manually clear the cached owners data.\n * Useful when you know the data has changed (e.g., after adding a new repository).\n */\nexport function clearOwnersCache(): void {\n clearInstallationsCache();\n}\n\nexport function useOwners() {\n const [owners, setOwners] = useState([]);\n const [status, setStatus] = useState('loading');\n const [bustVersion, setBustVersion] = useState(0);\n\n useEffect(() => {\n const abortController = new AbortController();\n\n const fetchEffect = async () => {\n setStatus('loading');\n const { error, data } = await fetchOwners(abortController.signal);\n\n // Don't update state if the component was unmounted\n if (abortController.signal.aborted) {\n return;\n }\n\n if (error || data === null) {\n setStatus('error');\n } else if (data) {\n setOwners(data.owners);\n setStatus('success');\n }\n };\n\n fetchEffect();\n\n return () => {\n abortController.abort();\n };\n }, [bustVersion]);\n\n const ownerMap = useMemo(() => {\n const map = new Map();\n for (const owner of owners) {\n map.set(owner.id, owner);\n }\n return map;\n }, [owners]);\n\n return {\n owners,\n status,\n getOwnerById: (id: string) => {\n return ownerMap.get(id);\n },\n getOwnerByName: (name: string) => {\n return owners.find((owner) => owner.login === name);\n },\n refresh: () => {\n clearOwnersCache();\n setBustVersion(bustVersion + 1);\n },\n };\n}\n\nexport function generateOwnerOptions(owners: Owner[]) {\n return owners.map((owner) => ({\n value: owner.id,\n label: owner.login,\n image: owner.avatar_url,\n }));\n}\n", + "content": "import { useEffect, useMemo, useState } from 'react';\n\nimport {\n type Owner,\n clearInstallationsCache,\n fetchInstallations,\n} from './github-app-connect';\n\nexport type { Owner };\n\nexport type OwnersFetchStatus = 'loading' | 'error' | 'success';\n\ntype FetchOwnersResult = {\n error: Error | null;\n data:\n | {\n owners: Owner[];\n }\n | undefined;\n};\n\nasync function fetchOwners(signal?: AbortSignal): Promise {\n try {\n const response = await fetchInstallations(\n '/api/code-storage/github/installations',\n signal\n );\n return {\n error: null,\n data: { owners: response.owners },\n };\n } catch (e) {\n // Don't set error state if the request was aborted\n if (e instanceof Error && e.name === 'AbortError') {\n return { error: null, data: undefined };\n }\n return {\n error: new Error('Failed to fetch owners'),\n data: undefined,\n };\n }\n}\n\n/**\n * Manually clear the cached owners data.\n * Useful when you know the data has changed (e.g., after adding a new repository).\n */\nexport function clearOwnersCache(): void {\n clearInstallationsCache();\n}\n\nexport function useOwners() {\n const [owners, setOwners] = useState([]);\n const [status, setStatus] = useState('loading');\n const [bustVersion, setBustVersion] = useState(0);\n\n useEffect(() => {\n const abortController = new AbortController();\n\n const fetchEffect = async () => {\n setStatus('loading');\n const { error, data } = await fetchOwners(abortController.signal);\n\n // Don't update state if the component was unmounted\n if (abortController.signal.aborted) {\n return;\n }\n\n if (error != null || data === null) {\n setStatus('error');\n } else if (data != null) {\n setOwners(data.owners);\n setStatus('success');\n }\n };\n\n void fetchEffect();\n\n return () => {\n abortController.abort();\n };\n }, [bustVersion]);\n\n const ownerMap = useMemo(() => {\n const map = new Map();\n for (const owner of owners) {\n map.set(owner.id, owner);\n }\n return map;\n }, [owners]);\n\n return {\n owners,\n status,\n getOwnerById: (id: string) => {\n return ownerMap.get(id);\n },\n getOwnerByName: (name: string) => {\n return owners.find((owner) => owner.login === name);\n },\n refresh: () => {\n clearOwnersCache();\n setBustVersion(bustVersion + 1);\n },\n };\n}\n\nexport function generateOwnerOptions(owners: Owner[]) {\n return owners.map((owner) => ({\n value: owner.id,\n label: owner.login,\n image: owner.avatar_url,\n }));\n}\n", "type": "registry:lib" }, { "path": "registry/new-york/blocks/git-platform-sync/pages/code-storage/success/page.tsx", - "content": "'use client';\n\nimport { Button } from '@/components/ui/button';\nimport { CheckCircle } from 'lucide-react';\nimport { useSearchParams } from 'next/navigation';\nimport { Suspense, useEffect } from 'react';\n\nconst appInstallType = 'git-platform-sync-app-installed--github';\n\nfunction SuccessPageContent() {\n const searchParams = useSearchParams();\n const installationId = searchParams.get('installation_id');\n const setupAction = searchParams.get('setup_action');\n const state = searchParams.get('state');\n\n useEffect(() => {\n if (window.opener) {\n window.opener.postMessage(\n {\n type: appInstallType,\n installationId,\n setupAction,\n state,\n },\n window.opener.origin\n );\n\n setTimeout(() => {\n window.close();\n }, 2000);\n }\n }, [installationId, setupAction, state]);\n\n return (\n
\n
\n
\n \n

\n Installation Successful!\n

\n

\n Your GitHub App has been successfully{' '}\n {setupAction === 'install' ? 'installed' : 'updated'}.\n

\n {installationId && (\n

\n Installation ID: {installationId}\n

\n )}\n
\n

\n This window will close automatically…\n

\n {\n if (window.opener) {\n window.opener.postMessage(\n {\n type: appInstallType,\n installationId,\n setupAction,\n state,\n },\n window.opener.origin\n );\n\n setTimeout(() => {\n window.close();\n }, 0);\n } else {\n window.location.href = '/';\n }\n }}\n className=\"w-full\"\n >\n Close Window\n \n
\n
\n
\n
\n );\n}\n\nexport default function SuccessPage() {\n return (\n \n \n \n );\n}\n", + "content": "'use client';\n\nimport { Button } from '@/components/ui/button';\nimport { CheckCircle } from 'lucide-react';\nimport { useSearchParams } from 'next/navigation';\nimport { Suspense, useEffect } from 'react';\n\nconst appInstallType = 'git-platform-sync-app-installed--github';\n\nfunction SuccessPageContent() {\n const searchParams = useSearchParams();\n const installationId = searchParams.get('installation_id');\n const setupAction = searchParams.get('setup_action');\n const state = searchParams.get('state');\n\n useEffect(() => {\n if (window.opener != null) {\n // eslint-disable-next-line @typescript-eslint/no-unsafe-call\n window.opener.postMessage(\n {\n type: appInstallType,\n installationId,\n setupAction,\n state,\n },\n window.opener.origin\n );\n\n setTimeout(() => {\n window.close();\n }, 2000);\n }\n }, [installationId, setupAction, state]);\n\n return (\n
\n
\n
\n \n

\n Installation Successful!\n

\n

\n Your GitHub App has been successfully{' '}\n {setupAction === 'install' ? 'installed' : 'updated'}.\n

\n {installationId != null && (\n

\n Installation ID: {installationId}\n

\n )}\n
\n

\n This window will close automatically…\n

\n {\n if (window.opener != null) {\n // eslint-disable-next-line @typescript-eslint/no-unsafe-call\n window.opener.postMessage(\n {\n type: appInstallType,\n installationId,\n setupAction,\n state,\n },\n window.opener.origin\n );\n\n setTimeout(() => {\n window.close();\n }, 0);\n } else {\n window.location.href = '/';\n }\n }}\n className=\"w-full\"\n >\n Close Window\n \n
\n
\n
\n
\n );\n}\n\nexport default function SuccessPage() {\n return (\n \n \n \n );\n}\n", "type": "registry:page", "target": "app/code-storage/success/page.tsx" }, { "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/github/callback/route.ts", - "content": "import { NextRequest, NextResponse } from 'next/server';\n\nexport async function GET(request: NextRequest) {\n const searchParams = request.nextUrl.searchParams;\n const code = searchParams.get('code');\n const installationId = searchParams.get('installation_id');\n const setupAction = searchParams.get('setup_action');\n const state = searchParams.get('state');\n\n if (!code) {\n return NextResponse.json({ error: 'No code provided' }, { status: 400 });\n }\n\n try {\n const tokenResponse = await fetch(\n 'https://github.com/login/oauth/access_token',\n {\n method: 'POST',\n headers: {\n Accept: 'application/json',\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n client_id: process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID,\n client_secret: process.env.GITHUB_CLIENT_SECRET,\n code,\n }),\n }\n );\n\n const tokenData = await tokenResponse.json();\n\n if (tokenData.error) {\n throw new Error(tokenData.error_description || tokenData.error);\n }\n\n const successUrl = new URL('/code-storage/success', request.url);\n if (setupAction) {\n successUrl.searchParams.set('setup_action', setupAction);\n }\n if (installationId) {\n successUrl.searchParams.set('installation_id', installationId);\n }\n if (state) {\n successUrl.searchParams.set('state', state);\n }\n const response = NextResponse.redirect(successUrl);\n\n // Store only the user's OAuth token - it works across all installations\n response.cookies.set('github_token', tokenData.access_token, {\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n maxAge: 60 * 60 * 24 * 30,\n });\n\n return response;\n } catch (error) {\n console.error('OAuth error:', error);\n return NextResponse.json(\n { error: 'Failed to exchange code for token' },\n { status: 500 }\n );\n }\n}\n", + "content": "import { NextRequest } from 'next/server';\n\nimport { CodeStorageSuccessCallback } from './success-callback';\n\nconst successCallback = new CodeStorageSuccessCallback({\n clientId: process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID!,\n clientSecret: process.env.GITHUB_CLIENT_SECRET!,\n platform: 'github',\n env: process.env.NODE_ENV === 'production' ? 'production' : 'development',\n});\n\nexport async function GET(request: NextRequest) {\n return successCallback.handleRequest(request);\n}\n", "type": "registry:page", "target": "app/api/code-storage/github/callback/route.ts" }, + { + "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/github/callback/success-callback.ts", + "content": "import { NextRequest, NextResponse } from 'next/server';\n\nexport type GitHubOAuthTokenResponse =\n | {\n access_token: string;\n token_type: 'bearer';\n scope: string;\n }\n | {\n error: string;\n error_description: string;\n error_uri: string;\n };\n\nexport type AvailablePlatform = 'github';\nexport type CodeStorageSuccessCallbackOptions = {\n clientId: string;\n clientSecret: string;\n platform: AvailablePlatform;\n redirectUrl?: string;\n env: 'production' | (string & {});\n};\n\nexport class CodeStorageSuccessCallback {\n private clientId: string;\n private clientSecret: string;\n private redirectUrl?: string;\n private env: 'production' | (string & {}) = 'production';\n\n constructor(options: CodeStorageSuccessCallbackOptions) {\n if (options.platform !== 'github') {\n throw new Error(\n `CodeStorageSuccessCallback Error: Invalid platform: ${options.platform}`\n );\n }\n this.clientId = options.clientId;\n this.clientSecret = options.clientSecret;\n this.redirectUrl = options.redirectUrl;\n this.env = options.env;\n }\n\n private getParams(request: NextRequest) {\n const searchParams = request.nextUrl.searchParams;\n\n const code = searchParams.get('code');\n const installationId = searchParams.get('installation_id');\n const setupAction = searchParams.get('setup_action');\n const state = searchParams.get('state');\n\n if (!code) {\n throw new Error('CodeStorageSuccessCallback Error: No code provided');\n } else if (!installationId) {\n throw new Error(\n 'CodeStorageSuccessCallback Error: No installation ID provided'\n );\n } else if (!setupAction) {\n throw new Error(\n 'CodeStorageSuccessCallback Error: No setup action provided'\n );\n } else if (!state) {\n throw new Error('CodeStorageSuccessCallback Error: No state provided');\n }\n\n return { code, installationId, setupAction, state };\n }\n\n private generateRedirectUrl(\n request: NextRequest,\n {\n installationId,\n setupAction,\n state,\n }: { installationId: string; setupAction?: string; state: string }\n ) {\n if (this.redirectUrl) {\n return this.redirectUrl;\n }\n\n const successUrl = new URL('/code-storage/success', request.url);\n\n if (setupAction) {\n successUrl.searchParams.set('setup_action', setupAction);\n }\n if (installationId) {\n successUrl.searchParams.set('installation_id', installationId);\n }\n if (state) {\n successUrl.searchParams.set('state', state);\n }\n return successUrl;\n }\n\n async handleRequest(request: NextRequest) {\n try {\n const { code, installationId, setupAction, state } =\n this.getParams(request);\n\n const tokenResponse = await fetch(\n 'https://github.com/login/oauth/access_token',\n {\n method: 'POST',\n headers: {\n Accept: 'application/json',\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n client_id: this.clientId,\n client_secret: this.clientSecret,\n code,\n }),\n }\n );\n\n const tokenData =\n (await tokenResponse.json()) as unknown as GitHubOAuthTokenResponse;\n\n if ('error' in tokenData) {\n throw new Error(tokenData.error_description || tokenData.error);\n }\n\n const successUrl = this.generateRedirectUrl(request, {\n installationId,\n setupAction,\n state,\n });\n\n const response = NextResponse.redirect(successUrl);\n\n response.cookies.set('github_token', tokenData.access_token, {\n httpOnly: true,\n secure: this.env === 'production',\n sameSite: 'lax',\n maxAge: 60 * 60 * 24 * 30,\n });\n\n return response;\n } catch (error) {\n console.error('CodeStorageSuccessCallback Error:', error);\n return NextResponse.json(\n {\n error:\n error instanceof Error\n ? error.message\n : 'CodeStorageSuccessCallback Error',\n },\n { status: 400 }\n );\n }\n }\n}\n", + "type": "registry:page", + "target": "app/api/code-storage/github/callback/success-callback.ts" + }, { "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/github/installations/route.ts", - "content": "import type { components } from '@octokit/openapi-types';\nimport { NextRequest, NextResponse } from 'next/server';\n\ntype Installation = components['schemas']['installation'];\n\ntype Owner = components['schemas']['simple-user'] &\n components['schemas']['enterprise'];\n\ntype InstallationResponse = {\n installations: Installation[];\n};\n\ntype FilteredInstallation = {\n id: string;\n app_id: string;\n app_slug: string;\n permissions: Record;\n owner_id: string | null;\n events: string[];\n type: string | null;\n};\n\ntype FilteredOwner = {\n id: string;\n login: string;\n type: string | null;\n avatar_url: string | null;\n};\n\nfunction filterInstallations(installations: Installation[]) {\n return installations.map((installation) => {\n return {\n id: `${installation.id}`,\n app_id: `${installation.app_id}`,\n app_slug: installation.app_slug,\n owner_id:\n installation.account &&\n ('id' in installation.account ? `${installation.account.id}` : null),\n permissions: installation.permissions,\n events: installation.events,\n type:\n installation.account &&\n ('type' in installation.account ? installation.account.type : null),\n } satisfies FilteredInstallation;\n });\n}\n\nfunction filterOwners(owners: Owner[]) {\n return owners.map((owner) => {\n return {\n id: `${owner.id}`,\n login: owner.login,\n type: owner.type,\n avatar_url: owner.avatar_url,\n } satisfies FilteredOwner;\n });\n}\n\nfunction getOwnersFromInstallations(installations: Installation[]) {\n return installations\n .map((installation) => installation.account)\n .filter((account): account is Owner => account !== null);\n}\n\nexport async function GET(request: NextRequest) {\n const token = request.cookies.get('github_token')?.value;\n\n if (!token) {\n return NextResponse.json({ data: { installations: [], owners: [] } });\n }\n\n try {\n const response = await fetch(`https://api.github.com/user/installations`, {\n headers: {\n Authorization: `Bearer ${token}`,\n Accept: 'application/vnd.github.v3+json',\n },\n });\n\n if (!response.ok) {\n return NextResponse.json(\n { error: 'Failed to fetch installations' },\n { status: 400 }\n );\n }\n\n const data = (await response.json()) as InstallationResponse;\n\n if (!data?.installations?.length) {\n return NextResponse.json({\n data: {\n installations: [],\n owners: [],\n },\n });\n }\n\n const filteredInstallations = filterInstallations(data.installations);\n\n const filteredOwners = filterOwners(\n getOwnersFromInstallations(data.installations)\n );\n\n return NextResponse.json({\n data: {\n installations: filteredInstallations,\n owners: filteredOwners,\n },\n // for debugging this can be nice\n // _raw: data.installations,\n });\n } catch (error) {\n console.error('Error fetching installations:', error);\n return NextResponse.json(\n {\n error: 'Failed to fetch installations',\n errorMessage: error instanceof Error ? error.message : 'Unknown error',\n },\n { status: 500 }\n );\n }\n}\n", + "content": "import type { components } from '@octokit/openapi-types';\nimport { type NextRequest, NextResponse } from 'next/server';\n\ntype Installation = components['schemas']['installation'];\n\ntype Owner = components['schemas']['simple-user'] &\n components['schemas']['enterprise'];\n\ntype InstallationResponse = {\n installations: Installation[];\n};\n\ntype FilteredInstallation = {\n id: string;\n app_id: string;\n app_slug: string;\n permissions: Record;\n owner_id: string | null;\n events: string[];\n type: string | null;\n};\n\ntype FilteredOwner = {\n id: string;\n login: string;\n type: string | null;\n avatar_url: string | null;\n};\n\nfunction filterInstallations(installations: Installation[]) {\n return installations.map((installation) => {\n return {\n id: `${installation.id}`,\n app_id: `${installation.app_id}`,\n app_slug: installation.app_slug,\n owner_id:\n installation.account != null && 'id' in installation.account\n ? `${installation.account.id}`\n : null,\n permissions: installation.permissions,\n events: installation.events,\n type:\n installation.account != null && 'type' in installation.account\n ? installation.account.type\n : null,\n } satisfies FilteredInstallation;\n });\n}\n\nfunction filterOwners(owners: Owner[]) {\n return owners.map((owner) => {\n return {\n id: `${owner.id}`,\n login: owner.login,\n type: owner.type,\n avatar_url: owner.avatar_url,\n } satisfies FilteredOwner;\n });\n}\n\nfunction getOwnersFromInstallations(installations: Installation[]) {\n return installations\n .map((installation) => installation.account)\n .filter((account): account is Owner => account !== null);\n}\n\nexport async function GET(request: NextRequest) {\n const token = request.cookies.get('github_token')?.value;\n\n if (token == null || token.trim() === '') {\n return NextResponse.json({ data: { installations: [], owners: [] } });\n }\n\n try {\n const response = await fetch(`https://api.github.com/user/installations`, {\n headers: {\n Authorization: `Bearer ${token}`,\n Accept: 'application/vnd.github.v3+json',\n },\n });\n\n if (!response.ok) {\n return NextResponse.json(\n { error: 'Failed to fetch installations' },\n { status: 400 }\n );\n }\n\n const data = (await response.json()) as InstallationResponse;\n\n if ((data?.installations?.length ?? 0) > 0) {\n return NextResponse.json({\n data: {\n installations: [],\n owners: [],\n },\n });\n }\n\n const filteredInstallations = filterInstallations(data.installations);\n\n const filteredOwners = filterOwners(\n getOwnersFromInstallations(data.installations)\n );\n\n return NextResponse.json({\n data: {\n installations: filteredInstallations,\n owners: filteredOwners,\n },\n // for debugging this can be nice\n // _raw: data.installations,\n });\n } catch (error) {\n console.error('Error fetching installations:', error);\n return NextResponse.json(\n {\n error: 'Failed to fetch installations',\n errorMessage: error instanceof Error ? error.message : 'Unknown error',\n },\n { status: 500 }\n );\n }\n}\n", "type": "registry:page", "target": "app/api/code-storage/github/installations/route.ts" }, { "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/repo/route.ts", - "content": "import { GitStorage } from '@pierre/storage';\nimport { NextRequest, NextResponse } from 'next/server';\n\nconst store = new GitStorage({\n name: 'pierre',\n key: process.env.CODE_STORAGE_SYNC_PRIVATE_KEY || '',\n});\n\nexport async function POST(request: NextRequest) {\n try {\n const body = await request.json();\n const { owner, name, defaultBranch } = body;\n\n if (!owner || !name) {\n return NextResponse.json(\n { success: false, error: 'Repository owner and name are required' },\n { status: 400 }\n );\n }\n\n // TODO: Authenticate the user for this operation\n\n const repo = await store.createRepo({\n baseRepo: {\n owner,\n name,\n defaultBranch: defaultBranch || 'main', // Optional, defaults to 'main'\n },\n defaultBranch: defaultBranch || 'main', // Optional, defaults to 'main' for the Git Storage repo\n });\n\n const ciUrl = await repo.getRemoteURL({\n permissions: ['git:read', 'git:write'],\n ttl: 86400, // 24 hours\n });\n\n return NextResponse.json({\n success: true,\n url: ciUrl,\n repository: {\n owner,\n name,\n defaultBranch: defaultBranch || 'main',\n },\n });\n } catch (error) {\n console.error('Error syncing storage:', error);\n return NextResponse.json(\n { error: 'Failed to sync storage' },\n { status: 500 }\n );\n }\n}\n", + "content": "import { GitStorage } from '@pierre/storage';\nimport { type NextRequest, NextResponse } from 'next/server';\n\nexport async function POST(request: NextRequest) {\n try {\n const store = new GitStorage({\n name: 'pierre',\n key: process.env.CODE_STORAGE_SYNC_PRIVATE_KEY ?? '',\n });\n\n const body = await request.json();\n const { owner, name, defaultBranch } = body;\n\n // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions\n if (!owner || !name) {\n return NextResponse.json(\n { success: false, error: 'Repository owner and name are required' },\n { status: 400 }\n );\n }\n\n // TODO: Authenticate the user for this operation\n\n const repo = await store.createRepo({\n baseRepo: {\n owner,\n name,\n // NOTE(amadeus): Given these types are `any`, not sure the safest way\n // to convert fix them...\n // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/prefer-nullish-coalescing\n defaultBranch: defaultBranch || 'main', // Optional, defaults to 'main'\n },\n // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/prefer-nullish-coalescing\n defaultBranch: defaultBranch || 'main', // Optional, defaults to 'main' for the Git Storage repo\n });\n\n const ciUrl = await repo.getRemoteURL({\n permissions: ['git:read', 'git:write'],\n ttl: 86400, // 24 hours\n });\n\n return NextResponse.json({\n success: true,\n url: ciUrl,\n repository: {\n owner,\n name,\n // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/prefer-nullish-coalescing\n defaultBranch: defaultBranch || 'main',\n },\n });\n } catch (error) {\n console.error('Error syncing storage:', error);\n return NextResponse.json(\n { error: 'Failed to sync storage' },\n { status: 500 }\n );\n }\n}\n", "type": "registry:page", "target": "app/api/code-storage/repo/route.ts" } diff --git a/apps/docs/public/r/registry.json b/apps/docs/public/r/registry.json index a8f0204a8..28ed3ad7b 100644 --- a/apps/docs/public/r/registry.json +++ b/apps/docs/public/r/registry.json @@ -49,6 +49,11 @@ "type": "registry:page", "target": "app/api/code-storage/github/callback/route.ts" }, + { + "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/github/callback/success-callback.ts", + "type": "registry:page", + "target": "app/api/code-storage/github/callback/success-callback.ts" + }, { "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/github/installations/route.ts", "type": "registry:page", From 20ebfb79fd0d884c5a83a0626e722998ee51b041 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Mon, 13 Oct 2025 13:36:22 -0500 Subject: [PATCH 3/6] make the code bigger for no reason --- apps/docs/components/ui/dropdown-menu.tsx | 6 ++-- .../api/code-storage/github/callback/route.ts | 2 +- .../github/callback/success-callback.ts | 34 +++++++++++-------- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/apps/docs/components/ui/dropdown-menu.tsx b/apps/docs/components/ui/dropdown-menu.tsx index fec3803db..a60c16a02 100644 --- a/apps/docs/components/ui/dropdown-menu.tsx +++ b/apps/docs/components/ui/dropdown-menu.tsx @@ -27,7 +27,7 @@ const DropdownMenuSubTrigger = React.forwardRef< ref={ref} className={cn( 'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent', - inset && 'pl-8', + inset === true && 'pl-8', className )} {...props} @@ -83,7 +83,7 @@ const DropdownMenuItem = React.forwardRef< ref={ref} className={cn( 'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', - inset && 'pl-8', + inset === true && 'pl-8', className )} {...props} @@ -147,7 +147,7 @@ const DropdownMenuLabel = React.forwardRef< ref={ref} className={cn( 'px-2 py-1.5 text-sm font-semibold', - inset && 'pl-8', + inset === true && 'pl-8', className )} {...props} diff --git a/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/route.ts b/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/route.ts index 8e3457759..6f6ee53fa 100644 --- a/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/route.ts +++ b/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/route.ts @@ -1,4 +1,4 @@ -import { NextRequest } from 'next/server'; +import { type NextRequest } from 'next/server'; import { CodeStorageSuccessCallback } from './success-callback'; diff --git a/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/success-callback.ts b/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/success-callback.ts index 5f92f4f84..496f82a3d 100644 --- a/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/success-callback.ts +++ b/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/callback/success-callback.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { type NextRequest, NextResponse } from 'next/server'; export type GitHubOAuthTokenResponse = | { @@ -30,6 +30,8 @@ export class CodeStorageSuccessCallback { constructor(options: CodeStorageSuccessCallbackOptions) { if (options.platform !== 'github') { throw new Error( + // The error is intentionally outputting an unexpected value + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `CodeStorageSuccessCallback Error: Invalid platform: ${options.platform}` ); } @@ -42,22 +44,22 @@ export class CodeStorageSuccessCallback { private getParams(request: NextRequest) { const searchParams = request.nextUrl.searchParams; - const code = searchParams.get('code'); - const installationId = searchParams.get('installation_id'); - const setupAction = searchParams.get('setup_action'); - const state = searchParams.get('state'); + const code = searchParams.get('code') ?? null; + const installationId = searchParams.get('installation_id') ?? null; + const setupAction = searchParams.get('setup_action') ?? null; + const state = searchParams.get('state') ?? null; - if (!code) { + if (code == null || code === '') { throw new Error('CodeStorageSuccessCallback Error: No code provided'); - } else if (!installationId) { + } else if (installationId == null || installationId === '') { throw new Error( 'CodeStorageSuccessCallback Error: No installation ID provided' ); - } else if (!setupAction) { + } else if (setupAction == null || setupAction === '') { throw new Error( 'CodeStorageSuccessCallback Error: No setup action provided' ); - } else if (!state) { + } else if (state == null || state === '') { throw new Error('CodeStorageSuccessCallback Error: No state provided'); } @@ -72,19 +74,19 @@ export class CodeStorageSuccessCallback { state, }: { installationId: string; setupAction?: string; state: string } ) { - if (this.redirectUrl) { + if (this.redirectUrl != null && this.redirectUrl !== '') { return this.redirectUrl; } const successUrl = new URL('/code-storage/success', request.url); - if (setupAction) { + if (setupAction != null && setupAction !== '') { successUrl.searchParams.set('setup_action', setupAction); } - if (installationId) { + if (installationId != null && installationId !== '') { successUrl.searchParams.set('installation_id', installationId); } - if (state) { + if (state != null && state !== '') { successUrl.searchParams.set('state', state); } return successUrl; @@ -115,7 +117,11 @@ export class CodeStorageSuccessCallback { (await tokenResponse.json()) as unknown as GitHubOAuthTokenResponse; if ('error' in tokenData) { - throw new Error(tokenData.error_description || tokenData.error); + throw new Error( + tokenData.error_description ?? + tokenData.error ?? + 'error fetching github token' + ); } const successUrl = this.generateRedirectUrl(request, { From db43f204dccc562572a4fc98b4d8872c42539722 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Mon, 13 Oct 2025 13:37:19 -0500 Subject: [PATCH 4/6] run build --- apps/docs/public/r/git-platform-sync.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/public/r/git-platform-sync.json b/apps/docs/public/r/git-platform-sync.json index 81145b28c..af1c1d2a4 100644 --- a/apps/docs/public/r/git-platform-sync.json +++ b/apps/docs/public/r/git-platform-sync.json @@ -51,13 +51,13 @@ }, { "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/github/callback/route.ts", - "content": "import { NextRequest } from 'next/server';\n\nimport { CodeStorageSuccessCallback } from './success-callback';\n\nconst successCallback = new CodeStorageSuccessCallback({\n clientId: process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID!,\n clientSecret: process.env.GITHUB_CLIENT_SECRET!,\n platform: 'github',\n env: process.env.NODE_ENV === 'production' ? 'production' : 'development',\n});\n\nexport async function GET(request: NextRequest) {\n return successCallback.handleRequest(request);\n}\n", + "content": "import { type NextRequest } from 'next/server';\n\nimport { CodeStorageSuccessCallback } from './success-callback';\n\nconst successCallback = new CodeStorageSuccessCallback({\n clientId: process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID!,\n clientSecret: process.env.GITHUB_CLIENT_SECRET!,\n platform: 'github',\n env: process.env.NODE_ENV === 'production' ? 'production' : 'development',\n});\n\nexport async function GET(request: NextRequest) {\n return successCallback.handleRequest(request);\n}\n", "type": "registry:page", "target": "app/api/code-storage/github/callback/route.ts" }, { "path": "registry/new-york/blocks/git-platform-sync/api/code-storage/github/callback/success-callback.ts", - "content": "import { NextRequest, NextResponse } from 'next/server';\n\nexport type GitHubOAuthTokenResponse =\n | {\n access_token: string;\n token_type: 'bearer';\n scope: string;\n }\n | {\n error: string;\n error_description: string;\n error_uri: string;\n };\n\nexport type AvailablePlatform = 'github';\nexport type CodeStorageSuccessCallbackOptions = {\n clientId: string;\n clientSecret: string;\n platform: AvailablePlatform;\n redirectUrl?: string;\n env: 'production' | (string & {});\n};\n\nexport class CodeStorageSuccessCallback {\n private clientId: string;\n private clientSecret: string;\n private redirectUrl?: string;\n private env: 'production' | (string & {}) = 'production';\n\n constructor(options: CodeStorageSuccessCallbackOptions) {\n if (options.platform !== 'github') {\n throw new Error(\n `CodeStorageSuccessCallback Error: Invalid platform: ${options.platform}`\n );\n }\n this.clientId = options.clientId;\n this.clientSecret = options.clientSecret;\n this.redirectUrl = options.redirectUrl;\n this.env = options.env;\n }\n\n private getParams(request: NextRequest) {\n const searchParams = request.nextUrl.searchParams;\n\n const code = searchParams.get('code');\n const installationId = searchParams.get('installation_id');\n const setupAction = searchParams.get('setup_action');\n const state = searchParams.get('state');\n\n if (!code) {\n throw new Error('CodeStorageSuccessCallback Error: No code provided');\n } else if (!installationId) {\n throw new Error(\n 'CodeStorageSuccessCallback Error: No installation ID provided'\n );\n } else if (!setupAction) {\n throw new Error(\n 'CodeStorageSuccessCallback Error: No setup action provided'\n );\n } else if (!state) {\n throw new Error('CodeStorageSuccessCallback Error: No state provided');\n }\n\n return { code, installationId, setupAction, state };\n }\n\n private generateRedirectUrl(\n request: NextRequest,\n {\n installationId,\n setupAction,\n state,\n }: { installationId: string; setupAction?: string; state: string }\n ) {\n if (this.redirectUrl) {\n return this.redirectUrl;\n }\n\n const successUrl = new URL('/code-storage/success', request.url);\n\n if (setupAction) {\n successUrl.searchParams.set('setup_action', setupAction);\n }\n if (installationId) {\n successUrl.searchParams.set('installation_id', installationId);\n }\n if (state) {\n successUrl.searchParams.set('state', state);\n }\n return successUrl;\n }\n\n async handleRequest(request: NextRequest) {\n try {\n const { code, installationId, setupAction, state } =\n this.getParams(request);\n\n const tokenResponse = await fetch(\n 'https://github.com/login/oauth/access_token',\n {\n method: 'POST',\n headers: {\n Accept: 'application/json',\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n client_id: this.clientId,\n client_secret: this.clientSecret,\n code,\n }),\n }\n );\n\n const tokenData =\n (await tokenResponse.json()) as unknown as GitHubOAuthTokenResponse;\n\n if ('error' in tokenData) {\n throw new Error(tokenData.error_description || tokenData.error);\n }\n\n const successUrl = this.generateRedirectUrl(request, {\n installationId,\n setupAction,\n state,\n });\n\n const response = NextResponse.redirect(successUrl);\n\n response.cookies.set('github_token', tokenData.access_token, {\n httpOnly: true,\n secure: this.env === 'production',\n sameSite: 'lax',\n maxAge: 60 * 60 * 24 * 30,\n });\n\n return response;\n } catch (error) {\n console.error('CodeStorageSuccessCallback Error:', error);\n return NextResponse.json(\n {\n error:\n error instanceof Error\n ? error.message\n : 'CodeStorageSuccessCallback Error',\n },\n { status: 400 }\n );\n }\n }\n}\n", + "content": "import { type NextRequest, NextResponse } from 'next/server';\n\nexport type GitHubOAuthTokenResponse =\n | {\n access_token: string;\n token_type: 'bearer';\n scope: string;\n }\n | {\n error: string;\n error_description: string;\n error_uri: string;\n };\n\nexport type AvailablePlatform = 'github';\nexport type CodeStorageSuccessCallbackOptions = {\n clientId: string;\n clientSecret: string;\n platform: AvailablePlatform;\n redirectUrl?: string;\n env: 'production' | (string & {});\n};\n\nexport class CodeStorageSuccessCallback {\n private clientId: string;\n private clientSecret: string;\n private redirectUrl?: string;\n private env: 'production' | (string & {}) = 'production';\n\n constructor(options: CodeStorageSuccessCallbackOptions) {\n if (options.platform !== 'github') {\n throw new Error(\n // The error is intentionally outputting an unexpected value\n // eslint-disable-next-line @typescript-eslint/restrict-template-expressions\n `CodeStorageSuccessCallback Error: Invalid platform: ${options.platform}`\n );\n }\n this.clientId = options.clientId;\n this.clientSecret = options.clientSecret;\n this.redirectUrl = options.redirectUrl;\n this.env = options.env;\n }\n\n private getParams(request: NextRequest) {\n const searchParams = request.nextUrl.searchParams;\n\n const code = searchParams.get('code') ?? null;\n const installationId = searchParams.get('installation_id') ?? null;\n const setupAction = searchParams.get('setup_action') ?? null;\n const state = searchParams.get('state') ?? null;\n\n if (code == null || code === '') {\n throw new Error('CodeStorageSuccessCallback Error: No code provided');\n } else if (installationId == null || installationId === '') {\n throw new Error(\n 'CodeStorageSuccessCallback Error: No installation ID provided'\n );\n } else if (setupAction == null || setupAction === '') {\n throw new Error(\n 'CodeStorageSuccessCallback Error: No setup action provided'\n );\n } else if (state == null || state === '') {\n throw new Error('CodeStorageSuccessCallback Error: No state provided');\n }\n\n return { code, installationId, setupAction, state };\n }\n\n private generateRedirectUrl(\n request: NextRequest,\n {\n installationId,\n setupAction,\n state,\n }: { installationId: string; setupAction?: string; state: string }\n ) {\n if (this.redirectUrl != null && this.redirectUrl !== '') {\n return this.redirectUrl;\n }\n\n const successUrl = new URL('/code-storage/success', request.url);\n\n if (setupAction != null && setupAction !== '') {\n successUrl.searchParams.set('setup_action', setupAction);\n }\n if (installationId != null && installationId !== '') {\n successUrl.searchParams.set('installation_id', installationId);\n }\n if (state != null && state !== '') {\n successUrl.searchParams.set('state', state);\n }\n return successUrl;\n }\n\n async handleRequest(request: NextRequest) {\n try {\n const { code, installationId, setupAction, state } =\n this.getParams(request);\n\n const tokenResponse = await fetch(\n 'https://github.com/login/oauth/access_token',\n {\n method: 'POST',\n headers: {\n Accept: 'application/json',\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n client_id: this.clientId,\n client_secret: this.clientSecret,\n code,\n }),\n }\n );\n\n const tokenData =\n (await tokenResponse.json()) as unknown as GitHubOAuthTokenResponse;\n\n if ('error' in tokenData) {\n throw new Error(\n tokenData.error_description ??\n tokenData.error ??\n 'error fetching github token'\n );\n }\n\n const successUrl = this.generateRedirectUrl(request, {\n installationId,\n setupAction,\n state,\n });\n\n const response = NextResponse.redirect(successUrl);\n\n response.cookies.set('github_token', tokenData.access_token, {\n httpOnly: true,\n secure: this.env === 'production',\n sameSite: 'lax',\n maxAge: 60 * 60 * 24 * 30,\n });\n\n return response;\n } catch (error) {\n console.error('CodeStorageSuccessCallback Error:', error);\n return NextResponse.json(\n {\n error:\n error instanceof Error\n ? error.message\n : 'CodeStorageSuccessCallback Error',\n },\n { status: 400 }\n );\n }\n }\n}\n", "type": "registry:page", "target": "app/api/code-storage/github/callback/success-callback.ts" }, From a6c38923282d4b2fee7de030ab7ed2e494212fb4 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Mon, 13 Oct 2025 13:40:06 -0500 Subject: [PATCH 5/6] redo symlink --- apps/docs/app/api/code-storage | 1 + 1 file changed, 1 insertion(+) create mode 120000 apps/docs/app/api/code-storage diff --git a/apps/docs/app/api/code-storage b/apps/docs/app/api/code-storage new file mode 120000 index 000000000..3984dd52b --- /dev/null +++ b/apps/docs/app/api/code-storage @@ -0,0 +1 @@ +../../../../packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage \ No newline at end of file From 34cb66b25c950264f642d300eb5a0245b53d8339 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Mon, 13 Oct 2025 13:58:13 -0500 Subject: [PATCH 6/6] undo symlink, but i think we just abandon this branch --- apps/docs/app/api/code-storage | 1 - .../api/code-storage/github/callback/route.ts | 14 ++ .../github/callback/success-callback.ts | 156 ++++++++++++++++++ .../github/installations/route.ts | 125 ++++++++++++++ apps/docs/app/api/code-storage/repo/route.ts | 59 +++++++ apps/docs/app/code-storage | 1 - apps/docs/app/code-storage/success/page.tsx | 94 +++++++++++ .../github/installations/route.ts | 1 + 8 files changed, 449 insertions(+), 2 deletions(-) delete mode 120000 apps/docs/app/api/code-storage create mode 100644 apps/docs/app/api/code-storage/github/callback/route.ts create mode 100644 apps/docs/app/api/code-storage/github/callback/success-callback.ts create mode 100644 apps/docs/app/api/code-storage/github/installations/route.ts create mode 100644 apps/docs/app/api/code-storage/repo/route.ts delete mode 120000 apps/docs/app/code-storage create mode 100644 apps/docs/app/code-storage/success/page.tsx diff --git a/apps/docs/app/api/code-storage b/apps/docs/app/api/code-storage deleted file mode 120000 index 3984dd52b..000000000 --- a/apps/docs/app/api/code-storage +++ /dev/null @@ -1 +0,0 @@ -../../../../packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage \ No newline at end of file diff --git a/apps/docs/app/api/code-storage/github/callback/route.ts b/apps/docs/app/api/code-storage/github/callback/route.ts new file mode 100644 index 000000000..6f6ee53fa --- /dev/null +++ b/apps/docs/app/api/code-storage/github/callback/route.ts @@ -0,0 +1,14 @@ +import { type NextRequest } from 'next/server'; + +import { CodeStorageSuccessCallback } from './success-callback'; + +const successCallback = new CodeStorageSuccessCallback({ + clientId: process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + platform: 'github', + env: process.env.NODE_ENV === 'production' ? 'production' : 'development', +}); + +export async function GET(request: NextRequest) { + return successCallback.handleRequest(request); +} diff --git a/apps/docs/app/api/code-storage/github/callback/success-callback.ts b/apps/docs/app/api/code-storage/github/callback/success-callback.ts new file mode 100644 index 000000000..496f82a3d --- /dev/null +++ b/apps/docs/app/api/code-storage/github/callback/success-callback.ts @@ -0,0 +1,156 @@ +import { type NextRequest, NextResponse } from 'next/server'; + +export type GitHubOAuthTokenResponse = + | { + access_token: string; + token_type: 'bearer'; + scope: string; + } + | { + error: string; + error_description: string; + error_uri: string; + }; + +export type AvailablePlatform = 'github'; +export type CodeStorageSuccessCallbackOptions = { + clientId: string; + clientSecret: string; + platform: AvailablePlatform; + redirectUrl?: string; + env: 'production' | (string & {}); +}; + +export class CodeStorageSuccessCallback { + private clientId: string; + private clientSecret: string; + private redirectUrl?: string; + private env: 'production' | (string & {}) = 'production'; + + constructor(options: CodeStorageSuccessCallbackOptions) { + if (options.platform !== 'github') { + throw new Error( + // The error is intentionally outputting an unexpected value + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `CodeStorageSuccessCallback Error: Invalid platform: ${options.platform}` + ); + } + this.clientId = options.clientId; + this.clientSecret = options.clientSecret; + this.redirectUrl = options.redirectUrl; + this.env = options.env; + } + + private getParams(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + + const code = searchParams.get('code') ?? null; + const installationId = searchParams.get('installation_id') ?? null; + const setupAction = searchParams.get('setup_action') ?? null; + const state = searchParams.get('state') ?? null; + + if (code == null || code === '') { + throw new Error('CodeStorageSuccessCallback Error: No code provided'); + } else if (installationId == null || installationId === '') { + throw new Error( + 'CodeStorageSuccessCallback Error: No installation ID provided' + ); + } else if (setupAction == null || setupAction === '') { + throw new Error( + 'CodeStorageSuccessCallback Error: No setup action provided' + ); + } else if (state == null || state === '') { + throw new Error('CodeStorageSuccessCallback Error: No state provided'); + } + + return { code, installationId, setupAction, state }; + } + + private generateRedirectUrl( + request: NextRequest, + { + installationId, + setupAction, + state, + }: { installationId: string; setupAction?: string; state: string } + ) { + if (this.redirectUrl != null && this.redirectUrl !== '') { + return this.redirectUrl; + } + + const successUrl = new URL('/code-storage/success', request.url); + + if (setupAction != null && setupAction !== '') { + successUrl.searchParams.set('setup_action', setupAction); + } + if (installationId != null && installationId !== '') { + successUrl.searchParams.set('installation_id', installationId); + } + if (state != null && state !== '') { + successUrl.searchParams.set('state', state); + } + return successUrl; + } + + async handleRequest(request: NextRequest) { + try { + const { code, installationId, setupAction, state } = + this.getParams(request); + + const tokenResponse = await fetch( + 'https://github.com/login/oauth/access_token', + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: this.clientId, + client_secret: this.clientSecret, + code, + }), + } + ); + + const tokenData = + (await tokenResponse.json()) as unknown as GitHubOAuthTokenResponse; + + if ('error' in tokenData) { + throw new Error( + tokenData.error_description ?? + tokenData.error ?? + 'error fetching github token' + ); + } + + const successUrl = this.generateRedirectUrl(request, { + installationId, + setupAction, + state, + }); + + const response = NextResponse.redirect(successUrl); + + response.cookies.set('github_token', tokenData.access_token, { + httpOnly: true, + secure: this.env === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, + }); + + return response; + } catch (error) { + console.error('CodeStorageSuccessCallback Error:', error); + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : 'CodeStorageSuccessCallback Error', + }, + { status: 400 } + ); + } + } +} diff --git a/apps/docs/app/api/code-storage/github/installations/route.ts b/apps/docs/app/api/code-storage/github/installations/route.ts new file mode 100644 index 000000000..5141d2e43 --- /dev/null +++ b/apps/docs/app/api/code-storage/github/installations/route.ts @@ -0,0 +1,125 @@ +import type { components } from '@octokit/openapi-types'; +import { type NextRequest, NextResponse } from 'next/server'; + +type Installation = components['schemas']['installation']; + +type Owner = components['schemas']['simple-user'] & + components['schemas']['enterprise']; + +type InstallationResponse = { + installations: Installation[]; +}; + +type FilteredInstallation = { + id: string; + app_id: string; + app_slug: string; + permissions: Record; + owner_id: string | null; + events: string[]; + type: string | null; +}; + +type FilteredOwner = { + id: string; + login: string; + type: string | null; + avatar_url: string | null; +}; + +function filterInstallations(installations: Installation[]) { + return installations.map((installation) => { + return { + id: `${installation.id}`, + app_id: `${installation.app_id}`, + app_slug: installation.app_slug, + owner_id: + installation.account != null && 'id' in installation.account + ? `${installation.account.id}` + : null, + permissions: installation.permissions, + events: installation.events, + type: + installation.account != null && 'type' in installation.account + ? installation.account.type + : null, + } satisfies FilteredInstallation; + }); +} + +function filterOwners(owners: Owner[]) { + return owners.map((owner) => { + return { + id: `${owner.id}`, + login: owner.login, + type: owner.type, + avatar_url: owner.avatar_url, + } satisfies FilteredOwner; + }); +} + +function getOwnersFromInstallations(installations: Installation[]) { + return installations + .map((installation) => installation.account) + .filter((account): account is Owner => account !== null); +} + +export async function GET(request: NextRequest) { + const token = request.cookies.get('github_token')?.value; + + if (token == null || token.trim() === '') { + return NextResponse.json({ data: { installations: [], owners: [] } }); + } + + try { + const response = await fetch(`https://api.github.com/user/installations`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + + if (!response.ok) { + console.error('Failed to fetch installations:', response.statusText); + return NextResponse.json( + { error: 'Failed to fetch installations' }, + { status: 400 } + ); + } + + const data = (await response.json()) as InstallationResponse; + + if ((data?.installations?.length ?? 0) > 0) { + return NextResponse.json({ + data: { + installations: [], + owners: [], + }, + }); + } + + const filteredInstallations = filterInstallations(data.installations); + + const filteredOwners = filterOwners( + getOwnersFromInstallations(data.installations) + ); + + return NextResponse.json({ + data: { + installations: filteredInstallations, + owners: filteredOwners, + }, + // for debugging this can be nice + // _raw: data.installations, + }); + } catch (error) { + console.error('Error fetching installations:', error); + return NextResponse.json( + { + error: 'Failed to fetch installations', + errorMessage: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/apps/docs/app/api/code-storage/repo/route.ts b/apps/docs/app/api/code-storage/repo/route.ts new file mode 100644 index 000000000..4abe14e4a --- /dev/null +++ b/apps/docs/app/api/code-storage/repo/route.ts @@ -0,0 +1,59 @@ +import { GitStorage } from '@pierre/storage'; +import { type NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + try { + const store = new GitStorage({ + name: 'pierre', + key: process.env.CODE_STORAGE_SYNC_PRIVATE_KEY ?? '', + }); + + const body = await request.json(); + const { owner, name, defaultBranch } = body; + + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (!owner || !name) { + return NextResponse.json( + { success: false, error: 'Repository owner and name are required' }, + { status: 400 } + ); + } + + // TODO: Authenticate the user for this operation + + const repo = await store.createRepo({ + baseRepo: { + owner, + name, + // NOTE(amadeus): Given these types are `any`, not sure the safest way + // to convert fix them... + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/prefer-nullish-coalescing + defaultBranch: defaultBranch || 'main', // Optional, defaults to 'main' + }, + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/prefer-nullish-coalescing + defaultBranch: defaultBranch || 'main', // Optional, defaults to 'main' for the Git Storage repo + }); + + const ciUrl = await repo.getRemoteURL({ + permissions: ['git:read', 'git:write'], + ttl: 86400, // 24 hours + }); + + return NextResponse.json({ + success: true, + url: ciUrl, + repository: { + owner, + name, + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/prefer-nullish-coalescing + defaultBranch: defaultBranch || 'main', + }, + }); + } catch (error) { + console.error('Error syncing storage:', error); + return NextResponse.json( + { error: 'Failed to sync storage' }, + { status: 500 } + ); + } +} diff --git a/apps/docs/app/code-storage b/apps/docs/app/code-storage deleted file mode 120000 index 6298ddb19..000000000 --- a/apps/docs/app/code-storage +++ /dev/null @@ -1 +0,0 @@ -../../../packages/sync-ui-shadcn/blocks/git-platform-sync/pages/code-storage \ No newline at end of file diff --git a/apps/docs/app/code-storage/success/page.tsx b/apps/docs/app/code-storage/success/page.tsx new file mode 100644 index 000000000..e5771deec --- /dev/null +++ b/apps/docs/app/code-storage/success/page.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { CheckCircle } from 'lucide-react'; +import { useSearchParams } from 'next/navigation'; +import { Suspense, useEffect } from 'react'; + +const appInstallType = 'git-platform-sync-app-installed--github'; + +function SuccessPageContent() { + const searchParams = useSearchParams(); + const installationId = searchParams.get('installation_id'); + const setupAction = searchParams.get('setup_action'); + const state = searchParams.get('state'); + + useEffect(() => { + if (window.opener != null) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + window.opener.postMessage( + { + type: appInstallType, + installationId, + setupAction, + state, + }, + window.opener.origin + ); + + setTimeout(() => { + window.close(); + }, 2000); + } + }, [installationId, setupAction, state]); + + return ( +
+
+
+ +

+ Installation Successful! +

+

+ Your GitHub App has been successfully{' '} + {setupAction === 'install' ? 'installed' : 'updated'}. +

+ {installationId != null && ( +

+ Installation ID: {installationId} +

+ )} +
+

+ This window will close automatically… +

+ +
+
+
+
+ ); +} + +export default function SuccessPage() { + return ( + + + + ); +} diff --git a/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/installations/route.ts b/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/installations/route.ts index eda381a3b..5141d2e43 100644 --- a/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/installations/route.ts +++ b/packages/sync-ui-shadcn/blocks/git-platform-sync/api/code-storage/github/installations/route.ts @@ -80,6 +80,7 @@ export async function GET(request: NextRequest) { }); if (!response.ok) { + console.error('Failed to fetch installations:', response.statusText); return NextResponse.json( { error: 'Failed to fetch installations' }, { status: 400 }