@pylonsync/create-pylon 0.3.50 → 0.3.53

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 (65) hide show
  1. package/bin/create-pylon.js +292 -1157
  2. package/package.json +4 -3
  3. package/templates/_root/.env.example +9 -0
  4. package/templates/_root/README.md +43 -0
  5. package/templates/backend/barebones/apps/api/functions/createWidget.ts +22 -0
  6. package/templates/backend/barebones/apps/api/functions/listWidgets.ts +18 -0
  7. package/templates/backend/barebones/apps/api/package.json +20 -0
  8. package/templates/backend/barebones/apps/api/schema.ts +61 -0
  9. package/templates/backend/barebones/apps/api/tsconfig.json +13 -0
  10. package/templates/backend/todo/apps/api/functions/addTodo.ts +27 -0
  11. package/templates/backend/todo/apps/api/functions/deleteTodo.ts +14 -0
  12. package/templates/backend/todo/apps/api/functions/editTodo.ts +16 -0
  13. package/templates/backend/todo/apps/api/functions/listTodos.ts +24 -0
  14. package/templates/backend/todo/apps/api/functions/reorderTodo.ts +14 -0
  15. package/templates/backend/todo/apps/api/functions/toggleTodo.ts +13 -0
  16. package/templates/backend/todo/apps/api/package.json +20 -0
  17. package/templates/backend/todo/apps/api/schema.ts +85 -0
  18. package/templates/backend/todo/apps/api/tsconfig.json +13 -0
  19. package/templates/expo/barebones/apps/expo/App.tsx +166 -0
  20. package/templates/expo/barebones/apps/expo/app.json +31 -0
  21. package/templates/expo/barebones/apps/expo/babel.config.js +6 -0
  22. package/templates/expo/barebones/apps/expo/package.json +30 -0
  23. package/templates/expo/barebones/apps/expo/tsconfig.json +16 -0
  24. package/templates/expo/todo/apps/expo/App.tsx +287 -0
  25. package/templates/expo/todo/apps/expo/app.json +25 -0
  26. package/templates/expo/todo/apps/expo/babel.config.js +6 -0
  27. package/templates/expo/todo/apps/expo/package.json +30 -0
  28. package/templates/expo/todo/apps/expo/tsconfig.json +16 -0
  29. package/templates/mobile/barebones/apps/mobile/Package.swift +34 -0
  30. package/templates/mobile/barebones/apps/mobile/Sources/__APP_NAME_PASCAL__/ContentView.swift +98 -0
  31. package/templates/mobile/barebones/apps/mobile/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
  32. package/templates/mobile/barebones/apps/mobile/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +34 -0
  33. package/templates/mobile/barebones/apps/mobile/project.yml +42 -0
  34. package/templates/mobile/todo/apps/mobile/Package.swift +23 -0
  35. package/templates/mobile/todo/apps/mobile/Sources/__APP_NAME_PASCAL__/Models.swift +18 -0
  36. package/templates/mobile/todo/apps/mobile/Sources/__APP_NAME_PASCAL__/TodoListView.swift +230 -0
  37. package/templates/mobile/todo/apps/mobile/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +28 -0
  38. package/templates/mobile/todo/apps/mobile/project.yml +32 -0
  39. package/templates/ui/packages/ui/package.json +26 -0
  40. package/templates/ui/packages/ui/src/button.tsx +44 -0
  41. package/templates/ui/packages/ui/src/card.tsx +39 -0
  42. package/templates/ui/packages/ui/src/cn.ts +12 -0
  43. package/templates/ui/packages/ui/src/index.ts +4 -0
  44. package/templates/ui/packages/ui/src/input.tsx +19 -0
  45. package/templates/ui/packages/ui/tsconfig.json +15 -0
  46. package/templates/web/barebones/apps/web/next-env.d.ts +2 -0
  47. package/templates/web/barebones/apps/web/next.config.ts +40 -0
  48. package/templates/web/barebones/apps/web/package.json +29 -0
  49. package/templates/web/barebones/apps/web/postcss.config.mjs +4 -0
  50. package/templates/web/barebones/apps/web/src/app/components/WidgetList.tsx +81 -0
  51. package/templates/web/barebones/apps/web/src/app/globals.css +9 -0
  52. package/templates/web/barebones/apps/web/src/app/layout.tsx +21 -0
  53. package/templates/web/barebones/apps/web/src/app/page.tsx +43 -0
  54. package/templates/web/barebones/apps/web/src/lib/pylon.ts +14 -0
  55. package/templates/web/barebones/apps/web/tsconfig.json +26 -0
  56. package/templates/web/todo/apps/web/next-env.d.ts +2 -0
  57. package/templates/web/todo/apps/web/next.config.ts +24 -0
  58. package/templates/web/todo/apps/web/package.json +32 -0
  59. package/templates/web/todo/apps/web/postcss.config.mjs +3 -0
  60. package/templates/web/todo/apps/web/src/app/components/TodoList.tsx +310 -0
  61. package/templates/web/todo/apps/web/src/app/globals.css +6 -0
  62. package/templates/web/todo/apps/web/src/app/layout.tsx +21 -0
  63. package/templates/web/todo/apps/web/src/app/page.tsx +36 -0
  64. package/templates/web/todo/apps/web/src/lib/pylon.ts +5 -0
  65. package/templates/web/todo/apps/web/tsconfig.json +26 -0
@@ -2,60 +2,137 @@
2
2
  /**
3
3
  * @pylonsync/create-pylon — scaffold a new Pylon app.
4
4
  *
5
- * Run via `npm create @pylonsync/pylon@latest [name]` (or yarn/pnpm/bun
6
- * create @pylonsync/pylon).
5
+ * Run via `npm create @pylonsync/pylon@latest [name]` (or
6
+ * yarn/pnpm/bun create @pylonsync/pylon).
7
7
  *
8
- * Generates a workspace with three packages under apps/* + packages/*:
9
- * - apps/api — Pylon backend (schema + functions/* handlers).
10
- * - apps/web — Next.js 16 + React 19 + Tailwind v4 frontend.
11
- * - packages/ui — shared shadcn-style UI primitives consumed by web.
8
+ * Picks one or more platforms (web, mobile, expo) and a template
9
+ * (barebones, todo, …). Each platform shares the same Pylon backend
10
+ * under apps/api so `bun run dev` brings the whole project up.
12
11
  *
13
- * Node-runnable (no Bun required) so `npm create` works for every
14
- * package manager. Uses only Node-builtin APIs no runtime deps.
12
+ * Templates live as real files under ../templates/<scope>/<template>.
13
+ * The scaffolder walks each requested template dir, substitutes
14
+ * placeholders, and writes the result. Keeping them on disk (instead
15
+ * of as inline strings in this file) is what stopped 0.3.50's tab-
16
+ * mangling regression class — there is no JS template-literal layer
17
+ * to corrupt.
18
+ *
19
+ * Node-runnable, no Bun required. Uses only Node-builtin APIs (no
20
+ * runtime deps): npm/yarn/pnpm/bun's `create` runners just need a
21
+ * working node binary.
15
22
  */
16
23
 
17
- import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
24
+ import {
25
+ cpSync,
26
+ existsSync,
27
+ mkdirSync,
28
+ readFileSync,
29
+ readdirSync,
30
+ statSync,
31
+ writeFileSync,
32
+ renameSync,
33
+ } from "node:fs";
18
34
  import { dirname, join, resolve } from "node:path";
35
+ import { fileURLToPath } from "node:url";
19
36
  import { createInterface } from "node:readline/promises";
20
37
  import { stdin, stdout, exit, argv, cwd } from "node:process";
21
38
 
39
+ // ---------------------------------------------------------------------------
40
+ // Locate templates relative to this script (works whether installed via
41
+ // npm, run from a clone, or invoked through `npx -p @pylonsync/create-pylon`).
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const HERE = dirname(fileURLToPath(import.meta.url));
45
+ const TEMPLATES = resolve(HERE, "..", "templates");
46
+
22
47
  // ---------------------------------------------------------------------------
23
48
  // Version pin — every generated dep references this version of @pylonsync/*.
24
- // Bumped via the workspace's release-please flow (same version as the rest
25
- // of the pylon stack).
49
+ // Read from this package's own package.json so the value follows the rest
50
+ // of the workspace automatically (release.sh bumps every package.json in
51
+ // lockstep). Hard-coding it here was a drift bug we hit historically.
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const PYLON_VERSION = JSON.parse(
55
+ readFileSync(resolve(HERE, "..", "package.json"), "utf8"),
56
+ ).version;
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Templates + platforms registry
26
60
  // ---------------------------------------------------------------------------
27
61
 
28
- const PYLON_VERSION = "0.3.22";
62
+ const TEMPLATES_AVAILABLE = ["barebones", "todo"];
63
+ const PLATFORMS_AVAILABLE = ["web", "mobile", "expo"];
29
64
 
30
65
  // ---------------------------------------------------------------------------
31
- // CLI args + interactive prompt
66
+ // CLI args + interactive prompts
32
67
  // ---------------------------------------------------------------------------
33
68
 
34
69
  const args = argv.slice(2);
35
70
  let projectName = args.find((a) => !a.startsWith("--"));
36
71
 
37
72
  const flags = {
38
- pm: args.find(
39
- (a) => a === "--bun" || a === "--pnpm" || a === "--yarn" || a === "--npm",
40
- )?.slice(2),
73
+ pm: pickValue(args, "--bun", "--pnpm", "--yarn", "--npm")?.replace(/^--/, ""),
74
+ template: takeValue(args, "--template"),
75
+ platforms: takeValue(args, "--platforms"),
41
76
  skipInstall: args.includes("--skip-install"),
42
77
  help: args.includes("--help") || args.includes("-h"),
43
78
  };
44
79
 
80
+ function takeValue(arr, name) {
81
+ const flagWithEq = arr.find((a) => a.startsWith(name + "="));
82
+ if (flagWithEq) return flagWithEq.slice(name.length + 1);
83
+ const idx = arr.indexOf(name);
84
+ if (idx >= 0 && idx + 1 < arr.length && !arr[idx + 1].startsWith("--")) {
85
+ return arr[idx + 1];
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ function pickValue(arr, ...candidates) {
91
+ for (const c of candidates) {
92
+ if (arr.includes(c)) return c;
93
+ }
94
+ return undefined;
95
+ }
96
+
45
97
  if (flags.help) {
46
- process.stdout.write(`\nUsage: npm create @pylonsync/pylon [name] [--bun|--pnpm|--yarn|--npm] [--skip-install]\n\n`);
98
+ process.stdout.write(`
99
+ Usage: npm create @pylonsync/pylon [name] [options]
100
+
101
+ --template <t> barebones | todo
102
+ --platforms <list> comma list: web,mobile,expo (default: web)
103
+ --bun|--pnpm|--yarn|--npm
104
+ --skip-install scaffold only, don't run install
105
+
106
+ Examples:
107
+ npm create @pylonsync/pylon my-app
108
+ npm create @pylonsync/pylon my-app --template todo --platforms web,mobile
109
+ npm create @pylonsync/pylon my-app --template barebones --platforms expo --bun
110
+ `);
47
111
  exit(0);
48
112
  }
49
113
 
50
- // Interactive prompts for project name + package manager. Default
51
- // PM to bun: it handles `workspace:*` correctly out of the box,
52
- // installs faster than the alternatives, and is what the
53
- // @pylonsync/* packages are tested against. The user can pick
54
- // anything though.
55
114
  const rl = createInterface({ input: stdin, output: stdout });
56
115
  if (!projectName) {
57
116
  projectName = (await rl.question("Project name: ")).trim() || "my-pylon-app";
58
117
  }
118
+ if (!flags.template) {
119
+ const ans = (
120
+ await rl.question(
121
+ `Template (${TEMPLATES_AVAILABLE.join(", ")}) [todo]: `,
122
+ )
123
+ )
124
+ .trim()
125
+ .toLowerCase();
126
+ flags.template = TEMPLATES_AVAILABLE.includes(ans) ? ans : "todo";
127
+ }
128
+ if (!flags.platforms) {
129
+ const ans = (
130
+ await rl.question(
131
+ `Platforms (${PLATFORMS_AVAILABLE.join(", ")}, comma-separated) [web]: `,
132
+ )
133
+ ).trim();
134
+ flags.platforms = ans || "web";
135
+ }
59
136
  if (!flags.pm) {
60
137
  const detected = detectPackageManager();
61
138
  const def = detected ?? "bun";
@@ -68,6 +145,30 @@ if (!flags.pm) {
68
145
  }
69
146
  rl.close();
70
147
 
148
+ const platforms = flags.platforms
149
+ .split(",")
150
+ .map((p) => p.trim().toLowerCase())
151
+ .filter(Boolean);
152
+ const unknownPlatforms = platforms.filter(
153
+ (p) => !PLATFORMS_AVAILABLE.includes(p),
154
+ );
155
+ if (unknownPlatforms.length > 0) {
156
+ console.error(
157
+ `\nError: unknown platform(s): ${unknownPlatforms.join(", ")}. Valid: ${PLATFORMS_AVAILABLE.join(", ")}\n`,
158
+ );
159
+ exit(1);
160
+ }
161
+ if (platforms.length === 0) {
162
+ console.error(`\nError: at least one platform required.\n`);
163
+ exit(1);
164
+ }
165
+ if (!TEMPLATES_AVAILABLE.includes(flags.template)) {
166
+ console.error(
167
+ `\nError: unknown template "${flags.template}". Valid: ${TEMPLATES_AVAILABLE.join(", ")}\n`,
168
+ );
169
+ exit(1);
170
+ }
171
+
71
172
  // Some PMs reject the `workspace:` protocol. Bun/pnpm/yarn berry
72
173
  // understand it and rewrite to the local sibling version at install
73
174
  // time. npm errors EUNSUPPORTEDPROTOCOL ("Unsupported URL Type").
@@ -78,1129 +179,217 @@ const usesWorkspaceProtocol = flags.pm !== "npm";
78
179
  const workspaceDepSpec = usesWorkspaceProtocol ? "workspace:*" : "*";
79
180
 
80
181
  const root = resolve(cwd(), projectName);
81
-
82
182
  if (existsSync(root) && readdirSync(root).length > 0) {
83
183
  console.error(`\nError: ${root} already exists and is not empty.\n`);
84
184
  exit(1);
85
185
  }
186
+ mkdirSync(root, { recursive: true });
86
187
 
87
- console.log(`\nCreating ${projectName} in ${root}\n`);
88
-
89
- // ---------------------------------------------------------------------------
90
- // File-tree generator — every `write(path, content)` call creates parent
91
- // dirs as needed and writes UTF-8 text. Keeping the scaffold inline (no
92
- // template files) means create-pylon stays a single zero-dep file.
93
- // ---------------------------------------------------------------------------
94
-
95
- function write(path, content) {
96
- const full = join(root, path);
97
- mkdirSync(dirname(full), { recursive: true });
98
- writeFileSync(full, content);
99
- }
100
-
101
- function writeJson(path, value) {
102
- write(path, JSON.stringify(value, null, 2) + "\n");
103
- }
104
-
105
- // ---------------------------------------------------------------------------
106
- // Root workspace
107
- // ---------------------------------------------------------------------------
108
-
109
- // Per-PM script syntax: bun has its own --filter, pnpm uses --filter,
110
- // npm/yarn use --workspace. Picking the right shape at scaffold time
111
- // means `npm run dev` (or whichever PM) works without the user
112
- // learning each PM's flag dialect.
113
- const wsScripts = pmScripts(flags.pm);
114
-
115
- writeJson("package.json", {
116
- name: projectName,
117
- private: true,
118
- type: "module",
119
- workspaces: ["apps/*", "packages/*"],
120
- scripts: {
121
- dev: "npm-run-all --parallel dev:api dev:web",
122
- "dev:api": wsScripts.devApi,
123
- "dev:web": wsScripts.devWeb,
124
- build: wsScripts.build,
125
- },
126
- devDependencies: {
127
- "npm-run-all": "^4.1.5",
128
- },
129
- });
130
-
131
- write(".gitignore", `node_modules/
132
- .next/
133
- .turbo/
134
- dist/
135
- out/
136
- .env
137
- .env.local
138
- *.db
139
- *.db-journal
140
- apps/api/pylon.manifest.json
141
- apps/api/pylon.client.ts
142
- `);
143
-
144
- write(".env.example", `# Backend port the Pylon control plane listens on.
145
- PYLON_PORT=4321
146
-
147
- # Where the Next.js dev server can reach the control plane.
148
- PYLON_TARGET=http://localhost:4321
149
-
150
- # Cookie name the auth helpers look for.
151
- # Pattern: \`\${app_name}_session\` from the Pylon manifest.
152
- PYLON_COOKIE_NAME=${projectName}_session
153
- `);
154
-
155
- write(
156
- "README.md",
157
- `# ${projectName}
158
-
159
- Realtime backend + Next.js dashboard, scaffolded by [@pylonsync/create-pylon](https://npmjs.com/@pylonsync/create-pylon).
160
-
161
- ## Layout
162
-
163
- \`\`\`
164
- apps/
165
- api/ Pylon backend — schema, policies, function handlers
166
- schema.ts
167
- functions/
168
- listTodos.ts live query handler
169
- addTodo.ts mutation handler
170
-
171
- web/ Next.js 16 + React 19 + Tailwind v4 frontend
172
- src/
173
- app/
174
- layout.tsx
175
- page.tsx server component → fetches initial todos
176
- components/
177
- TodoList.tsx client component → optimistic add
178
- lib/
179
- pylon.ts cookie-attached fetch helper
180
-
181
- packages/
182
- ui/ Shared shadcn-style primitives (Button, Input, etc.)
183
- src/
184
- button.tsx, input.tsx, card.tsx, ...
185
- \`\`\`
186
-
187
- ## Getting started
188
-
189
- \`\`\`sh
190
- ${flags.pm === "npm" ? "npm install" : `${flags.pm} install`}
191
- ${flags.pm === "npm" ? "npm run dev" : `${flags.pm} run dev`}
192
- \`\`\`
193
-
194
- That spins up two processes:
195
-
196
- - **api** on http://localhost:4321 — Pylon control plane
197
- - **web** on http://localhost:3000 — Next.js frontend wired via
198
- [\`@pylonsync/next\`](https://npmjs.com/@pylonsync/next)
199
-
200
- ## What to do next
201
-
202
- - Edit \`apps/api/schema.ts\` to add entities + policies.
203
- - Add handlers under \`apps/api/functions/\` — auto-discovered by name.
204
- - Drop new UI primitives into \`packages/ui/src/\`; import them from
205
- any app via \`import { Button } from "@${projectName}/ui";\`.
206
-
207
- ## Docs
208
-
209
- [pylonsync.com/docs](https://pylonsync.com/docs)
210
- `,
188
+ console.log(
189
+ `\nCreating ${projectName} (${flags.template}, ${platforms.join(" + ")}) in ${root}\n`,
211
190
  );
212
191
 
213
192
  // ---------------------------------------------------------------------------
214
- // apps/apiPylon backend
215
- // ---------------------------------------------------------------------------
216
-
217
- writeJson("apps/api/package.json", {
218
- name: `@${projectName}/api`,
219
- version: "0.0.1",
220
- private: true,
221
- type: "module",
222
- scripts: {
223
- dev: "pylon dev schema.ts --port 4321",
224
- build: "pylon codegen schema.ts --out pylon.manifest.json && pylon codegen client pylon.manifest.json --out pylon.client.ts",
225
- "schema:push": "pylon schema push pylon.manifest.json --sqlite dev.db",
226
- "schema:inspect": "pylon schema inspect --sqlite dev.db",
227
- },
228
- dependencies: {
229
- "@pylonsync/sdk": `^${PYLON_VERSION}`,
230
- "@pylonsync/functions": `^${PYLON_VERSION}`,
231
- },
232
- devDependencies: {
233
- "@pylonsync/cli": `^${PYLON_VERSION}`,
234
- typescript: "^5.5.0",
235
- },
236
- });
237
-
238
- writeJson("apps/api/tsconfig.json", {
239
- compilerOptions: {
240
- target: "ES2022",
241
- module: "ESNext",
242
- moduleResolution: "Bundler",
243
- strict: true,
244
- skipLibCheck: true,
245
- noEmit: true,
246
- esModuleInterop: true,
247
- allowSyntheticDefaultImports: true,
248
- },
249
- include: ["schema.ts", "functions/**/*.ts"],
250
- });
251
-
252
- // Schema declares NAMES only — the SDK's query/action/mutation are
253
- // pure manifest declarations. Handler code lives under functions/*.
254
- write(
255
- "apps/api/schema.ts",
256
- `import {
257
- \tentity,
258
- \tfield,
259
- \tquery,
260
- \taction,
261
- \tpolicy,
262
- \tbuildManifest,
263
- } from "@pylonsync/sdk";
264
-
265
- // ---------------------------------------------------------------------------
266
- // Schema
193
+ // Substitution table used by every template file copy. Names that
194
+ // only make sense for some platforms (e.g. PASCAL for Swift) are still
195
+ // computed unconditionally; the unused replacements are no-ops.
267
196
  // ---------------------------------------------------------------------------
268
197
 
269
- const Todo = entity("Todo", {
270
- \ttitle: field.string(),
271
- \tdone: field.bool(),
272
- \tcreatedAt: field.datetime(),
273
- \t// Float position so drag-reorder can insert between two existing
274
- \t// rows without renumbering the whole list. Frontend computes
275
- \t// (prev.position + next.position) / 2 on drop. Optional for
276
- \t// backwards compat with legacy rows.
277
- \tposition: field.float().optional(),
278
- });
279
-
280
- // ---------------------------------------------------------------------------
281
- // Function declarations — names only. Implementations live under
282
- // functions/<name>.ts and are auto-discovered by the runtime.
283
- // ---------------------------------------------------------------------------
284
-
285
- const listTodos = query("listTodos");
286
-
287
- const addTodo = action("addTodo", {
288
- \tinput: [{ name: "title", type: "string" }],
289
- });
290
-
291
- const toggleTodo = action("toggleTodo", {
292
- \tinput: [{ name: "id", type: "id(Todo)" }, { name: "done", type: "bool" }],
293
- });
294
-
295
- const deleteTodo = action("deleteTodo", {
296
- \tinput: [{ name: "id", type: "id(Todo)" }],
297
- });
298
-
299
- const editTodo = action("editTodo", {
300
- \tinput: [
301
- \t\t{ name: "id", type: "id(Todo)" },
302
- \t\t{ name: "title", type: "string" },
303
- \t],
304
- });
305
-
306
- const reorderTodo = action("reorderTodo", {
307
- \tinput: [
308
- \t\t{ name: "id", type: "id(Todo)" },
309
- \t\t{ name: "position", type: "float" },
310
- \t],
311
- });
312
-
313
- // ---------------------------------------------------------------------------
314
- // Policies — wide-open by default. Tighten for production.
315
- // ---------------------------------------------------------------------------
316
-
317
- const todoPolicy = policy({
318
- \tname: "todo_open",
319
- \tentity: "Todo",
320
- \tallowRead: "true",
321
- \tallowInsert: "true",
322
- \tallowUpdate: "true",
323
- \tallowDelete: "true",
324
- });
325
-
326
- // ---------------------------------------------------------------------------
327
- // Manifest — pylon codegen reads this and emits pylon.manifest.json
328
- // ---------------------------------------------------------------------------
329
-
330
- // pylon dev / pylon codegen run \`bun run schema.ts\` and read the
331
- // manifest off stdout. The framework expects JSON, not the JS object —
332
- // every Pylon entry file ends with this console.log line.
333
- const manifest = buildManifest({
334
- \tname: "${projectName}",
335
- \tversion: "0.0.1",
336
- \tentities: [Todo],
337
- \tqueries: [listTodos],
338
- \tactions: [addTodo, toggleTodo, deleteTodo, editTodo, reorderTodo],
339
- \tpolicies: [todoPolicy],
340
- \troutes: [],
341
- });
342
-
343
- console.log(JSON.stringify(manifest));
344
- `,
345
- );
346
-
347
- write(
348
- "apps/api/functions/listTodos.ts",
349
- `import { query } from "@pylonsync/functions";
350
-
351
- /**
352
- * Live query — every Todo, in user-controlled drag-reorder position.
353
- * Rows without a \`position\` (legacy data) get sorted by createdAt as
354
- * a fallback so the list stays deterministic.
355
- */
356
- export default query({
357
- \targs: {},
358
- \tasync handler(ctx) {
359
- \t\tconst rows = await ctx.db.query("Todo", {});
360
- \t\treturn [...rows].sort((a: any, b: any) => {
361
- \t\t\tconst ap =
362
- \t\t\t\ttypeof a.position === "number"
363
- \t\t\t\t\t? a.position
364
- \t\t\t\t\t: Date.parse(a.createdAt) || 0;
365
- \t\t\tconst bp =
366
- \t\t\t\ttypeof b.position === "number"
367
- \t\t\t\t\t? b.position
368
- \t\t\t\t\t: Date.parse(b.createdAt) || 0;
369
- \t\t\treturn ap - bp;
370
- \t\t});
371
- \t},
372
- });
373
- `,
374
- );
375
-
376
- write(
377
- \t"apps/api/functions/editTodo.ts",
378
- \t\`import { mutation, v } from "@pylonsync/functions";
379
-
380
- /**
381
- * Rename a Todo. Trims whitespace; rejects empty titles.
382
- */
383
- export default mutation({
384
- \\targs: { id: v.id("Todo"), title: v.string() },
385
- \\tasync handler(ctx, args: { id: string; title: string }) {
386
- \\t\\tconst trimmed = args.title.trim();
387
- \\t\\tif (!trimmed) {
388
- \\t\\t\\tthrow ctx.error("EMPTY_TITLE", "title cannot be empty");
389
- \\t\\t}
390
- \\t\\tawait ctx.db.update("Todo", args.id, { title: trimmed });
391
- \\t\\treturn await ctx.db.get("Todo", args.id);
392
- \\t},
393
- });
394
- \`,
395
- );
396
-
397
- write(
398
- \t"apps/api/functions/reorderTodo.ts",
399
- \t\`import { mutation, v } from "@pylonsync/functions";
400
-
401
- /**
402
- * Drag-reorder. Frontend computes \\\`position\\\` as the midpoint of the
403
- * drop target's neighbors; we just write it. Floats give us ~52 inserts
404
- * between any two rows before precision matters.
405
- */
406
- export default mutation({
407
- \\targs: { id: v.id("Todo"), position: v.number() },
408
- \\tasync handler(ctx, args: { id: string; position: number }) {
409
- \\t\\tawait ctx.db.update("Todo", args.id, { position: args.position });
410
- \\t\\treturn await ctx.db.get("Todo", args.id);
411
- \\t},
412
- });
413
- \`,
414
- );
415
-
416
- write(
417
- "apps/api/functions/toggleTodo.ts",
418
- `import { mutation, v } from "@pylonsync/functions";
419
-
420
- /**
421
- * Flip the \`done\` flag on a Todo. Mutation, not action — needs
422
- * \`ctx.db.update\` which is only on writable ctx variants.
423
- */
424
- export default mutation({
425
- \targs: { id: v.id("Todo"), done: v.bool() },
426
- \tasync handler(ctx, args: { id: string; done: boolean }) {
427
- \t\tawait ctx.db.update("Todo", args.id, { done: args.done });
428
- \t\treturn await ctx.db.get("Todo", args.id);
429
- \t},
430
- });
431
- `,
432
- );
198
+ const APP_NAME = projectName;
199
+ const APP_NAME_KEBAB = APP_NAME.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
200
+ const APP_NAME_SNAKE = APP_NAME.replace(/[^a-z0-9]+/gi, "_").toLowerCase();
201
+ const APP_NAME_PASCAL = APP_NAME.replace(/(^|[^a-z0-9])([a-z0-9])/gi, (_, _s, c) =>
202
+ c.toUpperCase(),
203
+ ).replace(/[^A-Za-z0-9]/g, "");
204
+
205
+ const SUBS = {
206
+ __APP_NAME__: APP_NAME,
207
+ __APP_NAME_KEBAB__: APP_NAME_KEBAB,
208
+ __APP_NAME_SNAKE__: APP_NAME_SNAKE,
209
+ __APP_NAME_PASCAL__: APP_NAME_PASCAL,
210
+ __PYLON_VERSION__: PYLON_VERSION,
211
+ __WORKSPACE_DEP__: workspaceDepSpec,
212
+ };
433
213
 
434
- write(
435
- "apps/api/functions/deleteTodo.ts",
436
- `import { mutation, v } from "@pylonsync/functions";
214
+ // Filenames that contain placeholders get renamed AFTER copy. Keeps
215
+ // the loader simple — copy the directory tree raw, then rename any
216
+ // file/dir whose name has a placeholder.
217
+ function substituteString(s) {
218
+ let out = s;
219
+ for (const [k, v] of Object.entries(SUBS)) {
220
+ out = out.split(k).join(v);
221
+ }
222
+ return out;
223
+ }
437
224
 
438
- /**
439
- * Remove a Todo row. Returns the row as it existed pre-delete so
440
- * the client can show a "todo removed" toast or animate it out.
441
- */
442
- export default mutation({
443
- \targs: { id: v.id("Todo") },
444
- \tasync handler(ctx, args: { id: string }) {
445
- \t\tconst snapshot = await ctx.db.get("Todo", args.id);
446
- \t\tawait ctx.db.delete("Todo", args.id);
447
- \t\treturn snapshot;
448
- \t},
449
- });
450
- `,
451
- );
225
+ function substituteFile(absPath) {
226
+ // Skip binary-ish files we only ever ship text in templates,
227
+ // but be safe in case someone drops a PNG icon in later.
228
+ const buf = readFileSync(absPath);
229
+ for (let i = 0; i < Math.min(buf.length, 8000); i++) {
230
+ if (buf[i] === 0) return;
231
+ }
232
+ const before = buf.toString("utf8");
233
+ const after = substituteString(before);
234
+ if (before !== after) writeFileSync(absPath, after);
235
+ }
452
236
 
453
- write(
454
- "apps/api/functions/addTodo.ts",
455
- `import { mutation, v } from "@pylonsync/functions";
237
+ function walkAndSubstitute(dir) {
238
+ for (const entry of readdirSync(dir)) {
239
+ const abs = join(dir, entry);
240
+ const renamed = substituteString(entry);
241
+ let target = abs;
242
+ if (renamed !== entry) {
243
+ target = join(dir, renamed);
244
+ renameSync(abs, target);
245
+ }
246
+ const st = statSync(target);
247
+ if (st.isDirectory()) walkAndSubstitute(target);
248
+ else if (st.isFile()) substituteFile(target);
249
+ }
250
+ }
456
251
 
457
- /**
458
- * Insert a new Todo. Seeds \`position\` to (max + 1024) so new rows
459
- * land at the end of the drag-reorder list; the 1024 step leaves
460
- * room for inserts-between without needing global renumber.
461
- */
462
- export default mutation({
463
- \targs: { title: v.string() },
464
- \tasync handler(ctx, args: { title: string }) {
465
- \t\tconst existing = await ctx.db.query("Todo", {});
466
- \t\tconst maxPos = existing.reduce((acc: number, row: any) => {
467
- \t\t\tconst p =
468
- \t\t\t\ttypeof row.position === "number"
469
- \t\t\t\t\t? row.position
470
- \t\t\t\t\t: Date.parse(row.createdAt) || 0;
471
- \t\t\treturn p > acc ? p : acc;
472
- \t\t}, 0);
473
- \t\tconst id = await ctx.db.insert("Todo", {
474
- \t\t\ttitle: args.title,
475
- \t\t\tdone: false,
476
- \t\t\tcreatedAt: new Date().toISOString(),
477
- \t\t\tposition: maxPos + 1024,
478
- \t\t});
479
- \t\treturn await ctx.db.get("Todo", id);
480
- \t},
481
- });
482
- `,
483
- );
252
+ function copyTemplate(srcSubpath, destSubpath = "") {
253
+ const src = join(TEMPLATES, srcSubpath);
254
+ if (!existsSync(src)) return false;
255
+ const dest = destSubpath ? join(root, destSubpath) : root;
256
+ mkdirSync(dest, { recursive: true });
257
+ cpSync(src, dest, { recursive: true });
258
+ return true;
259
+ }
484
260
 
485
261
  // ---------------------------------------------------------------------------
486
- // packages/ui shared shadcn-style primitives
262
+ // Apply templates in order:
263
+ // 1. _root/_shared — gitignore, env.example, basic README
264
+ // 2. backend/<template> — apps/api/* always present
265
+ // 3. ui — packages/ui (only if web is in platforms)
266
+ // 4. web/<template> — apps/web/* (only if web in platforms)
267
+ // 5. mobile/<template> — apps/mobile/* (only if mobile in platforms)
268
+ // 6. expo/<template> — apps/expo/* (only if expo in platforms)
269
+ // 7. Root package.json — generated, not templated; depends on platforms
487
270
  // ---------------------------------------------------------------------------
488
271
 
489
- writeJson("packages/ui/package.json", {
490
- name: `@${projectName}/ui`,
491
- version: "0.0.1",
492
- private: true,
493
- type: "module",
494
- main: "src/index.ts",
495
- types: "src/index.ts",
496
- exports: {
497
- ".": "./src/index.ts",
498
- "./button": "./src/button.tsx",
499
- "./input": "./src/input.tsx",
500
- "./card": "./src/card.tsx",
501
- "./cn": "./src/cn.ts",
502
- },
503
- dependencies: {
504
- clsx: "^2.1.0",
505
- "tailwind-merge": "^2.5.0",
506
- },
507
- peerDependencies: {
508
- react: "^19.0.0",
509
- },
510
- devDependencies: {
511
- "@types/react": "^19.0.0",
512
- typescript: "^5.5.0",
513
- },
514
- });
515
-
516
- writeJson("packages/ui/tsconfig.json", {
517
- compilerOptions: {
518
- target: "ES2022",
519
- lib: ["dom", "esnext"],
520
- jsx: "preserve",
521
- module: "ESNext",
522
- moduleResolution: "Bundler",
523
- strict: true,
524
- skipLibCheck: true,
525
- noEmit: true,
526
- esModuleInterop: true,
527
- allowSyntheticDefaultImports: true,
528
- },
529
- include: ["src/**/*.ts", "src/**/*.tsx"],
530
- });
272
+ copyTemplate("_root");
273
+ copyTemplate(`backend/${flags.template}`);
531
274
 
532
- write(
533
- "packages/ui/src/cn.ts",
534
- `import { clsx, type ClassValue } from "clsx";
535
- import { twMerge } from "tailwind-merge";
536
-
537
- /**
538
- * Tailwind-aware class merger. Last-class-wins semantics so a
539
- * caller's \`className\` reliably overrides a default in a UI
540
- * primitive (e.g. <Button className="bg-red-500"> beats the
541
- * primitive's bg-neutral-900 base).
542
- */
543
- export function cn(...inputs: ClassValue[]): string {
544
- \treturn twMerge(clsx(inputs));
275
+ if (platforms.includes("web")) {
276
+ copyTemplate("ui");
277
+ copyTemplate(`web/${flags.template}`);
545
278
  }
546
- `,
547
- );
548
-
549
- write(
550
- "packages/ui/src/button.tsx",
551
- `import * as React from "react";
552
- import { cn } from "./cn";
553
-
554
- type Variant = "default" | "primary" | "ghost";
555
- type Size = "sm" | "md";
556
-
557
- const variants: Record<Variant, string> = {
558
- \tdefault:
559
- \t\t"bg-neutral-100 hover:bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:text-neutral-100",
560
- \tprimary:
561
- \t\t"bg-neutral-900 hover:bg-neutral-800 text-white dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900",
562
- \tghost:
563
- \t\t"bg-transparent hover:bg-neutral-100 text-neutral-700 dark:hover:bg-neutral-800 dark:text-neutral-300",
564
- };
565
-
566
- const sizes: Record<Size, string> = {
567
- \tsm: "h-8 px-3 text-[13px]",
568
- \tmd: "h-9 px-4 text-sm",
569
- };
570
-
571
- export interface ButtonProps
572
- \textends React.ButtonHTMLAttributes<HTMLButtonElement> {
573
- \tvariant?: Variant;
574
- \tsize?: Size;
279
+ if (platforms.includes("mobile")) {
280
+ copyTemplate(`mobile/${flags.template}`);
575
281
  }
576
-
577
- export function Button({
578
- \tclassName,
579
- \tvariant = "default",
580
- \tsize = "md",
581
- \t...props
582
- }: ButtonProps) {
583
- \treturn (
584
- \t\t<button
585
- \t\t\tclassName={cn(
586
- \t\t\t\t"inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
587
- \t\t\t\tvariants[variant],
588
- \t\t\t\tsizes[size],
589
- \t\t\t\tclassName,
590
- \t\t\t)}
591
- \t\t\t{...props}
592
- \t\t/>
593
- \t);
282
+ if (platforms.includes("expo")) {
283
+ copyTemplate(`expo/${flags.template}`);
594
284
  }
595
- `,
596
- );
597
-
598
- write(
599
- "packages/ui/src/input.tsx",
600
- `import * as React from "react";
601
- import { cn } from "./cn";
602
285
 
603
- export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
286
+ walkAndSubstitute(root);
604
287
 
605
- export const Input = React.forwardRef<HTMLInputElement, InputProps>(
606
- \tfunction Input({ className, ...props }, ref) {
607
- \t\treturn (
608
- \t\t\t<input
609
- \t\t\t\tref={ref}
610
- \t\t\t\tclassName={cn(
611
- \t\t\t\t\t"flex h-9 w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm placeholder:text-neutral-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-50",
612
- \t\t\t\t\tclassName,
613
- \t\t\t\t)}
614
- \t\t\t\t{...props}
615
- \t\t\t/>
616
- \t\t);
617
- \t},
618
- );
619
- `,
620
- );
621
-
622
- write(
623
- "packages/ui/src/card.tsx",
624
- `import * as React from "react";
625
- import { cn } from "./cn";
288
+ // ---------------------------------------------------------------------------
289
+ // Root package.json generated based on selected platforms. Workspace
290
+ // scripts depend on which apps exist + which package manager the user
291
+ // picked (each PM exposes "run X in workspace Y" differently).
292
+ // ---------------------------------------------------------------------------
626
293
 
627
- export function Card({
628
- \tclassName,
629
- \t...props
630
- }: React.HTMLAttributes<HTMLDivElement>) {
631
- \treturn (
632
- \t\t<div
633
- \t\t\tclassName={cn(
634
- \t\t\t\t"rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900",
635
- \t\t\t\tclassName,
636
- \t\t\t)}
637
- \t\t\t{...props}
638
- \t\t/>
639
- \t);
294
+ const wsScripts = pmScripts(flags.pm);
295
+ const devScripts = {};
296
+ const buildScripts = {};
297
+ if (platforms.includes("web")) {
298
+ devScripts["dev:api"] = wsScripts.devApi;
299
+ devScripts["dev:web"] = wsScripts.devWeb;
640
300
  }
641
-
642
- export function CardHeader({
643
- \tclassName,
644
- \t...props
645
- }: React.HTMLAttributes<HTMLDivElement>) {
646
- \treturn (
647
- \t\t<div className={cn("p-5 border-b border-neutral-200 dark:border-neutral-800", className)} {...props} />
648
- \t);
301
+ if (!platforms.includes("web")) {
302
+ // API still runs even without a web platform — mobile / expo connect
303
+ // to it directly.
304
+ devScripts["dev:api"] = wsScripts.devApi;
649
305
  }
650
-
651
- export function CardContent({
652
- \tclassName,
653
- \t...props
654
- }: React.HTMLAttributes<HTMLDivElement>) {
655
- \treturn <div className={cn("p-5", className)} {...props} />;
306
+ if (platforms.includes("expo")) {
307
+ devScripts["dev:expo"] = wsScripts.devExpo;
308
+ }
309
+ if (platforms.includes("mobile")) {
310
+ // Swift/iOS isn't a `bun run dev` thing — surfaced as a separate
311
+ // script invocation since `swift run` blocks and Xcode is out-of-band.
312
+ devScripts["dev:mobile"] = "echo 'Open apps/mobile in Xcode (or run: cd apps/mobile && swift run)'";
656
313
  }
657
- `,
658
- );
659
-
660
- write(
661
- "packages/ui/src/index.ts",
662
- `export { cn } from "./cn";
663
- export { Button, type ButtonProps } from "./button";
664
- export { Input, type InputProps } from "./input";
665
- export { Card, CardHeader, CardContent } from "./card";
666
- `,
667
- );
668
-
669
- // ---------------------------------------------------------------------------
670
- // apps/web — Next.js 16 + React 19 + Tailwind v4
671
- // ---------------------------------------------------------------------------
672
314
 
673
- writeJson("apps/web/package.json", {
674
- name: `@${projectName}/web`,
675
- version: "0.0.1",
315
+ const parallelDevs = Object.keys(devScripts);
316
+ const rootPkg = {
317
+ name: APP_NAME_KEBAB,
676
318
  private: true,
677
319
  type: "module",
320
+ workspaces: ["apps/*", "packages/*"].filter((p) => {
321
+ // Only declare packages/* as a workspace if we actually scaffolded
322
+ // packages/ui — otherwise the empty match warns on bun install.
323
+ if (p === "packages/*") return platforms.includes("web");
324
+ return true;
325
+ }),
678
326
  scripts: {
679
- dev: "next dev --port 3000",
680
- build: "next build",
681
- start: "next start",
682
- lint: "next lint",
683
- },
684
- dependencies: {
685
- [`@${projectName}/ui`]: workspaceDepSpec,
686
- "@pylonsync/sdk": `^${PYLON_VERSION}`,
687
- "@pylonsync/react": `^${PYLON_VERSION}`,
688
- "@pylonsync/next": `^${PYLON_VERSION}`,
689
- // Drag-reorder for the scaffolded TodoList demo.
690
- "@dnd-kit/core": "^6.3.1",
691
- "@dnd-kit/sortable": "^10.0.0",
692
- "@dnd-kit/utilities": "^3.2.2",
693
- next: "^16.0.0",
694
- react: "^19.0.0",
695
- "react-dom": "^19.0.0",
696
- },
697
- devDependencies: {
698
- "@types/node": "^20.0.0",
699
- "@types/react": "^19.0.0",
700
- "@types/react-dom": "^19.0.0",
701
- "@tailwindcss/postcss": "^4.0.0",
702
- tailwindcss: "^4.0.0",
703
- typescript: "^5.5.0",
704
- },
705
- });
706
-
707
- writeJson("apps/web/tsconfig.json", {
708
- compilerOptions: {
709
- target: "ES2022",
710
- lib: ["dom", "dom.iterable", "esnext"],
711
- allowJs: true,
712
- skipLibCheck: true,
713
- strict: true,
714
- noEmit: true,
715
- esModuleInterop: true,
716
- module: "esnext",
717
- moduleResolution: "bundler",
718
- resolveJsonModule: true,
719
- isolatedModules: true,
720
- jsx: "preserve",
721
- incremental: true,
722
- plugins: [{ name: "next" }],
723
- paths: { "@/*": ["./src/*"] },
327
+ dev:
328
+ parallelDevs.length > 1
329
+ ? `npm-run-all --parallel ${parallelDevs.join(" ")}`
330
+ : wsScripts.devApi,
331
+ ...devScripts,
332
+ build: wsScripts.build,
724
333
  },
725
- include: ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"],
726
- exclude: ["node_modules"],
727
- });
728
-
729
- write(
730
- "apps/web/next.config.ts",
731
- `import type { NextConfig } from "next";
732
-
733
- /**
734
- * Pylon's typed client + functions packages re-export across the
735
- * server/client boundary AND the workspace UI package ships TSX.
736
- * \`transpilePackages\` makes Next bundle them cleanly.
737
- *
738
- * \`rewrites\` proxies every Pylon-owned path (\`/api/fn/*\`,
739
- * \`/api/auth/*\`, \`/api/sync/*\`, …) to the Pylon binary running
740
- * on \`PYLON_API_URL\` (default http://localhost:4321). Without this,
741
- * Next.js sees \`/api/fn/addTodo\` as a missing route and 404s before
742
- * the request ever reaches Pylon.
743
- *
744
- * In production set \`PYLON_API_URL\` to wherever you've deployed the
745
- * Pylon binary (Fly, Render, Railway, your own box). The browser
746
- * still hits same-origin paths under your Next deployment, and Next
747
- * forwards them server-side — no CORS, no extra DNS.
748
- */
749
- const PYLON_API_URL = process.env.PYLON_API_URL ?? "http://localhost:4321";
750
-
751
- const config: NextConfig = {
752
- \ttranspilePackages: [
753
- \t\t"@${projectName}/ui",
754
- \t\t"@pylonsync/sdk",
755
- \t\t"@pylonsync/react",
756
- \t\t"@pylonsync/next",
757
- \t\t"@pylonsync/functions",
758
- \t\t"@pylonsync/sync",
759
- \t],
760
- \tasync rewrites() {
761
- \t\treturn [
762
- \t\t\t{ source: "/api/fn/:path*", destination: \`\${PYLON_API_URL}/api/fn/:path*\` },
763
- \t\t\t{ source: "/api/auth/:path*", destination: \`\${PYLON_API_URL}/api/auth/:path*\` },
764
- \t\t\t{ source: "/api/sync/:path*", destination: \`\${PYLON_API_URL}/api/sync/:path*\` },
765
- \t\t\t{ source: "/api/:path*", destination: \`\${PYLON_API_URL}/api/:path*\` },
766
- \t\t];
767
- \t},
334
+ devDependencies: parallelDevs.length > 1 ? { "npm-run-all": "^4.1.5" } : {},
768
335
  };
769
-
770
- export default config;
771
- `,
772
- );
773
-
774
- write(
775
- "apps/web/postcss.config.mjs",
776
- `/** Tailwind v4 PostCSS pipeline. */
777
- export default {
778
- \tplugins: { "@tailwindcss/postcss": {} },
779
- };
780
- `,
781
- );
782
-
783
- write(
784
- "apps/web/src/app/globals.css",
785
- `@import "tailwindcss";
786
- @source "../../../../packages/ui/src/**/*.{ts,tsx}";
787
-
788
- :root {
789
- \tcolor-scheme: light dark;
790
- }
791
-
792
- html, body { height: 100%; }
793
- body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
794
- `,
336
+ writeFileSync(
337
+ join(root, "package.json"),
338
+ JSON.stringify(rootPkg, null, 2) + "\n",
795
339
  );
796
340
 
797
- write(
798
- "apps/web/src/app/layout.tsx",
799
- `import type { Metadata } from "next";
800
- import "./globals.css";
801
-
802
- export const metadata: Metadata = {
803
- \ttitle: "${projectName}",
804
- \tdescription: "Realtime app powered by Pylon",
805
- };
806
-
807
- export default function RootLayout({
808
- \tchildren,
809
- }: {
810
- \tchildren: React.ReactNode;
811
- }) {
812
- \treturn (
813
- \t\t<html lang="en">
814
- \t\t\t<body className="antialiased min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
815
- \t\t\t\t{children}
816
- \t\t\t</body>
817
- \t\t</html>
818
- \t);
819
- }
820
- `,
821
- );
822
-
823
- write(
824
- "apps/web/src/lib/pylon.ts",
825
- `import { createPylonServer } from "@pylonsync/next/server";
826
-
827
- /**
828
- * Single server-helper instance. Imported by every Server Component
829
- * and Server Action that needs to talk to the Pylon control plane.
830
- *
831
- * \`cookieName\` MUST match the backend's emitted cookie. Pylon uses
832
- * \`\${app_name}_session\` from the manifest — for this app that's
833
- * \`${projectName}_session\`. Pin it in code (NOT env) so a bad
834
- * deployment env can't silently break auth.
835
- */
836
- export const pylon = createPylonServer({
837
- \tcookieName: "${projectName}_session",
838
- });
839
- `,
840
- );
841
-
842
- write(
843
- "apps/web/src/app/page.tsx",
844
- `import { pylon } from "@/lib/pylon";
845
- import { TodoList } from "./components/TodoList";
846
-
847
- // Force dynamic — every render reads the live todo list from Pylon.
848
- // Without this Next would try to statically generate the page and
849
- // the cookie-attached fetch in pylon.json would error at build time.
850
- export const dynamic = "force-dynamic";
851
-
852
- type Todo = {
853
- \tid: string;
854
- \ttitle: string;
855
- \tdone: boolean;
856
- \tcreatedAt: string;
857
- };
858
-
859
- export default async function HomePage() {
860
- \tconst todos = await pylon
861
- \t\t.json<Todo[]>("/api/fn/listTodos", {
862
- \t\t\tmethod: "POST",
863
- \t\t\tbody: "{}",
864
- \t\t\theaders: { "Content-Type": "application/json" },
865
- \t\t})
866
- \t\t.catch(() => [] as Todo[]);
867
-
868
- \treturn (
869
- \t\t<main className="mx-auto max-w-2xl px-6 py-12 space-y-8">
870
- \t\t\t<header className="space-y-2">
871
- \t\t\t\t<h1 className="text-3xl font-semibold tracking-tight">${projectName}</h1>
872
- \t\t\t\t<p className="text-sm text-neutral-500 dark:text-neutral-400">
873
- \t\t\t\t\tA Pylon-powered realtime app. Edit{" "}
874
- \t\t\t\t\t<code className="font-mono text-xs">apps/api/schema.ts</code> to change
875
- \t\t\t\t\tthe data model,{" "}
876
- \t\t\t\t\t<code className="font-mono text-xs">apps/api/functions/</code> to add
877
- \t\t\t\t\thandlers, or{" "}
878
- \t\t\t\t\t<code className="font-mono text-xs">
879
- \t\t\t\t\t\tapps/web/src/app/components/TodoList.tsx
880
- \t\t\t\t\t</code>{" "}
881
- \t\t\t\t\tfor the UI.
882
- \t\t\t\t</p>
883
- \t\t\t</header>
341
+ // ---------------------------------------------------------------------------
342
+ // Optional: install dependencies
343
+ // ---------------------------------------------------------------------------
884
344
 
885
- \t\t\t<TodoList initialTodos={todos} />
886
- \t\t</main>
887
- \t);
345
+ if (!flags.skipInstall) {
346
+ console.log(`Installing dependencies with ${flags.pm}...`);
347
+ const { spawnSync } = await import("node:child_process");
348
+ const result = spawnSync(flags.pm, ["install"], {
349
+ cwd: root,
350
+ stdio: "inherit",
351
+ });
352
+ if (result.status !== 0) {
353
+ console.warn(
354
+ `\n${flags.pm} install exited with code ${result.status}. Re-run from ${projectName}/.\n`,
355
+ );
356
+ }
888
357
  }
889
- `,
890
- );
891
358
 
892
- write(
893
- "apps/web/src/app/components/TodoList.tsx",
894
- `"use client";
895
-
896
- import { useState, useTransition, useRef, useEffect } from "react";
897
- import { Button } from "@${projectName}/ui";
898
- import { Input } from "@${projectName}/ui";
899
- import {
900
- \tDndContext,
901
- \tclosestCenter,
902
- \tKeyboardSensor,
903
- \tPointerSensor,
904
- \tuseSensor,
905
- \tuseSensors,
906
- \ttype DragEndEvent,
907
- } from "@dnd-kit/core";
908
- import {
909
- \tarrayMove,
910
- \tSortableContext,
911
- \tsortableKeyboardCoordinates,
912
- \tuseSortable,
913
- \tverticalListSortingStrategy,
914
- } from "@dnd-kit/sortable";
915
- import { CSS } from "@dnd-kit/utilities";
916
-
917
- type Todo = {
918
- \tid: string;
919
- \ttitle: string;
920
- \tdone: boolean;
921
- \tcreatedAt: string;
922
- \tposition?: number;
923
- };
924
-
925
- /**
926
- * Optimistic todo list with drag-reorder, inline title edit, toggle,
927
- * and delete. All mutations are optimistic with revert-on-failure.
928
- * Drag uses @dnd-kit; on drop we compute the new row's position as
929
- * the midpoint between its new neighbors and POST it to reorderTodo.
930
- */
931
- export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
932
- \tconst [todos, setTodos] = useState(initialTodos);
933
- \tconst [title, setTitle] = useState("");
934
- \tconst [pending, startTransition] = useTransition();
935
- \tconst sensors = useSensors(
936
- \t\tuseSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
937
- \t\tuseSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
938
- \t);
939
-
940
- \tasync function add() {
941
- \t\tif (!title.trim()) return;
942
- \t\tconst newTitle = title;
943
- \t\tsetTitle("");
944
- \t\tstartTransition(async () => {
945
- \t\t\tconst res = await fetch("/api/fn/addTodo", {
946
- \t\t\t\tmethod: "POST",
947
- \t\t\t\theaders: { "Content-Type": "application/json" },
948
- \t\t\t\tbody: JSON.stringify({ title: newTitle }),
949
- \t\t\t});
950
- \t\t\tif (res.ok) {
951
- \t\t\t\tconst todo = (await res.json()) as Todo;
952
- \t\t\t\tsetTodos((prev) => [...prev, todo]);
953
- \t\t\t}
954
- \t\t});
955
- \t}
956
-
957
- \tasync function toggle(t: Todo) {
958
- \t\tconst next = !t.done;
959
- \t\tsetTodos((prev) =>
960
- \t\t\tprev.map((row) => (row.id === t.id ? { ...row, done: next } : row)),
961
- \t\t);
962
- \t\tstartTransition(async () => {
963
- \t\t\tconst res = await fetch("/api/fn/toggleTodo", {
964
- \t\t\t\tmethod: "POST",
965
- \t\t\t\theaders: { "Content-Type": "application/json" },
966
- \t\t\t\tbody: JSON.stringify({ id: t.id, done: next }),
967
- \t\t\t});
968
- \t\t\tif (!res.ok) {
969
- \t\t\t\tsetTodos((prev) =>
970
- \t\t\t\t\tprev.map((row) => (row.id === t.id ? { ...row, done: t.done } : row)),
971
- \t\t\t\t);
972
- \t\t\t}
973
- \t\t});
974
- \t}
975
-
976
- \tasync function remove(t: Todo) {
977
- \t\tconst snapshot = todos;
978
- \t\tsetTodos((prev) => prev.filter((row) => row.id !== t.id));
979
- \t\tstartTransition(async () => {
980
- \t\t\tconst res = await fetch("/api/fn/deleteTodo", {
981
- \t\t\t\tmethod: "POST",
982
- \t\t\t\theaders: { "Content-Type": "application/json" },
983
- \t\t\t\tbody: JSON.stringify({ id: t.id }),
984
- \t\t\t});
985
- \t\t\tif (!res.ok) setTodos(snapshot);
986
- \t\t});
987
- \t}
988
-
989
- \tasync function rename(t: Todo, newTitle: string) {
990
- \t\tconst trimmed = newTitle.trim();
991
- \t\tif (!trimmed || trimmed === t.title) return;
992
- \t\tsetTodos((prev) =>
993
- \t\t\tprev.map((row) => (row.id === t.id ? { ...row, title: trimmed } : row)),
994
- \t\t);
995
- \t\tstartTransition(async () => {
996
- \t\t\tconst res = await fetch("/api/fn/editTodo", {
997
- \t\t\t\tmethod: "POST",
998
- \t\t\t\theaders: { "Content-Type": "application/json" },
999
- \t\t\t\tbody: JSON.stringify({ id: t.id, title: trimmed }),
1000
- \t\t\t});
1001
- \t\t\tif (!res.ok) {
1002
- \t\t\t\tsetTodos((prev) =>
1003
- \t\t\t\t\tprev.map((row) => (row.id === t.id ? { ...row, title: t.title } : row)),
1004
- \t\t\t\t);
1005
- \t\t\t}
1006
- \t\t});
1007
- \t}
1008
-
1009
- \tfunction onDragEnd(e: DragEndEvent) {
1010
- \t\tconst { active, over } = e;
1011
- \t\tif (!over || active.id === over.id) return;
1012
- \t\tconst oldIndex = todos.findIndex((t) => t.id === active.id);
1013
- \t\tconst newIndex = todos.findIndex((t) => t.id === over.id);
1014
- \t\tif (oldIndex < 0 || newIndex < 0) return;
1015
- \t\tconst reordered = arrayMove(todos, oldIndex, newIndex);
1016
- \t\tsetTodos(reordered);
1017
- \t\tconst prev = reordered[newIndex - 1];
1018
- \t\tconst next = reordered[newIndex + 1];
1019
- \t\tconst prevPos = prev?.position ?? Date.parse(prev?.createdAt ?? "") ?? 0;
1020
- \t\tconst nextPos = next?.position ?? Date.parse(next?.createdAt ?? "") ?? 0;
1021
- \t\tlet position: number;
1022
- \t\tif (prev && next) position = (prevPos + nextPos) / 2;
1023
- \t\telse if (prev) position = prevPos + 1024;
1024
- \t\telse if (next) position = nextPos - 1024;
1025
- \t\telse position = 1024;
1026
- \t\tconst movedId = String(active.id);
1027
- \t\tconst snapshot = todos;
1028
- \t\tstartTransition(async () => {
1029
- \t\t\tconst res = await fetch("/api/fn/reorderTodo", {
1030
- \t\t\t\tmethod: "POST",
1031
- \t\t\t\theaders: { "Content-Type": "application/json" },
1032
- \t\t\t\tbody: JSON.stringify({ id: movedId, position }),
1033
- \t\t\t});
1034
- \t\t\tif (!res.ok) setTodos(snapshot);
1035
- \t\t});
1036
- \t}
359
+ // ---------------------------------------------------------------------------
360
+ // Final instructions
361
+ // ---------------------------------------------------------------------------
1037
362
 
1038
- \treturn (
1039
- \t\t<div className="space-y-4">
1040
- \t\t\t<form
1041
- \t\t\t\tonSubmit={(e) => {
1042
- \t\t\t\t\te.preventDefault();
1043
- \t\t\t\t\tadd();
1044
- \t\t\t\t}}
1045
- \t\t\t\tclassName="flex gap-2"
1046
- \t\t\t>
1047
- \t\t\t\t<Input
1048
- \t\t\t\t\tvalue={title}
1049
- \t\t\t\t\tonChange={(e) => setTitle(e.target.value)}
1050
- \t\t\t\t\tplaceholder="What needs doing?"
1051
- \t\t\t\t\tdisabled={pending}
1052
- \t\t\t\t\tclassName="flex-1"
1053
- \t\t\t\t/>
1054
- \t\t\t\t<Button
1055
- \t\t\t\t\ttype="submit"
1056
- \t\t\t\t\tvariant="primary"
1057
- \t\t\t\t\tdisabled={pending || !title.trim()}
1058
- \t\t\t\t>
1059
- \t\t\t\t\tAdd
1060
- \t\t\t\t</Button>
1061
- \t\t\t</form>
363
+ const runDev = flags.pm === "npm" ? "npm run dev" : `${flags.pm} run dev`;
1062
364
 
1063
- \t\t\t{todos.length === 0 ? (
1064
- \t\t\t\t<p className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
1065
- \t\t\t\t\tNo todos yet. Add one above.
1066
- \t\t\t\t</p>
1067
- \t\t\t) : (
1068
- \t\t\t\t<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
1069
- \t\t\t\t\t<SortableContext items={todos.map((t) => t.id)} strategy={verticalListSortingStrategy}>
1070
- \t\t\t\t\t\t<ul className="divide-y divide-neutral-200 dark:divide-neutral-800 rounded-md border border-neutral-200 dark:border-neutral-800">
1071
- \t\t\t\t\t\t\t{todos.map((t) => (
1072
- \t\t\t\t\t\t\t\t<SortableRow
1073
- \t\t\t\t\t\t\t\t\tkey={t.id}
1074
- \t\t\t\t\t\t\t\t\ttodo={t}
1075
- \t\t\t\t\t\t\t\t\tpending={pending}
1076
- \t\t\t\t\t\t\t\t\tonToggle={() => toggle(t)}
1077
- \t\t\t\t\t\t\t\t\tonRemove={() => remove(t)}
1078
- \t\t\t\t\t\t\t\t\tonRename={(next) => rename(t, next)}
1079
- \t\t\t\t\t\t\t\t/>
1080
- \t\t\t\t\t\t\t))}
1081
- \t\t\t\t\t\t</ul>
1082
- \t\t\t\t\t</SortableContext>
1083
- \t\t\t\t</DndContext>
1084
- \t\t\t)}
1085
- \t\t</div>
1086
- \t);
1087
- }
365
+ const platformLines = [];
366
+ if (platforms.includes("web"))
367
+ platformLines.push(" → web http://localhost:3000 (Next.js)");
368
+ platformLines.push(" → api http://localhost:4321 (Pylon control plane)");
369
+ if (platforms.includes("expo"))
370
+ platformLines.push(` → expo ${flags.pm} run dev:expo (Metro + simulator)`);
371
+ if (platforms.includes("mobile"))
372
+ platformLines.push(` → mobile open apps/mobile in Xcode (or: swift run)`);
1088
373
 
1089
- function SortableRow({ todo, pending, onToggle, onRemove, onRename }: {
1090
- \ttodo: Todo;
1091
- \tpending: boolean;
1092
- \tonToggle: () => void;
1093
- \tonRemove: () => void;
1094
- \tonRename: (next: string) => void;
1095
- }) {
1096
- \tconst { attributes, listeners, setNodeRef, transform, transition, isDragging } =
1097
- \t\tuseSortable({ id: todo.id });
1098
- \tconst style = {
1099
- \t\ttransform: CSS.Transform.toString(transform),
1100
- \t\ttransition,
1101
- \t\topacity: isDragging ? 0.4 : 1,
1102
- \t};
1103
- \tconst [editing, setEditing] = useState(false);
1104
- \tconst [draft, setDraft] = useState(todo.title);
1105
- \tconst inputRef = useRef<HTMLInputElement>(null);
374
+ console.log(`
375
+ Created ${projectName}
1106
376
 
1107
- \tuseEffect(() => {
1108
- \t\tif (editing) {
1109
- \t\t\tsetDraft(todo.title);
1110
- \t\t\trequestAnimationFrame(() => {
1111
- \t\t\t\tinputRef.current?.focus();
1112
- \t\t\t\tinputRef.current?.select();
1113
- \t\t\t});
1114
- \t\t}
1115
- \t}, [editing, todo.title]);
377
+ cd ${projectName}
378
+ ${runDev}
1116
379
 
1117
- \tfunction commit() {
1118
- \t\tsetEditing(false);
1119
- \t\tonRename(draft);
1120
- \t}
380
+ ${platformLines.join("\n")}
1121
381
 
1122
- \treturn (
1123
- \t\t<li
1124
- \t\t\tref={setNodeRef}
1125
- \t\t\tstyle={style}
1126
- \t\t\tclassName="flex items-center gap-3 px-4 py-3 text-sm group bg-white dark:bg-neutral-950"
1127
- \t\t>
1128
- \t\t\t<button
1129
- \t\t\t\ttype="button"
1130
- \t\t\t\t{...attributes}
1131
- \t\t\t\t{...listeners}
1132
- \t\t\t\tclassName="cursor-grab active:cursor-grabbing text-neutral-300 hover:text-neutral-500 select-none touch-none"
1133
- \t\t\t\taria-label="Drag to reorder"
1134
- \t\t\t\ttabIndex={-1}
1135
- \t\t\t>
1136
- \t\t\t\t⋮⋮
1137
- \t\t\t</button>
1138
- \t\t\t<input
1139
- \t\t\t\ttype="checkbox"
1140
- \t\t\t\tchecked={todo.done}
1141
- \t\t\t\tonChange={onToggle}
1142
- \t\t\t\tdisabled={pending}
1143
- \t\t\t\tclassName="size-4 cursor-pointer"
1144
- \t\t\t\taria-label={\\\`Mark "\${todo.title}" as \${todo.done ? "not done" : "done"}\\\`}
1145
- \t\t\t/>
1146
- \t\t\t{editing ? (
1147
- \t\t\t\t<input
1148
- \t\t\t\t\tref={inputRef}
1149
- \t\t\t\t\tvalue={draft}
1150
- \t\t\t\t\tonChange={(e) => setDraft(e.target.value)}
1151
- \t\t\t\t\tonBlur={commit}
1152
- \t\t\t\t\tonKeyDown={(e) => {
1153
- \t\t\t\t\t\tif (e.key === "Enter") commit();
1154
- \t\t\t\t\t\telse if (e.key === "Escape") {
1155
- \t\t\t\t\t\t\tsetEditing(false);
1156
- \t\t\t\t\t\t\tsetDraft(todo.title);
1157
- \t\t\t\t\t\t}
1158
- \t\t\t\t\t}}
1159
- \t\t\t\t\tclassName="flex-1 bg-transparent border-b border-neutral-300 dark:border-neutral-700 outline-none text-sm"
1160
- \t\t\t\t\taria-label="Edit title"
1161
- \t\t\t\t/>
1162
- \t\t\t) : (
1163
- \t\t\t\t<button
1164
- \t\t\t\t\ttype="button"
1165
- \t\t\t\t\tonDoubleClick={() => setEditing(true)}
1166
- \t\t\t\t\tclassName={\\\`flex-1 text-left \${todo.done ? "line-through text-neutral-400" : ""}\\\`}
1167
- \t\t\t\t\ttitle="Double-click to edit"
1168
- \t\t\t\t>
1169
- \t\t\t\t\t{todo.title}
1170
- \t\t\t\t</button>
1171
- \t\t\t)}
1172
- \t\t\t<button
1173
- \t\t\t\ttype="button"
1174
- \t\t\t\tonClick={() => setEditing(true)}
1175
- \t\t\t\tclassName="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"
1176
- \t\t\t\taria-label={\\\`Edit "\${todo.title}"\\\`}
1177
- \t\t\t>
1178
- \t\t\t\tEdit
1179
- \t\t\t</button>
1180
- \t\t\t<button
1181
- \t\t\t\ttype="button"
1182
- \t\t\t\tonClick={onRemove}
1183
- \t\t\t\tdisabled={pending}
1184
- \t\t\t\tclassName="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-neutral-500 hover:text-red-500"
1185
- \t\t\t\taria-label={\\\`Delete "\${todo.title}"\\\`}
1186
- \t\t\t>
1187
- \t\t\t\tDelete
1188
- \t\t\t</button>
1189
- \t\t</li>
1190
- \t);
1191
- }
1192
- `,
1193
- );
382
+ Layout:
383
+ apps/api schema + functions/ handlers
384
+ ${platforms.includes("web") ? " apps/web Next.js 16 + React 19 + Tailwind v4\n packages/ui shared UI primitives" : ""}
385
+ ${platforms.includes("mobile") ? " apps/mobile Swift / SwiftUI" : ""}
386
+ ${platforms.includes("expo") ? " apps/expo Expo + React Native" : ""}
1194
387
 
1195
- write(
1196
- "apps/web/next-env.d.ts",
1197
- `/// <reference types="next" />
1198
- /// <reference types="next/image-types/global" />
1199
- `,
1200
- );
388
+ Docs: https://pylonsync.com/docs
389
+ `);
1201
390
 
1202
391
  // ---------------------------------------------------------------------------
1203
- // Detect package manager — read npm_config_user_agent set by the runner.
392
+ // Helpers
1204
393
  // ---------------------------------------------------------------------------
1205
394
 
1206
395
  function detectPackageManager() {
@@ -1212,36 +401,27 @@ function detectPackageManager() {
1212
401
  return null;
1213
402
  }
1214
403
 
1215
- /**
1216
- * Per-package-manager workspace script syntax. Each PM exposes
1217
- * "run X in workspace Y" differently:
1218
- * bun bun run --filter ./apps/api dev
1219
- * pnpm pnpm --filter ./apps/api run dev
1220
- * yarn yarn workspace @<name>/api run dev
1221
- * npm npm --workspace apps/api run dev
1222
- *
1223
- * The scaffold doesn't try to abstract the PM — it bakes the right
1224
- * syntax into the generated scripts so `<pm> run dev` works
1225
- * everywhere with no further config.
1226
- */
1227
404
  function pmScripts(pm) {
1228
405
  switch (pm) {
1229
406
  case "bun":
1230
407
  return {
1231
408
  devApi: "bun run --filter './apps/api' dev",
1232
409
  devWeb: "bun run --filter './apps/web' dev",
410
+ devExpo: "bun run --filter './apps/expo' start",
1233
411
  build: "bun run --filter '*' build",
1234
412
  };
1235
413
  case "pnpm":
1236
414
  return {
1237
415
  devApi: "pnpm --filter './apps/api' run dev",
1238
416
  devWeb: "pnpm --filter './apps/web' run dev",
417
+ devExpo: "pnpm --filter './apps/expo' run start",
1239
418
  build: "pnpm --filter '*' run build",
1240
419
  };
1241
420
  case "yarn":
1242
421
  return {
1243
- devApi: `yarn workspace @${projectName}/api run dev`,
1244
- devWeb: `yarn workspace @${projectName}/web run dev`,
422
+ devApi: `yarn workspace @${APP_NAME_KEBAB}/api run dev`,
423
+ devWeb: `yarn workspace @${APP_NAME_KEBAB}/web run dev`,
424
+ devExpo: `yarn workspace @${APP_NAME_KEBAB}/expo run start`,
1245
425
  build: "yarn workspaces foreach -A run build",
1246
426
  };
1247
427
  case "npm":
@@ -1249,53 +429,8 @@ function pmScripts(pm) {
1249
429
  return {
1250
430
  devApi: "npm --workspace apps/api run dev",
1251
431
  devWeb: "npm --workspace apps/web run dev",
432
+ devExpo: "npm --workspace apps/expo run start",
1252
433
  build: "npm --workspaces run build --if-present",
1253
434
  };
1254
435
  }
1255
436
  }
1256
-
1257
- // ---------------------------------------------------------------------------
1258
- // Optional: install dependencies
1259
- // ---------------------------------------------------------------------------
1260
-
1261
- if (!flags.skipInstall) {
1262
- console.log(`Installing dependencies with ${flags.pm}...`);
1263
- const { spawnSync } = await import("node:child_process");
1264
- const result = spawnSync(flags.pm, ["install"], {
1265
- cwd: root,
1266
- stdio: "inherit",
1267
- });
1268
- if (result.status !== 0) {
1269
- console.warn(
1270
- `\n${flags.pm} install exited with code ${result.status}. Re-run from ${projectName}/.\n`,
1271
- );
1272
- }
1273
- }
1274
-
1275
- // ---------------------------------------------------------------------------
1276
- // Final instructions
1277
- // ---------------------------------------------------------------------------
1278
-
1279
- const runDev = flags.pm === "npm" ? "npm run dev" : `${flags.pm} run dev`;
1280
-
1281
- console.log(`
1282
- ✓ Created ${projectName}
1283
-
1284
- cd ${projectName}
1285
- ${runDev}
1286
-
1287
- → api http://localhost:4321 (Pylon control plane)
1288
- → web http://localhost:3000 (Next.js dashboard)
1289
-
1290
- Layout:
1291
- apps/api schema + functions/ handlers
1292
- apps/web Next.js 16 + React 19 + Tailwind v4
1293
- packages/ui shared shadcn-style primitives
1294
-
1295
- Next:
1296
- - Edit apps/api/schema.ts to add entities + policies.
1297
- - Drop handlers into apps/api/functions/ — auto-discovered by name.
1298
- - Components go in apps/web/src/app/components/.
1299
-
1300
- Docs: https://pylonsync.com/docs
1301
- `);