@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.
- package/README.md +12 -6
- package/dist/{chunk-7NPWSTCY.js → chunk-CG7IJSB4.js} +31 -2
- package/dist/index.js +77 -25
- package/dist/{init-NP6GRXLL.js → init-XDWSYHYK.js} +1 -1
- package/package.json +3 -2
- package/templates/monorepo/gitignore.template +1 -0
- package/templates/webapp/agent-skills/deploy.md +31 -15
- package/templates/webapp/deploy/README.md +26 -2
- package/templates/webapp/deploy/ryvn/environments/percepta-test/installations/__APP_NAME__.env.percepta-test.serviceinstallation.yaml +18 -5
- package/templates/webapp/eslint.config.mjs +1 -0
- package/templates/webapp/gitignore.template +1 -0
- package/templates/webapp/scripts/open-ryvn-deploy-pr.ts +149 -31
- package/templates/webapp/scripts/seed.ts +1 -1
- package/templates/webapp/scripts/setup-database.ts +2 -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 +1 -1
- package/templates/webapp/src/components/FaroProvider.tsx +2 -4
- package/templates/webapp/src/components/form/FormItem.tsx +2 -2
- package/templates/webapp/src/drizzle/db.ts +2 -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/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/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
|
|
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>` |
|
|
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)
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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(
|
|
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-
|
|
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))
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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,
|
|
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("
|
|
845
|
-
console.log(chalk.dim(" 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,
|
|
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,
|
|
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,
|
|
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,
|
|
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>", "
|
|
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-
|
|
1184
|
+
const { initCommand } = await import("./init-XDWSYHYK.js");
|
|
1133
1185
|
await initCommand(options);
|
|
1134
1186
|
});
|
|
1135
1187
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percepta/create",
|
|
3
|
-
"version": "3.1.
|
|
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.
|
|
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",
|
|
@@ -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
|
|
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
|
|
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
|
|
41
|
+
- opens the first PR against `Percepta-Core/infra`
|
|
42
42
|
|
|
43
43
|
Get the PR reviewed and merged.
|
|
44
44
|
|
|
45
|
-
### Step 2:
|
|
45
|
+
### Step 2: Wait for service/schema import
|
|
46
46
|
|
|
47
|
-
After the
|
|
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
|
-
|
|
51
|
-
ENCRYPTION_SECRET_KEY=$(openssl rand -hex 16)
|
|
60
|
+
pnpm deploy:percepta-test -- --phase installation --yes
|
|
52
61
|
```
|
|
53
62
|
|
|
54
|
-
|
|
63
|
+
The `--` is the pnpm argument delimiter; it passes `--phase` and `--yes` through to the deploy helper script.
|
|
55
64
|
|
|
56
|
-
|
|
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
|
-
|
|
67
|
+
### Step 5: Import Ryvn secrets
|
|
59
68
|
|
|
60
|
-
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
82
|
-
-
|
|
83
|
-
|
|
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
|
|
@@ -1,29 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
|
-
/* eslint-disable
|
|
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(
|
|
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
|
|
159
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
480
|
+
getPrTitle(options.phase),
|
|
364
481
|
"--body",
|
|
365
|
-
|
|
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-
|
|
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
|
|
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
|
|
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}
|
|
@@ -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 "
|
|
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
|
+
}
|
|
@@ -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 {
|