@percepta/create 3.0.1 → 3.1.2

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 (47) hide show
  1. package/README.md +16 -9
  2. package/dist/{chunk-GEVZERMP.js → chunk-CG7IJSB4.js} +33 -2
  3. package/dist/{chunk-R4FWPE4A.js → chunk-DCM7JOSC.js} +2 -2
  4. package/dist/index.js +281 -82
  5. package/dist/{init-Z4VGBHAK.js → init-XDWSYHYK.js} +1 -1
  6. package/dist/{status-MITGDLTT.js → status-BTHGN6QH.js} +1 -1
  7. package/dist/{sync-J4SFZHDX.js → sync-3Q27L7XZ.js} +1 -1
  8. package/dist/{upstream-AQI7P4EU.js → upstream-C5KFAHVR.js} +1 -1
  9. package/package.json +3 -2
  10. package/templates/monorepo/gitignore.template +1 -0
  11. package/templates/webapp/.github/workflows/__APP_NAME__-ryvn-release.yaml +3 -2
  12. package/templates/webapp/AGENTS.md +8 -2
  13. package/templates/webapp/Dockerfile +0 -1
  14. package/templates/webapp/README.md +1 -0
  15. package/templates/webapp/agent-skills/database.md +1 -0
  16. package/templates/webapp/agent-skills/deploy.md +45 -32
  17. package/templates/webapp/agent-skills/oneshot.md +3 -3
  18. package/templates/webapp/deploy/README.md +32 -6
  19. package/templates/webapp/deploy/ryvn/__APP_NAME__.service.yaml +0 -2
  20. package/templates/webapp/deploy/ryvn/environments/percepta-test/installations/__APP_NAME__.env.percepta-test.serviceinstallation.yaml +28 -31
  21. package/templates/webapp/drizzle.config.ts +15 -6
  22. package/templates/webapp/env.example.template +1 -0
  23. package/templates/webapp/eslint.config.mjs +8 -0
  24. package/templates/webapp/gitignore.template +1 -0
  25. package/templates/webapp/package.json.template +6 -6
  26. package/templates/webapp/scripts/open-ryvn-deploy-pr.ts +495 -0
  27. package/templates/webapp/scripts/seed.ts +1 -1
  28. package/templates/webapp/scripts/setup-database.ts +16 -1
  29. package/templates/webapp/scripts/start.sh +3 -2
  30. package/templates/webapp/src/app/(app)/layout.tsx +1 -5
  31. package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +11 -1
  32. package/templates/webapp/src/app/(auth)/auth/signup/CredentialsSignUpForm.tsx +113 -0
  33. package/templates/webapp/src/app/(auth)/auth/signup/page.tsx +30 -0
  34. package/templates/webapp/src/app/global-error.tsx +3 -1
  35. package/templates/webapp/src/components/FaroProvider.tsx +2 -4
  36. package/templates/webapp/src/components/form/FormItem.tsx +2 -2
  37. package/templates/webapp/src/config/getEnvConfig.ts +1 -0
  38. package/templates/webapp/src/drizzle/db.ts +5 -1
  39. package/templates/webapp/src/drizzle/migrations/0000_eager_grandmaster.sql +3 -3
  40. package/templates/webapp/src/drizzle/migrations/meta/0000_snapshot.json +7 -19
  41. package/templates/webapp/src/drizzle/searchPath.test.ts +21 -0
  42. package/templates/webapp/src/drizzle/searchPath.ts +16 -0
  43. package/templates/webapp/src/drizzle/ssl.ts +5 -0
  44. package/templates/webapp/src/lib/auth/index.ts +1 -1
  45. package/templates/webapp/src/lib/auth-client.ts +1 -1
  46. package/templates/webapp/src/services/observability/initFaro.ts +1 -1
  47. package/templates/webapp/src/styles/globals.css +0 -7
@@ -0,0 +1,495 @@
1
+ #!/usr/bin/env tsx
2
+ /* eslint-disable n/no-process-env */
3
+
4
+ import { execFileSync } from "node:child_process";
5
+ import { existsSync } from "node:fs";
6
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
7
+ import path from "node:path";
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
+
12
+ const APP_NAME = "__APP_NAME__";
13
+ const DATABASE_SCHEMA = "__APP_NAME_SNAKE__";
14
+ const ENVIRONMENT = "percepta-test";
15
+ const INFRA_REMOTE = "git@github.com:Percepta-Core/infra.git";
16
+
17
+ type DeployPhase = "service" | "installation";
18
+
19
+ interface Options {
20
+ infraPath?: string;
21
+ yes: boolean;
22
+ noClone: boolean;
23
+ phase: DeployPhase;
24
+ }
25
+
26
+ function parseArgs(argv: string[]): Options {
27
+ const options: Options = {
28
+ yes: false,
29
+ noClone: false,
30
+ phase: "service",
31
+ };
32
+
33
+ for (let index = 0; index < argv.length; index++) {
34
+ const arg = argv[index];
35
+ if (arg === "--yes" || arg === "-y") {
36
+ options.yes = true;
37
+ } else if (arg === "--no-clone") {
38
+ options.noClone = true;
39
+ } else if (arg === "--service") {
40
+ options.phase = "service";
41
+ } else if (arg === "--installation" || arg === "--install") {
42
+ options.phase = "installation";
43
+ } else if (arg === "--phase") {
44
+ const value = argv[index + 1];
45
+ if (!value) throw new Error("--phase requires service or installation");
46
+ if (value === "service") {
47
+ options.phase = "service";
48
+ } else if (value === "installation" || value === "install") {
49
+ options.phase = "installation";
50
+ } else {
51
+ throw new Error(
52
+ `Invalid --phase value "${value}". Use service or installation.`,
53
+ );
54
+ }
55
+ index++;
56
+ } else if (arg === "--infra") {
57
+ const value = argv[index + 1];
58
+ if (!value) throw new Error("--infra requires a path");
59
+ options.infraPath = path.resolve(value);
60
+ index++;
61
+ } else {
62
+ throw new Error(`Unknown argument: ${arg}`);
63
+ }
64
+ }
65
+
66
+ return options;
67
+ }
68
+
69
+ function run(
70
+ command: string,
71
+ args: string[],
72
+ cwd: string,
73
+ stdio: "inherit" | "pipe" = "inherit",
74
+ ): string {
75
+ const result = execFileSync(command, args, {
76
+ cwd,
77
+ encoding: "utf8",
78
+ stdio,
79
+ });
80
+
81
+ return typeof result === "string" ? result.trim() : "";
82
+ }
83
+
84
+ function assertCommand(command: string): void {
85
+ try {
86
+ run(command, ["--version"], process.cwd(), "pipe");
87
+ } catch {
88
+ throw new Error(
89
+ `Required command not found or not authenticated: ${command}`,
90
+ );
91
+ }
92
+ }
93
+
94
+ function findUp(startDir: string, fileName: string): string | null {
95
+ let dir = startDir;
96
+ while (true) {
97
+ if (existsSync(path.join(dir, fileName))) return dir;
98
+ const parent = path.dirname(dir);
99
+ if (parent === dir) return null;
100
+ dir = parent;
101
+ }
102
+ }
103
+
104
+ function isGitRepo(dir: string): boolean {
105
+ if (!existsSync(dir)) return false;
106
+ try {
107
+ run("git", ["rev-parse", "--is-inside-work-tree"], dir, "pipe");
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ async function confirm(message: string): Promise<boolean> {
115
+ const rl = createInterface({ input, output });
116
+ try {
117
+ const answer = (await rl.question(`${message} [Y/n] `))
118
+ .trim()
119
+ .toLowerCase();
120
+ return answer === "" || answer === "y" || answer === "yes";
121
+ } finally {
122
+ rl.close();
123
+ }
124
+ }
125
+
126
+ async function resolveInfraRepo(
127
+ options: Options,
128
+ defaultInfraPath: string,
129
+ ): Promise<string> {
130
+ const infraPath =
131
+ options.infraPath ?? process.env.INFRA_REPO ?? defaultInfraPath;
132
+
133
+ if (isGitRepo(infraPath)) {
134
+ console.log(`Using infra repo: ${infraPath}`);
135
+ return infraPath;
136
+ }
137
+
138
+ if (existsSync(infraPath)) {
139
+ throw new Error(`${infraPath} exists but is not a git repository`);
140
+ }
141
+
142
+ if (options.noClone) {
143
+ throw new Error(
144
+ `Infra repo not found at ${infraPath}. Re-run with --infra <path>.`,
145
+ );
146
+ }
147
+
148
+ if (!options.yes) {
149
+ if (!process.stdin.isTTY) {
150
+ throw new Error(
151
+ `Infra repo not found at ${infraPath}. Re-run with --yes to clone or --infra <path>.`,
152
+ );
153
+ }
154
+
155
+ const shouldClone = await confirm(
156
+ `Infra repo not found. Clone Percepta-Core/infra to ${infraPath}?`,
157
+ );
158
+ if (!shouldClone) {
159
+ throw new Error(
160
+ "Skipped cloning infra repo. Re-run with --infra <path>.",
161
+ );
162
+ }
163
+ }
164
+
165
+ await mkdir(path.dirname(infraPath), { recursive: true });
166
+ console.log(`Cloning ${INFRA_REMOTE} to ${infraPath}...`);
167
+ run("git", ["clone", INFRA_REMOTE, infraPath], process.cwd());
168
+ console.log(`Cloned infra repo: ${infraPath}`);
169
+ return infraPath;
170
+ }
171
+
172
+ function assertCleanGitWorktree(repoPath: string): void {
173
+ const status = run("git", ["status", "--porcelain"], repoPath, "pipe");
174
+ if (status.length === 0) return;
175
+
176
+ throw new Error(
177
+ `Infra repo has local changes. Commit/stash them first or use --infra with a clean checkout.\n${status}`,
178
+ );
179
+ }
180
+
181
+ function getDeployBranchName(phase: DeployPhase): string {
182
+ return phase === "service"
183
+ ? `deploy/${APP_NAME}-${ENVIRONMENT}-service`
184
+ : `deploy/${APP_NAME}-${ENVIRONMENT}-installation`;
185
+ }
186
+
187
+ function switchDeployBranch(repoPath: string, phase: DeployPhase): string {
188
+ const branchName = getDeployBranchName(phase);
189
+ const currentBranch = run(
190
+ "git",
191
+ ["branch", "--show-current"],
192
+ repoPath,
193
+ "pipe",
194
+ );
195
+ if (currentBranch === branchName) return branchName;
196
+
197
+ run("git", ["fetch", "origin", "main"], repoPath);
198
+
199
+ let branchExists = false;
200
+ try {
201
+ run("git", ["rev-parse", "--verify", branchName], repoPath, "pipe");
202
+ branchExists = true;
203
+ } catch {
204
+ branchExists = false;
205
+ }
206
+
207
+ if (branchExists) {
208
+ run("git", ["switch", branchName], repoPath);
209
+ } else {
210
+ run("git", ["switch", "-c", branchName, "origin/main"], repoPath);
211
+ }
212
+
213
+ return branchName;
214
+ }
215
+
216
+ async function writeIfChanged(
217
+ filePath: string,
218
+ content: string,
219
+ ): Promise<boolean> {
220
+ if (existsSync(filePath)) {
221
+ const existing = await readFile(filePath, "utf8");
222
+ if (existing === content) return false;
223
+ }
224
+
225
+ await mkdir(path.dirname(filePath), { recursive: true });
226
+ await writeFile(filePath, content);
227
+ return true;
228
+ }
229
+
230
+ async function copyDeployYaml(
231
+ packageDir: string,
232
+ infraRepo: string,
233
+ phase: DeployPhase,
234
+ ): Promise<string[]> {
235
+ const serviceSource = path.join(
236
+ packageDir,
237
+ "deploy",
238
+ "ryvn",
239
+ `${APP_NAME}.service.yaml`,
240
+ );
241
+ const installationSource = path.join(
242
+ packageDir,
243
+ "deploy",
244
+ "ryvn",
245
+ "environments",
246
+ ENVIRONMENT,
247
+ "installations",
248
+ `${APP_NAME}.env.${ENVIRONMENT}.serviceinstallation.yaml`,
249
+ );
250
+
251
+ const serviceTarget = path.join(
252
+ infraRepo,
253
+ "ryvn",
254
+ `${APP_NAME}.service.yaml`,
255
+ );
256
+ const installationTarget = path.join(
257
+ infraRepo,
258
+ "ryvn",
259
+ "environments",
260
+ ENVIRONMENT,
261
+ "installations",
262
+ `${APP_NAME}.env.${ENVIRONMENT}.serviceinstallation.yaml`,
263
+ );
264
+
265
+ const changed: string[] = [];
266
+ if (
267
+ phase === "service" &&
268
+ (await writeIfChanged(serviceTarget, await readFile(serviceSource, "utf8")))
269
+ ) {
270
+ changed.push(path.relative(infraRepo, serviceTarget));
271
+ }
272
+ if (
273
+ phase === "installation" &&
274
+ (await writeIfChanged(
275
+ installationTarget,
276
+ await readFile(installationSource, "utf8"),
277
+ ))
278
+ ) {
279
+ changed.push(path.relative(infraRepo, installationTarget));
280
+ }
281
+
282
+ return changed;
283
+ }
284
+
285
+ async function ensureDatabaseSchema(infraRepo: string): Promise<string | null> {
286
+ const databasesPath = path.join(
287
+ infraRepo,
288
+ "terraform",
289
+ "percepta-internal",
290
+ "databases.tf",
291
+ );
292
+ const content = await readFile(databasesPath, "utf8");
293
+
294
+ if (
295
+ content.includes(`resource "postgresql_schema" "${DATABASE_SCHEMA}"`) ||
296
+ content.includes(`name = "${DATABASE_SCHEMA}"`)
297
+ ) {
298
+ return null;
299
+ }
300
+
301
+ const block = `resource "postgresql_schema" "${DATABASE_SCHEMA}" {
302
+ provider = postgresql.percepta_internal
303
+ database = postgresql_database.demos.name
304
+ name = "${DATABASE_SCHEMA}"
305
+ }
306
+ `;
307
+
308
+ await appendFile(
309
+ databasesPath,
310
+ `${content.endsWith("\n") ? "\n" : "\n\n"}${block}`,
311
+ );
312
+
313
+ return path.relative(infraRepo, databasesPath);
314
+ }
315
+
316
+ function hasStagedChanges(repoPath: string): boolean {
317
+ try {
318
+ run("git", ["diff", "--cached", "--quiet"], repoPath, "pipe");
319
+ return false;
320
+ } catch (error) {
321
+ const status =
322
+ typeof error === "object" && error !== null && "status" in error
323
+ ? (error as { status?: number }).status
324
+ : undefined;
325
+ if (status === 1) return true;
326
+ throw error;
327
+ }
328
+ }
329
+
330
+ function getPrUrl(repoPath: string, branchName: string): string | null {
331
+ try {
332
+ return run(
333
+ "gh",
334
+ ["pr", "view", branchName, "--json", "url", "--jq", ".url"],
335
+ repoPath,
336
+ "pipe",
337
+ );
338
+ } catch {
339
+ return null;
340
+ }
341
+ }
342
+
343
+ function getPrTitle(phase: DeployPhase): string {
344
+ return phase === "service"
345
+ ? `Add ${APP_NAME} ${ENVIRONMENT} service`
346
+ : `Install ${APP_NAME} in ${ENVIRONMENT}`;
347
+ }
348
+
349
+ function getPrBody(phase: DeployPhase): string {
350
+ if (phase === "service") {
351
+ return [
352
+ `Adds the Ryvn service for ${APP_NAME}.`,
353
+ "",
354
+ `Also creates the ${DATABASE_SCHEMA} schema in the shared demos database.`,
355
+ "",
356
+ "After merge:",
357
+ "1. Let GitOps import the service.",
358
+ "2. Approve/apply the percepta-internal Terraform task if one is created.",
359
+ "3. Push the app to main or run the release workflow so Ryvn has a first release.",
360
+ `4. Open the installation PR with pnpm deploy:percepta-test -- --phase installation --yes.`,
361
+ ].join("\n");
362
+ }
363
+
364
+ return [
365
+ `Adds the ${ENVIRONMENT} ServiceInstallation for ${APP_NAME}.`,
366
+ "",
367
+ "Pre-merge checklist:",
368
+ "1. The Ryvn Service exists from the service/schema PR.",
369
+ "2. At least one Ryvn release exists for this service.",
370
+ "",
371
+ "After merge, import deploy/ryvn/percepta-test.secrets.env in the Ryvn UI for the installation secrets.",
372
+ ].join("\n");
373
+ }
374
+
375
+ function printNextSteps(phase: DeployPhase): void {
376
+ if (phase === "service") {
377
+ console.log("");
378
+ console.log("Next:");
379
+ console.log("1. Merge the service/schema PR.");
380
+ console.log("2. Wait for GitOps to import the service.");
381
+ console.log(
382
+ "3. Approve/apply the percepta-internal Terraform task if one is created.",
383
+ );
384
+ console.log("4. Push the app to main or run the release workflow.");
385
+ console.log(
386
+ "5. Run: pnpm deploy:percepta-test -- --phase installation --yes",
387
+ );
388
+ return;
389
+ }
390
+
391
+ console.log("");
392
+ console.log("Next:");
393
+ console.log("1. Merge the installation PR.");
394
+ console.log("2. Let GitOps import the ServiceInstallation.");
395
+ console.log(
396
+ "3. Import deploy/ryvn/percepta-test.secrets.env in the Ryvn UI.",
397
+ );
398
+ console.log(
399
+ "4. Verify health with: ryvn get installation __APP_NAME__ -e percepta-test",
400
+ );
401
+ }
402
+
403
+ async function confirmInstallationPrerequisites(
404
+ options: Options,
405
+ ): Promise<void> {
406
+ if (options.phase !== "installation" || options.yes) return;
407
+
408
+ const message =
409
+ "Before opening the installation PR, confirm the service exists, " +
410
+ "and a first Ryvn release exists. Continue?";
411
+
412
+ if (!process.stdin.isTTY) {
413
+ throw new Error(`${message} Re-run with --yes to confirm.`);
414
+ }
415
+
416
+ if (!(await confirm(message))) {
417
+ throw new Error("Skipped installation PR.");
418
+ }
419
+ }
420
+
421
+ async function main(): Promise<void> {
422
+ const options = parseArgs(process.argv.slice(2));
423
+ assertCommand("git");
424
+ assertCommand("gh");
425
+ await confirmInstallationPrerequisites(options);
426
+
427
+ const packageDir = path.resolve(
428
+ path.dirname(fileURLToPath(import.meta.url)),
429
+ "..",
430
+ );
431
+ const monorepoRoot = findUp(packageDir, "pnpm-workspace.yaml") ?? packageDir;
432
+ const defaultInfraPath = path.resolve(monorepoRoot, "..", "infra");
433
+ const infraRepo = await resolveInfraRepo(options, defaultInfraPath);
434
+
435
+ assertCleanGitWorktree(infraRepo);
436
+ const branchName = switchDeployBranch(infraRepo, options.phase);
437
+
438
+ const changedPaths = await copyDeployYaml(
439
+ packageDir,
440
+ infraRepo,
441
+ options.phase,
442
+ );
443
+ if (options.phase === "service") {
444
+ const schemaPath = await ensureDatabaseSchema(infraRepo);
445
+ if (schemaPath) changedPaths.push(schemaPath);
446
+ }
447
+
448
+ if (changedPaths.length === 0) {
449
+ console.log(
450
+ options.phase === "service"
451
+ ? "Infra repo already has the generated Ryvn service and database schema."
452
+ : "Infra repo already has the generated Ryvn installation.",
453
+ );
454
+ printNextSteps(options.phase);
455
+ return;
456
+ }
457
+
458
+ run("git", ["add", ...changedPaths], infraRepo);
459
+ if (!hasStagedChanges(infraRepo)) {
460
+ console.log("No infra changes to commit.");
461
+ return;
462
+ }
463
+
464
+ run("git", ["commit", "-m", getPrTitle(options.phase)], infraRepo);
465
+ run("git", ["push", "-u", "origin", branchName], infraRepo);
466
+
467
+ const existingPr = getPrUrl(infraRepo, branchName);
468
+ if (existingPr) {
469
+ console.log(`Infra PR: ${existingPr}`);
470
+ printNextSteps(options.phase);
471
+ return;
472
+ }
473
+
474
+ const prUrl = run(
475
+ "gh",
476
+ [
477
+ "pr",
478
+ "create",
479
+ "--title",
480
+ getPrTitle(options.phase),
481
+ "--body",
482
+ getPrBody(options.phase),
483
+ ],
484
+ infraRepo,
485
+ "pipe",
486
+ );
487
+
488
+ console.log(`Infra PR: ${prUrl}`);
489
+ printNextSteps(options.phase);
490
+ }
491
+
492
+ void main().catch((error) => {
493
+ console.error(error instanceof Error ? error.message : error);
494
+ process.exit(1);
495
+ });
@@ -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
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,9 @@
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";
5
+
6
+ const SHARED_DATABASES = new Set(["demos", "internal_apps"]);
4
7
 
5
8
  async function main(): Promise<void> {
6
9
  loadEnvConfig(process.cwd());
@@ -11,6 +14,7 @@ async function main(): Promise<void> {
11
14
  DATABASE_USERNAME: user,
12
15
  DATABASE_PASSWORD: password,
13
16
  DATABASE_NAME: database,
17
+ DATABASE_SCHEMA: databaseSchema,
14
18
  DATABASE_USE_SSL: useSSL,
15
19
  } = getEnvConfig();
16
20
 
@@ -18,6 +22,17 @@ async function main(): Promise<void> {
18
22
  console.log(`📍 Host: ${host}:${port}`);
19
23
  console.log(`👤 User: ${user}`);
20
24
 
25
+ if (SHARED_DATABASES.has(database)) {
26
+ console.log(
27
+ `✅ ${database} is a shared database; skipping CREATE DATABASE. ` +
28
+ "The database is managed by infra.",
29
+ );
30
+ if (databaseSchema) {
31
+ console.log(`📦 Using schema: ${databaseSchema}`);
32
+ }
33
+ return;
34
+ }
35
+
21
36
  // First, connect to the default 'postgres' database to create our target database
22
37
  const adminClient = new Pool({
23
38
  host,
@@ -25,7 +40,7 @@ async function main(): Promise<void> {
25
40
  user,
26
41
  password,
27
42
  database: "postgres", // Connect to default postgres database
28
- ssl: useSSL,
43
+ ssl: getPgSslConfig(useSSL),
29
44
  });
30
45
 
31
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
@@ -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">