From d0e6ad15fa8a18707e5c566d1d241a517da846dd Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 19 May 2026 10:41:34 -0700 Subject: [PATCH 01/16] fix(dashboard): repair GitHub config link-existing flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Generated workflow now passes the required --cloud-project-id flag (sourced from the STACK_PROJECT_ID secret), which was previously missing and never read — every workflow run failed. - workflow_dispatch is now best-effort: it 404s when the workflow is not on the default branch, but the workflow-file commit already triggers a run via the push paths filter, so the flow continues. - Config paths are normalized (leading ./ stripped) so the workflow's push paths filter actually matches ongoing config edits. - The github-repository step now shows a Connect button when no GitHub account is connected, instead of a dead-end alert. - "Connect new" uses linkConnectedAccount so it can actually add an account, rather than getOrLinkConnectedAccount which just returns the existing one. - Repositories load via an effect when the step has a selected account, fixing the empty repo list after a connect redirect or page reload. - Local CLI command shown to users uses --cloud-project-id, matching the actual CLI flag. --- .../link-existing-onboarding-workflow.ts | 11 ++- .../link-existing-onboarding.tsx | 82 +++++++++++++++---- 2 files changed, 75 insertions(+), 18 deletions(-) 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..5eddc39f5b 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,16 @@ 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 encodedConfigPath = encodeYamlScalar(normalizeConfigPath(configPath)); const encodedWorkflowPath = encodeYamlScalar(WORKFLOW_FILE_PATH); return `name: Stack Auth Config Sync @@ -36,6 +43,6 @@ jobs: 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: pnpx @stackframe/stack-cli@latest config push --cloud-project-id "$STACK_PROJECT_ID" --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 82d899b8be..a1f2b685b3 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 @@ -13,7 +13,7 @@ import { runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/ import { deindent, 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 { OnboardingPage } from "./components"; import { @@ -400,9 +400,10 @@ async function encryptSecretValue(value: string, base64PublicKey: string): Promi } 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)); } @@ -455,6 +456,7 @@ export function LinkExistingOnboarding(props: Props) { const capturedWorkflowFailureRef = useRef(null); const localAutoMonitoringKeyRef = useRef(null); const githubLogsAutoPollingKeyRef = useRef(null); + const repositoriesLoadedAccountRef = useRef(null); const [configPathInput, setConfigPathInput] = useState(persistedState?.configPathInput ?? "stack.config.ts"); const persistState = useCallback((partial: Partial) => { @@ -804,8 +806,9 @@ export function LinkExistingOnboarding(props: Props) { if (options?.forceConnect) { await user.getOrLinkConnectedAccount("github", { scopes: GITHUB_SCOPE_REQUIREMENTS }); } - await loadRepositories(); - }, [appendLog, loadRepositories, setStepWithPersistence, user]); + // Repositories load via the github-repository effect once an account is + // selected, which also covers returning here after a connect redirect. + }, [appendLog, setStepWithPersistence, user]); const loadBranches = useCallback(async (repositoryFullName: string): Promise => { if (repositoryFullName.length === 0) { @@ -1004,9 +1007,18 @@ 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(`Could not dispatch the workflow directly (${message}). The workflow commit should still trigger a run; continuing to monitor.`); + } setStepWithPersistence("github-logs"); setIsCommitDialogOpen(false); @@ -1097,10 +1109,35 @@ export function LinkExistingOnboarding(props: Props) { const localCommand = useMemo(() => { return deindent` pnpx @stackframe/stack-cli@latest login - pnpx @stackframe/stack-cli@latest config push --config-file --project-id "${project.id}" + pnpx @stackframe/stack-cli@latest config push --config-file --cloud-project-id "${project.id}" `; }, [project.id]); + // Load repositories whenever the github-repository step has a selected + // account we haven't loaded yet. This also covers landing back on this step + // after a connect-account OAuth redirect or a page reload. + 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) { + repositoriesLoadedAccountRef.current = null; + throw error; + } + }); + }, [loadRepositories, selectedGithubAccount, step]); + let title = "Link an existing config"; let subtitle = "Connect GitHub automation or push your local stack.config file."; let content: React.ReactNode; @@ -1221,18 +1258,30 @@ export function LinkExistingOnboarding(props: Props) { Connected GitHub account {githubAccounts.length === 0 ? ( - +
+ + runAsynchronouslyWithAlert(async () => { + await user.getOrLinkConnectedAccount("github", { scopes: GITHUB_SCOPE_REQUIREMENTS }); + })} + > + Connect 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; } @@ -1241,8 +1290,9 @@ export function LinkExistingOnboarding(props: Props) { throw new Error("Selected GitHub account not found."); } + // Switching the selected account triggers the + // github-repository effect, which reloads repositories. setSelectedGithubAccountIdWithPersistence(value); - await loadRepositories({ accountOverride: account }); })} options={[ { From 65789a1acb7133280c7e623522e4df3a298417b1 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 19 May 2026 10:59:47 -0700 Subject: [PATCH 02/16] fix(dashboard): use npx instead of pnpx in config sync workflow The generated GitHub Actions workflow ran the CLI via `pnpx`, but the ubuntu-latest runner has Node/npx but no pnpm, so the step failed with `pnpx: command not found` (exit 127). - Run the CLI with `npx --yes` and add an actions/setup-node step to pin Node on the runner. - Update the local CLI command shown to users to `npx` as well, since `pnpx` is not universally available. --- .../page-client-parts/link-existing-onboarding-workflow.ts | 6 +++++- .../page-client-parts/link-existing-onboarding.tsx | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) 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 5eddc39f5b..b73d1636d1 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 @@ -38,11 +38,15 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + 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 --cloud-project-id "$STACK_PROJECT_ID" --config-file "$STACK_AUTH_CONFIG_PATH" + run: npx --yes @stackframe/stack-cli@latest config push --cloud-project-id "$STACK_PROJECT_ID" --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 a1f2b685b3..129dc781b5 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 @@ -1108,8 +1108,8 @@ export function LinkExistingOnboarding(props: Props) { const localCommand = useMemo(() => { return deindent` - pnpx @stackframe/stack-cli@latest login - pnpx @stackframe/stack-cli@latest config push --config-file --cloud-project-id "${project.id}" + npx @stackframe/stack-cli@latest login + npx @stackframe/stack-cli@latest config push --config-file --cloud-project-id "${project.id}" `; }, [project.id]); From ed25eabf913f2f905b0a112a1d96f3abb18af1a1 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 19 May 2026 11:06:59 -0700 Subject: [PATCH 03/16] feat(dashboard): improve local CLI step in link-existing flow - Add an npx/pnpx/bunx package-runner toggle (npx default) so users can pick the runner that matches their setup. - Split the single command block into separate "Sign in" and "Push config" snippets so users who already ran login can copy just the push command. - Move --config-file to the end of the push command so the whole command up to the placeholder is easy to copy. - Reuse the shared CodeBlock component (built-in copy button) instead of a bare
 for consistency.
---
 .../link-existing-onboarding.tsx              | 69 +++++++++++++++----
 1 file changed, 54 insertions(+), 15 deletions(-)

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 129dc781b5..0b1f77fc6e 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";
@@ -10,7 +12,7 @@ import { GithubLogoIcon, LinkBreakIcon, TerminalWindowIcon } from "@phosphor-ico
 import { type AdminOwnedProject, type PushedConfigSource, useUser } 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, useEffect, useMemo, useRef, useState } from "react";
@@ -101,6 +103,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}`;
 }
@@ -458,6 +463,7 @@ export function LinkExistingOnboarding(props: Props) {
   const githubLogsAutoPollingKeyRef = useRef(null);
   const repositoriesLoadedAccountRef = useRef(null);
   const [configPathInput, setConfigPathInput] = useState(persistedState?.configPathInput ?? "stack.config.ts");
+  const [packageRunner, setPackageRunner] = useState("npx");
 
   const persistState = useCallback((partial: Partial) => {
     const existingState = readPersistedLinkExistingState(project.id);
@@ -1106,12 +1112,8 @@ export function LinkExistingOnboarding(props: Props) {
 
   const canContinue = pushedConfigSource != null && pushedConfigSource.type !== "unlinked";
 
-  const localCommand = useMemo(() => {
-    return deindent`
-      npx @stackframe/stack-cli@latest login
-      npx @stackframe/stack-cli@latest config push --config-file  --cloud-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 "${project.id}" --config-file `;
 
   // Load repositories whenever the github-repository step has a selected
   // account we haven't loaded yet. This also covers landing back on this step
@@ -1199,14 +1201,51 @@ 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 + + + + Skip this if you have already signed in with the CLI. + +
+ +
+ + 2. Push your config + + + + Replace <path-to-your-config-file> with your local config file path. This pushes the config for project {project.id}. + +
From ebb090e5b6f68950d36eb5b64bb6cd57a948f457 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 19 May 2026 11:11:50 -0700 Subject: [PATCH 04/16] chore(dashboard): trim helper text on local CLI link step Remove the "skip this if already signed in" and "this pushes the config for project ..." helper lines for a cleaner page. --- .../page-client-parts/link-existing-onboarding.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 0b1f77fc6e..71cf8b88ab 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 @@ -1227,9 +1227,6 @@ export function LinkExistingOnboarding(props: Props) { language="bash" content={loginCommand} /> - - Skip this if you have already signed in with the CLI. -
@@ -1243,7 +1240,7 @@ export function LinkExistingOnboarding(props: Props) { content={configPushCommand} /> - Replace <path-to-your-config-file> with your local config file path. This pushes the config for project {project.id}. + Replace <path-to-your-config-file> with your local config file path.
From 55ff7e3197c1ec188dea52dc65b9983de560ccac Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 19 May 2026 11:21:03 -0700 Subject: [PATCH 05/16] feat(stack-cli): fall back to STACK_PROJECT_ID env var for project id `config push` and `config pull` no longer require --cloud-project-id; when omitted, the project id is read from the STACK_PROJECT_ID environment variable via a new resolveProjectId helper. Empty option strings are treated as absent. The generated GitHub Actions workflow already exports STACK_PROJECT_ID as a step env var, so the explicit --cloud-project-id flag is dropped from the run command. --- .../link-existing-onboarding-workflow.ts | 2 +- .../stack-cli/src/commands/config-file.ts | 10 +++--- packages/stack-cli/src/lib/auth.test.ts | 36 ++++++++++++++++++- packages/stack-cli/src/lib/auth.ts | 12 +++++++ 4 files changed, 53 insertions(+), 7 deletions(-) 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 b73d1636d1..2c198b39cb 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 @@ -47,6 +47,6 @@ jobs: 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: npx --yes @stackframe/stack-cli@latest config push --cloud-project-id "$STACK_PROJECT_ID" --config-file "$STACK_AUTH_CONFIG_PATH" + run: npx --yes @stackframe/stack-cli@latest config push --config-file "$STACK_AUTH_CONFIG_PATH" `; } 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; } From de9ec19232b5aef3e10c960d009316e3e403add1 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 19 May 2026 14:41:30 -0700 Subject: [PATCH 06/16] fix(dashboard): avoid flashing GitHub providerAccountId in account dropdown The connected-account selector on the 'Choose repository and branch' step rendered with the numeric providerAccountId until the GitHub /user fetch populated githubAccountLogins. Replace the dropdown with a small Spinner + 'Loading GitHub account...' row while the selected account's login is unknown, then show the dropdown once available. --- .../page-client-parts/link-existing-onboarding.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) 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 71cf8b88ab..a9e68ec0ac 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 @@ -1309,6 +1309,15 @@ export function LinkExistingOnboarding(props: Props) { 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... + +
) : ( Date: Tue, 19 May 2026 14:55:59 -0700 Subject: [PATCH 07/16] feat(dashboard): searchable combobox for repo + branch in link flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New RemoteSearchCombobox (Popover + cmdk pattern already used in dashboard data-tables) drives both selectors. - Repository selector: type-ahead with debounced /search/repositories fetch so users with more than 100 repos can find any of them, not just the first /user/repos page. - Branch selector: type-ahead with debounced /git/matching-refs/heads prefix search (the branches endpoint itself has no query support). - Drop the Branch "Refresh" button — branches already auto-load on repository select, and the combobox can refresh by reopening. --- .../link-existing-combobox.tsx | 109 +++++++++++ .../link-existing-onboarding.tsx | 181 +++++++++++++++--- 2 files changed, 260 insertions(+), 30 deletions(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-combobox.tsx 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..2ce665e26a --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-combobox.tsx @@ -0,0 +1,109 @@ +"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 } from "react"; + +export type ComboboxItem = { + value: string, + label: string, + description?: string, +}; + +type Props = { + value: string, + selectedLabel: string, + items: ComboboxItem[], + onSelect: (value: string) => void, + query: string, + onQueryChange: (query: string) => void, + triggerPlaceholder?: string, + inputPlaceholder?: string, + emptyMessage?: string, + loading?: boolean, + disabled?: boolean, +}; + +// Combobox built on the same Popover + cmdk pattern the dashboard already uses +// in faceted-filter, so it inherits the project's visual language. The parent +// owns `items` and `query`, which lets us drive options from a debounced GitHub +// API call rather than the cmdk default client-side filter. +export function RemoteSearchCombobox(props: Props) { + const [open, setOpen] = useState(false); + + 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}
+ )} +
+
+ ))} +
+ )} +
+
+
+
+ ); +} 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 a9e68ec0ac..dfae39f221 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 @@ -17,6 +17,7 @@ import { urlString } from "@stackframe/stack-shared/dist/utils/urls"; import sodium from "libsodium-wrappers"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { RemoteSearchCombobox, type ComboboxItem } from "./link-existing-combobox"; import { OnboardingPage } from "./components"; import { buildWorkflowYaml, @@ -358,6 +359,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)) { + return []; + } + 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."); @@ -464,6 +485,12 @@ export function LinkExistingOnboarding(props: Props) { const repositoriesLoadedAccountRef = useRef(null); 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 [branchSearchQuery, setBranchSearchQuery] = useState(""); + const [branchSearchResults, setBranchSearchResults] = useState([]); + const [loadingBranchSearch, setLoadingBranchSearch] = useState(false); const persistState = useCallback((partial: Partial) => { const existingState = readPersistedLinkExistingState(project.id); @@ -1140,6 +1167,92 @@ export function LinkExistingOnboarding(props: Props) { }); }, [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. Server-side search includes private repos when authenticated. + useEffect(() => { + const trimmed = repoSearchQuery.trim(); + if (step !== "github-repository" || trimmed.length === 0 || selectedGithubAccount == null) { + setRepoSearchResults([]); + setLoadingRepoSearch(false); + return; + } + let cancelled = false; + setLoadingRepoSearch(true); + const handle = setTimeout(() => { + runAsynchronouslyWithAlert(async () => { + try { + const queryString = new URLSearchParams({ + q: `${trimmed} 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([]); + } + } finally { + if (!cancelled) { + setLoadingRepoSearch(false); + } + } + }); + }, 300); + return () => { + cancelled = true; + clearTimeout(handle); + }; + }, [githubFetch, repoSearchQuery, selectedGithubAccount, 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); + return; + } + let owner: string; + let repo: string; + try { + ({ owner, repo } = parseRepositoryFullName(selectedRepository.fullName)); + } catch { + setBranchSearchResults([]); + setLoadingBranchSearch(false); + return; + } + 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)); + } 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."; let content: React.ReactNode; @@ -1276,11 +1389,16 @@ 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, + description: repository.isPrivate ? "private" : undefined, })); - const branchOptions = branches.map((branch) => ({ + const branchComboboxItems: ComboboxItem[] = ( + branchSearchQuery.trim().length > 0 ? branchSearchResults : branches + ).map((branch) => ({ value: branch, label: branch, })); @@ -1356,48 +1474,51 @@ export function LinkExistingOnboarding(props: Props) {
Repository - runAsynchronouslyWithAlert(async () => { + selectedLabel={selectedRepositoryFullName} + 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={repoSearchQuery.trim().length === 0 ? "No repositories loaded yet." : "No matching repositories."} + disabled={selectedGithubAccount == null} />
Branch -
- - runAsynchronouslyWithAlert(async () => { - await loadBranches(selectedRepositoryFullName); - })} - > - Refresh - -
+ { + setSelectedBranchWithPersistence(nextBranch); + setBranchSearchQuery(""); + }} + triggerPlaceholder={loadingBranches ? "Loading branches..." : "Select a branch"} + inputPlaceholder="Search branches..." + loading={loadingBranchSearch || (loadingBranches && branches.length === 0)} + emptyMessage={branchSearchQuery.trim().length === 0 ? "No branches loaded yet." : "No matching branches."} + disabled={selectedRepositoryFullName.length === 0} + />
From 2faffb662ab778f88cd99cfd5fc1a146d8de27fc Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 19 May 2026 15:23:12 -0700 Subject: [PATCH 08/16] refactor(dashboard): drop unused useTransition around onboarding status update The startStatusTransition wrap around a single Map insert into projectStatuses wasn't deferring anything meaningful, and the [, startStatusTransition] destructure with an unused first slot was noise. Inline the setState call and drop the useTransition import. --- .../new-project/page-client-parts/content.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) 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 99a454bd92..7a2d35f43b 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 @@ -26,7 +26,7 @@ import { PlusCircleIcon } from "@phosphor-icons/react"; import { AdminOwnedProject, useStackApp, useUser } 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"; @@ -72,7 +72,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); @@ -214,12 +213,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(); From 5ce1b6bd96bd56d4e290b5978d46ddcbb58ed796 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 19 May 2026 15:36:25 -0700 Subject: [PATCH 09/16] refactor(dashboard): race-safe loadRepositories and simpler combobox API - RemoteSearchCombobox derives the trigger label internally from items + value (falling back to the value string) instead of taking a selectedLabel prop, so call sites don't have to thread it. - loadRepositories now uses a runId guard (matching the existing pollingRunIdRef / localMonitoringRunIdRef pattern) so a stale call can't clobber state set by a newer one. The repo auto-load effect's catch only resets the loaded-account ref when it still matches the failed account, for the same reason. - Drop a defensive try/catch around parseRepositoryFullName in the branch-search effect; selectedRepository is already null-guarded. --- .../link-existing-combobox.tsx | 10 ++---- .../link-existing-onboarding.tsx | 35 ++++++++----------- 2 files changed, 18 insertions(+), 27 deletions(-) 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 index 2ce665e26a..139c06858b 100644 --- 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 @@ -14,7 +14,6 @@ export type ComboboxItem = { type Props = { value: string, - selectedLabel: string, items: ComboboxItem[], onSelect: (value: string) => void, query: string, @@ -26,12 +25,9 @@ type Props = { disabled?: boolean, }; -// Combobox built on the same Popover + cmdk pattern the dashboard already uses -// in faceted-filter, so it inherits the project's visual language. The parent -// owns `items` and `query`, which lets us drive options from a debounced GitHub -// API call rather than the cmdk default client-side filter. export function RemoteSearchCombobox(props: Props) { const [open, setOpen] = useState(false); + const selectedLabel = props.items.find((item) => item.value === props.value)?.label ?? props.value; return ( @@ -48,8 +44,8 @@ export function RemoteSearchCombobox(props: Props) { "dark:border-white/[0.06] dark:bg-background/60 dark:ring-white/[0.06] dark:hover:ring-white/[0.1]", )} > - - {props.selectedLabel.length > 0 ? props.selectedLabel : (props.triggerPlaceholder ?? "Select")} + + {selectedLabel.length > 0 ? selectedLabel : (props.triggerPlaceholder ?? "Select")} 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 dfae39f221..048960b3b9 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 @@ -483,6 +483,7 @@ export function LinkExistingOnboarding(props: Props) { 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(""); @@ -785,9 +786,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); @@ -800,6 +804,7 @@ export function LinkExistingOnboarding(props: Props) { undefined, account, ); + if (!isCurrent()) return; const parsedRepositories = parseGithubRepositories(response); setRepositories(parsedRepositories); setBranches([]); @@ -829,7 +834,9 @@ export function LinkExistingOnboarding(props: Props) { } } } finally { - setLoadingRepositories(false); + if (isCurrent()) { + setLoadingRepositories(false); + } } }, [githubFetch, selectedGithubAccount, selectedRepositoryFullName, setSelectedBranchWithPersistence, setSelectedRepositoryFullNameWithPersistence]); @@ -839,8 +846,6 @@ export function LinkExistingOnboarding(props: Props) { if (options?.forceConnect) { await user.getOrLinkConnectedAccount("github", { scopes: GITHUB_SCOPE_REQUIREMENTS }); } - // Repositories load via the github-repository effect once an account is - // selected, which also covers returning here after a connect redirect. }, [appendLog, setStepWithPersistence, user]); const loadBranches = useCallback(async (repositoryFullName: string): Promise => { @@ -1142,9 +1147,9 @@ export function LinkExistingOnboarding(props: Props) { const loginCommand = `${packageRunner} @stackframe/stack-cli@latest login`; const configPushCommand = `${packageRunner} @stackframe/stack-cli@latest config push --cloud-project-id "${project.id}" --config-file `; - // Load repositories whenever the github-repository step has a selected - // account we haven't loaded yet. This also covers landing back on this step - // after a connect-account OAuth redirect or a page reload. + // 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; @@ -1161,7 +1166,9 @@ export function LinkExistingOnboarding(props: Props) { try { await loadRepositories({ accountOverride: account }); } catch (error) { - repositoriesLoadedAccountRef.current = null; + if (repositoriesLoadedAccountRef.current === account.providerAccountId) { + repositoriesLoadedAccountRef.current = null; + } throw error; } }); @@ -1219,15 +1226,7 @@ export function LinkExistingOnboarding(props: Props) { setLoadingBranchSearch(false); return; } - let owner: string; - let repo: string; - try { - ({ owner, repo } = parseRepositoryFullName(selectedRepository.fullName)); - } catch { - setBranchSearchResults([]); - setLoadingBranchSearch(false); - return; - } + const { owner, repo } = parseRepositoryFullName(selectedRepository.fullName); let cancelled = false; setLoadingBranchSearch(true); const handle = setTimeout(() => { @@ -1453,8 +1452,6 @@ export function LinkExistingOnboarding(props: Props) { throw new Error("Selected GitHub account not found."); } - // Switching the selected account triggers the - // github-repository effect, which reloads repositories. setSelectedGithubAccountIdWithPersistence(value); })} options={[ @@ -1476,7 +1473,6 @@ export function LinkExistingOnboarding(props: Props) { Repository Branch Date: Tue, 19 May 2026 16:03:12 -0700 Subject: [PATCH 10/16] feat(dashboard): scope repo search, surface rate limits, branch refresh - Repo search now adds `user:` to the /search/repositories query so results stay within the connected user's repos instead of returning global GitHub results - Inline rate-limit message in the repo and branch combobox when GitHub returns a 403/429, instead of firing a generic alert - Refresh icon button next to the branch combobox so users who create a branch on GitHub mid-flow can refetch without switching repos - Clearer log when workflow_dispatch fails because the workflow file is not yet on the default branch --- .../link-existing-onboarding.tsx | 117 ++++++++++++++---- 1 file changed, 96 insertions(+), 21 deletions(-) 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 048960b3b9..3e0d25c321 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 @@ -8,7 +8,7 @@ import { DesignCard } from "@/components/design-components/card"; import { DesignInput } from "@/components/design-components/input"; import { DesignSelectorDropdown } from "@/components/design-components/select"; import { ActionDialog, Spinner, Typography, cn } from "@/components/ui"; -import { GithubLogoIcon, LinkBreakIcon, TerminalWindowIcon } from "@phosphor-icons/react"; +import { ArrowsClockwiseIcon, GithubLogoIcon, LinkBreakIcon, TerminalWindowIcon } from "@phosphor-icons/react"; import { type AdminOwnedProject, type PushedConfigSource, useUser } from "@stackframe/stack"; import { captureError } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises"; @@ -425,6 +425,14 @@ 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. @@ -489,9 +497,11 @@ export function LinkExistingOnboarding(props: Props) { 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 persistState = useCallback((partial: Partial) => { const existingState = readPersistedLinkExistingState(project.id); @@ -1055,7 +1065,13 @@ export function LinkExistingOnboarding(props: Props) { appendLog("Workflow dispatched. Waiting for Stack Auth push..."); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - appendLog(`Could not dispatch the workflow directly (${message}). The workflow commit should still trigger a run; continuing to monitor.`); + 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"); @@ -1176,12 +1192,20 @@ export function LinkExistingOnboarding(props: Props) { // 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. Server-side search includes private repos when authenticated. + // 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; @@ -1189,8 +1213,9 @@ export function LinkExistingOnboarding(props: Props) { const handle = setTimeout(() => { runAsynchronouslyWithAlert(async () => { try { + const qualifiers = selectedGithubLogin != null ? ` user:${selectedGithubLogin}` : ""; const queryString = new URLSearchParams({ - q: `${trimmed} fork:true`, + q: `${trimmed}${qualifiers} fork:true`, per_page: "30", sort: "updated", }).toString(); @@ -1203,6 +1228,16 @@ export function LinkExistingOnboarding(props: Props) { } 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); @@ -1214,7 +1249,7 @@ export function LinkExistingOnboarding(props: Props) { cancelled = true; clearTimeout(handle); }; - }, [githubFetch, repoSearchQuery, selectedGithubAccount, step]); + }, [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 @@ -1224,6 +1259,7 @@ export function LinkExistingOnboarding(props: Props) { if (step !== "github-repository" || trimmed.length === 0 || selectedRepository == null) { setBranchSearchResults([]); setLoadingBranchSearch(false); + setBranchSearchError(null); return; } const { owner, repo } = parseRepositoryFullName(selectedRepository.fullName); @@ -1239,6 +1275,16 @@ export function LinkExistingOnboarding(props: Props) { 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); @@ -1492,28 +1538,57 @@ export function LinkExistingOnboarding(props: Props) { triggerPlaceholder={loadingRepositories ? "Loading repositories..." : "Select a repository"} inputPlaceholder="Search GitHub repositories..." loading={loadingRepoSearch || (loadingRepositories && repositories.length === 0)} - emptyMessage={repoSearchQuery.trim().length === 0 ? "No repositories loaded yet." : "No matching repositories."} + 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={branchSearchQuery.trim().length === 0 ? "No branches loaded yet." : "No matching branches."} - disabled={selectedRepositoryFullName.length === 0} - /> +
+
+ { + 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} + /> +
+ +
From 08c83569e1b9e0ab8ea3729763829e7391af5c03 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 19 May 2026 16:07:36 -0700 Subject: [PATCH 11/16] chore(dashboard): bump generated workflow to actions/{checkout,setup-node}@v6 Matches the version used by every other workflow in this repo. --- .../page-client-parts/link-existing-onboarding-workflow.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 2c198b39cb..e20877393f 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 @@ -37,9 +37,9 @@ jobs: contents: read steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "20" - name: Push Stack Auth config From cdf4c683ffa9db32b0f32129834114789c9ff2dc Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 19 May 2026 16:19:24 -0700 Subject: [PATCH 12/16] fix(dashboard): throw if config path normalizes to empty in workflow yaml Inputs like "./" pass the upstream non-empty trim check but normalize to "" inside buildWorkflowYaml, which would emit `paths: [""]` and an empty STACK_AUTH_CONFIG_PATH env var. Fail fast at the boundary instead of committing a silently broken workflow to the user's repo. --- .../page-client-parts/link-existing-onboarding-workflow.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 e20877393f..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 @@ -16,7 +16,11 @@ export function normalizeConfigPath(configPath: string): string { export function buildWorkflowYaml(branch: string, configPath: string): string { const encodedBranch = encodeYamlScalar(branch); - const encodedConfigPath = encodeYamlScalar(normalizeConfigPath(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 From b6783b2306ea706dc88bf60e70df7ca25565da4b Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 19 May 2026 16:41:28 -0700 Subject: [PATCH 13/16] fix(dashboard): escape project.id via JSON.stringify in copy-paste CLI command Matches the team convention for interpolating values into CLI commands displayed for user copy-paste. Visually identical for current project ID formats, defensive against future changes. --- .../new-project/page-client-parts/link-existing-onboarding.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f645cfac13..664c424691 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 @@ -1162,7 +1162,7 @@ export function LinkExistingOnboarding(props: Props) { const canContinue = pushedConfigSource != null && pushedConfigSource.type !== "unlinked"; const loginCommand = `${packageRunner} @stackframe/stack-cli@latest login`; - const configPushCommand = `${packageRunner} @stackframe/stack-cli@latest config push --cloud-project-id "${project.id}" --config-file `; + 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 From c80c89fbee04cf4b9c6d0e163091f4ef4f39f90f Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 19 May 2026 17:12:57 -0700 Subject: [PATCH 14/16] fix(dashboard): throw on invalid matching-refs response parseGithubMatchingRefs was silently returning [] on non-array input, unlike every other parseGithub* helper in the file which throws. Match the established pattern so a malformed response surfaces instead of quietly producing an empty branch list. --- .../new-project/page-client-parts/link-existing-onboarding.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 664c424691..af661151b5 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 @@ -364,7 +364,7 @@ function parseGitTreePaths(value: unknown): { paths: string[], truncated: boolea // with `refs/heads/`. Strip the prefix so callers see plain branch names. function parseGithubMatchingRefs(value: unknown): string[] { if (!Array.isArray(value)) { - return []; + throw new Error("GitHub returned an invalid matching refs response."); } const HEADS_PREFIX = "refs/heads/"; const branches: string[] = []; From 337cd9e9538ca77558fa7f35744856b65d64a9a2 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 20 May 2026 10:34:32 -0700 Subject: [PATCH 15/16] fix(dashboard): tighten repo combobox row + handle empty-tree repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Render private-repo indicator as a trailing lock icon on a single-line row instead of stacking a "private" subtitle. - Harden checkConfigPathExists against directory/symlink responses and reject `.`/`..` paths before hitting the API. - Treat 404 from `git/trees/` as "no paths yet" so freshly-initialized repos whose commit points at the empty-tree SHA (4b825dc6…) no longer surface a fatal alert on the Select config file step. --- .../link-existing-combobox.tsx | 8 +- .../link-existing-onboarding.tsx | 90 +++++++++++++++++-- 2 files changed, 90 insertions(+), 8 deletions(-) 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 index 139c06858b..175275e78c 100644 --- 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 @@ -4,12 +4,13 @@ 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 } from "react"; +import { useState, type ReactNode } from "react"; export type ComboboxItem = { value: string, label: string, description?: string, + trailingIcon?: ReactNode, }; type Props = { @@ -93,6 +94,11 @@ export function RemoteSearchCombobox(props: Props) {
{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.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding.tsx index af661151b5..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 @@ -9,7 +9,7 @@ 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 { ArrowsClockwiseIcon, 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"; @@ -503,6 +503,8 @@ export function LinkExistingOnboarding(props: Props) { 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); @@ -537,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]); @@ -898,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); @@ -945,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, @@ -1440,7 +1488,9 @@ export function LinkExistingOnboarding(props: Props) { ).map((repository) => ({ value: repository.fullName, label: repository.fullName, - description: repository.isPrivate ? "private" : undefined, + trailingIcon: repository.isPrivate ? ( + + ) : undefined, })); const branchComboboxItems: ComboboxItem[] = ( branchSearchQuery.trim().length > 0 ? branchSearchResults : branches @@ -1638,6 +1688,14 @@ export function LinkExistingOnboarding(props: Props) { glassmorphic /> )} + {configPathError != null && ( + + )}
@@ -1688,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"} ); From 762dcbcdbcc01fcacee54fc3f20f48c2758ceddb Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 20 May 2026 15:52:44 -0700 Subject: [PATCH 16/16] fix(dashboard): repair two new-project test failures from this branch - update buildWorkflowYaml assertion to expect `npx --yes` (the generator switched away from `pnpx` because pnpx isn't on ubuntu-latest) - stub `window.matchMedia` in page-client.test.tsx; new imports pull theme.tsx into the JSDOM graph and it calls matchMedia at module load --- .../link-existing-onboarding-workflow.test.ts | 2 +- .../new-project/page-client.test.tsx | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) 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.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();