@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.
Files changed (37) hide show
  1. package/dist/index.js +313 -0
  2. package/package.json +24 -0
  3. package/template/addons/content/src/content/Hero.tsx +13 -0
  4. package/template/addons/content/src/content/admin.ts +5 -0
  5. package/template/addons/content/src/content/schema.ts +13 -0
  6. package/template/addons/content/src/content/sections.ts +6 -0
  7. package/template/addons/content/src/routes/index.tsx +21 -0
  8. package/template/addons/content/src/server/content.ts +25 -0
  9. package/template/base/.env.example +15 -0
  10. package/template/base/README.md +58 -0
  11. package/template/base/gitignore +14 -0
  12. package/template/base/package.json +40 -0
  13. package/template/base/scripts/migrate.ts +43 -0
  14. package/template/base/scripts/seed-staff.ts +68 -0
  15. package/template/base/src/admin/data.ts +261 -0
  16. package/template/base/src/admin/registry.ts +6 -0
  17. package/template/base/src/router.tsx +19 -0
  18. package/template/base/src/routes/__root.tsx +34 -0
  19. package/template/base/src/routes/admin/$.tsx +17 -0
  20. package/template/base/src/routes/admin/index.tsx +8 -0
  21. package/template/base/src/routes/admin/route.tsx +29 -0
  22. package/template/base/src/routes/api/assets/$.ts +125 -0
  23. package/template/base/src/routes/api/assets/upload.ts +99 -0
  24. package/template/base/src/routes/api/auth/staff/$.ts +12 -0
  25. package/template/base/src/routes/api/plugin/$.ts +51 -0
  26. package/template/base/src/routes/index.tsx +19 -0
  27. package/template/base/src/server/admin-context.ts +39 -0
  28. package/template/base/src/server/auth.ts +32 -0
  29. package/template/base/src/server/branding.ts +19 -0
  30. package/template/base/src/server/business-profile.ts +19 -0
  31. package/template/base/src/server/core.ts +41 -0
  32. package/template/base/src/server/plugin-host.ts +21 -0
  33. package/template/base/src/server/require-ctx.ts +25 -0
  34. package/template/base/src/server/team.ts +58 -0
  35. package/template/base/src/server/translations.ts +74 -0
  36. package/template/base/tsconfig.json +22 -0
  37. 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,14 @@
1
+ node_modules
2
+ dist
3
+ .output
4
+ .tanstack
5
+ .nitro
6
+ .turbo
7
+ .vite
8
+
9
+ # Local env + data
10
+ .env
11
+ .data
12
+
13
+ *.log
14
+ .DS_Store
@@ -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);