create-smoodly-app 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Laniakea Studio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # create-smoodly-app
2
+
3
+ npx create-smoodly-app my-site
4
+
5
+ A fresh Next.js app with the Smoodly admin mounted at `/admin`, a Supabase
6
+ project holding the schema, `.env.local` written, and a first editor who can
7
+ sign in. Spec: `docs/superpowers/specs/2026-09-04-create-smoodly-app-design.md`.
8
+
9
+ ## What it asks
10
+
11
+ 1. Project name (also the positional argument).
12
+ 2. Where content lives: a **local Supabase stack** (`supabase init` +
13
+ `supabase start` inside the app; needs the Supabase CLI and Docker) or a
14
+ **hosted project** (URL, publishable key, secret key; then an offer to
15
+ `supabase link` + `supabase db push`).
16
+ 3. The first editor: pick an existing Auth user or create one (email +
17
+ password). The `editors` row is written with role `owner`.
18
+
19
+ Every prompt has a flag, so CI can run it: `--supabase local|hosted`, `--url`,
20
+ `--publishable-key`, `--secret-key`, `--editor-email` (password from
21
+ `SMOODLY_EDITOR_PASSWORD`), `--skip-link`, `--yes`. `--help` lists them.
22
+ A hidden `--smoodly-dep <spec>` overrides the `smoodly` dependency written into
23
+ `package.json`; CI points it at the packed tarball.
24
+
25
+ ## How it is built
26
+
27
+ `templates/next/` is generated by `scripts/build-template.ts` from
28
+ `examples/site` — an allow-list of copied files plus a generated
29
+ `package.json` (with `smoodly` pinned to this package's version), `tsconfig`,
30
+ `next.config`, README and the one migration (`coreTablesSQL` + the starter
31
+ collections' views). It is git-ignored and rebuilt by `npm run build`.
32
+
33
+ `src/plan.ts` turns answers into steps; `src/run.ts` executes them with
34
+ injected `exec`, fs and Supabase client so the sequence and every fallback
35
+ message are unit-tested. `src/prompts.ts` is the only interactive code.
36
+ The CLI has no runtime dependency on `smoodly`.
37
+
38
+ npm test # unit tests (template build, planner, runner, editor)
39
+ npm run build # template + esbuild bundle → dist/index.js
40
+
41
+ ## Follow-ups (logged 2026-09-04)
42
+
43
+ - Windows: `exec` passes `shell: true` on win32 (needed for the `.cmd` shims)
44
+ but does not quote arguments, so a project name or path containing a space
45
+ or `&` breaks. Quote the args when the shell is on, or resolve the `.cmd`
46
+ path and drop `shell`. Untested on Windows.
47
+ - Hosted mode has not been run against a real hosted Supabase project with a
48
+ new-format `sb_secret_` key; CI and the local smoke run both use legacy JWT
49
+ keys. One manual run before the first customer install.
50
+ - `listUsers` reads one page of 200 Auth users when offering an existing user
51
+ to make the first editor. Fine for a fresh project, wrong for a populated one.
52
+ - `supabase link` runs without `--project-ref`, so the CLI re-asks for the
53
+ project even though it is derivable from the URL already typed.
54
+ - The `--yes` flag defaults to local mode; with the Supabase CLI absent that
55
+ stops with the install message rather than falling through to hosted.
56
+ - Captured `exec` output is stdout only; stderr streams straight through, so
57
+ `git init` hints and the Supabase CLI's update notice appear between the
58
+ prompt lines.
59
+ - CI never runs local mode, and never applies the template's migration to a
60
+ fresh database: the e2e uses hosted mode against a stack whose schema came
61
+ from the repo's own migration. Add a local-mode e2e step — the runner's stack
62
+ uses ports 643xx and a fresh `supabase init` defaults to 543xx, so both can
63
+ run at once.
64
+ - The "Local" option is still offered in the select when the Supabase CLI is
65
+ absent, and only bails afterwards; the spec wants the check before the prompt.
package/dist/index.js ADDED
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import * as p2 from "@clack/prompts";
5
+ import { rm, writeFile as writeFile2 } from "node:fs/promises";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ // src/args.ts
9
+ import { parseArgs } from "node:util";
10
+ function parseFlags(argv) {
11
+ const { values, positionals } = parseArgs({
12
+ args: argv,
13
+ allowPositionals: true,
14
+ options: {
15
+ supabase: { type: "string" },
16
+ url: { type: "string" },
17
+ "publishable-key": { type: "string" },
18
+ "secret-key": { type: "string" },
19
+ "editor-email": { type: "string" },
20
+ "smoodly-dep": { type: "string" },
21
+ "skip-link": { type: "boolean", default: false },
22
+ yes: { type: "boolean", short: "y", default: false },
23
+ help: { type: "boolean", short: "h", default: false }
24
+ }
25
+ });
26
+ if (positionals.length > 1) throw new Error("Only one project name is accepted.");
27
+ const supabase = values.supabase;
28
+ if (supabase !== void 0 && supabase !== "local" && supabase !== "hosted") {
29
+ throw new Error(`--supabase must be "local" or "hosted", got "${supabase}"`);
30
+ }
31
+ return {
32
+ name: positionals[0],
33
+ supabase,
34
+ url: values.url,
35
+ publishableKey: values["publishable-key"],
36
+ secretKey: values["secret-key"],
37
+ editorEmail: values["editor-email"],
38
+ skipLink: values["skip-link"] ?? false,
39
+ yes: values.yes ?? false,
40
+ help: values.help ?? false,
41
+ smoodlyDep: values["smoodly-dep"]
42
+ };
43
+ }
44
+ var HELP = `create-smoodly-app \u2014 a fresh Next.js site with Smoodly installed
45
+
46
+ Usage: npx create-smoodly-app [name] [options]
47
+
48
+ Options:
49
+ --supabase local|hosted Where content lives (asked when omitted; local needs the Supabase CLI)
50
+ --url <url> Hosted: the Supabase project URL
51
+ --publishable-key <key> Hosted: the anon / publishable key
52
+ --secret-key <key> Hosted: the service-role / secret key
53
+ --editor-email <email> The first editor (password from SMOODLY_EDITOR_PASSWORD)
54
+ --skip-link Hosted: do not run supabase link / db push
55
+ -y, --yes Accept every default
56
+ -h, --help This text
57
+ `;
58
+
59
+ // src/editor.ts
60
+ import { createClient } from "@supabase/supabase-js";
61
+ async function ensureEditor(client, ask) {
62
+ const existing = await client.listUsers();
63
+ const choice = await ask(existing);
64
+ const user = choice.kind === "pick" ? choice.user : await client.createUser(choice.email, choice.password);
65
+ await client.upsertEditor(user.id);
66
+ return user;
67
+ }
68
+ function clientFor(keys) {
69
+ const secret = keys.secretKey.startsWith("sb_secret_");
70
+ return createClient(keys.url, secret ? keys.secretKey : keys.publishableKey, {
71
+ global: { headers: secret ? {} : { Authorization: `Bearer ${keys.secretKey}` } },
72
+ auth: { persistSession: false, autoRefreshToken: false }
73
+ });
74
+ }
75
+ function supabaseEditorClient(keys) {
76
+ const db = clientFor(keys);
77
+ const fail = (what, message) => new Error(`${what}: ${message}`);
78
+ return {
79
+ async listUsers() {
80
+ const res = await db.auth.admin.listUsers({ perPage: 200 });
81
+ if (res.error) throw fail("listing Auth users", res.error.message);
82
+ return res.data.users.map((u) => ({ id: u.id, email: u.email ?? null }));
83
+ },
84
+ async createUser(email, password2) {
85
+ const res = await db.auth.admin.createUser({ email, password: password2, email_confirm: true });
86
+ if (res.error) throw fail("creating the Auth user", res.error.message);
87
+ return { id: res.data.user.id, email: res.data.user.email ?? null };
88
+ },
89
+ async upsertEditor(userId) {
90
+ const res = await db.from("editors").upsert({ user_id: userId, role: "owner" }, { onConflict: "space_id,user_id" });
91
+ if (res.error) throw fail("inserting the editors row", res.error.message);
92
+ }
93
+ };
94
+ }
95
+
96
+ // src/exec.ts
97
+ import { spawn } from "node:child_process";
98
+ var exec = (cmd, args, opts) => new Promise((resolve2, reject) => {
99
+ const child = spawn(cmd, args, {
100
+ cwd: opts.cwd,
101
+ stdio: [opts.stdin ?? "inherit", opts.capture ? "pipe" : "inherit", "inherit"],
102
+ shell: process.platform === "win32"
103
+ });
104
+ let stdout = "";
105
+ child.stdout?.on("data", (chunk) => stdout += chunk);
106
+ child.on("error", reject);
107
+ child.on(
108
+ "close",
109
+ (code) => code === 0 ? resolve2({ stdout }) : reject(new Error(`\`${cmd} ${args.join(" ")}\` exited with code ${code}`))
110
+ );
111
+ });
112
+ async function hasCommand(cmd, run2, cwd) {
113
+ try {
114
+ await run2(cmd, ["--version"], { cwd, capture: true });
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ // src/prompts.ts
122
+ import * as p from "@clack/prompts";
123
+ import { resolve } from "node:path";
124
+
125
+ // src/scaffold.ts
126
+ import { cp, readdir, readFile, rename, writeFile } from "node:fs/promises";
127
+ import { join } from "node:path";
128
+ function validName(name) {
129
+ if (!name) return "The name is empty.";
130
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(name)) return "Use lowercase letters, digits, dots, dashes and underscores (it becomes the package name).";
131
+ return null;
132
+ }
133
+ async function isEmptyOrMissing(dir) {
134
+ try {
135
+ return (await readdir(dir)).length === 0;
136
+ } catch (e) {
137
+ const code = e.code;
138
+ if (code === "ENOENT") return true;
139
+ if (code === "ENOTDIR") return false;
140
+ throw e;
141
+ }
142
+ }
143
+ async function scaffold(opts) {
144
+ await cp(opts.templateDir, opts.dir, { recursive: true });
145
+ await rename(join(opts.dir, "_gitignore"), join(opts.dir, ".gitignore"));
146
+ const pkgPath = join(opts.dir, "package.json");
147
+ const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
148
+ pkg.name = opts.name;
149
+ if (opts.smoodlyDep) pkg.dependencies.smoodly = opts.smoodlyDep;
150
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
151
+ }
152
+
153
+ // src/prompts.ts
154
+ function bail(message) {
155
+ p.cancel(message);
156
+ process.exit(1);
157
+ }
158
+ async function answer(value) {
159
+ const v = await value;
160
+ if (p.isCancel(v)) bail("Cancelled.");
161
+ return v;
162
+ }
163
+ async function answerOrThrow(value) {
164
+ const v = await value;
165
+ if (p.isCancel(v)) throw new Error("Cancelled.");
166
+ return v;
167
+ }
168
+ async function resolveAnswers(flags2, ctx) {
169
+ const name = flags2.name ?? await answer(p.text({ message: "Project name", placeholder: "my-site", validate: (v) => validName(v ?? "") ?? void 0 }));
170
+ const invalid = validName(name);
171
+ if (invalid) bail(invalid);
172
+ const dir = resolve(ctx.cwd, name);
173
+ if (!await isEmptyOrMissing(dir)) bail(`${dir} exists and is not empty.`);
174
+ const supabase = flags2.supabase ?? (flags2.yes ? "local" : await answer(
175
+ p.select({
176
+ message: "Where should content live?",
177
+ options: [
178
+ { value: "local", label: "Local Supabase stack", hint: "supabase start inside the new app; needs the Supabase CLI and Docker" },
179
+ { value: "hosted", label: "Hosted Supabase project", hint: "paste the project URL and keys" }
180
+ ]
181
+ })
182
+ ));
183
+ if (supabase === "local") {
184
+ if (!ctx.hasSupabaseCli) bail("Local mode needs the Supabase CLI: https://supabase.com/docs/guides/cli (brew install supabase/tap/supabase)");
185
+ return { name, dir, supabase, hasSupabaseCli: true, smoodlyDep: flags2.smoodlyDep };
186
+ }
187
+ const url = flags2.url ?? await answer(p.text({ message: "Supabase project URL", placeholder: "https://abcd.supabase.co" }));
188
+ const publishableKey = flags2.publishableKey ?? await answer(p.text({ message: "Publishable (anon) key" }));
189
+ const secretKey = flags2.secretKey ?? await answer(p.password({ message: "Secret (service role) key" }));
190
+ const linkAndPush = !ctx.hasSupabaseCli ? false : flags2.skipLink ? false : flags2.yes ? true : await answer(p.confirm({ message: "Link this app to the project and push the migration now? (supabase link, supabase db push)" }));
191
+ return { name, dir, supabase, hasSupabaseCli: ctx.hasSupabaseCli, hosted: { keys: { url, publishableKey, secretKey }, linkAndPush }, smoodlyDep: flags2.smoodlyDep };
192
+ }
193
+ function askEditorWith(flags2, env = process.env) {
194
+ return async (existing) => {
195
+ if (flags2.editorEmail) {
196
+ const found = existing.find((u) => u.email === flags2.editorEmail);
197
+ if (found) return { kind: "pick", user: found };
198
+ const password3 = env.SMOODLY_EDITOR_PASSWORD;
199
+ if (!password3) throw new Error(`Set SMOODLY_EDITOR_PASSWORD to create ${flags2.editorEmail}.`);
200
+ return { kind: "create", email: flags2.editorEmail, password: password3 };
201
+ }
202
+ if (existing.length > 0) {
203
+ const picked = await answerOrThrow(
204
+ p.select({
205
+ message: "Who is the first editor?",
206
+ options: [
207
+ ...existing.map((u) => ({ value: u.id, label: u.email ?? u.id })),
208
+ { value: "__new__", label: "Create a new user" }
209
+ ]
210
+ })
211
+ );
212
+ if (picked !== "__new__") return { kind: "pick", user: existing.find((u) => u.id === picked) };
213
+ }
214
+ const email = await answerOrThrow(p.text({ message: "Editor email", validate: (v) => v && v.includes("@") ? void 0 : "An email address" }));
215
+ const password2 = await answerOrThrow(p.password({ message: "Editor password", validate: (v) => v && v.length >= 8 ? void 0 : "At least 8 characters" }));
216
+ return { kind: "create", email, password: password2 };
217
+ };
218
+ }
219
+
220
+ // src/run.ts
221
+ import { join as join2 } from "node:path";
222
+
223
+ // src/env.ts
224
+ function renderEnv(keys) {
225
+ return [
226
+ "# Smoodly, self-host: the content project's values. SMOODLY_AUTH_URL and",
227
+ "# SMOODLY_AUTH_KEY default to these two \u2014 leave them unset.",
228
+ `SMOODLY_URL=${keys.url}`,
229
+ `SMOODLY_SECRET_KEY=${keys.secretKey}`,
230
+ `SMOODLY_PUBLISHABLE_KEY=${keys.publishableKey}`,
231
+ ""
232
+ ].join("\n");
233
+ }
234
+
235
+ // src/plan.ts
236
+ function plan(a) {
237
+ if (a.supabase === "local" && !a.hasSupabaseCli) {
238
+ throw new Error("Local mode needs the Supabase CLI: https://supabase.com/docs/guides/cli (brew install supabase/tap/supabase)");
239
+ }
240
+ const steps = ["copyTemplate", "install", "gitInit"];
241
+ if (a.supabase === "local") steps.push("supabaseLocal");
242
+ else if (a.hasSupabaseCli) {
243
+ steps.push("supabaseInit");
244
+ if (a.hosted?.linkAndPush) steps.push("linkAndPush");
245
+ }
246
+ steps.push("writeEnv", "ensureEditor", "finish");
247
+ return steps;
248
+ }
249
+
250
+ // src/supabase-cli.ts
251
+ function parseStatusEnv(output) {
252
+ const vars = /* @__PURE__ */ new Map();
253
+ for (const line of output.split("\n")) {
254
+ const m = /^([A-Z_]+)=(?:"([^"]*)"|(.*))$/.exec(line.trim());
255
+ if (m) vars.set(m[1], m[2] ?? m[3]);
256
+ }
257
+ const url = vars.get("API_URL");
258
+ const publishableKey = vars.get("ANON_KEY");
259
+ const secretKey = vars.get("SERVICE_ROLE_KEY");
260
+ if (!url || !publishableKey || !secretKey) {
261
+ throw new Error("`supabase status -o env` did not report API_URL, ANON_KEY and SERVICE_ROLE_KEY");
262
+ }
263
+ return { url, publishableKey, secretKey };
264
+ }
265
+
266
+ // src/run.ts
267
+ async function run(answers2, deps) {
268
+ if (answers2.supabase === "hosted" && !answers2.hosted) {
269
+ throw new Error("hosted answers need the project's keys");
270
+ }
271
+ if (!await deps.fs.isEmptyOrMissing(answers2.dir)) {
272
+ throw new Error(`${answers2.dir} exists and is not empty.`);
273
+ }
274
+ const steps = plan(answers2);
275
+ const ctx = { keys: answers2.hosted?.keys };
276
+ const cwd = answers2.dir;
277
+ const sh = (cmd, args, extra = {}) => deps.exec(cmd, args, { cwd, ...extra });
278
+ for (const step of steps) {
279
+ try {
280
+ switch (step) {
281
+ case "copyTemplate":
282
+ await deps.fs.scaffold({ templateDir: deps.templateDir, dir: cwd, name: answers2.name, smoodlyDep: answers2.smoodlyDep });
283
+ break;
284
+ case "install":
285
+ deps.log("Installing dependencies\u2026");
286
+ await sh("npm", ["install"]);
287
+ break;
288
+ case "gitInit":
289
+ await sh("git", ["init"], { capture: true });
290
+ await sh("git", ["add", "-A"], { capture: true });
291
+ await sh("git", ["commit", "-m", "Smoodly site"], { capture: true });
292
+ break;
293
+ case "supabaseLocal":
294
+ deps.log("Starting the local Supabase stack (first run pulls Docker images)\u2026");
295
+ await sh("supabase", ["init"], { stdin: "ignore" });
296
+ await sh("supabase", ["start"]);
297
+ ctx.keys = parseStatusEnv((await sh("supabase", ["status", "-o", "env"], { capture: true })).stdout);
298
+ break;
299
+ case "supabaseInit":
300
+ await sh("supabase", ["init"], { stdin: "ignore" });
301
+ break;
302
+ case "linkAndPush":
303
+ await sh("supabase", ["link"]);
304
+ await sh("supabase", ["db", "push"]);
305
+ break;
306
+ case "writeEnv":
307
+ await deps.fs.writeFile(join2(cwd, ".env.local"), renderEnv(ctx.keys));
308
+ break;
309
+ case "ensureEditor":
310
+ ctx.editor = await ensureEditor(deps.editorClient(ctx.keys), deps.askEditor);
311
+ break;
312
+ case "finish":
313
+ break;
314
+ }
315
+ } catch (e) {
316
+ const error = e instanceof Error ? e.message : String(e);
317
+ if (step === "copyTemplate" || step === "install") {
318
+ await deps.fs.rm(cwd);
319
+ return { ok: false, step, error, fallback: `Nothing was left behind. Fix the cause and run create-smoodly-app again.` };
320
+ }
321
+ if (step === "gitInit") {
322
+ deps.log(`Skipped the initial git commit (${error}). Commit when you like.`);
323
+ continue;
324
+ }
325
+ return { ok: false, step, error, fallback: fallbackFor(step, answers2, ctx.keys) };
326
+ }
327
+ }
328
+ return { ok: true, keys: ctx.keys, editor: ctx.editor };
329
+ }
330
+ function fallbackFor(step, answers2, keys) {
331
+ const envLines = keys ? `Write ${answers2.name}/.env.local:
332
+
333
+ ${renderEnv(keys).split("\n").filter((l) => !l.startsWith("#")).join("\n").trimEnd()}` : `Write ${answers2.name}/.env.local with SMOODLY_URL, SMOODLY_SECRET_KEY and SMOODLY_PUBLISHABLE_KEY (from \`supabase status\`).`;
334
+ const editorLines = [
335
+ "Create the first editor: add a user in Supabase Studio (Authentication \u2192 Users), then in the SQL editor:",
336
+ "",
337
+ " insert into editors (user_id, role) values ('<the user id>', 'owner');"
338
+ ].join("\n");
339
+ switch (step) {
340
+ case "supabaseLocal":
341
+ return [`cd ${answers2.name} && supabase init && supabase start`, "", envLines, "", editorLines].join("\n");
342
+ case "supabaseInit": {
343
+ const commands = answers2.hosted?.linkAndPush ? [`cd ${answers2.name} && supabase init && supabase link && supabase db push`] : [`cd ${answers2.name} && supabase init`, "", "When you are ready: supabase link && supabase db push"];
344
+ return [...commands, "", envLines, "", editorLines].join("\n");
345
+ }
346
+ case "linkAndPush":
347
+ return [`cd ${answers2.name} && supabase link && supabase db push`, "", envLines, "", editorLines].join("\n");
348
+ case "writeEnv":
349
+ return [envLines, "", editorLines].join("\n");
350
+ case "ensureEditor":
351
+ return [
352
+ "If the editors table is missing, apply the migration first: `supabase db push` (hosted) or `supabase start` (local).",
353
+ "",
354
+ editorLines
355
+ ].join("\n");
356
+ default:
357
+ return "";
358
+ }
359
+ }
360
+
361
+ // src/index.ts
362
+ var TEMPLATE_DIR = fileURLToPath(new URL("../templates/next/", import.meta.url));
363
+ var flags;
364
+ try {
365
+ flags = parseFlags(process.argv.slice(2));
366
+ } catch (e) {
367
+ console.error(e instanceof Error ? e.message : String(e));
368
+ console.error(HELP);
369
+ process.exit(2);
370
+ }
371
+ if (flags.help) {
372
+ console.log(HELP);
373
+ process.exit(0);
374
+ }
375
+ p2.intro("create-smoodly-app");
376
+ var answers;
377
+ var outcome;
378
+ try {
379
+ const hasSupabaseCli = await hasCommand("supabase", exec, process.cwd());
380
+ answers = await resolveAnswers(flags, { hasSupabaseCli, cwd: process.cwd() });
381
+ outcome = await run(answers, {
382
+ exec,
383
+ templateDir: TEMPLATE_DIR,
384
+ editorClient: supabaseEditorClient,
385
+ askEditor: askEditorWith(flags),
386
+ log: (line) => p2.log.step(line),
387
+ fs: { isEmptyOrMissing, scaffold, writeFile: (path, text2) => writeFile2(path, text2), rm: (dir) => rm(dir, { recursive: true, force: true }) }
388
+ });
389
+ } catch (e) {
390
+ p2.cancel(e instanceof Error ? e.message : String(e));
391
+ process.exit(1);
392
+ }
393
+ if (!outcome.ok) {
394
+ p2.log.error(`${outcome.step} failed: ${outcome.error}`);
395
+ if (outcome.fallback) p2.note(outcome.fallback, "Finish by hand");
396
+ process.exit(1);
397
+ }
398
+ p2.note(
399
+ [`cd ${answers.name}`, "npm run dev", "", "Site: http://localhost:3000", "Admin: http://localhost:3000/admin", `Editor: ${outcome.editor.email ?? outcome.editor.id}`].join("\n"),
400
+ "Ready"
401
+ );
402
+ p2.outro("Happy editing.");
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "create-smoodly-app",
3
+ "version": "0.0.1",
4
+ "description": "Create a Smoodly site: a fresh Next.js app with the admin mounted, a Supabase schema, an env file and a first editor.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/laniakea-studio/smoodly-cms.git",
9
+ "directory": "packages/create-smoodly-app"
10
+ },
11
+ "type": "module",
12
+ "bin": { "create-smoodly-app": "./dist/index.js" },
13
+ "files": ["dist", "templates"],
14
+ "engines": { "node": ">=20" },
15
+ "scripts": {
16
+ "build": "npm run build:template && npm run build:cli",
17
+ "build:template": "tsx scripts/build-template.ts",
18
+ "build:cli": "esbuild src/index.ts --bundle --packages=external --platform=node --format=esm --target=node20 --outfile=dist/index.js --banner:js='#!/usr/bin/env node'",
19
+ "prepublishOnly": "npm run build",
20
+ "test": "vitest run",
21
+ "typecheck": "tsc --noEmit"
22
+ },
23
+ "dependencies": {
24
+ "@clack/prompts": "^1.7.0",
25
+ "@supabase/supabase-js": "^2.113.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^26.4.1",
29
+ "@types/react": "^19",
30
+ "esbuild": "^0.28.2",
31
+ "tsx": "^4",
32
+ "typescript": "^5",
33
+ "vitest": "^3"
34
+ }
35
+ }
@@ -0,0 +1,31 @@
1
+ # Smoodly site
2
+
3
+ Created by `create-smoodly-app`. Content lives in Supabase; the admin is
4
+ mounted at `/admin` from the `smoodly` package.
5
+
6
+ npm run dev # http://localhost:3000 — /admin is the editor
7
+
8
+ ## Where things are
9
+
10
+ - `app/page.tsx` — the home page: its zones and JSX, registered as the fixed slug `home`.
11
+ - `app/[slug]/page.tsx` — every other page editors create.
12
+ - `components/sections/` — the building blocks editors compose with. Add one, list it in `sections.ts`.
13
+ - `collections.ts` — structured content (people, articles). Add a collection, then add its view to a new migration with `collectionSQL` from `smoodly`.
14
+ - `lib/smoodly/` — the wiring: stores, the admin ops action, the admin mount.
15
+ - `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
16
+
17
+ ## Editors
18
+
19
+ Anyone who may open `/admin` is a Supabase Auth user with a row in the
20
+ `editors` table. To add one: create the user in Supabase Studio
21
+ (Authentication → Users), then in the SQL editor:
22
+
23
+ insert into editors (user_id, role) values ('<the user id>', 'editor');
24
+
25
+ ## Hosted Supabase
26
+
27
+ supabase init # if the CLI has not been set up here yet
28
+ supabase link # pick the project
29
+ supabase db push # applies supabase/migrations/
30
+
31
+ Then put the project's URL and keys in `.env.local`.
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ .next/
3
+ .env.local
4
+ next-env.d.ts
5
+ *.tsbuildinfo
@@ -0,0 +1 @@
1
+ export { AdminPage as default } from "../../../../lib/smoodly/admin";
@@ -0,0 +1 @@
1
+ export { AdminLayout as default } from "../../../lib/smoodly/admin";
@@ -0,0 +1,42 @@
1
+ import { smoodly, z, Zone, type Props } from "smoodly";
2
+ import { Hero } from "../../components/sections/Hero";
3
+ import { ArticleList } from "../../components/sections/ArticleList";
4
+ import { CtaBanner } from "../../components/sections/CtaBanner";
5
+ import { renderSmoodlyPage, smoodlyMetadata } from "../../lib/smoodly";
6
+
7
+ type SearchParams = Promise<Record<string, string | string[] | undefined>>;
8
+ type Params = Promise<{ slug: string }>;
9
+
10
+ // Open registration: editors create as many of these as they like, and
11
+ // this one file serves all of them — at depth 1 only. Nested paths are
12
+ // the catch-all's job (app/[...rest]).
13
+ export const pageSchema = smoodly.schema.page({
14
+ name: "page",
15
+ title: "Page",
16
+ zones: {
17
+ hero: z.sections([Hero]).max(1),
18
+ body: z.freeform({ sections: [ArticleList] }),
19
+ // Locked: fields only. One CtaBanner, always there, never movable.
20
+ prefooter: z.locked(CtaBanner),
21
+ },
22
+ meta: { seo: true },
23
+ });
24
+
25
+ function PageView({ zones }: Props<typeof pageSchema>) {
26
+ return (
27
+ <>
28
+ <Zone of={zones.hero} />
29
+ <Zone of={zones.body} />
30
+ <Zone of={zones.prefooter} />
31
+ </>
32
+ );
33
+ }
34
+
35
+ export const StandardPage = smoodly.page(pageSchema, PageView);
36
+
37
+ export const generateMetadata = async ({ params }: { params: Params }) =>
38
+ smoodlyMetadata(StandardPage, { slug: (await params).slug });
39
+
40
+ export default async function Page({ params, searchParams }: { params: Params; searchParams: SearchParams }) {
41
+ return renderSmoodlyPage(StandardPage, { slug: (await params).slug, searchParams });
42
+ }
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export const metadata = { title: "Smoodly site" };
4
+
5
+ export default function RootLayout({ children }: { children: ReactNode }) {
6
+ return (
7
+ <html lang="en">
8
+ <body>{children}</body>
9
+ </html>
10
+ );
11
+ }
@@ -0,0 +1,48 @@
1
+ import { smoodly, z, Zone, type Props } from "smoodly";
2
+ import { Hero } from "../components/sections/Hero";
3
+ import { ArticleList } from "../components/sections/ArticleList";
4
+ import { CtaBanner } from "../components/sections/CtaBanner";
5
+ import { renderSmoodlyPage, smoodlyMetadata } from "../lib/smoodly";
6
+
7
+ type SearchParams = Promise<Record<string, string | string[] | undefined>>;
8
+
9
+ // The simple strategy: this file IS the page. Zones, JSX and the route
10
+ // live together; `slug` makes it a singleton the editor cannot delete or
11
+ // duplicate. See docs/DESIGN.md §3.
12
+ export const homeSchema = smoodly.schema.page({
13
+ name: "home",
14
+ title: "Home",
15
+ slug: "home",
16
+ zones: {
17
+ hero: z.sections([Hero]).max(1),
18
+ body: z.freeform({ sections: [ArticleList] }),
19
+ // Locked: fields only. One CtaBanner, always there, never movable.
20
+ prefooter: z.locked(CtaBanner),
21
+ },
22
+ meta: { seo: true },
23
+ });
24
+
25
+ function HomeView({ zones }: Props<typeof homeSchema>) {
26
+ return (
27
+ <>
28
+ <Zone of={zones.hero} />
29
+ <Zone of={zones.body} />
30
+ <Zone of={zones.prefooter} />
31
+ <footer>
32
+ <p>Hard-coded footer — owned by the developer, invisible to the editor.</p>
33
+ </footer>
34
+ </>
35
+ );
36
+ }
37
+
38
+ export const HomePage = smoodly.page(homeSchema, HomeView);
39
+
40
+ // No `dynamic = "force-dynamic"` needed: renderSmoodlyPage and
41
+ // smoodlyMetadata wait for a request themselves, so `next build` never
42
+ // prerenders this route against the database.
43
+
44
+ export const generateMetadata = () => smoodlyMetadata(HomePage);
45
+
46
+ export default async function Page({ searchParams }: { searchParams: SearchParams }) {
47
+ return renderSmoodlyPage(HomePage, { searchParams });
48
+ }