From d0e6ad15fa8a18707e5c566d1d241a517da846dd Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 19 May 2026 10:41:34 -0700 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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 c8b750bd77ea71dd9937f329b83ef7d032af0370 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 20 May 2026 11:44:57 -0700 Subject: [PATCH 16/22] feat(stack-cli): add --source flags to `config push` Adds `--source github` with `--source-repo`, `--source-path`, and `--source-workflow-path` (all required together) so the CLI can declare its provenance explicitly instead of relying solely on `GITHUB_*` env vars. `commit_hash` and `branch` are still read from `GITHUB_SHA` / `GITHUB_REF_NAME`. Adds `workflow_path` to the `pushed-from-github` source schema (optional for backward compat with existing rows). The dashboard's generated workflow YAML now emits the new flags and uses `${{ github.repository }}` at runtime so the stored source reflects renames/transfers. The project-settings UI surfaces the workflow file as a clickable GitHub link. --- apps/backend/src/lib/seed-dummy-data.ts | 1 + .../link-existing-onboarding-workflow.test.ts | 10 +- .../link-existing-onboarding-workflow.ts | 8 +- .../project-settings/page-client.tsx | 13 ++ apps/e2e/tests/backend/backend-helpers.ts | 2 +- .../src/commands/config-file.test.ts | 162 +++++++++++++++++- .../stack-cli/src/commands/config-file.ts | 92 ++++++++-- packages/stack-shared/src/schema-fields.ts | 1 + .../apps/implementations/admin-app-impl.ts | 2 + .../src/lib/stack-app/projects/index.ts | 2 +- 10 files changed, 274 insertions(+), 19 deletions(-) diff --git a/apps/backend/src/lib/seed-dummy-data.ts b/apps/backend/src/lib/seed-dummy-data.ts index a61140f189..42e88c35ad 100644 --- a/apps/backend/src/lib/seed-dummy-data.ts +++ b/apps/backend/src/lib/seed-dummy-data.ts @@ -2038,6 +2038,7 @@ export async function seedDummyProject(options: SeedDummyProjectOptions): Promis branch: "main", commit_hash: "abc123def456789", config_file_path: "stack.config.json", + workflow_path: ".github/workflows/stack-auth-config-sync.yml", }, })], globalPrismaClient.project.update({ 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..b7162fd0f1 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,9 @@ 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(` STACK_AUTH_SOURCE_REPO: \${{ github.repository }}`); + expect(workflowYaml).toContain(` STACK_AUTH_SOURCE_WORKFLOW_PATH: ${JSON.stringify(WORKFLOW_FILE_PATH)}`); + expect(workflowYaml).toContain("run: npx --yes @stackframe/stack-cli@latest config push --config-file \"$STACK_AUTH_CONFIG_PATH\" --source github --source-repo \"$STACK_AUTH_SOURCE_REPO\" --source-path \"$STACK_AUTH_CONFIG_PATH\" --source-workflow-path \"$STACK_AUTH_SOURCE_WORKFLOW_PATH\""); expect(workflowYaml).not.toContain(`--config-file "${configPath}"`); }); @@ -27,4 +29,10 @@ describe("buildWorkflowYaml", () => { expect(workflowYaml).toContain(`\${{ secrets.${GITHUB_PROJECT_ID_SECRET_NAME} }}`); expect(workflowYaml).toContain(`\${{ secrets.${GITHUB_SECRET_SERVER_KEY_SECRET_NAME} }}`); }); + + it("uses the GitHub Actions runtime repository context for --source-repo", () => { + const workflowYaml = buildWorkflowYaml("main", "stack.config.ts"); + expect(workflowYaml).toContain("STACK_AUTH_SOURCE_REPO: ${{ github.repository }}"); + expect(workflowYaml).not.toMatch(/STACK_AUTH_SOURCE_REPO:\s+"[^$]/); + }); }); 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 0276adb20c..5c0bd21456 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 @@ -23,6 +23,10 @@ export function buildWorkflowYaml(branch: string, configPath: string): string { const encodedConfigPath = encodeYamlScalar(normalizedConfigPath); const encodedWorkflowPath = encodeYamlScalar(WORKFLOW_FILE_PATH); + // `actions/checkout` lands the repo at the runner cwd, so `$STACK_AUTH_CONFIG_PATH` + // (repo-relative) is also the local path on disk — that's why the same env var is + // safe to use for both `--config-file` and `--source-path`. If a future workflow + // checks out with `with: path: `, these would diverge. return `name: Stack Auth Config Sync on: @@ -51,6 +55,8 @@ 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 --config-file "$STACK_AUTH_CONFIG_PATH" + STACK_AUTH_SOURCE_REPO: \${{ github.repository }} + STACK_AUTH_SOURCE_WORKFLOW_PATH: ${encodedWorkflowPath} + run: npx --yes @stackframe/stack-cli@latest config push --config-file "$STACK_AUTH_CONFIG_PATH" --source github --source-repo "$STACK_AUTH_SOURCE_REPO" --source-path "$STACK_AUTH_CONFIG_PATH" --source-workflow-path "$STACK_AUTH_SOURCE_WORKFLOW_PATH" `; } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx index 394cbb7f41..b35af9ae1d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx @@ -489,6 +489,19 @@ export default function PageClient() {
Repository: {configSource.owner}/{configSource.repo}
Branch: {configSource.branch}
Config file: {configSource.configFilePath}
+ {configSource.workflowPath ? ( +
+ Workflow file:{" "} + + {configSource.workflowPath} + +
+ ) : null}
Last commit: {configSource.commitHash.substring(0, 7)}
diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index d7c83aeecf..f65a0592ef 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -1362,7 +1362,7 @@ export namespace Project { } export type BranchConfigSource = - | { type: "pushed-from-github", owner: string, repo: string, branch: string, commit_hash: string, config_file_path: string } + | { type: "pushed-from-github", owner: string, repo: string, branch: string, commit_hash: string, config_file_path: string, workflow_path?: string } | { type: "pushed-from-unknown" } | { type: "unlinked" }; diff --git a/packages/stack-cli/src/commands/config-file.test.ts b/packages/stack-cli/src/commands/config-file.test.ts index d8fa23cadb..af7919f09c 100644 --- a/packages/stack-cli/src/commands/config-file.test.ts +++ b/packages/stack-cli/src/commands/config-file.test.ts @@ -2,7 +2,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { resolveConfigFilePathForPull } from "./config-file.js"; +import { buildConfigPushSource, resolveConfigFilePathForPull } from "./config-file.js"; describe("resolveConfigFilePathForPull", () => { let tmpDir: string; @@ -45,3 +45,163 @@ describe("resolveConfigFilePathForPull", () => { expect(() => resolveConfigFilePathForPull({}, tmpDir)).toThrow(/Pass --config-file/); }); }); + +describe("buildConfigPushSource", () => { + const ORIGINAL_ENV = { ...process.env }; + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("returns pushed-from-unknown with no flags and no GitHub env vars", () => { + delete process.env.GITHUB_REPOSITORY; + delete process.env.GITHUB_SHA; + delete process.env.GITHUB_REF_NAME; + expect(buildConfigPushSource("stack.config.ts", {})).toEqual({ type: "pushed-from-unknown" }); + }); + + it("auto-detects pushed-from-github from GitHub Actions env vars when no flags are set", () => { + process.env.GITHUB_REPOSITORY = "myorg/my-repo"; + process.env.GITHUB_SHA = "abc123"; + process.env.GITHUB_REF_NAME = "main"; + expect(buildConfigPushSource("stack.config.ts", {})).toEqual({ + type: "pushed-from-github", + owner: "myorg", + repo: "my-repo", + branch: "main", + commit_hash: "abc123", + config_file_path: "stack.config.ts", + }); + }); + + it("builds pushed-from-github from --source flags", () => { + process.env.GITHUB_SHA = "abc123"; + process.env.GITHUB_REF_NAME = "main"; + expect( + buildConfigPushSource("stack.config.ts", { + source: "github", + sourceRepo: "myorg/my-repo", + sourcePath: "configs/stack.config.ts", + sourceWorkflowPath: ".github/workflows/stack-auth-config-sync.yml", + }) + ).toEqual({ + type: "pushed-from-github", + owner: "myorg", + repo: "my-repo", + branch: "main", + commit_hash: "abc123", + config_file_path: "configs/stack.config.ts", + workflow_path: ".github/workflows/stack-auth-config-sync.yml", + }); + }); + + it("rejects --source values other than 'github'", () => { + expect(() => + buildConfigPushSource("stack.config.ts", { source: "gitlab" }) + ).toThrow(/Only 'github' is supported/); + }); + + it("requires all four flags together when --source github is set", () => { + process.env.GITHUB_SHA = "abc123"; + process.env.GITHUB_REF_NAME = "main"; + expect(() => + buildConfigPushSource("stack.config.ts", { + source: "github", + sourceRepo: "myorg/my-repo", + }) + ).toThrow(/--source-path.*--source-workflow-path/); + }); + + it("lists all three missing dependent flags when only --source github is passed", () => { + process.env.GITHUB_SHA = "abc123"; + process.env.GITHUB_REF_NAME = "main"; + expect(() => + buildConfigPushSource("stack.config.ts", { source: "github" }) + ).toThrow(/--source-repo.*--source-path.*--source-workflow-path/); + }); + + it("treats empty-string --source-repo as malformed (not missing)", () => { + process.env.GITHUB_SHA = "abc123"; + process.env.GITHUB_REF_NAME = "main"; + expect(() => + buildConfigPushSource("stack.config.ts", { + source: "github", + sourceRepo: "", + sourcePath: "stack.config.ts", + sourceWorkflowPath: ".github/workflows/x.yml", + }) + ).toThrow(/owner\/repo/); + }); + + it("rejects --source-repo with whitespace or invalid characters", () => { + process.env.GITHUB_SHA = "abc123"; + process.env.GITHUB_REF_NAME = "main"; + const base = { + source: "github" as const, + sourcePath: "stack.config.ts", + sourceWorkflowPath: ".github/workflows/x.yml", + }; + expect(() => buildConfigPushSource("stack.config.ts", { ...base, sourceRepo: "myorg/my-repo " })).toThrow(/owner\/repo/); + expect(() => buildConfigPushSource("stack.config.ts", { ...base, sourceRepo: " myorg/my-repo" })).toThrow(/owner\/repo/); + expect(() => buildConfigPushSource("stack.config.ts", { ...base, sourceRepo: "my org/my-repo" })).toThrow(/owner\/repo/); + expect(() => buildConfigPushSource("stack.config.ts", { ...base, sourceRepo: "myorg/my repo" })).toThrow(/owner\/repo/); + expect(() => buildConfigPushSource("stack.config.ts", { ...base, sourceRepo: "myorg/my$repo" })).toThrow(/owner\/repo/); + }); + + it("rejects --source-repo without --source github", () => { + expect(() => + buildConfigPushSource("stack.config.ts", { sourceRepo: "myorg/my-repo" }) + ).toThrow(/can only be used with --source github/); + }); + + it("rejects --source-path without --source github", () => { + expect(() => + buildConfigPushSource("stack.config.ts", { sourcePath: "stack.config.ts" }) + ).toThrow(/can only be used with --source github/); + }); + + it("rejects --source-workflow-path without --source github", () => { + expect(() => + buildConfigPushSource("stack.config.ts", { sourceWorkflowPath: ".github/workflows/x.yml" }) + ).toThrow(/can only be used with --source github/); + }); + + it("rejects malformed --source-repo", () => { + process.env.GITHUB_SHA = "abc123"; + process.env.GITHUB_REF_NAME = "main"; + expect(() => + buildConfigPushSource("stack.config.ts", { + source: "github", + sourceRepo: "noslash", + sourcePath: "stack.config.ts", + sourceWorkflowPath: ".github/workflows/x.yml", + }) + ).toThrow(/owner\/repo/); + }); + + it("errors if GITHUB_SHA is missing when --source github is set", () => { + delete process.env.GITHUB_SHA; + process.env.GITHUB_REF_NAME = "main"; + expect(() => + buildConfigPushSource("stack.config.ts", { + source: "github", + sourceRepo: "myorg/my-repo", + sourcePath: "stack.config.ts", + sourceWorkflowPath: ".github/workflows/x.yml", + }) + ).toThrow(/GITHUB_SHA/); + }); + + it("errors if GITHUB_REF_NAME is missing when --source github is set", () => { + process.env.GITHUB_SHA = "abc123"; + delete process.env.GITHUB_REF_NAME; + expect(() => + buildConfigPushSource("stack.config.ts", { + source: "github", + sourceRepo: "myorg/my-repo", + sourcePath: "stack.config.ts", + sourceWorkflowPath: ".github/workflows/x.yml", + }) + ).toThrow(/GITHUB_REF_NAME/); + }); +}); diff --git a/packages/stack-cli/src/commands/config-file.ts b/packages/stack-cli/src/commands/config-file.ts index ab471fc8ea..2cfcf66865 100644 --- a/packages/stack-cli/src/commands/config-file.ts +++ b/packages/stack-cli/src/commands/config-file.ts @@ -26,29 +26,83 @@ function parseConfigOverride(value: unknown): EnvironmentConfigOverrideOverride } type BranchConfigSourceApi = - | { type: "pushed-from-github", owner: string, repo: string, branch: string, commit_hash: string, config_file_path: string } + | { type: "pushed-from-github", owner: string, repo: string, branch: string, commit_hash: string, config_file_path: string, workflow_path?: string } | { type: "pushed-from-unknown" } | { type: "unlinked" }; -function parseGitHubRepository(): { owner: string, repo: string } | null { +type SourceFlagOptions = { + source?: string, + sourceRepo?: string, + sourcePath?: string, + sourceWorkflowPath?: string, +}; + +const OWNER_REPO_SEGMENT = /^[A-Za-z0-9._-]+$/; + +function parseOwnerRepo(value: string, flagName: string): { owner: string, repo: string } { + const parts = value.split("/"); + if (parts.length !== 2 || !OWNER_REPO_SEGMENT.test(parts[0]) || !OWNER_REPO_SEGMENT.test(parts[1])) { + throw new CliError(`${flagName} must be in the format 'owner/repo' using only letters, digits, '.', '_' or '-' (got '${value}').`); + } + return { owner: parts[0], repo: parts[1] }; +} + +function parseGitHubRepositoryEnv(): { owner: string, repo: string } | null { const repository = process.env.GITHUB_REPOSITORY; if (!repository) { return null; } - - const slashIndex = repository.indexOf("/"); - if (slashIndex <= 0 || slashIndex >= repository.length - 1) { + try { + return parseOwnerRepo(repository, "GITHUB_REPOSITORY"); + } catch { return null; } - - return { - owner: repository.slice(0, slashIndex), - repo: repository.slice(slashIndex + 1), - }; } -function buildConfigPushSource(configFilePath: string): BranchConfigSourceApi { - const repository = parseGitHubRepository(); +export function buildConfigPushSource(configFilePath: string, flags: SourceFlagOptions): BranchConfigSourceApi { + const dependentFlags: Array<[string, string | undefined]> = [ + ["--source-repo", flags.sourceRepo], + ["--source-path", flags.sourcePath], + ["--source-workflow-path", flags.sourceWorkflowPath], + ]; + const providedDependent = dependentFlags.filter(([, v]) => v !== undefined).map(([k]) => k); + + if (flags.source !== undefined) { + if (flags.source !== "github") { + throw new CliError(`Invalid --source value '${flags.source}'. Only 'github' is supported.`); + } + const missing = dependentFlags.filter(([, v]) => v === undefined).map(([k]) => k); + if (missing.length > 0) { + throw new CliError(`When --source github is specified, the following flags are also required: ${missing.join(", ")}.`); + } + + const { owner, repo } = parseOwnerRepo(flags.sourceRepo!, "--source-repo"); + + const sha = process.env.GITHUB_SHA; + const branch = process.env.GITHUB_REF_NAME; + if (!sha) { + throw new CliError("--source github requires the GITHUB_SHA environment variable (commit hash) to be set."); + } + if (!branch) { + throw new CliError("--source github requires the GITHUB_REF_NAME environment variable (branch) to be set."); + } + + return { + type: "pushed-from-github", + owner, + repo, + branch, + commit_hash: sha, + config_file_path: flags.sourcePath!, + workflow_path: flags.sourceWorkflowPath!, + }; + } + + if (providedDependent.length > 0) { + throw new CliError(`${providedDependent.join(", ")} can only be used with --source github.`); + } + + const repository = parseGitHubRepositoryEnv(); const sha = process.env.GITHUB_SHA; const branch = process.env.GITHUB_REF_NAME; @@ -98,7 +152,7 @@ async function pushConfigWithSecretServerKey( } function sourceToSdkSource(source: BranchConfigSourceApi): - { type: "pushed-from-github", owner: string, repo: string, branch: string, commitHash: string, configFilePath: string } + { type: "pushed-from-github", owner: string, repo: string, branch: string, commitHash: string, configFilePath: string, workflowPath?: string } | { type: "pushed-from-unknown" } | { type: "unlinked" } { if (source.type === "pushed-from-github") { @@ -109,6 +163,7 @@ function sourceToSdkSource(source: BranchConfigSourceApi): branch: source.branch, commitHash: source.commit_hash, configFilePath: source.config_file_path, + workflowPath: source.workflow_path, }; } if (source.type === "pushed-from-unknown") { @@ -176,6 +231,10 @@ export function registerConfigCommand(program: Command) { .description("Push a local config file to branch config") .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)") + .option("--source ", "Explicit source type for this push. Only 'github' is supported.") + .option("--source-repo ", "GitHub repository in 'owner/repo' format. Only allowed with --source github.") + .option("--source-path ", "Path to the config file within the source repository. Only allowed with --source github.") + .option("--source-workflow-path ", "Path to the syncing workflow file within the source repository. Only allowed with --source github.") .action(async (opts) => { const auth = resolveAuth(resolveProjectId(opts.cloudProjectId)); @@ -196,7 +255,12 @@ export function registerConfigCommand(program: Command) { throw new CliError(`Config file must export a plain \`config\` object or "show-onboarding". Example: import type { StackConfig } from "${examplePkg}"; export const config: StackConfig = { ... };`); } - const source = buildConfigPushSource(opts.configFile); + const source = buildConfigPushSource(opts.configFile, { + source: opts.source, + sourceRepo: opts.sourceRepo, + sourcePath: opts.sourcePath, + sourceWorkflowPath: opts.sourceWorkflowPath, + }); if (isProjectAuthWithSecretServerKey(auth)) { await pushConfigWithSecretServerKey(auth, config, source); diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index a457d4b562..c2977ad398 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -921,6 +921,7 @@ export const branchConfigSourceSchema = yupUnion( branch: yupString().defined(), commit_hash: yupString().defined(), config_file_path: yupString().defined(), + workflow_path: yupString().optional(), }), yupObject({ type: yupString().oneOf(["pushed-from-unknown"]).defined(), diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index d7a6a19dfb..e6128936aa 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -45,6 +45,7 @@ function pushedConfigSourceToApi(source: PushedConfigSource): BranchConfigSource branch: source.branch, commit_hash: source.commitHash, config_file_path: source.configFilePath, + workflow_path: source.workflowPath, }; } return source; @@ -62,6 +63,7 @@ function apiToPushedConfigSource(source: BranchConfigSourceApi): PushedConfigSou branch: source.branch, commitHash: source.commit_hash, configFilePath: source.config_file_path, + workflowPath: source.workflow_path, }; } return source; diff --git a/packages/template/src/lib/stack-app/projects/index.ts b/packages/template/src/lib/stack-app/projects/index.ts index 950a8c8e9a..e630808c57 100644 --- a/packages/template/src/lib/stack-app/projects/index.ts +++ b/packages/template/src/lib/stack-app/projects/index.ts @@ -11,7 +11,7 @@ import { AdminProjectConfig, AdminProjectConfigUpdateOptions, ProjectConfig } fr * Represents where the branch config was pushed from. */ export type PushedConfigSource = - | { type: "pushed-from-github", owner: string, repo: string, branch: string, commitHash: string, configFilePath: string } + | { type: "pushed-from-github", owner: string, repo: string, branch: string, commitHash: string, configFilePath: string, workflowPath?: string } | { type: "pushed-from-unknown" } | { type: "unlinked" }; From 89754cc1f9ac66a069fae0a7fa932c61b475a11a Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 20 May 2026 11:54:09 -0700 Subject: [PATCH 17/22] fix(stack-cli): reject empty-string --source-path / --source-workflow-path Previously these flags were only checked for `=== undefined`, so passing `--source-path ""` would store `config_file_path: ""` verbatim. Now we require a non-empty value, matching the existing `parseOwnerRepo` check for `--source-repo`. Adds tests covering both cases. --- .../src/commands/config-file.test.ts | 26 +++++++++++++++++++ .../stack-cli/src/commands/config-file.ts | 7 +++++ 2 files changed, 33 insertions(+) diff --git a/packages/stack-cli/src/commands/config-file.test.ts b/packages/stack-cli/src/commands/config-file.test.ts index af7919f09c..e8fd7319fb 100644 --- a/packages/stack-cli/src/commands/config-file.test.ts +++ b/packages/stack-cli/src/commands/config-file.test.ts @@ -133,6 +133,32 @@ describe("buildConfigPushSource", () => { ).toThrow(/owner\/repo/); }); + it("rejects empty-string --source-path", () => { + process.env.GITHUB_SHA = "abc123"; + process.env.GITHUB_REF_NAME = "main"; + expect(() => + buildConfigPushSource("stack.config.ts", { + source: "github", + sourceRepo: "myorg/my-repo", + sourcePath: "", + sourceWorkflowPath: ".github/workflows/x.yml", + }) + ).toThrow(/--source-path must be a non-empty path string/); + }); + + it("rejects empty-string --source-workflow-path", () => { + process.env.GITHUB_SHA = "abc123"; + process.env.GITHUB_REF_NAME = "main"; + expect(() => + buildConfigPushSource("stack.config.ts", { + source: "github", + sourceRepo: "myorg/my-repo", + sourcePath: "stack.config.ts", + sourceWorkflowPath: "", + }) + ).toThrow(/--source-workflow-path must be a non-empty path string/); + }); + it("rejects --source-repo with whitespace or invalid characters", () => { process.env.GITHUB_SHA = "abc123"; process.env.GITHUB_REF_NAME = "main"; diff --git a/packages/stack-cli/src/commands/config-file.ts b/packages/stack-cli/src/commands/config-file.ts index 2cfcf66865..47a0130f1d 100644 --- a/packages/stack-cli/src/commands/config-file.ts +++ b/packages/stack-cli/src/commands/config-file.ts @@ -78,6 +78,13 @@ export function buildConfigPushSource(configFilePath: string, flags: SourceFlagO const { owner, repo } = parseOwnerRepo(flags.sourceRepo!, "--source-repo"); + if (flags.sourcePath!.length === 0) { + throw new CliError("--source-path must be a non-empty path string."); + } + if (flags.sourceWorkflowPath!.length === 0) { + throw new CliError("--source-workflow-path must be a non-empty path string."); + } + const sha = process.env.GITHUB_SHA; const branch = process.env.GITHUB_REF_NAME; if (!sha) { From 6b8d5084d41bbadbd4208917379516b29be98d19 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 20 May 2026 19:08:20 -0700 Subject: [PATCH 18/22] feat(dashboard): push config edits to GitHub from "Push to GitHub" dialog - Dialog now commits the user's change to the linked repo via the GitHub Contents API, replacing the prior TODO buttons. - Pre-flights `repo`+`workflow` scopes on connected GitHub accounts so the push button only enables when the token can actually commit; otherwise shows "Reconnect with GitHub". - Wraps the suspending bits in a local Suspense boundary so opening the dialog doesn't blank the dashboard. - Issues the Contents GET with `cache: no-store` so back-to-back pushes within ~60s don't 409 on a stale browser-cached blob SHA. - Moves `renderConfigFileContent` from `config-rendering.ts` (Node-only due to fs/path) into `stack-config-file.ts` so the dashboard can call it in the browser. - instrumentation.ts: read `process.env.NEXT_RUNTIME` directly instead of `getNextRuntime()` so the early-startup branch can't throw. --- apps/dashboard/src/instrumentation.ts | 2 +- apps/dashboard/src/lib/config-update.tsx | 426 ++++++++++++++---- apps/dashboard/src/lib/github-api.test.ts | 189 ++++++++ apps/dashboard/src/lib/github-api.ts | 205 +++++++++ .../src/lib/github-config-push.test.ts | 182 ++++++++ apps/dashboard/src/lib/github-config-push.ts | 107 +++++ packages/stack-shared/src/config-rendering.ts | 26 +- .../stack-shared/src/stack-config-file.ts | 29 ++ 8 files changed, 1062 insertions(+), 104 deletions(-) create mode 100644 apps/dashboard/src/lib/github-api.test.ts create mode 100644 apps/dashboard/src/lib/github-api.ts create mode 100644 apps/dashboard/src/lib/github-config-push.test.ts create mode 100644 apps/dashboard/src/lib/github-config-push.ts diff --git a/apps/dashboard/src/instrumentation.ts b/apps/dashboard/src/instrumentation.ts index 5962696e9a..f818a2b41e 100644 --- a/apps/dashboard/src/instrumentation.ts +++ b/apps/dashboard/src/instrumentation.ts @@ -5,7 +5,7 @@ import { nicify } from "@stackframe/stack-shared/dist/utils/strings"; import "./polyfills"; async function startRemoteDevelopmentEnvironmentLifecycleIfNeeded(): Promise { - if (getNextRuntime() !== "nodejs" || getEnvVariable("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT", "") !== "true") { + if (process.env.NEXT_RUNTIME !== "nodejs" || getEnvVariable("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT", "") !== "true") { return; } diff --git a/apps/dashboard/src/lib/config-update.tsx b/apps/dashboard/src/lib/config-update.tsx index a0e8793570..a21436fe76 100644 --- a/apps/dashboard/src/lib/config-update.tsx +++ b/apps/dashboard/src/lib/config-update.tsx @@ -2,11 +2,18 @@ import { Link } from "@/components/link"; import { ActionDialog } from "@/components/ui/action-dialog"; +import { useDashboardInternalUser } from "@/lib/dashboard-user"; import { getPublicEnvVar } from "@/lib/env"; -import type { PushedConfigSource, StackAdminApp } from "@stackframe/stack"; +import type { OAuthConnection, PushedConfigSource, StackAdminApp } from "@stackframe/stack"; import type { EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; -import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; -import React, { createContext, useCallback, useContext, useState } from "react"; +import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import React, { createContext, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; + +import { createGithubFetch, GITHUB_SCOPE_REQUIREMENTS } from "./github-api"; +import { pushConfigUpdateToGitHub } from "./github-config-push"; + +type GithubPushedSource = Extract; type ConfigUpdateDialogState = { isOpen: boolean, @@ -15,10 +22,6 @@ type ConfigUpdateDialogState = { resolve: ((result: boolean) => void) | null, source: PushedConfigSource | null, isLoadingSource: boolean, - // For GitHub dialog - commitMessage: string, - // Temporary: 50/50 chance of showing "Connect with GitHub" vs "Push changes" - showConnectWithGitHub: boolean, }; const ConfigUpdateDialogContext = createContext<{ @@ -37,8 +40,6 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React resolve: null, source: null, isLoadingSource: false, - commitMessage: "", - showConnectWithGitHub: false, }); const showPushableDialog = useCallback(async (adminApp: StackAdminApp, configUpdate: EnvironmentConfigOverrideOverride): Promise => { @@ -56,9 +57,6 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React resolve, source, isLoadingSource: false, - commitMessage: "", - // Temporary: 50/50 chance for GitHub dialog - showConnectWithGitHub: Math.random() < 0.5, }); }); } @@ -73,22 +71,19 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React return false; }, []); - const handleClose = useCallback((result: boolean) => { - if (dialogState.resolve) { - dialogState.resolve(result); - } - setDialogState({ - isOpen: false, - adminApp: null, - configUpdate: null, - resolve: null, - source: null, - isLoadingSource: false, - commitMessage: "", - showConnectWithGitHub: false, + const settleDialog = useCallback((result: boolean) => { + setDialogState((prev) => { + prev.resolve?.(result); + return { + isOpen: false, + adminApp: null, + configUpdate: null, + resolve: null, + source: null, + isLoadingSource: false, + }; }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- we only care about the resolve function, not the entire dialogState - }, [dialogState.resolve]); + }, []); const projectId = dialogState.adminApp?.projectId; @@ -101,57 +96,13 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React switch (dialogState.source.type) { case "pushed-from-github": { return ( - handleClose(false)} - title="Push Configuration to GitHub" - description="This project's configuration is managed via GitHub." - okButton={dialogState.showConnectWithGitHub ? { - label: "Connect with GitHub", - onClick: async () => { - // TODO: Implement GitHub OAuth connection - alert("TODO: GitHub connection not yet implemented"); - }, - } : { - label: "Push to GitHub", - onClick: async () => { - // TODO: Implement actual GitHub push - alert("TODO: GitHub push not yet implemented"); - }, - }} - cancelButton={{ - label: "Cancel", - onClick: async () => { - handleClose(false); - }, - }} - > -
- {!dialogState.showConnectWithGitHub && ( -
- - setDialogState(s => ({ ...s, commitMessage: e.target.value }))} - /> -
- )} -

- - If your configuration is no longer on GitHub, you can unlink it in{" "} - - Project Settings - . - -

-
-
+ source={dialogState.source} + configUpdate={dialogState.configUpdate} + projectId={projectId} + onSettle={settleDialog} + /> ); } @@ -159,7 +110,7 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React return ( handleClose(false)} + onClose={() => settleDialog(false)} title="Configuration Managed by CLI" description="This project's configuration was pushed via the Stack Auth CLI." okButton={{ @@ -172,7 +123,7 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React cancelButton={{ label: "Cancel", onClick: async () => { - handleClose(false); + settleDialog(false); }, }} > @@ -212,6 +163,323 @@ function useConfigUpdateDialog() { return context; } +type GithubPushDialogProps = { + open: boolean, + source: GithubPushedSource, + configUpdate: EnvironmentConfigOverrideOverride | null, + projectId: string | undefined, + onSettle: (result: boolean) => void, +}; + +/** + * Renders the "Push to GitHub" dialog. Detects whether the dashboard user has + * a GitHub account connected; if not, walks them through linking one first. + * Once a connection is available, commits a config-file edit to the linked + * repo/branch via the Contents API. + * + * On success, `onSettle(true)` is called so the surrounding + * `ConfigUpdateDialogProvider` then mirrors the change into Stack Auth's + * cloud config for immediate UI feedback. Eventually the GitHub Actions + * workflow will re-push the canonical config from the freshly-committed file. + */ +type ScopeCheck = + | { status: "no-account" } + | { status: "checking" } + | { status: "ok", account: OAuthConnection } + | { status: "missing-scopes" }; + +type GithubPushHandlers = { + push: () => Promise<"prevent-close" | undefined>, + connect: () => Promise<"prevent-close" | undefined>, +}; + +const UNLINK_HINT_PROJECT_SETTINGS_HREF = (projectId: string | undefined) => + `/projects/${projectId}/project-settings`; + +/** + * Outer shell. Renders `ActionDialog` synchronously (no suspending hooks) so + * opening the dialog doesn't bubble a Suspense promise up to the dashboard + * root and blank the page. The suspending pieces (current user, connected + * accounts, OAuth token probe) live in `GithubPushBody`, wrapped in a local + * `Suspense` boundary whose fallback mirrors the dialog body except that the + * "Push to GitHub" button stays disabled while we resolve. + */ +function GithubPushDialog({ open, source, configUpdate, projectId, onSettle }: GithubPushDialogProps) { + // Status starts as "checking" so the initial render shows a disabled + // "Push to GitHub" button — matching what we want during Suspense fallback. + const [scopeStatus, setScopeStatus] = useState("checking"); + const handlersRef = useRef(null); + + const dispatch = useCallback( + (key: keyof GithubPushHandlers) => async (): Promise<"prevent-close" | undefined> => { + // While the Suspense fallback is showing, handlers aren't registered + // yet. In that window the button is disabled anyway, but we guard + // defensively and prevent close if somehow clicked. + return (await handlersRef.current?.[key]()) ?? "prevent-close"; + }, + [], + ); + + const okButton = (() => { + switch (scopeStatus) { + case "no-account": { + return { label: "Connect with GitHub", onClick: dispatch("connect") }; + } + case "checking": { + return { + label: "Push to GitHub", + onClick: async (): Promise<"prevent-close" | undefined> => "prevent-close", + props: { disabled: true }, + }; + } + case "ok": { + return { label: "Push to GitHub", onClick: dispatch("push") }; + } + case "missing-scopes": { + return { label: "Reconnect with GitHub", onClick: dispatch("connect") }; + } + } + })(); + + const description = (() => { + switch (scopeStatus) { + case "no-account": { + return "Connect a GitHub account to push configuration changes to this repository."; + } + case "checking": { + return "Checking GitHub permissions..."; + } + case "ok": { + return `This will commit your change to ${source.owner}/${source.repo}@${source.branch}.`; + } + case "missing-scopes": { + return "Your linked GitHub account is missing the \"repo\" and \"workflow\" permissions required to push configuration changes. Reconnect to grant them."; + } + } + })(); + + return ( + onSettle(false)} + title="Push Configuration to GitHub" + description={description} + okButton={okButton} + cancelButton={{ + label: "Cancel", + onClick: async () => { + onSettle(false); + }, + }} + > + }> + + + + ); +} + +function GithubPushBodyFallback({ projectId }: { projectId: string | undefined }) { + // Static body shown during the initial Suspense — no commit input yet + // (we don't know whether push is even available), just the unlink hint + // so the dialog "looks normal except the button is disabled". + return ( +
+

+ + If your configuration is no longer on GitHub, you can unlink it in{" "} + + Project Settings + . + +

+
+ ); +} + +type GithubPushBodyProps = { + source: GithubPushedSource, + configUpdate: EnvironmentConfigOverrideOverride | null, + projectId: string | undefined, + onSettle: (result: boolean) => void, + onScopeStatusChange: (status: ScopeCheck["status"]) => void, + handlersRef: React.MutableRefObject, +}; + +function GithubPushBody({ + source, + configUpdate, + projectId, + onSettle, + onScopeStatusChange, + handlersRef, +}: GithubPushBodyProps) { + const user = useDashboardInternalUser(); + const githubAccounts = user.useConnectedAccounts().filter((account) => account.provider === "github"); + + // Stable dep for the scope-check effect — re-run only when the set of + // connections actually changes, not on every parent render. + const githubAccountsKey = githubAccounts.map((a) => a.providerAccountId).join("|"); + + const [scopeCheck, setScopeCheck] = useState( + githubAccounts.length === 0 ? { status: "no-account" } : { status: "checking" }, + ); + const [commitMessage, setCommitMessage] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + + const placeholderCommitMessage = "Update Stack Auth configuration"; + + // Sync our local status string up to the dialog shell so it can pick the + // right button label / description without itself needing to suspend. + useEffect(() => { + onScopeStatusChange(scopeCheck.status); + }, [scopeCheck.status, onScopeStatusChange]); + + // Probe each connected GitHub account for a token that already covers + // `repo` + `workflow`. The dashboard user may have multiple GitHub + // connections; only one needs to carry the elevated scopes. We pre-flight + // here (rather than on Push click) so the user doesn't waste a typed commit + // message on a redirect, since `linkConnectedAccount` is a full page nav. + useEffect(() => { + if (githubAccounts.length === 0) { + setScopeCheck({ status: "no-account" }); + return; + } + // Mutable holder rather than a `let` so TS sees the reassignment in the + // cleanup callback as a real write; otherwise its flow analysis narrows + // the closure read to its initial value and the `cancelled` checks below + // are flagged as constant-condition errors. + const cancelToken = { cancelled: false }; + setScopeCheck({ status: "checking" }); + runAsynchronously(async () => { + for (const account of githubAccounts) { + let tokenResult; + try { + tokenResult = await account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS }); + } catch { + // Transport/cache failures — fall through and try the next account. + continue; + } + if (cancelToken.cancelled) return; + if (tokenResult.status === "ok") { + setScopeCheck({ status: "ok", account }); + return; + } + } + if (!cancelToken.cancelled) setScopeCheck({ status: "missing-scopes" }); + }); + return () => { + cancelToken.cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- githubAccountsKey is the stable identity for githubAccounts + }, [githubAccountsKey]); + + const githubFetch = useMemo( + () => (scopeCheck.status === "ok" ? createGithubFetch(scopeCheck.account) : null), + [scopeCheck], + ); + + const handlePush = useCallback(async (): Promise<"prevent-close" | undefined> => { + if (configUpdate == null) { + setErrorMessage("No configuration changes to push."); + return "prevent-close"; + } + if (githubFetch == null) { + setErrorMessage("Connect a GitHub account with the required scopes before pushing changes."); + return "prevent-close"; + } + setErrorMessage(null); + try { + await pushConfigUpdateToGitHub({ + source, + configUpdate, + commitMessage: commitMessage.trim().length > 0 ? commitMessage : placeholderCommitMessage, + githubFetch, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error pushing to GitHub."; + captureError("config-update-github-push", { + projectId, + owner: source.owner, + repo: source.repo, + branch: source.branch, + configFilePath: source.configFilePath, + cause: error, + }); + setErrorMessage(message); + return "prevent-close"; + } + onSettle(true); + return undefined; + }, [commitMessage, configUpdate, githubFetch, onSettle, projectId, source]); + + const handleConnect = useCallback(async (): Promise<"prevent-close" | undefined> => { + // Full-page redirect to the OAuth provider. When scopes are missing on + // an existing connection, `getOrLinkConnectedAccount` still redirects + // because none of the present tokens satisfies the scope set. Returning + // `prevent-close` is defensive — in practice the redirect happens first. + try { + await user.getOrLinkConnectedAccount("github", { scopes: GITHUB_SCOPE_REQUIREMENTS }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error connecting to GitHub."; + setErrorMessage(message); + return "prevent-close"; + } + return "prevent-close"; + }, [user]); + + // Expose the latest handlers to the dialog shell. A ref (rather than + // calling up via state) avoids re-rendering the shell on every handler + // identity change, which would also reset the okButton onClick reference. + useEffect(() => { + handlersRef.current = { push: handlePush, connect: handleConnect }; + }, [handlersRef, handlePush, handleConnect]); + + return ( +
+ {scopeCheck.status === "ok" && ( +
+ + setCommitMessage(e.target.value)} + /> +

+ Committing to {source.configFilePath} on{" "} + {source.branch}. +

+
+ )} + {errorMessage != null && ( +

+ {errorMessage} +

+ )} +

+ + If your configuration is no longer on GitHub, you can unlink it in{" "} + + Project Settings + . + +

+
+ ); +} + async function updateRemoteDevelopmentEnvironmentConfigFile( adminApp: StackAdminApp, configUpdate: EnvironmentConfigOverrideOverride, diff --git a/apps/dashboard/src/lib/github-api.test.ts b/apps/dashboard/src/lib/github-api.test.ts new file mode 100644 index 0000000000..9a64311329 --- /dev/null +++ b/apps/dashboard/src/lib/github-api.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { + commitFile, + encodeGitHubPath, + getFileContent, + githubRepositoryContentsUrl, + isObject, + parseRepositoryFullName, +} from "./github-api"; + +describe("parseRepositoryFullName", () => { + it("splits a well-formed full name into owner and repo", () => { + expect(parseRepositoryFullName("myorg/my-repo")).toEqual({ owner: "myorg", repo: "my-repo" }); + expect(parseRepositoryFullName("acme.io/some_repo.2")).toEqual({ owner: "acme.io", repo: "some_repo.2" }); + }); + + it("rejects names without exactly one slash", () => { + expect(() => parseRepositoryFullName("no-slash")).toThrow(/owner\/repo/); + expect(() => parseRepositoryFullName("a/b/c")).toThrow(/owner\/repo/); + }); + + it("rejects empty owner or empty repo", () => { + expect(() => parseRepositoryFullName("/repo")).toThrow(/owner\/repo/); + expect(() => parseRepositoryFullName("owner/")).toThrow(/owner\/repo/); + }); +}); + +describe("encodeGitHubPath", () => { + it("percent-encodes each segment but leaves slashes intact", () => { + expect(encodeGitHubPath("a/b/c")).toBe("a/b/c"); + expect(encodeGitHubPath("dir with space/file.ts")).toBe("dir%20with%20space/file.ts"); + expect(encodeGitHubPath(".github/workflows/x.yml")).toBe(".github/workflows/x.yml"); + }); + + it("encodes special characters in segments", () => { + expect(encodeGitHubPath("hash#dir/q?file.ts")).toBe("hash%23dir/q%3Ffile.ts"); + }); +}); + +describe("githubRepositoryContentsUrl", () => { + it("composes a contents URL with encoded owner, repo, and path", () => { + expect(githubRepositoryContentsUrl("myorg", "my-repo", "stack.config.ts")) + .toBe("/repos/myorg/my-repo/contents/stack.config.ts"); + expect(githubRepositoryContentsUrl("my org", "my repo", "dir with space/file.ts")) + .toBe("/repos/my%20org/my%20repo/contents/dir%20with%20space/file.ts"); + }); +}); + +describe("isObject", () => { + it("matches plain objects only", () => { + expect(isObject({})).toBe(true); + expect(isObject({ a: 1 })).toBe(true); + expect(isObject(null)).toBe(false); + expect(isObject([])).toBe(false); + expect(isObject("string")).toBe(false); + expect(isObject(42)).toBe(false); + }); +}); + +describe("getFileContent", () => { + function fakeGithubFetch(handler: (path: string, init?: RequestInit) => unknown) { + const calls: { path: string, init?: RequestInit }[] = []; + const fn = async (path: string, init?: RequestInit) => { + calls.push({ path, init }); + return handler(path, init); + }; + return { fn, calls }; + } + + it("decodes base64 content and returns the SHA on success", async () => { + const text = "export const config = {};\n"; + const base64 = Buffer.from(text, "utf-8").toString("base64"); + const { fn, calls } = fakeGithubFetch(() => ({ + type: "file", + encoding: "base64", + content: base64, + sha: "abc123", + })); + + const result = await getFileContent(fn, { + owner: "myorg", + repo: "my-repo", + branch: "main", + path: "stack.config.ts", + }); + expect(result).toEqual({ text, sha: "abc123" }); + expect(calls[0].path).toBe("/repos/myorg/my-repo/contents/stack.config.ts?ref=main"); + }); + + it("handles base64 content with embedded whitespace (GitHub line-wraps long blobs)", async () => { + const text = "x".repeat(200); + const base64 = Buffer.from(text, "utf-8").toString("base64"); + const wrapped = base64.match(/.{1,60}/g)!.join("\n"); + const { fn } = fakeGithubFetch(() => ({ + type: "file", + encoding: "base64", + content: wrapped, + sha: "abc", + })); + const result = await getFileContent(fn, { + owner: "o", + repo: "r", + branch: "main", + path: "stack.config.ts", + }); + expect(result?.text).toBe(text); + }); + + it("returns null when the file is missing (Not Found error)", async () => { + const { fn } = fakeGithubFetch(() => { + throw new Error("Not Found"); + }); + const result = await getFileContent(fn, { + owner: "o", repo: "r", branch: "main", path: "missing.ts", + }); + expect(result).toBeNull(); + }); + + it("returns null when the response is a directory (array)", async () => { + const { fn } = fakeGithubFetch(() => [{ type: "file", path: "x" }]); + const result = await getFileContent(fn, { owner: "o", repo: "r", branch: "main", path: "x" }); + expect(result).toBeNull(); + }); + + it("returns null when the response type is not 'file'", async () => { + const { fn } = fakeGithubFetch(() => ({ type: "dir", sha: "x", content: "" })); + const result = await getFileContent(fn, { owner: "o", repo: "r", branch: "main", path: "x" }); + expect(result).toBeNull(); + }); + + it("re-throws non-404 errors", async () => { + const { fn } = fakeGithubFetch(() => { + throw new Error("Server error"); + }); + await expect(getFileContent(fn, { owner: "o", repo: "r", branch: "main", path: "x.ts" })) + .rejects.toThrow(/Server error/); + }); + + it("throws on unexpected encoding", async () => { + const { fn } = fakeGithubFetch(() => ({ + type: "file", + encoding: "utf-8", + content: "raw", + sha: "abc", + })); + await expect(getFileContent(fn, { owner: "o", repo: "r", branch: "main", path: "x.ts" })) + .rejects.toThrow(/encoding/); + }); +}); + +describe("commitFile", () => { + it("PUTs the encoded content with the given message and sha", async () => { + const calls: { path: string, init?: RequestInit }[] = []; + const fn = async (path: string, init?: RequestInit) => { + calls.push({ path, init }); + return null; + }; + await commitFile(fn, { + owner: "myorg", + repo: "my-repo", + branch: "main", + path: "stack.config.ts", + content: "hello", + message: "chore: update", + sha: "deadbeef", + }); + expect(calls).toHaveLength(1); + expect(calls[0].path).toBe("/repos/myorg/my-repo/contents/stack.config.ts"); + expect(calls[0].init?.method).toBe("PUT"); + const parsedBody = JSON.parse(String(calls[0].init?.body)); + expect(parsedBody.message).toBe("chore: update"); + expect(parsedBody.branch).toBe("main"); + expect(parsedBody.sha).toBe("deadbeef"); + expect(Buffer.from(parsedBody.content, "base64").toString("utf-8")).toBe("hello"); + }); + + it("omits sha when creating a new file", async () => { + const calls: { path: string, init?: RequestInit }[] = []; + const fn = async (path: string, init?: RequestInit) => { + calls.push({ path, init }); + return null; + }; + await commitFile(fn, { + owner: "o", repo: "r", branch: "main", path: "new.ts", content: "x", message: "create", + }); + const parsedBody = JSON.parse(String(calls[0].init?.body)); + expect(parsedBody).not.toHaveProperty("sha"); + }); +}); diff --git a/apps/dashboard/src/lib/github-api.ts b/apps/dashboard/src/lib/github-api.ts new file mode 100644 index 0000000000..4413a67af3 --- /dev/null +++ b/apps/dashboard/src/lib/github-api.ts @@ -0,0 +1,205 @@ +/** + * Client-side helpers for talking to the GitHub REST API on behalf of a Stack + * user's connected GitHub account. + * + * Kept separate from any React/hook code so the helpers are easy to unit-test + * and to share between the new-project onboarding flow and the config-update + * dialog. + */ + +import type { OAuthConnection } from "@stackframe/stack"; + +export const GITHUB_SCOPE_REQUIREMENTS = ["repo", "workflow"]; + +export function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function getObjectString(value: Record, key: string): string | null { + const field = value[key]; + return typeof field === "string" ? field : null; +} + +export function parseRepositoryFullName(fullName: string): { owner: string, repo: string } { + const slashIndex = fullName.indexOf("/"); + if (slashIndex <= 0 || slashIndex >= fullName.length - 1 || fullName.indexOf("/", slashIndex + 1) !== -1) { + throw new Error(`Repository must be in the format 'owner/repo' (got '${fullName}').`); + } + return { + owner: fullName.slice(0, slashIndex), + repo: fullName.slice(slashIndex + 1), + }; +} + +export function encodeGitHubPath(path: string): string { + return path + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +export function githubRepositoryContentsUrl(owner: string, repo: string, path: string): string { + return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodeGitHubPath(path)}`; +} + +export type GithubFetch = (path: string, requestInit?: RequestInit) => Promise; + +/** + * Returns a `githubFetch` helper bound to the given OAuth connection. The + * helper accepts an `api.github.com`-relative path (e.g. "/user") and returns + * the parsed JSON body. Non-2xx responses are turned into thrown Errors whose + * message is the GitHub-supplied `message` field when present. + */ +export function createGithubFetch(account: OAuthConnection): GithubFetch { + return async (path, requestInit) => { + const tokenResult = await account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS }); + if (tokenResult.status === "error") { + throw new Error("Could not get a GitHub access token. Reconnect your GitHub account and try again."); + } + + const response = await fetch(new URL(path, "https://api.github.com").toString(), { + ...requestInit, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${tokenResult.data.accessToken}`, + ...(requestInit?.headers ?? {}), + }, + }); + + if (response.status === 204) { + if (!response.ok) { + throw new Error("GitHub API request failed."); + } + return null; + } + + const responseText = await response.text(); + const parsedBody = responseText.length > 0 ? JSON.parse(responseText) : null; + + if (!response.ok) { + const parsedMessage = isObject(parsedBody) ? getObjectString(parsedBody, "message") : null; + throw new Error(parsedMessage ?? `GitHub API request failed with status ${response.status}.`); + } + + return parsedBody; + }; +} + +export type GithubFileContent = { + /** UTF-8 decoded file content. */ + text: string, + /** Blob SHA — required when updating the file via the Contents API. */ + sha: string, +}; + +/** + * Fetches a file via `GET /repos/{owner}/{repo}/contents/{path}` and returns + * its decoded UTF-8 content plus blob SHA. Returns `null` if the file does not + * exist on the given branch. + * + * Errors that are not 404s (network failures, permission errors, etc.) are + * re-thrown. + */ +export async function getFileContent( + githubFetch: GithubFetch, + options: { owner: string, repo: string, branch: string, path: string }, +): Promise { + const { owner, repo, branch, path } = options; + const refQuery = new URLSearchParams({ ref: branch }).toString(); + try { + // `cache: "no-store"` because GitHub's Contents API responds with + // `Cache-Control: private, max-age=60` for authenticated reads, and the + // browser's HTTP cache is not invalidated by our subsequent PUT to the + // same URL. Without this, a second push within ~60s reads a stale blob + // SHA and the PUT fails with 409 "{path} does not match {sha}". + const response = await githubFetch(`${githubRepositoryContentsUrl(owner, repo, path)}?${refQuery}`, { cache: "no-store" }); + if (!isObject(response) || Array.isArray(response)) { + // GitHub returns an array when the path is a directory; treat that as + // "file not found" so the caller surfaces a clear error. + return null; + } + const type = getObjectString(response, "type"); + if (type !== "file") { + return null; + } + const encoding = getObjectString(response, "encoding"); + const rawContent = getObjectString(response, "content"); + const sha = getObjectString(response, "sha"); + if (rawContent == null || sha == null) { + throw new Error("GitHub file response is missing content or sha."); + } + if (encoding !== "base64") { + throw new Error(`Unexpected GitHub file encoding '${encoding ?? ""}'.`); + } + return { + text: decodeBase64Utf8(rawContent), + sha, + }; + } catch (error) { + if (error instanceof Error && /Not Found/i.test(error.message)) { + return null; + } + throw error; + } +} + +/** + * Creates or updates a file via `PUT /repos/{owner}/{repo}/contents/{path}`. + * `sha` is required when updating an existing file (the blob SHA from + * `getFileContent`) and must be omitted when creating a new file. + */ +export async function commitFile( + githubFetch: GithubFetch, + options: { + owner: string, + repo: string, + branch: string, + path: string, + content: string, + message: string, + sha?: string, + }, +): Promise { + const { owner, repo, branch, path, content, message, sha } = options; + const body: Record = { + message, + content: encodeBase64Utf8(content), + branch, + }; + if (sha !== undefined) { + body.sha = sha; + } + await githubFetch(githubRepositoryContentsUrl(owner, repo, path), { + method: "PUT", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); +} + +function decodeBase64Utf8(base64: string): string { + const stripped = base64.replace(/\s+/g, ""); + if (typeof globalThis.atob === "function") { + const binary = globalThis.atob(stripped); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new TextDecoder("utf-8").decode(bytes); + } + // Node fallback for unit tests. + return Buffer.from(stripped, "base64").toString("utf-8"); +} + +function encodeBase64Utf8(text: string): string { + const bytes = new TextEncoder().encode(text); + if (typeof globalThis.btoa === "function") { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return globalThis.btoa(binary); + } + return Buffer.from(bytes).toString("base64"); +} diff --git a/apps/dashboard/src/lib/github-config-push.test.ts b/apps/dashboard/src/lib/github-config-push.test.ts new file mode 100644 index 0000000000..294d5fdce2 --- /dev/null +++ b/apps/dashboard/src/lib/github-config-push.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { buildUpdatedConfigFileContent, pushConfigUpdateToGitHub } from "./github-config-push"; + +describe("buildUpdatedConfigFileContent", () => { + it("merges a flat dot-notation update into the existing config", () => { + const current = `import type { StackConfig } from "@stackframe/stack"; + +export const config: StackConfig = { + teams: { allowClientTeamCreation: false }, +}; +`; + const result = buildUpdatedConfigFileContent(current, { "teams.allowClientTeamCreation": true }); + expect(result).toContain('"teams": {'); + expect(result).toContain('"allowClientTeamCreation": true'); + expect(result).toContain('import type { StackConfig } from "@stackframe/stack"'); + }); + + it("preserves the existing @stackframe/* import package when re-rendering", () => { + const current = `import type { StackConfig } from "@stackframe/react"; + +export const config: StackConfig = {}; +`; + const result = buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true }); + expect(result).toContain('import type { StackConfig } from "@stackframe/react"'); + }); + + it("defaults to @stackframe/js when no recognizable import is present", () => { + const current = `export const config = {};\n`; + const result = buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true }); + expect(result).toContain('import type { StackConfig } from "@stackframe/js"'); + }); + + it("adds new top-level keys to an empty config", () => { + const current = `import type { StackConfig } from "@stackframe/js"; +export const config: StackConfig = {}; +`; + const result = buildUpdatedConfigFileContent(current, { + "payments.items.todos.displayName": "Todos", + "payments.items.todos.customerType": "user", + }); + expect(result).toContain(`"payments": { + "items": { + "todos": { + "displayName": "Todos", + "customerType": "user" + } + } + }`); + }); + + it("replaces an existing nested value via dot notation", () => { + const current = `import type { StackConfig } from "@stackframe/js"; +export const config: StackConfig = { + payments: { items: { todos: { displayName: "Old" } } }, +}; +`; + const result = buildUpdatedConfigFileContent(current, { + "payments.items.todos.displayName": "New", + }); + expect(result).toContain('"displayName": "New"'); + expect(result).not.toContain('"Old"'); + }); + + it("refuses to mutate a show-onboarding placeholder file", () => { + const current = `export const config = "show-onboarding";`; + expect(() => buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true })) + .toThrow(/onboarding placeholder/); + }); + + it("throws when the file does not export a `config` binding", () => { + expect(() => buildUpdatedConfigFileContent(`export const other = {};`, { "a": 1 })) + .toThrow(/must export a plain `config` object/); + }); +}); + +describe("pushConfigUpdateToGitHub", () => { + function buildFakeFetch(initialContent: string) { + const base64 = Buffer.from(initialContent, "utf-8").toString("base64"); + const calls: { path: string, init?: RequestInit }[] = []; + const fn = async (path: string, init?: RequestInit) => { + calls.push({ path, init }); + if (init?.method === "PUT") { + return { commit: { sha: "newsha" } }; + } + return { + type: "file", + encoding: "base64", + content: base64, + sha: "oldsha", + }; + }; + return { fn, calls }; + } + + const baseSource = { + type: "pushed-from-github" as const, + owner: "myorg", + repo: "my-repo", + branch: "main", + commitHash: "abc", + configFilePath: "stack.config.ts", + }; + + it("fetches the existing file, merges the update, and PUTs the new content", async () => { + const { fn, calls } = buildFakeFetch(`import type { StackConfig } from "@stackframe/js"; +export const config: StackConfig = { teams: { allowClientTeamCreation: false } }; +`); + await pushConfigUpdateToGitHub({ + source: baseSource, + configUpdate: { "teams.allowClientTeamCreation": true }, + commitMessage: "feat: enable team creation", + githubFetch: fn, + }); + expect(calls).toHaveLength(2); + expect(calls[0].path).toBe("/repos/myorg/my-repo/contents/stack.config.ts?ref=main"); + expect(calls[1].init?.method).toBe("PUT"); + const body = JSON.parse(String(calls[1].init?.body)); + expect(body.message).toBe("feat: enable team creation"); + expect(body.sha).toBe("oldsha"); + expect(body.branch).toBe("main"); + expect(Buffer.from(body.content, "base64").toString("utf-8")).toContain('"allowClientTeamCreation": true'); + }); + + it("falls back to a default commit message when none is provided", async () => { + const { fn, calls } = buildFakeFetch(`export const config = {};\n`); + await pushConfigUpdateToGitHub({ + source: baseSource, + configUpdate: { "auth.allowSignUp": true }, + commitMessage: " ", + githubFetch: fn, + }); + const putBody = JSON.parse(String(calls[1].init?.body)); + expect(putBody.message).toBe("chore(stack-auth): update config from dashboard"); + }); + + it("skips the commit when the new rendered file is identical to the old one", async () => { + const same = `import type { StackConfig } from "@stackframe/js"; + +export const config: StackConfig = { + "teams": { + "allowClientTeamCreation": true + } +}; +`; + const { fn, calls } = buildFakeFetch(same); + await pushConfigUpdateToGitHub({ + source: baseSource, + configUpdate: { "teams.allowClientTeamCreation": true }, + commitMessage: "no-op", + githubFetch: fn, + }); + expect(calls.find((c) => c.init?.method === "PUT")).toBeUndefined(); + }); + + it("surfaces a clear error when the config file is missing on the branch", async () => { + const fn = async () => { + throw new Error("Not Found"); + }; + await expect( + pushConfigUpdateToGitHub({ + source: baseSource, + configUpdate: { "auth.allowSignUp": true }, + commitMessage: "x", + githubFetch: fn, + }) + ).rejects.toThrow(/Could not find stack\.config\.ts/); + }); + + it("propagates non-404 GitHub errors", async () => { + const fn = async () => { + throw new Error("Bad credentials"); + }; + await expect( + pushConfigUpdateToGitHub({ + source: baseSource, + configUpdate: { "auth.allowSignUp": true }, + commitMessage: "x", + githubFetch: fn, + }) + ).rejects.toThrow(/Bad credentials/); + }); +}); diff --git a/apps/dashboard/src/lib/github-config-push.ts b/apps/dashboard/src/lib/github-config-push.ts new file mode 100644 index 0000000000..5d55d3abf5 --- /dev/null +++ b/apps/dashboard/src/lib/github-config-push.ts @@ -0,0 +1,107 @@ +/** + * Pure logic for taking a config update produced by the dashboard, merging it + * into the user's GitHub-stored `stack.config.ts` file, and committing the + * result back to GitHub via the Contents API. + * + * `buildUpdatedConfigFileContent` is the pure heart of this module — it's + * directly unit-testable, takes the current file content and a config update, + * and returns the new file content. The orchestrator `pushConfigUpdateToGitHub` + * wires it up to GitHub's REST API. + */ + +import type { PushedConfigSource } from "@stackframe/stack"; +import type { EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; +import { isValidConfig, override } from "@stackframe/stack-shared/dist/config/format"; +import { parseStackConfigFileContent, renderConfigFileContent, showOnboardingStackConfigValue } from "@stackframe/stack-shared/dist/stack-config-file"; + +import { + commitFile, + getFileContent, + type GithubFetch, +} from "./github-api"; + +/** + * Detects the `@stackframe/*` import package used by the existing config file + * so the re-rendered file keeps the same import line. Falls back to + * `@stackframe/js` when the file is empty or the import cannot be detected. + */ +function detectImportPackage(currentFileContent: string): string | undefined { + // Match `from "@stackframe/"` — single or double quotes. + const match = currentFileContent.match(/from\s+["']@stackframe\/([a-z0-9-]+)["']/i); + return match ? `@stackframe/${match[1]}` : undefined; +} + +/** + * Pure: given the existing contents of a `stack.config.ts` file and a config + * update (the same dot-notation override shape that flows through + * `updatePushedConfig`), returns the new file contents. + * + * The existing import line is preserved when the source file imports + * `StackConfig` from a known `@stackframe/*` package; otherwise the renderer + * uses its own default. + */ +export function buildUpdatedConfigFileContent( + currentFileContent: string, + configUpdate: EnvironmentConfigOverrideOverride, +): string { + const parsed = parseStackConfigFileContent(currentFileContent, "stack.config.ts"); + if (parsed === showOnboardingStackConfigValue) { + throw new Error( + "The config file currently exports the onboarding placeholder. Finish setting up Stack Auth in your repo before pushing dashboard changes." + ); + } + if (!isValidConfig(parsed)) { + throw new Error("Existing GitHub config file does not parse as a valid Stack Auth config object."); + } + const merged = override(parsed, configUpdate); + const importPackage = detectImportPackage(currentFileContent); + return renderConfigFileContent(merged, importPackage); +} + +export type PushConfigUpdateOptions = { + source: Extract, + configUpdate: EnvironmentConfigOverrideOverride, + commitMessage: string, + githubFetch: GithubFetch, +}; + +/** + * Pushes a config update to GitHub by editing the user's `stack.config.ts` + * file in place via the Contents API. The accompanying GitHub Actions workflow + * (added in onboarding) will pick up the commit and re-push the canonical + * config back to Stack Auth. + * + * Returns the commit SHA of the new commit, useful for surfacing in logs / UI. + */ +export async function pushConfigUpdateToGitHub(options: PushConfigUpdateOptions): Promise { + const { source, configUpdate, commitMessage, githubFetch } = options; + const { owner, repo, branch, configFilePath } = source; + + const existing = await getFileContent(githubFetch, { owner, repo, branch, path: configFilePath }); + if (existing == null) { + throw new Error( + `Could not find ${configFilePath} on ${owner}/${repo}@${branch}. Check that the config file still exists in the linked branch.` + ); + } + + const newContent = buildUpdatedConfigFileContent(existing.text, configUpdate); + if (newContent === existing.text) { + // Nothing changed in the rendered file — no need to commit. The dashboard + // will still update the cloud-side override for immediate feedback. + return; + } + + const trimmedMessage = commitMessage.trim().length > 0 + ? commitMessage.trim() + : "chore(stack-auth): update config from dashboard"; + + await commitFile(githubFetch, { + owner, + repo, + branch, + path: configFilePath, + content: newContent, + message: trimmedMessage, + sha: existing.sha, + }); +} diff --git a/packages/stack-shared/src/config-rendering.ts b/packages/stack-shared/src/config-rendering.ts index fa4cdda354..87e8136e29 100644 --- a/packages/stack-shared/src/config-rendering.ts +++ b/packages/stack-shared/src/config-rendering.ts @@ -1,8 +1,7 @@ import { existsSync, readFileSync } from "fs"; import path from "path"; -import { isValidConfig, normalize } from "./config/format"; -import { parseStackConfigFileContent } from "./stack-config-file"; -export { parseStackConfigFileContent }; +import { parseStackConfigFileContent, renderConfigFileContent } from "./stack-config-file"; +export { parseStackConfigFileContent, renderConfigFileContent }; /** * Packages that export the `StackConfig` type, in priority order. @@ -15,8 +14,6 @@ const STACKFRAME_CONFIG_PACKAGES = [ "@stackframe/template", ] as const; -const DEFAULT_CONFIG_IMPORT_PACKAGE = "@stackframe/js"; - /** * Given a list of dependency names (from package.json), returns the * `@stackframe/*` package that should be used for the `StackConfig` import, @@ -58,25 +55,6 @@ export function detectImportPackageFromDir(dir: string): string | undefined { return undefined; } -export function renderConfigFileContent(config: unknown, importPackage?: string): string { - if (!isValidConfig(config)) { - throw new Error("Invalid config: expected a plain object."); - } - - const droppedKeys: string[] = []; - const normalizedConfig = normalize(config, { - onDotIntoNonObject: "ignore", - onDotIntoNull: "empty-object", - droppedKeys, - }); - if (droppedKeys.length > 0) { - throw new Error(`Config has conflicting keys that would be dropped during normalization: ${droppedKeys.map(k => JSON.stringify(k)).join(", ")}`); - } - const pkg = importPackage ?? DEFAULT_CONFIG_IMPORT_PACKAGE; - const importLine = `import type { StackConfig } from "${pkg}";`; - return `${importLine}\n\nexport const config: StackConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`; -} - import.meta.vitest?.test("renderConfigFileContent normalizes config exports", ({ expect }) => { expect(renderConfigFileContent({ "payments.items.todos.displayName": "Todo Slots", diff --git a/packages/stack-shared/src/stack-config-file.ts b/packages/stack-shared/src/stack-config-file.ts index 96aa7430d7..39837c3f8c 100644 --- a/packages/stack-shared/src/stack-config-file.ts +++ b/packages/stack-shared/src/stack-config-file.ts @@ -1,8 +1,37 @@ import * as parser from "@babel/parser"; import * as t from "@babel/types"; +import { isValidConfig, normalize } from "./config/format"; export const showOnboardingStackConfigValue = "show-onboarding"; +const DEFAULT_CONFIG_IMPORT_PACKAGE = "@stackframe/js"; + +/** + * Renders a config object into the source text of a `stack.config.ts` file. + * + * Browser-safe: kept here (next to `parseStackConfigFileContent`) instead of in + * `config-rendering.ts` so dashboard client code can render config files + * without pulling in `fs` / `path`. + */ +export function renderConfigFileContent(config: unknown, importPackage?: string): string { + if (!isValidConfig(config)) { + throw new Error("Invalid config: expected a plain object."); + } + + const droppedKeys: string[] = []; + const normalizedConfig = normalize(config, { + onDotIntoNonObject: "ignore", + onDotIntoNull: "empty-object", + droppedKeys, + }); + if (droppedKeys.length > 0) { + throw new Error(`Config has conflicting keys that would be dropped during normalization: ${droppedKeys.map(k => JSON.stringify(k)).join(", ")}`); + } + const pkg = importPackage ?? DEFAULT_CONFIG_IMPORT_PACKAGE; + const importLine = `import type { StackConfig } from "${pkg}";`; + return `${importLine}\n\nexport const config: StackConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`; +} + type ParsedStackConfig = Record | typeof showOnboardingStackConfigValue; function unwrapStaticConfigExpression(expression: t.Expression): t.Expression { From 0a81649c79c93640b58ced8f17f174802c8154c9 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 20 May 2026 19:26:15 -0700 Subject: [PATCH 19/22] chore: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - instrumentation.ts: revert one-line bypass back to getNextRuntime() for consistency across all four call sites. - config-update.tsx: move `resolve?.(result)` out of the setState updater in settleDialog; sync scope-status via useLayoutEffect so users without a GitHub connection no longer see a "Checking…" flash; rename the ALL_CAPS const helper to a verb-form function. - github-api.ts: drop dead 204-branch error check. - checkConfigPathExists: pass `cache: "no-store"` to dodge the GitHub Contents 60s cache; tighten the path-normalize regex to collapse `.//path`, `././path`, and leading `/` in one pass; unify with the workflow YAML helper. Added unit tests for the new cases. - link-existing-onboarding: persist `packageRunner` (npx/pnpx/bunx) so the choice survives reloads. - stack-cli config-file: trim --source-path / --source-workflow-path before the empty check and store the trimmed form; added test cases for whitespace-only rejection and surrounding-whitespace trim. --- .../link-existing-onboarding-workflow.test.ts | 27 ++++++++++++ .../link-existing-onboarding-workflow.ts | 8 ++-- .../link-existing-onboarding.tsx | 20 +++++++-- apps/dashboard/src/instrumentation.ts | 2 +- apps/dashboard/src/lib/config-update.tsx | 42 +++++++++++-------- apps/dashboard/src/lib/github-api.ts | 5 +-- .../src/commands/config-file.test.ts | 42 +++++++++++++++++++ .../stack-cli/src/commands/config-file.ts | 13 ++++-- 8 files changed, 128 insertions(+), 31 deletions(-) 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 b7162fd0f1..96396b66cd 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 @@ -4,6 +4,7 @@ import { buildWorkflowYaml, GITHUB_PROJECT_ID_SECRET_NAME, GITHUB_SECRET_SERVER_KEY_SECRET_NAME, + normalizeConfigPath, WORKFLOW_FILE_PATH, } from "./link-existing-onboarding-workflow"; @@ -36,3 +37,29 @@ describe("buildWorkflowYaml", () => { expect(workflowYaml).not.toMatch(/STACK_AUTH_SOURCE_REPO:\s+"[^$]/); }); }); + +describe("normalizeConfigPath", () => { + it("strips a single leading ./", () => { + expect(normalizeConfigPath("./stack.config.ts")).toBe("stack.config.ts"); + }); + + it("strips repeated leading ./", () => { + expect(normalizeConfigPath("././stack.config.ts")).toBe("stack.config.ts"); + }); + + it("strips a mix of leading ./ and extra slashes", () => { + expect(normalizeConfigPath(".//src/stack.config.ts")).toBe("src/stack.config.ts"); + }); + + it("strips a single leading /", () => { + expect(normalizeConfigPath("/src/stack.config.ts")).toBe("src/stack.config.ts"); + }); + + it("leaves a repo-relative path alone", () => { + expect(normalizeConfigPath("src/stack.config.ts")).toBe("src/stack.config.ts"); + }); + + it("trims whitespace before normalization", () => { + expect(normalizeConfigPath(" ./stack.config.ts ")).toBe("stack.config.ts"); + }); +}); 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 5c0bd21456..699889d642 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 @@ -8,10 +8,12 @@ function encodeYamlScalar(value: string): string { } // 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. +// leading `./` or `/`. Config-path suggestions and manual input may include +// either, possibly repeated (e.g. `.//src/...`), so strip any combination of +// leading `./` and `/` segments to keep the push trigger and checked-out path +// canonical. export function normalizeConfigPath(configPath: string): string { - return configPath.trim().replace(/^(?:\.\/)+/, ""); + return configPath.trim().replace(/^(?:\.?\/+)+/, ""); } export function buildWorkflowYaml(branch: string, configPath: string): string { 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 6019d03418..a160d72976 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 @@ -90,6 +90,7 @@ type PersistedLinkExistingState = { selectedRepositoryFullName: string, selectedBranch: string, configPathInput: string, + packageRunner: PackageRunner, }; function createRepositoryReference(fullName: string, defaultBranch: string): GithubRepository { @@ -135,12 +136,15 @@ function readPersistedLinkExistingState(projectId: string): PersistedLinkExistin return null; } const selectedGithubAccountIdField = parsed.selectedGithubAccountId; + const packageRunnerField = getObjectString(parsed, "packageRunner"); + const packageRunner: PackageRunner = PACKAGE_RUNNERS.find((entry) => entry === packageRunnerField) ?? "npx"; return { step: parsePersistedLinkExistingStep(parsed.step), selectedGithubAccountId: typeof selectedGithubAccountIdField === "string" ? selectedGithubAccountIdField : null, selectedRepositoryFullName: getObjectString(parsed, "selectedRepositoryFullName") ?? "", selectedBranch: getObjectString(parsed, "selectedBranch") ?? "", configPathInput: getObjectString(parsed, "configPathInput") ?? "stack.config.ts", + packageRunner, }; } catch { return null; @@ -494,7 +498,7 @@ export function LinkExistingOnboarding(props: Props) { const repositoriesLoadedAccountRef = useRef(null); const loadRepositoriesRunIdRef = useRef(0); const [configPathInput, setConfigPathInput] = useState(persistedState?.configPathInput ?? "stack.config.ts"); - const [packageRunner, setPackageRunner] = useState("npx"); + const [packageRunner, setPackageRunner] = useState(persistedState?.packageRunner ?? "npx"); const [repoSearchQuery, setRepoSearchQuery] = useState(""); const [repoSearchResults, setRepoSearchResults] = useState([]); const [loadingRepoSearch, setLoadingRepoSearch] = useState(false); @@ -514,9 +518,10 @@ export function LinkExistingOnboarding(props: Props) { selectedRepositoryFullName: partial.selectedRepositoryFullName ?? existingState?.selectedRepositoryFullName ?? selectedRepositoryFullName, selectedBranch: partial.selectedBranch ?? existingState?.selectedBranch ?? selectedBranch, configPathInput: partial.configPathInput ?? existingState?.configPathInput ?? configPathInput, + packageRunner: partial.packageRunner ?? existingState?.packageRunner ?? packageRunner, ...partial, }); - }, [configPathInput, project.id, selectedBranch, selectedGithubAccountId, selectedRepositoryFullName, step]); + }, [configPathInput, packageRunner, project.id, selectedBranch, selectedGithubAccountId, selectedRepositoryFullName, step]); const setStepWithPersistence = useCallback((nextStep: LinkExistingStep) => { if (nextStep !== "github-logs") { @@ -971,14 +976,22 @@ export function LinkExistingOnboarding(props: Props) { branch: string, path: string, ): Promise => { - const normalizedPath = path.trim().replace(/^\.?\/+/, ""); + // Same shape as normalizeConfigPath in link-existing-onboarding-workflow.ts: + // strip any combination of leading `./` and `/` segments so inputs like + // `.//src/...` or `/src/...` collapse to a repo-relative path. + const normalizedPath = path.trim().replace(/^(?:\.?\/+)+/, ""); if (normalizedPath.length === 0 || normalizedPath.split("/").includes("..")) { return false; } const refQuery = new URLSearchParams({ ref: branch }).toString(); try { + // `cache: "no-store"` because GitHub's Contents API responds with + // `Cache-Control: private, max-age=60` for authenticated reads. Without + // this, a user who just pushed the config file and immediately clicks + // "Create GitHub Action" can see a cached 404 from before the push. const response = await githubFetch( githubRepositoryApiPath(owner, repo, `/contents/${encodeGitHubPath(normalizedPath)}?${refQuery}`), + { cache: "no-store" }, ); if (!isObject(response) || Array.isArray(response)) { return false; @@ -1418,6 +1431,7 @@ export function LinkExistingOnboarding(props: Props) { const runner = PACKAGE_RUNNERS.find((entry) => entry === id); if (runner != null) { setPackageRunner(runner); + persistState({ packageRunner: runner }); } }} size="sm" diff --git a/apps/dashboard/src/instrumentation.ts b/apps/dashboard/src/instrumentation.ts index f818a2b41e..5962696e9a 100644 --- a/apps/dashboard/src/instrumentation.ts +++ b/apps/dashboard/src/instrumentation.ts @@ -5,7 +5,7 @@ import { nicify } from "@stackframe/stack-shared/dist/utils/strings"; import "./polyfills"; async function startRemoteDevelopmentEnvironmentLifecycleIfNeeded(): Promise { - if (process.env.NEXT_RUNTIME !== "nodejs" || getEnvVariable("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT", "") !== "true") { + if (getNextRuntime() !== "nodejs" || getEnvVariable("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT", "") !== "true") { return; } diff --git a/apps/dashboard/src/lib/config-update.tsx b/apps/dashboard/src/lib/config-update.tsx index a21436fe76..be1a04c70c 100644 --- a/apps/dashboard/src/lib/config-update.tsx +++ b/apps/dashboard/src/lib/config-update.tsx @@ -8,7 +8,7 @@ import type { OAuthConnection, PushedConfigSource, StackAdminApp } from "@stackf import type { EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; -import React, { createContext, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import React, { createContext, Suspense, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createGithubFetch, GITHUB_SCOPE_REQUIREMENTS } from "./github-api"; import { pushConfigUpdateToGitHub } from "./github-config-push"; @@ -72,18 +72,21 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React }, []); const settleDialog = useCallback((result: boolean) => { - setDialogState((prev) => { - prev.resolve?.(result); - return { - isOpen: false, - adminApp: null, - configUpdate: null, - resolve: null, - source: null, - isLoadingSource: false, - }; + // Pull `resolve` out before the state update so we never invoke it from + // inside a setState updater — React strict mode double-invokes updaters, + // which would call `resolve` twice. Promise resolution is idempotent so + // this was harmless in practice, but the pattern is wrong. + const resolve = dialogState.resolve; + setDialogState({ + isOpen: false, + adminApp: null, + configUpdate: null, + resolve: null, + source: null, + isLoadingSource: false, }); - }, []); + resolve?.(result); + }, [dialogState.resolve]); const projectId = dialogState.adminApp?.projectId; @@ -193,8 +196,9 @@ type GithubPushHandlers = { connect: () => Promise<"prevent-close" | undefined>, }; -const UNLINK_HINT_PROJECT_SETTINGS_HREF = (projectId: string | undefined) => - `/projects/${projectId}/project-settings`; +function projectSettingsHref(projectId: string | undefined): string { + return `/projects/${projectId}/project-settings`; +} /** * Outer shell. Renders `ActionDialog` synchronously (no suspending hooks) so @@ -295,7 +299,7 @@ function GithubPushBodyFallback({ projectId }: { projectId: string | undefined }

If your configuration is no longer on GitHub, you can unlink it in{" "} - + Project Settings . @@ -338,7 +342,11 @@ function GithubPushBody({ // Sync our local status string up to the dialog shell so it can pick the // right button label / description without itself needing to suspend. - useEffect(() => { + // `useLayoutEffect` (not `useEffect`) so the shell's "checking" placeholder + // never reaches the screen for users whose initial state is actually + // "no-account" — the sync runs before the browser paints the first frame + // after the Suspense fallback resolves. + useLayoutEffect(() => { onScopeStatusChange(scopeCheck.status); }, [scopeCheck.status, onScopeStatusChange]); @@ -471,7 +479,7 @@ function GithubPushBody({

If your configuration is no longer on GitHub, you can unlink it in{" "} - + Project Settings . diff --git a/apps/dashboard/src/lib/github-api.ts b/apps/dashboard/src/lib/github-api.ts index 4413a67af3..226adc76c8 100644 --- a/apps/dashboard/src/lib/github-api.ts +++ b/apps/dashboard/src/lib/github-api.ts @@ -67,9 +67,8 @@ export function createGithubFetch(account: OAuthConnection): GithubFetch { }); if (response.status === 204) { - if (!response.ok) { - throw new Error("GitHub API request failed."); - } + // 204 is always a success status (any 2xx satisfies `response.ok`), + // so no error check is needed here. return null; } diff --git a/packages/stack-cli/src/commands/config-file.test.ts b/packages/stack-cli/src/commands/config-file.test.ts index e8fd7319fb..dfcdfa28ed 100644 --- a/packages/stack-cli/src/commands/config-file.test.ts +++ b/packages/stack-cli/src/commands/config-file.test.ts @@ -159,6 +159,48 @@ describe("buildConfigPushSource", () => { ).toThrow(/--source-workflow-path must be a non-empty path string/); }); + it("rejects whitespace-only --source-path", () => { + process.env.GITHUB_SHA = "abc123"; + process.env.GITHUB_REF_NAME = "main"; + expect(() => + buildConfigPushSource("stack.config.ts", { + source: "github", + sourceRepo: "myorg/my-repo", + sourcePath: " ", + sourceWorkflowPath: ".github/workflows/x.yml", + }) + ).toThrow(/--source-path must be a non-empty path string/); + }); + + it("rejects whitespace-only --source-workflow-path", () => { + process.env.GITHUB_SHA = "abc123"; + process.env.GITHUB_REF_NAME = "main"; + expect(() => + buildConfigPushSource("stack.config.ts", { + source: "github", + sourceRepo: "myorg/my-repo", + sourcePath: "stack.config.ts", + sourceWorkflowPath: "\t\n ", + }) + ).toThrow(/--source-workflow-path must be a non-empty path string/); + }); + + it("trims surrounding whitespace from --source-path and --source-workflow-path", () => { + process.env.GITHUB_SHA = "abc123"; + process.env.GITHUB_REF_NAME = "main"; + const result = buildConfigPushSource("stack.config.ts", { + source: "github", + sourceRepo: "myorg/my-repo", + sourcePath: " configs/stack.config.ts ", + sourceWorkflowPath: " .github/workflows/x.yml ", + }); + expect(result).toMatchObject({ + type: "pushed-from-github", + config_file_path: "configs/stack.config.ts", + workflow_path: ".github/workflows/x.yml", + }); + }); + it("rejects --source-repo with whitespace or invalid characters", () => { process.env.GITHUB_SHA = "abc123"; process.env.GITHUB_REF_NAME = "main"; diff --git a/packages/stack-cli/src/commands/config-file.ts b/packages/stack-cli/src/commands/config-file.ts index 47a0130f1d..3482215e60 100644 --- a/packages/stack-cli/src/commands/config-file.ts +++ b/packages/stack-cli/src/commands/config-file.ts @@ -78,10 +78,15 @@ export function buildConfigPushSource(configFilePath: string, flags: SourceFlagO const { owner, repo } = parseOwnerRepo(flags.sourceRepo!, "--source-repo"); - if (flags.sourcePath!.length === 0) { + // Trim before the empty check so whitespace-only values (e.g. `--source-path " "`) + // are rejected the same way as an empty string. The stored value is the trimmed + // form to keep the downstream config row free of accidental whitespace. + const sourcePath = flags.sourcePath!.trim(); + if (sourcePath.length === 0) { throw new CliError("--source-path must be a non-empty path string."); } - if (flags.sourceWorkflowPath!.length === 0) { + const sourceWorkflowPath = flags.sourceWorkflowPath!.trim(); + if (sourceWorkflowPath.length === 0) { throw new CliError("--source-workflow-path must be a non-empty path string."); } @@ -100,8 +105,8 @@ export function buildConfigPushSource(configFilePath: string, flags: SourceFlagO repo, branch, commit_hash: sha, - config_file_path: flags.sourcePath!, - workflow_path: flags.sourceWorkflowPath!, + config_file_path: sourcePath, + workflow_path: sourceWorkflowPath, }; } From e0013df9c6632364eb072c949fe630bb32a8cd1d Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 21 May 2026 10:21:37 -0700 Subject: [PATCH 20/22] chore: address config push review comments --- .claude/CLAUDE-KNOWLEDGE.md | 3 + apps/dashboard/src/lib/github-api.test.ts | 194 +++++++++++++---- .../src/lib/github-config-push.test.ts | 202 +++++++++++++++--- apps/dashboard/src/lib/github-config-push.ts | 3 +- 4 files changed, 333 insertions(+), 69 deletions(-) diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index 25bb9b3eae..6ca60c51a1 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -2,6 +2,9 @@ This file contains knowledge learned while working on the codebase in Q&A format. +## Q: How should GitHub Contents API request-body assertions be written in Stack Auth tests? +A: Prefer inline snapshots over individual field selectors. For request bodies that contain base64 file content, parse the JSON body, assert it is an object, decode the `content` field back to UTF-8, and snapshot the normalized call object so the test verifies the path, method, headers, branch, message, sha, and rendered file content together. + ## Q: How do anonymous users work in Stack Auth? A: Anonymous users are a special type of user that can be created without any authentication. They have `isAnonymous: true` in the database and use different JWT signing keys with a `role: 'anon'` claim. Anonymous JWTs use a prefixed secret ("anon-" + audience) for signing and verification. diff --git a/apps/dashboard/src/lib/github-api.test.ts b/apps/dashboard/src/lib/github-api.test.ts index 9a64311329..82b6af5014 100644 --- a/apps/dashboard/src/lib/github-api.test.ts +++ b/apps/dashboard/src/lib/github-api.test.ts @@ -8,52 +8,127 @@ import { parseRepositoryFullName, } from "./github-api"; +function getStringField(value: Record, key: string): string { + const field = value[key]; + if (typeof field !== "string") { + throw new Error(`Expected request body field ${key} to be a string.`); + } + return field; +} + +function snapshotGithubCall(call: { path: string, init?: RequestInit }) { + if (call.init == null) { + return { path: call.path }; + } + const body = call.init.body; + if (body == null) { + return { + path: call.path, + init: call.init, + }; + } + if (typeof body !== "string") { + throw new Error("Expected request body to be a JSON string."); + } + const parsedBody: unknown = JSON.parse(body); + if (!isObject(parsedBody)) { + throw new Error("Expected request body to parse as an object."); + } + const content = getStringField(parsedBody, "content"); + return { + path: call.path, + method: call.init.method, + headers: call.init.headers, + body: { + ...parsedBody, + content: Buffer.from(content, "base64").toString("utf-8"), + }, + }; +} + describe("parseRepositoryFullName", () => { it("splits a well-formed full name into owner and repo", () => { - expect(parseRepositoryFullName("myorg/my-repo")).toEqual({ owner: "myorg", repo: "my-repo" }); - expect(parseRepositoryFullName("acme.io/some_repo.2")).toEqual({ owner: "acme.io", repo: "some_repo.2" }); + expect([ + parseRepositoryFullName("myorg/my-repo"), + parseRepositoryFullName("acme.io/some_repo.2"), + ]).toMatchInlineSnapshot(` + [ + { + "owner": "myorg", + "repo": "my-repo", + }, + { + "owner": "acme.io", + "repo": "some_repo.2", + }, + ] + `); }); it("rejects names without exactly one slash", () => { - expect(() => parseRepositoryFullName("no-slash")).toThrow(/owner\/repo/); - expect(() => parseRepositoryFullName("a/b/c")).toThrow(/owner\/repo/); + expect(() => parseRepositoryFullName("no-slash")).toThrowErrorMatchingInlineSnapshot(`[Error: Repository must be in the format 'owner/repo' (got 'no-slash').]`); + expect(() => parseRepositoryFullName("a/b/c")).toThrowErrorMatchingInlineSnapshot(`[Error: Repository must be in the format 'owner/repo' (got 'a/b/c').]`); }); it("rejects empty owner or empty repo", () => { - expect(() => parseRepositoryFullName("/repo")).toThrow(/owner\/repo/); - expect(() => parseRepositoryFullName("owner/")).toThrow(/owner\/repo/); + expect(() => parseRepositoryFullName("/repo")).toThrowErrorMatchingInlineSnapshot(`[Error: Repository must be in the format 'owner/repo' (got '/repo').]`); + expect(() => parseRepositoryFullName("owner/")).toThrowErrorMatchingInlineSnapshot(`[Error: Repository must be in the format 'owner/repo' (got 'owner/').]`); }); }); describe("encodeGitHubPath", () => { it("percent-encodes each segment but leaves slashes intact", () => { - expect(encodeGitHubPath("a/b/c")).toBe("a/b/c"); - expect(encodeGitHubPath("dir with space/file.ts")).toBe("dir%20with%20space/file.ts"); - expect(encodeGitHubPath(".github/workflows/x.yml")).toBe(".github/workflows/x.yml"); + expect([ + encodeGitHubPath("a/b/c"), + encodeGitHubPath("dir with space/file.ts"), + encodeGitHubPath(".github/workflows/x.yml"), + ]).toMatchInlineSnapshot(` + [ + "a/b/c", + "dir%20with%20space/file.ts", + ".github/workflows/x.yml", + ] + `); }); it("encodes special characters in segments", () => { - expect(encodeGitHubPath("hash#dir/q?file.ts")).toBe("hash%23dir/q%3Ffile.ts"); + expect(encodeGitHubPath("hash#dir/q?file.ts")).toMatchInlineSnapshot(`"hash%23dir/q%3Ffile.ts"`); }); }); describe("githubRepositoryContentsUrl", () => { it("composes a contents URL with encoded owner, repo, and path", () => { - expect(githubRepositoryContentsUrl("myorg", "my-repo", "stack.config.ts")) - .toBe("/repos/myorg/my-repo/contents/stack.config.ts"); - expect(githubRepositoryContentsUrl("my org", "my repo", "dir with space/file.ts")) - .toBe("/repos/my%20org/my%20repo/contents/dir%20with%20space/file.ts"); + expect([ + githubRepositoryContentsUrl("myorg", "my-repo", "stack.config.ts"), + githubRepositoryContentsUrl("my org", "my repo", "dir with space/file.ts"), + ]).toMatchInlineSnapshot(` + [ + "/repos/myorg/my-repo/contents/stack.config.ts", + "/repos/my%20org/my%20repo/contents/dir%20with%20space/file.ts", + ] + `); }); }); describe("isObject", () => { it("matches plain objects only", () => { - expect(isObject({})).toBe(true); - expect(isObject({ a: 1 })).toBe(true); - expect(isObject(null)).toBe(false); - expect(isObject([])).toBe(false); - expect(isObject("string")).toBe(false); - expect(isObject(42)).toBe(false); + expect([ + isObject({}), + isObject({ a: 1 }), + isObject(null), + isObject([]), + isObject("string"), + isObject(42), + ]).toMatchInlineSnapshot(` + [ + true, + true, + false, + false, + false, + false, + ] + `); }); }); @@ -83,8 +158,23 @@ describe("getFileContent", () => { branch: "main", path: "stack.config.ts", }); - expect(result).toEqual({ text, sha: "abc123" }); - expect(calls[0].path).toBe("/repos/myorg/my-repo/contents/stack.config.ts?ref=main"); + expect({ result, calls }).toMatchInlineSnapshot(` + { + "calls": [ + { + "init": { + "cache": "no-store", + }, + "path": "/repos/myorg/my-repo/contents/stack.config.ts?ref=main", + }, + ], + "result": { + "sha": "abc123", + "text": "export const config = {}; + ", + }, + } + `); }); it("handles base64 content with embedded whitespace (GitHub line-wraps long blobs)", async () => { @@ -103,7 +193,12 @@ describe("getFileContent", () => { branch: "main", path: "stack.config.ts", }); - expect(result?.text).toBe(text); + expect(result).toMatchInlineSnapshot(` + { + "sha": "abc", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + } + `); }); it("returns null when the file is missing (Not Found error)", async () => { @@ -113,19 +208,19 @@ describe("getFileContent", () => { const result = await getFileContent(fn, { owner: "o", repo: "r", branch: "main", path: "missing.ts", }); - expect(result).toBeNull(); + expect(result).toMatchInlineSnapshot(`null`); }); it("returns null when the response is a directory (array)", async () => { const { fn } = fakeGithubFetch(() => [{ type: "file", path: "x" }]); const result = await getFileContent(fn, { owner: "o", repo: "r", branch: "main", path: "x" }); - expect(result).toBeNull(); + expect(result).toMatchInlineSnapshot(`null`); }); it("returns null when the response type is not 'file'", async () => { const { fn } = fakeGithubFetch(() => ({ type: "dir", sha: "x", content: "" })); const result = await getFileContent(fn, { owner: "o", repo: "r", branch: "main", path: "x" }); - expect(result).toBeNull(); + expect(result).toMatchInlineSnapshot(`null`); }); it("re-throws non-404 errors", async () => { @@ -133,7 +228,7 @@ describe("getFileContent", () => { throw new Error("Server error"); }); await expect(getFileContent(fn, { owner: "o", repo: "r", branch: "main", path: "x.ts" })) - .rejects.toThrow(/Server error/); + .rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Server error]`); }); it("throws on unexpected encoding", async () => { @@ -144,7 +239,7 @@ describe("getFileContent", () => { sha: "abc", })); await expect(getFileContent(fn, { owner: "o", repo: "r", branch: "main", path: "x.ts" })) - .rejects.toThrow(/encoding/); + .rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Unexpected GitHub file encoding 'utf-8'.]`); }); }); @@ -164,14 +259,23 @@ describe("commitFile", () => { message: "chore: update", sha: "deadbeef", }); - expect(calls).toHaveLength(1); - expect(calls[0].path).toBe("/repos/myorg/my-repo/contents/stack.config.ts"); - expect(calls[0].init?.method).toBe("PUT"); - const parsedBody = JSON.parse(String(calls[0].init?.body)); - expect(parsedBody.message).toBe("chore: update"); - expect(parsedBody.branch).toBe("main"); - expect(parsedBody.sha).toBe("deadbeef"); - expect(Buffer.from(parsedBody.content, "base64").toString("utf-8")).toBe("hello"); + expect(calls.map(snapshotGithubCall)).toMatchInlineSnapshot(` + [ + { + "body": { + "branch": "main", + "content": "hello", + "message": "chore: update", + "sha": "deadbeef", + }, + "headers": { + "content-type": "application/json", + }, + "method": "PUT", + "path": "/repos/myorg/my-repo/contents/stack.config.ts", + }, + ] + `); }); it("omits sha when creating a new file", async () => { @@ -183,7 +287,21 @@ describe("commitFile", () => { await commitFile(fn, { owner: "o", repo: "r", branch: "main", path: "new.ts", content: "x", message: "create", }); - const parsedBody = JSON.parse(String(calls[0].init?.body)); - expect(parsedBody).not.toHaveProperty("sha"); + expect(calls.map(snapshotGithubCall)).toMatchInlineSnapshot(` + [ + { + "body": { + "branch": "main", + "content": "x", + "message": "create", + }, + "headers": { + "content-type": "application/json", + }, + "method": "PUT", + "path": "/repos/o/r/contents/new.ts", + }, + ] + `); }); }); diff --git a/apps/dashboard/src/lib/github-config-push.test.ts b/apps/dashboard/src/lib/github-config-push.test.ts index 294d5fdce2..3a5fc4cd58 100644 --- a/apps/dashboard/src/lib/github-config-push.test.ts +++ b/apps/dashboard/src/lib/github-config-push.test.ts @@ -1,6 +1,45 @@ import { describe, expect, it } from "vitest"; +import { isObject } from "./github-api"; import { buildUpdatedConfigFileContent, pushConfigUpdateToGitHub } from "./github-config-push"; +function getStringField(value: Record, key: string): string { + const field = value[key]; + if (typeof field !== "string") { + throw new Error(`Expected request body field ${key} to be a string.`); + } + return field; +} + +function snapshotGithubCall(call: { path: string, init?: RequestInit }) { + if (call.init == null) { + return { path: call.path }; + } + const body = call.init.body; + if (body == null) { + return { + path: call.path, + init: call.init, + }; + } + if (typeof body !== "string") { + throw new Error("Expected request body to be a JSON string."); + } + const parsedBody: unknown = JSON.parse(body); + if (!isObject(parsedBody)) { + throw new Error("Expected request body to parse as an object."); + } + const content = getStringField(parsedBody, "content"); + return { + path: call.path, + method: call.init.method, + headers: call.init.headers, + body: { + ...parsedBody, + content: Buffer.from(content, "base64").toString("utf-8"), + }, + }; +} + describe("buildUpdatedConfigFileContent", () => { it("merges a flat dot-notation update into the existing config", () => { const current = `import type { StackConfig } from "@stackframe/stack"; @@ -10,9 +49,16 @@ export const config: StackConfig = { }; `; const result = buildUpdatedConfigFileContent(current, { "teams.allowClientTeamCreation": true }); - expect(result).toContain('"teams": {'); - expect(result).toContain('"allowClientTeamCreation": true'); - expect(result).toContain('import type { StackConfig } from "@stackframe/stack"'); + expect(result).toMatchInlineSnapshot(` + "import type { StackConfig } from "@stackframe/stack"; + + export const config: StackConfig = { + "teams": { + "allowClientTeamCreation": true + } + }; + " + `); }); it("preserves the existing @stackframe/* import package when re-rendering", () => { @@ -21,13 +67,31 @@ export const config: StackConfig = { export const config: StackConfig = {}; `; const result = buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true }); - expect(result).toContain('import type { StackConfig } from "@stackframe/react"'); + expect(result).toMatchInlineSnapshot(` + "import type { StackConfig } from "@stackframe/react"; + + export const config: StackConfig = { + "auth": { + "allowSignUp": true + } + }; + " + `); }); it("defaults to @stackframe/js when no recognizable import is present", () => { const current = `export const config = {};\n`; const result = buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true }); - expect(result).toContain('import type { StackConfig } from "@stackframe/js"'); + expect(result).toMatchInlineSnapshot(` + "import type { StackConfig } from "@stackframe/js"; + + export const config: StackConfig = { + "auth": { + "allowSignUp": true + } + }; + " + `); }); it("adds new top-level keys to an empty config", () => { @@ -38,14 +102,21 @@ export const config: StackConfig = {}; "payments.items.todos.displayName": "Todos", "payments.items.todos.customerType": "user", }); - expect(result).toContain(`"payments": { - "items": { - "todos": { - "displayName": "Todos", - "customerType": "user" - } - } - }`); + expect(result).toMatchInlineSnapshot(` + "import type { StackConfig } from "@stackframe/js"; + + export const config: StackConfig = { + "payments": { + "items": { + "todos": { + "displayName": "Todos", + "customerType": "user" + } + } + } + }; + " + `); }); it("replaces an existing nested value via dot notation", () => { @@ -57,19 +128,31 @@ export const config: StackConfig = { const result = buildUpdatedConfigFileContent(current, { "payments.items.todos.displayName": "New", }); - expect(result).toContain('"displayName": "New"'); - expect(result).not.toContain('"Old"'); + expect(result).toMatchInlineSnapshot(` + "import type { StackConfig } from "@stackframe/js"; + + export const config: StackConfig = { + "payments": { + "items": { + "todos": { + "displayName": "New" + } + } + } + }; + " + `); }); it("refuses to mutate a show-onboarding placeholder file", () => { const current = `export const config = "show-onboarding";`; expect(() => buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true })) - .toThrow(/onboarding placeholder/); + .toThrowErrorMatchingInlineSnapshot(`[Error: The config file currently exports the onboarding placeholder. Finish setting up Stack Auth in your repo before pushing dashboard changes.]`); }); it("throws when the file does not export a `config` binding", () => { expect(() => buildUpdatedConfigFileContent(`export const other = {};`, { "a": 1 })) - .toThrow(/must export a plain `config` object/); + .toThrowErrorMatchingInlineSnapshot(`[Error: Invalid config in stack.config.ts. The file must export a plain \`config\` object or "show-onboarding".]`); }); }); @@ -111,14 +194,36 @@ export const config: StackConfig = { teams: { allowClientTeamCreation: false } } commitMessage: "feat: enable team creation", githubFetch: fn, }); - expect(calls).toHaveLength(2); - expect(calls[0].path).toBe("/repos/myorg/my-repo/contents/stack.config.ts?ref=main"); - expect(calls[1].init?.method).toBe("PUT"); - const body = JSON.parse(String(calls[1].init?.body)); - expect(body.message).toBe("feat: enable team creation"); - expect(body.sha).toBe("oldsha"); - expect(body.branch).toBe("main"); - expect(Buffer.from(body.content, "base64").toString("utf-8")).toContain('"allowClientTeamCreation": true'); + expect(calls.map(snapshotGithubCall)).toMatchInlineSnapshot(` + [ + { + "init": { + "cache": "no-store", + }, + "path": "/repos/myorg/my-repo/contents/stack.config.ts?ref=main", + }, + { + "body": { + "branch": "main", + "content": "import type { StackConfig } from "@stackframe/js"; + + export const config: StackConfig = { + "teams": { + "allowClientTeamCreation": true + } + }; + ", + "message": "feat: enable team creation", + "sha": "oldsha", + }, + "headers": { + "content-type": "application/json", + }, + "method": "PUT", + "path": "/repos/myorg/my-repo/contents/stack.config.ts", + }, + ] + `); }); it("falls back to a default commit message when none is provided", async () => { @@ -129,8 +234,36 @@ export const config: StackConfig = { teams: { allowClientTeamCreation: false } } commitMessage: " ", githubFetch: fn, }); - const putBody = JSON.parse(String(calls[1].init?.body)); - expect(putBody.message).toBe("chore(stack-auth): update config from dashboard"); + expect(calls.map(snapshotGithubCall)).toMatchInlineSnapshot(` + [ + { + "init": { + "cache": "no-store", + }, + "path": "/repos/myorg/my-repo/contents/stack.config.ts?ref=main", + }, + { + "body": { + "branch": "main", + "content": "import type { StackConfig } from "@stackframe/js"; + + export const config: StackConfig = { + "auth": { + "allowSignUp": true + } + }; + ", + "message": "chore(stack-auth): update config from dashboard", + "sha": "oldsha", + }, + "headers": { + "content-type": "application/json", + }, + "method": "PUT", + "path": "/repos/myorg/my-repo/contents/stack.config.ts", + }, + ] + `); }); it("skips the commit when the new rendered file is identical to the old one", async () => { @@ -149,7 +282,16 @@ export const config: StackConfig = { commitMessage: "no-op", githubFetch: fn, }); - expect(calls.find((c) => c.init?.method === "PUT")).toBeUndefined(); + expect(calls.map(snapshotGithubCall)).toMatchInlineSnapshot(` + [ + { + "init": { + "cache": "no-store", + }, + "path": "/repos/myorg/my-repo/contents/stack.config.ts?ref=main", + }, + ] + `); }); it("surfaces a clear error when the config file is missing on the branch", async () => { @@ -163,7 +305,7 @@ export const config: StackConfig = { commitMessage: "x", githubFetch: fn, }) - ).rejects.toThrow(/Could not find stack\.config\.ts/); + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Could not find stack.config.ts on myorg/my-repo@main. Check that the config file still exists in the linked branch.]`); }); it("propagates non-404 GitHub errors", async () => { @@ -177,6 +319,6 @@ export const config: StackConfig = { commitMessage: "x", githubFetch: fn, }) - ).rejects.toThrow(/Bad credentials/); + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Bad credentials]`); }); }); diff --git a/apps/dashboard/src/lib/github-config-push.ts b/apps/dashboard/src/lib/github-config-push.ts index 5d55d3abf5..d78fd79558 100644 --- a/apps/dashboard/src/lib/github-config-push.ts +++ b/apps/dashboard/src/lib/github-config-push.ts @@ -71,7 +71,8 @@ export type PushConfigUpdateOptions = { * (added in onboarding) will pick up the commit and re-push the canonical * config back to Stack Auth. * - * Returns the commit SHA of the new commit, useful for surfacing in logs / UI. + * Commits the updated config file when needed; returns once GitHub accepts the + * write. */ export async function pushConfigUpdateToGitHub(options: PushConfigUpdateOptions): Promise { const { source, configUpdate, commitMessage, githubFetch } = options; From 02d635a03e275fa0fa0cfd4076e2bb6564091be9 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 21 May 2026 10:41:42 -0700 Subject: [PATCH 21/22] fix: normalize github source paths in config push --- .claude/CLAUDE-KNOWLEDGE.md | 3 ++ .../src/commands/config-file.test.ts | 51 ++++++++++++++----- .../stack-cli/src/commands/config-file.ts | 21 ++++---- 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index 6ca60c51a1..7fb2bb3f12 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -5,6 +5,9 @@ This file contains knowledge learned while working on the codebase in Q&A format ## Q: How should GitHub Contents API request-body assertions be written in Stack Auth tests? A: Prefer inline snapshots over individual field selectors. For request bodies that contain base64 file content, parse the JSON body, assert it is an object, decode the `content` field back to UTF-8, and snapshot the normalized call object so the test verifies the path, method, headers, branch, message, sha, and rendered file content together. +## Q: How should Stack CLI GitHub source paths be stored? +A: Explicit `stack config push --source github` paths should be normalized as repo-relative paths before storing source metadata. Trim whitespace and strip leading `./`, repeated `./`, and leading `/` segments, matching the dashboard workflow generator's normalization for `STACK_AUTH_CONFIG_PATH` and workflow paths. + ## Q: How do anonymous users work in Stack Auth? A: Anonymous users are a special type of user that can be created without any authentication. They have `isAnonymous: true` in the database and use different JWT signing keys with a `role: 'anon'` claim. Anonymous JWTs use a prefixed secret ("anon-" + audience) for signing and verification. diff --git a/packages/stack-cli/src/commands/config-file.test.ts b/packages/stack-cli/src/commands/config-file.test.ts index dfcdfa28ed..da95a8b92e 100644 --- a/packages/stack-cli/src/commands/config-file.test.ts +++ b/packages/stack-cli/src/commands/config-file.test.ts @@ -143,7 +143,7 @@ describe("buildConfigPushSource", () => { sourcePath: "", sourceWorkflowPath: ".github/workflows/x.yml", }) - ).toThrow(/--source-path must be a non-empty path string/); + ).toThrowErrorMatchingInlineSnapshot(`[CliError: --source-path must be a non-empty repo-relative path string.]`); }); it("rejects empty-string --source-workflow-path", () => { @@ -156,7 +156,7 @@ describe("buildConfigPushSource", () => { sourcePath: "stack.config.ts", sourceWorkflowPath: "", }) - ).toThrow(/--source-workflow-path must be a non-empty path string/); + ).toThrowErrorMatchingInlineSnapshot(`[CliError: --source-workflow-path must be a non-empty repo-relative path string.]`); }); it("rejects whitespace-only --source-path", () => { @@ -169,7 +169,7 @@ describe("buildConfigPushSource", () => { sourcePath: " ", sourceWorkflowPath: ".github/workflows/x.yml", }) - ).toThrow(/--source-path must be a non-empty path string/); + ).toThrowErrorMatchingInlineSnapshot(`[CliError: --source-path must be a non-empty repo-relative path string.]`); }); it("rejects whitespace-only --source-workflow-path", () => { @@ -182,23 +182,50 @@ describe("buildConfigPushSource", () => { sourcePath: "stack.config.ts", sourceWorkflowPath: "\t\n ", }) - ).toThrow(/--source-workflow-path must be a non-empty path string/); + ).toThrowErrorMatchingInlineSnapshot(`[CliError: --source-workflow-path must be a non-empty repo-relative path string.]`); }); - it("trims surrounding whitespace from --source-path and --source-workflow-path", () => { + it("normalizes surrounding whitespace and leading repo-root markers from --source paths", () => { process.env.GITHUB_SHA = "abc123"; process.env.GITHUB_REF_NAME = "main"; const result = buildConfigPushSource("stack.config.ts", { source: "github", sourceRepo: "myorg/my-repo", - sourcePath: " configs/stack.config.ts ", - sourceWorkflowPath: " .github/workflows/x.yml ", - }); - expect(result).toMatchObject({ - type: "pushed-from-github", - config_file_path: "configs/stack.config.ts", - workflow_path: ".github/workflows/x.yml", + sourcePath: " ././configs/stack.config.ts ", + sourceWorkflowPath: " /.github/workflows/x.yml ", }); + expect(result).toMatchInlineSnapshot(` + { + "branch": "main", + "commit_hash": "abc123", + "config_file_path": "configs/stack.config.ts", + "owner": "myorg", + "repo": "my-repo", + "type": "pushed-from-github", + "workflow_path": ".github/workflows/x.yml", + } + `); + }); + + it("rejects source paths that normalize to empty repo-relative paths", () => { + process.env.GITHUB_SHA = "abc123"; + process.env.GITHUB_REF_NAME = "main"; + expect(() => + buildConfigPushSource("stack.config.ts", { + source: "github", + sourceRepo: "myorg/my-repo", + sourcePath: "././", + sourceWorkflowPath: ".github/workflows/x.yml", + }) + ).toThrowErrorMatchingInlineSnapshot(`[CliError: --source-path must be a non-empty repo-relative path string.]`); + expect(() => + buildConfigPushSource("stack.config.ts", { + source: "github", + sourceRepo: "myorg/my-repo", + sourcePath: "stack.config.ts", + sourceWorkflowPath: "/", + }) + ).toThrowErrorMatchingInlineSnapshot(`[CliError: --source-workflow-path must be a non-empty repo-relative path string.]`); }); it("rejects --source-repo with whitespace or invalid characters", () => { diff --git a/packages/stack-cli/src/commands/config-file.ts b/packages/stack-cli/src/commands/config-file.ts index 3482215e60..f45ec4d64c 100644 --- a/packages/stack-cli/src/commands/config-file.ts +++ b/packages/stack-cli/src/commands/config-file.ts @@ -59,6 +59,14 @@ function parseGitHubRepositoryEnv(): { owner: string, repo: string } | null { } } +function normalizeRepoRelativePath(value: string, flagName: string): string { + const normalized = value.trim().replace(/^(?:\.?\/+)+/, ""); + if (normalized.length === 0) { + throw new CliError(`${flagName} must be a non-empty repo-relative path string.`); + } + return normalized; +} + export function buildConfigPushSource(configFilePath: string, flags: SourceFlagOptions): BranchConfigSourceApi { const dependentFlags: Array<[string, string | undefined]> = [ ["--source-repo", flags.sourceRepo], @@ -78,17 +86,8 @@ export function buildConfigPushSource(configFilePath: string, flags: SourceFlagO const { owner, repo } = parseOwnerRepo(flags.sourceRepo!, "--source-repo"); - // Trim before the empty check so whitespace-only values (e.g. `--source-path " "`) - // are rejected the same way as an empty string. The stored value is the trimmed - // form to keep the downstream config row free of accidental whitespace. - const sourcePath = flags.sourcePath!.trim(); - if (sourcePath.length === 0) { - throw new CliError("--source-path must be a non-empty path string."); - } - const sourceWorkflowPath = flags.sourceWorkflowPath!.trim(); - if (sourceWorkflowPath.length === 0) { - throw new CliError("--source-workflow-path must be a non-empty path string."); - } + const sourcePath = normalizeRepoRelativePath(flags.sourcePath!, "--source-path"); + const sourceWorkflowPath = normalizeRepoRelativePath(flags.sourceWorkflowPath!, "--source-workflow-path"); const sha = process.env.GITHUB_SHA; const branch = process.env.GITHUB_REF_NAME; From cb85d12ddb1f63b4d925273d4be11cacc0c005a5 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 21 May 2026 11:07:59 -0700 Subject: [PATCH 22/22] chore: avoid non-null assertions in config push source --- .claude/CLAUDE-KNOWLEDGE.md | 3 +++ packages/stack-cli/src/commands/config-file.ts | 18 ++++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index 7fb2bb3f12..570bb526a5 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -8,6 +8,9 @@ A: Prefer inline snapshots over individual field selectors. For request bodies t ## Q: How should Stack CLI GitHub source paths be stored? A: Explicit `stack config push --source github` paths should be normalized as repo-relative paths before storing source metadata. Trim whitespace and strip leading `./`, repeated `./`, and leading `/` segments, matching the dashboard workflow generator's normalization for `STACK_AUTH_CONFIG_PATH` and workflow paths. +## Q: How should Stack CLI code handle flags proven present by nearby validation? +A: Avoid non-null assertions even when an earlier missing-flags check proves presence. Use `flags.foo ?? throwErr("Expected ...; this should have been caught by ...")` so the type system receives a definite value and future refactors fail loudly with the violated assumption. + ## Q: How do anonymous users work in Stack Auth? A: Anonymous users are a special type of user that can be created without any authentication. They have `isAnonymous: true` in the database and use different JWT signing keys with a `role: 'anon'` claim. Anonymous JWTs use a prefixed secret ("anon-" + audience) for signing and verification. diff --git a/packages/stack-cli/src/commands/config-file.ts b/packages/stack-cli/src/commands/config-file.ts index f45ec4d64c..aaa3e392e2 100644 --- a/packages/stack-cli/src/commands/config-file.ts +++ b/packages/stack-cli/src/commands/config-file.ts @@ -7,6 +7,7 @@ import { CliError } from "../lib/errors.js"; import { resolveConfigFilePathOption } from "../lib/config-file-path.js"; import type { EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; const SHOW_ONBOARDING_STACK_CONFIG_VALUE = "show-onboarding"; @@ -84,10 +85,19 @@ export function buildConfigPushSource(configFilePath: string, flags: SourceFlagO throw new CliError(`When --source github is specified, the following flags are also required: ${missing.join(", ")}.`); } - const { owner, repo } = parseOwnerRepo(flags.sourceRepo!, "--source-repo"); - - const sourcePath = normalizeRepoRelativePath(flags.sourcePath!, "--source-path"); - const sourceWorkflowPath = normalizeRepoRelativePath(flags.sourceWorkflowPath!, "--source-workflow-path"); + const { owner, repo } = parseOwnerRepo( + flags.sourceRepo ?? throwErr("Expected --source-repo to be provided when --source github is specified; this should have been caught by the missing-flags check."), + "--source-repo", + ); + + const sourcePath = normalizeRepoRelativePath( + flags.sourcePath ?? throwErr("Expected --source-path to be provided when --source github is specified; this should have been caught by the missing-flags check."), + "--source-path", + ); + const sourceWorkflowPath = normalizeRepoRelativePath( + flags.sourceWorkflowPath ?? throwErr("Expected --source-workflow-path to be provided when --source github is specified; this should have been caught by the missing-flags check."), + "--source-workflow-path", + ); const sha = process.env.GITHUB_SHA; const branch = process.env.GITHUB_REF_NAME;