stack/apps/dashboard/src/lib/github-config-push.test.ts
BilalG1 b8fc04bdbd
feat: link Stack Auth projects to GitHub and push config from the dashboard (#1450)
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.


![demo](https://gist.githubusercontent.com/BilalG1/29d1188fc581e87d1311baec6e2ae770/raw/demo-2x.gif)

## 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 -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](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 -->
2026-05-21 13:47:46 -07:00

325 lines
9.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { isObject } from "./github-api";
import { buildUpdatedConfigFileContent, pushConfigUpdateToGitHub } from "./github-config-push";
function getStringField(value: Record<string, unknown>, 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]`);
});
});