mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +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 **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>
2076 lines
76 KiB
TypeScript
2076 lines
76 KiB
TypeScript
import * as child_process from "child_process";
|
|
import { Command } from "commander";
|
|
import * as crypto from 'crypto';
|
|
import * as fs from "fs";
|
|
import inquirer from "inquirer";
|
|
import open from "open";
|
|
import * as os from 'os';
|
|
import * as path from "path";
|
|
import { PostHog } from 'posthog-node';
|
|
import packageJson from '../package.json';
|
|
import { scheduleMcpConfiguration } from "./mcp";
|
|
import { invokeCallback } from "./telegram";
|
|
import { Colorize, configureVerboseLogging, logVerbose, templateIdentity } from "./util";
|
|
|
|
export { templateIdentity } from "./util";
|
|
|
|
const jsLikeFileExtensions: string[] = [
|
|
"mtsx",
|
|
"ctsx",
|
|
"tsx",
|
|
"mts",
|
|
"cts",
|
|
"ts",
|
|
"mjsx",
|
|
"cjsx",
|
|
"jsx",
|
|
"mjs",
|
|
"cjs",
|
|
"js",
|
|
];
|
|
|
|
class UserError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "UserError";
|
|
}
|
|
}
|
|
|
|
class UnansweredQuestionError extends UserError {
|
|
constructor(message: string) {
|
|
super(message + ", or use --on-question <guess|ask> to answer questions automatically or interactively");
|
|
this.name = "UnansweredQuestionError";
|
|
}
|
|
}
|
|
|
|
type OnQuestionMode = "ask" | "guess" | "error";
|
|
|
|
function isTruthyEnv(name: string): boolean {
|
|
const v = process.env[name];
|
|
if (!v) return false;
|
|
const s = String(v).toLowerCase();
|
|
return s === "1" || s === "true" || s === "yes";
|
|
}
|
|
|
|
function isNonInteractiveEnv(): boolean {
|
|
if (isTruthyEnv("CI")) return true;
|
|
if (isTruthyEnv("GITHUB_ACTIONS")) return true;
|
|
if (isTruthyEnv("NONINTERACTIVE")) return true;
|
|
if (isTruthyEnv("NO_INTERACTIVE")) return true;
|
|
if (isTruthyEnv("PNPM_NON_INTERACTIVE")) return true;
|
|
if (isTruthyEnv("YARN_ENABLE_NON_INTERACTIVE")) return true;
|
|
if (isTruthyEnv("CURSOR_AGENT")) return true;
|
|
if (isTruthyEnv("CLAUDECODE")) return true;
|
|
return false;
|
|
}
|
|
|
|
function resolveOnQuestionMode(opt: string): OnQuestionMode {
|
|
if (!opt || opt === "default") {
|
|
return isNonInteractiveEnv() ? "error" : "ask";
|
|
}
|
|
if (opt === "ask" || opt === "guess" || opt === "error") {
|
|
return opt;
|
|
}
|
|
throw new UserError(`Invalid argument for --on-question: "${opt}". Valid modes are: "ask", "guess", "error", "default".`);
|
|
}
|
|
|
|
// Setup command line parsing
|
|
const program = new Command();
|
|
program
|
|
.name(packageJson.name)
|
|
.description("Hexclave Initialization Tool")
|
|
.version(packageJson.version)
|
|
.argument("[project-path]", "Path to your project")
|
|
.usage(`[project-path] [options]`)
|
|
.option("--dry-run", "Run without making any changes")
|
|
.option("--neon", "Use Neon database")
|
|
.option("--js", "Initialize for JavaScript project")
|
|
.option("--next", "Initialize for Next.js project")
|
|
.option("--react", "Initialize for React project")
|
|
.option("--npm", "Use npm as package manager")
|
|
.option("--yarn", "Use yarn as package manager")
|
|
.option("--pnpm", "Use pnpm as package manager")
|
|
.option("--bun", "Use bun as package manager")
|
|
.option("--client", "Initialize client-side only")
|
|
.option("--server", "Initialize server-side only")
|
|
.option("--project-id <project-id>", "Project ID to use in setup")
|
|
.option("--publishable-client-key <publishable-client-key>", "Publishable client key to use in setup")
|
|
.option("--no-browser", "Don't open browser for environment variable setup")
|
|
.option("--on-question <mode>", "How to handle interactive questions: ask | guess | error | default", "default")
|
|
.option("--no-warn-uncommitted-changes", "Don't warn about uncommitted changes in the Git repository")
|
|
.addHelpText('after', `
|
|
For more information, please visit https://docs.hexclave.com/getting-started/setup`);
|
|
|
|
program.parse();
|
|
|
|
const options = program.opts();
|
|
|
|
// Keep existing variables but assign from Commander
|
|
let savedProjectPath: string | undefined = program.args[0] || undefined;
|
|
const verboseEnvRaw = process.env.STACK_VERBOSE;
|
|
const parsedVerboseLevel = typeof verboseEnvRaw === "string" && verboseEnvRaw.trim().length > 0
|
|
? Number.parseInt(verboseEnvRaw.trim(), 10)
|
|
: 0;
|
|
const verboseLevel: number = Number.isFinite(parsedVerboseLevel) ? Math.max(0, parsedVerboseLevel) : 0;
|
|
const isVerbose: boolean = verboseLevel > 0;
|
|
const isDryRun: boolean = options.dryRun || isTruthyEnv("STACK_DRY_RUN") || false;
|
|
const isNeon: boolean = options.neon || false;
|
|
const typeFromArgs: "js" | "next" | "react" | undefined = options.js ? "js" : options.next ? "next" : options.react ? "react" : undefined;
|
|
const packageManagerFromArgs: string | undefined = options.npm ? "npm" : options.yarn ? "yarn" : options.pnpm ? "pnpm" : options.bun ? "bun" : undefined;
|
|
const isClient: boolean = options.client || false;
|
|
const isServer: boolean = options.server || false;
|
|
const projectIdFromArgs: string | undefined = options.projectId;
|
|
const publishableClientKeyFromArgs: string | undefined = options.publishableClientKey;
|
|
const onQuestionMode: OnQuestionMode = resolveOnQuestionMode(options.onQuestion);
|
|
const warnUncommittedChanges: boolean = options.warnUncommittedChanges ?? true;
|
|
|
|
// Commander negates the boolean options with prefix `--no-`
|
|
// so `--no-browser` becomes `browser: false`
|
|
const noBrowser: boolean = !options.browser;
|
|
|
|
type Ansis = {
|
|
red: string,
|
|
blue: string,
|
|
green: string,
|
|
yellow: string,
|
|
clear: string,
|
|
bold: string,
|
|
};
|
|
|
|
const ansis: Ansis = {
|
|
red: "\x1b[31m",
|
|
blue: "\x1b[34m",
|
|
green: "\x1b[32m",
|
|
yellow: "\x1b[33m",
|
|
|
|
clear: "\x1b[0m",
|
|
bold: "\x1b[1m",
|
|
};
|
|
|
|
const colorize: Colorize = {
|
|
red: (strings, ...values) => ansis.red + templateIdentity(strings, ...values) + ansis.clear,
|
|
blue: (strings, ...values) => ansis.blue + templateIdentity(strings, ...values) + ansis.clear,
|
|
green: (strings, ...values) => ansis.green + templateIdentity(strings, ...values) + ansis.clear,
|
|
yellow: (strings, ...values) => ansis.yellow + templateIdentity(strings, ...values) + ansis.clear,
|
|
bold: (strings, ...values) => ansis.bold + templateIdentity(strings, ...values) + ansis.clear,
|
|
};
|
|
|
|
configureVerboseLogging({
|
|
level: verboseLevel,
|
|
formatter: (message) => colorize.blue`[verbose] ${message}`,
|
|
});
|
|
|
|
const filesCreated: string[] = [];
|
|
const filesModified: string[] = [];
|
|
const commandsExecuted: string[] = [];
|
|
|
|
const packagesToInstall: string[] = [];
|
|
const writeFileHandlers: Array<() => Promise<void>> = [];
|
|
const deferredCommandHandlers: Array<() => Promise<void>> = [];
|
|
const nextSteps: string[] = [
|
|
`Create an account and Hexclave API key for your project on https://app.hexclave.com`,
|
|
];
|
|
|
|
|
|
const STACK_AUTH_PUBLIC_HOG_KEY = "phc_vIUFi0HzHo7oV26OsaZbUASqxvs8qOmap1UBYAutU4k";
|
|
const EVENT_PREFIX = "stack-init-";
|
|
const ph_client = new PostHog(STACK_AUTH_PUBLIC_HOG_KEY, {
|
|
host: "https://eu.i.posthog.com",
|
|
flushAt: 1,
|
|
flushInterval: 0,
|
|
});
|
|
const distinctId = crypto.randomUUID();
|
|
|
|
|
|
async function capture(event: string, properties: Record<string, any>) {
|
|
logVerbose("capture event", { event, properties });
|
|
ph_client.capture({
|
|
event: `${EVENT_PREFIX}${event}`,
|
|
distinctId,
|
|
properties,
|
|
});
|
|
}
|
|
|
|
|
|
async function main(): Promise<void> {
|
|
// Welcome message
|
|
console.log();
|
|
console.log(`
|
|
██████
|
|
██████████████
|
|
████████████████████
|
|
████████████████████ WELCOME TO
|
|
█████████████████ ╔═╗╔╦╗╔═╗╔═╗╦╔═ ┌─┐┬ ┬┌┬┐┬ ┬
|
|
█████████████ ╚═╗ ║ ╠═╣║ ╠╩╗ ├─┤│ │ │ ├─┤
|
|
█████████████ ████ ╚═╝ ╩ ╩ ╩╚═╝╩ ╩ ┴ ┴└─┘ ┴ ┴ ┴
|
|
█████████████████
|
|
██████ ██
|
|
████ ████
|
|
█████ █████
|
|
██████
|
|
`);
|
|
console.log();
|
|
|
|
logVerbose("Initialization run metadata", {
|
|
version: packageJson.version,
|
|
cwd: process.cwd(),
|
|
args: program.args,
|
|
options: {
|
|
isDryRun,
|
|
isVerbose,
|
|
isNeon,
|
|
typeFromArgs,
|
|
packageManagerFromArgs,
|
|
isClient,
|
|
isServer,
|
|
projectIdFromArgs: Boolean(projectIdFromArgs),
|
|
publishableClientKeyFromArgs: Boolean(publishableClientKeyFromArgs),
|
|
noBrowser,
|
|
onQuestionMode,
|
|
verboseLevel,
|
|
},
|
|
});
|
|
|
|
await capture("start", {
|
|
version: packageJson.version,
|
|
isDryRun,
|
|
isNeon,
|
|
typeFromArgs,
|
|
packageManagerFromArgs,
|
|
isClient,
|
|
isServer,
|
|
noBrowser,
|
|
platform: os.platform(),
|
|
arch: os.arch(),
|
|
nodeVersion: process.version,
|
|
});
|
|
|
|
// Wait just briefly so we can use `Steps` in here (it's defined only after the call to `main()`)
|
|
await new Promise<void>((resolve) => resolve());
|
|
|
|
|
|
// Prepare some stuff
|
|
await clearStdin();
|
|
const projectPath = await getProjectPath();
|
|
await ensureGitWorkspaceIsReady(projectPath);
|
|
logVerbose("Project path prepared", { projectPath, isDryRun, isVerbose });
|
|
scheduleMcpConfiguration({
|
|
projectPath,
|
|
isDryRun,
|
|
colorize,
|
|
registerWriteHandler: (handler) => writeFileHandlers.push(handler),
|
|
registerCommandHandler: (handler) => deferredCommandHandlers.push(handler),
|
|
recordFileChange,
|
|
runScheduledCommand,
|
|
});
|
|
nextSteps.push("Restart your MCP-enabled editors so they pick up the Hexclave MCP.");
|
|
logVerbose("MCP configuration scheduled", {
|
|
writeHandlers: writeFileHandlers.length,
|
|
deferredCommands: deferredCommandHandlers.length,
|
|
});
|
|
|
|
|
|
// Steps
|
|
const { packageJson: projectPackageJson } = await Steps.getProject();
|
|
const type = await Steps.getProjectType({ packageJson: projectPackageJson });
|
|
logVerbose("Project inspection complete", {
|
|
detectedType: type,
|
|
dependencies: {
|
|
hasReact: Boolean(projectPackageJson.dependencies?.["react"]),
|
|
hasNext: Boolean(projectPackageJson.dependencies?.["next"]),
|
|
},
|
|
});
|
|
|
|
await capture("project-type-selected", {
|
|
type,
|
|
wasSpecifiedInArgs: !!typeFromArgs,
|
|
});
|
|
|
|
await Steps.addStackPackage(type);
|
|
if (isNeon) packagesToInstall.push('@neondatabase/serverless');
|
|
|
|
await Steps.writeEnvVars(type);
|
|
const convexIntegration = await Steps.maybeInstallConvexIntegration({ packageJson: projectPackageJson, type });
|
|
if (convexIntegration) {
|
|
nextSteps.push(...convexIntegration.instructions);
|
|
logVerbose("Convex integration detected", convexIntegration);
|
|
}
|
|
|
|
if (type === "next") {
|
|
const projectInfo = await Steps.getNextProjectInfo({ packageJson: projectPackageJson });
|
|
await Steps.updateNextLayoutFile(projectInfo);
|
|
await Steps.writeStackAppFile(projectInfo, "client", true);
|
|
await Steps.writeStackAppFile(projectInfo, "server", true);
|
|
await Steps.writeNextHandlerFile(projectInfo);
|
|
await Steps.writeNextLoadingFile(projectInfo);
|
|
nextSteps.push(`Copy the environment variables from the new API key into your .env.local file`);
|
|
} else if (type === "react") {
|
|
const defaultExtension = await Steps.guessDefaultFileExtension();
|
|
const srcPath = await Steps.guessSrcPath();
|
|
const hasReactRouterDom = !!(projectPackageJson.dependencies?.["react-router-dom"] || projectPackageJson.devDependencies?.["react-router-dom"]);
|
|
const { fileName } = await Steps.writeReactClientFile({
|
|
srcPath,
|
|
defaultExtension,
|
|
indentation: " ",
|
|
hasReactRouterDom,
|
|
});
|
|
nextSteps.push(
|
|
`Copy the environment variables from the new API key into your own environment and reference them in ${fileName}`,
|
|
);
|
|
} else {
|
|
const defaultExtension = await Steps.guessDefaultFileExtension();
|
|
const where = await Steps.getServerOrClientOrBoth();
|
|
const srcPath = await Steps.guessSrcPath();
|
|
const appFiles: string[] = [];
|
|
for (const w of where) {
|
|
const { fileName } = await Steps.writeStackAppFile({
|
|
type,
|
|
defaultExtension,
|
|
indentation: " ",
|
|
srcPath,
|
|
}, w, where.includes("client"));
|
|
appFiles.push(fileName);
|
|
}
|
|
nextSteps.push(
|
|
`Copy the environment variables from the new API key into your own environment and reference them in ${appFiles.join(" and ")}`,
|
|
`Follow the instructions on how to use Hexclave's vanilla SDK at https://docs.hexclave.com/guides/going-further/stack-app`,
|
|
);
|
|
}
|
|
logVerbose("Primary integration steps completed", { type, nextStepsCount: nextSteps.length });
|
|
|
|
const { packageManager } = await Steps.getPackageManager();
|
|
logVerbose("Package manager determined", { packageManager });
|
|
|
|
await capture(`package-manager-selected`, {
|
|
packageManager,
|
|
wasSpecifiedInArgs: !!packageManagerFromArgs,
|
|
});
|
|
|
|
await Steps.ensureReady(type);
|
|
|
|
|
|
// Install dependencies
|
|
console.log();
|
|
console.log(colorize.bold`Installing dependencies...`);
|
|
const installCommandMap = new Map<string, string>([
|
|
["npm", "npm install"],
|
|
["yarn", "yarn add"],
|
|
["pnpm", "pnpm add"],
|
|
["bun", "bun add"],
|
|
]);
|
|
const installCommand = installCommandMap.get(packageManager) ?? `${packageManager} install`;
|
|
// Quote each package name to avoid shell interpretation of env-overridden values.
|
|
const safePackages = packagesToInstall.map((p) => JSON.stringify(p));
|
|
await shellNicelyFormatted(`${installCommand} ${safePackages.join(' ')}`, {
|
|
shell: true,
|
|
cwd: projectPath,
|
|
});
|
|
logVerbose("Dependency installation finished", {
|
|
packageManager,
|
|
packages: packagesToInstall,
|
|
});
|
|
|
|
await capture(`dependencies-installed`, {
|
|
packageManager,
|
|
packages: packagesToInstall,
|
|
});
|
|
|
|
// Write files
|
|
console.log();
|
|
console.log(colorize.bold`Writing files...`);
|
|
console.log();
|
|
for (let i = 0; i < writeFileHandlers.length; i++) {
|
|
const writeFileHandler = writeFileHandlers[i];
|
|
logVerbose("Executing write handler", { index: i });
|
|
await writeFileHandler();
|
|
}
|
|
console.log(`${colorize.green`√`} Done writing files`);
|
|
|
|
await runDeferredCommands();
|
|
|
|
console.log('\n\n\n');
|
|
console.log(colorize.bold`${colorize.green`Installation succeeded!`}`);
|
|
console.log();
|
|
console.log("Commands executed:");
|
|
for (const command of commandsExecuted) {
|
|
console.log(` ${colorize.blue`${command}`}`);
|
|
}
|
|
console.log();
|
|
console.log("MCP servers installed:");
|
|
console.log(` ${colorize.green`https://mcp.hexclave.com/mcp`}`);
|
|
console.log();
|
|
console.log("Files written:");
|
|
for (const file of filesModified) {
|
|
console.log(` ${colorize.yellow`${file}`}`);
|
|
}
|
|
for (const file of filesCreated) {
|
|
console.log(` ${colorize.green`${file}`}`);
|
|
}
|
|
console.log();
|
|
|
|
await capture("complete", {
|
|
success: true,
|
|
type,
|
|
packageManager,
|
|
isNeon,
|
|
isClient,
|
|
isServer,
|
|
noBrowser,
|
|
filesCreated,
|
|
filesModified,
|
|
commandsExecuted,
|
|
});
|
|
|
|
await invokeCallback({
|
|
success: true,
|
|
distinctId,
|
|
options,
|
|
args: program.args,
|
|
isNonInteractive: isNonInteractiveEnv(),
|
|
timestamp: new Date().toISOString(),
|
|
projectPath,
|
|
});
|
|
|
|
// Success!
|
|
console.log(`
|
|
${colorize.green`===============================================`}
|
|
|
|
${colorize.green`Successfully installed Stack! 🚀🚀🚀`}
|
|
|
|
${colorize.bold`Next steps:`}
|
|
|
|
1. ${noBrowser ?
|
|
`Create a project at https://app.hexclave.com and get your API keys` :
|
|
`Complete the setup in your browser to get your API keys`}
|
|
2. Add the API keys to your .env.local file
|
|
3. Import the Stack components in your app
|
|
4. Add authentication to your app
|
|
|
|
For more information, please visit https://docs.hexclave.com/getting-started/setup
|
|
`.trim());
|
|
if (!noBrowser) {
|
|
// Hexclave rebrand: emit the new query param name (hyphen delimiter preserved).
|
|
await open(`https://app.hexclave.com/wizard-congrats?hexclave-init-id=${encodeURIComponent(distinctId)}`);
|
|
}
|
|
await ph_client.shutdown();
|
|
}
|
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
main().catch(async (err) => {
|
|
try {
|
|
await capture("error", {
|
|
error: err.message,
|
|
errorType: err instanceof UserError ? "UserError" : "SystemError",
|
|
stack: err.stack,
|
|
});
|
|
} catch (e) { }
|
|
if (!(err instanceof UserError)) {
|
|
console.error(err);
|
|
}
|
|
console.error('\n\n\n\n');
|
|
console.log(colorize.red`===============================================`);
|
|
console.error();
|
|
if (err instanceof UserError) {
|
|
console.error(`${colorize.red`ERROR!`} ${err.message}`);
|
|
} else {
|
|
console.error("An error occurred during the initialization process.");
|
|
}
|
|
console.error();
|
|
console.log(colorize.red`===============================================`);
|
|
console.error();
|
|
console.error(
|
|
"If you need assistance, please try installing Stack manually as described in https://docs.hexclave.com/getting-started/setup or join our Discord where we're happy to help: https://discord.hexclave.com"
|
|
);
|
|
if (!(err instanceof UserError)) {
|
|
console.error("");
|
|
console.error(`Error message: ${err.message}`);
|
|
}
|
|
console.error();
|
|
const fallbackErrorMessage = (() => {
|
|
if (err instanceof Error) return err.message;
|
|
if (typeof err === "string") return err;
|
|
try {
|
|
return JSON.stringify(err);
|
|
} catch {
|
|
return "Unknown error";
|
|
}
|
|
})();
|
|
await invokeCallback({
|
|
success: false,
|
|
distinctId,
|
|
options,
|
|
args: program.args,
|
|
isNonInteractive: isNonInteractiveEnv(),
|
|
timestamp: new Date().toISOString(),
|
|
projectPath: savedProjectPath,
|
|
error: {
|
|
name: err instanceof Error ? err.name : undefined,
|
|
message: fallbackErrorMessage,
|
|
stack: err instanceof Error ? err.stack : undefined,
|
|
},
|
|
});
|
|
await ph_client.shutdown();
|
|
process.exit(1);
|
|
});
|
|
|
|
|
|
type PackageJson = {
|
|
dependencies?: Record<string, string>,
|
|
devDependencies?: Record<string, string>,
|
|
[key: string]: any,
|
|
}
|
|
|
|
type ProjectInfo = {
|
|
type: "js" | "next" | "react",
|
|
srcPath: string,
|
|
appPath: string,
|
|
defaultExtension: string,
|
|
indentation: string,
|
|
}
|
|
|
|
type NextProjectInfoError = {
|
|
error: string,
|
|
}
|
|
|
|
type NextProjectInfoResult = ProjectInfo | NextProjectInfoError;
|
|
|
|
type StackAppFileOptions = {
|
|
type: "js" | "next" | "react",
|
|
srcPath: string,
|
|
defaultExtension: string,
|
|
indentation: string,
|
|
}
|
|
|
|
type StackAppFileResult = {
|
|
fileName: string,
|
|
}
|
|
|
|
type ConvexIntegrationResult = {
|
|
instructions: string[],
|
|
}
|
|
|
|
const Steps = {
|
|
async getProject(): Promise<{ packageJson: PackageJson }> {
|
|
let projectPath = await getProjectPath();
|
|
logVerbose("Steps.getProject invoked", { projectPath });
|
|
if (!fs.existsSync(projectPath)) {
|
|
throw new UserError(`The project path ${projectPath} does not exist`);
|
|
}
|
|
|
|
const packageJsonPath = path.join(projectPath, "package.json");
|
|
if (!fs.existsSync(packageJsonPath)) {
|
|
throw new UserError(
|
|
`The package.json file does not exist in the project path ${projectPath}. You must initialize a new project first before installing Stack.`
|
|
);
|
|
}
|
|
|
|
const packageJsonText = fs.readFileSync(packageJsonPath, "utf-8");
|
|
let packageJson: PackageJson;
|
|
try {
|
|
packageJson = JSON.parse(packageJsonText);
|
|
} catch (e) {
|
|
throw new UserError(`package.json file is not valid JSON: ${e}`);
|
|
}
|
|
|
|
logVerbose("Steps.getProject completed", {
|
|
packageJsonPath,
|
|
dependencyCounts: {
|
|
dependencies: Object.keys(packageJson.dependencies ?? {}).length,
|
|
devDependencies: Object.keys(packageJson.devDependencies ?? {}).length,
|
|
},
|
|
});
|
|
|
|
return { packageJson };
|
|
},
|
|
|
|
async getProjectType({ packageJson }: { packageJson: PackageJson }): Promise<"js" | "next" | "react"> {
|
|
if (typeFromArgs) {
|
|
logVerbose("Steps.getProjectType using CLI override", { typeFromArgs });
|
|
return typeFromArgs;
|
|
}
|
|
|
|
logVerbose("Steps.getProjectType attempting autodetect", {
|
|
hasNext: Boolean(packageJson.dependencies?.["next"] || packageJson.devDependencies?.["next"]),
|
|
hasReact: Boolean(packageJson.dependencies?.["react"] || packageJson.dependencies?.["react-dom"]),
|
|
onQuestionMode,
|
|
});
|
|
|
|
const maybeNextProject = await Steps.maybeGetNextProjectInfo({ packageJson });
|
|
if (!("error" in maybeNextProject)) {
|
|
logVerbose("Steps.getProjectType resolved to Next.js project");
|
|
return "next";
|
|
}
|
|
if (packageJson.dependencies?.["react"] || packageJson.dependencies?.["react-dom"]) {
|
|
logVerbose("Steps.getProjectType resolved to React project");
|
|
return "react";
|
|
}
|
|
if (onQuestionMode === "guess") {
|
|
logVerbose("Steps.getProjectType defaulting to JS due to --on-question=guess");
|
|
return "js";
|
|
}
|
|
if (onQuestionMode === "error") {
|
|
throw new UnansweredQuestionError("Unable to auto-detect project type (checked for Next.js and React dependencies). Re-run with one of: --js, --react, or --next.");
|
|
}
|
|
|
|
const { type } = await inquirer.prompt([
|
|
{
|
|
type: "list",
|
|
name: "type",
|
|
message: "Which integration would you like to install?",
|
|
choices: [
|
|
{ name: "Vanilla JS (other/no framework)", value: "js" },
|
|
{ name: "Node.js", value: "js" },
|
|
{ name: "React", value: "react" },
|
|
{ name: "Next.js", value: "next" },
|
|
]
|
|
}
|
|
]);
|
|
|
|
logVerbose("Steps.getProjectType received user selection", { type });
|
|
return type;
|
|
},
|
|
|
|
async getStackPackageName(type: "js" | "next" | "react", install = false): Promise<string> {
|
|
const mapping = {
|
|
js: (install && process.env.STACK_JS_INSTALL_PACKAGE_NAME_OVERRIDE) || "@hexclave/js",
|
|
next: (install && process.env.STACK_NEXT_INSTALL_PACKAGE_NAME_OVERRIDE) || "@hexclave/next",
|
|
react: (install && process.env.STACK_REACT_INSTALL_PACKAGE_NAME_OVERRIDE) || "@hexclave/react",
|
|
} as const;
|
|
const packageName = mapping[type];
|
|
logVerbose("Steps.getStackPackageName resolved", { type, install, packageName });
|
|
return packageName;
|
|
},
|
|
|
|
async addStackPackage(type: "js" | "next" | "react"): Promise<void> {
|
|
const pkgName = await Steps.getStackPackageName(type, true);
|
|
logVerbose("Steps.addStackPackage scheduling install", { pkgName });
|
|
packagesToInstall.push(pkgName);
|
|
},
|
|
|
|
async getNextProjectInfo({ packageJson }: { packageJson: PackageJson }): Promise<ProjectInfo> {
|
|
logVerbose("Steps.getNextProjectInfo invoked");
|
|
const maybe = await Steps.maybeGetNextProjectInfo({ packageJson });
|
|
if ("error" in maybe) {
|
|
logVerbose("Steps.getNextProjectInfo failed validation", maybe);
|
|
throw new UserError(maybe.error);
|
|
}
|
|
logVerbose("Steps.getNextProjectInfo resolved", maybe);
|
|
return maybe;
|
|
},
|
|
|
|
async maybeGetNextProjectInfo({ packageJson }: { packageJson: PackageJson }): Promise<NextProjectInfoResult> {
|
|
const projectPath = await getProjectPath();
|
|
logVerbose("Steps.maybeGetNextProjectInfo evaluating Next.js eligibility", { projectPath });
|
|
|
|
const nextVersionInPackageJson = packageJson.dependencies?.["next"] ?? packageJson.devDependencies?.["next"];
|
|
if (!nextVersionInPackageJson) {
|
|
logVerbose("Steps.maybeGetNextProjectInfo missing Next dependency");
|
|
return { error: `The project at ${projectPath} does not appear to be a Next.js project, or does not have 'next' installed as a dependency.` };
|
|
}
|
|
if (!nextVersionInPackageJson) {
|
|
logVerbose("Steps.maybeGetNextProjectInfo found unsupported Next version", { version: nextVersionInPackageJson });
|
|
return { error: `The project at ${projectPath} is using an unsupported version of Next.js (found ${nextVersionInPackageJson}).\n\nOnly Next.js 14 & 15 projects are currently supported. See Next's upgrade guide: https://nextjs.org/docs/app/building-your-application/upgrading/version-14` };
|
|
}
|
|
|
|
const hasSrcAppFolder = fs.existsSync(path.join(projectPath, "src/app"));
|
|
const srcPath = path.join(projectPath, hasSrcAppFolder ? "src" : "");
|
|
const appPath = path.join(srcPath, "app");
|
|
if (!fs.existsSync(appPath)) {
|
|
logVerbose("Steps.maybeGetNextProjectInfo missing Next app directory", { appPath });
|
|
return { error: `The app path ${appPath} does not exist. Only the Next.js App router is supported — are you maybe on the Pages router instead?` };
|
|
}
|
|
|
|
const nextConfigPathWithoutExtension = path.join(projectPath, "next.config");
|
|
const nextConfigFileExtension = await findJsExtension(
|
|
nextConfigPathWithoutExtension
|
|
);
|
|
const nextConfigPath =
|
|
nextConfigPathWithoutExtension + "." + (nextConfigFileExtension ?? "js");
|
|
if (!fs.existsSync(nextConfigPath)) {
|
|
logVerbose("Steps.maybeGetNextProjectInfo missing next.config file", { nextConfigPath });
|
|
return { error: `Expected file at ${nextConfigPath} for Next.js projects.` };
|
|
}
|
|
|
|
const dryUpdateNextLayoutFileResult = await Steps.dryUpdateNextLayoutFile({ appPath, defaultExtension: "jsx" });
|
|
|
|
logVerbose("Steps.maybeGetNextProjectInfo success", {
|
|
projectPath,
|
|
srcPath,
|
|
appPath,
|
|
detectedExtension: dryUpdateNextLayoutFileResult.fileExtension,
|
|
});
|
|
return {
|
|
type: "next",
|
|
srcPath,
|
|
appPath,
|
|
defaultExtension: dryUpdateNextLayoutFileResult.fileExtension,
|
|
indentation: dryUpdateNextLayoutFileResult.indentation,
|
|
};
|
|
},
|
|
|
|
async writeEnvVars(type: "js" | "next" | "react"): Promise<boolean> {
|
|
const projectPath = await getProjectPath();
|
|
logVerbose("Steps.writeEnvVars invoked", { type, projectPath });
|
|
|
|
// TODO: in non-Next environments, ask the user what method they prefer for envvars
|
|
if (type !== "next") {
|
|
logVerbose("Steps.writeEnvVars skipped", { reason: "non-next-project" });
|
|
return false;
|
|
}
|
|
|
|
const envLocalPath = path.join(projectPath, ".env.local");
|
|
|
|
const potentialEnvLocations = [
|
|
path.join(projectPath, ".env"),
|
|
path.join(projectPath, ".env.development"),
|
|
path.join(projectPath, ".env.default"),
|
|
path.join(projectPath, ".env.defaults"),
|
|
path.join(projectPath, ".env.example"),
|
|
envLocalPath,
|
|
];
|
|
if (potentialEnvLocations.every((p) => !fs.existsSync(p))) {
|
|
const envContent = noBrowser
|
|
? "# Hexclave keys\n" +
|
|
"# To get these variables:\n" +
|
|
"# 1. Go to https://app.hexclave.com\n" +
|
|
"# 2. Create a new project\n" +
|
|
"# 3. Copy the keys below\n" +
|
|
`NEXT_PUBLIC_STACK_PROJECT_ID="${projectIdFromArgs ?? ""}"\n` +
|
|
`NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY="${publishableClientKeyFromArgs ?? ""}"\n` +
|
|
"STACK_SECRET_SERVER_KEY=\n"
|
|
: "# Hexclave keys\n" +
|
|
"# Get these variables by creating a project on https://app.hexclave.com.\n" +
|
|
`NEXT_PUBLIC_STACK_PROJECT_ID="${projectIdFromArgs ?? ""}"\n` +
|
|
`NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY="${publishableClientKeyFromArgs ?? ""}"\n` +
|
|
"STACK_SECRET_SERVER_KEY=\n";
|
|
|
|
laterWriteFile(envLocalPath, envContent);
|
|
logVerbose("Steps.writeEnvVars scheduled .env.local creation", { envLocalPath });
|
|
return true;
|
|
}
|
|
|
|
logVerbose("Steps.writeEnvVars found existing env files", { potentialEnvLocations });
|
|
return false;
|
|
},
|
|
|
|
async maybeInstallConvexIntegration({ packageJson, type }: { packageJson: PackageJson, type: "js" | "next" | "react" }): Promise<ConvexIntegrationResult | null> {
|
|
const hasConvexDependency = Boolean(packageJson.dependencies?.["convex"] || packageJson.devDependencies?.["convex"]);
|
|
if (!hasConvexDependency) {
|
|
logVerbose("Steps.maybeInstallConvexIntegration skipped", { reason: "no-convex-dependency" });
|
|
return null;
|
|
}
|
|
|
|
const projectPath = await getProjectPath();
|
|
const convexDir = path.join(projectPath, "convex");
|
|
if (!fs.existsSync(convexDir)) {
|
|
logVerbose("Steps.maybeInstallConvexIntegration skipped", { reason: "missing-convex-dir", convexDir });
|
|
return null;
|
|
}
|
|
|
|
const stackPackageName = await Steps.getStackPackageName(type);
|
|
const instructions: string[] = [];
|
|
logVerbose("Steps.maybeInstallConvexIntegration configuring", { convexDir, stackPackageName });
|
|
|
|
const authConfigPath = path.join(convexDir, "auth.config.ts");
|
|
const desiredAuthConfig = createConvexAuthConfigContent({ stackPackageName, type });
|
|
const existingAuthConfig = await readFile(authConfigPath);
|
|
if (!existingAuthConfig || (!existingAuthConfig.includes("getConvexProvidersConfig") && !existingAuthConfig.includes("@stackframe/") && !existingAuthConfig.includes("@hexclave/"))) {
|
|
laterWriteFile(authConfigPath, desiredAuthConfig);
|
|
logVerbose("Steps.maybeInstallConvexIntegration scheduled auth.config.ts update", { authConfigPath });
|
|
}
|
|
|
|
const convexConfigPath = path.join(convexDir, "convex.config.ts");
|
|
const existingConvexConfig = await readFile(convexConfigPath);
|
|
const desiredConvexConfig = createConvexIntegrationConvexConfigContent(stackPackageName);
|
|
let needsManualConvexConfig = false;
|
|
|
|
if (!existingConvexConfig) {
|
|
laterWriteFile(convexConfigPath, desiredConvexConfig);
|
|
logVerbose("Steps.maybeInstallConvexIntegration writing convex.config.ts from template", { convexConfigPath });
|
|
} else if (existingConvexConfig.includes("app.use(stackAuthComponent") && existingConvexConfig.includes("/convex.config") && existingConvexConfig.includes("stackframe")) {
|
|
// already integrated
|
|
logVerbose("Steps.maybeInstallConvexIntegration detected existing convex.config.ts integration", { convexConfigPath });
|
|
} else {
|
|
const integratedContent = integrateConvexConfig(existingConvexConfig, stackPackageName);
|
|
if (integratedContent) {
|
|
laterWriteFile(convexConfigPath, integratedContent);
|
|
logVerbose("Steps.maybeInstallConvexIntegration merged convex.config.ts content", { convexConfigPath });
|
|
} else if (isSimpleConvexConfig(existingConvexConfig)) {
|
|
laterWriteFile(convexConfigPath, desiredConvexConfig);
|
|
logVerbose("Steps.maybeInstallConvexIntegration replaced simple convex.config.ts", { convexConfigPath });
|
|
} else {
|
|
needsManualConvexConfig = true;
|
|
logVerbose("Steps.maybeInstallConvexIntegration requiring manual convex.config.ts review", { convexConfigPath });
|
|
}
|
|
}
|
|
|
|
if (needsManualConvexConfig) {
|
|
instructions.push(`Update convex/convex.config.ts to import ${stackPackageName}/convex.config and call app.use(stackAuthComponent).`);
|
|
}
|
|
|
|
const convexClientUpdateResult = await updateConvexClients({ projectPath, type });
|
|
if (convexClientUpdateResult.skippedFiles.length > 0) {
|
|
instructions.push("Review your Convex client setup and call hexclaveClientApp.getConvexClientAuth({}) or hexclaveServerApp.getConvexClientAuth({}) manually where needed.");
|
|
}
|
|
logVerbose("Steps.maybeInstallConvexIntegration client update summary", convexClientUpdateResult);
|
|
|
|
instructions.push(
|
|
"Set the Hexclave environment variables in Convex (Deployment → Settings → Environment Variables).",
|
|
"Verify your Convex clients call hexclaveClientApp.getConvexClientAuth({}) or hexclaveServerApp.getConvexClientAuth({}) so they share authentication with Hexclave."
|
|
);
|
|
|
|
logVerbose("Steps.maybeInstallConvexIntegration completed", { instructions });
|
|
return { instructions };
|
|
},
|
|
|
|
async dryUpdateNextLayoutFile({ appPath, defaultExtension }: { appPath: string, defaultExtension: string }): Promise<{
|
|
path: string,
|
|
updatedContent: string,
|
|
fileExtension: string,
|
|
indentation: string,
|
|
}> {
|
|
const layoutPathWithoutExtension = path.join(appPath, "layout");
|
|
const layoutFileExtension =
|
|
(await findJsExtension(layoutPathWithoutExtension)) ?? defaultExtension;
|
|
const layoutPath = layoutPathWithoutExtension + "." + layoutFileExtension;
|
|
const layoutContent =
|
|
(await readFile(layoutPath)) ??
|
|
throwErr(
|
|
`The layout file at ${layoutPath} does not exist. Stack requires a layout file to be present in the /app folder.`
|
|
);
|
|
const updatedLayoutResult =
|
|
(await getUpdatedLayout(layoutContent)) ??
|
|
throwErr(
|
|
"Unable to parse root layout file. Make sure it contains a <body> tag. If it still doesn't work, you may need to manually install Stack. See: https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#root-layout-required"
|
|
);
|
|
const updatedLayoutContent = updatedLayoutResult.content;
|
|
return {
|
|
path: layoutPath,
|
|
updatedContent: updatedLayoutContent,
|
|
fileExtension: layoutFileExtension,
|
|
indentation: updatedLayoutResult.indentation
|
|
};
|
|
},
|
|
|
|
async updateNextLayoutFile(projectInfo: ProjectInfo): Promise<{
|
|
path: string,
|
|
updatedContent: string,
|
|
fileExtension: string,
|
|
indentation: string,
|
|
}> {
|
|
logVerbose("Steps.updateNextLayoutFile invoked", projectInfo);
|
|
const res = await Steps.dryUpdateNextLayoutFile(projectInfo);
|
|
laterWriteFile(res.path, res.updatedContent);
|
|
logVerbose("Steps.updateNextLayoutFile scheduled write", { path: res.path });
|
|
return res;
|
|
},
|
|
|
|
async writeStackAppFile({ type, srcPath, defaultExtension, indentation }: StackAppFileOptions, clientOrServer: "server" | "client", alsoHasClient: boolean): Promise<StackAppFileResult> {
|
|
logVerbose("Steps.writeStackAppFile invoked", { type, srcPath, clientOrServer, alsoHasClient });
|
|
const packageName = await Steps.getStackPackageName(type);
|
|
|
|
const clientOrServerCap = {
|
|
client: "Client",
|
|
server: "Server",
|
|
}[clientOrServer as "client" | "server"];
|
|
const relativeStackAppPath = `stack/${clientOrServer}`;
|
|
|
|
const stackAppPathWithoutExtension = path.join(srcPath, relativeStackAppPath);
|
|
const stackAppFileExtension =
|
|
(await findJsExtension(stackAppPathWithoutExtension)) ?? defaultExtension;
|
|
const stackAppPath =
|
|
stackAppPathWithoutExtension + "." + stackAppFileExtension;
|
|
const stackAppContent = await readFile(stackAppPath);
|
|
if (stackAppContent) {
|
|
logVerbose("Steps.writeStackAppFile found existing file", { stackAppPath });
|
|
if (!stackAppContent.includes("@stackframe/") && !stackAppContent.includes("@hexclave/")) {
|
|
throw new UserError(
|
|
`A file at the path ${stackAppPath} already exists. Stack uses the stack/${clientOrServer}.ts file to initialize the Stack SDK. Please remove the existing file and try again.`
|
|
);
|
|
}
|
|
throw new UserError(
|
|
`It seems that you already installed Stack in this project.`
|
|
);
|
|
}
|
|
|
|
const tokenStore = type === "next" ? '"nextjs-cookie"' : (clientOrServer === "client" ? '"cookie"' : '"memory"');
|
|
const publishableClientKeyWrite = clientOrServer === "server"
|
|
? `process.env.STACK_PUBLISHABLE_CLIENT_KEY ${publishableClientKeyFromArgs ? `|| ${JSON.stringify(publishableClientKeyFromArgs)}` : ""}`
|
|
: `'${publishableClientKeyFromArgs ?? 'INSERT_YOUR_PUBLISHABLE_CLIENT_KEY_HERE'}'`;
|
|
const jsOptions = type === "js" ? [
|
|
`\n\n${indentation}// get your Hexclave API keys from https://app.hexclave.com${clientOrServer === "client" ? ` and store them in a safe place (e.g. environment variables)` : ""}`,
|
|
`${projectIdFromArgs ? `${indentation}projectId: ${JSON.stringify(projectIdFromArgs)},` : ""}`,
|
|
`${indentation}publishableClientKey: ${publishableClientKeyWrite},`,
|
|
`${clientOrServer === "server" ? `${indentation}secretServerKey: process.env.STACK_SECRET_SERVER_KEY,` : ""}`,
|
|
].filter(Boolean).join("\n") : "";
|
|
|
|
const nextClientOptions = (type === "next" && clientOrServer === "client")
|
|
? (() => {
|
|
const lines = [
|
|
projectIdFromArgs ? `${indentation}projectId: process.env.NEXT_PUBLIC_STACK_PROJECT_ID ?? ${JSON.stringify(projectIdFromArgs)},` : "",
|
|
publishableClientKeyFromArgs ? `${indentation}publishableClientKey: process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY ?? ${JSON.stringify(publishableClientKeyFromArgs)},` : "",
|
|
].filter(Boolean).join("\n");
|
|
return lines ? `\n${lines}` : "";
|
|
})()
|
|
: "";
|
|
|
|
const shouldInheritFromClient = clientOrServer === "server" && alsoHasClient;
|
|
|
|
laterWriteFileIfNotExists(
|
|
stackAppPath,
|
|
`
|
|
${type === "next" && clientOrServer === "server" ? `import "server-only";\n\n` : ""}import { Hexclave${clientOrServerCap}App } from ${JSON.stringify(packageName)};
|
|
${shouldInheritFromClient ? `import { hexclaveClientApp } from "./client";\n\n` : "\n"}export const hexclave${clientOrServerCap}App = new Hexclave${clientOrServerCap}App({
|
|
${shouldInheritFromClient ? `${indentation}inheritsFrom: hexclaveClientApp,` : `${indentation}tokenStore: ${tokenStore},${jsOptions}${nextClientOptions}`}
|
|
});
|
|
`.trim() + "\n"
|
|
);
|
|
logVerbose("Steps.writeStackAppFile scheduled creation", { stackAppPath, inheritsFromClient: shouldInheritFromClient });
|
|
return { fileName: stackAppPath };
|
|
},
|
|
|
|
async writeReactClientFile({ srcPath, defaultExtension, indentation, hasReactRouterDom }: { srcPath: string, defaultExtension: string, indentation: string, hasReactRouterDom: boolean }): Promise<StackAppFileResult> {
|
|
logVerbose("Steps.writeReactClientFile invoked", { srcPath, hasReactRouterDom });
|
|
const packageName = await Steps.getStackPackageName("react");
|
|
const relativeStackAppPath = `stack/client`;
|
|
const stackAppPathWithoutExtension = path.join(srcPath, relativeStackAppPath);
|
|
const stackAppFileExtension = (await findJsExtension(stackAppPathWithoutExtension)) ?? defaultExtension;
|
|
const stackAppPath = stackAppPathWithoutExtension + "." + stackAppFileExtension;
|
|
const stackAppContent = await readFile(stackAppPath);
|
|
if (stackAppContent) {
|
|
logVerbose("Steps.writeReactClientFile found existing file", { stackAppPath });
|
|
if (!stackAppContent.includes("@stackframe/") && !stackAppContent.includes("@hexclave/")) {
|
|
throw new UserError(`A file at the path ${stackAppPath} already exists. Stack uses the stack/client.ts file to initialize the Stack SDK. Please remove the existing file and try again.`);
|
|
}
|
|
throw new UserError(`It seems that you already installed Stack in this project.`);
|
|
}
|
|
|
|
const publishableClientKeyWrite = `'${publishableClientKeyFromArgs ?? 'INSERT_YOUR_PUBLISHABLE_CLIENT_KEY_HERE'}'`;
|
|
const projectIdWrite = `'${projectIdFromArgs ?? 'INSERT_PROJECT_ID'}'`;
|
|
|
|
const imports = hasReactRouterDom
|
|
? `import { HexclaveClientApp } from ${JSON.stringify(packageName)};\nimport { useNavigate } from "react-router-dom";\n\n`
|
|
: `import { HexclaveClientApp } from ${JSON.stringify(packageName)};\n\n`;
|
|
|
|
const redirectMethod = hasReactRouterDom
|
|
? `,\n${indentation}redirectMethod: {\n${indentation}${indentation}useNavigate,\n${indentation}}`
|
|
: "";
|
|
|
|
laterWriteFileIfNotExists(
|
|
stackAppPath,
|
|
`${imports}export const hexclaveClientApp = new HexclaveClientApp({ \n${indentation}tokenStore: "cookie", \n${indentation}projectId: ${projectIdWrite}, \n${indentation}publishableClientKey: ${publishableClientKeyWrite}${redirectMethod}, \n}); \n`
|
|
);
|
|
logVerbose("Steps.writeReactClientFile scheduled creation", { stackAppPath });
|
|
return { fileName: stackAppPath };
|
|
},
|
|
|
|
async writeNextHandlerFile(projectInfo: ProjectInfo): Promise<void> {
|
|
logVerbose("Steps.writeNextHandlerFile invoked", projectInfo);
|
|
const handlerPathWithoutExtension = path.join(
|
|
projectInfo.appPath,
|
|
"handler/[...stack]/page"
|
|
);
|
|
const handlerFileExtension =
|
|
(await findJsExtension(handlerPathWithoutExtension)) ?? projectInfo.defaultExtension;
|
|
const handlerPath = handlerPathWithoutExtension + "." + handlerFileExtension;
|
|
const handlerContent = await readFile(handlerPath);
|
|
if (handlerContent && !handlerContent.includes("@stackframe/") && !handlerContent.includes("@hexclave/")) {
|
|
logVerbose("Steps.writeNextHandlerFile found conflicting file", { handlerPath });
|
|
throw new UserError(
|
|
`A file at the path ${handlerPath} already exists.Stack uses the / handler path to handle incoming requests.Please remove the existing file and try again.`
|
|
);
|
|
}
|
|
laterWriteFileIfNotExists(
|
|
handlerPath,
|
|
`import { HexclaveHandler } from "@hexclave/next";\n\nexport default function Handler() {\n${projectInfo.indentation}return <HexclaveHandler fullPage />;\n}\n`
|
|
);
|
|
},
|
|
|
|
async writeNextLoadingFile(projectInfo: ProjectInfo): Promise<void> {
|
|
logVerbose("Steps.writeNextLoadingFile invoked", projectInfo);
|
|
let loadingPathWithoutExtension = path.join(projectInfo.appPath, "loading");
|
|
const loadingFileExtension =
|
|
(await findJsExtension(loadingPathWithoutExtension)) ?? projectInfo.defaultExtension;
|
|
const loadingPath = loadingPathWithoutExtension + "." + loadingFileExtension;
|
|
laterWriteFileIfNotExists(
|
|
loadingPath,
|
|
`export default function Loading() {
|
|
\n${projectInfo.indentation}// Stack uses React Suspense, which will render this page while user data is being fetched.\n${projectInfo.indentation}// See: https://nextjs.org/docs/app/api-reference/file-conventions/loading\n${projectInfo.indentation}return <></>;\n}\n`
|
|
);
|
|
},
|
|
|
|
async getPackageManager(): Promise<{ packageManager: string }> {
|
|
if (packageManagerFromArgs) {
|
|
logVerbose("Steps.getPackageManager using CLI override", { packageManager: packageManagerFromArgs });
|
|
return { packageManager: packageManagerFromArgs };
|
|
}
|
|
const packageManager = await promptPackageManager();
|
|
const versionCommand = `${packageManager} --version`;
|
|
logVerbose("Steps.getPackageManager checking binary availability", { packageManager });
|
|
|
|
try {
|
|
await shellNicelyFormatted(versionCommand, { shell: true, quiet: true });
|
|
} catch (err) {
|
|
console.error(err);
|
|
throw new UserError(
|
|
`Could not run the package manager command '${versionCommand}'. Please make sure ${packageManager} is installed on your system.`
|
|
);
|
|
}
|
|
|
|
logVerbose("Steps.getPackageManager resolved", { packageManager });
|
|
return { packageManager };
|
|
},
|
|
|
|
async ensureReady(type: "js" | "next" | "react"): Promise<void> {
|
|
const projectPath = await getProjectPath();
|
|
|
|
const typeStringMap = {
|
|
js: "JavaScript",
|
|
next: "Next.js",
|
|
react: "React",
|
|
} as const;
|
|
const typeString = typeStringMap[type];
|
|
const isReady = (onQuestionMode !== "ask") || (await inquirer.prompt([
|
|
{
|
|
type: "confirm",
|
|
name: "ready",
|
|
message: `Found a ${typeString} project at ${projectPath} — ready to install Hexclave?`,
|
|
default: true,
|
|
},
|
|
])).ready;
|
|
if (!isReady) {
|
|
throw new UserError("Installation aborted.");
|
|
}
|
|
logVerbose("Steps.ensureReady confirmed", { type, projectPath, isReady });
|
|
},
|
|
|
|
async getServerOrClientOrBoth(): Promise<Array<"server" | "client">> {
|
|
logVerbose("Steps.getServerOrClientOrBoth invoked", { isClientFlag: isClient, isServerFlag: isServer, onQuestionMode });
|
|
if (isClient && isServer) {
|
|
logVerbose("Steps.getServerOrClientOrBoth using CLI flags", { selection: ["server", "client"] });
|
|
return ["server", "client"];
|
|
}
|
|
if (isServer) {
|
|
logVerbose("Steps.getServerOrClientOrBoth using server flag");
|
|
return ["server"];
|
|
}
|
|
if (isClient) {
|
|
logVerbose("Steps.getServerOrClientOrBoth using client flag");
|
|
return ["client"];
|
|
}
|
|
|
|
if (onQuestionMode === "guess") {
|
|
logVerbose("Steps.getServerOrClientOrBoth defaulting to both");
|
|
return ["server", "client"];
|
|
}
|
|
if (onQuestionMode === "error") {
|
|
throw new UnansweredQuestionError("Ambiguous installation type. Re-run with --server, --client, or both.");
|
|
}
|
|
|
|
const selection = (await inquirer.prompt([{
|
|
type: "list",
|
|
name: "type",
|
|
message: "Do you want to use Hexclave on the server, or on the client?",
|
|
choices: [
|
|
{ name: "Client (e.g. Vite, HTML)", value: ["client"] },
|
|
{ name: "Server (e.g. Node.js)", value: ["server"] },
|
|
{ name: "Both (e.g. Next.js)", value: ["server", "client"] }
|
|
]
|
|
}])).type;
|
|
logVerbose("Steps.getServerOrClientOrBoth received user selection", { selection });
|
|
return selection;
|
|
},
|
|
|
|
/**
|
|
* note: this is a heuristic, specific frameworks may have better heuristics (e.g. the Next.js code uses the extension of the global layout file)
|
|
*/
|
|
async guessDefaultFileExtension(): Promise<string> {
|
|
const projectPath = await getProjectPath();
|
|
const hasTsConfig = fs.existsSync(
|
|
path.join(projectPath, "tsconfig.json")
|
|
);
|
|
const extension = hasTsConfig ? "ts" : "js";
|
|
logVerbose("Steps.guessDefaultFileExtension result", { projectPath, hasTsConfig, extension });
|
|
return extension;
|
|
},
|
|
|
|
/**
|
|
* note: this is a heuristic, specific frameworks may have better heuristics (e.g. the Next.js code uses the location of the app folder)
|
|
*/
|
|
async guessSrcPath(): Promise<string> {
|
|
const projectPath = await getProjectPath();
|
|
const potentialSrcPath = path.join(projectPath, "src");
|
|
const hasSrcFolder = fs.existsSync(
|
|
path.join(projectPath, "src")
|
|
);
|
|
const resolvedPath = hasSrcFolder ? potentialSrcPath : projectPath;
|
|
logVerbose("Steps.guessSrcPath result", { hasSrcFolder, resolvedPath });
|
|
return resolvedPath;
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
type LayoutResult = {
|
|
content: string,
|
|
indentation: string,
|
|
}
|
|
|
|
async function getUpdatedLayout(originalLayout: string): Promise<LayoutResult | undefined> {
|
|
logVerbose("getUpdatedLayout invoked", { length: originalLayout.length });
|
|
let layout = originalLayout;
|
|
const indentation = guessIndentation(originalLayout);
|
|
|
|
const firstImportLocationM1 = /\simport\s/.exec(layout)?.index;
|
|
const hasStringAsFirstLine = layout.startsWith('"') || layout.startsWith("'");
|
|
const importInsertLocationM1 =
|
|
firstImportLocationM1 ?? (hasStringAsFirstLine ? layout.indexOf("\n") : -1);
|
|
const importInsertLocation = importInsertLocationM1 + 1;
|
|
const importStatement = `import { HexclaveProvider, HexclaveTheme } from "@hexclave/next";\nimport { hexclaveClientApp } from "../stack/client";\n`;
|
|
layout =
|
|
layout.slice(0, importInsertLocation) +
|
|
importStatement +
|
|
layout.slice(importInsertLocation);
|
|
|
|
const bodyOpenTag = /<\s*body[^>]*>/.exec(layout);
|
|
const bodyCloseTag = /<\s*\/\s*body[^>]*>/.exec(layout);
|
|
if (!bodyOpenTag || !bodyCloseTag) {
|
|
logVerbose("getUpdatedLayout missing body tag");
|
|
return undefined;
|
|
}
|
|
const bodyOpenEndIndex = bodyOpenTag.index + bodyOpenTag[0].length;
|
|
const bodyCloseStartIndex = bodyCloseTag.index;
|
|
if (bodyCloseStartIndex <= bodyOpenEndIndex) {
|
|
logVerbose("getUpdatedLayout invalid body indices", { bodyOpenEndIndex, bodyCloseStartIndex });
|
|
return undefined;
|
|
}
|
|
|
|
const lines = layout.split("\n");
|
|
const [bodyOpenEndLine, bodyOpenEndIndexInLine] = getLineIndex(
|
|
lines,
|
|
bodyOpenEndIndex
|
|
);
|
|
const [bodyCloseStartLine, bodyCloseStartIndexInLine] = getLineIndex(
|
|
lines,
|
|
bodyCloseStartIndex
|
|
);
|
|
|
|
const insertOpen = "<HexclaveProvider app={hexclaveClientApp}><HexclaveTheme>";
|
|
const insertClose = "</HexclaveTheme></HexclaveProvider>";
|
|
|
|
layout =
|
|
layout.slice(0, bodyCloseStartIndex) +
|
|
insertClose +
|
|
layout.slice(bodyCloseStartIndex);
|
|
layout =
|
|
layout.slice(0, bodyOpenEndIndex) +
|
|
insertOpen +
|
|
layout.slice(bodyOpenEndIndex);
|
|
|
|
logVerbose("getUpdatedLayout success", { updatedLength: layout.length });
|
|
return {
|
|
content: `${layout}`,
|
|
indentation,
|
|
};
|
|
}
|
|
|
|
function guessIndentation(str: string): string {
|
|
const lines = str.split("\n");
|
|
const linesLeadingWhitespaces = lines
|
|
.map((line) => line.match(/^\s*/)![0])
|
|
.filter((ws) => ws.length > 0);
|
|
const isMostlyTabs =
|
|
linesLeadingWhitespaces.filter((ws) => ws.includes("\t")).length >=
|
|
(linesLeadingWhitespaces.length * 2) / 3;
|
|
if (isMostlyTabs) return "\t";
|
|
const linesLeadingWhitespacesCount = linesLeadingWhitespaces.map(
|
|
(ws) => ws.length
|
|
);
|
|
const min = Math.min(Infinity, ...linesLeadingWhitespacesCount);
|
|
return Number.isFinite(min) ? " ".repeat(Math.max(2, min)) : " ";
|
|
}
|
|
|
|
function getLineIndex(lines: string[], stringIndex: number): [number, number] {
|
|
let lineIndex = 0;
|
|
for (let l = 0; l < lines.length; l++) {
|
|
const line = lines[l];
|
|
if (stringIndex < lineIndex + line.length) {
|
|
return [l, stringIndex - lineIndex];
|
|
}
|
|
lineIndex += line.length + 1;
|
|
}
|
|
throw new Error(
|
|
`Index ${stringIndex} is out of bounds for lines ${JSON.stringify(lines)}`
|
|
);
|
|
}
|
|
|
|
async function getProjectPath(): Promise<string> {
|
|
logVerbose("getProjectPath invoked", { savedProjectPath });
|
|
if (savedProjectPath === undefined) {
|
|
savedProjectPath = process.cwd();
|
|
|
|
const askForPathModification = !fs.existsSync(
|
|
path.join(savedProjectPath, "package.json")
|
|
);
|
|
if (askForPathModification) {
|
|
logVerbose("getProjectPath did not find package.json in cwd", { cwd: savedProjectPath });
|
|
if (onQuestionMode === "guess" || onQuestionMode === "error") {
|
|
throw new UserError(`No package.json file found in ${savedProjectPath}. Re-run providing the project path argument (e.g. 'init-stack <project-path>').`);
|
|
}
|
|
savedProjectPath = (
|
|
await inquirer.prompt([
|
|
{
|
|
type: "input",
|
|
name: "newPath",
|
|
message: "Please enter the path to your project:",
|
|
default: ".",
|
|
},
|
|
])
|
|
).newPath;
|
|
logVerbose("getProjectPath received manual input", { savedProjectPath });
|
|
}
|
|
}
|
|
logVerbose("getProjectPath resolved", { savedProjectPath });
|
|
return savedProjectPath as string;
|
|
}
|
|
|
|
async function ensureGitWorkspaceIsReady(projectPath: string): Promise<void> {
|
|
if (!warnUncommittedChanges) {
|
|
logVerbose("ensureGitWorkspaceIsReady skipped as requested by user");
|
|
return;
|
|
}
|
|
|
|
logVerbose("ensureGitWorkspaceIsReady invoked", { projectPath });
|
|
let isGitRepo = false;
|
|
try {
|
|
const gitRepoResult = child_process.spawnSync(
|
|
"git",
|
|
["rev-parse", "--is-inside-work-tree"],
|
|
{
|
|
shell: true,
|
|
cwd: projectPath,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
}
|
|
);
|
|
isGitRepo = gitRepoResult.status === 0 && gitRepoResult.stdout.trim() === "true";
|
|
} catch (e) {
|
|
logVerbose("ensureGitWorkspaceIsReady failed to detect git repository", { error: e });
|
|
return;
|
|
}
|
|
if (!isGitRepo) {
|
|
logVerbose("ensureGitWorkspaceIsReady skipping", { reason: "not-a-git-repo" });
|
|
return;
|
|
}
|
|
|
|
const statusResult = child_process.spawnSync(
|
|
"git",
|
|
["status", "--porcelain"],
|
|
{
|
|
shell: true,
|
|
cwd: projectPath,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
}
|
|
);
|
|
if (statusResult.error || statusResult.status !== 0) {
|
|
logVerbose("ensureGitWorkspaceIsReady git status failed", { status: statusResult.status, error: statusResult.error });
|
|
return;
|
|
}
|
|
|
|
const lines = statusResult.stdout
|
|
.split("\n")
|
|
.map((line) => line.replace(/\r$/, ""))
|
|
.filter((line) => line.length > 0);
|
|
|
|
const unstagedLines = lines.filter((line) => {
|
|
if (line.startsWith("!!")) return false;
|
|
if (line.startsWith("??")) return true;
|
|
if (line.length < 2) return false;
|
|
const workingTreeStatus = line[1];
|
|
return Boolean(workingTreeStatus && workingTreeStatus !== " ");
|
|
});
|
|
|
|
if (unstagedLines.length === 0) {
|
|
logVerbose("ensureGitWorkspaceIsReady clean working tree");
|
|
return;
|
|
}
|
|
|
|
const changedFiles = unstagedLines.map((line) => {
|
|
const filePath = line.slice(3).trim();
|
|
return filePath.length > 0 ? filePath : line;
|
|
});
|
|
|
|
console.log();
|
|
console.log(colorize.yellow`Detected unstaged/uncommitted changes in your Git repository:`);
|
|
const filesToShow = changedFiles.slice(0, 10);
|
|
for (const file of filesToShow) {
|
|
console.log(` - ${file}`);
|
|
}
|
|
if (changedFiles.length > filesToShow.length) {
|
|
console.log(` - ...and ${changedFiles.length - filesToShow.length} more`);
|
|
}
|
|
console.log(colorize.yellow`You may want to stage and commit these changes before installing Hexclave, so you can review the changes afterwards.`);
|
|
console.log();
|
|
|
|
if (onQuestionMode === "guess") {
|
|
console.log(colorize.yellow`Continuing because --on-question=guess.`);
|
|
return;
|
|
}
|
|
if (onQuestionMode === "error") {
|
|
throw new UnansweredQuestionError("Unstaged changes detected in the project directory");
|
|
}
|
|
|
|
const { proceed } = await inquirer.prompt([
|
|
{
|
|
type: "confirm",
|
|
name: "proceed",
|
|
message: "Continue with Stack initialization anyway?",
|
|
default: false,
|
|
},
|
|
]);
|
|
|
|
if (!proceed) {
|
|
throw new UserError("Aborting Stack initialization to avoid overwriting unstaged changes.");
|
|
}
|
|
logVerbose("ensureGitWorkspaceIsReady user confirmed proceed despite unstaged changes");
|
|
}
|
|
|
|
async function findJsExtension(fullPathWithoutExtension: string): Promise<string | null> {
|
|
logVerbose("findJsExtension invoked", { fullPathWithoutExtension });
|
|
for (const ext of jsLikeFileExtensions) {
|
|
const fullPath = fullPathWithoutExtension + "." + ext;
|
|
if (fs.existsSync(fullPath)) {
|
|
logVerbose("findJsExtension found file", { fullPath, ext });
|
|
return ext;
|
|
}
|
|
}
|
|
logVerbose("findJsExtension no matching file", { fullPathWithoutExtension });
|
|
return null;
|
|
}
|
|
|
|
async function promptPackageManager(): Promise<string> {
|
|
const projectPath = await getProjectPath();
|
|
const yarnLock = fs.existsSync(path.join(projectPath, "yarn.lock"));
|
|
const pnpmLock = fs.existsSync(path.join(projectPath, "pnpm-lock.yaml"));
|
|
const npmLock = fs.existsSync(path.join(projectPath, "package-lock.json"));
|
|
const bunLock = fs.existsSync(path.join(projectPath, "bun.lockb")) || fs.existsSync(path.join(projectPath, "bun.lock"));
|
|
|
|
logVerbose("promptPackageManager inspecting lockfiles", { yarnLock, pnpmLock, npmLock, bunLock });
|
|
|
|
if (yarnLock && !pnpmLock && !npmLock && !bunLock) {
|
|
logVerbose("promptPackageManager auto-selected yarn");
|
|
return "yarn";
|
|
} else if (!yarnLock && pnpmLock && !npmLock && !bunLock) {
|
|
logVerbose("promptPackageManager auto-selected pnpm");
|
|
return "pnpm";
|
|
} else if (!yarnLock && !pnpmLock && npmLock && !bunLock) {
|
|
logVerbose("promptPackageManager auto-selected npm");
|
|
return "npm";
|
|
} else if (!yarnLock && !pnpmLock && !npmLock && bunLock) {
|
|
logVerbose("promptPackageManager auto-selected bun");
|
|
return "bun";
|
|
}
|
|
|
|
if (onQuestionMode === "guess") {
|
|
logVerbose("promptPackageManager defaulting to npm due to guess mode");
|
|
return "npm";
|
|
}
|
|
if (onQuestionMode === "error") {
|
|
throw new UnansweredQuestionError("Unable to determine the package manager. Re-run with one of: --npm, --yarn, --pnpm, or --bun.");
|
|
}
|
|
|
|
const answers = await inquirer.prompt([
|
|
{
|
|
type: "list",
|
|
name: "packageManager",
|
|
message: "Which package manager are you using for this project?",
|
|
choices: ["npm", "yarn", "pnpm", "bun"],
|
|
},
|
|
]);
|
|
logVerbose("promptPackageManager user selected", { packageManager: answers.packageManager });
|
|
return answers.packageManager;
|
|
}
|
|
|
|
type ShellOptions = {
|
|
quiet?: boolean,
|
|
shell?: boolean,
|
|
cwd?: string,
|
|
[key: string]: any,
|
|
}
|
|
|
|
async function shellNicelyFormatted(command: string, { quiet, ...options }: ShellOptions): Promise<void> {
|
|
logVerbose("shellNicelyFormatted invoked", { command, options: { ...options, quiet } });
|
|
let ui: any;
|
|
let interval: NodeJS.Timeout | undefined;
|
|
if (!quiet) {
|
|
console.log();
|
|
ui = new inquirer.ui.BottomBar();
|
|
let dots = 4;
|
|
ui.updateBottomBar(
|
|
colorize.blue`Running command: ${command}...`
|
|
);
|
|
interval = setInterval(() => {
|
|
if (!isDryRun) {
|
|
ui.updateBottomBar(
|
|
colorize.blue`Running command: ${command}${".".repeat(dots++ % 5)}`
|
|
);
|
|
}
|
|
}, 700);
|
|
}
|
|
|
|
try {
|
|
if (!isDryRun) {
|
|
const child = child_process.spawn(command, options);
|
|
logVerbose("shellNicelyFormatted spawned process", { pid: child.pid, command });
|
|
if (!quiet) {
|
|
child.stdout.pipe(ui.log);
|
|
child.stderr.pipe(ui.log);
|
|
}
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
child.on("exit", (code) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
} else {
|
|
logVerbose("shellNicelyFormatted command failed", { code });
|
|
reject(new Error(`Command ${command} failed with code ${code}`));
|
|
}
|
|
});
|
|
});
|
|
} else {
|
|
console.log(`[DRY-RUN] Would have run: ${command}`);
|
|
logVerbose("shellNicelyFormatted skipped due to dry run", { command });
|
|
}
|
|
|
|
if (!quiet) {
|
|
commandsExecuted.push(command);
|
|
ui.updateBottomBar(
|
|
`${colorize.green`√`} Command ${command} succeeded\n`
|
|
);
|
|
}
|
|
logVerbose("shellNicelyFormatted completed", { command });
|
|
} catch (e) {
|
|
logVerbose("shellNicelyFormatted encountered error", { command, error: e instanceof Error ? { message: e.message, stack: e.stack } : e });
|
|
if (!quiet) {
|
|
ui.updateBottomBar(
|
|
`${colorize.red`X`} Command ${command} failed\n`
|
|
);
|
|
}
|
|
throw e;
|
|
} finally {
|
|
if (interval) {
|
|
clearTimeout(interval);
|
|
}
|
|
if (!quiet) {
|
|
ui.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
async function readFile(fullPath: string): Promise<string | null> {
|
|
logVerbose("readFile invoked", { fullPath, isDryRun });
|
|
try {
|
|
if (!isDryRun) {
|
|
const content = fs.readFileSync(fullPath, "utf-8");
|
|
logVerbose("readFile succeeded", { fullPath, length: content.length });
|
|
return content;
|
|
}
|
|
logVerbose("readFile skipped due to dry run", { fullPath });
|
|
return null;
|
|
} catch (err: any) {
|
|
if (err.code === "ENOENT") {
|
|
logVerbose("readFile file missing", { fullPath });
|
|
return null;
|
|
}
|
|
logVerbose("readFile errored", { fullPath, error: err instanceof Error ? { message: err.message, stack: err.stack } : err });
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function writeFile(fullPath: string, content: string): Promise<void> {
|
|
logVerbose("writeFile invoked", { fullPath, length: content.length, isDryRun });
|
|
let create = !fs.existsSync(fullPath);
|
|
if (!isDryRun) {
|
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
fs.writeFileSync(fullPath, content);
|
|
logVerbose("writeFile wrote to disk", { fullPath, created: create });
|
|
} else {
|
|
console.log(`[DRY-RUN] Would have written to ${fullPath}`);
|
|
logVerbose("writeFile skipped due to dry run", { fullPath });
|
|
}
|
|
const relativeToProjectPath = path.relative(await getProjectPath(), fullPath);
|
|
if (!create) {
|
|
filesModified.push(relativeToProjectPath);
|
|
} else {
|
|
filesCreated.push(relativeToProjectPath);
|
|
}
|
|
logVerbose("writeFile recorded change", { relativeToProjectPath, created: create });
|
|
}
|
|
|
|
function laterWriteFile(fullPath: string, content: string): void {
|
|
logVerbose("laterWriteFile scheduled", { fullPath, length: content.length });
|
|
writeFileHandlers.push(async () => {
|
|
await writeFile(fullPath, content);
|
|
});
|
|
}
|
|
|
|
async function writeFileIfNotExists(fullPath: string, content: string): Promise<void> {
|
|
if (!fs.existsSync(fullPath)) {
|
|
logVerbose("writeFileIfNotExists writing new file", { fullPath });
|
|
await writeFile(fullPath, content);
|
|
} else {
|
|
logVerbose("writeFileIfNotExists skipped", { fullPath });
|
|
}
|
|
}
|
|
|
|
function laterWriteFileIfNotExists(fullPath: string, content: string): void {
|
|
logVerbose("laterWriteFileIfNotExists scheduled", { fullPath });
|
|
writeFileHandlers.push(async () => {
|
|
await writeFileIfNotExists(fullPath, content);
|
|
});
|
|
}
|
|
|
|
async function runDeferredCommands(): Promise<void> {
|
|
if (!deferredCommandHandlers.length) {
|
|
logVerbose("runDeferredCommands skipped", { reason: "no-handlers" });
|
|
return;
|
|
}
|
|
logVerbose("runDeferredCommands executing handlers", { count: deferredCommandHandlers.length });
|
|
for (let index = 0; index < deferredCommandHandlers.length; index++) {
|
|
logVerbose("runDeferredCommands executing handler", { index });
|
|
const handler = deferredCommandHandlers[index];
|
|
await handler();
|
|
}
|
|
logVerbose("runDeferredCommands completed");
|
|
}
|
|
|
|
type RunScheduledCommandMetadata = {
|
|
recordInCommandsExecuted?: boolean,
|
|
};
|
|
|
|
async function runScheduledCommand(
|
|
command: string,
|
|
args: string[],
|
|
options: child_process.SpawnSyncOptions = {},
|
|
metadata: RunScheduledCommandMetadata = {},
|
|
): Promise<void> {
|
|
logVerbose("runScheduledCommand invoked", { command, args, options, isDryRun });
|
|
const display = [command, ...args].join(" ");
|
|
if (isDryRun) {
|
|
console.log(`[DRY-RUN] Would run: ${display}`);
|
|
logVerbose("runScheduledCommand skipped due to dry run", { display });
|
|
return;
|
|
}
|
|
|
|
const result = child_process.spawnSync(command, args, {
|
|
stdio: "pipe",
|
|
...options,
|
|
});
|
|
const recordInCommandsExecuted = metadata.recordInCommandsExecuted;
|
|
if (recordInCommandsExecuted && !commandsExecuted.includes(display)) {
|
|
commandsExecuted.push(display);
|
|
}
|
|
if (result.status === 0) {
|
|
console.log(`${colorize.green`√`} ${display}`);
|
|
logVerbose("runScheduledCommand succeeded", { display });
|
|
} else {
|
|
logVerbose("runScheduledCommand failed", { display, result, stderr: result.stderr.toString(), stdout: result.stdout.toString() });
|
|
throw new Error(`Command ${display} failed with status ${result.status}: ${result.stderr.toString()}`);
|
|
}
|
|
}
|
|
|
|
async function recordFileChange(fullPath: string, existed: boolean): Promise<void> {
|
|
logVerbose("recordFileChange invoked", { fullPath, existed });
|
|
const projectRoot = path.resolve(await getProjectPath());
|
|
const relative = path.relative(projectRoot, fullPath);
|
|
const insideProject = relative && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
const entry = insideProject ? relative : fullPath;
|
|
|
|
if (existed) {
|
|
if (!filesModified.includes(entry)) {
|
|
filesModified.push(entry);
|
|
}
|
|
logVerbose("recordFileChange marked modified", { entry });
|
|
} else if (!filesCreated.includes(entry)) {
|
|
filesCreated.push(entry);
|
|
logVerbose("recordFileChange marked created", { entry });
|
|
}
|
|
}
|
|
|
|
function createConvexAuthConfigContent(options: { stackPackageName: string, type: "js" | "next" | "react" }): string {
|
|
const envVarName = getPublicProjectEnvVarName(options.type);
|
|
return `import { getConvexProvidersConfig } from ${JSON.stringify(options.stackPackageName)};
|
|
|
|
export default {
|
|
providers: getConvexProvidersConfig({
|
|
projectId: process.env.${envVarName},
|
|
}),
|
|
};
|
|
`;
|
|
}
|
|
|
|
function createConvexIntegrationConvexConfigContent(stackPackageName: string): string {
|
|
const importPath = `${stackPackageName}/convex.config`;
|
|
return `import stackAuthComponent from ${JSON.stringify(importPath)};
|
|
import { defineApp } from "convex/server";
|
|
|
|
const app = defineApp();
|
|
app.use(stackAuthComponent);
|
|
|
|
export default app;
|
|
`;
|
|
}
|
|
|
|
function integrateConvexConfig(existingContent: string, stackPackageName: string): string | null {
|
|
if (!existingContent.includes("defineApp")) {
|
|
return null;
|
|
}
|
|
|
|
const newline = existingContent.includes("\r\n") ? "\r\n" : "\n";
|
|
const normalizedLines = existingContent.replace(/\r\n/g, "\n").split("\n");
|
|
const importPath = `${stackPackageName}/convex.config`;
|
|
|
|
const hasImport = normalizedLines.some((line) => line.includes(importPath));
|
|
if (!hasImport) {
|
|
let insertIndex = 0;
|
|
while (insertIndex < normalizedLines.length && normalizedLines[insertIndex].trim() === "") {
|
|
insertIndex++;
|
|
}
|
|
while (insertIndex < normalizedLines.length && normalizedLines[insertIndex].trim().startsWith("import")) {
|
|
insertIndex++;
|
|
}
|
|
normalizedLines.splice(insertIndex, 0, `import stackAuthComponent from "${importPath}";`);
|
|
}
|
|
|
|
let lastImportIndex = -1;
|
|
for (let i = 0; i < normalizedLines.length; i++) {
|
|
if (normalizedLines[i].trim().startsWith("import")) {
|
|
lastImportIndex = i;
|
|
continue;
|
|
}
|
|
if (normalizedLines[i].trim() === "") {
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
if (lastImportIndex >= 0) {
|
|
const nextIndex = lastImportIndex + 1;
|
|
if (!normalizedLines[nextIndex] || normalizedLines[nextIndex].trim() !== "") {
|
|
normalizedLines.splice(nextIndex, 0, "");
|
|
}
|
|
}
|
|
|
|
const hasStackUse = normalizedLines.some((line) => line.includes("app.use(stackAuthComponent"));
|
|
if (!hasStackUse) {
|
|
const appLineIndex = normalizedLines.findIndex((line) => /const\s+app\s*=\s*defineApp/.test(line));
|
|
if (appLineIndex === -1) {
|
|
return null;
|
|
}
|
|
const indent = normalizedLines[appLineIndex].match(/^\s*/)?.[0] ?? "";
|
|
const insertIndexForUse = appLineIndex + 1;
|
|
normalizedLines.splice(insertIndexForUse, 0, `${indent}app.use(stackAuthComponent);`);
|
|
const nextLineIndex = insertIndexForUse + 1;
|
|
if (!normalizedLines[nextLineIndex] || normalizedLines[nextLineIndex].trim() !== "") {
|
|
normalizedLines.splice(nextLineIndex, 0, "");
|
|
}
|
|
}
|
|
|
|
let updated = normalizedLines.join(newline);
|
|
if (!updated.endsWith(newline)) {
|
|
updated += newline;
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
function isSimpleConvexConfig(content: string): boolean {
|
|
const normalized = content
|
|
.replace(/\r\n/g, "\n")
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0);
|
|
if (normalized.length !== 3) {
|
|
return false;
|
|
}
|
|
const [line1, line2, line3] = normalized;
|
|
const importRegex = /^import\s+\{\s*defineApp\s*\}\s+from\s+['"]convex\/server['"];?$/;
|
|
const appRegex = /^const\s+app\s*=\s*defineApp\(\s*\);?$/;
|
|
const exportRegex = /^export\s+default\s+app;?$/;
|
|
return importRegex.test(line1) && appRegex.test(line2) && exportRegex.test(line3);
|
|
}
|
|
|
|
function getPublicProjectEnvVarName(type: "js" | "next" | "react"): string {
|
|
if (type === "react") {
|
|
return "VITE_STACK_PROJECT_ID";
|
|
}
|
|
if (type === "next") {
|
|
return "NEXT_PUBLIC_STACK_PROJECT_ID";
|
|
}
|
|
return "STACK_PROJECT_ID";
|
|
}
|
|
|
|
type ConvexClientUpdateResult = {
|
|
updatedFiles: string[],
|
|
skippedFiles: string[],
|
|
};
|
|
|
|
type AddSetAuthResult = {
|
|
updatedContent: string,
|
|
changed: boolean,
|
|
usedClientApp: boolean,
|
|
usedServerApp: boolean,
|
|
instantiationCount: number,
|
|
skippedHttpCount: number,
|
|
};
|
|
|
|
async function updateConvexClients({ projectPath, type }: { projectPath: string, type: "js" | "next" | "react" }): Promise<ConvexClientUpdateResult> {
|
|
const files = collectConvexClientCandidateFiles(projectPath);
|
|
logVerbose("updateConvexClients collected files", { projectPath, count: files.length });
|
|
const updatedFiles: string[] = [];
|
|
const skippedFiles: string[] = [];
|
|
|
|
for (const filePath of files) {
|
|
logVerbose("updateConvexClients inspecting file", { filePath });
|
|
const fileContent = await readFile(filePath);
|
|
if (!fileContent) {
|
|
logVerbose("updateConvexClients skipped file (no content)", { filePath });
|
|
continue;
|
|
}
|
|
if (!/new\s+Convex(?:React|Http)?Client\b/.test(fileContent)) {
|
|
logVerbose("updateConvexClients skipped file (no Convex client)", { filePath });
|
|
continue;
|
|
}
|
|
|
|
const addResult = addSetAuthToConvexClients(fileContent, type);
|
|
logVerbose("updateConvexClients processed file", { filePath, addResult });
|
|
if (!addResult.changed) {
|
|
if (addResult.instantiationCount > 0 && addResult.skippedHttpCount > 0) {
|
|
skippedFiles.push(filePath);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let finalContent = addResult.updatedContent;
|
|
if (addResult.usedClientApp) {
|
|
logVerbose("updateConvexClients ensuring client import", { filePath });
|
|
finalContent = await ensureStackAppImport(finalContent, filePath, "client");
|
|
}
|
|
if (addResult.usedServerApp) {
|
|
logVerbose("updateConvexClients ensuring server import", { filePath });
|
|
finalContent = await ensureStackAppImport(finalContent, filePath, "server");
|
|
}
|
|
|
|
if (finalContent !== fileContent) {
|
|
laterWriteFile(filePath, finalContent);
|
|
updatedFiles.push(filePath);
|
|
logVerbose("updateConvexClients scheduled update", { filePath });
|
|
}
|
|
}
|
|
|
|
logVerbose("updateConvexClients finished", { updatedFiles, skippedFiles });
|
|
return {
|
|
updatedFiles,
|
|
skippedFiles,
|
|
};
|
|
}
|
|
|
|
type StackAppKind = "client" | "server";
|
|
|
|
async function ensureStackAppImport(content: string, filePath: string, kind: StackAppKind): Promise<string> {
|
|
logVerbose("ensureStackAppImport invoked", { filePath, kind });
|
|
const identifier = kind === "client" ? "hexclaveClientApp" : "hexclaveServerApp";
|
|
if (new RegExp(`import\\s+[^;]*\\b${identifier}\\b`).test(content)) {
|
|
logVerbose("ensureStackAppImport found existing import", { filePath, identifier });
|
|
return content;
|
|
}
|
|
|
|
const stackBasePath = await getStackAppBasePath(kind);
|
|
const relativeImportPath = convertToModuleSpecifier(path.relative(path.dirname(filePath), stackBasePath));
|
|
const newline = content.includes("\r\n") ? "\r\n" : "\n";
|
|
|
|
const lines = content.split(/\r?\n/);
|
|
const importLine = `import { ${identifier} } from "${relativeImportPath}";`;
|
|
|
|
let insertIndex = 0;
|
|
while (insertIndex < lines.length) {
|
|
const line = lines[insertIndex];
|
|
if (/^\s*['"]use (client|server)['"];?\s*$/.test(line)) {
|
|
insertIndex += 1;
|
|
continue;
|
|
}
|
|
if (/^\s*import\b/.test(line)) {
|
|
insertIndex += 1;
|
|
continue;
|
|
}
|
|
if (line.trim() === "") {
|
|
insertIndex += 1;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
|
|
lines.splice(insertIndex, 0, importLine);
|
|
const nextLine = lines[insertIndex + 1];
|
|
if (nextLine && nextLine.trim() !== "" && !/^\s*import\b/.test(nextLine)) {
|
|
lines.splice(insertIndex + 1, 0, "");
|
|
}
|
|
|
|
logVerbose("ensureStackAppImport added import", { filePath, importLine });
|
|
return lines.join(newline);
|
|
}
|
|
|
|
function convertToModuleSpecifier(relativePath: string): string {
|
|
let specifier = relativePath.replace(/\\/g, "/");
|
|
if (!specifier.startsWith(".")) {
|
|
specifier = "./" + specifier;
|
|
}
|
|
return specifier;
|
|
}
|
|
|
|
async function getStackAppBasePath(kind: StackAppKind): Promise<string> {
|
|
const srcPath = await Steps.guessSrcPath();
|
|
const basePath = path.join(srcPath, "stack", kind);
|
|
logVerbose("getStackAppBasePath resolved", { kind, basePath });
|
|
return basePath;
|
|
}
|
|
|
|
function addSetAuthToConvexClients(content: string, type: "js" | "next" | "react"): AddSetAuthResult {
|
|
logVerbose("addSetAuthToConvexClients invoked", { type, length: content.length });
|
|
const newline = content.includes("\r\n") ? "\r\n" : "\n";
|
|
const instantiationRegex = /^[ \t]*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+(Convex(?:React|Http)?Client)\b([\s\S]*?);/gm;
|
|
const replacements: Array<{ start: number, end: number, text: string }> = [];
|
|
let instantiationCount = 0;
|
|
let skippedHttpCount = 0;
|
|
let usedClientApp = false;
|
|
let usedServerApp = false;
|
|
|
|
let match: RegExpExecArray | null;
|
|
while ((match = instantiationRegex.exec(content)) !== null) {
|
|
instantiationCount += 1;
|
|
const fullMatch = match[0];
|
|
const variableName = match[1];
|
|
const className = match[2];
|
|
|
|
if (className === "ConvexHttpClient") {
|
|
skippedHttpCount += 1;
|
|
logVerbose("addSetAuthToConvexClients skipping ConvexHttpClient", { variableName, fileLength: content.length });
|
|
continue;
|
|
}
|
|
|
|
const remainder = content.slice(match.index + fullMatch.length);
|
|
const setAuthRegex = new RegExp(`\\b${escapeRegExp(variableName)}\\s*\\.setAuth\\s*\\(`);
|
|
if (setAuthRegex.test(remainder)) {
|
|
logVerbose("addSetAuthToConvexClients found existing setAuth", { variableName });
|
|
continue;
|
|
}
|
|
|
|
const indentation = fullMatch.match(/^[\t ]*/)?.[0] ?? "";
|
|
const authCall = determineAuthCallExpression({ type, className, content });
|
|
|
|
if (authCall.identifier === "hexclaveClientApp") {
|
|
usedClientApp = true;
|
|
} else {
|
|
usedServerApp = true;
|
|
}
|
|
|
|
const replacementText = `${fullMatch}${newline}${indentation}${variableName}.setAuth(${authCall.expression});`;
|
|
replacements.push({
|
|
start: match.index,
|
|
end: match.index + fullMatch.length,
|
|
text: replacementText,
|
|
});
|
|
logVerbose("addSetAuthToConvexClients queued replacement", { variableName, authCall });
|
|
}
|
|
|
|
if (replacements.length === 0) {
|
|
logVerbose("addSetAuthToConvexClients no replacements", { instantiationCount, skippedHttpCount });
|
|
return {
|
|
updatedContent: content,
|
|
changed: false,
|
|
usedClientApp,
|
|
usedServerApp,
|
|
instantiationCount,
|
|
skippedHttpCount,
|
|
};
|
|
}
|
|
|
|
let updatedContent = content;
|
|
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
const replacement = replacements[i];
|
|
updatedContent = `${updatedContent.slice(0, replacement.start)}${replacement.text}${updatedContent.slice(replacement.end)}`;
|
|
}
|
|
|
|
logVerbose("addSetAuthToConvexClients completed replacements", { replacements: replacements.length });
|
|
logVerbose("addSetAuthToConvexClients result", { changed: true, instantiationCount, skippedHttpCount, usedClientApp, usedServerApp });
|
|
return {
|
|
updatedContent,
|
|
changed: true,
|
|
usedClientApp,
|
|
usedServerApp,
|
|
instantiationCount,
|
|
skippedHttpCount,
|
|
};
|
|
}
|
|
|
|
function determineAuthCallExpression({ type, className, content }: { type: "js" | "next" | "react", className: string, content: string }): { expression: string, identifier: "hexclaveClientApp" | "hexclaveServerApp" } {
|
|
const hasClientAppReference = /\bhexclaveClientApp\b/.test(content);
|
|
const hasServerAppReference = /\bhexclaveServerApp\b/.test(content);
|
|
logVerbose("determineAuthCallExpression context", { type, className, hasClientAppReference, hasServerAppReference });
|
|
|
|
if (type === "js") {
|
|
const result = { expression: "hexclaveServerApp.getConvexClientAuth({})", identifier: "hexclaveServerApp" as const };
|
|
logVerbose("determineAuthCallExpression returning for JS", result);
|
|
return result;
|
|
}
|
|
|
|
if (hasClientAppReference) {
|
|
const result = { expression: getClientAuthCall(type), identifier: "hexclaveClientApp" as const };
|
|
logVerbose("determineAuthCallExpression using client reference", result);
|
|
return result;
|
|
}
|
|
if (hasServerAppReference && className !== "ConvexReactClient") {
|
|
const result = { expression: "hexclaveServerApp.getConvexClientAuth({})", identifier: "hexclaveServerApp" as const };
|
|
logVerbose("determineAuthCallExpression using server reference", result);
|
|
return result;
|
|
}
|
|
|
|
const fallback = { expression: getClientAuthCall(type), identifier: "hexclaveClientApp" as const };
|
|
logVerbose("determineAuthCallExpression fallback", fallback);
|
|
return fallback;
|
|
}
|
|
|
|
function getClientAuthCall(type: "js" | "next" | "react"): string {
|
|
logVerbose("getClientAuthCall invoked", { type });
|
|
return "hexclaveClientApp.getConvexClientAuth({})";
|
|
}
|
|
|
|
function collectConvexClientCandidateFiles(projectPath: string): string[] {
|
|
logVerbose("collectConvexClientCandidateFiles invoked", { projectPath });
|
|
const roots = getConvexSearchRoots(projectPath);
|
|
logVerbose("collectConvexClientCandidateFiles roots", { roots });
|
|
const files = new Set<string>();
|
|
const visited = new Set<string>();
|
|
|
|
for (const root of roots) {
|
|
walkDirectory(root, files, visited);
|
|
}
|
|
|
|
const result = Array.from(files);
|
|
logVerbose("collectConvexClientCandidateFiles result", { count: result.length });
|
|
return result;
|
|
}
|
|
|
|
function getConvexSearchRoots(projectPath: string): string[] {
|
|
const candidateDirs = ["convex", "src", "app", "components"];
|
|
const existing = candidateDirs
|
|
.map((dir) => path.join(projectPath, dir))
|
|
.filter((dirPath) => {
|
|
try {
|
|
return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
if (existing.length > 0) {
|
|
logVerbose("getConvexSearchRoots using existing directories", { existing });
|
|
return existing;
|
|
}
|
|
logVerbose("getConvexSearchRoots defaulting to project root", { projectPath });
|
|
return [projectPath];
|
|
}
|
|
|
|
const directorySkipList = new Set([
|
|
"node_modules",
|
|
".git",
|
|
".next",
|
|
".turbo",
|
|
".output",
|
|
".vercel",
|
|
"dist",
|
|
"build",
|
|
"coverage",
|
|
".cache",
|
|
".storybook",
|
|
"storybook-static",
|
|
]);
|
|
|
|
function walkDirectory(currentDir: string, files: Set<string>, visited: Set<string>): void {
|
|
const realPath = (() => {
|
|
try {
|
|
return fs.realpathSync(currentDir);
|
|
} catch {
|
|
return currentDir;
|
|
}
|
|
})();
|
|
|
|
if (visited.has(realPath)) return;
|
|
visited.add(realPath);
|
|
logVerbose("walkDirectory scanning", { currentDir: realPath });
|
|
|
|
let dirEntries: fs.Dirent[];
|
|
try {
|
|
dirEntries = fs.readdirSync(realPath, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
for (const entry of dirEntries) {
|
|
const entryName = entry.name;
|
|
if (entry.isDirectory()) {
|
|
if (directorySkipList.has(entryName)) {
|
|
logVerbose("walkDirectory skipping directory in skip list", { directory: entryName, parent: realPath });
|
|
continue;
|
|
}
|
|
if (entryName.startsWith(".") || entryName.startsWith("_")) {
|
|
logVerbose("walkDirectory skipping hidden directory", { directory: entryName, parent: realPath });
|
|
continue;
|
|
}
|
|
walkDirectory(path.join(realPath, entryName), files, visited);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) continue;
|
|
if (entryName.endsWith(".d.ts")) continue;
|
|
if (!hasJsLikeExtension(entryName)) continue;
|
|
const filePath = path.join(realPath, entryName);
|
|
files.add(filePath);
|
|
logVerbose("walkDirectory added file", { filePath });
|
|
}
|
|
}
|
|
|
|
function hasJsLikeExtension(fileName: string): boolean {
|
|
return jsLikeFileExtensions.some((ext) => fileName.endsWith(`.${ext}`));
|
|
}
|
|
|
|
function escapeRegExp(str: string): string {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function throwErr(message: string): never {
|
|
throw new Error(message);
|
|
}
|
|
|
|
async function clearStdin(): Promise<void> {
|
|
logVerbose("clearStdin invoked");
|
|
await new Promise<void>((resolve) => {
|
|
if (process.stdin.isTTY) {
|
|
process.stdin.setRawMode(true);
|
|
}
|
|
process.stdin.resume();
|
|
process.stdin.removeAllListeners('data');
|
|
|
|
const flush = () => {
|
|
while (process.stdin.read() !== null) { }
|
|
if (process.stdin.isTTY) {
|
|
process.stdin.setRawMode(false);
|
|
}
|
|
logVerbose("clearStdin flushed");
|
|
resolve();
|
|
};
|
|
|
|
// Add a small delay to allow any buffered input to clear
|
|
setTimeout(flush, 10);
|
|
});
|
|
logVerbose("clearStdin completed");
|
|
}
|