mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge branch 'dev' into clickhouse-sync-improve-test-speed
This commit is contained in:
commit
82fabef6b6
@ -108,6 +108,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
|
||||
- Any design components you add or modify in the dashboard, update the Playground page accordingly to showcase the changes.
|
||||
- Unless very clearly equivalent from types, prefer explicit null/undefinedness checks over boolean checks, eg. `foo == null` instead of `!foo`.
|
||||
- Ensure **aggressively** that all code has low coupling and high cohesion. This is really important as it makes sure our code remains consistent and maintainable. Eagerly refactor things into better abstractions and look out for them actively.
|
||||
- Whenever you change the URL of a page in the docs (or remove one), add a redirect in the docs-mintlify/docs.json file to make sure we don't lose any SEO juice.
|
||||
|
||||
### Code-related
|
||||
- Use ES6 maps instead of records wherever you can.
|
||||
|
||||
@ -77,7 +77,7 @@ describe("local emulator config", () => {
|
||||
await writeConfigToFile(absoluteFilePath, { auth: { allowLocalhost: true } });
|
||||
|
||||
await expect(fs.readFile(mountedFilePath, "utf-8")).resolves.toBe(
|
||||
`export const config = {\n "auth": {\n "allowLocalhost": true\n }\n};\n`
|
||||
`import type { StackConfig } from "@stackframe/js";\n\nexport const config: StackConfig = {\n "auth": {\n "allowLocalhost": true\n }\n};\n`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { globalPrismaClient } from "@/prisma-client";
|
||||
import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering";
|
||||
import { isValidConfig } from "@stackframe/stack-shared/dist/config/format";
|
||||
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
@ -97,6 +98,7 @@ export async function writeConfigToFile(filePath: string, config: Record<string,
|
||||
} else {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
const content = `export const config = ${JSON.stringify(config, null, 2)};\n`;
|
||||
const importPackage = detectImportPackageFromDir(dir);
|
||||
const content = renderConfigFileContent(config, importPackage);
|
||||
await fs.writeFile(resolvedPath, content, "utf-8");
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
NEXT_PUBLIC_STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02
|
||||
NEXT_PUBLIC_STACK_DOCS_BASE_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}04
|
||||
NEXT_PUBLIC_STACK_DOCS_BASE_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}26
|
||||
NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX=.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09
|
||||
NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=false
|
||||
|
||||
|
||||
@ -174,7 +174,7 @@
|
||||
},
|
||||
{
|
||||
name: "Docs",
|
||||
portSuffix: "04",
|
||||
portSuffix: "26",
|
||||
description: [
|
||||
"Src: ./docs",
|
||||
"Prod: https://docs.stack-auth.com",
|
||||
@ -182,6 +182,15 @@
|
||||
img: "https://www.svgrepo.com/show/448400/docs.svg",
|
||||
importance: 2,
|
||||
},
|
||||
{
|
||||
name: "Mintlify docs",
|
||||
portSuffix: "04",
|
||||
description: [
|
||||
"Src: ./docs-mintlify",
|
||||
],
|
||||
img: "https://www.svgrepo.com/show/448400/docs.svg",
|
||||
importance: 2,
|
||||
},
|
||||
{
|
||||
name: "Hosted Components",
|
||||
portSuffix: "09",
|
||||
|
||||
@ -30,6 +30,7 @@ async function switchToLocalEmulatorProject() {
|
||||
superSecretAdminKey: response.body.super_secret_admin_key,
|
||||
},
|
||||
});
|
||||
return filePath;
|
||||
}
|
||||
|
||||
describe("local emulator config restrictions", () => {
|
||||
@ -96,7 +97,7 @@ describe("local emulator config restrictions", () => {
|
||||
});
|
||||
|
||||
it.runIf(isLocalEmulator)("keeps branch override updates enabled", async ({ expect }) => {
|
||||
await switchToLocalEmulatorProject();
|
||||
const filePath = await switchToLocalEmulatorProject();
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/internal/config/override/branch", {
|
||||
method: "PATCH",
|
||||
@ -110,5 +111,18 @@ describe("local emulator config restrictions", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toStrictEqual({ success: true });
|
||||
|
||||
const fileContent = await fs.readFile(filePath, "utf-8");
|
||||
expect(fileContent).toMatchInlineSnapshot(`
|
||||
deindent\`
|
||||
import type { StackConfig } from "@stackframe/js";
|
||||
|
||||
export const config: StackConfig = {
|
||||
"teams": {
|
||||
"allowClientTeamCreation": true
|
||||
}
|
||||
};
|
||||
\` + "\\n"
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@ -85,7 +85,13 @@ describe("local emulator project endpoint", () => {
|
||||
expect(JSON.parse(response.body.branch_config_override_string)).toEqual({});
|
||||
|
||||
const fileContent = await fs.readFile(filePath, "utf-8");
|
||||
expect(fileContent).toContain("export const config");
|
||||
expect(fileContent).toMatchInlineSnapshot(`
|
||||
deindent\`
|
||||
import type { StackConfig } from "@stackframe/js";
|
||||
|
||||
export const config: StackConfig = {};
|
||||
\` + "\\n"
|
||||
`);
|
||||
});
|
||||
|
||||
it.runIf(isLocalEmulator)("creates path-based projects, reuses mappings, and returns valid credentials", async ({ expect }) => {
|
||||
|
||||
@ -10,6 +10,14 @@ import { it, niceFetch, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_CLIENT_KE
|
||||
const CLI_BIN = path.resolve("packages/stack-cli/dist/index.js");
|
||||
const CLI_SRC_BIN = path.resolve("packages/stack-cli/src/index.ts");
|
||||
|
||||
function extractConfigObjectString(content: string): string {
|
||||
const configMatch = content.match(/export const config:\s*StackConfig\s*=\s*(.+);\s*$/s);
|
||||
if (!configMatch) {
|
||||
throw new Error(`Could not extract config object from file:\n${content}`);
|
||||
}
|
||||
return configMatch[1];
|
||||
}
|
||||
|
||||
function runCli(
|
||||
args: string[],
|
||||
envOverrides?: Record<string, string>,
|
||||
@ -308,13 +316,14 @@ describe("Stack CLI", () => {
|
||||
it("config pull writes a .ts file", async ({ expect }) => {
|
||||
configTsPath = path.join(tmpDir, "config.ts");
|
||||
const { stdout, exitCode } = await runCli(
|
||||
["config", "pull", "--config-file", configTsPath],
|
||||
["config", "pull", "--config-file", configTsPath, "--overwrite"],
|
||||
{ STACK_PROJECT_ID: createdProjectId },
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Config written to");
|
||||
const content = fs.readFileSync(configTsPath, "utf-8");
|
||||
expect(content).toContain("export const config");
|
||||
expect(content).toContain('import type { StackConfig } from "@stackframe/js";');
|
||||
expect(content).toContain("export const config: StackConfig");
|
||||
});
|
||||
|
||||
it("config push succeeds", async ({ expect }) => {
|
||||
@ -334,7 +343,7 @@ describe("Stack CLI", () => {
|
||||
{ STACK_PROJECT_ID: createdProjectId },
|
||||
);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain(".js or .ts");
|
||||
expect(stderr).toContain(".ts extension");
|
||||
});
|
||||
|
||||
it("config push rejects array config export", async ({ expect }) => {
|
||||
@ -348,6 +357,19 @@ describe("Stack CLI", () => {
|
||||
expect(stderr).toContain("plain `config` object");
|
||||
});
|
||||
|
||||
it("config pull rejects overwriting an existing file without --overwrite", async ({ expect }) => {
|
||||
const existingConfigPath = path.join(tmpDir, "existing-config.ts");
|
||||
fs.writeFileSync(existingConfigPath, "existing\n");
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
["config", "pull", "--config-file", existingConfigPath],
|
||||
{ STACK_PROJECT_ID: createdProjectId },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("re-run with --overwrite");
|
||||
});
|
||||
|
||||
// --- init command tests ---
|
||||
|
||||
// TODO: Re-enable these create-mode tests once init mode handling is finalized.
|
||||
@ -363,12 +385,16 @@ describe("Stack CLI", () => {
|
||||
expect(stdout).toContain("Config file written to");
|
||||
|
||||
const content = fs.readFileSync(path.join(initDir, "stack.config.ts"), "utf-8");
|
||||
expect(content).toContain("export const config");
|
||||
const configMatch = content.match(/export const config = (.+);/s);
|
||||
expect(configMatch).toBeTruthy();
|
||||
const parsed = JSON.parse(configMatch![1]);
|
||||
expect(parsed.apps.installed.authentication).toEqual({ enabled: true });
|
||||
expect(parsed.apps.installed.teams).toEqual({ enabled: true });
|
||||
expect(content).toContain('import type { StackConfig } from "@stackframe/js";');
|
||||
expect(content).toContain("export const config: StackConfig");
|
||||
expect(JSON.parse(extractConfigObjectString(content))).toMatchObject({
|
||||
apps: {
|
||||
installed: {
|
||||
authentication: { enabled: true },
|
||||
teams: { enabled: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.skip("init create with single app", async ({ expect }) => {
|
||||
@ -382,9 +408,14 @@ describe("Stack CLI", () => {
|
||||
expect(stdout).toContain("Config file written to");
|
||||
|
||||
const content = fs.readFileSync(path.join(initDir, "stack.config.ts"), "utf-8");
|
||||
const configMatch = content.match(/export const config = (.+);/s);
|
||||
const parsed = JSON.parse(configMatch![1]);
|
||||
expect(Object.keys(parsed.apps.installed)).toEqual(["authentication"]);
|
||||
expect(JSON.parse(extractConfigObjectString(content))).toMatchObject({
|
||||
apps: {
|
||||
installed: {
|
||||
authentication: { enabled: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(content).not.toContain('"teams"');
|
||||
});
|
||||
|
||||
it("init link-config with valid path", async ({ expect }) => {
|
||||
|
||||
@ -161,3 +161,6 @@ A: Use `signed_up_at` (OIDC-style naming) in access tokens and encode it as Unix
|
||||
|
||||
Q: Where should new globally searchable Cmd+K destinations be added in the dashboard?
|
||||
A: Add project-level shortcuts to `PROJECT_SHORTCUTS` in `apps/dashboard/src/components/cmdk-commands.tsx` (optionally gated with `requiredApps`), and for app subpages rely on the flattened `appFrontend.navigationItems` command generation in the same file so pages are directly searchable without nested preview navigation.
|
||||
|
||||
Q: Which port suffixes are assigned to the two local docs sites?
|
||||
A: `docs` (old docs app) uses suffix `26`, and `docs-mintlify` uses suffix `04`. Keep these in sync across `docs/package.json`, `docs-mintlify/package.json`, `apps/dev-launchpad/public/index.html`, and `apps/dashboard/.env.development` (`NEXT_PUBLIC_STACK_DOCS_BASE_URL` points to old docs on `26`).
|
||||
|
||||
59
docs-mintlify/README.md
Normal file
59
docs-mintlify/README.md
Normal file
@ -0,0 +1,59 @@
|
||||
# docs-mintlify
|
||||
|
||||
How to run the Mintlify docs preview locally from this repository.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js `>=20.17.0`
|
||||
- `pnpm`
|
||||
- Repository dependencies installed (`pnpm install` from repo root)
|
||||
|
||||
## Run locally
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
pnpm -C docs-mintlify run dev
|
||||
```
|
||||
|
||||
This starts Mintlify in `docs-mintlify` on `http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}04` (for example, `http://localhost:8104` with the default prefix).
|
||||
|
||||
From inside `docs-mintlify`, you can also run:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Useful variants:
|
||||
|
||||
```bash
|
||||
# Override the default port
|
||||
pnpm -C docs-mintlify run dev -- --port 3333
|
||||
|
||||
# Skip OpenAPI processing for faster iteration
|
||||
pnpm -C docs-mintlify run dev -- --disable-openapi
|
||||
```
|
||||
|
||||
## Search + assistant in local preview
|
||||
|
||||
If you want local search and the Mintlify assistant:
|
||||
|
||||
```bash
|
||||
pnpm -C docs-mintlify run login
|
||||
pnpm -C docs-mintlify run status
|
||||
```
|
||||
|
||||
Then re-run `pnpm -C docs-mintlify run dev`.
|
||||
|
||||
## Package scripts
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
pnpm -C docs-mintlify run lint
|
||||
pnpm -C docs-mintlify run typecheck
|
||||
pnpm -C docs-mintlify run build
|
||||
pnpm -C docs-mintlify run clean
|
||||
```
|
||||
|
||||
`lint` runs both `mint validate` and `mint broken-links`.
|
||||
@ -42,14 +42,10 @@
|
||||
},
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Home",
|
||||
"pages": ["index"]
|
||||
},
|
||||
{
|
||||
"tab": "Documentation",
|
||||
"pages": [
|
||||
"docs/overview",
|
||||
"index",
|
||||
"docs/faq",
|
||||
{
|
||||
"group": "Getting Started",
|
||||
@ -597,5 +593,11 @@
|
||||
"metatags": {
|
||||
"robots": "noindex"
|
||||
}
|
||||
}
|
||||
},
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/docs/overview",
|
||||
"destination": "/"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
---
|
||||
mode: "custom"
|
||||
title: "Welcome"
|
||||
description: "Stack Auth documentation for setup, components, SDK usage, and REST APIs."
|
||||
---
|
||||
|
||||
18
docs-mintlify/package.json
Normal file
18
docs-mintlify/package.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@stackframe/docs-mintlify",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "mint build",
|
||||
"dev": "mint dev --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}04 --no-open",
|
||||
"typecheck": "mint validate",
|
||||
"lint": "mint validate && mint broken-links",
|
||||
"clean": "node -e \"require('node:fs').rmSync('.mintlify', { recursive: true, force: true })\"",
|
||||
"login": "mint login",
|
||||
"status": "mint status"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mint": "^4.2.487"
|
||||
},
|
||||
"packageManager": "pnpm@10.23.0"
|
||||
}
|
||||
@ -8,8 +8,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}04",
|
||||
"start": "next start --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}04",
|
||||
"dev": "next dev --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}26",
|
||||
"start": "next start --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}26",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
|
||||
@ -51,7 +51,7 @@
|
||||
"db:migrate": "pnpm pre && pnpm run --filter=@stackframe/backend db:migrate",
|
||||
"fern": "pnpm pre && pnpm run --filter=@stackframe/docs fern",
|
||||
"dev:full": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"turbo run dev --concurrency 99999\"",
|
||||
"dev": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"pnpm run generate-openapi-docs:watch\" \"turbo run dev --concurrency 99999 --filter=./apps/* --filter=@stackframe/stack-docs --filter=./packages/* --filter=./examples/demo \"",
|
||||
"dev": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"pnpm run generate-openapi-docs:watch\" \"turbo run dev --concurrency 99999 --filter=./apps/* --filter=@stackframe/docs-mintlify --filter=./packages/* --filter=./examples/demo \"",
|
||||
"dev:tui": "pnpm pre && (trap 'kill 0' EXIT; pnpm run generate-sdks:watch & pnpm run generate-openapi-docs:watch & turbo run dev --ui tui --concurrency 99999 --filter=./apps/* --filter=@stackframe/stack-docs --filter=./packages/* --filter=./examples/demo)",
|
||||
"dev:inspect": "pnpm pre && STACK_BACKEND_DEV_EXTRA_ARGS=\"--inspect\" pnpm run dev",
|
||||
"dev:profile": "pnpm pre && STACK_BACKEND_DEV_EXTRA_ARGS=\"--experimental-cpu-prof\" pnpm run dev",
|
||||
|
||||
@ -4,6 +4,7 @@ import * as fs from "fs";
|
||||
import { resolveAuth } from "../lib/auth.js";
|
||||
import { getAdminProject } from "../lib/app.js";
|
||||
import { CliError } from "../lib/errors.js";
|
||||
import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering";
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
@ -21,7 +22,8 @@ export function registerConfigCommand(program: Command) {
|
||||
config
|
||||
.command("pull")
|
||||
.description("Pull branch config to a local file")
|
||||
.requiredOption("--config-file <path>", "Path to write config file (.js or .ts)")
|
||||
.requiredOption("--config-file <path>", "Path to write config file (.ts)")
|
||||
.option("--overwrite", "Overwrite an existing config file")
|
||||
.action(async (opts) => {
|
||||
const flags = program.opts();
|
||||
const auth = resolveAuth(flags);
|
||||
@ -31,14 +33,16 @@ export function registerConfigCommand(program: Command) {
|
||||
const filePath = path.resolve(opts.configFile);
|
||||
const ext = path.extname(filePath);
|
||||
|
||||
if (ext !== ".js" && ext !== ".ts") {
|
||||
throw new CliError("Config file must have a .js or .ts extension.");
|
||||
if (ext !== ".ts") {
|
||||
throw new CliError("Config file must have a .ts extension. Typed config files require TypeScript.");
|
||||
}
|
||||
|
||||
const json = JSON.stringify(configOverride, null, 2);
|
||||
const content = ext === ".ts"
|
||||
? `export const config = ${json} as const;\n`
|
||||
: `export const config = ${json};\n`;
|
||||
if (fs.existsSync(filePath) && !opts.overwrite) {
|
||||
throw new CliError(`Config file already exists at ${filePath}. Stage or back up your changes, then re-run with --overwrite.`);
|
||||
}
|
||||
|
||||
const importPackage = detectImportPackageFromDir(path.dirname(filePath));
|
||||
const content = renderConfigFileContent(configOverride, importPackage);
|
||||
|
||||
fs.writeFileSync(filePath, content);
|
||||
console.log(`Config written to ${filePath}`);
|
||||
@ -64,18 +68,14 @@ export function registerConfigCommand(program: Command) {
|
||||
throw new CliError(`Config file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
let configModule: { config?: unknown };
|
||||
if (ext === ".ts") {
|
||||
const { createJiti } = await import("jiti");
|
||||
const jiti = createJiti(import.meta.url);
|
||||
configModule = await jiti.import(filePath);
|
||||
} else {
|
||||
configModule = await import(filePath);
|
||||
}
|
||||
const { createJiti } = await import("jiti");
|
||||
const jiti = createJiti(import.meta.url);
|
||||
const configModule: { config?: unknown } = await jiti.import(filePath);
|
||||
|
||||
const config = configModule.config;
|
||||
if (!isPlainObject(config)) {
|
||||
throw new CliError("Config file must export a plain `config` object. Example: export const config = { ... };");
|
||||
const examplePkg = detectImportPackageFromDir(path.dirname(filePath)) ?? "@stackframe/js";
|
||||
throw new CliError(`Config file must export a plain \`config\` object. Example: import type { StackConfig } from "${examplePkg}"; export const config: StackConfig = { ... };`);
|
||||
}
|
||||
|
||||
await project.replaceConfigOverride("branch", config);
|
||||
|
||||
@ -11,6 +11,7 @@ import { CliError, AuthError } from "../lib/errors.js";
|
||||
import { isNonInteractiveEnv } from "../lib/interactive.js";
|
||||
import { createInitPrompt } from "../lib/init-prompt.js";
|
||||
import { runClaudeAgent } from "../lib/claude-agent.js";
|
||||
import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering";
|
||||
|
||||
type InitOptions = {
|
||||
mode?: "create" | "link-config" | "link-cloud",
|
||||
@ -294,7 +295,8 @@ async function handleCreate(opts: InitOptions, outputDir: string): Promise<{ con
|
||||
},
|
||||
};
|
||||
|
||||
const content = `export const config = ${JSON.stringify(config, null, 2)};\n`;
|
||||
const importPackage = detectImportPackageFromDir(path.dirname(configPath));
|
||||
const content = renderConfigFileContent(config, importPackage);
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, content);
|
||||
|
||||
|
||||
@ -24,6 +24,13 @@
|
||||
},
|
||||
"default": "./dist/esm/index.js"
|
||||
},
|
||||
"./config": {
|
||||
"types": "./dist/config-authoring.d.ts",
|
||||
"require": {
|
||||
"default": "./dist/config-authoring.js"
|
||||
},
|
||||
"default": "./dist/esm/config-authoring.js"
|
||||
},
|
||||
"./dist/*": {
|
||||
"types": "./dist/*.d.ts",
|
||||
"require": {
|
||||
|
||||
49
packages/stack-shared/src/config-authoring.test.ts
Normal file
49
packages/stack-shared/src/config-authoring.test.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { typeAssertExtends } from "./utils/types";
|
||||
import { defineStackConfig, type StackConfig } from "./config-authoring";
|
||||
|
||||
const validConfig = defineStackConfig({
|
||||
payments: {
|
||||
items: {
|
||||
todos: {
|
||||
displayName: "Todo Slots",
|
||||
customerType: "user",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
typeAssertExtends<typeof validConfig, StackConfig>()();
|
||||
|
||||
it("returns its input unchanged", () => {
|
||||
expect(defineStackConfig(validConfig)).toBe(validConfig);
|
||||
});
|
||||
|
||||
defineStackConfig({
|
||||
// @ts-expect-error Top-level dot notation should not be accepted in typed config files.
|
||||
"payments.items": {
|
||||
todos: {
|
||||
displayName: "Todo Slots",
|
||||
customerType: "user",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
defineStackConfig({
|
||||
payments: {
|
||||
// @ts-expect-error Unknown keys should not be accepted in typed config files.
|
||||
missingField: true,
|
||||
},
|
||||
});
|
||||
|
||||
defineStackConfig({
|
||||
payments: {
|
||||
items: {
|
||||
todos: {
|
||||
displayName: "Todo Slots",
|
||||
// @ts-expect-error Invalid enum values should fail type-checking.
|
||||
customerType: "workspace",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
22
packages/stack-shared/src/config-authoring.ts
Normal file
22
packages/stack-shared/src/config-authoring.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { BranchConfigNormalizedOverride } from "./config/schema";
|
||||
|
||||
export type StackConfig = BranchConfigNormalizedOverride;
|
||||
|
||||
type StrictConfigShape<Actual, Expected> =
|
||||
Expected extends readonly unknown[]
|
||||
? Actual extends readonly unknown[]
|
||||
? { [K in keyof Actual]: K extends keyof Expected ? StrictConfigShape<Actual[K], Expected[K]> : never }
|
||||
: Actual
|
||||
: Expected extends object
|
||||
? Actual extends object
|
||||
? Exclude<keyof Actual, keyof Expected> extends never
|
||||
? { [K in keyof Actual]: K extends keyof Expected ? StrictConfigShape<Actual[K], Expected[K]> : never }
|
||||
: never
|
||||
: Actual
|
||||
: Actual;
|
||||
|
||||
type StrictStackConfig<T extends StackConfig> = T & StrictConfigShape<T, StackConfig>;
|
||||
|
||||
export function defineStackConfig<const T extends StackConfig>(config: StrictStackConfig<T>): T {
|
||||
return config;
|
||||
}
|
||||
124
packages/stack-shared/src/config-rendering.ts
Normal file
124
packages/stack-shared/src/config-rendering.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { isValidConfig, normalize } from "./config/format";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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,
|
||||
* 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;
|
||||
}
|
||||
|
||||
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",
|
||||
"payments.items.todos.customerType": "user",
|
||||
})).toContain(`export const config: StackConfig = {
|
||||
"payments": {
|
||||
"items": {
|
||||
"todos": {
|
||||
"displayName": "Todo Slots",
|
||||
"customerType": "user"
|
||||
}
|
||||
}
|
||||
}
|
||||
};`);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@ -1,5 +1,7 @@
|
||||
export * from './lib/stack-app';
|
||||
export { getConvexProvidersConfig } from "./integrations/convex";
|
||||
export type { StackConfig } from "@stackframe/stack-shared/config";
|
||||
export { defineStackConfig } from "@stackframe/stack-shared/config";
|
||||
|
||||
// IF_PLATFORM react-like
|
||||
export type { AnalyticsOptions, AnalyticsReplayOptions } from "./lib/stack-app/apps/implementations/session-replay";
|
||||
|
||||
4418
pnpm-lock.yaml
4418
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,7 @@ packages:
|
||||
- apps/*
|
||||
- examples/*
|
||||
- docs
|
||||
- docs-mintlify
|
||||
- sdks/*
|
||||
- sdks/implementations/*
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user