mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
## Summary
Completes the env-var side of the Hexclave rebrand: every
`STACK_*`-prefixed variable (including `NEXT_PUBLIC_STACK_*` and
`VITE_STACK_*`) is renamed to `HEXCLAVE_*` across all checked-in `.env`,
`.env.development`, and `.env.example` files (30 files, ~135 keys).
Legacy `STACK_*` names keep working everywhere via dual-read, so
**existing deployments, `.env.local` files, and self-hosted setups need
no immediate migration**.
## How legacy names keep working
- **Server code** already resolves `HEXCLAVE_*` first with `STACK_*`
fallback via `getEnvVariable`. Direct `process.env.STACK_X` readers fed
by the renamed files (prisma seed, e2e tests/helpers, internal-tool
scripts, examples, `prisma.config.ts`) now read `HEXCLAVE_X || STACK_X`.
- **Client code** (Next.js build-time inlining) uses literal dual-read
expressions; the dashboard's `_inlineEnvVars` already had them.
- **Docker/self-hosting**: `docker/server/entrypoint.sh` (shared by the
server and local-emulator images) gets a generic two-way
`HEXCLAVE_`↔`STACK_` env mirror — runs at startup and again before
sentinel replacement — replacing the previous URL-trio-only mirror.
Operators can use either prefix.
## The empty-placeholder trap (`||` vs `??`)
The checked-in templates define empty placeholders (`HEXCLAVE_X=#
comment` parses to `""` via dotenv). With `?? `-based fallbacks, that
empty string would silently shadow a real value under the legacy name —
including legacy vars set in Vercel/CI env at build time, since the
tracked `.env` is present during builds. All fallback chains therefore
treat empty-as-unset (`||`):
- `getEnvVariable` and `getProcessEnv` in `packages/shared`
- the dashboard/docs/example literal dual-reads
- the generated SDK env getters (via
`packages/template/scripts/generate-env.ts`; the generated
`src/generated/env.ts` files are gitignored and regenerate at build)
## Other notable changes
- Tests that override env now set the canonical `HEXCLAVE_*` name (it
wins over `STACK_*`): e2e `cross-domain-auth`, backend
`internal-feedback-emails` in-source test.
- e2e `helpers.ts` port-prefix expansion loop also matches the
`HEXCLAVE_` prefixes.
- `docker/local-emulator/generate-env-development.mjs` reads source keys
canonically (legacy fallback) and emits canonical keys; regenerated
output matches.
- `rotate-secrets.sh` falls back to
`HEXCLAVE_DATABASE_CONNECTION_STRING`.
- Docs code snippets (`docs/code-examples`) renamed outright to
canonical names, consistent with #1571.
- OAuth callback `console.warn` in `packages/template/src/lib/auth.ts`
now says Hexclave.
## Migration note for the team
Local `.env.local` files with legacy `STACK_*` overrides keep working
**unless** the override targets a var that `.env.development` now sets
to a real (non-empty) `HEXCLAVE_*` value — the canonical name wins over
file precedence. Rename those keys in your `.env.local` once.
## Verification
- `typecheck` + `lint` pass on every touched package (shared, backend,
dashboard, e2e, internal-tool, cli, docs, template). Pre-existing
failures on dev (`admin-app-impl.ts` typecheck, dashboard metrics-page
errors) are unchanged (identical error counts with/without this change).
- `getEnvVariable`/`getProcessEnv` fallback semantics smoke-tested
directly (empty-HEXCLAVE → legacy fallback, HEXCLAVE wins when set,
defaults intact).
- `internal-feedback-emails` in-source vitest passes; emulator env
generator `--check` passes; `bash -n` on touched shell scripts.
- Two independent review agents audited the diff for correctness bugs
and coverage gaps; all confirmed findings are fixed in the third commit.
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Renamed all `STACK_*` env vars (including
`NEXT_PUBLIC_STACK_*`/`VITE_STACK_*`) to `HEXCLAVE_*` across env
templates and code, with dual‑read that treats empty as unset, detects
conflicts, ignores post‑build sentinels, and falls back to legacy names.
All GitHub Actions now use `HEXCLAVE_*`; local‑emulator e2e is fixed by
setting `NEXT_PUBLIC_HEXCLAVE_IS_LOCAL_EMULATOR` in CI.
- **Refactors**
- Added conflict‑aware dual‑read helpers (prefer `HEXCLAVE_*`,
empty‑as‑unset, ignore post‑build sentinels, preserve empty passthrough)
and used them across `packages/shared` (resolver + tests),
`apps/dashboard` inline/public envs (with tests), `apps/backend` Prisma
config/seed and vitest (accept both prefixes), `packages/cli`
(API/Dashboard URLs, project ID, `HEXCLAVE_EMULATOR_HOME`; tests),
Docker (`entrypoint.sh` mirroring + `rotate-secrets.sh` DB URL),
docs/components (`docs/src/lib/env.ts`), and examples; hosted/Vite apps
now error if both spellings differ.
- Port‑prefix expansion includes `HEXCLAVE_*`; backend tests use a new
helper to resolve DB connection strings; Prisma prefers
`HEXCLAVE_DATABASE_CONNECTION_STRING` with legacy fallback.
- Generated SDK env getters use plain `HEXCLAVE_*` || `STACK_*` (no
conflict throw); dashboard inline resolver preserves empty/sentinel
passthrough to avoid build failures; docs/examples include dual‑read
utilities.
- Tests now stub canonical `HEXCLAVE_*` flags (e.g., plan limits, bot
challenge, OAuth tokens, hosted handler) to avoid shadowing/conflict
with committed defaults.
- **Migration**
- No immediate action; legacy `STACK_*` names still work.
- If both names are set with different values, builds/scripts error. Set
only `HEXCLAVE_*` or make both equal.
- SDK consumers won’t see conflict throws; update env names to
`HEXCLAVE_*` over time.
<sup>Written for commit 7539fb9fbf.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1588?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Chores**
* Migrated environment variable names from the legacy `STACK_*` prefix
to the new `HEXCLAVE_*` prefix across backend, dashboard, tooling,
Docker, and examples.
* Updated environment/config resolution to prefer `HEXCLAVE_*`, treat
empty strings as unset, and detect conflicts when both `STACK_*` and
`HEXCLAVE_*` are set to different values.
* Updated local emulator, server startup, and env-generation workflows
to use the new names (with legacy fallback where applicable).
* **Documentation**
* Updated docs and code examples to reference `HEXCLAVE_*` variables.
* **Tests**
* Refreshed unit and e2e coverage to validate dual-read behavior,
conflict detection, and empty-value handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
1099 lines
44 KiB
TypeScript
1099 lines
44 KiB
TypeScript
import { execFile } from "child_process";
|
|
import * as fs from "fs";
|
|
import * as os from "os";
|
|
import * as path from "path";
|
|
import { StackAdminApp } from "@hexclave/js";
|
|
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
|
|
import { Result } from "@hexclave/shared/dist/utils/results";
|
|
import { describe, beforeAll, afterAll } from "vitest";
|
|
import { it, niceFetch, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_SERVER_KEY, STACK_INTERNAL_PROJECT_ADMIN_KEY } from "../helpers";
|
|
|
|
const isLocalEmulator = getEnvVariable("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "") === "true";
|
|
|
|
const CLI_BIN = path.resolve("packages/cli/dist/index.js");
|
|
|
|
function extractConfigObjectString(content: string): string {
|
|
const configMatch = content.match(/export const config:\s*HexclaveConfig\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>,
|
|
cwd?: string,
|
|
): Promise<{ stdout: string, stderr: string, exitCode: number | null }> {
|
|
return new Promise((resolve) => {
|
|
execFile("node", [CLI_BIN, ...args], {
|
|
env: { ...baseEnv, ...envOverrides },
|
|
cwd,
|
|
timeout: 30_000,
|
|
}, (error, stdout, stderr) => {
|
|
resolve({
|
|
stdout: stdout.toString(),
|
|
stderr: stderr.toString(),
|
|
exitCode: error ? (error as any).code ?? 1 : 0,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
let baseEnv: Record<string, string>;
|
|
let tmpDir: string;
|
|
let configFilePath: string;
|
|
let refreshToken: string;
|
|
|
|
describe("Stack CLI", () => {
|
|
beforeAll(async () => {
|
|
// Check CLI is built
|
|
if (!fs.existsSync(CLI_BIN)) {
|
|
throw new Error("CLI not built. Run `pnpm --filter @hexclave/cli run build` first.");
|
|
}
|
|
|
|
// Create temp dir for config file
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-test-"));
|
|
configFilePath = path.join(tmpDir, "credentials.json");
|
|
|
|
// Create test user on internal project (auto-creates team)
|
|
const internalApp = new StackAdminApp({
|
|
projectId: "internal",
|
|
baseUrl: STACK_BACKEND_BASE_URL,
|
|
publishableClientKey: STACK_INTERNAL_PROJECT_CLIENT_KEY,
|
|
secretServerKey: STACK_INTERNAL_PROJECT_SERVER_KEY,
|
|
superSecretAdminKey: STACK_INTERNAL_PROJECT_ADMIN_KEY,
|
|
tokenStore: "memory",
|
|
redirectMethod: "none",
|
|
});
|
|
|
|
const fakeEmail = `cli-test-${crypto.randomUUID()}@stack-generated.example.com`;
|
|
Result.orThrow(await internalApp.signUpWithCredential({
|
|
email: fakeEmail,
|
|
password: "test-password-123",
|
|
verificationCallbackUrl: "http://localhost:3000",
|
|
}));
|
|
|
|
const user = await internalApp.getUser({ or: "throw" });
|
|
|
|
// Create a session to get a refresh token
|
|
const sessionRes = await niceFetch(`${STACK_BACKEND_BASE_URL}/api/v1/auth/sessions`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-stack-access-type": "server",
|
|
"x-stack-project-id": "internal",
|
|
"x-stack-publishable-client-key": STACK_INTERNAL_PROJECT_CLIENT_KEY,
|
|
"x-stack-secret-server-key": STACK_INTERNAL_PROJECT_SERVER_KEY,
|
|
},
|
|
body: JSON.stringify({
|
|
user_id: user.id,
|
|
expires_in_millis: 1000 * 60 * 60 * 24,
|
|
is_impersonation: false,
|
|
}),
|
|
});
|
|
|
|
if (sessionRes.status !== 200) {
|
|
throw new Error(`Failed to create session: ${sessionRes.status} ${JSON.stringify(sessionRes.body)}`);
|
|
}
|
|
refreshToken = sessionRes.body.refresh_token;
|
|
|
|
// Set base env for CLI
|
|
baseEnv = {
|
|
PATH: process.env.PATH ?? "",
|
|
HOME: process.env.HOME ?? "",
|
|
STACK_API_URL: STACK_BACKEND_BASE_URL,
|
|
STACK_CLI_REFRESH_TOKEN: refreshToken,
|
|
STACK_CLI_PUBLISHABLE_CLIENT_KEY: STACK_INTERNAL_PROJECT_CLIENT_KEY,
|
|
STACK_CLI_CONFIG_PATH: configFilePath,
|
|
CI: "1",
|
|
};
|
|
}, 120_000);
|
|
|
|
afterAll(() => {
|
|
if (tmpDir && fs.existsSync(tmpDir)) {
|
|
fs.rmSync(tmpDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
it("shows help output", async ({ expect }) => {
|
|
const { stdout, exitCode } = await runCli(["--help"]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("Hexclave CLI");
|
|
});
|
|
|
|
it("shows version output", async ({ expect }) => {
|
|
const pkg = JSON.parse(fs.readFileSync(path.resolve("packages/cli/package.json"), "utf-8"));
|
|
const { stdout, exitCode } = await runCli(["--version"]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout.trim()).toBe(pkg.version);
|
|
});
|
|
|
|
it("errors when not logged in", async ({ expect }) => {
|
|
const { stderr, exitCode } = await runCli(["project", "list"], {
|
|
STACK_CLI_REFRESH_TOKEN: "",
|
|
});
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("Not logged in");
|
|
});
|
|
|
|
it("exec errors when neither --cloud-project-id nor --config-file is given", async ({ expect }) => {
|
|
const { stderr, exitCode } = await runCli(["exec", "return 1"]);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("Specify a target");
|
|
});
|
|
|
|
it("exec errors when both --cloud-project-id and --config-file are given", async ({ expect }) => {
|
|
const { stderr, exitCode } = await runCli(["exec", "--cloud-project-id", "proj_x", "--config-file", "./stack.config.ts", "return 1"]);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("not both");
|
|
});
|
|
|
|
it("logout clears config", async ({ expect }) => {
|
|
// Write a fake token to the config file
|
|
fs.writeFileSync(configFilePath, JSON.stringify({ STACK_CLI_REFRESH_TOKEN: "fake-token" }), { mode: 0o600 });
|
|
|
|
const { stdout, exitCode } = await runCli(["logout"]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("Logged out");
|
|
|
|
const content = fs.readFileSync(configFilePath, "utf-8");
|
|
expect(content).not.toContain("fake-token");
|
|
});
|
|
|
|
let createdProjectId: string;
|
|
|
|
it("lists cloud projects as empty JSON array", async ({ expect }) => {
|
|
const { stdout, exitCode } = await runCli(["--json", "project", "list", "--cloud"]);
|
|
expect(exitCode).toBe(0);
|
|
const projects = JSON.parse(stdout);
|
|
expect(Array.isArray(projects)).toBe(true);
|
|
});
|
|
|
|
it("project create requires --cloud", async ({ expect }) => {
|
|
const { stderr, exitCode } = await runCli(["project", "create", "--display-name", "Should Fail"]);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("--cloud to confirm");
|
|
});
|
|
|
|
it("project list rejects --cloud and --dev together", async ({ expect }) => {
|
|
const { stderr, exitCode } = await runCli(["project", "list", "--cloud", "--dev"]);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("not both");
|
|
});
|
|
|
|
it("creates a project", async ({ expect }) => {
|
|
const { stdout, exitCode } = await runCli(["--json", "project", "create", "--cloud", "--display-name", "CLI Test"]);
|
|
expect(exitCode).toBe(0);
|
|
const project = JSON.parse(stdout);
|
|
expect(project).toHaveProperty("id");
|
|
expect(project).toHaveProperty("displayName");
|
|
expect(project.target).toBe("cloud");
|
|
expect(project.displayName).toBe("CLI Test");
|
|
createdProjectId = project.id;
|
|
});
|
|
|
|
it("lists cloud projects including created one with target=cloud", async ({ expect }) => {
|
|
expect(createdProjectId).toBeDefined();
|
|
const { stdout, exitCode } = await runCli(["--json", "project", "list", "--cloud"]);
|
|
expect(exitCode).toBe(0);
|
|
const projects = JSON.parse(stdout);
|
|
const found = projects.find((p: any) => p.id === createdProjectId);
|
|
expect(found).toBeDefined();
|
|
expect(found.displayName).toBe("CLI Test");
|
|
expect(found.target).toBe("cloud");
|
|
});
|
|
|
|
it("project list (no flags) emits a stderr warning when the emulator is unreachable", async ({ expect }) => {
|
|
expect(createdProjectId).toBeDefined();
|
|
// Default (no flags) tries both sources; the dev branch fails because the
|
|
// emulator PCK isn't where the CLI expects. We should still get a 0 exit
|
|
// and cloud results, plus a single stderr warning line.
|
|
const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-list-warn-"));
|
|
try {
|
|
const { stdout, stderr, exitCode } = await runCli(["--json", "project", "list"], {
|
|
STACK_EMULATOR_HOME: fakeEmulatorHome,
|
|
STACK_EMULATOR_READY_TIMEOUT_MS: "0",
|
|
});
|
|
expect(exitCode).toBe(0);
|
|
expect(stderr).toContain("skipping dev projects");
|
|
const projects = JSON.parse(stdout);
|
|
const found = projects.find((p: any) => p.id === createdProjectId);
|
|
expect(found).toBeDefined();
|
|
expect(found.target).toBe("cloud");
|
|
} finally {
|
|
fs.rmSync(fakeEmulatorHome, { recursive: true });
|
|
}
|
|
});
|
|
|
|
it("returns basic expression", async ({ expect }) => {
|
|
expect(createdProjectId).toBeDefined();
|
|
const { stdout, exitCode } = await runCli(
|
|
["exec", "--cloud-project-id", createdProjectId, "return 1+1"],
|
|
);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout.trim()).toBe("2");
|
|
});
|
|
|
|
it("has hexclaveServerApp object available", async ({ expect }) => {
|
|
const { stdout, exitCode } = await runCli(
|
|
["exec", "--cloud-project-id", createdProjectId, "return typeof hexclaveServerApp"],
|
|
);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout.trim()).toBe('"object"');
|
|
});
|
|
|
|
it("exec help mentions docs URL", async ({ expect }) => {
|
|
const { stdout, exitCode } = await runCli(["exec", "--help"]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("https://docs.hexclave.com/sdk/overview");
|
|
});
|
|
|
|
it("exec help mentions --cloud-project-id and --config-file", async ({ expect }) => {
|
|
const { stdout, exitCode } = await runCli(["exec", "--help"]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("--cloud-project-id");
|
|
expect(stdout).toContain("--config-file");
|
|
});
|
|
|
|
it("errors when no javascript is provided", async ({ expect }) => {
|
|
const { stderr, exitCode } = await runCli(["exec", "--cloud-project-id", createdProjectId]);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("Missing JavaScript argument");
|
|
});
|
|
|
|
it("reports syntax error", async ({ expect }) => {
|
|
const { stderr, exitCode } = await runCli(
|
|
["exec", "--cloud-project-id", createdProjectId, "return @@invalid"],
|
|
);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("Syntax error");
|
|
});
|
|
|
|
it("reports runtime error", async ({ expect }) => {
|
|
const { stderr, exitCode } = await runCli(
|
|
["exec", "--cloud-project-id", createdProjectId, "throw new Error('boom')"],
|
|
);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("boom");
|
|
});
|
|
|
|
it("reports string runtime error", async ({ expect }) => {
|
|
const { stderr, exitCode } = await runCli(
|
|
["exec", "--cloud-project-id", createdProjectId, "throw 'boom-string'"],
|
|
);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("boom-string");
|
|
});
|
|
|
|
it("reports object runtime error", async ({ expect }) => {
|
|
const { stderr, exitCode } = await runCli(
|
|
["exec", "--cloud-project-id", createdProjectId, "throw { code: 123 }"],
|
|
);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain('{"code":123}');
|
|
});
|
|
|
|
it("reports undefined variable", async ({ expect }) => {
|
|
const { stderr, exitCode } = await runCli(
|
|
["exec", "--cloud-project-id", createdProjectId, "return nonExistentVar"],
|
|
);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("nonExistentVar");
|
|
});
|
|
|
|
it("returns undefined for no return value", async ({ expect }) => {
|
|
const { stdout, exitCode } = await runCli(
|
|
["exec", "--cloud-project-id", createdProjectId, "const x = 1"],
|
|
);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout.trim()).toBe("");
|
|
});
|
|
|
|
it("returns complex object as JSON", async ({ expect }) => {
|
|
const { stdout, exitCode } = await runCli(
|
|
["exec", "--cloud-project-id", createdProjectId, "return {a: 1, b: [2, 3]}"],
|
|
);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed).toEqual({ a: 1, b: [2, 3] });
|
|
});
|
|
|
|
it("supports async code", async ({ expect }) => {
|
|
const { stdout, exitCode } = await runCli(
|
|
["exec", "--cloud-project-id", createdProjectId, "return await Promise.resolve(42)"],
|
|
);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout.trim()).toBe("42");
|
|
});
|
|
|
|
let createdUserEmail: string;
|
|
|
|
it("can create user with hexclaveServerApp", async ({ expect }) => {
|
|
createdUserEmail = `exec-test-${crypto.randomUUID()}@stack-generated.example.com`;
|
|
const code = `const u = await hexclaveServerApp.createUser({ primaryEmail: "${createdUserEmail}", password: "test123456" }); return { id: u.id, email: u.primaryEmail }`;
|
|
const { stdout, exitCode } = await runCli(
|
|
["exec", "--cloud-project-id", createdProjectId, code],
|
|
);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed).toHaveProperty("id");
|
|
expect(parsed.email).toBe(createdUserEmail);
|
|
});
|
|
|
|
it("can list users with hexclaveServerApp", async ({ expect }) => {
|
|
expect(createdProjectId).toBeDefined();
|
|
expect(createdUserEmail).toBeDefined();
|
|
const { stdout, exitCode } = await runCli(
|
|
["exec", "--cloud-project-id", createdProjectId, "const users = await hexclaveServerApp.listUsers(); return users.length"],
|
|
);
|
|
expect(exitCode).toBe(0);
|
|
const count = JSON.parse(stdout);
|
|
expect(count).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("exec --config-file errors when the config file does not exist", async ({ expect }) => {
|
|
const { stderr, exitCode } = await runCli(
|
|
["exec", "--config-file", path.join(tmpDir, "missing-stack.config.ts"), "return 1"],
|
|
);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("Config file not found");
|
|
});
|
|
|
|
it("exec --config-file errors when emulator PCK file is missing", async ({ expect }) => {
|
|
// The file exists on disk but the emulator PCK file isn't where the CLI
|
|
// expects. PCK lookup fires before any network call so this fails fast.
|
|
const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-fake-emulator-"));
|
|
const configFile = path.join(tmpDir, `cfg-pck-missing-${crypto.randomUUID()}.config.ts`);
|
|
fs.writeFileSync(configFile, "");
|
|
try {
|
|
const { stderr, exitCode } = await runCli(
|
|
["exec", "--config-file", configFile, "return 1"],
|
|
{
|
|
STACK_EMULATOR_HOME: fakeEmulatorHome,
|
|
STACK_EMULATOR_READY_TIMEOUT_MS: "0",
|
|
},
|
|
);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("Development environment publishable client key not found");
|
|
} finally {
|
|
fs.rmSync(fakeEmulatorHome, { recursive: true });
|
|
}
|
|
});
|
|
|
|
it("exec --config-file errors when emulator API is unreachable", async ({ expect }) => {
|
|
// PCK file present but the API URL points at a port nothing is listening
|
|
// on — fetch fails with a clear error. READY_TIMEOUT_MS=0 keeps the retry
|
|
// loop from waiting.
|
|
const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-fake-emulator-"));
|
|
const configFile = path.join(tmpDir, `cfg-unreachable-${crypto.randomUUID()}.config.ts`);
|
|
fs.writeFileSync(configFile, "");
|
|
try {
|
|
const pckDir = path.join(fakeEmulatorHome, "run", "vm");
|
|
fs.mkdirSync(pckDir, { recursive: true });
|
|
fs.writeFileSync(path.join(pckDir, "internal-pck"), "pck_stub_for_test");
|
|
const { stderr, exitCode } = await runCli(
|
|
["exec", "--config-file", configFile, "return 1"],
|
|
{
|
|
STACK_EMULATOR_HOME: fakeEmulatorHome,
|
|
STACK_EMULATOR_API_URL: "http://127.0.0.1:1",
|
|
STACK_EMULATOR_READY_TIMEOUT_MS: "0",
|
|
},
|
|
);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("Cannot reach development environment");
|
|
} finally {
|
|
fs.rmSync(fakeEmulatorHome, { recursive: true });
|
|
}
|
|
});
|
|
|
|
// Positive happy-path: only runs when the backend is in local-emulator mode
|
|
// (the password sign-in for local-emulator@hexclave.com only succeeds
|
|
// there). Mints a project against the local-emulator backend keyed by an
|
|
// absolute config-file path, then runs `stack exec --config-file <path>`
|
|
// and expects it to resolve the same project.
|
|
it.runIf(isLocalEmulator)("exec --config-file runs against the local emulator backend", async ({ expect }) => {
|
|
const emulatorConfigPath = path.join(tmpDir, `stack-emulator-${crypto.randomUUID()}.config.ts`);
|
|
fs.writeFileSync(emulatorConfigPath, "");
|
|
const projectRes = await niceFetch(`${STACK_BACKEND_BASE_URL}/api/v1/internal/local-emulator/project`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-stack-access-type": "server",
|
|
"x-stack-project-id": "internal",
|
|
"x-stack-publishable-client-key": STACK_INTERNAL_PROJECT_CLIENT_KEY,
|
|
"x-stack-secret-server-key": STACK_INTERNAL_PROJECT_SERVER_KEY,
|
|
},
|
|
body: JSON.stringify({ absolute_file_path: emulatorConfigPath }),
|
|
});
|
|
if (projectRes.status !== 200) {
|
|
throw new Error(`Failed to mint local emulator project: ${projectRes.status} ${JSON.stringify(projectRes.body)}`);
|
|
}
|
|
|
|
const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-emu-positive-"));
|
|
try {
|
|
const pckDir = path.join(fakeEmulatorHome, "run", "vm");
|
|
fs.mkdirSync(pckDir, { recursive: true });
|
|
fs.writeFileSync(path.join(pckDir, "internal-pck"), STACK_INTERNAL_PROJECT_CLIENT_KEY);
|
|
const { stdout, stderr, exitCode } = await runCli(
|
|
["exec", "--config-file", emulatorConfigPath, "return 1+1"],
|
|
{
|
|
STACK_EMULATOR_HOME: fakeEmulatorHome,
|
|
STACK_EMULATOR_API_URL: STACK_BACKEND_BASE_URL,
|
|
},
|
|
);
|
|
if (exitCode !== 0) {
|
|
throw new Error(`CLI exited ${exitCode}. stderr: ${stderr}`);
|
|
}
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout.trim()).toBe("2");
|
|
} finally {
|
|
fs.rmSync(fakeEmulatorHome, { recursive: true });
|
|
}
|
|
});
|
|
|
|
let configTsPath: string;
|
|
|
|
it("config pull writes a .ts file", async ({ expect }) => {
|
|
configTsPath = path.join(tmpDir, "config.ts");
|
|
const { stdout, exitCode } = await runCli(
|
|
["config", "pull", "--cloud-project-id", createdProjectId, "--config-file", configTsPath, "--overwrite"],
|
|
);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("Config written to");
|
|
const content = fs.readFileSync(configTsPath, "utf-8");
|
|
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js/config";');
|
|
expect(content).toContain("export const config: HexclaveConfig");
|
|
});
|
|
|
|
it("config push succeeds", async ({ expect }) => {
|
|
expect(configTsPath).toBeDefined();
|
|
const { stdout, exitCode } = await runCli(
|
|
["config", "push", "--cloud-project-id", createdProjectId, "--config-file", configTsPath],
|
|
);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("Config pushed successfully");
|
|
});
|
|
|
|
it("config pull rejects bad extension", async ({ expect }) => {
|
|
const badPath = path.join(tmpDir, "config.json");
|
|
const { stderr, exitCode } = await runCli(
|
|
["config", "pull", "--cloud-project-id", createdProjectId, "--config-file", badPath],
|
|
);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain(".ts extension");
|
|
});
|
|
|
|
it("config push rejects array config export", async ({ expect }) => {
|
|
const badConfigPath = path.join(tmpDir, "config-array.ts");
|
|
fs.writeFileSync(badConfigPath, "export const config = [];\n");
|
|
const { stderr, exitCode } = await runCli(
|
|
["config", "push", "--cloud-project-id", createdProjectId, "--config-file", badConfigPath],
|
|
);
|
|
expect(exitCode).toBe(1);
|
|
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", "--cloud-project-id", createdProjectId, "--config-file", existingConfigPath],
|
|
);
|
|
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("re-run with --overwrite");
|
|
});
|
|
|
|
it("config pull falls back to ./stack.config.ts in cwd when --config-file is omitted", async ({ expect }) => {
|
|
// realpathSync normalizes macOS's /var/folders/... → /private/var/folders/...
|
|
// (Node resolves the symlink when reporting the written path).
|
|
const cwdDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-config-pull-cwd-")));
|
|
const expected = path.join(cwdDir, "stack.config.ts");
|
|
fs.writeFileSync(expected, "// placeholder so the file exists\n");
|
|
try {
|
|
const { stdout, exitCode } = await runCli(
|
|
["config", "pull", "--cloud-project-id", createdProjectId, "--overwrite"],
|
|
undefined,
|
|
cwdDir,
|
|
);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain(`Config written to ${expected}`);
|
|
const content = fs.readFileSync(expected, "utf-8");
|
|
expect(content).toContain("export const config: HexclaveConfig");
|
|
} finally {
|
|
fs.rmSync(cwdDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
it("config pull errors when --config-file is omitted and cwd has no stack.config.ts", async ({ expect }) => {
|
|
const cwdDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-config-pull-empty-")));
|
|
try {
|
|
const { stderr, exitCode } = await runCli(
|
|
["config", "pull", "--cloud-project-id", createdProjectId],
|
|
undefined,
|
|
cwdDir,
|
|
);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("Pass --config-file");
|
|
} finally {
|
|
fs.rmSync(cwdDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
// --- init command tests ---
|
|
|
|
// TODO: Re-enable these create-mode tests once init mode handling is finalized.
|
|
// We keep these skipped (instead of todo) so the test logic remains visible and easy to re-enable.
|
|
it.skip("init create writes stack.config.ts with selected apps", async ({ expect }) => {
|
|
const initDir = path.join(tmpDir, "init-create");
|
|
fs.mkdirSync(initDir, { recursive: true });
|
|
|
|
const { stdout, exitCode } = await runCli([
|
|
"init", "--mode", "create", "--apps", "authentication,teams", "--output-dir", initDir,
|
|
]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("Config file written to");
|
|
|
|
const content = fs.readFileSync(path.join(initDir, "stack.config.ts"), "utf-8");
|
|
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js/config";');
|
|
expect(content).toContain("export const config: HexclaveConfig");
|
|
expect(JSON.parse(extractConfigObjectString(content))).toMatchObject({
|
|
apps: {
|
|
installed: {
|
|
authentication: { enabled: true },
|
|
teams: { enabled: true },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it.skip("init create with single app", async ({ expect }) => {
|
|
const initDir = path.join(tmpDir, "init-create-single");
|
|
fs.mkdirSync(initDir, { recursive: true });
|
|
|
|
const { stdout, exitCode } = await runCli([
|
|
"init", "--mode", "create", "--apps", "authentication", "--output-dir", initDir,
|
|
]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("Config file written to");
|
|
|
|
const content = fs.readFileSync(path.join(initDir, "stack.config.ts"), "utf-8");
|
|
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 }) => {
|
|
// Create a dummy config file to link to
|
|
const dummyConfig = path.join(tmpDir, "dummy-stack.config.ts");
|
|
fs.writeFileSync(dummyConfig, "export const config = {};\n");
|
|
|
|
const { stdout, exitCode } = await runCli([
|
|
"init", "--mode", "link-config", "--config-file", dummyConfig,
|
|
]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("Linked to config file");
|
|
expect(stdout).toContain(dummyConfig);
|
|
});
|
|
|
|
it("init link-config with invalid path fails", async ({ expect }) => {
|
|
const { stderr, exitCode } = await runCli([
|
|
"init", "--mode", "link-config", "--config-file", "/nonexistent/stack.config.ts",
|
|
]);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("Config file not found");
|
|
});
|
|
|
|
it("init link-cloud creates .env with API keys", async ({ expect }) => {
|
|
expect(createdProjectId).toBeDefined();
|
|
|
|
const initDir = path.join(tmpDir, "init-cloud");
|
|
fs.mkdirSync(initDir, { recursive: true });
|
|
|
|
const { stdout, exitCode } = await runCli([
|
|
"init", "--mode", "link-cloud", "--select-project-id", createdProjectId, "--output-dir", initDir,
|
|
]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("Created .env with Hexclave keys");
|
|
|
|
const envContent = fs.readFileSync(path.join(initDir, ".env"), "utf-8");
|
|
expect(envContent).toContain("# Hexclave");
|
|
expect(envContent).toContain(`NEXT_PUBLIC_HEXCLAVE_PROJECT_ID=${createdProjectId}`);
|
|
expect(envContent).toContain("NEXT_PUBLIC_HEXCLAVE_PUBLISHABLE_CLIENT_KEY=");
|
|
expect(envContent).toContain("HEXCLAVE_SECRET_SERVER_KEY=");
|
|
});
|
|
|
|
it("init link-cloud appends to existing .env", async ({ expect }) => {
|
|
expect(createdProjectId).toBeDefined();
|
|
|
|
const initDir = path.join(tmpDir, "init-cloud-append");
|
|
fs.mkdirSync(initDir, { recursive: true });
|
|
fs.writeFileSync(path.join(initDir, ".env"), "EXISTING_VAR=hello\n");
|
|
|
|
const { stdout, exitCode } = await runCli([
|
|
"init", "--mode", "link-cloud", "--select-project-id", createdProjectId, "--output-dir", initDir,
|
|
]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("Appended Hexclave keys to .env");
|
|
|
|
const envContent = fs.readFileSync(path.join(initDir, ".env"), "utf-8");
|
|
expect(envContent).toContain("EXISTING_VAR=hello");
|
|
expect(envContent).toContain("# Hexclave");
|
|
expect(envContent).toContain(`NEXT_PUBLIC_HEXCLAVE_PROJECT_ID=${createdProjectId}`);
|
|
});
|
|
|
|
it("init link-cloud fails with invalid project ID", async ({ expect }) => {
|
|
const { stderr, exitCode } = await runCli([
|
|
"init", "--mode", "link-cloud", "--select-project-id", "nonexistent-project-id",
|
|
]);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("not found");
|
|
});
|
|
|
|
it.skip("init outputs setup instructions", async ({ expect }) => {
|
|
const initDir = path.join(tmpDir, "init-instructions");
|
|
fs.mkdirSync(initDir, { recursive: true });
|
|
|
|
const { stdout, exitCode } = await runCli([
|
|
"init", "--mode", "create", "--apps", "authentication", "--output-dir", initDir,
|
|
]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("STACK AUTH SETUP INSTRUCTIONS");
|
|
});
|
|
});
|
|
|
|
// Doctor CLI tests — no backend required. Each test builds a fixture project
|
|
// in a temp dir and runs `stack doctor --output-dir <dir> --json`.
|
|
describe("Stack CLI — Doctor", () => {
|
|
let doctorTmpRoot: string;
|
|
|
|
beforeAll(() => {
|
|
if (!fs.existsSync(CLI_BIN)) {
|
|
throw new Error("CLI not built. Run `pnpm --filter @hexclave/cli run build` first.");
|
|
}
|
|
doctorTmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-doctor-test-"));
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (doctorTmpRoot && fs.existsSync(doctorTmpRoot)) {
|
|
fs.rmSync(doctorTmpRoot, { recursive: true });
|
|
}
|
|
});
|
|
|
|
function runDoctor(
|
|
args: string[],
|
|
envOverrides?: Record<string, string>,
|
|
): Promise<{ stdout: string, stderr: string, exitCode: number | null }> {
|
|
const env: Record<string, string> = {
|
|
PATH: process.env.PATH ?? "",
|
|
HOME: process.env.HOME ?? "",
|
|
CI: "1",
|
|
...envOverrides,
|
|
};
|
|
return new Promise((resolve) => {
|
|
execFile("node", [CLI_BIN, ...args], {
|
|
env,
|
|
timeout: 30_000,
|
|
}, (error, stdout, stderr) => {
|
|
resolve({
|
|
stdout: stdout.toString(),
|
|
stderr: stderr.toString(),
|
|
exitCode: error ? (error as any).code ?? 1 : 0,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function makeProject(subdir: string, files: Record<string, string>): string {
|
|
const dir = path.join(doctorTmpRoot, `${subdir}-${crypto.randomUUID().slice(0, 8)}`);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
for (const [rel, content] of Object.entries(files)) {
|
|
const full = path.join(dir, rel);
|
|
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
fs.writeFileSync(full, content);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
function pkg(extra: Record<string, unknown>): string {
|
|
return JSON.stringify({ name: "fixture", version: "0.0.0", ...extra }, null, 2);
|
|
}
|
|
|
|
// Reusable Next.js all-green fixture
|
|
function nextHappyFiles(): Record<string, string> {
|
|
return {
|
|
"package.json": pkg({
|
|
dependencies: { next: "14.0.0", "@hexclave/next": "1.0.0" },
|
|
}),
|
|
"hexclave/client.ts": "export const hexclaveClientApp = {};\n",
|
|
"hexclave/server.ts": "export const hexclaveServerApp = {};\n",
|
|
"app/handler/[...hexclave]/page.tsx": "export default function Page() { return null; }\n",
|
|
"app/layout.tsx":
|
|
`import { HexclaveProvider } from "@hexclave/next";\n` +
|
|
`export default function RootLayout({ children }) {\n` +
|
|
` return <HexclaveProvider>{children}</HexclaveProvider>;\n` +
|
|
`}\n`,
|
|
".env.local":
|
|
`NEXT_PUBLIC_STACK_PROJECT_ID=proj_test\n` +
|
|
`NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=pck_test\n` +
|
|
`STACK_SECRET_SERVER_KEY="ssk_test"\n`,
|
|
};
|
|
}
|
|
|
|
it("doctor --help shows options", async ({ expect }) => {
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--help"]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("--output-dir");
|
|
expect(stdout).toContain("--framework");
|
|
expect(stdout).toContain("--json");
|
|
});
|
|
|
|
it("fails when package.json is missing", async ({ expect }) => {
|
|
const dir = makeProject("no-pkg", {});
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(1);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.error).toBe("no package.json");
|
|
expect(parsed.projectDir).toBe(dir);
|
|
});
|
|
|
|
it("fails when package.json is invalid JSON", async ({ expect }) => {
|
|
const dir = makeProject("bad-pkg", { "package.json": "not json" });
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(1);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.error).toBe("invalid package.json");
|
|
expect(typeof parsed.detail).toBe("string");
|
|
expect(parsed.detail.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("fails when no dependencies declared", async ({ expect }) => {
|
|
const dir = makeProject("empty-deps", { "package.json": pkg({}) });
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(1);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.error).toContain("no dependencies");
|
|
});
|
|
|
|
it("rejects Next.js project without app router", async ({ expect }) => {
|
|
const dir = makeProject("next-pages", {
|
|
"package.json": pkg({ dependencies: { next: "14.0.0" } }),
|
|
"pages/index.tsx": "export default function Home() { return null; }\n",
|
|
});
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(1);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.error).toContain("pages router");
|
|
});
|
|
|
|
it("rejects unknown --framework value", async ({ expect }) => {
|
|
const dir = makeProject("bad-fw", { "package.json": pkg({ dependencies: { next: "14.0.0" } }) });
|
|
const { stdout, exitCode } = await runDoctor([
|
|
"doctor", "--output-dir", dir, "--framework", "bogus", "--json",
|
|
]);
|
|
expect(exitCode).toBe(1);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.error).toContain("Unknown framework");
|
|
});
|
|
|
|
it("--framework override applies even when deps don't list it", async ({ expect }) => {
|
|
const dir = makeProject("fw-override", {
|
|
"package.json": pkg({ dependencies: { something: "1.0.0" } }),
|
|
"app/marker.txt": "ensures app router exists\n",
|
|
});
|
|
const { stdout, exitCode } = await runDoctor([
|
|
"doctor", "--output-dir", dir, "--framework", "next", "--json",
|
|
]);
|
|
// Will fail many checks (no Stack package, no files), but framework should be next.
|
|
expect(exitCode).toBe(1);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.framework).toBe("next");
|
|
});
|
|
|
|
it("Next.js happy path passes all checks", async ({ expect }) => {
|
|
const dir = makeProject("next-happy", nextHappyFiles());
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.framework).toBe("next");
|
|
expect(parsed.failed).toBe(0);
|
|
expect(parsed.warned).toBe(0);
|
|
expect(parsed.checks.every((c: any) => c.status === "pass")).toBe(true);
|
|
});
|
|
|
|
it("Next.js legacy stack paths are still accepted", async ({ expect }) => {
|
|
const files = nextHappyFiles();
|
|
files["stack/client.ts"] = files["hexclave/client.ts"];
|
|
files["stack/server.ts"] = files["hexclave/server.ts"];
|
|
files["app/handler/[...stack]/page.tsx"] = files["app/handler/[...hexclave]/page.tsx"];
|
|
delete files["hexclave/client.ts"];
|
|
delete files["hexclave/server.ts"];
|
|
delete files["app/handler/[...hexclave]/page.tsx"];
|
|
const dir = makeProject("next-legacy-stack-paths", files);
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.failed).toBe(0);
|
|
});
|
|
|
|
it("Next.js applies src/ prefix when src/app exists", async ({ expect }) => {
|
|
const dir = makeProject("next-src", {
|
|
"package.json": pkg({
|
|
dependencies: { next: "14.0.0", "@hexclave/next": "1.0.0" },
|
|
}),
|
|
"src/hexclave/client.ts": "export const hexclaveClientApp = {};\n",
|
|
"src/hexclave/server.ts": "export const hexclaveServerApp = {};\n",
|
|
"src/app/handler/[...hexclave]/page.tsx": "export default function P() { return null; }\n",
|
|
"src/app/layout.tsx":
|
|
`import { HexclaveProvider } from "@hexclave/next";\n` +
|
|
`export default function L({ children }) { return <HexclaveProvider>{children}</HexclaveProvider>; }\n`,
|
|
".env.local":
|
|
`NEXT_PUBLIC_STACK_PROJECT_ID=p\n` +
|
|
`NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=k\n` +
|
|
`STACK_SECRET_SERVER_KEY=s\n`,
|
|
});
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
const clientCheck = parsed.checks.find((c: any) => c.id === "next.client-app");
|
|
expect(clientCheck.status).toBe("pass");
|
|
expect(clientCheck.label).toContain("src/hexclave/client.ts");
|
|
});
|
|
|
|
it("React happy path passes all checks", async ({ expect }) => {
|
|
const dir = makeProject("react-happy", {
|
|
"package.json": pkg({
|
|
dependencies: { react: "18.0.0", "@hexclave/react": "1.0.0" },
|
|
}),
|
|
"hexclave/client.ts": "export const hexclaveClientApp = {};\n",
|
|
".env.local":
|
|
`VITE_STACK_PROJECT_ID=p\n` +
|
|
`VITE_STACK_PUBLISHABLE_CLIENT_KEY=k\n`,
|
|
});
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.framework).toBe("react");
|
|
expect(parsed.failed).toBe(0);
|
|
});
|
|
|
|
it("JS catch-all happy path passes all checks", async ({ expect }) => {
|
|
const dir = makeProject("js-happy", {
|
|
"package.json": pkg({
|
|
dependencies: { svelte: "4.0.0", "@hexclave/js": "1.0.0" },
|
|
}),
|
|
"hexclave/server.ts": "export const hexclaveServerApp = {};\n",
|
|
".env":
|
|
`STACK_PROJECT_ID=p\n` +
|
|
`STACK_PUBLISHABLE_CLIENT_KEY=k\n` +
|
|
`STACK_SECRET_SERVER_KEY=s\n`,
|
|
});
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.framework).toBe("js");
|
|
expect(parsed.failed).toBe(0);
|
|
});
|
|
|
|
it("JS catch-all accepts PUBLIC_* env aliases", async ({ expect }) => {
|
|
const dir = makeProject("js-public", {
|
|
"package.json": pkg({
|
|
dependencies: { svelte: "4.0.0", "@hexclave/js": "1.0.0" },
|
|
}),
|
|
"hexclave/client.ts": "export const hexclaveClientApp = {};\n",
|
|
".env":
|
|
`PUBLIC_STACK_PROJECT_ID=p\n` +
|
|
`PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=k\n` +
|
|
`STACK_SECRET_SERVER_KEY=s\n`,
|
|
});
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.framework).toBe("js");
|
|
expect(parsed.failed).toBe(0);
|
|
});
|
|
|
|
it("fails when @hexclave/next is not installed", async ({ expect }) => {
|
|
const files = nextHappyFiles();
|
|
files["package.json"] = pkg({ dependencies: { next: "14.0.0" } });
|
|
const dir = makeProject("no-stack-pkg", files);
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(1);
|
|
const parsed = JSON.parse(stdout);
|
|
const check = parsed.checks.find((c: any) => c.id === "next.package");
|
|
expect(check.status).toBe("fail");
|
|
});
|
|
|
|
it("fails when client app file is missing", async ({ expect }) => {
|
|
const files = nextHappyFiles();
|
|
delete files["hexclave/client.ts"];
|
|
const dir = makeProject("no-client", files);
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(1);
|
|
const parsed = JSON.parse(stdout);
|
|
const check = parsed.checks.find((c: any) => c.id === "next.client-app");
|
|
expect(check.status).toBe("fail");
|
|
});
|
|
|
|
it("fails when handler route is missing", async ({ expect }) => {
|
|
const files = nextHappyFiles();
|
|
delete files["app/handler/[...hexclave]/page.tsx"];
|
|
const dir = makeProject("no-handler", files);
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(1);
|
|
const parsed = JSON.parse(stdout);
|
|
const check = parsed.checks.find((c: any) => c.id === "next.handler-route");
|
|
expect(check.status).toBe("fail");
|
|
expect(check.hint).toContain("app/handler/[...hexclave]/page.tsx");
|
|
});
|
|
|
|
it("warns when layout imports StackProvider but does not render it", async ({ expect }) => {
|
|
const files = nextHappyFiles();
|
|
files["app/layout.tsx"] =
|
|
`import { StackProvider } from "@hexclave/next";\n` +
|
|
`export default function L({ children }) { return <html><body>{children}</body></html>; }\n`;
|
|
const dir = makeProject("layout-no-jsx", files);
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
// Warn does not flip exit code.
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
const check = parsed.checks.find((c: any) => c.id === "next.layout-provider");
|
|
expect(check.status).toBe("warn");
|
|
});
|
|
|
|
it("fails when layout renders <StackProvider> without importing it", async ({ expect }) => {
|
|
const files = nextHappyFiles();
|
|
files["app/layout.tsx"] =
|
|
`export default function L({ children }) { return <StackProvider>{children}</StackProvider>; }\n`;
|
|
const dir = makeProject("layout-no-import", files);
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(1);
|
|
const parsed = JSON.parse(stdout);
|
|
const check = parsed.checks.find((c: any) => c.id === "next.layout-provider");
|
|
expect(check.status).toBe("fail");
|
|
});
|
|
|
|
it("fails when layout file is missing entirely", async ({ expect }) => {
|
|
const files = nextHappyFiles();
|
|
delete files["app/layout.tsx"];
|
|
const dir = makeProject("layout-missing", files);
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(1);
|
|
const parsed = JSON.parse(stdout);
|
|
const check = parsed.checks.find((c: any) => c.id === "next.layout-provider");
|
|
expect(check.status).toBe("fail");
|
|
});
|
|
|
|
it("fails when a required env var is missing", async ({ expect }) => {
|
|
const files = nextHappyFiles();
|
|
files[".env.local"] =
|
|
`NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=k\n` +
|
|
`STACK_SECRET_SERVER_KEY=s\n`;
|
|
const dir = makeProject("env-fail", files);
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(1);
|
|
const parsed = JSON.parse(stdout);
|
|
const check = parsed.checks.find((c: any) => c.id === "env-vars");
|
|
expect(check.status).toBe("fail");
|
|
expect(check.label).toContain("NEXT_PUBLIC_STACK_PROJECT_ID");
|
|
});
|
|
|
|
it("warns (without failing) when only the recommended env var is missing", async ({ expect }) => {
|
|
const files = nextHappyFiles();
|
|
files[".env.local"] =
|
|
`NEXT_PUBLIC_STACK_PROJECT_ID=p\n` +
|
|
`STACK_SECRET_SERVER_KEY=s\n`;
|
|
const dir = makeProject("env-warn", files);
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
const check = parsed.checks.find((c: any) => c.id === "env-vars");
|
|
expect(check.status).toBe("warn");
|
|
expect(check.label).toContain("NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY");
|
|
});
|
|
|
|
it("resolves env vars from .env.local before .env", async ({ expect }) => {
|
|
const files = nextHappyFiles();
|
|
// .env is missing the required project ID; .env.local supplies it.
|
|
files[".env"] = `UNRELATED=1\n`;
|
|
files[".env.local"] =
|
|
`NEXT_PUBLIC_STACK_PROJECT_ID=p\n` +
|
|
`NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=k\n` +
|
|
`STACK_SECRET_SERVER_KEY=s\n`;
|
|
const dir = makeProject("env-precedence", files);
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
const check = parsed.checks.find((c: any) => c.id === "env-vars");
|
|
expect(check.status).toBe("pass");
|
|
});
|
|
|
|
it("skips config-file check when stack.config.ts is absent", async ({ expect }) => {
|
|
const dir = makeProject("no-config", nextHappyFiles());
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
const check = parsed.checks.find((c: any) => c.id === "config-file");
|
|
expect(check).toBeUndefined();
|
|
});
|
|
|
|
it("fails config-file check when config export is an array", async ({ expect }) => {
|
|
const files = nextHappyFiles();
|
|
files["stack.config.ts"] = "export const config = [];\n";
|
|
const dir = makeProject("config-array", files);
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(1);
|
|
const parsed = JSON.parse(stdout);
|
|
const check = parsed.checks.find((c: any) => c.id === "config-file");
|
|
expect(check.status).toBe("fail");
|
|
expect(check.label).toContain("not a plain object");
|
|
});
|
|
|
|
it("fails config-file check when there is no config export", async ({ expect }) => {
|
|
const files = nextHappyFiles();
|
|
files["stack.config.ts"] = "export const other = 1;\n";
|
|
const dir = makeProject("config-missing", files);
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(1);
|
|
const parsed = JSON.parse(stdout);
|
|
const check = parsed.checks.find((c: any) => c.id === "config-file");
|
|
expect(check.status).toBe("fail");
|
|
expect(check.label).toContain("missing a `config` export");
|
|
});
|
|
|
|
it("passes config-file check when config is a valid plain object", async ({ expect }) => {
|
|
const files = nextHappyFiles();
|
|
files["stack.config.ts"] = "export const config = { apps: { installed: {} } };\n";
|
|
const dir = makeProject("config-ok", files);
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir, "--json"]);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
const check = parsed.checks.find((c: any) => c.id === "config-file");
|
|
expect(check.status).toBe("pass");
|
|
});
|
|
|
|
it("renders a human report with header and summary when --json is omitted", async ({ expect }) => {
|
|
const dir = makeProject("human", nextHappyFiles());
|
|
const { stdout, exitCode } = await runDoctor(["doctor", "--output-dir", dir]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("Hexclave doctor");
|
|
expect(stdout).toMatch(/\d+ passed, \d+ failed/);
|
|
});
|
|
|
|
it("honors top-level --json flag (stack --json doctor)", async ({ expect }) => {
|
|
const dir = makeProject("top-json", nextHappyFiles());
|
|
const { stdout, exitCode } = await runDoctor(["--json", "doctor", "--output-dir", dir]);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.framework).toBe("next");
|
|
expect(Array.isArray(parsed.checks)).toBe(true);
|
|
});
|
|
});
|