diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx index 5957d3b55d..4f975f9ab6 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx @@ -27,7 +27,7 @@ import { PlusCircleIcon } from "@phosphor-icons/react"; import { AdminOwnedProject, useStackApp } from "@stackframe/stack"; import { runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises"; import { useSearchParams } from "next/navigation"; -import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ProjectOnboardingStatus } from "@stackframe/stack-shared/dist/schema-fields"; import { ProjectOnboardingWizard } from "./project-onboarding-wizard"; @@ -75,7 +75,6 @@ function PageClientInner() { const [projectStatuses, setProjectStatuses] = useState>(new Map()); const [projectOnboardingStates, setProjectOnboardingStates] = useState>(new Map()); const [loadingStatuses, setLoadingStatuses] = useState(true); - const [, startStatusTransition] = useTransition(); const [projectName, setProjectName] = useState(displayNameFromSearch ?? ""); const [selectedTeamId, setSelectedTeamId] = useState(null); const [creatingTeam, setCreatingTeam] = useState(false); @@ -217,12 +216,10 @@ function PageClientInner() { throw new Error(`Failed to update onboarding status: ${response.status} ${await response.text()}`); } - startStatusTransition(() => { - setProjectStatuses((previous) => { - const next = new Map(previous); - next.set(project.id, status); - return next; - }); + setProjectStatuses((previous) => { + const next = new Map(previous); + next.set(project.id, status); + return next; }); await appInternals.refreshOwnedProjects(); diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-combobox.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-combobox.tsx new file mode 100644 index 0000000000..175275e78c --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-combobox.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { Spinner, Typography, cn } from "@/components/ui"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; +import { useState, type ReactNode } from "react"; + +export type ComboboxItem = { + value: string, + label: string, + description?: string, + trailingIcon?: ReactNode, +}; + +type Props = { + value: string, + items: ComboboxItem[], + onSelect: (value: string) => void, + query: string, + onQueryChange: (query: string) => void, + triggerPlaceholder?: string, + inputPlaceholder?: string, + emptyMessage?: string, + loading?: boolean, + disabled?: boolean, +}; + +export function RemoteSearchCombobox(props: Props) { + const [open, setOpen] = useState(false); + const selectedLabel = props.items.find((item) => item.value === props.value)?.label ?? props.value; + + return ( + + + + + + + + + {props.loading && ( +
+ + Searching... +
+ )} + {!props.loading && props.items.length === 0 && ( + {props.emptyMessage ?? "No results."} + )} + {props.items.length > 0 && ( + + {props.items.map((item) => ( + { + props.onSelect(item.value); + setOpen(false); + }} + > + +
+
{item.label}
+ {item.description != null && ( +
{item.description}
+ )} +
+ {item.trailingIcon != null && ( + + {item.trailingIcon} + + )} +
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.test.ts b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.test.ts index df6b55659e..4841938025 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.test.ts +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.test.ts @@ -17,7 +17,7 @@ describe("buildWorkflowYaml", () => { expect(workflowYaml).toContain(` - ${JSON.stringify(configPath)}`); expect(workflowYaml).toContain(` - ${JSON.stringify(WORKFLOW_FILE_PATH)}`); expect(workflowYaml).toContain(` STACK_AUTH_CONFIG_PATH: ${JSON.stringify(configPath)}`); - expect(workflowYaml).toContain("run: pnpx @stackframe/stack-cli@latest config push --config-file \"$STACK_AUTH_CONFIG_PATH\""); + expect(workflowYaml).toContain("run: npx --yes @stackframe/stack-cli@latest config push --config-file \"$STACK_AUTH_CONFIG_PATH\""); expect(workflowYaml).not.toContain(`--config-file "${configPath}"`); }); diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.ts b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.ts index 7ff3312816..0276adb20c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.ts +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding-workflow.ts @@ -7,9 +7,20 @@ function encodeYamlScalar(value: string): string { return JSON.stringify(value); } +// GitHub Actions `on.push.paths` filters are repo-relative and do not match a +// leading `./`. Config-path suggestions and manual input may include one, so +// strip it to keep the push trigger (and the checked-out file path) canonical. +export function normalizeConfigPath(configPath: string): string { + return configPath.trim().replace(/^(?:\.\/)+/, ""); +} + export function buildWorkflowYaml(branch: string, configPath: string): string { const encodedBranch = encodeYamlScalar(branch); - const encodedConfigPath = encodeYamlScalar(configPath); + const normalizedConfigPath = normalizeConfigPath(configPath); + if (normalizedConfigPath.length === 0) { + throw new Error("Expected a non-empty config path after normalization (input must not be blank or only './')."); + } + const encodedConfigPath = encodeYamlScalar(normalizedConfigPath); const encodedWorkflowPath = encodeYamlScalar(WORKFLOW_FILE_PATH); return `name: Stack Auth Config Sync @@ -30,12 +41,16 @@ jobs: contents: read steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" - name: Push Stack Auth config env: STACK_PROJECT_ID: \${{ secrets.${GITHUB_PROJECT_ID_SECRET_NAME} }} STACK_SECRET_SERVER_KEY: \${{ secrets.${GITHUB_SECRET_SERVER_KEY_SECRET_NAME} }} STACK_AUTH_CONFIG_PATH: ${encodedConfigPath} - run: pnpx @stackframe/stack-cli@latest config push --config-file "$STACK_AUTH_CONFIG_PATH" + run: npx --yes @stackframe/stack-cli@latest config push --config-file "$STACK_AUTH_CONFIG_PATH" `; } diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding.tsx index ecfca434bb..6019d03418 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding.tsx @@ -1,5 +1,7 @@ "use client"; +import { CodeBlock } from "@/components/code-block"; +import { DesignPillToggle } from "@/components/design-components"; import { DesignAlert } from "@/components/design-components/alert"; import { DesignButton } from "@/components/design-components/button"; import { DesignCard } from "@/components/design-components/card"; @@ -7,15 +9,16 @@ import { DesignInput } from "@/components/design-components/input"; import { DesignSelectorDropdown } from "@/components/design-components/select"; import { ActionDialog, Spinner, Typography, cn } from "@/components/ui"; import { useDashboardInternalUser } from "@/lib/dashboard-user"; -import { GithubLogoIcon, LinkBreakIcon, TerminalWindowIcon } from "@phosphor-icons/react"; +import { ArrowsClockwiseIcon, GithubLogoIcon, LinkBreakIcon, LockSimpleIcon, TerminalWindowIcon } from "@phosphor-icons/react"; import { type AdminOwnedProject, type PushedConfigSource } from "@stackframe/stack"; import { captureError } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises"; -import { deindent, stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; +import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; import { urlString } from "@stackframe/stack-shared/dist/utils/urls"; import sodium from "libsodium-wrappers"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { RemoteSearchCombobox, type ComboboxItem } from "./link-existing-combobox"; import { OnboardingPage } from "./components"; import { buildWorkflowYaml, @@ -102,6 +105,9 @@ const GITHUB_SCOPE_REQUIREMENTS = ["repo", "workflow"]; const CONNECT_NEW_GITHUB_ACCOUNT_OPTION = "__connect-new-github-account__"; const LINK_EXISTING_STEPS: LinkExistingStep[] = ["choose-method", "local", "github-repository", "github-config-path", "github-logs"]; +type PackageRunner = "npx" | "pnpx" | "bunx"; +const PACKAGE_RUNNERS: PackageRunner[] = ["npx", "pnpx", "bunx"]; + function getLinkExistingStorageKey(projectId: string): string { return `stack-auth-link-existing-onboarding:${projectId}`; } @@ -354,6 +360,26 @@ function parseGitTreePaths(value: unknown): { paths: string[], truncated: boolea return { paths, truncated }; } +// `/repos/{owner}/{repo}/git/matching-refs/heads/{prefix}` returns refs prefixed +// with `refs/heads/`. Strip the prefix so callers see plain branch names. +function parseGithubMatchingRefs(value: unknown): string[] { + if (!Array.isArray(value)) { + throw new Error("GitHub returned an invalid matching refs response."); + } + const HEADS_PREFIX = "refs/heads/"; + const branches: string[] = []; + for (const item of value) { + if (!isObject(item)) { + continue; + } + const ref = getObjectString(item, "ref"); + if (ref != null && ref.startsWith(HEADS_PREFIX)) { + branches.push(ref.slice(HEADS_PREFIX.length)); + } + } + return branches; +} + function parseGitReferenceSha(value: unknown): string { if (!isObject(value)) { throw new Error("GitHub returned an invalid branch reference response."); @@ -400,10 +426,19 @@ async function encryptSecretValue(value: string, base64PublicKey: string): Promi return sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL); } +// GitHub returns 403/429 with a "rate limit" message when the primary or +// secondary rate limit is hit. We surface these as inline messages in the +// combobox rather than firing an alert, since they self-resolve. +function isGithubRateLimitError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return /rate limit/i.test(error.message); +} + function buildConfigPathSuggestions(paths: string[]): string[] { + // Keep suggestions repo-relative (no `./` prefix) so they match both the + // workflow's push `paths` filter and the default config path input. return paths .filter((path) => path.endsWith("/stack.config.ts") || path.endsWith("/stack.config.js") || path === "stack.config.ts" || path === "stack.config.js") - .map((path) => path.startsWith("./") ? path : `./${path}`) .sort((a, b) => stringCompare(a, b)); } @@ -456,7 +491,20 @@ export function LinkExistingOnboarding(props: Props) { const capturedWorkflowFailureRef = useRef(null); const localAutoMonitoringKeyRef = useRef(null); const githubLogsAutoPollingKeyRef = useRef(null); + const repositoriesLoadedAccountRef = useRef(null); + const loadRepositoriesRunIdRef = useRef(0); const [configPathInput, setConfigPathInput] = useState(persistedState?.configPathInput ?? "stack.config.ts"); + const [packageRunner, setPackageRunner] = useState("npx"); + const [repoSearchQuery, setRepoSearchQuery] = useState(""); + const [repoSearchResults, setRepoSearchResults] = useState([]); + const [loadingRepoSearch, setLoadingRepoSearch] = useState(false); + const [repoSearchError, setRepoSearchError] = useState(null); + const [branchSearchQuery, setBranchSearchQuery] = useState(""); + const [branchSearchResults, setBranchSearchResults] = useState([]); + const [loadingBranchSearch, setLoadingBranchSearch] = useState(false); + const [branchSearchError, setBranchSearchError] = useState(null); + const [configPathError, setConfigPathError] = useState(null); + const [isCheckingConfigPath, setIsCheckingConfigPath] = useState(false); const persistState = useCallback((partial: Partial) => { const existingState = readPersistedLinkExistingState(project.id); @@ -491,16 +539,19 @@ export function LinkExistingOnboarding(props: Props) { const setSelectedRepositoryFullNameWithPersistence = useCallback((nextRepositoryFullName: string) => { setSelectedRepositoryFullName(nextRepositoryFullName); + setConfigPathError(null); persistState({ selectedRepositoryFullName: nextRepositoryFullName }); }, [persistState]); const setSelectedBranchWithPersistence = useCallback((nextBranch: string) => { setSelectedBranch(nextBranch); + setConfigPathError(null); persistState({ selectedBranch: nextBranch }); }, [persistState]); const setConfigPathInputWithPersistence = useCallback((nextConfigPath: string) => { setConfigPathInput(nextConfigPath); + setConfigPathError(null); persistState({ configPathInput: nextConfigPath }); }, [persistState]); @@ -751,9 +802,12 @@ export function LinkExistingOnboarding(props: Props) { throw new Error("Connect a GitHub account before loading repositories."); } + const runId = ++loadRepositoriesRunIdRef.current; + const isCurrent = () => loadRepositoriesRunIdRef.current === runId; setLoadingRepositories(true); try { const userResponse = await githubFetch("/user", undefined, account); + if (!isCurrent()) return; const githubUser = parseGithubUser(userResponse); setGithubAccountLogins((previous) => { const next = new Map(previous); @@ -766,6 +820,7 @@ export function LinkExistingOnboarding(props: Props) { undefined, account, ); + if (!isCurrent()) return; const parsedRepositories = parseGithubRepositories(response); setRepositories(parsedRepositories); setBranches([]); @@ -795,7 +850,9 @@ export function LinkExistingOnboarding(props: Props) { } } } finally { - setLoadingRepositories(false); + if (isCurrent()) { + setLoadingRepositories(false); + } } }, [githubFetch, selectedGithubAccount, selectedRepositoryFullName, setSelectedBranchWithPersistence, setSelectedRepositoryFullNameWithPersistence]); @@ -805,8 +862,7 @@ export function LinkExistingOnboarding(props: Props) { if (options?.forceConnect) { await user.getOrLinkConnectedAccount("github", { scopes: GITHUB_SCOPE_REQUIREMENTS }); } - await loadRepositories(); - }, [appendLog, loadRepositories, setStepWithPersistence, user]); + }, [appendLog, setStepWithPersistence, user]); const loadBranches = useCallback(async (repositoryFullName: string): Promise => { if (repositoryFullName.length === 0) { @@ -847,8 +903,23 @@ export function LinkExistingOnboarding(props: Props) { setGitTreeTruncated(false); const referenceResponse = await githubFetch(githubRepositoryApiPath(owner, repo, urlString`/git/ref/heads/${branch}`)); const treeSha = parseGitReferenceSha(referenceResponse); - const treeResponse = await githubFetch(githubRepositoryApiPath(owner, repo, urlString`/git/trees/${treeSha}?recursive=1`)); - const { paths: allPaths, truncated } = parseGitTreePaths(treeResponse); + let allPaths: string[] = []; + let truncated = false; + try { + const treeResponse = await githubFetch(githubRepositoryApiPath(owner, repo, urlString`/git/trees/${treeSha}?recursive=1`)); + const parsedTree = parseGitTreePaths(treeResponse); + allPaths = parsedTree.paths; + truncated = parsedTree.truncated; + } catch (error) { + // GitHub returns 404 for the empty-tree SHA + // (4b825dc642cb6eb9a060e54bf8d69288fbee4904) instead of an empty array, + // so a freshly-initialized repo with no files lands here. Treat it as + // "no files yet" rather than surfacing a fatal alert. + const message = error instanceof Error ? error.message : ""; + if (!message.includes("Not Found")) { + throw error; + } + } setGitTreeTruncated(truncated); const suggestions = buildConfigPathSuggestions(allPaths); setConfigPathSuggestions(suggestions); @@ -894,6 +965,34 @@ export function LinkExistingOnboarding(props: Props) { return sha; }, [githubFetch]); + const checkConfigPathExists = useCallback(async ( + owner: string, + repo: string, + branch: string, + path: string, + ): Promise => { + const normalizedPath = path.trim().replace(/^\.?\/+/, ""); + if (normalizedPath.length === 0 || normalizedPath.split("/").includes("..")) { + return false; + } + const refQuery = new URLSearchParams({ ref: branch }).toString(); + try { + const response = await githubFetch( + githubRepositoryApiPath(owner, repo, `/contents/${encodeGitHubPath(normalizedPath)}?${refQuery}`), + ); + if (!isObject(response) || Array.isArray(response)) { + return false; + } + return getObjectString(response, "type") === "file"; + } catch (error) { + const message = error instanceof Error ? error.message : ""; + if (message.includes("Not Found")) { + return false; + } + throw error; + } + }, [githubFetch]); + const createGithubWorkflowCommit = useCallback(async ( owner: string, repo: string, @@ -1005,9 +1104,24 @@ export function LinkExistingOnboarding(props: Props) { commitDescription, ); - appendLog("Dispatching workflow run..."); - await triggerGithubWorkflow(owner, repo, selectedBranch); - appendLog("Workflow dispatched. Waiting for Stack Auth push..."); + // workflow_dispatch only works once the workflow exists on the default + // branch, so it 404s for runs targeting other branches. The workflow-file + // commit above already triggers a run via the push `paths` filter, so a + // failed dispatch is non-fatal — continue and let the logs step monitor. + try { + appendLog("Dispatching workflow run..."); + await triggerGithubWorkflow(owner, repo, selectedBranch); + appendLog("Workflow dispatched. Waiting for Stack Auth push..."); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + appendLog( + "Skipping direct workflow dispatch — this is expected when the " + + "workflow file is not yet on the repository's default branch. " + + "The workflow commit above triggers a run via the push filter, " + + "so we'll continue monitoring." + ); + appendLog(`(Dispatch error: ${message})`); + } setStepWithPersistence("github-logs"); setIsCommitDialogOpen(false); @@ -1095,12 +1209,143 @@ export function LinkExistingOnboarding(props: Props) { const canContinue = pushedConfigSource != null && pushedConfigSource.type !== "unlinked"; - const localCommand = useMemo(() => { - return deindent` - pnpx @stackframe/stack-cli@latest login - pnpx @stackframe/stack-cli@latest config push --config-file --project-id "${project.id}" - `; - }, [project.id]); + const loginCommand = `${packageRunner} @stackframe/stack-cli@latest login`; + const configPushCommand = `${packageRunner} @stackframe/stack-cli@latest config push --cloud-project-id ${JSON.stringify(project.id)} --config-file `; + + // Also covers landing back on this step after the connect-account OAuth + // redirect or a page reload, since the effect runs whenever the account + // resolves and we have not yet loaded for it. + useEffect(() => { + if (step !== "github-repository") { + return; + } + const account = selectedGithubAccount; + if (account == null) { + return; + } + if (repositoriesLoadedAccountRef.current === account.providerAccountId) { + return; + } + repositoriesLoadedAccountRef.current = account.providerAccountId; + runAsynchronouslyWithAlert(async () => { + try { + await loadRepositories({ accountOverride: account }); + } catch (error) { + if (repositoriesLoadedAccountRef.current === account.providerAccountId) { + repositoriesLoadedAccountRef.current = null; + } + throw error; + } + }); + }, [loadRepositories, selectedGithubAccount, step]); + + // Debounced GitHub search for repositories. /user/repos only returns the + // first 100 entries, so for users with many repos we hit /search/repositories + // as they type. We scope the query with `user:LOGIN` so results stay within + // the connected user's repos — without it, /search/repositories is global + // and would surface unrelated public repos ahead of the user's own. + // Note: this also excludes repos the user only has access to via org + // membership; those still appear via the prefetched /user/repos list. + const selectedGithubLogin = selectedGithubAccount != null + ? githubAccountLogins.get(selectedGithubAccount.providerAccountId) ?? null + : null; + useEffect(() => { + const trimmed = repoSearchQuery.trim(); + if (step !== "github-repository" || trimmed.length === 0 || selectedGithubAccount == null) { + setRepoSearchResults([]); + setLoadingRepoSearch(false); + setRepoSearchError(null); + return; + } + let cancelled = false; + setLoadingRepoSearch(true); + const handle = setTimeout(() => { + runAsynchronouslyWithAlert(async () => { + try { + const qualifiers = selectedGithubLogin != null ? ` user:${selectedGithubLogin}` : ""; + const queryString = new URLSearchParams({ + q: `${trimmed}${qualifiers} fork:true`, + per_page: "30", + sort: "updated", + }).toString(); + const json = await githubFetch(`/search/repositories?${queryString}`); + if (cancelled) { + return; + } + if (isObject(json) && Array.isArray(json.items)) { + setRepoSearchResults(parseGithubRepositories(json.items)); + } else { + setRepoSearchResults([]); + } + setRepoSearchError(null); + } catch (error) { + if (cancelled) return; + if (isGithubRateLimitError(error)) { + setRepoSearchResults([]); + setRepoSearchError("GitHub rate-limited the search. Wait a moment and try again."); + return; + } + setRepoSearchError(null); + throw error; + } finally { + if (!cancelled) { + setLoadingRepoSearch(false); + } + } + }); + }, 300); + return () => { + cancelled = true; + clearTimeout(handle); + }; + }, [githubFetch, repoSearchQuery, selectedGithubAccount, selectedGithubLogin, step]); + + // Debounced GitHub search for branches. The branches endpoint has no search, + // but /git/matching-refs/heads/{prefix} returns prefix-matched refs and is + // the right tool for repos with many branches. + useEffect(() => { + const trimmed = branchSearchQuery.trim(); + if (step !== "github-repository" || trimmed.length === 0 || selectedRepository == null) { + setBranchSearchResults([]); + setLoadingBranchSearch(false); + setBranchSearchError(null); + return; + } + const { owner, repo } = parseRepositoryFullName(selectedRepository.fullName); + let cancelled = false; + setLoadingBranchSearch(true); + const handle = setTimeout(() => { + runAsynchronouslyWithAlert(async () => { + try { + const json = await githubFetch( + githubRepositoryApiPath(owner, repo, urlString`/git/matching-refs/heads/${trimmed}`), + ); + if (cancelled) { + return; + } + setBranchSearchResults(parseGithubMatchingRefs(json)); + setBranchSearchError(null); + } catch (error) { + if (cancelled) return; + if (isGithubRateLimitError(error)) { + setBranchSearchResults([]); + setBranchSearchError("GitHub rate-limited the search. Wait a moment and try again."); + return; + } + setBranchSearchError(null); + throw error; + } finally { + if (!cancelled) { + setLoadingBranchSearch(false); + } + } + }); + }, 300); + return () => { + cancelled = true; + clearTimeout(handle); + }; + }, [branchSearchQuery, githubFetch, selectedRepository, step]); let title = "Link an existing config"; let subtitle = "Connect GitHub automation or push your local stack.config file."; @@ -1163,14 +1408,48 @@ export function LinkExistingOnboarding(props: Props) { content = (
-
- CLI command -
-              {localCommand}
-            
- - This signs in to Stack Auth, then pushes your local config file for project {project.id}. - +
+
+ CLI commands + ({ id: runner, label: runner }))} + selected={packageRunner} + onSelect={(id) => { + const runner = PACKAGE_RUNNERS.find((entry) => entry === id); + if (runner != null) { + setPackageRunner(runner); + } + }} + size="sm" + /> +
+ +
+ + 1. Sign in to Stack Auth + + +
+ +
+ + 2. Push your config + + + + Replace <path-to-your-config-file> with your local config file path. + +
@@ -1204,11 +1483,18 @@ export function LinkExistingOnboarding(props: Props) { title = "Choose repository and branch"; subtitle = "Connect your GitHub account, then choose where the workflow should run."; - const repoOptions = repositories.map((repository) => ({ + const repoComboboxItems: ComboboxItem[] = ( + repoSearchQuery.trim().length > 0 ? repoSearchResults : repositories + ).map((repository) => ({ value: repository.fullName, - label: repository.isPrivate ? `${repository.fullName} (private)` : repository.fullName, + label: repository.fullName, + trailingIcon: repository.isPrivate ? ( + + ) : undefined, })); - const branchOptions = branches.map((branch) => ({ + const branchComboboxItems: ComboboxItem[] = ( + branchSearchQuery.trim().length > 0 ? branchSearchResults : branches + ).map((branch) => ({ value: branch, label: branch, })); @@ -1222,18 +1508,39 @@ export function LinkExistingOnboarding(props: Props) { Connected GitHub account {githubAccounts.length === 0 ? ( - +
+ + runAsynchronouslyWithAlert(async () => { + await user.getOrLinkConnectedAccount("github", { scopes: GITHUB_SCOPE_REQUIREMENTS }); + })} + > + Connect GitHub account + +
+ ) : selectedGithubAccount != null && !githubAccountLogins.has(selectedGithubAccount.providerAccountId) ? ( + // Hide the dropdown until the GitHub /user fetch populates the + // login, so we never briefly show the numeric providerAccountId. +
+ + + Loading GitHub account... + +
) : ( runAsynchronouslyWithAlert(async () => { if (value === CONNECT_NEW_GITHUB_ACCOUNT_OPTION) { - await user.getOrLinkConnectedAccount("github", { scopes: GITHUB_SCOPE_REQUIREMENTS }); - await loadRepositories(); + // linkConnectedAccount always starts a fresh OAuth flow; + // getOrLinkConnectedAccount would just return the existing + // account and never let the user add another one. + await user.linkConnectedAccount("github", { scopes: GITHUB_SCOPE_REQUIREMENTS }); return; } @@ -1243,7 +1550,6 @@ export function LinkExistingOnboarding(props: Props) { } setSelectedGithubAccountIdWithPersistence(value); - await loadRepositories({ accountOverride: account }); })} options={[ { @@ -1262,47 +1568,77 @@ export function LinkExistingOnboarding(props: Props) {
Repository - runAsynchronouslyWithAlert(async () => { + items={repoComboboxItems} + query={repoSearchQuery} + onQueryChange={setRepoSearchQuery} + onSelect={(nextRepository) => runAsynchronouslyWithAlert(async () => { setSelectedRepositoryFullNameWithPersistence(nextRepository); setBranches([]); setSelectedBranchWithPersistence(""); + setBranchSearchQuery(""); + setBranchSearchResults([]); setConfigPathSuggestions([]); setGitTreeTruncated(false); + setRepoSearchQuery(""); if (nextRepository.length > 0) { await loadBranches(nextRepository); } })} - options={repoOptions} - placeholder={loadingRepositories ? "Loading repositories..." : "Select a repository"} - size="md" - disabled={repositories.length === 0} + triggerPlaceholder={loadingRepositories ? "Loading repositories..." : "Select a repository"} + inputPlaceholder="Search GitHub repositories..." + loading={loadingRepoSearch || (loadingRepositories && repositories.length === 0)} + emptyMessage={ + repoSearchError + ?? (repoSearchQuery.trim().length === 0 ? "No repositories loaded yet." : "No matching repositories.") + } + disabled={selectedGithubAccount == null} />
Branch
- - + { + setSelectedBranchWithPersistence(nextBranch); + setBranchSearchQuery(""); + }} + triggerPlaceholder={loadingBranches ? "Loading branches..." : "Select a branch"} + inputPlaceholder="Search branches..." + loading={loadingBranchSearch || (loadingBranches && branches.length === 0)} + emptyMessage={ + branchSearchError + ?? (branchSearchQuery.trim().length === 0 ? "No branches loaded yet." : "No matching branches.") + } + disabled={selectedRepositoryFullName.length === 0} + /> +
+
@@ -1352,6 +1688,14 @@ export function LinkExistingOnboarding(props: Props) { glassmorphic /> )} + {configPathError != null && ( + + )}
@@ -1402,10 +1746,28 @@ export function LinkExistingOnboarding(props: Props) { primaryAction = ( setIsCommitDialogOpen(true)} + disabled={configPathInput.trim().length === 0 || isCheckingConfigPath} + onClick={() => runAsynchronouslyWithAlert(async () => { + if (selectedRepository == null || selectedBranch.length === 0) { + return; + } + const { owner, repo } = parseRepositoryFullName(selectedRepository.fullName); + const path = configPathInput.trim(); + setConfigPathError(null); + setIsCheckingConfigPath(true); + try { + const exists = await checkConfigPathExists(owner, repo, selectedBranch, path); + if (!exists) { + setConfigPathError(`"${path}" was not found on branch "${selectedBranch}". Double-check the path or push the file to that branch first.`); + return; + } + setIsCommitDialogOpen(true); + } finally { + setIsCheckingConfigPath(false); + } + })} > - Create GitHub Action + {isCheckingConfigPath ? "Checking..." : "Create GitHub Action"} ); diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx index eb6dbd50b7..5ec49fc0d8 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx @@ -4,6 +4,28 @@ import type { ButtonHTMLAttributes } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +// JSDOM does not ship `window.matchMedia`, and modules transitively imported by +// `./page-client` (theme.tsx via code-block.tsx) call it at module-load time. +// Stub it before those imports run so the test file can be evaluated. +vi.hoisted(() => { + if (typeof window !== "undefined" && typeof window.matchMedia !== "function") { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); + } +}); + vi.mock("@/components/ui", async (importOriginal) => { const actual = await importOriginal(); diff --git a/packages/stack-cli/src/commands/config-file.ts b/packages/stack-cli/src/commands/config-file.ts index fdbec50ad9..ab471fc8ea 100644 --- a/packages/stack-cli/src/commands/config-file.ts +++ b/packages/stack-cli/src/commands/config-file.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import * as path from "path"; import * as fs from "fs"; -import { isProjectAuthWithRefreshToken, isProjectAuthWithSecretServerKey, resolveAuth, type ProjectAuthWithSecretServerKey } from "../lib/auth.js"; +import { isProjectAuthWithRefreshToken, isProjectAuthWithSecretServerKey, resolveAuth, resolveProjectId, type ProjectAuthWithSecretServerKey } from "../lib/auth.js"; import { getAdminProject } from "../lib/app.js"; import { CliError } from "../lib/errors.js"; import { resolveConfigFilePathOption } from "../lib/config-file-path.js"; @@ -142,11 +142,11 @@ export function registerConfigCommand(program: Command) { config .command("pull") .description("Pull branch config to a local file") - .requiredOption("--cloud-project-id ", "Cloud project ID to pull config from") + .option("--cloud-project-id ", "Cloud project ID to pull config from (defaults to the STACK_PROJECT_ID env var)") .option("--config-file ", "Path to write config file (.ts); defaults to ./stack.config.ts in the current directory") .option("--overwrite", "Overwrite an existing config file") .action(async (opts) => { - const auth = resolveAuth(opts.cloudProjectId); + const auth = resolveAuth(resolveProjectId(opts.cloudProjectId)); if (!isProjectAuthWithRefreshToken(auth)) { throw new CliError("`stack config pull` requires `stack login`. Remove STACK_SECRET_SERVER_KEY and try again."); } @@ -174,10 +174,10 @@ export function registerConfigCommand(program: Command) { config .command("push") .description("Push a local config file to branch config") - .requiredOption("--cloud-project-id ", "Cloud project ID to push config to") + .option("--cloud-project-id ", "Cloud project ID to push config to (defaults to the STACK_PROJECT_ID env var)") .requiredOption("--config-file ", "Path to config file (.js or .ts)") .action(async (opts) => { - const auth = resolveAuth(opts.cloudProjectId); + const auth = resolveAuth(resolveProjectId(opts.cloudProjectId)); const filePath = resolveConfigFilePathOption(opts.configFile, { mustExist: true }); const ext = path.extname(filePath); diff --git a/packages/stack-cli/src/lib/auth.test.ts b/packages/stack-cli/src/lib/auth.test.ts index 8feb5a9ede..3466a5039c 100644 --- a/packages/stack-cli/src/lib/auth.test.ts +++ b/packages/stack-cli/src/lib/auth.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { isRetryableFetchError, localEmulatorReadyTimeoutMs } from "./auth.js"; +import { isRetryableFetchError, localEmulatorReadyTimeoutMs, resolveProjectId } from "./auth.js"; describe("isRetryableFetchError", () => { it("retries TypeError (Node fetch wraps connection errors as TypeError)", () => { @@ -69,3 +69,37 @@ describe("localEmulatorReadyTimeoutMs", () => { expect(() => localEmulatorReadyTimeoutMs()).toThrow(/Invalid STACK_EMULATOR_READY_TIMEOUT_MS/); }); }); + +describe("resolveProjectId", () => { + const SAVED = process.env.STACK_PROJECT_ID; + beforeEach(() => { + delete process.env.STACK_PROJECT_ID; + }); + afterEach(() => { + if (SAVED === undefined) delete process.env.STACK_PROJECT_ID; + else process.env.STACK_PROJECT_ID = SAVED; + }); + + it("uses the --cloud-project-id option when provided", () => { + expect(resolveProjectId("proj_from_flag")).toBe("proj_from_flag"); + }); + + it("falls back to the STACK_PROJECT_ID env var when the option is omitted", () => { + process.env.STACK_PROJECT_ID = "proj_from_env"; + expect(resolveProjectId(undefined)).toBe("proj_from_env"); + }); + + it("prefers the option over the env var", () => { + process.env.STACK_PROJECT_ID = "proj_from_env"; + expect(resolveProjectId("proj_from_flag")).toBe("proj_from_flag"); + }); + + it("treats an empty option string as absent and falls back to the env var", () => { + process.env.STACK_PROJECT_ID = "proj_from_env"; + expect(resolveProjectId("")).toBe("proj_from_env"); + }); + + it("throws a CliError with help text when neither is provided", () => { + expect(() => resolveProjectId(undefined)).toThrow(/STACK_PROJECT_ID/); + }); +}); diff --git a/packages/stack-cli/src/lib/auth.ts b/packages/stack-cli/src/lib/auth.ts index 14147af7d6..19589a5de1 100644 --- a/packages/stack-cli/src/lib/auth.ts +++ b/packages/stack-cli/src/lib/auth.ts @@ -86,6 +86,18 @@ export function resolveAuth(projectId: string): ProjectAuth { }; } +// Resolve the cloud project ID from the `--cloud-project-id` option, falling +// back to the STACK_PROJECT_ID environment variable. Empty strings are treated +// as absent so callers can pass through optional option values directly. +export function resolveProjectId(projectIdOption?: string): string { + for (const candidate of [projectIdOption, process.env.STACK_PROJECT_ID]) { + if (candidate != null && candidate !== "") { + return candidate; + } + } + throw new CliError("No project ID provided. Pass --cloud-project-id or set the STACK_PROJECT_ID environment variable."); +} + export function isProjectAuthWithSecretServerKey(auth: ProjectAuth): auth is ProjectAuthWithSecretServerKey { return "secretServerKey" in auth; }