mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-19 21:00:40 +08:00
End-to-end flow for managing Stack Auth config via GitHub: link a repo
during onboarding, edit settings in the dashboard, and have the change
committed to your repo + synced back via a GitHub Actions workflow.

## What this adds
- **CLI** — `stack config push --source github --source-repo
--source-path --source-workflow-path`. Records the source on the config
row so the dashboard knows where the file lives. Reads `GITHUB_SHA` /
`GITHUB_REF_NAME` for commit + branch.
- **Onboarding "Link existing project"** — searchable repo/branch
comboboxes, auto-detects candidate `stack.config.{ts,js}` paths, writes
`STACK_AUTH_PROJECT_ID` + `STACK_AUTH_SECRET_SERVER_KEY` secrets, and
commits a generated workflow YAML that re-runs `stack config push` on
every change to the config file.
- **Dashboard "Push to GitHub" dialog** — replaces the prior TODO
buttons. Pre-flights `repo`+`workflow` scopes on the user's GitHub
connection; if missing, the button flips to "Reconnect with GitHub". On
push, commits the dashboard's edit straight to the linked repo/branch
via the Contents API (with `cache: "no-store"` to dodge GitHub's 60s GET
cache so consecutive pushes don't 409). Suspense boundary scoped to the
dialog body so opening it doesn't blank the dashboard.
- **Project settings** — surface the linked workflow file as a clickable
GitHub link when the source carries `workflow_path`.
## Test plan
- `pnpm lint` (29/29) ✓
- `pnpm typecheck` (29/29) ✓
- `pnpm --filter @stackframe/stack-cli test` (111/111) ✓
- Dashboard vitest on the three relevant files
(`link-existing-onboarding-workflow`, `github-api`,
`github-config-push`) — 37/37 ✓
- Live end-to-end: `BilalG1/lex-lookup` linked to a local dev project;
passkey toggled, push committed `0bb958bd`
([commit](0bb958bda3)).
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Persist workflow file paths for GitHub-backed config sync
* Dashboard “Push” flow to commit config updates with trimmed/default
commit messages
* CLI options to declare GitHub source (repo/path/workflow) and persist
selectable package runner for manual pushes
* Show workflow-file link in project configuration when present
* **Improvements**
* Robust config-path normalization, existence checks, debounced
repo/branch search, and better GitHub rate-limit handling
* New GitHub API utilities for safe file read/commit and import-package
detection
* **Tests**
* Expanded tests covering GitHub API, config rendering/merge, and push
behaviors
<!-- review_stack_entry_start -->
[](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1450?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)
<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
205 lines
7.0 KiB
TypeScript
205 lines
7.0 KiB
TypeScript
/**
|
|
* 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<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
export function getObjectString(value: Record<string, unknown>, 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<unknown>;
|
|
|
|
/**
|
|
* 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<GithubFileContent | null> {
|
|
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 ?? "<missing>"}'.`);
|
|
}
|
|
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<void> {
|
|
const { owner, repo, branch, path, content, message, sha } = options;
|
|
const body: Record<string, unknown> = {
|
|
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");
|
|
}
|