mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +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 -->
132 lines
4.7 KiB
TypeScript
132 lines
4.7 KiB
TypeScript
import { existsSync, readFileSync } from "fs";
|
|
import path from "path";
|
|
import { parseStackConfigFileContent, renderConfigFileContent } from "./stack-config-file";
|
|
export { parseStackConfigFileContent, renderConfigFileContent };
|
|
|
|
/**
|
|
* Packages that export the `StackConfig` type, in priority order.
|
|
* The first match found in a project's dependencies wins.
|
|
*/
|
|
const STACKFRAME_CONFIG_PACKAGES = [
|
|
"@stackframe/stack",
|
|
"@stackframe/react",
|
|
"@stackframe/js",
|
|
"@stackframe/template",
|
|
] as const;
|
|
|
|
/**
|
|
* Given a list of dependency names (from package.json), returns the
|
|
* `@stackframe/*` package that should be used for the `StackConfig` import,
|
|
* or `undefined` if none of the known packages are installed.
|
|
*/
|
|
export function detectStackframeImportPackage(dependencies: string[]): string | undefined {
|
|
for (const pkg of STACKFRAME_CONFIG_PACKAGES) {
|
|
if (dependencies.includes(pkg)) {
|
|
return pkg;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Walks up from `dir` to find the nearest `package.json` and returns the
|
|
* best `@stackframe/*` package to use for the `StackConfig` type import.
|
|
*/
|
|
export function detectImportPackageFromDir(dir: string): string | undefined {
|
|
let current = dir;
|
|
while (true) {
|
|
const pkgPath = path.join(current, "package.json");
|
|
if (existsSync(pkgPath)) {
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
const deps = [
|
|
...Object.keys(pkg.dependencies ?? {}),
|
|
...Object.keys(pkg.devDependencies ?? {}),
|
|
];
|
|
return detectStackframeImportPackage(deps);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
const parent = path.dirname(current);
|
|
if (parent === current) break;
|
|
current = parent;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
import.meta.vitest?.test("renderConfigFileContent normalizes config exports", ({ expect }) => {
|
|
expect(renderConfigFileContent({
|
|
"payments.items.todos.displayName": "Todo Slots",
|
|
"payments.items.todos.customerType": "user",
|
|
})).toContain(`export const config: StackConfig = {
|
|
"payments": {
|
|
"items": {
|
|
"todos": {
|
|
"displayName": "Todo Slots",
|
|
"customerType": "user"
|
|
}
|
|
}
|
|
}
|
|
};`);
|
|
});
|
|
|
|
import.meta.vitest?.test("parseStackConfigFileContent parses static config exports", ({ expect }) => {
|
|
expect(parseStackConfigFileContent(`
|
|
import type { StackConfig } from "@stackframe/js";
|
|
export const config: StackConfig = {
|
|
auth: { allowSignUp: true },
|
|
payments: { testMode: false },
|
|
};
|
|
`, "stack.config.ts")).toMatchInlineSnapshot(`
|
|
{
|
|
"auth": {
|
|
"allowSignUp": true,
|
|
},
|
|
"payments": {
|
|
"testMode": false,
|
|
},
|
|
}
|
|
`);
|
|
});
|
|
|
|
import.meta.vitest?.test("parseStackConfigFileContent parses show-onboarding", ({ expect }) => {
|
|
expect(parseStackConfigFileContent('export const config = "show-onboarding";', "stack.config.ts")).toBe("show-onboarding");
|
|
});
|
|
|
|
import.meta.vitest?.test("parseStackConfigFileContent rejects dynamic config exports", ({ expect }) => {
|
|
expect(() => parseStackConfigFileContent("export const config = makeConfig();", "stack.config.ts")).toThrow(/Unsupported config expression/);
|
|
});
|
|
|
|
import.meta.vitest?.test("renderConfigFileContent rejects conflicting dotted keys", ({ expect }) => {
|
|
expect(() => renderConfigFileContent({
|
|
"a.b": 1,
|
|
"a.b.c": 2,
|
|
})).toThrowError(/conflicting keys.*"a\.b\.c"/);
|
|
});
|
|
|
|
import.meta.vitest?.test("renderConfigFileContent rejects invalid config exports", ({ expect }) => {
|
|
expect(() => renderConfigFileContent(null)).toThrowErrorMatchingInlineSnapshot(
|
|
`[Error: Invalid config: expected a plain object.]`,
|
|
);
|
|
});
|
|
|
|
import.meta.vitest?.test("renderConfigFileContent uses custom import package", ({ expect }) => {
|
|
const content = renderConfigFileContent({}, "@stackframe/stack");
|
|
expect(content).toContain('import type { StackConfig } from "@stackframe/stack";');
|
|
});
|
|
|
|
import.meta.vitest?.test("renderConfigFileContent defaults to @stackframe/js", ({ expect }) => {
|
|
const content = renderConfigFileContent({});
|
|
expect(content).toContain('import type { StackConfig } from "@stackframe/js";');
|
|
});
|
|
|
|
import.meta.vitest?.test("detectStackframeImportPackage picks first matching package by priority", ({ expect }) => {
|
|
expect(detectStackframeImportPackage(["@stackframe/stack", "@stackframe/js"])).toBe("@stackframe/stack");
|
|
expect(detectStackframeImportPackage(["@stackframe/react", "@stackframe/js"])).toBe("@stackframe/react");
|
|
expect(detectStackframeImportPackage(["@stackframe/js"])).toBe("@stackframe/js");
|
|
expect(detectStackframeImportPackage(["@stackframe/template"])).toBe("@stackframe/template");
|
|
expect(detectStackframeImportPackage(["lodash", "express"])).toBeUndefined();
|
|
expect(detectStackframeImportPackage([])).toBeUndefined();
|
|
});
|