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 -->
121 lines
4.6 KiB
TypeScript
121 lines
4.6 KiB
TypeScript
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<string, unknown> | typeof showOnboardingStackConfigValue;
|
|
|
|
function unwrapStaticConfigExpression(expression: t.Expression): t.Expression {
|
|
if (
|
|
t.isTSAsExpression(expression)
|
|
|| t.isTSSatisfiesExpression(expression)
|
|
|| t.isTSTypeAssertion(expression)
|
|
|| t.isTSNonNullExpression(expression)
|
|
) {
|
|
return unwrapStaticConfigExpression(expression.expression);
|
|
}
|
|
return expression;
|
|
}
|
|
|
|
function evaluateStaticConfigExpression(expression: t.Expression): unknown {
|
|
const unwrapped = unwrapStaticConfigExpression(expression);
|
|
if (t.isStringLiteral(unwrapped)) return unwrapped.value;
|
|
if (t.isBooleanLiteral(unwrapped)) return unwrapped.value;
|
|
if (t.isNumericLiteral(unwrapped)) return unwrapped.value;
|
|
if (t.isNullLiteral(unwrapped)) return null;
|
|
if (t.isIdentifier(unwrapped) && unwrapped.name === "undefined") return undefined;
|
|
if (t.isUnaryExpression(unwrapped) && unwrapped.operator === "-" && t.isNumericLiteral(unwrapped.argument)) {
|
|
return -unwrapped.argument.value;
|
|
}
|
|
if (t.isArrayExpression(unwrapped)) {
|
|
return unwrapped.elements.map((element) => {
|
|
if (element == null || t.isSpreadElement(element)) {
|
|
throw new Error("Config arrays cannot contain holes or spreads.");
|
|
}
|
|
return evaluateStaticConfigExpression(element);
|
|
});
|
|
}
|
|
if (t.isObjectExpression(unwrapped)) {
|
|
const result: Record<string, unknown> = {};
|
|
for (const property of unwrapped.properties) {
|
|
if (t.isSpreadElement(property)) {
|
|
throw new Error("Config objects cannot contain spreads.");
|
|
}
|
|
if (property.computed) {
|
|
throw new Error("Config object keys cannot be computed.");
|
|
}
|
|
const key = t.isIdentifier(property.key)
|
|
? property.key.name
|
|
: t.isStringLiteral(property.key) || t.isNumericLiteral(property.key)
|
|
? String(property.key.value)
|
|
: null;
|
|
if (key == null) {
|
|
throw new Error("Unsupported config object key.");
|
|
}
|
|
if (t.isObjectMethod(property)) {
|
|
throw new Error("Config objects cannot contain methods.");
|
|
}
|
|
if (!t.isExpression(property.value)) {
|
|
throw new Error("Unsupported config object value.");
|
|
}
|
|
result[key] = evaluateStaticConfigExpression(property.value);
|
|
}
|
|
return result;
|
|
}
|
|
throw new Error(`Unsupported config expression: ${unwrapped.type}`);
|
|
}
|
|
|
|
export function parseStackConfigFileContent(content: string, filePath: string): ParsedStackConfig {
|
|
if (content.trim() === "") return {};
|
|
const ast = parser.parse(content, {
|
|
sourceType: "module",
|
|
plugins: ["typescript"],
|
|
});
|
|
|
|
for (const statement of ast.program.body) {
|
|
if (!t.isExportNamedDeclaration(statement) || !t.isVariableDeclaration(statement.declaration)) {
|
|
continue;
|
|
}
|
|
for (const declaration of statement.declaration.declarations) {
|
|
if (!t.isIdentifier(declaration.id) || declaration.id.name !== "config") {
|
|
continue;
|
|
}
|
|
if (declaration.init == null || !t.isExpression(declaration.init)) {
|
|
throw new Error(`Config export in ${filePath} must have an initializer.`);
|
|
}
|
|
return evaluateStaticConfigExpression(declaration.init) as ParsedStackConfig;
|
|
}
|
|
}
|
|
|
|
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
|
|
}
|