@percepta/create 3.1.0 → 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 (29) hide show
  1. package/README.md +12 -6
  2. package/dist/{chunk-7NPWSTCY.js → chunk-CG7IJSB4.js} +31 -2
  3. package/dist/index.js +77 -25
  4. package/dist/{init-NP6GRXLL.js → init-XDWSYHYK.js} +1 -1
  5. package/package.json +3 -2
  6. package/templates/monorepo/gitignore.template +1 -0
  7. package/templates/webapp/agent-skills/deploy.md +31 -15
  8. package/templates/webapp/deploy/README.md +26 -2
  9. package/templates/webapp/deploy/ryvn/environments/percepta-test/installations/__APP_NAME__.env.percepta-test.serviceinstallation.yaml +18 -5
  10. package/templates/webapp/eslint.config.mjs +1 -0
  11. package/templates/webapp/gitignore.template +1 -0
  12. package/templates/webapp/scripts/open-ryvn-deploy-pr.ts +149 -31
  13. package/templates/webapp/scripts/seed.ts +1 -1
  14. package/templates/webapp/scripts/setup-database.ts +2 -1
  15. package/templates/webapp/scripts/start.sh +3 -2
  16. package/templates/webapp/src/app/(app)/layout.tsx +1 -5
  17. package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +11 -1
  18. package/templates/webapp/src/app/(auth)/auth/signup/CredentialsSignUpForm.tsx +113 -0
  19. package/templates/webapp/src/app/(auth)/auth/signup/page.tsx +30 -0
  20. package/templates/webapp/src/app/global-error.tsx +1 -1
  21. package/templates/webapp/src/components/FaroProvider.tsx +2 -4
  22. package/templates/webapp/src/components/form/FormItem.tsx +2 -2
  23. package/templates/webapp/src/drizzle/db.ts +2 -1
  24. package/templates/webapp/src/drizzle/migrations/0000_eager_grandmaster.sql +3 -3
  25. package/templates/webapp/src/drizzle/migrations/meta/0000_snapshot.json +7 -19
  26. package/templates/webapp/src/drizzle/ssl.ts +5 -0
  27. package/templates/webapp/src/lib/auth/index.ts +1 -1
  28. package/templates/webapp/src/lib/auth-client.ts +1 -1
  29. package/templates/webapp/src/services/observability/initFaro.ts +1 -1
package/README.md CHANGED
@@ -8,7 +8,7 @@ Scaffold and manage Mosaic packages.
8
8
  npx @percepta/create
9
9
  ```
10
10
 
11
- That's it. The CLI prompts you for the package type and project name. Defaults yield a running app — sign in as `admin@example.com` / `password`.
11
+ That's it. The CLI prompts you for the package type, repo name, and package name as needed. Defaults yield a running app — sign in as `admin@example.com` / `password`.
12
12
 
13
13
  ## Options (mostly for automation)
14
14
 
@@ -17,7 +17,9 @@ The bare command above is the canonical UX. The flags below exist for tests and
17
17
  | Option | Description |
18
18
  |--------|-------------|
19
19
  | `-t, --type <type>` | Package type: `monorepo`, `webapp`, or `library` (skips the type prompt) |
20
- | `--name <name>` | Project name (skips the name prompt) |
20
+ | `--name <name>` | Package/app name (skips the package name prompt) |
21
+ | `--repo-name <name>` | Repo name when creating a new monorepo (skips the repo name prompt) |
22
+ | `--cwd <dir>` | Run as if the CLI was started from `<dir>` |
21
23
  | `--skip-install` | Skip dependency installation, which also skips the auto-run setup + dev + browser, leaving you with manual next-steps |
22
24
  | `-y, --yes` | Skip all prompts; requires `--name` |
23
25
 
@@ -33,7 +35,7 @@ The bare command above is the canonical UX. The flags below exist for tests and
33
35
 
34
36
  `create` auto-detects whether you're inside an existing pnpm monorepo (by walking up for `pnpm-workspace.yaml`) and changes its prompts accordingly:
35
37
 
36
- - **Outside a monorepo** — you're asked "Initialize with a webapp?" (Y/n, default Y) before the project name. Picking the webapp option scaffolds a monorepo with a webapp inside `packages/<name>/`. Declining gives you an empty monorepo.
38
+ - **Outside a monorepo** — you're asked "Initialize with a webapp?" (Y/n, default Y), then for the repo name. Picking the webapp option also asks for the webapp name and scaffolds it inside `packages/<webapp-name>/`. Declining gives you an empty monorepo.
37
39
  - **Inside a monorepo** — pick `Webapp` (default) or `Library` to add a new package under the workspace pattern.
38
40
 
39
41
  ## Happy-path: zero-friction webapp
@@ -75,9 +77,13 @@ pnpm build
75
77
  ### Testing locally
76
78
 
77
79
  ```bash
78
- pnpm build
79
- npm link
80
- create test-app
80
+ pnpm create:local --cwd /tmp --name test-app --yes --skip-install
81
+ ```
82
+
83
+ From the repo root, the same script can be run with a filter:
84
+
85
+ ```bash
86
+ pnpm --filter @percepta/create create:local --cwd /tmp --name test-app --yes --skip-install
81
87
  ```
82
88
 
83
89
  ### Syncing template files
@@ -77,6 +77,7 @@ async function promptInsideMonorepoType() {
77
77
  }
78
78
  async function promptProjectDetails(defaults) {
79
79
  const inMonorepo = defaults.monorepoContext?.found ?? false;
80
+ const cwd = defaults.cwd ?? process.cwd();
80
81
  let projectType;
81
82
  let finalName;
82
83
  if (inMonorepo) {
@@ -86,10 +87,38 @@ async function promptProjectDetails(defaults) {
86
87
  } else {
87
88
  projectType = defaults.projectType ?? await promptOutsideMonorepoType();
88
89
  await defaults.beforeNamePrompt?.(projectType);
89
- finalName = defaults.name || await promptName("Project name?");
90
+ const repoName = defaults.repoName || (projectType === "monorepo" ? defaults.name : void 0) || await promptName("Repo name?");
91
+ const repoTitle = toTitleCase(repoName);
92
+ if (projectType === "monorepo") {
93
+ finalName = repoName;
94
+ const finalTitle3 = repoTitle;
95
+ const finalDirectory3 = path.resolve(cwd, repoName);
96
+ return {
97
+ projectType,
98
+ directory: finalDirectory3,
99
+ name: finalName,
100
+ title: finalTitle3,
101
+ installDeps: !defaults.skipInstall,
102
+ monorepoName: repoName,
103
+ monorepoTitle: repoTitle
104
+ };
105
+ }
106
+ const packageNamePrompt = projectType === "webapp" ? "Webapp name?" : "Library name?";
107
+ finalName = defaults.name || await promptName(packageNamePrompt);
108
+ const finalTitle2 = toTitleCase(finalName);
109
+ const finalDirectory2 = path.resolve(cwd, repoName);
110
+ return {
111
+ projectType,
112
+ directory: finalDirectory2,
113
+ name: finalName,
114
+ title: finalTitle2,
115
+ installDeps: !defaults.skipInstall,
116
+ monorepoName: repoName,
117
+ monorepoTitle: repoTitle
118
+ };
90
119
  }
91
120
  const finalTitle = finalName ? toTitleCase(finalName) : "";
92
- const finalDirectory = !inMonorepo && finalName ? path.resolve(process.cwd(), finalName) : "";
121
+ const finalDirectory = !inMonorepo && finalName ? path.resolve(cwd, finalName) : "";
93
122
  return {
94
123
  projectType,
95
124
  directory: finalDirectory,
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  toSnakeCase,
8
8
  toTitleCase,
9
9
  validateProjectName
10
- } from "./chunk-7NPWSTCY.js";
10
+ } from "./chunk-CG7IJSB4.js";
11
11
  import {
12
12
  derivePlaceholders,
13
13
  writeManifest
@@ -262,11 +262,28 @@ async function generateEnvLocal(packageDir) {
262
262
  const examplePath = path4.join(packageDir, ".env.example");
263
263
  const localPath = path4.join(packageDir, ".env.local");
264
264
  if (!await fs4.pathExists(examplePath)) return;
265
- if (await fs4.pathExists(localPath)) return;
266
- const authSecret = randomBytes(32).toString("base64");
267
- const encKey = randomBytes(16).toString("hex");
268
- const content = (await fs4.readFile(examplePath, "utf-8")).replace(/^BETTER_AUTH_SECRET=.*$/m, `BETTER_AUTH_SECRET=${authSecret}`).replace(/^ENCRYPTION_SECRET_KEY=.*$/m, `ENCRYPTION_SECRET_KEY=${encKey}`);
269
- await fs4.writeFile(localPath, content);
265
+ if (!await fs4.pathExists(localPath)) {
266
+ const authSecret = randomBytes(32).toString("base64");
267
+ const encKey = randomBytes(16).toString("hex");
268
+ const content = (await fs4.readFile(examplePath, "utf-8")).replace(/^BETTER_AUTH_SECRET=.*$/m, `BETTER_AUTH_SECRET=${authSecret}`).replace(/^ENCRYPTION_SECRET_KEY=.*$/m, `ENCRYPTION_SECRET_KEY=${encKey}`);
269
+ await fs4.writeFile(localPath, content);
270
+ }
271
+ const ryvnSecretsPath = path4.join(
272
+ packageDir,
273
+ "deploy",
274
+ "ryvn",
275
+ "percepta-test.secrets.env"
276
+ );
277
+ if (await fs4.pathExists(ryvnSecretsPath)) return;
278
+ const deployAuthSecret = randomBytes(32).toString("base64");
279
+ const deployEncKey = randomBytes(16).toString("hex");
280
+ const deploySecrets = [
281
+ `BETTER_AUTH_SECRET=${deployAuthSecret}`,
282
+ `ENCRYPTION_SECRET_KEY=${deployEncKey}`,
283
+ ""
284
+ ].join("\n");
285
+ await fs4.ensureDir(path4.dirname(ryvnSecretsPath));
286
+ await fs4.writeFile(ryvnSecretsPath, deploySecrets);
270
287
  }
271
288
 
272
289
  // src/utils/relocate-workflows.ts
@@ -529,6 +546,15 @@ _None yet \u2014 freshly created from template._
529
546
  `
530
547
  );
531
548
  }
549
+ function buildAppConfig(name, title = toTitleCase(name)) {
550
+ return {
551
+ name,
552
+ title,
553
+ dbName: `${toSnakeCase(name)}_db`,
554
+ nameUpper: name.toUpperCase(),
555
+ nameSnake: toSnakeCase(name)
556
+ };
557
+ }
532
558
  async function scaffoldMonorepo(targetDir, config) {
533
559
  const monoSpinner = ora("Copying monorepo template...").start();
534
560
  try {
@@ -700,6 +726,7 @@ function requireNpmTokenForWebappInstall(projectType, installDeps) {
700
726
  process.exit(1);
701
727
  }
702
728
  async function createProject(options) {
729
+ const cwd = await resolveCreateCwd(options.cwd);
703
730
  if (options.type !== void 0 && !isValidProjectType(options.type)) {
704
731
  console.error(
705
732
  chalk.red(
@@ -708,7 +735,6 @@ async function createProject(options) {
708
735
  );
709
736
  process.exit(1);
710
737
  }
711
- const cwd = process.cwd();
712
738
  console.log();
713
739
  console.log(chalk.bold("Creating a new Mosaic package..."));
714
740
  console.log();
@@ -735,6 +761,7 @@ async function createProject(options) {
735
761
  }
736
762
  console.log();
737
763
  const projectName = options.name;
764
+ const repoName = options.repoName;
738
765
  if (options.yes && !projectName) {
739
766
  console.error(
740
767
  chalk.red("Error: --name is required when using --yes flag")
@@ -748,38 +775,47 @@ async function createProject(options) {
748
775
  process.exit(1);
749
776
  }
750
777
  }
778
+ if (repoName) {
779
+ const validation = validateProjectName(toKebabCase(repoName));
780
+ if (!validation.valid) {
781
+ console.error(chalk.red(`Invalid repo name: ${validation.error}`));
782
+ process.exit(1);
783
+ }
784
+ }
751
785
  let answers;
752
786
  if (options.yes) {
753
787
  const projectType = options.type || "webapp";
754
788
  requireNpmTokenForWebappInstall(projectType, !options.skipInstall);
755
789
  const kebabName = toKebabCase(projectName);
756
- const directory = monorepoContext.found && monorepoContext.packageDir ? path7.join(monorepoContext.packageDir, kebabName) : path7.resolve(cwd, kebabName);
790
+ const kebabRepoName = repoName ? toKebabCase(repoName) : kebabName;
791
+ const directory = monorepoContext.found && monorepoContext.packageDir ? path7.join(monorepoContext.packageDir, kebabName) : path7.resolve(cwd, kebabRepoName);
757
792
  answers = {
758
793
  projectType,
759
794
  directory,
760
795
  name: kebabName,
761
796
  title: toTitleCase(kebabName),
762
- installDeps: !options.skipInstall
797
+ installDeps: !options.skipInstall,
798
+ monorepoName: monorepoContext.found ? void 0 : kebabRepoName,
799
+ monorepoTitle: monorepoContext.found ? void 0 : toTitleCase(kebabRepoName)
763
800
  };
764
801
  } else {
765
802
  answers = await promptProjectDetails({
766
803
  projectType: options.type,
767
804
  name: projectName ? toKebabCase(projectName) : void 0,
805
+ repoName: repoName ? toKebabCase(repoName) : void 0,
768
806
  skipInstall: options.skipInstall,
769
807
  monorepoContext,
808
+ cwd,
770
809
  beforeNamePrompt: (projectType) => requireNpmTokenForWebappInstall(projectType, !options.skipInstall)
771
810
  });
772
811
  if (monorepoContext.found && monorepoContext.packageDir && !answers.directory) {
773
812
  answers.directory = path7.join(monorepoContext.packageDir, answers.name);
774
813
  }
775
814
  }
776
- const config = {
777
- name: answers.name,
778
- title: answers.title,
779
- dbName: toSnakeCase(answers.name) + "_db",
780
- nameUpper: answers.name.toUpperCase(),
781
- nameSnake: toSnakeCase(answers.name)
782
- };
815
+ const config = buildAppConfig(answers.name, answers.title);
816
+ const monorepoName = answers.monorepoName ?? answers.name;
817
+ const monorepoTitle = answers.monorepoTitle ?? toTitleCase(monorepoName);
818
+ const monorepoConfig = buildAppConfig(monorepoName, monorepoTitle);
783
819
  const typeLabel = getProjectTypeLabel(answers.projectType);
784
820
  if (monorepoContext.found) {
785
821
  const monorepoRoot = monorepoContext.rootDir;
@@ -833,7 +869,7 @@ async function createProject(options) {
833
869
  installSucceeded
834
870
  );
835
871
  if (devStarted) return;
836
- printNextStepsExisting(answers, options, packageDir);
872
+ printNextStepsExisting(answers, packageDir);
837
873
  } else {
838
874
  const isBareMonorepo = answers.projectType === "monorepo";
839
875
  const monorepoRoot = answers.directory;
@@ -841,11 +877,12 @@ async function createProject(options) {
841
877
  if (isBareMonorepo) {
842
878
  console.log(chalk.dim(" Type:"), typeLabel);
843
879
  console.log(chalk.dim(" Directory:"), monorepoRoot);
844
- console.log(chalk.dim(" Name:"), config.name);
845
- console.log(chalk.dim(" Title:"), config.title);
880
+ console.log(chalk.dim(" Repo name:"), monorepoConfig.name);
881
+ console.log(chalk.dim(" Title:"), monorepoConfig.title);
846
882
  } else {
847
883
  console.log(chalk.dim(" Package type:"), typeLabel);
848
884
  console.log(chalk.dim(" Monorepo directory:"), monorepoRoot);
885
+ console.log(chalk.dim(" Repo name:"), monorepoConfig.name);
849
886
  console.log(chalk.dim(" Package:"), `packages/${answers.name}/`);
850
887
  console.log(chalk.dim(" Name:"), config.name);
851
888
  console.log(chalk.dim(" Title:"), config.title);
@@ -863,7 +900,7 @@ async function createProject(options) {
863
900
  process.exit(1);
864
901
  }
865
902
  }
866
- await scaffoldMonorepo(monorepoRoot, config);
903
+ await scaffoldMonorepo(monorepoRoot, monorepoConfig);
867
904
  if (packageDir && answers.projectType !== "monorepo") {
868
905
  await addPackageToMonorepo({
869
906
  packageDir,
@@ -903,8 +940,23 @@ async function createProject(options) {
903
940
  installSucceeded
904
941
  );
905
942
  if (devStarted) return;
906
- printNextStepsNew(answers, options, monorepoRoot);
943
+ printNextStepsNew(answers, monorepoRoot);
944
+ }
945
+ }
946
+ async function resolveCreateCwd(cwdOption) {
947
+ const cwd = cwdOption ? path7.resolve(cwdOption) : process.cwd();
948
+ let stat;
949
+ try {
950
+ stat = await fs7.stat(cwd);
951
+ } catch {
952
+ console.error(chalk.red(`Error: --cwd directory does not exist: ${cwd}`));
953
+ process.exit(1);
954
+ }
955
+ if (!stat.isDirectory()) {
956
+ console.error(chalk.red(`Error: --cwd is not a directory: ${cwd}`));
957
+ process.exit(1);
907
958
  }
959
+ return cwd;
908
960
  }
909
961
  function printWebappNextSteps(params) {
910
962
  const {
@@ -1006,7 +1058,7 @@ async function warnIfMissingRootNpmrc(rootDir) {
1006
1058
  );
1007
1059
  console.log();
1008
1060
  }
1009
- function printNextStepsNew(answers, options, targetDir) {
1061
+ function printNextStepsNew(answers, targetDir) {
1010
1062
  const pm = PACKAGE_MANAGER;
1011
1063
  const relativePath = path7.relative(process.cwd(), targetDir) || ".";
1012
1064
  console.log("Next steps:");
@@ -1062,7 +1114,7 @@ function printNextStepsNew(answers, options, targetDir) {
1062
1114
  );
1063
1115
  console.log();
1064
1116
  }
1065
- function printNextStepsExisting(answers, options, packageDir) {
1117
+ function printNextStepsExisting(answers, packageDir) {
1066
1118
  const pm = PACKAGE_MANAGER;
1067
1119
  const packageRelativePath = path7.relative(process.cwd(), packageDir) || ".";
1068
1120
  const monorepoRoot = path7.dirname(path7.dirname(packageDir));
@@ -1106,7 +1158,7 @@ var packageJson = {
1106
1158
  version: "1.0.0"
1107
1159
  };
1108
1160
  program.name("create").description("Scaffold and manage Mosaic packages").version(packageJson.version);
1109
- program.command("create", { isDefault: true }).description("Scaffold a new Mosaic package").option("-t, --type <type>", "Package type: monorepo, webapp, or library").option("--name <name>", "Project name").option("--skip-install", "Skip dependency installation (also skips the auto-run setup + dev + browser)", false).option("-y, --yes", "Skip all prompts and use defaults", false).action(createProject);
1161
+ program.command("create", { isDefault: true }).description("Scaffold a new Mosaic package").option("-t, --type <type>", "Package type: monorepo, webapp, or library").option("--name <name>", "Package/app name").option("--repo-name <name>", "Repository name when creating a new monorepo").option("--cwd <dir>", "Run create as if started from this directory").option("--skip-install", "Skip dependency installation (also skips the auto-run setup + dev + browser)", false).option("-y, --yes", "Skip all prompts and use defaults", false).action(createProject);
1110
1162
  program.command("status").description("Show template sync status for current app").option(
1111
1163
  "--mosaic-template-path <path>",
1112
1164
  "Path to local mosaic repo checkout"
@@ -1129,7 +1181,7 @@ program.command("upstream").description("Generate upstream context (app \u2192 t
1129
1181
  await upstreamCommand(options);
1130
1182
  });
1131
1183
  program.command("init").description("Add .mosaic-template.json to an existing app").option("-t, --type <type>", "Template type (e.g., webapp, library)").option("--template-version <version>", "Template version to set").action(async (options) => {
1132
- const { initCommand } = await import("./init-NP6GRXLL.js");
1184
+ const { initCommand } = await import("./init-XDWSYHYK.js");
1133
1185
  await initCommand(options);
1134
1186
  });
1135
1187
  program.parse();
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  VALID_PROJECT_TYPES,
3
3
  isValidProjectType
4
- } from "./chunk-7NPWSTCY.js";
4
+ } from "./chunk-CG7IJSB4.js";
5
5
  import {
6
6
  derivePlaceholders,
7
7
  manifestExists,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percepta/create",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
4
4
  "description": "Scaffold a new Mosaic package",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "tsup": "^8.4.0",
28
28
  "typescript": "^5.7.3",
29
29
  "vitest": "^4.0.0",
30
- "@percepta/build": "0.4.0"
30
+ "@percepta/build": "0.4.1"
31
31
  },
32
32
  "engines": {
33
33
  "node": ">=18.0.0"
@@ -47,6 +47,7 @@
47
47
  "license": "MIT",
48
48
  "scripts": {
49
49
  "build": "tsup src/index.ts --format esm --dts --clean",
50
+ "create:local": "pnpm build && node dist/index.js",
50
51
  "dev": "tsup src/index.ts --format esm --watch",
51
52
  "typecheck": "tsc --noEmit",
52
53
  "sync-template": "tsx scripts/sync-template.ts",
@@ -21,6 +21,7 @@ Thumbs.db
21
21
  .env
22
22
  .env.local
23
23
  .env.*.local
24
+ **/deploy/ryvn/*.secrets.env
24
25
 
25
26
  # Logs
26
27
  *.log
@@ -10,7 +10,7 @@ When this app was created with `@percepta/create`, the IaC files were generated
10
10
  - `deploy/ryvn/environments/percepta-test/installations/__APP_NAME__.env.percepta-test.serviceinstallation.yaml` — percepta-test installation
11
11
  - `.github/workflows/__APP_NAME__-ryvn-release.yaml` (at the repo root) — release workflow that builds the Docker image and creates a Ryvn release on push to `main`. Path filter scoped to `packages/__APP_NAME__/` so unrelated changes don't trigger builds.
12
12
 
13
- Per-app values (URLs, k8s service names, database schema) are substituted at create-time. The Better Auth and encryption secrets are declared as Ryvn-managed secrets so their values are set after the infra PR merges, not committed to git. Shared platform values (Inngest and OTel endpoints) are baked in as literals because they're stable across percepta-test apps.
13
+ Per-app values (URLs, k8s service names, database schema) are substituted at create-time. The Better Auth and encryption secrets are written to `deploy/ryvn/percepta-test.secrets.env`, which is ignored by git and intended for Ryvn UI import. Shared platform values (Inngest and OTel endpoints) are baked in as literals because they're stable across percepta-test apps.
14
14
 
15
15
  See [`deploy/README.md`](../deploy/README.md) for the file-by-file breakdown.
16
16
 
@@ -24,42 +24,57 @@ See [`deploy/README.md`](../deploy/README.md) for the file-by-file breakdown.
24
24
 
25
25
  ## Deploy
26
26
 
27
- ### Step 1: Open the infra PR
27
+ ### Step 1: Open the service/schema infra PR
28
28
 
29
29
  Run the generated deploy helper from this package directory:
30
30
 
31
31
  ```bash
32
- pnpm deploy:percepta-test -- --yes
32
+ pnpm deploy:percepta-test -- --phase service --yes
33
33
  ```
34
34
 
35
35
  This script:
36
36
 
37
37
  - uses `INFRA_REPO` or `--infra <path>` when provided
38
38
  - otherwise uses a sibling `../infra` checkout, cloning `Percepta-Core/infra` there if needed
39
- - copies the Ryvn service and installation YAML into infra
39
+ - copies the Ryvn service YAML into infra
40
40
  - appends a `postgresql_schema "__APP_NAME_SNAKE__"` resource to `terraform/percepta-internal/databases.tf`
41
- - opens one PR against `Percepta-Core/infra`
41
+ - opens the first PR against `Percepta-Core/infra`
42
42
 
43
43
  Get the PR reviewed and merged.
44
44
 
45
- ### Step 2: Set Ryvn secrets
45
+ ### Step 2: Wait for service/schema import
46
46
 
47
- After the infra PR merges, set the base Ryvn secrets for this installation:
47
+ After the service/schema PR merges, wait for Ryvn GitOps to import the service. If Ryvn creates a `percepta-internal-terraform` task for the database schema, approve/apply it in Ryvn.
48
+
49
+ ### Step 3: Create the first release
50
+
51
+ Push to `main` in the app repo (or `gh workflow run "Build & Release __APP_NAME__"`). The release workflow builds the Docker image and creates a Ryvn release. Do this before creating the ServiceInstallation, otherwise GitOps can fail with `ReleaseNotFound`.
52
+
53
+ The workflow lives at `.github/workflows/__APP_NAME__-ryvn-release.yaml` (at the repo root, where GitHub Actions picks it up). It only fires on changes under `packages/__APP_NAME__/`, so unrelated edits to other packages in the monorepo won't trigger it.
54
+
55
+ ### Step 4: Open the installation infra PR
56
+
57
+ After the first release exists, open the installation PR:
48
58
 
49
59
  ```bash
50
- BETTER_AUTH_SECRET=$(openssl rand -base64 32)
51
- ENCRYPTION_SECRET_KEY=$(openssl rand -hex 16)
60
+ pnpm deploy:percepta-test -- --phase installation --yes
52
61
  ```
53
62
 
54
- Also set any app-specific secrets the implementation added, such as `OPENAI_API_KEY`.
63
+ The `--` is the pnpm argument delimiter; it passes `--phase` and `--yes` through to the deploy helper script.
55
64
 
56
- ### Step 3: Trigger the first deploy
65
+ Get the PR reviewed and merged. Ryvn GitOps will import the ServiceInstallation and the Staging channel should deploy the latest release.
57
66
 
58
- Push to `main` in the app repo (or `gh workflow run "Build & Release __APP_NAME__"`). The release workflow builds the Docker image and creates a Ryvn release; Ryvn deploys it to percepta-test.
67
+ ### Step 5: Import Ryvn secrets
59
68
 
60
- The workflow lives at `.github/workflows/__APP_NAME__-ryvn-release.yaml` (at the repo root, where GitHub Actions picks it up). It only fires on changes under `packages/__APP_NAME__/`, so unrelated edits to other packages in the monorepo won't trigger it.
69
+ After GitOps imports the installation, import this generated file in the Ryvn UI for the installation secrets:
70
+
71
+ ```bash
72
+ deploy/ryvn/percepta-test.secrets.env
73
+ ```
74
+
75
+ Also set any app-specific secrets the implementation added, such as `OPENAI_API_KEY`.
61
76
 
62
- ### Step 4: Verify
77
+ ### Step 6: Verify
63
78
 
64
79
  ```bash
65
80
  ryvn get installation __APP_NAME__ -e percepta-test
@@ -72,7 +87,8 @@ The app will be available at **https://__APP_NAME__.percepta-test.aitco.dev**.
72
87
 
73
88
  ## Troubleshooting
74
89
 
75
- - **Pod crash-looping** → check `ryvn logs`; missing Ryvn secrets from Step 2 are the most common cause after a fresh deploy.
90
+ - **Auth/sign-in routes fail after install** → import `deploy/ryvn/percepta-test.secrets.env` in the Ryvn UI, then let Ryvn update/restart the installation.
91
+ - **Pod crash-looping** → check `ryvn logs`; migration or database connectivity failures are the most common fresh-deploy causes.
76
92
  - **Database connection refused** → verify `DATABASE_USE_SSL=true` and that `percepta-internal-terraform` is deployed.
77
93
  - **Database schema missing** → verify the infra PR added `postgresql_schema "__APP_NAME_SNAKE__"` and Ryvn applied `percepta-internal-terraform`.
78
94
  - **Inngest can't reach the app** → `INNGEST_APP_URL` must use the k8s service name `__APP_NAME__-web-server`. The scaffolded YAML gets this right; if you renamed anything, double-check.
@@ -12,7 +12,7 @@ deploy/
12
12
  └── __APP_NAME__.env.percepta-test.serviceinstallation.yaml # percepta-test installation
13
13
  ```
14
14
 
15
- These files are pre-filled with all base values needed to deploy to `https://__APP_NAME__.percepta-test.aitco.dev`. The generated `pnpm deploy:percepta-test -- --yes` helper opens the required `Percepta-Core/infra` PR.
15
+ These files are pre-filled with all base values needed to deploy to `https://__APP_NAME__.percepta-test.aitco.dev`. The generated deploy helper opens the required `Percepta-Core/infra` PRs in two phases.
16
16
 
17
17
  ## Deploying
18
18
 
@@ -28,7 +28,31 @@ Tell Claude "deploy this app to percepta-test" — it'll follow [`agent-skills/d
28
28
  2. **Shared platform values** (Inngest and OTel endpoints) — copied as literals from existing percepta-test installations. These are stable across percepta-test apps.
29
29
  3. **Database wiring** — points at the shared `demos` database and a per-app schema created by the infra PR.
30
30
 
31
- `BETTER_AUTH_SECRET` and `ENCRYPTION_SECRET_KEY` are declared as Ryvn-managed secrets. Set their values in Ryvn after the infra PR merges; the scaffold does not commit deployment secret values to git.
31
+ **`percepta-test.secrets.env`** generated locally and ignored by git. Import it in the Ryvn UI for `BETTER_AUTH_SECRET` and `ENCRYPTION_SECRET_KEY`; deployment secret values are intentionally not committed to GitOps IaC.
32
+
33
+ ## Deployment order
34
+
35
+ Use two infra PRs. The Service must exist before the first release can be created, and the first release must exist before the ServiceInstallation can be imported.
36
+
37
+ 1. Open and merge the service/schema PR:
38
+
39
+ ```bash
40
+ pnpm deploy:percepta-test -- --phase service --yes
41
+ ```
42
+
43
+ 2. Wait for GitOps to import the service. If the schema change creates a `percepta-internal-terraform` task in Ryvn, approve/apply it.
44
+
45
+ 3. Push the app to `main` or run the release workflow so Ryvn has a first release for `__APP_NAME__`.
46
+
47
+ 4. Open and merge the installation PR:
48
+
49
+ ```bash
50
+ pnpm deploy:percepta-test -- --phase installation --yes
51
+ ```
52
+
53
+ 5. After GitOps imports the installation, import `deploy/ryvn/percepta-test.secrets.env` into the Ryvn UI for the `__APP_NAME__` installation secrets.
54
+
55
+ The `--` is the pnpm argument delimiter; it passes the flags after it through to `scripts/open-ryvn-deploy-pr.ts`.
32
56
 
33
57
  ## Repo layout note
34
58
 
@@ -10,8 +10,23 @@ spec:
10
10
  service:
11
11
  port: 3000
12
12
 
13
+ startupEnabled: true
14
+ startupProbe:
15
+ httpGet:
16
+ path: /api/healthz
17
+ port: 3000
18
+ failureThreshold: 30
19
+ periodSeconds: 10
13
20
  livenessEnabled: true
21
+ livenessProbe:
22
+ httpGet:
23
+ path: /api/healthz
24
+ port: 3000
14
25
  readinessEnabled: true
26
+ readinessProbe:
27
+ httpGet:
28
+ path: /api/readyz
29
+ port: 3000
15
30
 
16
31
  resources:
17
32
  requests:
@@ -78,11 +93,9 @@ spec:
78
93
  value: https://__APP_NAME__.percepta-test.aitco.dev
79
94
  - key: BETTER_AUTH_URL
80
95
  value: https://__APP_NAME__.percepta-test.aitco.dev
81
- # Set these in Ryvn after the infra PR merges.
82
- - key: BETTER_AUTH_SECRET
83
- isSecret: true
84
- - key: ENCRYPTION_SECRET_KEY
85
- isSecret: true
96
+ # Import BETTER_AUTH_SECRET and ENCRYPTION_SECRET_KEY from
97
+ # deploy/ryvn/percepta-test.secrets.env in the Ryvn UI after the installation
98
+ # exists. Secret values are intentionally not declared in GitOps IaC.
86
99
 
87
100
  # Inngest (shared percepta-test instance)
88
101
  - key: INNGEST_BASE_URL
@@ -11,6 +11,7 @@ export default [
11
11
  {
12
12
  ignores: [
13
13
  "pnpm-lock.yaml",
14
+ "package.json",
14
15
  ".next/**",
15
16
  "next-env.d.ts",
16
17
  "public/**",
@@ -33,6 +33,7 @@ yarn-error.log*
33
33
  # env files (can opt-in for committing if needed)
34
34
  .env*
35
35
  !.env.example
36
+ deploy/ryvn/*.secrets.env
36
37
 
37
38
  # vercel
38
39
  .vercel
@@ -1,29 +1,33 @@
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++) {
@@ -32,6 +36,23 @@ function parseArgs(argv: string[]): Options {
32
36
  options.yes = true;
33
37
  } else if (arg === "--no-clone") {
34
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++;
35
56
  } else if (arg === "--infra") {
36
57
  const value = argv[index + 1];
37
58
  if (!value) throw new Error("--infra requires a path");
@@ -135,7 +156,9 @@ async function resolveInfraRepo(
135
156
  `Infra repo not found. Clone Percepta-Core/infra to ${infraPath}?`,
136
157
  );
137
158
  if (!shouldClone) {
138
- throw new Error("Skipped cloning infra repo. Re-run with --infra <path>.");
159
+ throw new Error(
160
+ "Skipped cloning infra repo. Re-run with --infra <path>.",
161
+ );
139
162
  }
140
163
  }
141
164
 
@@ -155,8 +178,14 @@ function assertCleanGitWorktree(repoPath: string): void {
155
178
  );
156
179
  }
157
180
 
158
- function switchDeployBranch(repoPath: string): string {
159
- const branchName = `deploy/${APP_NAME}-${ENVIRONMENT}`;
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);
160
189
  const currentBranch = run(
161
190
  "git",
162
191
  ["branch", "--show-current"],
@@ -165,6 +194,8 @@ function switchDeployBranch(repoPath: string): string {
165
194
  );
166
195
  if (currentBranch === branchName) return branchName;
167
196
 
197
+ run("git", ["fetch", "origin", "main"], repoPath);
198
+
168
199
  let branchExists = false;
169
200
  try {
170
201
  run("git", ["rev-parse", "--verify", branchName], repoPath, "pipe");
@@ -176,7 +207,7 @@ function switchDeployBranch(repoPath: string): string {
176
207
  if (branchExists) {
177
208
  run("git", ["switch", branchName], repoPath);
178
209
  } else {
179
- run("git", ["switch", "-c", branchName], repoPath);
210
+ run("git", ["switch", "-c", branchName, "origin/main"], repoPath);
180
211
  }
181
212
 
182
213
  return branchName;
@@ -199,6 +230,7 @@ async function writeIfChanged(
199
230
  async function copyDeployYaml(
200
231
  packageDir: string,
201
232
  infraRepo: string,
233
+ phase: DeployPhase,
202
234
  ): Promise<string[]> {
203
235
  const serviceSource = path.join(
204
236
  packageDir,
@@ -216,7 +248,11 @@ async function copyDeployYaml(
216
248
  `${APP_NAME}.env.${ENVIRONMENT}.serviceinstallation.yaml`,
217
249
  );
218
250
 
219
- const serviceTarget = path.join(infraRepo, "ryvn", `${APP_NAME}.service.yaml`);
251
+ const serviceTarget = path.join(
252
+ infraRepo,
253
+ "ryvn",
254
+ `${APP_NAME}.service.yaml`,
255
+ );
220
256
  const installationTarget = path.join(
221
257
  infraRepo,
222
258
  "ryvn",
@@ -227,14 +263,18 @@ async function copyDeployYaml(
227
263
  );
228
264
 
229
265
  const changed: string[] = [];
230
- if (await writeIfChanged(serviceTarget, await readFile(serviceSource, "utf8"))) {
266
+ if (
267
+ phase === "service" &&
268
+ (await writeIfChanged(serviceTarget, await readFile(serviceSource, "utf8")))
269
+ ) {
231
270
  changed.push(path.relative(infraRepo, serviceTarget));
232
271
  }
233
272
  if (
234
- await writeIfChanged(
273
+ phase === "installation" &&
274
+ (await writeIfChanged(
235
275
  installationTarget,
236
276
  await readFile(installationSource, "utf8"),
237
- )
277
+ ))
238
278
  ) {
239
279
  changed.push(path.relative(infraRepo, installationTarget));
240
280
  }
@@ -300,10 +340,89 @@ function getPrUrl(repoPath: string, branchName: string): string | null {
300
340
  }
301
341
  }
302
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
+
303
421
  async function main(): Promise<void> {
304
422
  const options = parseArgs(process.argv.slice(2));
305
423
  assertCommand("git");
306
424
  assertCommand("gh");
425
+ await confirmInstallationPrerequisites(options);
307
426
 
308
427
  const packageDir = path.resolve(
309
428
  path.dirname(fileURLToPath(import.meta.url)),
@@ -314,16 +433,25 @@ async function main(): Promise<void> {
314
433
  const infraRepo = await resolveInfraRepo(options, defaultInfraPath);
315
434
 
316
435
  assertCleanGitWorktree(infraRepo);
317
- const branchName = switchDeployBranch(infraRepo);
436
+ const branchName = switchDeployBranch(infraRepo, options.phase);
318
437
 
319
- const changedPaths = await copyDeployYaml(packageDir, infraRepo);
320
- const schemaPath = await ensureDatabaseSchema(infraRepo);
321
- if (schemaPath) changedPaths.push(schemaPath);
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
+ }
322
447
 
323
448
  if (changedPaths.length === 0) {
324
449
  console.log(
325
- "Infra repo already has the generated Ryvn files and database schema.",
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.",
326
453
  );
454
+ printNextSteps(options.phase);
327
455
  return;
328
456
  }
329
457
 
@@ -333,42 +461,32 @@ async function main(): Promise<void> {
333
461
  return;
334
462
  }
335
463
 
336
- run(
337
- "git",
338
- ["commit", "-m", `Add ${APP_NAME} ${ENVIRONMENT} deployment`],
339
- infraRepo,
340
- );
464
+ run("git", ["commit", "-m", getPrTitle(options.phase)], infraRepo);
341
465
  run("git", ["push", "-u", "origin", branchName], infraRepo);
342
466
 
343
467
  const existingPr = getPrUrl(infraRepo, branchName);
344
468
  if (existingPr) {
345
469
  console.log(`Infra PR: ${existingPr}`);
470
+ printNextSteps(options.phase);
346
471
  return;
347
472
  }
348
473
 
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
474
  const prUrl = run(
358
475
  "gh",
359
476
  [
360
477
  "pr",
361
478
  "create",
362
479
  "--title",
363
- `Add ${APP_NAME} ${ENVIRONMENT} deployment`,
480
+ getPrTitle(options.phase),
364
481
  "--body",
365
- body,
482
+ getPrBody(options.phase),
366
483
  ],
367
484
  infraRepo,
368
485
  "pipe",
369
486
  );
370
487
 
371
488
  console.log(`Infra PR: ${prUrl}`);
489
+ printNextSteps(options.phase);
372
490
  }
373
491
 
374
492
  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
@@ -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
 
@@ -34,7 +34,7 @@ export const FormItem: React.FC<FormItemProps> = ({
34
34
  return (
35
35
  <p
36
36
  id={messageId}
37
- className="text-destructive-foreground text-sm"
37
+ className="text-sm text-destructive-foreground"
38
38
  data-slot="form-message"
39
39
  >
40
40
  {body}
@@ -70,7 +70,7 @@ export const FormItem: React.FC<FormItemProps> = ({
70
70
  {description != null && (
71
71
  <p
72
72
  id={descriptionId}
73
- className="text-muted-foreground text-sm"
73
+ className="text-sm text-muted-foreground"
74
74
  data-slot="form-description"
75
75
  >
76
76
  {description}
@@ -3,6 +3,7 @@ import { Pool } from "pg";
3
3
  import { getEnvConfig } from "../config/getEnvConfig";
4
4
  import * as schema from "./schema";
5
5
  import { getPgSearchPathOption } from "./searchPath";
6
+ import { getPgSslConfig } from "./ssl";
6
7
 
7
8
  export const { client, db } = createDb();
8
9
 
@@ -23,7 +24,7 @@ function createDb(): { client: Pool; db: NodePgDatabase<typeof schema> } {
23
24
  user,
24
25
  password,
25
26
  database,
26
- ssl: useSSL,
27
+ ssl: getPgSslConfig(useSSL),
27
28
  options: getPgSearchPathOption(databaseSchema),
28
29
  });
29
30
 
@@ -52,6 +52,6 @@ CREATE TABLE "verification" (
52
52
  "updated_at" timestamp
53
53
  );
54
54
  --> statement-breakpoint
55
- ALTER TABLE "account" ADD CONSTRAINT "account_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
56
- ALTER TABLE "session" ADD CONSTRAINT "session_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
57
- CREATE UNIQUE INDEX "lower_email_index" ON "users" USING btree (lower("email"));
55
+ ALTER TABLE "account" ADD CONSTRAINT "account_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
56
+ ALTER TABLE "session" ADD CONSTRAINT "session_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
57
+ CREATE UNIQUE INDEX "lower_email_index" ON "users" USING btree (lower("email"));
@@ -99,12 +99,8 @@
99
99
  "name": "account_user_id_users_id_fk",
100
100
  "tableFrom": "account",
101
101
  "tableTo": "users",
102
- "columnsFrom": [
103
- "user_id"
104
- ],
105
- "columnsTo": [
106
- "id"
107
- ],
102
+ "columnsFrom": ["user_id"],
103
+ "columnsTo": ["id"],
108
104
  "onDelete": "cascade",
109
105
  "onUpdate": "no action"
110
106
  }
@@ -180,12 +176,8 @@
180
176
  "name": "session_user_id_users_id_fk",
181
177
  "tableFrom": "session",
182
178
  "tableTo": "users",
183
- "columnsFrom": [
184
- "user_id"
185
- ],
186
- "columnsTo": [
187
- "id"
188
- ],
179
+ "columnsFrom": ["user_id"],
180
+ "columnsTo": ["id"],
189
181
  "onDelete": "cascade",
190
182
  "onUpdate": "no action"
191
183
  }
@@ -195,9 +187,7 @@
195
187
  "session_token_unique": {
196
188
  "name": "session_token_unique",
197
189
  "nullsNotDistinct": false,
198
- "columns": [
199
- "token"
200
- ]
190
+ "columns": ["token"]
201
191
  }
202
192
  },
203
193
  "policies": {},
@@ -303,9 +293,7 @@
303
293
  "users_email_unique": {
304
294
  "name": "users_email_unique",
305
295
  "nullsNotDistinct": false,
306
- "columns": [
307
- "email"
308
- ]
296
+ "columns": ["email"]
309
297
  }
310
298
  },
311
299
  "policies": {},
@@ -373,4 +361,4 @@
373
361
  "schemas": {},
374
362
  "tables": {}
375
363
  }
376
- }
364
+ }
@@ -0,0 +1,5 @@
1
+ export function getPgSslConfig(
2
+ useSSL: boolean,
3
+ ): false | { rejectUnauthorized: false } {
4
+ return useSSL ? { rejectUnauthorized: false } : false;
5
+ }
@@ -1,12 +1,12 @@
1
1
  import { betterAuth } from "better-auth";
2
2
  import { drizzleAdapter } from "better-auth/adapters/drizzle";
3
3
  import { admin } from "better-auth/plugins";
4
+ import { getEnvConfig } from "../../config/getEnvConfig";
4
5
  import { db } from "../../drizzle/db";
5
6
  import { accounts } from "../../drizzle/schema/auth/accounts";
6
7
  import { sessions } from "../../drizzle/schema/auth/sessions";
7
8
  import { users } from "../../drizzle/schema/auth/users";
8
9
  import { verifications } from "../../drizzle/schema/auth/verifications";
9
- import { getEnvConfig } from "../../config/getEnvConfig";
10
10
  import { getLogger } from "../../services/logger/AppLogger";
11
11
 
12
12
  // eslint-disable-next-line n/no-process-env -- detecting Next.js build phase
@@ -1,5 +1,5 @@
1
- import { createAuthClient } from "better-auth/react";
2
1
  import { adminClient } from "better-auth/client/plugins";
2
+ import { createAuthClient } from "better-auth/react";
3
3
 
4
4
  export const authClient = createAuthClient({
5
5
  plugins: [adminClient()],
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { createFaroInstance } from "@percepta/next-utils/faro";
4
3
  import { TracingInstrumentation } from "@grafana/faro-web-tracing";
4
+ import { createFaroInstance } from "@percepta/next-utils/faro";
5
5
  import { getClientEnvConfig } from "../../config/clientEnvConfig";
6
6
 
7
7
  const {