@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,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
+ }
@@ -8,7 +8,9 @@ export default function GlobalError({
8
8
  reset: () => void;
9
9
  }) {
10
10
  try {
11
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
11
12
  const { faro } = require("@grafana/faro-web-sdk");
13
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
12
14
  faro.api?.pushError(error);
13
15
  } catch {
14
16
  // Faro may not be initialized yet — don't let reporting break the error page
@@ -16,7 +18,7 @@ export default function GlobalError({
16
18
 
17
19
  return (
18
20
  <html lang="en">
19
- <body suppressHydrationWarning>
21
+ <body suppressHydrationWarning={true}>
20
22
  <div style={{ padding: "2rem", textAlign: "center" }}>
21
23
  <h1>Something went wrong</h1>
22
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}
@@ -16,6 +16,7 @@ export const { getEnvConfig, schema: ENV_CONFIG_SCHEMA } = createEnvConfig(
16
16
  DATABASE_USERNAME: z.string().default("postgres"),
17
17
  DATABASE_PASSWORD: z.string().default("postgres"),
18
18
  DATABASE_NAME: z.string().default("__DB_NAME__"),
19
+ DATABASE_SCHEMA: z.string().optional(),
19
20
  DATABASE_USE_SSL: z
20
21
  .string()
21
22
  .transform((value: string): boolean => value === "true")
@@ -2,6 +2,8 @@ import { type NodePgDatabase, drizzle } from "drizzle-orm/node-postgres";
2
2
  import { Pool } from "pg";
3
3
  import { getEnvConfig } from "../config/getEnvConfig";
4
4
  import * as schema from "./schema";
5
+ import { getPgSearchPathOption } from "./searchPath";
6
+ import { getPgSslConfig } from "./ssl";
5
7
 
6
8
  export const { client, db } = createDb();
7
9
 
@@ -12,6 +14,7 @@ function createDb(): { client: Pool; db: NodePgDatabase<typeof schema> } {
12
14
  DATABASE_USERNAME: user,
13
15
  DATABASE_PASSWORD: password,
14
16
  DATABASE_NAME: database,
17
+ DATABASE_SCHEMA: databaseSchema,
15
18
  DATABASE_USE_SSL: useSSL,
16
19
  } = getEnvConfig();
17
20
 
@@ -21,7 +24,8 @@ function createDb(): { client: Pool; db: NodePgDatabase<typeof schema> } {
21
24
  user,
22
25
  password,
23
26
  database,
24
- ssl: useSSL,
27
+ ssl: getPgSslConfig(useSSL),
28
+ options: getPgSearchPathOption(databaseSchema),
25
29
  });
26
30
 
27
31
  return { client: _client, db: drizzle(_client, { schema }) };
@@ -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,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getPgSearchPathOption } from "./searchPath";
3
+
4
+ describe("getPgSearchPathOption", () => {
5
+ it("includes public so extension functions remain resolvable", () => {
6
+ expect(getPgSearchPathOption("my_app")).toBe(
7
+ "-c search_path=my_app,public",
8
+ );
9
+ });
10
+
11
+ it("returns undefined for blank schema names", () => {
12
+ expect(getPgSearchPathOption(undefined)).toBeUndefined();
13
+ expect(getPgSearchPathOption(" ")).toBeUndefined();
14
+ });
15
+
16
+ it("rejects unsafe unquoted identifiers", () => {
17
+ expect(() => getPgSearchPathOption("my-app")).toThrow(
18
+ "DATABASE_SCHEMA must be a valid unquoted Postgres identifier",
19
+ );
20
+ });
21
+ });
@@ -0,0 +1,16 @@
1
+ const POSTGRES_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2
+
3
+ export function getPgSearchPathOption(
4
+ schemaName: string | undefined,
5
+ ): string | undefined {
6
+ const searchPath = schemaName?.trim();
7
+ if (!searchPath) return undefined;
8
+
9
+ if (!POSTGRES_IDENTIFIER_PATTERN.test(searchPath)) {
10
+ throw new Error(
11
+ `DATABASE_SCHEMA must be a valid unquoted Postgres identifier. Received: ${searchPath}`,
12
+ );
13
+ }
14
+
15
+ return `-c search_path=${searchPath},public`;
16
+ }
@@ -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 {
@@ -13,13 +13,6 @@
13
13
  --font-mono: var(--font-geist-mono);
14
14
  }
15
15
 
16
- @media (prefers-color-scheme: dark) {
17
- :root {
18
- --background: #0a0a0a;
19
- --foreground: #ededed;
20
- }
21
- }
22
-
23
16
  body {
24
17
  background: var(--background);
25
18
  color: var(--foreground);