diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index 25bb9b3eae..570bb526a5 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -2,6 +2,15 @@ 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 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/apps/backend/src/lib/seed-dummy-data.ts b/apps/backend/src/lib/seed-dummy-data.ts index 872a8820ed..d332c29266 100644 --- a/apps/backend/src/lib/seed-dummy-data.ts +++ b/apps/backend/src/lib/seed-dummy-data.ts @@ -2076,6 +2076,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 4841938025..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"; @@ -17,7 +18,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: npx --yes @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 +30,36 @@ 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+"[^$]/); + }); +}); + +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 0276adb20c..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 { @@ -23,6 +25,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 +57,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)/(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/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/dashboard/src/lib/config-update.tsx b/apps/dashboard/src/lib/config-update.tsx index a0e8793570..be1a04c70c 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, useLayoutEffect, 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,10 +71,12 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React return false; }, []); - const handleClose = useCallback((result: boolean) => { - if (dialogState.resolve) { - dialogState.resolve(result); - } + const settleDialog = useCallback((result: boolean) => { + // 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, @@ -84,10 +84,8 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React resolve: null, source: null, isLoadingSource: false, - commitMessage: "", - showConnectWithGitHub: false, }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- we only care about the resolve function, not the entire dialogState + resolve?.(result); }, [dialogState.resolve]); const projectId = dialogState.adminApp?.projectId; @@ -101,57 +99,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 +113,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 +126,7 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React cancelButton={{ label: "Cancel", onClick: async () => { - handleClose(false); + settleDialog(false); }, }} > @@ -212,6 +166,328 @@ 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>, +}; + +function projectSettingsHref(projectId: string | undefined): string { + return `/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. + // `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]); + + // 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..82b6af5014 --- /dev/null +++ b/apps/dashboard/src/lib/github-api.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from "vitest"; +import { + commitFile, + encodeGitHubPath, + getFileContent, + githubRepositoryContentsUrl, + isObject, + 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"), + 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")).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")).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"), + 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")).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"), + 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({}), + isObject({ a: 1 }), + isObject(null), + isObject([]), + isObject("string"), + isObject(42), + ]).toMatchInlineSnapshot(` + [ + true, + true, + false, + false, + false, + 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, 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 () => { + 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).toMatchInlineSnapshot(` + { + "sha": "abc", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + } + `); + }); + + 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).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).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).toMatchInlineSnapshot(`null`); + }); + + 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.toThrowErrorMatchingInlineSnapshot(`[Error: 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.toThrowErrorMatchingInlineSnapshot(`[Error: Unexpected GitHub file encoding 'utf-8'.]`); + }); +}); + +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.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 () => { + 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", + }); + 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-api.ts b/apps/dashboard/src/lib/github-api.ts new file mode 100644 index 0000000000..226adc76c8 --- /dev/null +++ b/apps/dashboard/src/lib/github-api.ts @@ -0,0 +1,204 @@ +/** + * 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) { + // 204 is always a success status (any 2xx satisfies `response.ok`), + // so no error check is needed here. + 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..3a5fc4cd58 --- /dev/null +++ b/apps/dashboard/src/lib/github-config-push.test.ts @@ -0,0 +1,324 @@ +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"; + +export const config: StackConfig = { + teams: { allowClientTeamCreation: false }, +}; +`; + const result = buildUpdatedConfigFileContent(current, { "teams.allowClientTeamCreation": true }); + 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", () => { + const current = `import type { StackConfig } from "@stackframe/react"; + +export const config: StackConfig = {}; +`; + const result = buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true }); + 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).toMatchInlineSnapshot(` + "import type { StackConfig } from "@stackframe/js"; + + export const config: StackConfig = { + "auth": { + "allowSignUp": true + } + }; + " + `); + }); + + 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).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", () => { + 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).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 })) + .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 })) + .toThrowErrorMatchingInlineSnapshot(`[Error: Invalid config in stack.config.ts. The file must export a plain \`config\` object or "show-onboarding".]`); + }); +}); + +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.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 () => { + const { fn, calls } = buildFakeFetch(`export const config = {};\n`); + await pushConfigUpdateToGitHub({ + source: baseSource, + configUpdate: { "auth.allowSignUp": true }, + commitMessage: " ", + githubFetch: fn, + }); + 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 () => { + 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.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 () => { + const fn = async () => { + throw new Error("Not Found"); + }; + await expect( + pushConfigUpdateToGitHub({ + source: baseSource, + configUpdate: { "auth.allowSignUp": true }, + commitMessage: "x", + githubFetch: fn, + }) + ).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 () => { + const fn = async () => { + throw new Error("Bad credentials"); + }; + await expect( + pushConfigUpdateToGitHub({ + source: baseSource, + configUpdate: { "auth.allowSignUp": true }, + commitMessage: "x", + githubFetch: fn, + }) + ).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 new file mode 100644 index 0000000000..d78fd79558 --- /dev/null +++ b/apps/dashboard/src/lib/github-config-push.ts @@ -0,0 +1,108 @@ +/** + * 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. + * + * 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; + 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/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index b0b1e02398..09da1f189c 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -1364,7 +1364,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..da95a8b92e 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,258 @@ 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 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", + }) + ).toThrowErrorMatchingInlineSnapshot(`[CliError: --source-path must be a non-empty repo-relative 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: "", + }) + ).toThrowErrorMatchingInlineSnapshot(`[CliError: --source-workflow-path must be a non-empty repo-relative 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", + }) + ).toThrowErrorMatchingInlineSnapshot(`[CliError: --source-path must be a non-empty repo-relative 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 ", + }) + ).toThrowErrorMatchingInlineSnapshot(`[CliError: --source-workflow-path must be a non-empty repo-relative path string.]`); + }); + + 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).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", () => { + 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..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"; @@ -26,29 +27,103 @@ 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 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; } -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 ?? 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; + 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: sourcePath, + workflow_path: 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 +173,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 +184,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 +252,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 +276,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/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/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/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 { 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" };