@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.
- package/README.md +16 -9
- package/dist/{chunk-GEVZERMP.js → chunk-CG7IJSB4.js} +33 -2
- package/dist/{chunk-R4FWPE4A.js → chunk-DCM7JOSC.js} +2 -2
- package/dist/index.js +281 -82
- package/dist/{init-Z4VGBHAK.js → init-XDWSYHYK.js} +1 -1
- package/dist/{status-MITGDLTT.js → status-BTHGN6QH.js} +1 -1
- package/dist/{sync-J4SFZHDX.js → sync-3Q27L7XZ.js} +1 -1
- package/dist/{upstream-AQI7P4EU.js → upstream-C5KFAHVR.js} +1 -1
- package/package.json +3 -2
- package/templates/monorepo/gitignore.template +1 -0
- package/templates/webapp/.github/workflows/__APP_NAME__-ryvn-release.yaml +3 -2
- package/templates/webapp/AGENTS.md +8 -2
- package/templates/webapp/Dockerfile +0 -1
- package/templates/webapp/README.md +1 -0
- package/templates/webapp/agent-skills/database.md +1 -0
- package/templates/webapp/agent-skills/deploy.md +45 -32
- package/templates/webapp/agent-skills/oneshot.md +3 -3
- package/templates/webapp/deploy/README.md +32 -6
- package/templates/webapp/deploy/ryvn/__APP_NAME__.service.yaml +0 -2
- package/templates/webapp/deploy/ryvn/environments/percepta-test/installations/__APP_NAME__.env.percepta-test.serviceinstallation.yaml +28 -31
- package/templates/webapp/drizzle.config.ts +15 -6
- package/templates/webapp/env.example.template +1 -0
- package/templates/webapp/eslint.config.mjs +8 -0
- package/templates/webapp/gitignore.template +1 -0
- package/templates/webapp/package.json.template +6 -6
- package/templates/webapp/scripts/open-ryvn-deploy-pr.ts +495 -0
- package/templates/webapp/scripts/seed.ts +1 -1
- package/templates/webapp/scripts/setup-database.ts +16 -1
- package/templates/webapp/scripts/start.sh +3 -2
- package/templates/webapp/src/app/(app)/layout.tsx +1 -5
- package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +11 -1
- package/templates/webapp/src/app/(auth)/auth/signup/CredentialsSignUpForm.tsx +113 -0
- package/templates/webapp/src/app/(auth)/auth/signup/page.tsx +30 -0
- package/templates/webapp/src/app/global-error.tsx +3 -1
- package/templates/webapp/src/components/FaroProvider.tsx +2 -4
- package/templates/webapp/src/components/form/FormItem.tsx +2 -2
- package/templates/webapp/src/config/getEnvConfig.ts +1 -0
- package/templates/webapp/src/drizzle/db.ts +5 -1
- package/templates/webapp/src/drizzle/migrations/0000_eager_grandmaster.sql +3 -3
- package/templates/webapp/src/drizzle/migrations/meta/0000_snapshot.json +7 -19
- package/templates/webapp/src/drizzle/searchPath.test.ts +21 -0
- package/templates/webapp/src/drizzle/searchPath.ts +16 -0
- package/templates/webapp/src/drizzle/ssl.ts +5 -0
- package/templates/webapp/src/lib/auth/index.ts +1 -1
- package/templates/webapp/src/lib/auth-client.ts +1 -1
- package/templates/webapp/src/services/observability/initFaro.ts +1 -1
- 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
|
|
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
|
|
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 "
|
|
56
|
-
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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,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 {
|