@percepta/create 3.1.0 → 3.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +15 -10
  2. package/dist/{chunk-7NPWSTCY.js → chunk-CO3YWUD6.js} +31 -2
  3. package/dist/{chunk-WMJT7CB5.js → chunk-V5EJIUBJ.js} +5 -2
  4. package/dist/index.js +93 -73
  5. package/dist/{init-NP6GRXLL.js → init-EQZ2TCSJ.js} +2 -2
  6. package/dist/{status-BTHGN6QH.js → status-QW5TQDYY.js} +1 -1
  7. package/dist/{sync-3Q27L7XZ.js → sync-RLBZDOFB.js} +1 -1
  8. package/dist/{upstream-C5KFAHVR.js → upstream-TQFVPMEG.js} +1 -1
  9. package/package.json +3 -2
  10. package/templates/monorepo/.dockerignore +18 -0
  11. package/templates/monorepo/gitignore.template +1 -0
  12. package/templates/webapp/.github/workflows/__APP_NAME__-ryvn-release.yaml +6 -2
  13. package/templates/webapp/.github/workflows/__APP_NAME__-terraform-ryvn-release.yaml +98 -0
  14. package/templates/webapp/AGENTS.md +17 -5
  15. package/templates/webapp/Dockerfile +16 -7
  16. package/templates/webapp/README.md +64 -2
  17. package/templates/webapp/agent-skills/deploy.md +50 -51
  18. package/templates/webapp/agent-skills/inngest.md +4 -4
  19. package/templates/webapp/agent-skills/langfuse.md +15 -14
  20. package/templates/webapp/agent-skills/llm.md +59 -0
  21. package/templates/webapp/agent-skills/oneshot.md +14 -1
  22. package/templates/webapp/agent-skills/ryvn.md +1 -1
  23. package/templates/webapp/deploy/README.md +41 -16
  24. package/templates/webapp/deploy/ryvn/__APP_NAME__-terraform.service.yaml +10 -0
  25. package/templates/webapp/deploy/ryvn/__APP_NAME__.service.yaml +2 -2
  26. package/templates/webapp/deploy/ryvn/environments/percepta-test/installations/__APP_NAME__-terraform.env.percepta-test.serviceinstallation.yaml +11 -0
  27. package/templates/webapp/deploy/ryvn/environments/percepta-test/installations/__APP_NAME__.env.percepta-test.serviceinstallation.yaml +60 -11
  28. package/templates/webapp/env.example.template +20 -2
  29. package/templates/webapp/eslint.config.mjs +7 -0
  30. package/templates/webapp/gitignore.template +1 -0
  31. package/templates/webapp/next.config.ts +9 -0
  32. package/templates/webapp/package.json.template +6 -2
  33. package/templates/webapp/scripts/deploy-percepta-test.ts +837 -0
  34. package/templates/webapp/scripts/migrate.ts +3 -0
  35. package/templates/webapp/scripts/open-ryvn-deploy-pr.ts +152 -32
  36. package/templates/webapp/scripts/seed.ts +1 -1
  37. package/templates/webapp/scripts/setup-database.ts +2 -1
  38. package/templates/webapp/scripts/start.sh +3 -2
  39. package/templates/webapp/scripts/with-local-env.ts +75 -0
  40. package/templates/webapp/src/app/(app)/layout.tsx +1 -5
  41. package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +11 -1
  42. package/templates/webapp/src/app/(auth)/auth/signup/CredentialsSignUpForm.tsx +113 -0
  43. package/templates/webapp/src/app/(auth)/auth/signup/page.tsx +30 -0
  44. package/templates/webapp/src/app/global-error.tsx +1 -1
  45. package/templates/webapp/src/components/FaroProvider.tsx +2 -4
  46. package/templates/webapp/src/components/form/FormItem.tsx +2 -2
  47. package/templates/webapp/src/config/getEnvConfig.ts +14 -0
  48. package/templates/webapp/src/drizzle/db.ts +2 -1
  49. package/templates/webapp/src/drizzle/migrations/0000_eager_grandmaster.sql +3 -3
  50. package/templates/webapp/src/drizzle/migrations/meta/0000_snapshot.json +7 -19
  51. package/templates/webapp/src/drizzle/ssl.ts +5 -0
  52. package/templates/webapp/src/instrumentation.ts +102 -10
  53. package/templates/webapp/src/lib/auth/index.ts +1 -1
  54. package/templates/webapp/src/lib/auth-client.ts +1 -1
  55. package/templates/webapp/src/services/llm/LLMService.ts +88 -0
  56. package/templates/webapp/src/services/llm/LlmProviderService.ts +85 -0
  57. package/templates/webapp/src/services/observability/initFaro.ts +1 -1
  58. package/templates/webapp/terraform/schema/main.tf +4 -0
  59. package/templates/webapp/terraform/schema/outputs.tf +9 -0
  60. package/templates/webapp/terraform/schema/variables.tf +19 -0
  61. package/templates/webapp/terraform/schema/versions.tf +38 -0
  62. package/templates/webapp/.github/workflows/__APP_NAME__-terraform.yml +0 -28
@@ -6,10 +6,13 @@ async function main(): Promise<void> {
6
6
  loadEnvConfig(process.cwd());
7
7
 
8
8
  // Dynamically load because we need to load the environment variables before importing modules that depend on them.
9
+ const { getEnvConfig } = await import("../src/config/getEnvConfig");
9
10
  const { client } = await import("../src/drizzle/db");
11
+ const { DATABASE_SCHEMA: databaseSchema } = getEnvConfig();
10
12
 
11
13
  await migrate(drizzle(client), {
12
14
  migrationsFolder: "./src/drizzle/migrations",
15
+ ...(databaseSchema ? { migrationsSchema: databaseSchema } : {}),
13
16
  });
14
17
 
15
18
  await client.end();
@@ -1,37 +1,60 @@
1
1
  #!/usr/bin/env tsx
2
- /* eslint-disable no-console, n/no-process-env */
2
+ /* eslint-disable n/no-process-env */
3
3
 
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { existsSync } from "node:fs";
6
6
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
7
7
  import path from "node:path";
8
- import { fileURLToPath } from "node:url";
9
- import { createInterface } from "node:readline/promises";
10
8
  import { stdin as input, stdout as output } from "node:process";
9
+ import { createInterface } from "node:readline/promises";
10
+ import { fileURLToPath } from "node:url";
11
11
 
12
12
  const APP_NAME = "__APP_NAME__";
13
13
  const DATABASE_SCHEMA = "__APP_NAME_SNAKE__";
14
14
  const ENVIRONMENT = "percepta-test";
15
15
  const INFRA_REMOTE = "git@github.com:Percepta-Core/infra.git";
16
16
 
17
+ type DeployPhase = "service" | "installation";
18
+
17
19
  interface Options {
18
20
  infraPath?: string;
19
21
  yes: boolean;
20
22
  noClone: boolean;
23
+ phase: DeployPhase;
21
24
  }
22
25
 
23
26
  function parseArgs(argv: string[]): Options {
24
27
  const options: Options = {
25
28
  yes: false,
26
29
  noClone: false,
30
+ phase: "service",
27
31
  };
28
32
 
29
33
  for (let index = 0; index < argv.length; index++) {
30
34
  const arg = argv[index];
31
- if (arg === "--yes" || arg === "-y") {
35
+ if (arg === "--") {
36
+ continue;
37
+ } else if (arg === "--yes" || arg === "-y") {
32
38
  options.yes = true;
33
39
  } else if (arg === "--no-clone") {
34
40
  options.noClone = true;
41
+ } else if (arg === "--service") {
42
+ options.phase = "service";
43
+ } else if (arg === "--installation" || arg === "--install") {
44
+ options.phase = "installation";
45
+ } else if (arg === "--phase") {
46
+ const value = argv[index + 1];
47
+ if (!value) throw new Error("--phase requires service or installation");
48
+ if (value === "service") {
49
+ options.phase = "service";
50
+ } else if (value === "installation" || value === "install") {
51
+ options.phase = "installation";
52
+ } else {
53
+ throw new Error(
54
+ `Invalid --phase value "${value}". Use service or installation.`,
55
+ );
56
+ }
57
+ index++;
35
58
  } else if (arg === "--infra") {
36
59
  const value = argv[index + 1];
37
60
  if (!value) throw new Error("--infra requires a path");
@@ -135,7 +158,9 @@ async function resolveInfraRepo(
135
158
  `Infra repo not found. Clone Percepta-Core/infra to ${infraPath}?`,
136
159
  );
137
160
  if (!shouldClone) {
138
- throw new Error("Skipped cloning infra repo. Re-run with --infra <path>.");
161
+ throw new Error(
162
+ "Skipped cloning infra repo. Re-run with --infra <path>.",
163
+ );
139
164
  }
140
165
  }
141
166
 
@@ -155,8 +180,14 @@ function assertCleanGitWorktree(repoPath: string): void {
155
180
  );
156
181
  }
157
182
 
158
- function switchDeployBranch(repoPath: string): string {
159
- const branchName = `deploy/${APP_NAME}-${ENVIRONMENT}`;
183
+ function getDeployBranchName(phase: DeployPhase): string {
184
+ return phase === "service"
185
+ ? `deploy/${APP_NAME}-${ENVIRONMENT}-service`
186
+ : `deploy/${APP_NAME}-${ENVIRONMENT}-installation`;
187
+ }
188
+
189
+ function switchDeployBranch(repoPath: string, phase: DeployPhase): string {
190
+ const branchName = getDeployBranchName(phase);
160
191
  const currentBranch = run(
161
192
  "git",
162
193
  ["branch", "--show-current"],
@@ -165,6 +196,8 @@ function switchDeployBranch(repoPath: string): string {
165
196
  );
166
197
  if (currentBranch === branchName) return branchName;
167
198
 
199
+ run("git", ["fetch", "origin", "main"], repoPath);
200
+
168
201
  let branchExists = false;
169
202
  try {
170
203
  run("git", ["rev-parse", "--verify", branchName], repoPath, "pipe");
@@ -176,7 +209,7 @@ function switchDeployBranch(repoPath: string): string {
176
209
  if (branchExists) {
177
210
  run("git", ["switch", branchName], repoPath);
178
211
  } else {
179
- run("git", ["switch", "-c", branchName], repoPath);
212
+ run("git", ["switch", "-c", branchName, "origin/main"], repoPath);
180
213
  }
181
214
 
182
215
  return branchName;
@@ -199,6 +232,7 @@ async function writeIfChanged(
199
232
  async function copyDeployYaml(
200
233
  packageDir: string,
201
234
  infraRepo: string,
235
+ phase: DeployPhase,
202
236
  ): Promise<string[]> {
203
237
  const serviceSource = path.join(
204
238
  packageDir,
@@ -216,7 +250,11 @@ async function copyDeployYaml(
216
250
  `${APP_NAME}.env.${ENVIRONMENT}.serviceinstallation.yaml`,
217
251
  );
218
252
 
219
- const serviceTarget = path.join(infraRepo, "ryvn", `${APP_NAME}.service.yaml`);
253
+ const serviceTarget = path.join(
254
+ infraRepo,
255
+ "ryvn",
256
+ `${APP_NAME}.service.yaml`,
257
+ );
220
258
  const installationTarget = path.join(
221
259
  infraRepo,
222
260
  "ryvn",
@@ -227,14 +265,18 @@ async function copyDeployYaml(
227
265
  );
228
266
 
229
267
  const changed: string[] = [];
230
- if (await writeIfChanged(serviceTarget, await readFile(serviceSource, "utf8"))) {
268
+ if (
269
+ phase === "service" &&
270
+ (await writeIfChanged(serviceTarget, await readFile(serviceSource, "utf8")))
271
+ ) {
231
272
  changed.push(path.relative(infraRepo, serviceTarget));
232
273
  }
233
274
  if (
234
- await writeIfChanged(
275
+ phase === "installation" &&
276
+ (await writeIfChanged(
235
277
  installationTarget,
236
278
  await readFile(installationSource, "utf8"),
237
- )
279
+ ))
238
280
  ) {
239
281
  changed.push(path.relative(infraRepo, installationTarget));
240
282
  }
@@ -300,10 +342,89 @@ function getPrUrl(repoPath: string, branchName: string): string | null {
300
342
  }
301
343
  }
302
344
 
345
+ function getPrTitle(phase: DeployPhase): string {
346
+ return phase === "service"
347
+ ? `Add ${APP_NAME} ${ENVIRONMENT} service`
348
+ : `Install ${APP_NAME} in ${ENVIRONMENT}`;
349
+ }
350
+
351
+ function getPrBody(phase: DeployPhase): string {
352
+ if (phase === "service") {
353
+ return [
354
+ `Adds the Ryvn service for ${APP_NAME}.`,
355
+ "",
356
+ `Also creates the ${DATABASE_SCHEMA} schema in the shared demos database.`,
357
+ "",
358
+ "After merge:",
359
+ "1. Let GitOps import the service.",
360
+ "2. Approve/apply the percepta-internal Terraform task if one is created.",
361
+ "3. Push the app to main or run the release workflow so Ryvn has a first release.",
362
+ `4. Open the installation PR with pnpm deploy:percepta-test:pr -- --phase installation --yes.`,
363
+ ].join("\n");
364
+ }
365
+
366
+ return [
367
+ `Adds the ${ENVIRONMENT} ServiceInstallation for ${APP_NAME}.`,
368
+ "",
369
+ "Pre-merge checklist:",
370
+ "1. The Ryvn Service exists from the service/schema PR.",
371
+ "2. At least one Ryvn release exists for this service.",
372
+ "",
373
+ "After merge, import deploy/ryvn/percepta-test.secrets.env in the Ryvn UI for the installation secrets.",
374
+ ].join("\n");
375
+ }
376
+
377
+ function printNextSteps(phase: DeployPhase): void {
378
+ if (phase === "service") {
379
+ console.log("");
380
+ console.log("Next:");
381
+ console.log("1. Merge the service/schema PR.");
382
+ console.log("2. Wait for GitOps to import the service.");
383
+ console.log(
384
+ "3. Approve/apply the percepta-internal Terraform task if one is created.",
385
+ );
386
+ console.log("4. Push the app to main or run the release workflow.");
387
+ console.log(
388
+ "5. Run: pnpm deploy:percepta-test:pr -- --phase installation --yes",
389
+ );
390
+ return;
391
+ }
392
+
393
+ console.log("");
394
+ console.log("Next:");
395
+ console.log("1. Merge the installation PR.");
396
+ console.log("2. Let GitOps import the ServiceInstallation.");
397
+ console.log(
398
+ "3. Import deploy/ryvn/percepta-test.secrets.env in the Ryvn UI.",
399
+ );
400
+ console.log(
401
+ "4. Verify health with: ryvn get installation __APP_NAME__ -e percepta-test",
402
+ );
403
+ }
404
+
405
+ async function confirmInstallationPrerequisites(
406
+ options: Options,
407
+ ): Promise<void> {
408
+ if (options.phase !== "installation" || options.yes) return;
409
+
410
+ const message =
411
+ "Before opening the installation PR, confirm the service exists, " +
412
+ "and a first Ryvn release exists. Continue?";
413
+
414
+ if (!process.stdin.isTTY) {
415
+ throw new Error(`${message} Re-run with --yes to confirm.`);
416
+ }
417
+
418
+ if (!(await confirm(message))) {
419
+ throw new Error("Skipped installation PR.");
420
+ }
421
+ }
422
+
303
423
  async function main(): Promise<void> {
304
424
  const options = parseArgs(process.argv.slice(2));
305
425
  assertCommand("git");
306
426
  assertCommand("gh");
427
+ await confirmInstallationPrerequisites(options);
307
428
 
308
429
  const packageDir = path.resolve(
309
430
  path.dirname(fileURLToPath(import.meta.url)),
@@ -314,16 +435,25 @@ async function main(): Promise<void> {
314
435
  const infraRepo = await resolveInfraRepo(options, defaultInfraPath);
315
436
 
316
437
  assertCleanGitWorktree(infraRepo);
317
- const branchName = switchDeployBranch(infraRepo);
438
+ const branchName = switchDeployBranch(infraRepo, options.phase);
318
439
 
319
- const changedPaths = await copyDeployYaml(packageDir, infraRepo);
320
- const schemaPath = await ensureDatabaseSchema(infraRepo);
321
- if (schemaPath) changedPaths.push(schemaPath);
440
+ const changedPaths = await copyDeployYaml(
441
+ packageDir,
442
+ infraRepo,
443
+ options.phase,
444
+ );
445
+ if (options.phase === "service") {
446
+ const schemaPath = await ensureDatabaseSchema(infraRepo);
447
+ if (schemaPath) changedPaths.push(schemaPath);
448
+ }
322
449
 
323
450
  if (changedPaths.length === 0) {
324
451
  console.log(
325
- "Infra repo already has the generated Ryvn files and database schema.",
452
+ options.phase === "service"
453
+ ? "Infra repo already has the generated Ryvn service and database schema."
454
+ : "Infra repo already has the generated Ryvn installation.",
326
455
  );
456
+ printNextSteps(options.phase);
327
457
  return;
328
458
  }
329
459
 
@@ -333,42 +463,32 @@ async function main(): Promise<void> {
333
463
  return;
334
464
  }
335
465
 
336
- run(
337
- "git",
338
- ["commit", "-m", `Add ${APP_NAME} ${ENVIRONMENT} deployment`],
339
- infraRepo,
340
- );
466
+ run("git", ["commit", "-m", getPrTitle(options.phase)], infraRepo);
341
467
  run("git", ["push", "-u", "origin", branchName], infraRepo);
342
468
 
343
469
  const existingPr = getPrUrl(infraRepo, branchName);
344
470
  if (existingPr) {
345
471
  console.log(`Infra PR: ${existingPr}`);
472
+ printNextSteps(options.phase);
346
473
  return;
347
474
  }
348
475
 
349
- const body = [
350
- `Adds the Ryvn service and ${ENVIRONMENT} installation for ${APP_NAME}.`,
351
- "",
352
- `Also creates the ${DATABASE_SCHEMA} schema in the shared demos database.`,
353
- "",
354
- "After merge, Ryvn GitOps should create the installation and the release workflow can deploy the app.",
355
- ].join("\n");
356
-
357
476
  const prUrl = run(
358
477
  "gh",
359
478
  [
360
479
  "pr",
361
480
  "create",
362
481
  "--title",
363
- `Add ${APP_NAME} ${ENVIRONMENT} deployment`,
482
+ getPrTitle(options.phase),
364
483
  "--body",
365
- body,
484
+ getPrBody(options.phase),
366
485
  ],
367
486
  infraRepo,
368
487
  "pipe",
369
488
  );
370
489
 
371
490
  console.log(`Infra PR: ${prUrl}`);
491
+ printNextSteps(options.phase);
372
492
  }
373
493
 
374
494
  void main().catch((error) => {
@@ -18,7 +18,7 @@ const DEFAULT_USER = {
18
18
 
19
19
  async function main(): Promise<void> {
20
20
  loadEnvConfig(process.cwd());
21
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
22
22
  (globalThis as any).AsyncLocalStorage = AsyncLocalStorage;
23
23
 
24
24
  const { auth } = await import("../src/lib/auth");
@@ -1,6 +1,7 @@
1
1
  import { loadEnvConfig } from "@next/env";
2
2
  import { Pool } from "pg";
3
3
  import { getEnvConfig } from "../src/config/getEnvConfig";
4
+ import { getPgSslConfig } from "../src/drizzle/ssl";
4
5
 
5
6
  const SHARED_DATABASES = new Set(["demos", "internal_apps"]);
6
7
 
@@ -39,7 +40,7 @@ async function main(): Promise<void> {
39
40
  user,
40
41
  password,
41
42
  database: "postgres", // Connect to default postgres database
42
- ssl: useSSL,
43
+ ssl: getPgSslConfig(useSSL),
43
44
  });
44
45
 
45
46
  try {
@@ -17,8 +17,9 @@ echo "Running database migrations..."
17
17
  if pnpm db:setup-and-migrate; then
18
18
  echo "✅ Database migrations completed successfully"
19
19
  else
20
- echo "❌ Database migration failed. App will start anyway."
20
+ echo "❌ Database migration failed. App will not start."
21
21
  echo "Check your database configuration and connectivity."
22
+ exit 1
22
23
  fi
23
24
 
24
25
  # Setup readonly database user for EDW (only if READONLY_SECRET_NAME is set)
@@ -49,4 +50,4 @@ fi
49
50
 
50
51
  # Start the Next.js application
51
52
  echo "Starting Next.js server on port ${PORT:-3000}..."
52
- exec pnpm start
53
+ exec pnpm start
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env tsx
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import path from "node:path";
7
+
8
+ type EnvMap = Record<string, string>;
9
+
10
+ const LOCAL_ENV_PATH = path.join(
11
+ homedir(),
12
+ ".config",
13
+ "percepta",
14
+ "create.env",
15
+ );
16
+
17
+ function parseEnvFile(filePath: string): EnvMap {
18
+ if (!existsSync(filePath)) return {};
19
+
20
+ const env: EnvMap = {};
21
+ const content = readFileSync(filePath, "utf8");
22
+ for (const rawLine of content.split(/\r?\n/)) {
23
+ const line = rawLine.trim();
24
+ if (!line || line.startsWith("#")) continue;
25
+
26
+ const normalized = line.startsWith("export ") ? line.slice(7).trim() : line;
27
+ const separatorIndex = normalized.indexOf("=");
28
+ if (separatorIndex === -1) continue;
29
+
30
+ const key = normalized.slice(0, separatorIndex).trim();
31
+ const rawValue = normalized.slice(separatorIndex + 1).trim();
32
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
33
+
34
+ env[key] = unquote(rawValue);
35
+ }
36
+
37
+ return env;
38
+ }
39
+
40
+ function unquote(value: string): string {
41
+ if (
42
+ (value.startsWith('"') && value.endsWith('"')) ||
43
+ (value.startsWith("'") && value.endsWith("'"))
44
+ ) {
45
+ return value.slice(1, -1);
46
+ }
47
+
48
+ return value;
49
+ }
50
+
51
+ const [command, ...args] = process.argv.slice(2);
52
+ if (!command) {
53
+ throw new Error("Usage: tsx scripts/with-local-env.ts <command> [...args]");
54
+ }
55
+
56
+ const child = spawn(command, args, {
57
+ env: {
58
+ ...parseEnvFile(LOCAL_ENV_PATH),
59
+ ...process.env,
60
+ },
61
+ stdio: "inherit",
62
+ });
63
+
64
+ child.on("error", (error) => {
65
+ throw error;
66
+ });
67
+
68
+ child.on("exit", (code, signal) => {
69
+ if (signal) {
70
+ process.kill(process.pid, signal);
71
+ return;
72
+ }
73
+
74
+ process.exit(code ?? 1);
75
+ });
@@ -1,11 +1,7 @@
1
1
  import React from "react";
2
2
  import Header from "../../components/Header";
3
3
 
4
- export default function AppLayout({
5
- children,
6
- }: {
7
- children: React.ReactNode;
8
- }) {
4
+ export default function AppLayout({ children }: { children: React.ReactNode }) {
9
5
  return (
10
6
  <>
11
7
  <Header />
@@ -2,14 +2,15 @@
2
2
 
3
3
  import { zodResolver } from "@hookform/resolvers/zod";
4
4
  import { Button, Input } from "@percepta/design";
5
+ import Link from "next/link";
5
6
  import { useRouter, useSearchParams } from "next/navigation";
6
7
  import React, { useCallback } from "react";
7
8
  import { Controller, useForm } from "react-hook-form";
8
9
  import { toast } from "sonner";
9
10
  import z from "zod";
10
11
  import { FormItem } from "../../../../components/form/FormItem";
11
- import { authClient } from "../../../../lib/auth-client";
12
12
  import { IS_DEV } from "../../../../config/isDev";
13
+ import { authClient } from "../../../../lib/auth-client";
13
14
 
14
15
  const CREDENTIALS_SCHEMA = z.object({
15
16
  email: z.string().min(1, "Email is required"),
@@ -66,6 +67,15 @@ export function CredentialsSignInForm() {
66
67
  <div className="space-y-8">
67
68
  <div className="text-center">
68
69
  <h1 className="text-2xl font-bold text-foreground">Sign In</h1>
70
+ <p className="mt-2 text-sm text-muted-foreground">
71
+ Need an account?{" "}
72
+ <Link
73
+ className="font-medium text-primary underline-offset-4 hover:underline"
74
+ href={`/auth/signup?callbackUrl=${encodeURIComponent(callbackUrl)}`}
75
+ >
76
+ Create one
77
+ </Link>
78
+ </p>
69
79
  </div>
70
80
  <form className="space-y-6" onSubmit={handleSubmit(submit)}>
71
81
  <div className="space-y-4">
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ import { zodResolver } from "@hookform/resolvers/zod";
4
+ import { Button, Input } from "@percepta/design";
5
+ import Link from "next/link";
6
+ import { useRouter, useSearchParams } from "next/navigation";
7
+ import React, { useCallback } from "react";
8
+ import { Controller, useForm } from "react-hook-form";
9
+ import { toast } from "sonner";
10
+ import z from "zod";
11
+ import { FormItem } from "../../../../components/form/FormItem";
12
+ import { authClient } from "../../../../lib/auth-client";
13
+
14
+ const CREDENTIALS_SCHEMA = z.object({
15
+ name: z.string().min(1, "Name is required"),
16
+ email: z.string().email("Enter a valid email address"),
17
+ password: z.string().min(8, "Password must be at least 8 characters"),
18
+ });
19
+
20
+ type Credentials = z.infer<typeof CREDENTIALS_SCHEMA>;
21
+
22
+ export function CredentialsSignUpForm() {
23
+ const router = useRouter();
24
+ const searchParams = useSearchParams();
25
+
26
+ const {
27
+ control,
28
+ formState: { isSubmitting },
29
+ handleSubmit,
30
+ } = useForm<Credentials>({
31
+ resolver: zodResolver(CREDENTIALS_SCHEMA),
32
+ defaultValues: {
33
+ name: "",
34
+ email: "",
35
+ password: "",
36
+ },
37
+ });
38
+
39
+ const callbackUrl = searchParams.get("callbackUrl") ?? "/";
40
+
41
+ const submit = useCallback(
42
+ async ({ name, email, password }: Credentials): Promise<void> => {
43
+ const { error } = await authClient.signUp.email({
44
+ name,
45
+ email,
46
+ password,
47
+ callbackURL: callbackUrl,
48
+ });
49
+
50
+ if (error != null) {
51
+ toast.error(error.message ?? "Unable to create account.");
52
+ return;
53
+ }
54
+
55
+ router.push(callbackUrl);
56
+ router.refresh();
57
+ },
58
+ [callbackUrl, router],
59
+ );
60
+
61
+ return (
62
+ <div className="space-y-8">
63
+ <div className="text-center">
64
+ <h1 className="text-2xl font-bold text-foreground">Create Account</h1>
65
+ <p className="mt-2 text-sm text-muted-foreground">
66
+ Already have an account?{" "}
67
+ <Link
68
+ className="font-medium text-primary underline-offset-4 hover:underline"
69
+ href={`/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`}
70
+ >
71
+ Sign in
72
+ </Link>
73
+ </p>
74
+ </div>
75
+ <form className="space-y-6" onSubmit={handleSubmit(submit)}>
76
+ <div className="space-y-4">
77
+ <Controller
78
+ control={control}
79
+ name="name"
80
+ render={({ field, fieldState }) => (
81
+ <FormItem label="Name" fieldState={fieldState}>
82
+ <Input {...field} autoComplete="name" />
83
+ </FormItem>
84
+ )}
85
+ />
86
+ <Controller
87
+ control={control}
88
+ name="email"
89
+ render={({ field, fieldState }) => (
90
+ <FormItem label="Email" fieldState={fieldState}>
91
+ <Input {...field} type="email" autoComplete="email" />
92
+ </FormItem>
93
+ )}
94
+ />
95
+ <Controller
96
+ control={control}
97
+ name="password"
98
+ render={({ field, fieldState }) => (
99
+ <FormItem label="Password" fieldState={fieldState}>
100
+ <Input {...field} type="password" autoComplete="new-password" />
101
+ </FormItem>
102
+ )}
103
+ />
104
+ </div>
105
+ <div className="flex justify-end">
106
+ <Button type="submit" loading={isSubmitting}>
107
+ Create Account
108
+ </Button>
109
+ </div>
110
+ </form>
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,30 @@
1
+ import type { Metadata } from "next";
2
+ import { headers } from "next/headers";
3
+ import { redirect } from "next/navigation";
4
+ import { Suspense } from "react";
5
+ import { auth } from "../../../../lib/auth";
6
+ import { CredentialsSignUpForm } from "./CredentialsSignUpForm";
7
+
8
+ export const metadata: Metadata = {
9
+ title: "Create Account — __APP_TITLE__",
10
+ };
11
+
12
+ export default async function SignUpPage() {
13
+ const session = await auth.api.getSession({
14
+ headers: await headers(),
15
+ });
16
+
17
+ if (session?.user != null) {
18
+ redirect("/");
19
+ }
20
+
21
+ return (
22
+ <Suspense
23
+ fallback={
24
+ <p className="text-center text-sm text-muted-foreground">Loading...</p>
25
+ }
26
+ >
27
+ <CredentialsSignUpForm />
28
+ </Suspense>
29
+ );
30
+ }
@@ -18,7 +18,7 @@ export default function GlobalError({
18
18
 
19
19
  return (
20
20
  <html lang="en">
21
- <body suppressHydrationWarning>
21
+ <body suppressHydrationWarning={true}>
22
22
  <div style={{ padding: "2rem", textAlign: "center" }}>
23
23
  <h1>Something went wrong</h1>
24
24
  <button onClick={() => reset()}>Try again</button>
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
+ import { Alert, AlertDescription, AlertTitle, Button } from "@percepta/design";
3
4
  import { FaroProvider as BaseFaroProvider } from "@percepta/next-utils/faro";
4
- import { Alert, AlertTitle, AlertDescription, Button } from "@percepta/design";
5
5
  import { type ReactNode } from "react";
6
6
 
7
7
  // Import to trigger Faro initialization at module scope (earliest possible)
@@ -9,9 +9,7 @@ import "../services/observability/initFaro";
9
9
 
10
10
  export function AppFaroProvider({ children }: { children: ReactNode }) {
11
11
  return (
12
- <BaseFaroProvider fallback={<ErrorFallback />}>
13
- {children}
14
- </BaseFaroProvider>
12
+ <BaseFaroProvider fallback={<ErrorFallback />}>{children}</BaseFaroProvider>
15
13
  );
16
14
  }
17
15