@percepta/create 3.0.0 → 3.1.0

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 (32) hide show
  1. package/README.md +6 -5
  2. package/dist/{chunk-GEVZERMP.js → chunk-7NPWSTCY.js} +3 -1
  3. package/dist/{chunk-R4FWPE4A.js → chunk-DCM7JOSC.js} +2 -2
  4. package/dist/index.js +371 -210
  5. package/dist/{init-Z4VGBHAK.js → init-NP6GRXLL.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 +1 -1
  10. package/templates/webapp/.github/workflows/__APP_NAME__-ryvn-release.yaml +3 -2
  11. package/templates/webapp/AGENTS.md +8 -2
  12. package/templates/webapp/Dockerfile +0 -1
  13. package/templates/webapp/README.md +1 -0
  14. package/templates/webapp/agent-skills/database.md +1 -0
  15. package/templates/webapp/agent-skills/deploy.md +24 -27
  16. package/templates/webapp/agent-skills/oneshot.md +3 -3
  17. package/templates/webapp/deploy/README.md +8 -6
  18. package/templates/webapp/deploy/ryvn/__APP_NAME__.service.yaml +0 -2
  19. package/templates/webapp/deploy/ryvn/environments/percepta-test/installations/__APP_NAME__.env.percepta-test.serviceinstallation.yaml +11 -27
  20. package/templates/webapp/drizzle.config.ts +15 -6
  21. package/templates/webapp/env.example.template +1 -0
  22. package/templates/webapp/eslint.config.mjs +7 -0
  23. package/templates/webapp/package.json.template +6 -6
  24. package/templates/webapp/scripts/open-ryvn-deploy-pr.ts +377 -0
  25. package/templates/webapp/scripts/seed.ts +1 -1
  26. package/templates/webapp/scripts/setup-database.ts +14 -0
  27. package/templates/webapp/src/app/global-error.tsx +2 -0
  28. package/templates/webapp/src/config/getEnvConfig.ts +1 -0
  29. package/templates/webapp/src/drizzle/db.ts +3 -0
  30. package/templates/webapp/src/drizzle/searchPath.test.ts +21 -0
  31. package/templates/webapp/src/drizzle/searchPath.ts +16 -0
  32. package/templates/webapp/src/styles/globals.css +0 -7
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env tsx
2
+ /* eslint-disable no-console, 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 { fileURLToPath } from "node:url";
9
+ import { createInterface } from "node:readline/promises";
10
+ import { stdin as input, stdout as output } from "node:process";
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
+ interface Options {
18
+ infraPath?: string;
19
+ yes: boolean;
20
+ noClone: boolean;
21
+ }
22
+
23
+ function parseArgs(argv: string[]): Options {
24
+ const options: Options = {
25
+ yes: false,
26
+ noClone: false,
27
+ };
28
+
29
+ for (let index = 0; index < argv.length; index++) {
30
+ const arg = argv[index];
31
+ if (arg === "--yes" || arg === "-y") {
32
+ options.yes = true;
33
+ } else if (arg === "--no-clone") {
34
+ options.noClone = true;
35
+ } else if (arg === "--infra") {
36
+ const value = argv[index + 1];
37
+ if (!value) throw new Error("--infra requires a path");
38
+ options.infraPath = path.resolve(value);
39
+ index++;
40
+ } else {
41
+ throw new Error(`Unknown argument: ${arg}`);
42
+ }
43
+ }
44
+
45
+ return options;
46
+ }
47
+
48
+ function run(
49
+ command: string,
50
+ args: string[],
51
+ cwd: string,
52
+ stdio: "inherit" | "pipe" = "inherit",
53
+ ): string {
54
+ const result = execFileSync(command, args, {
55
+ cwd,
56
+ encoding: "utf8",
57
+ stdio,
58
+ });
59
+
60
+ return typeof result === "string" ? result.trim() : "";
61
+ }
62
+
63
+ function assertCommand(command: string): void {
64
+ try {
65
+ run(command, ["--version"], process.cwd(), "pipe");
66
+ } catch {
67
+ throw new Error(
68
+ `Required command not found or not authenticated: ${command}`,
69
+ );
70
+ }
71
+ }
72
+
73
+ function findUp(startDir: string, fileName: string): string | null {
74
+ let dir = startDir;
75
+ while (true) {
76
+ if (existsSync(path.join(dir, fileName))) return dir;
77
+ const parent = path.dirname(dir);
78
+ if (parent === dir) return null;
79
+ dir = parent;
80
+ }
81
+ }
82
+
83
+ function isGitRepo(dir: string): boolean {
84
+ if (!existsSync(dir)) return false;
85
+ try {
86
+ run("git", ["rev-parse", "--is-inside-work-tree"], dir, "pipe");
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ async function confirm(message: string): Promise<boolean> {
94
+ const rl = createInterface({ input, output });
95
+ try {
96
+ const answer = (await rl.question(`${message} [Y/n] `))
97
+ .trim()
98
+ .toLowerCase();
99
+ return answer === "" || answer === "y" || answer === "yes";
100
+ } finally {
101
+ rl.close();
102
+ }
103
+ }
104
+
105
+ async function resolveInfraRepo(
106
+ options: Options,
107
+ defaultInfraPath: string,
108
+ ): Promise<string> {
109
+ const infraPath =
110
+ options.infraPath ?? process.env.INFRA_REPO ?? defaultInfraPath;
111
+
112
+ if (isGitRepo(infraPath)) {
113
+ console.log(`Using infra repo: ${infraPath}`);
114
+ return infraPath;
115
+ }
116
+
117
+ if (existsSync(infraPath)) {
118
+ throw new Error(`${infraPath} exists but is not a git repository`);
119
+ }
120
+
121
+ if (options.noClone) {
122
+ throw new Error(
123
+ `Infra repo not found at ${infraPath}. Re-run with --infra <path>.`,
124
+ );
125
+ }
126
+
127
+ if (!options.yes) {
128
+ if (!process.stdin.isTTY) {
129
+ throw new Error(
130
+ `Infra repo not found at ${infraPath}. Re-run with --yes to clone or --infra <path>.`,
131
+ );
132
+ }
133
+
134
+ const shouldClone = await confirm(
135
+ `Infra repo not found. Clone Percepta-Core/infra to ${infraPath}?`,
136
+ );
137
+ if (!shouldClone) {
138
+ throw new Error("Skipped cloning infra repo. Re-run with --infra <path>.");
139
+ }
140
+ }
141
+
142
+ await mkdir(path.dirname(infraPath), { recursive: true });
143
+ console.log(`Cloning ${INFRA_REMOTE} to ${infraPath}...`);
144
+ run("git", ["clone", INFRA_REMOTE, infraPath], process.cwd());
145
+ console.log(`Cloned infra repo: ${infraPath}`);
146
+ return infraPath;
147
+ }
148
+
149
+ function assertCleanGitWorktree(repoPath: string): void {
150
+ const status = run("git", ["status", "--porcelain"], repoPath, "pipe");
151
+ if (status.length === 0) return;
152
+
153
+ throw new Error(
154
+ `Infra repo has local changes. Commit/stash them first or use --infra with a clean checkout.\n${status}`,
155
+ );
156
+ }
157
+
158
+ function switchDeployBranch(repoPath: string): string {
159
+ const branchName = `deploy/${APP_NAME}-${ENVIRONMENT}`;
160
+ const currentBranch = run(
161
+ "git",
162
+ ["branch", "--show-current"],
163
+ repoPath,
164
+ "pipe",
165
+ );
166
+ if (currentBranch === branchName) return branchName;
167
+
168
+ let branchExists = false;
169
+ try {
170
+ run("git", ["rev-parse", "--verify", branchName], repoPath, "pipe");
171
+ branchExists = true;
172
+ } catch {
173
+ branchExists = false;
174
+ }
175
+
176
+ if (branchExists) {
177
+ run("git", ["switch", branchName], repoPath);
178
+ } else {
179
+ run("git", ["switch", "-c", branchName], repoPath);
180
+ }
181
+
182
+ return branchName;
183
+ }
184
+
185
+ async function writeIfChanged(
186
+ filePath: string,
187
+ content: string,
188
+ ): Promise<boolean> {
189
+ if (existsSync(filePath)) {
190
+ const existing = await readFile(filePath, "utf8");
191
+ if (existing === content) return false;
192
+ }
193
+
194
+ await mkdir(path.dirname(filePath), { recursive: true });
195
+ await writeFile(filePath, content);
196
+ return true;
197
+ }
198
+
199
+ async function copyDeployYaml(
200
+ packageDir: string,
201
+ infraRepo: string,
202
+ ): Promise<string[]> {
203
+ const serviceSource = path.join(
204
+ packageDir,
205
+ "deploy",
206
+ "ryvn",
207
+ `${APP_NAME}.service.yaml`,
208
+ );
209
+ const installationSource = path.join(
210
+ packageDir,
211
+ "deploy",
212
+ "ryvn",
213
+ "environments",
214
+ ENVIRONMENT,
215
+ "installations",
216
+ `${APP_NAME}.env.${ENVIRONMENT}.serviceinstallation.yaml`,
217
+ );
218
+
219
+ const serviceTarget = path.join(infraRepo, "ryvn", `${APP_NAME}.service.yaml`);
220
+ const installationTarget = path.join(
221
+ infraRepo,
222
+ "ryvn",
223
+ "environments",
224
+ ENVIRONMENT,
225
+ "installations",
226
+ `${APP_NAME}.env.${ENVIRONMENT}.serviceinstallation.yaml`,
227
+ );
228
+
229
+ const changed: string[] = [];
230
+ if (await writeIfChanged(serviceTarget, await readFile(serviceSource, "utf8"))) {
231
+ changed.push(path.relative(infraRepo, serviceTarget));
232
+ }
233
+ if (
234
+ await writeIfChanged(
235
+ installationTarget,
236
+ await readFile(installationSource, "utf8"),
237
+ )
238
+ ) {
239
+ changed.push(path.relative(infraRepo, installationTarget));
240
+ }
241
+
242
+ return changed;
243
+ }
244
+
245
+ async function ensureDatabaseSchema(infraRepo: string): Promise<string | null> {
246
+ const databasesPath = path.join(
247
+ infraRepo,
248
+ "terraform",
249
+ "percepta-internal",
250
+ "databases.tf",
251
+ );
252
+ const content = await readFile(databasesPath, "utf8");
253
+
254
+ if (
255
+ content.includes(`resource "postgresql_schema" "${DATABASE_SCHEMA}"`) ||
256
+ content.includes(`name = "${DATABASE_SCHEMA}"`)
257
+ ) {
258
+ return null;
259
+ }
260
+
261
+ const block = `resource "postgresql_schema" "${DATABASE_SCHEMA}" {
262
+ provider = postgresql.percepta_internal
263
+ database = postgresql_database.demos.name
264
+ name = "${DATABASE_SCHEMA}"
265
+ }
266
+ `;
267
+
268
+ await appendFile(
269
+ databasesPath,
270
+ `${content.endsWith("\n") ? "\n" : "\n\n"}${block}`,
271
+ );
272
+
273
+ return path.relative(infraRepo, databasesPath);
274
+ }
275
+
276
+ function hasStagedChanges(repoPath: string): boolean {
277
+ try {
278
+ run("git", ["diff", "--cached", "--quiet"], repoPath, "pipe");
279
+ return false;
280
+ } catch (error) {
281
+ const status =
282
+ typeof error === "object" && error !== null && "status" in error
283
+ ? (error as { status?: number }).status
284
+ : undefined;
285
+ if (status === 1) return true;
286
+ throw error;
287
+ }
288
+ }
289
+
290
+ function getPrUrl(repoPath: string, branchName: string): string | null {
291
+ try {
292
+ return run(
293
+ "gh",
294
+ ["pr", "view", branchName, "--json", "url", "--jq", ".url"],
295
+ repoPath,
296
+ "pipe",
297
+ );
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+
303
+ async function main(): Promise<void> {
304
+ const options = parseArgs(process.argv.slice(2));
305
+ assertCommand("git");
306
+ assertCommand("gh");
307
+
308
+ const packageDir = path.resolve(
309
+ path.dirname(fileURLToPath(import.meta.url)),
310
+ "..",
311
+ );
312
+ const monorepoRoot = findUp(packageDir, "pnpm-workspace.yaml") ?? packageDir;
313
+ const defaultInfraPath = path.resolve(monorepoRoot, "..", "infra");
314
+ const infraRepo = await resolveInfraRepo(options, defaultInfraPath);
315
+
316
+ assertCleanGitWorktree(infraRepo);
317
+ const branchName = switchDeployBranch(infraRepo);
318
+
319
+ const changedPaths = await copyDeployYaml(packageDir, infraRepo);
320
+ const schemaPath = await ensureDatabaseSchema(infraRepo);
321
+ if (schemaPath) changedPaths.push(schemaPath);
322
+
323
+ if (changedPaths.length === 0) {
324
+ console.log(
325
+ "Infra repo already has the generated Ryvn files and database schema.",
326
+ );
327
+ return;
328
+ }
329
+
330
+ run("git", ["add", ...changedPaths], infraRepo);
331
+ if (!hasStagedChanges(infraRepo)) {
332
+ console.log("No infra changes to commit.");
333
+ return;
334
+ }
335
+
336
+ run(
337
+ "git",
338
+ ["commit", "-m", `Add ${APP_NAME} ${ENVIRONMENT} deployment`],
339
+ infraRepo,
340
+ );
341
+ run("git", ["push", "-u", "origin", branchName], infraRepo);
342
+
343
+ const existingPr = getPrUrl(infraRepo, branchName);
344
+ if (existingPr) {
345
+ console.log(`Infra PR: ${existingPr}`);
346
+ return;
347
+ }
348
+
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
+ const prUrl = run(
358
+ "gh",
359
+ [
360
+ "pr",
361
+ "create",
362
+ "--title",
363
+ `Add ${APP_NAME} ${ENVIRONMENT} deployment`,
364
+ "--body",
365
+ body,
366
+ ],
367
+ infraRepo,
368
+ "pipe",
369
+ );
370
+
371
+ console.log(`Infra PR: ${prUrl}`);
372
+ }
373
+
374
+ void main().catch((error) => {
375
+ console.error(error instanceof Error ? error.message : error);
376
+ process.exit(1);
377
+ });
@@ -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-unsafe-assignment, @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");
@@ -2,6 +2,8 @@ import { loadEnvConfig } from "@next/env";
2
2
  import { Pool } from "pg";
3
3
  import { getEnvConfig } from "../src/config/getEnvConfig";
4
4
 
5
+ const SHARED_DATABASES = new Set(["demos", "internal_apps"]);
6
+
5
7
  async function main(): Promise<void> {
6
8
  loadEnvConfig(process.cwd());
7
9
 
@@ -11,6 +13,7 @@ async function main(): Promise<void> {
11
13
  DATABASE_USERNAME: user,
12
14
  DATABASE_PASSWORD: password,
13
15
  DATABASE_NAME: database,
16
+ DATABASE_SCHEMA: databaseSchema,
14
17
  DATABASE_USE_SSL: useSSL,
15
18
  } = getEnvConfig();
16
19
 
@@ -18,6 +21,17 @@ async function main(): Promise<void> {
18
21
  console.log(`📍 Host: ${host}:${port}`);
19
22
  console.log(`👤 User: ${user}`);
20
23
 
24
+ if (SHARED_DATABASES.has(database)) {
25
+ console.log(
26
+ `✅ ${database} is a shared database; skipping CREATE DATABASE. ` +
27
+ "The database is managed by infra.",
28
+ );
29
+ if (databaseSchema) {
30
+ console.log(`📦 Using schema: ${databaseSchema}`);
31
+ }
32
+ return;
33
+ }
34
+
21
35
  // First, connect to the default 'postgres' database to create our target database
22
36
  const adminClient = new Pool({
23
37
  host,
@@ -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,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,7 @@ 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";
5
6
 
6
7
  export const { client, db } = createDb();
7
8
 
@@ -12,6 +13,7 @@ function createDb(): { client: Pool; db: NodePgDatabase<typeof schema> } {
12
13
  DATABASE_USERNAME: user,
13
14
  DATABASE_PASSWORD: password,
14
15
  DATABASE_NAME: database,
16
+ DATABASE_SCHEMA: databaseSchema,
15
17
  DATABASE_USE_SSL: useSSL,
16
18
  } = getEnvConfig();
17
19
 
@@ -22,6 +24,7 @@ function createDb(): { client: Pool; db: NodePgDatabase<typeof schema> } {
22
24
  password,
23
25
  database,
24
26
  ssl: useSSL,
27
+ options: getPgSearchPathOption(databaseSchema),
25
28
  });
26
29
 
27
30
  return { client: _client, db: drizzle(_client, { schema }) };
@@ -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
+ }
@@ -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);