stack/apps/e2e/tests/general/cli.test.ts
BilalG1 57ff5d3ce9
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
feat(hexclave): PR 2 — visible rebrand (Hexclave brand goes public) (#1481)
## Summary

**Stacked on [#1475](https://github.com/hexclave/stack-auth/pull/1475)**
(`cl/hexclave-pr1`, the invisible compatibility layer). Diff vs that
base = the actual PR 2 code.

This is **PR 2 of the Stack Auth → Hexclave rebrand: the visible flip**.
Old wire identifiers (cookies, request/response headers, Bearer prefix,
JWT issuers, MCP tool name) keep working indefinitely via PR 1's
dual-accept. This PR flips every user-visible surface — package names
taught in docs, SDK class names in code examples, dashboard setup
snippets, page titles, error messages, email content, CLI binary,
default base URLs, GitHub repo slug, contributor guidance — to the
Hexclave brand.

See [`RENAME-TO-HEXCLAVE.md`](./RENAME-TO-HEXCLAVE.md) → *"PR 2: Rebrand
to Hexclave (visible)"* for the full per-work-area spec.

## What's implemented (per the plan's PR 2 scope)

- **SDK base URLs** flipped: `defaultBaseUrl` and
`defaultAnalyticsBaseUrl` in
[common.ts](packages/template/src/lib/stack-app/apps/implementations/common.ts:127)
→ `https://api.hexclave.com` / `https://r.hexclave.com`. PR 1's
[`getHardcodedFallbackUrls`](packages/stack-shared/src/utils/urls.tsx:199)
table now keys on the Hexclave domain.

- **Domain inventory sweep** (16 subdomains from the plan): every
`api/app/docs/discord/demo/mcp/skill/feedback/test/preview/r/api2/api.staging/idp-jwk-audience/built-with.stack-auth.com`
reference in production code, docs-mintlify, examples, READMEs, and
contributor guidance flipped to `*.hexclave.com`. Carve-outs: PR 1's
intentional JWT issuer dual-accept table in
[tokens.tsx](apps/backend/src/lib/tokens.tsx), the legacy `./docs/`
folder, the `unified-docs-widget` allowlist (deliberately accepts both
during DNS transition), and `url-targets.ts` hosted-component default
(baked into existing customer deploys).

- **`@deprecated` JSDoc** on every `Stack*` public export
([packages/template/src/lib/stack-app/index.ts](packages/template/src/lib/stack-app/index.ts)
+ [packages/template/src/index.ts](packages/template/src/index.ts)) —
`StackClientApp`, `StackServerApp`, `StackAdminApp` + every
constructor/options/JSON type, `StackHandler`, `StackProvider`,
`StackTheme`, `useStackApp`, `defineStackConfig`, `StackConfig`.
Hexclave\* aliases are now canonical.

- **Runtime `console.warn`**
([packages/template/src/internal/deprecation-warning.ts](packages/template/src/internal/deprecation-warning.ts))
— once-per-process when the SDK is loaded from a `@stackframe/*`
artifact. Detection uses the existing
`STACK_COMPILE_TIME_CLIENT_PACKAGE_VERSION_SENTINEL` (rewritten at build
time to e.g. `js @stackframe/stack@2.8.92` or `js
@hexclave/next@1.0.0`); `@hexclave/*` mirror artifacts short-circuit the
warning.

- **Tier 3 data migration**: new idempotent SQL migration
[`20260523000000_rename_internal_project_to_hexclave`](apps/backend/prisma/migrations/20260523000000_rename_internal_project_to_hexclave/migration.sql)
— updates the internal Project `displayName` 'Stack Dashboard' →
'Hexclave Dashboard' and `description` only if both still hold the
pre-rebrand defaults. Operator-renamed projects untouched, missing row
no-ops, re-runs are no-ops. [`seed.ts`](apps/backend/prisma/seed.ts:87)
default flipped. `getSharedEmailConfig("Stack Auth")` → `("Hexclave")`.

- **Tier 4 brand strings** (mechanical sweep, ~340 files):
- Page + OpenAPI titles (Hexclave API / Dashboard / REST API / Webhooks
API / Documentation). OpenAPI `info.description` documents
`X-Hexclave-*` headers as canonical with compat note on `X-Stack-*`.
- `HexclaveAssertionError` message text
([errors.tsx:71](packages/stack-shared/src/utils/errors.tsx:71)) — "an
error in Stack." → "an error in Hexclave."
- Known-error message templates
([known-errors.tsx](packages/stack-shared/src/known-errors.tsx)) flipped
to lead with `x-hexclave-*` + the new `docs.hexclave.com` URL; legacy
`x-stack-*` mentioned as compat aliases. **25 e2e test files updated in
lockstep**.
- Email content: failed-emails-digest body, sendTestEmail recipient (now
`sent-with-hexclave.com`), test-email-recipient default.
  - `CHANGELOG.md` title → "Hexclave Changelog".
- `AGENTS.md` env var convention: new vars prefix `HEXCLAVE_` /
`NEXT_PUBLIC_HEXCLAVE_` for Category A/B; legacy `STACK_*` explicitly
noted as accepted via PR 1's dual-read.

- **CLI / init wizard**:
- Every dashboard setup snippet, init-stack template, and docs-mintlify
page teaches `npx @hexclave/cli@latest init` (was
`@stackframe/stack-cli`).
[setup-page.tsx](apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/setup-page.tsx)
+
[link-existing-onboarding](apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/link-existing-onboarding.tsx).
- [init-stack](packages/init-stack/src/index.ts:634)
`STACK_*_INSTALL_PACKAGE_NAME_OVERRIDE` defaults flipped to
`@hexclave/*`.
- Generated `stack/client.ts` / `stack/server.ts` import from
`@hexclave/next` and reference `HexclaveClientApp` /
`HexclaveServerApp`.
- Internal `StackAuthKeys` dashboard component renamed to
`HexclaveKeys`.

- **docs-mintlify rewrite** (legacy `./docs/` intentionally untouched
per scoping decision):
- **78 MDX files swept**.
`@stackframe/{react,stack,js,tanstack-start,...}` →
`@hexclave/{react,stack,js,...}` in install snippets and code blocks;
`Stack*` SDK class names → `Hexclave*` in all code examples; 'Stack
Auth' brand phrase → 'Hexclave'.
- `openapi/{server,admin,client,webhooks}.json` titles → 'Hexclave REST
API' / 'Hexclave Webhooks API'.

- **Generators flipped before regeneration**:
-
[`packages/stack-shared/src/helpers/init-prompt.ts`](packages/stack-shared/src/helpers/init-prompt.ts),
[`/ai/prompts.ts`](packages/stack-shared/src/ai/prompts.ts),
[`apps/backend/src/lib/ai/prompts.ts`](apps/backend/src/lib/ai/prompts.ts),
[`apps/backend/src/lib/ai/tools/create-email-{template,draft}.ts`](apps/backend/src/lib/ai/tools/create-email-template.ts),
[`apps/skills/src/app/route.ts`](apps/skills/src/app/route.ts) (taught
MCP tool → `ask_hexclave` with compat note; CLI binary teach →
`hexclave`),
[`docs-mintlify/snippets/home-prompt-island.jsx`](docs-mintlify/snippets/home-prompt-island.jsx),
[`packages/template/README.md`](packages/template/README.md) +
integrations/convex/component/README.md.
  - `generate-sdks` propagated changes to `packages/{react,stack,js}`.

- **OpenAPI dual-documentation**:
[`apps/backend/src/app/api/latest/route.ts`](apps/backend/src/app/api/latest/route.ts)
now lists `X-Hexclave-*` headers as primary documented schemas with
`X-Stack-*` duplicates marked `.optional()` (both accepted at runtime by
PR 1's normalize-at-proxy shim).

- **`@stackframe/emails` virtual module**: dual-aliased to
`@hexclave/emails` at the bundler boundary
([email-rendering.tsx:89](apps/backend/src/lib/email-rendering.tsx:89)).
Stored email templates continue to import from either name; new
AI-generated templates and the system prompt teach `@hexclave/emails`.

- **Tier 2 mirror-publish wiring** (new this PR, lays the groundwork for
`@hexclave/*` first publish):
-
[`scripts/rewrite-packages-to-hexclave.ts`](scripts/rewrite-packages-to-hexclave.ts)
— rewrites 9 publishable `@stackframe/*` → `@hexclave/*` `package.json`
files (reads `HEXCLAVE_VERSION` env or `--version=` flag), pins
cross-deps to the shared `@hexclave` version, registers `hexclave` bin
alongside `stack` for `@hexclave/cli`.
-
[`.github/workflows/npm-publish.yaml`](.github/workflows/npm-publish.yaml)
appended with rewrite-then-republish step. `pnpm publish` skips
already-on-npm versions so reruns are safe.

- **Sender email domain**: `noreply@stackframe.co` →
`noreply@sent-with-hexclave.com` (the dedicated transactional-sender
domain split per the plan, to isolate bulk deliverability from
`hexclave.com` reputation); `security@` / `team@stack-auth.com` inbound
mailboxes → `@hexclave.com`.

- **Self-host docs**: docker network / container names in the bash
examples flipped from `stack-auth` to `hexclave` (`hexclave-postgres`,
`hexclave-clickhouse`, `hexclave.env`). The docker image tag
`stackauth/server:latest` stays per the plan's locked decision.

- **GitHub repo slug**: `hexclave/stack-auth` → `hexclave/hexclave` in
every `package.json` `repository` field, README link, CHANGELOG
raw-asset URL.

## Carve-outs (deliberately untouched)

-
**[`apps/backend/src/lib/tokens.tsx`](apps/backend/src/lib/tokens.tsx)**
JWT issuer dual-accept table — PR 1 intentional infrastructure, kept
indefinitely.
- **Legacy `./docs/` folder** — per scoping decision (only
`docs-mintlify/` rewritten).
- **`unified-docs-widget` hostname allowlist** — accepts both
`.hexclave.com` (canonical) and `.stack-auth.com` (transition window)
for DNS rollout.
- **`url-targets.ts`** hosted-domain default
`.built-with-stack-auth.com` — wire identifier baked into existing
customer deploys; indefinite read-fallback.
- **Binary visual assets** (logos, favicons, OG images, README
screenshots) — out of scope for this PR. Need design work; tracked
separately.

## Verification

- **`pnpm typecheck`** on
`packages/{template,stack-shared,react,stack,js}` + `apps/dashboard`:
**all green**. The remaining backend / e-commerce-demo typecheck errors
are pre-existing (Prisma codegen output +
`./generated/api-versions.json` not present in fresh worktrees without
`pnpm run codegen-prisma` + a live DB) and unrelated to this diff.
- **`pnpm lint`** on the same 6 packages: all green.
- **Final grep** for residual `Stack Auth` / `stack-auth.com` /
`@stackframe/stack-cli@latest` references: zero outside the intentional
carve-outs above.
- **25 e2e test files updated in lockstep** with the known-error message
changes (asserted strings flipped to match the new x-hexclave-* +
compat-note messages).

## Deploy blockers (ops sequencing before this rebrand goes live)

This PR is code-complete, but the rebrand's visible surfaces (SDK
default URLs, dashboard links, npm READMEs, REST error messages, runtime
deprecation warning) all point at `*.hexclave.com` / `@hexclave/*`
resources that don't exist yet. None of these are fixable from a PR —
they're ops/registrar/npm work that has to be sequenced before merging
this to a release tag.

Suggested ordering, hardest blockers first:

### Tier 1 — required before customer-facing deploy (everything below
this line *will visibly break customers on day 1* if skipped)

1. **DNS + TLS for `api.hexclave.com` + `api1./api2.hexclave.com`** →
must point at the same backend that serves `api.stack-auth.com` (or a
backend that mirrors PR 1's dual-accept). The SDK's new `defaultBaseUrl`
is `https://api.hexclave.com`; every customer that relied on the old
default and upgrades to a post-PR2 SDK build sends API requests here.
Until this resolves, every default-config customer's API call NXDOMAINs.
2. **DNS for `app.hexclave.com`** → the dashboard. Referenced in the
SDK's default-error messages ("Please create a project on the Hexclave
dashboard at https://app.hexclave.com"), the init-stack flow's
`wizard-congrats` redirect, and the OAuth dashboard handoff.
3. **DNS for `docs.hexclave.com`** + Mintlify deploy → the SDK runtime
deprecation warning (`https://docs.hexclave.com/migration`), every
README, every "Learn more" link in the dashboard, and every REST API
error body (`/api/overview#authentication`) points here. The MDX is in
this PR; the docs build target needs DNS.
4. **DNS for `mcp.hexclave.com`** → the MCP server endpoint that every
taught agent integration (`claude mcp add ...`, `cursor`, `codex`,
`vscode`) registers. Until this resolves, every `npx
@hexclave/cli@latest init` MCP-registration step fails.
5. **Reserve the `@hexclave` npm scope + set repo variable
`HEXCLAVE_VERSION`** → the mirror-publish step in
`.github/workflows/npm-publish.yaml` is gated on this variable. Without
it, the entire taught onboarding command `npx @hexclave/cli@latest init`
404s from the npm registry, *and* every README that says "install
`@hexclave/next`" leads to install failure. Pick the initial version
intentionally (`1.0.0` or aligned to `@stackframe/stack`); don't accept
a silent default.

### Tier 2 — required before announcing the rebrand publicly (lookalike
or low-traffic surfaces, but visibly broken)

6. **DNS for `r.hexclave.com`** → the analytics beacon
`defaultAnalyticsBaseUrl`. Silent failure if missing (analytics drops),
but should land alongside Tier 1.
7. **Register `sent-with-hexclave.com` + full email auth (SPF / DKIM /
DMARC)** → the new default sender domain for shared-sender transactional
emails. Without it the dashboard "send test email" path emits bounces,
and shared-sender flows (`getSharedEmailConfig("Hexclave")`) deliver to
spam at best.
8. **MX + SPF / DMARC for `hexclave.com`** → `team@hexclave.com` and
`security@hexclave.com` mailboxes. The security disclosure mailbox is
referenced in [`.github/SECURITY.md`](.github/SECURITY.md);
`team@hexclave.com` is the actual recipient of internal feedback emails
sent at runtime by
[`apps/backend/src/lib/internal-feedback-emails.tsx`](apps/backend/src/lib/internal-feedback-emails.tsx).
Today, every runtime feedback email bounces.
9. **DNS for `skill.hexclave.com`** → the canonical AI-agent skill fetch
URL (the agent bootstrap pivot). Without it, the entire "agent downloads
`SKILL.md` from a known URL" flow taught in
[`packages/stack-shared/src/helpers/init-prompt.ts`](packages/stack-shared/src/helpers/init-prompt.ts)
fails.
10. **Create `github.com/hexclave/hexclave` as a public repo** (even as
a redirect to `hexclave/stack-auth`) **OR** rewrite every `package.json`
`"repository"` field + dashboard footer "view on GitHub" link to point
at `hexclave/stack-auth` (which already exists). Currently every npm
package page's "Repository" link is dead, and the dashboard's GitHub
button + dev-tool repo link are dead.

### Tier 3 — broken but low-visibility / low-traffic

11. **DNS for `discord.hexclave.com`** → Discord invite redirect, used
in every README's chip and the dashboard footer.
12. **DNS for `demo.hexclave.com`** → " Demo" badge in every npm
package README. Broken-image badge on the package page.
13. **DNS + TLS for `built-with-hexclave.com`** → optional
hosted-handler domain (the default reverted to
`.built-with-stack-auth.com` in this PR's carve-outs, so this only
matters for projects that manually flip).

## Other follow-ups (not deploy-blocking)

- **E2E snapshot regen across the full suite** for the dual-emitted
`x-hexclave-*` response headers (PR 1 follow-up; `vitest -u` in CI
absorbs).
- **Binary visual assets** — logos, favicons, OG images, README
screenshots; need design pass.
- **Backend OpenAPI fumadocs regen** in CI flow — the JSON files in
`docs-mintlify/openapi/` are committed but regen runs in CI. Verify the
workflow that does this still works against the post-PR2 source.
- **Backend typecheck infra debt** — needs `codegen-prisma` +
`codegen-route-info` to clear; pre-existing, unaffected by this PR.

## Test plan

- [ ] CI runs full e2e suite (with `vitest -u` to absorb residual
snapshot deltas, then committed back).
- [ ] Spot-check: new `@hexclave/cli init` (once published) generates
`hexclave.config.ts` and works against a fresh project.
- [ ] Spot-check: existing customer with `@stackframe/stack` import sees
the once-per-process `console.warn` recommending `@hexclave/next` on SDK
init.
- [ ] Manual: dashboard setup page renders the `npx @hexclave/cli@latest
init` snippet and the `x-hexclave-publishable-client-key` API header in
the curl example.
- [ ] Manual: a fresh `pnpm run prisma migrate` against a clean DB sets
the internal project displayName to 'Hexclave Dashboard'.

---------

Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
2026-05-26 19:18:20 -07:00

1153 lines
46 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 "@stackframe/js";
import { Result } from "@stackframe/stack-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 = process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR === "true";
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>,
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 @stackframe/stack-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/stack-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 stackServerApp object available", async ({ expect }) => {
const { stdout, exitCode } = await runCli(
["exec", "--cloud-project-id", createdProjectId, "return typeof stackServerApp"],
);
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/docs/sdk");
});
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 stackServerApp", async ({ expect }) => {
createdUserEmail = `exec-test-${crypto.randomUUID()}@stack-generated.example.com`;
const code = `const u = await stackServerApp.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 stackServerApp", async ({ expect }) => {
expect(createdProjectId).toBeDefined();
expect(createdUserEmail).toBeDefined();
const { stdout, exitCode } = await runCli(
["exec", "--cloud-project-id", createdProjectId, "const users = await stackServerApp.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("Local emulator 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 local emulator");
} 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 { StackConfig } from "@stackframe/js";');
expect(content).toContain("export const config: StackConfig");
});
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: StackConfig");
} 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 { 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 }) => {
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_STACK_PROJECT_ID=${createdProjectId}`);
expect(envContent).toContain("NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=");
expect(envContent).toContain("STACK_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_STACK_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");
});
});
// Emulator CLI tests — no backend required, just validates help/arg parsing
describe("Stack CLI — Emulator", () => {
function runCliBare(
args: string[],
): Promise<{ stdout: string, stderr: string, exitCode: number | null }> {
return new Promise((resolve) => {
execFile("node", [CLI_BIN, ...args], {
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", CI: "1" },
timeout: 15_000,
}, (error, stdout, stderr) => {
resolve({
stdout: stdout.toString(),
stderr: stderr.toString(),
exitCode: error ? (error as any).code ?? 1 : 0,
});
});
});
}
function runCliBareFromSource(
args: string[],
): Promise<{ stdout: string, stderr: string, exitCode: number | null }> {
return new Promise((resolve) => {
execFile("node", ["--import", "tsx", CLI_SRC_BIN, ...args], {
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", CI: "1" },
timeout: 15_000,
}, (error, stdout, stderr) => {
resolve({
stdout: stdout.toString(),
stderr: stderr.toString(),
exitCode: error ? (error as any).code ?? 1 : 0,
});
});
});
}
it("emulator help shows subcommands", async ({ expect }) => {
const { stdout, exitCode } = await runCliBare(["emulator", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("pull");
expect(stdout).toContain("start");
expect(stdout).toContain("stop");
expect(stdout).toContain("reset");
expect(stdout).toContain("status");
expect(stdout).toContain("list-releases");
});
it("emulator pull help shows options", async ({ expect }) => {
const { stdout, exitCode } = await runCliBare(["emulator", "pull", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("--arch");
expect(stdout).toContain("--branch");
expect(stdout).toContain("--tag");
expect(stdout).toContain("--repo");
});
it("emulator pull rejects invalid arch values", async ({ expect }) => {
const { stderr, exitCode } = await runCliBareFromSource(["emulator", "pull", "--arch", "sparc"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("Invalid architecture: sparc. Expected arm64 or amd64.");
});
it("emulator list-releases help shows repo option", async ({ expect }) => {
const { stdout, exitCode } = await runCliBare(["emulator", "list-releases", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("--repo");
});
});
// 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 @stackframe/stack-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", "@stackframe/stack": "1.0.0" },
}),
"stack/client.ts": "export const stackClientApp = {};\n",
"stack/server.ts": "export const stackServerApp = {};\n",
"app/handler/[...stack]/page.tsx": "export default function Page() { return null; }\n",
"app/layout.tsx":
`import { StackProvider } from "@stackframe/stack";\n` +
`export default function RootLayout({ children }) {\n` +
` return <StackProvider>{children}</StackProvider>;\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 applies src/ prefix when src/app exists", async ({ expect }) => {
const dir = makeProject("next-src", {
"package.json": pkg({
dependencies: { next: "14.0.0", "@stackframe/stack": "1.0.0" },
}),
"src/stack/client.ts": "export const stackClientApp = {};\n",
"src/stack/server.ts": "export const stackServerApp = {};\n",
"src/app/handler/[...stack]/page.tsx": "export default function P() { return null; }\n",
"src/app/layout.tsx":
`import { StackProvider } from "@stackframe/stack";\n` +
`export default function L({ children }) { return <StackProvider>{children}</StackProvider>; }\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/stack/client.ts");
});
it("React happy path passes all checks", async ({ expect }) => {
const dir = makeProject("react-happy", {
"package.json": pkg({
dependencies: { react: "18.0.0", "@stackframe/react": "1.0.0" },
}),
"stack/client.ts": "export const stackClientApp = {};\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", "@stackframe/js": "1.0.0" },
}),
"stack/server.ts": "export const stackServerApp = {};\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", "@stackframe/js": "1.0.0" },
}),
"stack/client.ts": "export const stackClientApp = {};\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 @stackframe/stack 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["stack/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/[...stack]/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/[...stack]/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 "@stackframe/stack";\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);
});
});