@saena-io/create 0.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.
- package/dist/index.js +313 -0
- package/package.json +24 -0
- package/template/addons/content/src/content/Hero.tsx +13 -0
- package/template/addons/content/src/content/admin.ts +5 -0
- package/template/addons/content/src/content/schema.ts +13 -0
- package/template/addons/content/src/content/sections.ts +6 -0
- package/template/addons/content/src/routes/index.tsx +21 -0
- package/template/addons/content/src/server/content.ts +25 -0
- package/template/base/.env.example +15 -0
- package/template/base/README.md +58 -0
- package/template/base/gitignore +14 -0
- package/template/base/package.json +40 -0
- package/template/base/scripts/migrate.ts +43 -0
- package/template/base/scripts/seed-staff.ts +68 -0
- package/template/base/src/admin/data.ts +261 -0
- package/template/base/src/admin/registry.ts +6 -0
- package/template/base/src/router.tsx +19 -0
- package/template/base/src/routes/__root.tsx +34 -0
- package/template/base/src/routes/admin/$.tsx +17 -0
- package/template/base/src/routes/admin/index.tsx +8 -0
- package/template/base/src/routes/admin/route.tsx +29 -0
- package/template/base/src/routes/api/assets/$.ts +125 -0
- package/template/base/src/routes/api/assets/upload.ts +99 -0
- package/template/base/src/routes/api/auth/staff/$.ts +12 -0
- package/template/base/src/routes/api/plugin/$.ts +51 -0
- package/template/base/src/routes/index.tsx +19 -0
- package/template/base/src/server/admin-context.ts +39 -0
- package/template/base/src/server/auth.ts +32 -0
- package/template/base/src/server/branding.ts +19 -0
- package/template/base/src/server/business-profile.ts +19 -0
- package/template/base/src/server/core.ts +41 -0
- package/template/base/src/server/plugin-host.ts +21 -0
- package/template/base/src/server/require-ctx.ts +25 -0
- package/template/base/src/server/team.ts +58 -0
- package/template/base/src/server/translations.ts +74 -0
- package/template/base/tsconfig.json +22 -0
- package/template/base/vite.config.ts +25 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { spawnSync } from "child_process";
|
|
5
|
+
import {
|
|
6
|
+
cpSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
statSync,
|
|
13
|
+
writeFileSync
|
|
14
|
+
} from "fs";
|
|
15
|
+
import { dirname, join, resolve } from "path";
|
|
16
|
+
import { fileURLToPath } from "url";
|
|
17
|
+
import {
|
|
18
|
+
cancel,
|
|
19
|
+
confirm,
|
|
20
|
+
intro,
|
|
21
|
+
isCancel,
|
|
22
|
+
multiselect,
|
|
23
|
+
note,
|
|
24
|
+
outro,
|
|
25
|
+
spinner,
|
|
26
|
+
text
|
|
27
|
+
} from "@clack/prompts";
|
|
28
|
+
|
|
29
|
+
// src/generate.ts
|
|
30
|
+
var SAENA_VERSION = "^0.1.1";
|
|
31
|
+
var PLUGINS = [
|
|
32
|
+
{
|
|
33
|
+
id: "customers",
|
|
34
|
+
name: "Customers",
|
|
35
|
+
hint: "storefront identity (customer accounts + auth)",
|
|
36
|
+
pkg: "@saena-io/customers",
|
|
37
|
+
version: SAENA_VERSION,
|
|
38
|
+
dependsOn: [],
|
|
39
|
+
serverExport: "customersPlugin",
|
|
40
|
+
serverImport: "import { customersPlugin } from '@saena-io/customers';"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "ai",
|
|
44
|
+
name: "AI",
|
|
45
|
+
hint: "AI capability (models + credentials) other plugins build on",
|
|
46
|
+
pkg: "@saena-io/ai",
|
|
47
|
+
version: SAENA_VERSION,
|
|
48
|
+
dependsOn: [],
|
|
49
|
+
serverExport: "aiPlugin",
|
|
50
|
+
serverImport: "import { aiPlugin } from '@saena-io/ai';",
|
|
51
|
+
adminBuilder: "buildAiAdmin()",
|
|
52
|
+
adminImport: "import { buildAiAdmin } from '@saena-io/ai/admin';",
|
|
53
|
+
env: "# Encryption key for stored secrets (AI provider API keys), 32 bytes base64.\n# Generate one: openssl rand -base64 32\nSAENA_MASTER_KEY=\n"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "translation",
|
|
57
|
+
name: "Translation",
|
|
58
|
+
hint: "AI machine-translation in the Translations grid (needs AI)",
|
|
59
|
+
pkg: "@saena-io/translation",
|
|
60
|
+
version: SAENA_VERSION,
|
|
61
|
+
dependsOn: ["ai"],
|
|
62
|
+
serverExport: "translationPlugin",
|
|
63
|
+
serverImport: "import { translationPlugin } from '@saena-io/translation';",
|
|
64
|
+
enablesTranslation: true
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "content",
|
|
68
|
+
name: "Content (CMS)",
|
|
69
|
+
hint: "no-code content editor \u2014 pages, sections, collections",
|
|
70
|
+
pkg: "@saena-io/content",
|
|
71
|
+
version: SAENA_VERSION,
|
|
72
|
+
dependsOn: [],
|
|
73
|
+
serverExport: "contentPlugin",
|
|
74
|
+
serverImport: "import { contentPlugin } from '@saena-io/content';",
|
|
75
|
+
serverSideEffect: "import './content/schema';",
|
|
76
|
+
adminBuilder: "buildContentAdmin()",
|
|
77
|
+
adminImport: "import { buildContentAdmin } from '@saena-io/content/admin';",
|
|
78
|
+
adminSideEffect: "import './content/admin';",
|
|
79
|
+
addon: "content"
|
|
80
|
+
}
|
|
81
|
+
];
|
|
82
|
+
var byId = new Map(PLUGINS.map((p) => [p.id, p]));
|
|
83
|
+
function resolveSelection(ids) {
|
|
84
|
+
const want = new Set(ids);
|
|
85
|
+
for (const id of ids) for (const dep of byId.get(id)?.dependsOn ?? []) want.add(dep);
|
|
86
|
+
return PLUGINS.filter((p) => want.has(p.id));
|
|
87
|
+
}
|
|
88
|
+
function normalizeName(input) {
|
|
89
|
+
return input.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "saena-app";
|
|
90
|
+
}
|
|
91
|
+
function applyMarkers(content, feature, enabled) {
|
|
92
|
+
const lines = content.split("\n");
|
|
93
|
+
const out = [];
|
|
94
|
+
const start = `// SAENA:${feature}:start`;
|
|
95
|
+
const end = `// SAENA:${feature}:end`;
|
|
96
|
+
const line = `// SAENA:${feature}:line`;
|
|
97
|
+
let skipping = false;
|
|
98
|
+
for (let i = 0; i < lines.length; i++) {
|
|
99
|
+
const l = lines[i] ?? "";
|
|
100
|
+
const t = l.trim();
|
|
101
|
+
if (t === start) {
|
|
102
|
+
if (!enabled) skipping = true;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (t === end) {
|
|
106
|
+
skipping = false;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (t === line) {
|
|
110
|
+
if (!enabled) i++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!skipping) out.push(l);
|
|
114
|
+
}
|
|
115
|
+
return out.join("\n");
|
|
116
|
+
}
|
|
117
|
+
function generatePluginsTs(selected) {
|
|
118
|
+
const imports = selected.map((p) => p.serverImport);
|
|
119
|
+
const sideEffects = selected.flatMap((p) => p.serverSideEffect ? [p.serverSideEffect] : []);
|
|
120
|
+
const list = selected.map((p) => ` ${p.serverExport},`).join("\n");
|
|
121
|
+
const head = [...imports, "import type { Plugin } from '@saena-io/plugin-sdk';"].join("\n");
|
|
122
|
+
return `${head}${sideEffects.length ? `
|
|
123
|
+
${sideEffects.join("\n")}` : ""}
|
|
124
|
+
|
|
125
|
+
// The SERVER manifest \u2014 the enabled plugins, fed to the plugin host (boot/install) and runPluginMigrations.
|
|
126
|
+
// Add or remove a plugin here, then run \`bun run db:migrate\`. Generated by create-saena; edit freely.
|
|
127
|
+
export const serverPlugins: Plugin[] = [${selected.length ? `
|
|
128
|
+
${list}
|
|
129
|
+
` : ""}];
|
|
130
|
+
`;
|
|
131
|
+
}
|
|
132
|
+
function generatePluginsAdminTs(selected) {
|
|
133
|
+
const sideEffects = selected.flatMap((p) => p.adminSideEffect ? [p.adminSideEffect] : []);
|
|
134
|
+
const withAdmin = selected.filter((p) => p.adminBuilder);
|
|
135
|
+
const imports = withAdmin.flatMap((p) => p.adminImport ? [p.adminImport] : []);
|
|
136
|
+
const builders = withAdmin.map((p) => ` ${p.adminBuilder},`).join("\n");
|
|
137
|
+
const header = [
|
|
138
|
+
...sideEffects,
|
|
139
|
+
// side-effect registrations must run before the builders read them
|
|
140
|
+
...imports,
|
|
141
|
+
"import type { AdminExtension } from '@saena-io/ui/admin';"
|
|
142
|
+
].join("\n");
|
|
143
|
+
return `${header}
|
|
144
|
+
|
|
145
|
+
// The ADMIN manifest \u2014 the client half. Composed into the admin shell via buildAdminRegistry. Kept separate
|
|
146
|
+
// from src/plugins.ts so the server graph stays React-free. Generated by create-saena; edit freely.
|
|
147
|
+
export const adminExtensions: AdminExtension[] = [${withAdmin.length ? `
|
|
148
|
+
${builders}
|
|
149
|
+
` : ""}];
|
|
150
|
+
`;
|
|
151
|
+
}
|
|
152
|
+
function parseArgs(argv) {
|
|
153
|
+
let dir;
|
|
154
|
+
let plugins;
|
|
155
|
+
let install;
|
|
156
|
+
let git;
|
|
157
|
+
let yes = false;
|
|
158
|
+
for (const arg of argv) {
|
|
159
|
+
if (arg === "--no-install") install = false;
|
|
160
|
+
else if (arg === "--no-git") git = false;
|
|
161
|
+
else if (arg === "-y" || arg === "--yes") yes = true;
|
|
162
|
+
else if (arg.startsWith("--plugins="))
|
|
163
|
+
plugins = arg.slice("--plugins=".length).split(",").map((s) => s.trim()).filter(Boolean);
|
|
164
|
+
else if (!arg.startsWith("-") && !dir) dir = arg;
|
|
165
|
+
}
|
|
166
|
+
return { dir, plugins, install, git, yes };
|
|
167
|
+
}
|
|
168
|
+
function pmRun(pm) {
|
|
169
|
+
return pm === "npm" ? "npm run" : pm;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/index.ts
|
|
173
|
+
function replacePlaceholders(dir, name) {
|
|
174
|
+
for (const entry of readdirSync(dir)) {
|
|
175
|
+
const full = join(dir, entry);
|
|
176
|
+
if (statSync(full).isDirectory()) replacePlaceholders(full, name);
|
|
177
|
+
else {
|
|
178
|
+
const before = readFileSync(full, "utf8");
|
|
179
|
+
const after = before.replaceAll("{{PROJECT_NAME}}", name);
|
|
180
|
+
if (after !== before) writeFileSync(full, after);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function detectPackageManager() {
|
|
185
|
+
const ua = process.env.npm_config_user_agent ?? "";
|
|
186
|
+
if (ua.startsWith("bun")) return "bun";
|
|
187
|
+
if (ua.startsWith("pnpm")) return "pnpm";
|
|
188
|
+
if (ua.startsWith("yarn")) return "yarn";
|
|
189
|
+
return "npm";
|
|
190
|
+
}
|
|
191
|
+
async function main() {
|
|
192
|
+
const args = parseArgs(process.argv.slice(2));
|
|
193
|
+
intro("create-saena");
|
|
194
|
+
let dir = args.dir;
|
|
195
|
+
if (!dir) {
|
|
196
|
+
const res = await text({
|
|
197
|
+
message: "Where should we create your app?",
|
|
198
|
+
placeholder: "./my-saena-app",
|
|
199
|
+
defaultValue: "./my-saena-app"
|
|
200
|
+
});
|
|
201
|
+
if (isCancel(res)) return cancel("Cancelled.");
|
|
202
|
+
dir = res || "./my-saena-app";
|
|
203
|
+
}
|
|
204
|
+
const targetDir = resolve(process.cwd(), dir);
|
|
205
|
+
const projectName = normalizeName(dir.replace(/^.*[/\\]/, ""));
|
|
206
|
+
if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
|
|
207
|
+
return cancel(`Directory "${dir}" already exists and is not empty.`);
|
|
208
|
+
}
|
|
209
|
+
let selectedIds = args.plugins;
|
|
210
|
+
if (!selectedIds) {
|
|
211
|
+
if (args.yes) {
|
|
212
|
+
selectedIds = ["content", "ai", "translation"];
|
|
213
|
+
} else {
|
|
214
|
+
const res = await multiselect({
|
|
215
|
+
message: "Which plugins? (Core already includes auth, RBAC, settings, the translations grid, and Help)",
|
|
216
|
+
options: PLUGINS.map((p) => ({ value: p.id, label: p.name, hint: p.hint })),
|
|
217
|
+
required: false,
|
|
218
|
+
initialValues: ["content", "ai", "translation"]
|
|
219
|
+
});
|
|
220
|
+
if (isCancel(res)) return cancel("Cancelled.");
|
|
221
|
+
selectedIds = res;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const selected = resolveSelection(selectedIds ?? []);
|
|
225
|
+
const added = selected.filter((p) => !(selectedIds ?? []).includes(p.id));
|
|
226
|
+
if (added.length)
|
|
227
|
+
note(`Added required dependencies: ${added.map((p) => p.name).join(", ")}`, "Dependencies");
|
|
228
|
+
let doInstall = args.install;
|
|
229
|
+
let doGit = args.git;
|
|
230
|
+
if (!args.yes) {
|
|
231
|
+
if (doInstall === void 0) {
|
|
232
|
+
const r = await confirm({ message: "Install dependencies now?", initialValue: true });
|
|
233
|
+
if (isCancel(r)) return cancel("Cancelled.");
|
|
234
|
+
doInstall = r;
|
|
235
|
+
}
|
|
236
|
+
if (doGit === void 0) {
|
|
237
|
+
const r = await confirm({ message: "Initialize a git repository?", initialValue: true });
|
|
238
|
+
if (isCancel(r)) return cancel("Cancelled.");
|
|
239
|
+
doGit = r;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
doInstall = doInstall ?? true;
|
|
243
|
+
doGit = doGit ?? true;
|
|
244
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
245
|
+
const templateRoot = join(here, "..", "template");
|
|
246
|
+
const base = join(templateRoot, "base");
|
|
247
|
+
mkdirSync(targetDir, { recursive: true });
|
|
248
|
+
cpSync(base, targetDir, { recursive: true });
|
|
249
|
+
renameSync(join(targetDir, "gitignore"), join(targetDir, ".gitignore"));
|
|
250
|
+
for (const p of selected) {
|
|
251
|
+
if (p.addon) {
|
|
252
|
+
const addonDir = join(templateRoot, "addons", p.addon);
|
|
253
|
+
if (existsSync(addonDir)) cpSync(addonDir, targetDir, { recursive: true });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
writeFileSync(join(targetDir, "src", "plugins.ts"), generatePluginsTs(selected));
|
|
257
|
+
writeFileSync(join(targetDir, "src", "plugins-admin.ts"), generatePluginsAdminTs(selected));
|
|
258
|
+
const dataPath = join(targetDir, "src", "admin", "data.ts");
|
|
259
|
+
const translationOn = selected.some((p) => p.enablesTranslation);
|
|
260
|
+
writeFileSync(
|
|
261
|
+
dataPath,
|
|
262
|
+
applyMarkers(readFileSync(dataPath, "utf8"), "translation", translationOn)
|
|
263
|
+
);
|
|
264
|
+
const pkgPath = join(targetDir, "package.json");
|
|
265
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
266
|
+
for (const p of selected) pkg.dependencies[p.pkg] = p.version;
|
|
267
|
+
pkg.dependencies = Object.fromEntries(
|
|
268
|
+
Object.entries(pkg.dependencies).sort(([a], [b]) => a.localeCompare(b))
|
|
269
|
+
);
|
|
270
|
+
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
271
|
+
`);
|
|
272
|
+
const envExtra = [...new Set(selected.flatMap((p) => p.env ? [p.env] : []))];
|
|
273
|
+
if (envExtra.length) {
|
|
274
|
+
const envPath = join(targetDir, ".env.example");
|
|
275
|
+
writeFileSync(envPath, `${readFileSync(envPath, "utf8").trimEnd()}
|
|
276
|
+
|
|
277
|
+
${envExtra.join("\n")}`);
|
|
278
|
+
}
|
|
279
|
+
replacePlaceholders(targetDir, projectName);
|
|
280
|
+
if (doGit) {
|
|
281
|
+
const r = spawnSync("git", ["init", "-q"], { cwd: targetDir, stdio: "ignore" });
|
|
282
|
+
if (r.status !== 0) note("git init failed (is git installed?) \u2014 skipped.", "Warning");
|
|
283
|
+
}
|
|
284
|
+
if (doInstall) {
|
|
285
|
+
const pm2 = detectPackageManager();
|
|
286
|
+
const s = spinner();
|
|
287
|
+
s.start(`Installing dependencies with ${pm2}`);
|
|
288
|
+
const r = spawnSync(pm2, ["install"], {
|
|
289
|
+
cwd: targetDir,
|
|
290
|
+
stdio: "ignore",
|
|
291
|
+
shell: process.platform === "win32"
|
|
292
|
+
});
|
|
293
|
+
if (r.status === 0) s.stop("Dependencies installed.");
|
|
294
|
+
else s.stop("Install failed \u2014 run it yourself after fixing the error.");
|
|
295
|
+
}
|
|
296
|
+
const pm = detectPackageManager();
|
|
297
|
+
const steps = [
|
|
298
|
+
`cd ${dir}`,
|
|
299
|
+
...doInstall ? [] : [`${pm} install`],
|
|
300
|
+
"cp .env.example .env # then set DATABASE_URL",
|
|
301
|
+
`${pmRun(pm)} db:migrate`,
|
|
302
|
+
`${pmRun(pm)} db:seed # creates your admin (prints the generated password once)`,
|
|
303
|
+
`${pmRun(pm)} dev`
|
|
304
|
+
];
|
|
305
|
+
note(steps.join("\n"), "Next steps");
|
|
306
|
+
outro(
|
|
307
|
+
`Created ${projectName} with: ${selected.map((p) => p.name).join(", ") || "core only"}. Admin at /admin.`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
main().catch((err) => {
|
|
311
|
+
console.error(err);
|
|
312
|
+
process.exit(1);
|
|
313
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@saena-io/create",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold a SAENA app (admin + plugins) from the published @saena-io packages.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-saena": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"files": ["dist", "template"],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsup src/index.ts --format esm --clean",
|
|
13
|
+
"test": "vitest run --passWithNoTests",
|
|
14
|
+
"lint": "biome check src",
|
|
15
|
+
"typecheck": "tsc --noEmit",
|
|
16
|
+
"clean": "rm -rf dist .turbo"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@clack/prompts": "^0.11.0"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ContentRichText } from '@saena-io/content/public';
|
|
2
|
+
import type { Hero } from './schema';
|
|
3
|
+
|
|
4
|
+
// The Hero section's public component — title + rich description, read from the typed content the SDK
|
|
5
|
+
// hydrates server-side. Design this however you like; the shape comes from the schema.
|
|
6
|
+
export function HeroSection({ content }: { content: Hero }) {
|
|
7
|
+
return (
|
|
8
|
+
<section className="mx-auto max-w-2xl px-6 pt-16 pb-4">
|
|
9
|
+
<h1 className="font-bold text-4xl tracking-tight">{content.title}</h1>
|
|
10
|
+
<ContentRichText className="mt-3 text-lg text-muted-foreground" value={content.description} />
|
|
11
|
+
</section>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Registers this project's content into the ADMIN bundle: the section/page schemas (so the Content screen's
|
|
2
|
+
// structure tree + generated forms see them) and the section components (so the live preview can render
|
|
3
|
+
// them). Side-effect only; pulled in by the admin manifest (src/plugins-admin.ts).
|
|
4
|
+
import './schema';
|
|
5
|
+
import './sections';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { definePage, defineSection } from '@saena-io/content/define';
|
|
2
|
+
import { type InferValue, object, richtext, text } from '@saena-io/content/field';
|
|
3
|
+
|
|
4
|
+
// Starter content schema (React-free) — one "Home" page with a single "Hero" section. This is where you
|
|
5
|
+
// model your content: add sections, pages, and routed collections, then run `bun run db:migrate`.
|
|
6
|
+
const heroSchema = object({
|
|
7
|
+
title: text({ label: 'Title' }),
|
|
8
|
+
description: richtext({ label: 'Description' }),
|
|
9
|
+
});
|
|
10
|
+
export const heroSection = defineSection({ id: 'hero', label: 'Hero', schema: heroSchema });
|
|
11
|
+
export type Hero = InferValue<typeof heroSchema>;
|
|
12
|
+
|
|
13
|
+
export const homePage = definePage({ id: 'home', path: '/', label: 'Home', sections: ['hero'] });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { registerSectionComponent } from '@saena-io/content/public';
|
|
2
|
+
import { HeroSection } from './Hero';
|
|
3
|
+
|
|
4
|
+
// Maps section ids → their public components, used by both the public routes and the admin live preview.
|
|
5
|
+
// Registering here (side-effect) is what lets the Content editor preview each section.
|
|
6
|
+
registerSectionComponent('hero', HeroSection);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { HeroSection } from '@/content/Hero';
|
|
2
|
+
import type { Hero } from '@/content/schema';
|
|
3
|
+
import { loadPage } from '@/server/content';
|
|
4
|
+
import { createFileRoute } from '@tanstack/react-router';
|
|
5
|
+
|
|
6
|
+
// Public homepage (ADR-0008): a dev-authored route whose loader hydrates the "home" page's sections SSR.
|
|
7
|
+
// Edit the copy in the admin (Content → Home); own the markup here.
|
|
8
|
+
export const Route = createFileRoute('/')({
|
|
9
|
+
component: Home,
|
|
10
|
+
loader: async () => ({ page: await loadPage({ data: { page: 'home' } }) }),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function Home() {
|
|
14
|
+
const { page } = Route.useLoaderData();
|
|
15
|
+
const hero = (page?.hero ?? { title: '{{PROJECT_NAME}}', description: '' }) as Hero;
|
|
16
|
+
return (
|
|
17
|
+
<main>
|
|
18
|
+
<HeroSection content={hero} />
|
|
19
|
+
</main>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { getPageContent } from '@saena-io/content';
|
|
2
|
+
import { createRequestContext } from '@saena-io/core';
|
|
3
|
+
import { createServerFn } from '@tanstack/react-start';
|
|
4
|
+
import '../content/schema'; // side-effect: registers this project's sections + pages in the server graph
|
|
5
|
+
import { resolveRequestActor } from './auth';
|
|
6
|
+
import { getCoreContext, getCoreDb } from './core';
|
|
7
|
+
|
|
8
|
+
// Host glue for public content (ADR-0008): the SSR page loader. A public route's loader calls this to
|
|
9
|
+
// hydrate its sections' content server-side (so pages are server-rendered and SEO-friendly). Public
|
|
10
|
+
// requests are anonymous and rendered in the main locale (createRequestContext's default).
|
|
11
|
+
|
|
12
|
+
/** A JSON value — hydrated content is plain JSON, typed concretely so it crosses the server-fn boundary. */
|
|
13
|
+
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
|
|
14
|
+
|
|
15
|
+
export const loadPage = createServerFn({ method: 'GET' })
|
|
16
|
+
.validator((data: { page: string }) => data)
|
|
17
|
+
.handler(async ({ data }): Promise<Record<string, Json> | null> => {
|
|
18
|
+
const ctx = await createRequestContext({
|
|
19
|
+
core: getCoreContext(),
|
|
20
|
+
db: getCoreDb(),
|
|
21
|
+
resolveActor: resolveRequestActor,
|
|
22
|
+
headers: new Headers(),
|
|
23
|
+
});
|
|
24
|
+
return (await getPageContent(ctx, data.page)) as Record<string, Json> | null;
|
|
25
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Postgres — SAENA needs Postgres (Drizzle schema + better-auth + pg-boss jobs).
|
|
2
|
+
# Local dev example: createdb {{PROJECT_NAME}} && set the URL below.
|
|
3
|
+
DATABASE_URL=postgres://postgres:postgres@localhost:5432/{{PROJECT_NAME}}
|
|
4
|
+
|
|
5
|
+
# better-auth secret — 32+ chars. Generate one: openssl rand -hex 32 (CHANGE THIS before deploying).
|
|
6
|
+
BETTER_AUTH_SECRET=dev-secret-change-me-0123456789abcdef0123456789abcdef
|
|
7
|
+
|
|
8
|
+
# The app's public origin (must match the dev/prod URL for better-auth's CSRF/origin check).
|
|
9
|
+
BETTER_AUTH_URL=http://localhost:3000
|
|
10
|
+
|
|
11
|
+
# Dev + preview server port.
|
|
12
|
+
PORT=3000
|
|
13
|
+
|
|
14
|
+
# Local asset storage directory (ADR-0002). In production, point this at a mounted volume.
|
|
15
|
+
SAENA_ASSETS_DIR=.data/assets
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# {{PROJECT_NAME}}
|
|
2
|
+
|
|
3
|
+
A [SAENA](https://www.npmjs.com/org/saena-io) app — an admin + plugin platform built on TanStack Start,
|
|
4
|
+
Postgres, and Drizzle. The admin lives at `/admin`; everything else is yours to build.
|
|
5
|
+
|
|
6
|
+
## Prerequisites
|
|
7
|
+
|
|
8
|
+
- [Bun](https://bun.sh) ≥ 1.3
|
|
9
|
+
- A Postgres database (local or hosted)
|
|
10
|
+
|
|
11
|
+
## Getting started
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 1. Configure environment
|
|
15
|
+
cp .env.example .env
|
|
16
|
+
# → edit .env and set DATABASE_URL (+ BETTER_AUTH_SECRET for anything non-local)
|
|
17
|
+
|
|
18
|
+
# 2. Install dependencies (skip if create-saena already did it)
|
|
19
|
+
bun install
|
|
20
|
+
|
|
21
|
+
# 3. Create the schema (core + each enabled plugin) and seed predefined roles
|
|
22
|
+
bun run db:migrate
|
|
23
|
+
|
|
24
|
+
# 4. Seed your first admin login
|
|
25
|
+
bun run db:seed
|
|
26
|
+
# → admin@example.com + a generated password, printed once (override with STAFF_EMAIL / STAFF_PASSWORD)
|
|
27
|
+
|
|
28
|
+
# 5. Run it
|
|
29
|
+
bun run dev
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Open <http://localhost:3000/admin> and sign in.
|
|
33
|
+
|
|
34
|
+
## What's included
|
|
35
|
+
|
|
36
|
+
The core foundation (always present): the admin shell, staff auth + RBAC (Admin / Owner / Editor),
|
|
37
|
+
business profile + branding settings, the translations grid, and the **Help** screen — a catalog of every
|
|
38
|
+
SAENA plugin (installed and available) with install instructions.
|
|
39
|
+
|
|
40
|
+
Plugins you selected are wired into `src/plugins.ts` (server) and `src/plugins-admin.ts` (admin). Add or
|
|
41
|
+
remove plugins later by editing those two files and running `bun run db:migrate` again.
|
|
42
|
+
|
|
43
|
+
## Project layout
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
src/
|
|
47
|
+
plugins.ts enabled plugins (server manifest)
|
|
48
|
+
plugins-admin.ts enabled plugins (admin manifest)
|
|
49
|
+
routes/ your public site + the /admin and /api routes
|
|
50
|
+
admin/data.ts the admin data layer (server fns → TanStack DB collections)
|
|
51
|
+
server/ host glue (auth, db, core context, server functions)
|
|
52
|
+
scripts/migrate.ts deploy-time migration
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Deploying
|
|
56
|
+
|
|
57
|
+
`bun run build` produces a server you start with `bun run start` (binds `$PORT`). Run
|
|
58
|
+
`bun run db:migrate:deploy` as a release step. See the SAENA deploy docs for a Railway setup.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME}}",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite dev",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"start": "vite preview",
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"db:migrate": "bun --env-file=.env scripts/migrate.ts",
|
|
12
|
+
"db:migrate:deploy": "bun scripts/migrate.ts",
|
|
13
|
+
"db:seed": "bun --env-file=.env scripts/seed-staff.ts"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@saena-io/admin": "^0.1.1",
|
|
17
|
+
"@saena-io/catalog": "^0.1.0",
|
|
18
|
+
"@saena-io/core": "^0.1.1",
|
|
19
|
+
"@saena-io/plugin-sdk": "^0.1.0",
|
|
20
|
+
"@saena-io/ui": "^0.1.1",
|
|
21
|
+
"@tanstack/db": "^0.6.8",
|
|
22
|
+
"@tanstack/query-core": "^5.101.0",
|
|
23
|
+
"@tanstack/query-db-collection": "^1.0.40",
|
|
24
|
+
"@tanstack/react-router": "^1.170.0",
|
|
25
|
+
"@tanstack/react-start": "^1.168.0",
|
|
26
|
+
"react": "^19.2.6",
|
|
27
|
+
"react-dom": "^19.2.6"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@tailwindcss/vite": "^4",
|
|
31
|
+
"@tanstack/router-plugin": "^1.168.0",
|
|
32
|
+
"@types/node": "^22",
|
|
33
|
+
"@types/react": "^19",
|
|
34
|
+
"@types/react-dom": "^19",
|
|
35
|
+
"@vitejs/plugin-react": "^6",
|
|
36
|
+
"tailwindcss": "^4",
|
|
37
|
+
"typescript": "^5.7.2",
|
|
38
|
+
"vite": "^8"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCoreContext,
|
|
3
|
+
createDb,
|
|
4
|
+
createPluginHost,
|
|
5
|
+
ensureRoles,
|
|
6
|
+
migrateCore,
|
|
7
|
+
runPluginMigrations,
|
|
8
|
+
} from '@saena-io/core';
|
|
9
|
+
import { serverPlugins } from '../src/plugins';
|
|
10
|
+
|
|
11
|
+
// Deploy-time migration for the web app: apply the core schema, then each enabled plugin's migrations (in
|
|
12
|
+
// dependsOn order, isolated history tables — runPluginMigrations), then run plugin install() hooks. This is
|
|
13
|
+
// the project's pinned-manifest + ordered-migration step (ADR-0006); it must run at deploy time, not in a
|
|
14
|
+
// request. Run with `bun --filter @saena-io/web db:migrate` (loads ../../.env).
|
|
15
|
+
async function main(): Promise<void> {
|
|
16
|
+
const url = process.env.DATABASE_URL;
|
|
17
|
+
if (!url) {
|
|
18
|
+
throw new Error('DATABASE_URL is not set — migration needs Postgres (see .env).');
|
|
19
|
+
}
|
|
20
|
+
const db = createDb(url);
|
|
21
|
+
const ctx = createCoreContext({ db });
|
|
22
|
+
|
|
23
|
+
ctx.logger.info('migrate: core schema');
|
|
24
|
+
await migrateCore(db);
|
|
25
|
+
|
|
26
|
+
ctx.logger.info(
|
|
27
|
+
`migrate: plugin migrations (${serverPlugins.map((p) => p.id).join(', ') || 'none'})`,
|
|
28
|
+
);
|
|
29
|
+
await runPluginMigrations(db, serverPlugins);
|
|
30
|
+
|
|
31
|
+
ctx.logger.info('migrate: plugin install hooks');
|
|
32
|
+
const host = createPluginHost(serverPlugins, ctx);
|
|
33
|
+
await host.install();
|
|
34
|
+
|
|
35
|
+
// Seed the predefined roles (Admin/Owner/Editor) from the catalogue by tier + bootstrap an Admin (ADR-0012).
|
|
36
|
+
const pluginPermissions = serverPlugins.flatMap((p) => p.permissions ?? []);
|
|
37
|
+
await ensureRoles(db, pluginPermissions);
|
|
38
|
+
|
|
39
|
+
ctx.logger.info('migrate: complete');
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
void main();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
createDb,
|
|
4
|
+
createStaffAuth,
|
|
5
|
+
createStaffMember,
|
|
6
|
+
ensureRoles,
|
|
7
|
+
getBusinessProfile,
|
|
8
|
+
listStaff,
|
|
9
|
+
upsertBusinessProfile,
|
|
10
|
+
} from '@saena-io/core';
|
|
11
|
+
import { serverPlugins } from '../src/plugins';
|
|
12
|
+
|
|
13
|
+
// Seed the first staff login into DATABASE_URL — run once after `db:migrate` on a fresh database.
|
|
14
|
+
// Creates one staff user via better-auth (password hashed there) and runs `ensureRoles`, which seeds the
|
|
15
|
+
// predefined Admin/Owner/Editor roles and bootstraps this user as Admin (ADR-0012). Staff self-sign-up is
|
|
16
|
+
// disabled, so this is how you get your first login. Idempotent: re-running is a no-op if the user exists.
|
|
17
|
+
// Override defaults with STAFF_EMAIL / STAFF_PASSWORD / STAFF_NAME.
|
|
18
|
+
const url = process.env.DATABASE_URL;
|
|
19
|
+
if (!url) {
|
|
20
|
+
console.error('DATABASE_URL is not set — see .env');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Roles grant the enabled plugins' permissions too (e.g. content.manage), so seed with them — exactly like
|
|
25
|
+
// db:migrate. Calling ensureRoles WITHOUT these would rebuild the roles with core permissions only and strip
|
|
26
|
+
// the plugin permissions a prior db:migrate granted.
|
|
27
|
+
const pluginPermissions = serverPlugins.flatMap((p) => p.permissions ?? []);
|
|
28
|
+
|
|
29
|
+
const name = process.env.STAFF_NAME ?? 'Admin';
|
|
30
|
+
const email = process.env.STAFF_EMAIL ?? 'admin@example.com';
|
|
31
|
+
|
|
32
|
+
const db = createDb(url);
|
|
33
|
+
const staffAuth = createStaffAuth(db, {
|
|
34
|
+
secret: process.env.BETTER_AUTH_SECRET ?? 'dev-secret-change-me-0123456789abcdef',
|
|
35
|
+
basePath: '/api/auth/staff',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const existing = await listStaff(db);
|
|
39
|
+
if (existing.some((s) => s.email === email)) {
|
|
40
|
+
await ensureRoles(db, pluginPermissions);
|
|
41
|
+
console.log(`staff "${email}" already exists — roles ensured.`);
|
|
42
|
+
} else {
|
|
43
|
+
// No known default password (security): use STAFF_PASSWORD if given, else a random one printed once below.
|
|
44
|
+
const password = process.env.STAFF_PASSWORD ?? randomBytes(12).toString('base64url');
|
|
45
|
+
await createStaffMember(db, staffAuth, { name, email, password });
|
|
46
|
+
await ensureRoles(db, pluginPermissions);
|
|
47
|
+
console.log(`Seeded staff "${email}" as Admin.`);
|
|
48
|
+
console.log(` email: ${email}`);
|
|
49
|
+
console.log(
|
|
50
|
+
` password: ${password}${process.env.STAFF_PASSWORD ? '' : ' (randomly generated — save it now)'}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Seed a starter business profile (edit in Settings → Business profile) so the admin brand shows your
|
|
55
|
+
// project name on first boot instead of the SAENA placeholder. Idempotent; country is a placeholder.
|
|
56
|
+
if (!(await getBusinessProfile(db))) {
|
|
57
|
+
const brandName = '{{PROJECT_NAME}}'.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
58
|
+
await upsertBusinessProfile(db, {
|
|
59
|
+
name: brandName,
|
|
60
|
+
legalName: '',
|
|
61
|
+
country: 'US',
|
|
62
|
+
vatNumber: '',
|
|
63
|
+
registrationNumber: '',
|
|
64
|
+
});
|
|
65
|
+
console.log(`Seeded business profile "${brandName}" (edit it in Settings).`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
process.exit(0);
|