@pylonsync/create-pylon 0.3.51 → 0.3.54
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/bin/create-pylon.js +347 -1156
- package/package.json +4 -3
- package/templates/_root/.env.example +9 -0
- package/templates/_root/README.md +43 -0
- package/templates/backend/b2b/apps/api/functions/archiveProject.ts +15 -0
- package/templates/backend/b2b/apps/api/functions/createOrg.ts +43 -0
- package/templates/backend/b2b/apps/api/functions/createProject.ts +25 -0
- package/templates/backend/b2b/apps/api/functions/inviteMember.ts +49 -0
- package/templates/backend/b2b/apps/api/functions/myOrgs.ts +37 -0
- package/templates/backend/b2b/apps/api/functions/orgMembers.ts +13 -0
- package/templates/backend/b2b/apps/api/functions/orgProjects.ts +18 -0
- package/templates/backend/b2b/apps/api/functions/removeMember.ts +29 -0
- package/templates/backend/b2b/apps/api/functions/setMemberRole.ts +38 -0
- package/templates/backend/b2b/apps/api/package.json +20 -0
- package/templates/backend/b2b/apps/api/schema.ts +171 -0
- package/templates/backend/b2b/apps/api/tsconfig.json +13 -0
- package/templates/backend/barebones/apps/api/functions/createWidget.ts +22 -0
- package/templates/backend/barebones/apps/api/functions/listWidgets.ts +18 -0
- package/templates/backend/barebones/apps/api/package.json +20 -0
- package/templates/backend/barebones/apps/api/schema.ts +61 -0
- package/templates/backend/barebones/apps/api/tsconfig.json +13 -0
- package/templates/backend/chat/apps/api/functions/createRoom.ts +32 -0
- package/templates/backend/chat/apps/api/functions/listRooms.ts +15 -0
- package/templates/backend/chat/apps/api/functions/roomMessages.ts +20 -0
- package/templates/backend/chat/apps/api/functions/sendMessage.ts +37 -0
- package/templates/backend/chat/apps/api/package.json +20 -0
- package/templates/backend/chat/apps/api/schema.ts +93 -0
- package/templates/backend/chat/apps/api/tsconfig.json +13 -0
- package/templates/backend/consumer/apps/api/functions/createPost.ts +48 -0
- package/templates/backend/consumer/apps/api/functions/deletePost.ts +21 -0
- package/templates/backend/consumer/apps/api/functions/feed.ts +57 -0
- package/templates/backend/consumer/apps/api/functions/myProfile.ts +17 -0
- package/templates/backend/consumer/apps/api/functions/profilePosts.ts +17 -0
- package/templates/backend/consumer/apps/api/functions/toggleLike.ts +48 -0
- package/templates/backend/consumer/apps/api/functions/upsertProfile.ts +70 -0
- package/templates/backend/consumer/apps/api/package.json +20 -0
- package/templates/backend/consumer/apps/api/schema.ts +130 -0
- package/templates/backend/consumer/apps/api/tsconfig.json +13 -0
- package/templates/backend/todo/apps/api/functions/addTodo.ts +27 -0
- package/templates/backend/todo/apps/api/functions/deleteTodo.ts +14 -0
- package/templates/backend/todo/apps/api/functions/editTodo.ts +16 -0
- package/templates/backend/todo/apps/api/functions/listTodos.ts +24 -0
- package/templates/backend/todo/apps/api/functions/reorderTodo.ts +14 -0
- package/templates/backend/todo/apps/api/functions/toggleTodo.ts +13 -0
- package/templates/backend/todo/apps/api/package.json +20 -0
- package/templates/backend/todo/apps/api/schema.ts +85 -0
- package/templates/backend/todo/apps/api/tsconfig.json +13 -0
- package/templates/expo/barebones/apps/expo/App.tsx +166 -0
- package/templates/expo/barebones/apps/expo/app.json +31 -0
- package/templates/expo/barebones/apps/expo/babel.config.js +6 -0
- package/templates/expo/barebones/apps/expo/package.json +30 -0
- package/templates/expo/barebones/apps/expo/tsconfig.json +16 -0
- package/templates/expo/chat/apps/expo/App.tsx +414 -0
- package/templates/expo/chat/apps/expo/app.json +25 -0
- package/templates/expo/chat/apps/expo/babel.config.js +6 -0
- package/templates/expo/chat/apps/expo/package.json +30 -0
- package/templates/expo/chat/apps/expo/tsconfig.json +16 -0
- package/templates/expo/consumer/apps/expo/App.tsx +360 -0
- package/templates/expo/consumer/apps/expo/app.json +25 -0
- package/templates/expo/consumer/apps/expo/babel.config.js +6 -0
- package/templates/expo/consumer/apps/expo/package.json +30 -0
- package/templates/expo/consumer/apps/expo/tsconfig.json +16 -0
- package/templates/expo/todo/apps/expo/App.tsx +287 -0
- package/templates/expo/todo/apps/expo/app.json +25 -0
- package/templates/expo/todo/apps/expo/babel.config.js +6 -0
- package/templates/expo/todo/apps/expo/package.json +30 -0
- package/templates/expo/todo/apps/expo/tsconfig.json +16 -0
- package/templates/ios/barebones/apps/ios/Package.swift +34 -0
- package/templates/ios/barebones/apps/ios/Sources/__APP_NAME_PASCAL__/ContentView.swift +98 -0
- package/templates/ios/barebones/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
- package/templates/ios/barebones/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +34 -0
- package/templates/ios/barebones/apps/ios/project.yml +42 -0
- package/templates/ios/chat/apps/ios/Package.swift +34 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +120 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +137 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +35 -0
- package/templates/ios/chat/apps/ios/project.yml +42 -0
- package/templates/ios/consumer/apps/ios/Package.swift +23 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +170 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +42 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +60 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +30 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +29 -0
- package/templates/ios/consumer/apps/ios/project.yml +42 -0
- package/templates/ios/todo/apps/ios/Package.swift +23 -0
- package/templates/ios/todo/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +18 -0
- package/templates/ios/todo/apps/ios/Sources/__APP_NAME_PASCAL__/TodoListView.swift +230 -0
- package/templates/ios/todo/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +28 -0
- package/templates/ios/todo/apps/ios/project.yml +32 -0
- package/templates/mac/b2b/apps/mac/Package.swift +22 -0
- package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +15 -0
- package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/OrgPickerView.swift +178 -0
- package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
- package/templates/mac/b2b/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/b2b/apps/mac/project.yml +34 -0
- package/templates/mac/barebones/apps/mac/Package.swift +33 -0
- package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/ContentView.swift +104 -0
- package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
- package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
- package/templates/mac/barebones/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/barebones/apps/mac/project.yml +34 -0
- package/templates/mac/chat/apps/mac/Package.swift +33 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +140 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +137 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +37 -0
- package/templates/mac/chat/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/chat/apps/mac/project.yml +34 -0
- package/templates/mac/consumer/apps/mac/Package.swift +33 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +170 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +42 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +60 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +30 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +31 -0
- package/templates/mac/consumer/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/consumer/apps/mac/project.yml +34 -0
- package/templates/mac/todo/apps/mac/Package.swift +33 -0
- package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +19 -0
- package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/TodoListView.swift +244 -0
- package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
- package/templates/mac/todo/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/todo/apps/mac/project.yml +34 -0
- package/templates/ui/packages/ui/package.json +26 -0
- package/templates/ui/packages/ui/src/button.tsx +44 -0
- package/templates/ui/packages/ui/src/card.tsx +39 -0
- package/templates/ui/packages/ui/src/cn.ts +12 -0
- package/templates/ui/packages/ui/src/index.ts +4 -0
- package/templates/ui/packages/ui/src/input.tsx +19 -0
- package/templates/ui/packages/ui/tsconfig.json +15 -0
- package/templates/web/b2b/apps/web/next-env.d.ts +2 -0
- package/templates/web/b2b/apps/web/next.config.ts +24 -0
- package/templates/web/b2b/apps/web/package.json +29 -0
- package/templates/web/b2b/apps/web/postcss.config.mjs +3 -0
- package/templates/web/b2b/apps/web/src/app/components/OrgPicker.tsx +171 -0
- package/templates/web/b2b/apps/web/src/app/globals.css +6 -0
- package/templates/web/b2b/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/b2b/apps/web/src/app/page.tsx +39 -0
- package/templates/web/b2b/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/b2b/apps/web/tsconfig.json +26 -0
- package/templates/web/barebones/apps/web/next-env.d.ts +2 -0
- package/templates/web/barebones/apps/web/next.config.ts +40 -0
- package/templates/web/barebones/apps/web/package.json +29 -0
- package/templates/web/barebones/apps/web/postcss.config.mjs +4 -0
- package/templates/web/barebones/apps/web/src/app/components/WidgetList.tsx +81 -0
- package/templates/web/barebones/apps/web/src/app/globals.css +9 -0
- package/templates/web/barebones/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/barebones/apps/web/src/app/page.tsx +43 -0
- package/templates/web/barebones/apps/web/src/lib/pylon.ts +14 -0
- package/templates/web/barebones/apps/web/tsconfig.json +26 -0
- package/templates/web/chat/apps/web/next-env.d.ts +2 -0
- package/templates/web/chat/apps/web/next.config.ts +24 -0
- package/templates/web/chat/apps/web/package.json +29 -0
- package/templates/web/chat/apps/web/postcss.config.mjs +3 -0
- package/templates/web/chat/apps/web/src/app/components/ChatRoom.tsx +250 -0
- package/templates/web/chat/apps/web/src/app/globals.css +6 -0
- package/templates/web/chat/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/chat/apps/web/src/app/page.tsx +51 -0
- package/templates/web/chat/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/chat/apps/web/tsconfig.json +26 -0
- package/templates/web/consumer/apps/web/next-env.d.ts +2 -0
- package/templates/web/consumer/apps/web/next.config.ts +24 -0
- package/templates/web/consumer/apps/web/package.json +29 -0
- package/templates/web/consumer/apps/web/postcss.config.mjs +3 -0
- package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +295 -0
- package/templates/web/consumer/apps/web/src/app/globals.css +6 -0
- package/templates/web/consumer/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/consumer/apps/web/src/app/page.tsx +55 -0
- package/templates/web/consumer/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/consumer/apps/web/tsconfig.json +26 -0
- package/templates/web/todo/apps/web/next-env.d.ts +2 -0
- package/templates/web/todo/apps/web/next.config.ts +24 -0
- package/templates/web/todo/apps/web/package.json +32 -0
- package/templates/web/todo/apps/web/postcss.config.mjs +3 -0
- package/templates/web/todo/apps/web/src/app/components/TodoList.tsx +310 -0
- package/templates/web/todo/apps/web/src/app/globals.css +6 -0
- package/templates/web/todo/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/todo/apps/web/src/app/page.tsx +36 -0
- package/templates/web/todo/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/todo/apps/web/tsconfig.json +26 -0
package/bin/create-pylon.js
CHANGED
|
@@ -2,60 +2,176 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* @pylonsync/create-pylon — scaffold a new Pylon app.
|
|
4
4
|
*
|
|
5
|
-
* Run via `npm create @pylonsync/pylon@latest [name]` (or
|
|
6
|
-
* create @pylonsync/pylon).
|
|
5
|
+
* Run via `npm create @pylonsync/pylon@latest [name]` (or
|
|
6
|
+
* yarn/pnpm/bun create @pylonsync/pylon).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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 {
|
|
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
|
-
//
|
|
25
|
-
// of the
|
|
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.
|
|
26
52
|
// ---------------------------------------------------------------------------
|
|
27
53
|
|
|
28
|
-
const PYLON_VERSION =
|
|
54
|
+
const PYLON_VERSION = JSON.parse(
|
|
55
|
+
readFileSync(resolve(HERE, "..", "package.json"), "utf8"),
|
|
56
|
+
).version;
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Templates + platforms registry
|
|
60
|
+
//
|
|
61
|
+
// Each template declares which platforms it supports — `b2b` is web/mac
|
|
62
|
+
// only because the demo flow (org switcher, member invite, RBAC admin
|
|
63
|
+
// panel) is desktop-shaped and porting it to mobile would be busy work
|
|
64
|
+
// without value. Pick a different template if you want mobile.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const PLATFORMS_AVAILABLE = ["web", "ios", "mac", "expo"];
|
|
68
|
+
|
|
69
|
+
const TEMPLATE_REGISTRY = {
|
|
70
|
+
barebones: {
|
|
71
|
+
blurb: "Single entity, list + create. The smallest working app.",
|
|
72
|
+
platforms: ["web", "ios", "mac", "expo"],
|
|
73
|
+
},
|
|
74
|
+
todo: {
|
|
75
|
+
blurb: "CRUD + drag-reorder + optimistic mutations.",
|
|
76
|
+
platforms: ["web", "ios", "mac", "expo"],
|
|
77
|
+
},
|
|
78
|
+
b2b: {
|
|
79
|
+
blurb: "Multi-tenant SaaS: orgs, members, roles, RBAC policies.",
|
|
80
|
+
platforms: ["web", "mac"],
|
|
81
|
+
},
|
|
82
|
+
consumer: {
|
|
83
|
+
blurb: "Social feed: profiles, posts, likes, follows.",
|
|
84
|
+
platforms: ["web", "ios", "mac", "expo"],
|
|
85
|
+
},
|
|
86
|
+
chat: {
|
|
87
|
+
blurb: "Realtime messaging: rooms, presence, live message feed.",
|
|
88
|
+
platforms: ["web", "ios", "mac", "expo"],
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
const TEMPLATES_AVAILABLE = Object.keys(TEMPLATE_REGISTRY);
|
|
29
92
|
|
|
30
93
|
// ---------------------------------------------------------------------------
|
|
31
|
-
// CLI args + interactive
|
|
94
|
+
// CLI args + interactive prompts
|
|
32
95
|
// ---------------------------------------------------------------------------
|
|
33
96
|
|
|
34
97
|
const args = argv.slice(2);
|
|
35
98
|
let projectName = args.find((a) => !a.startsWith("--"));
|
|
36
99
|
|
|
37
100
|
const flags = {
|
|
38
|
-
pm: args
|
|
39
|
-
|
|
40
|
-
|
|
101
|
+
pm: pickValue(args, "--bun", "--pnpm", "--yarn", "--npm")?.replace(/^--/, ""),
|
|
102
|
+
template: takeValue(args, "--template"),
|
|
103
|
+
platforms: takeValue(args, "--platforms"),
|
|
41
104
|
skipInstall: args.includes("--skip-install"),
|
|
42
105
|
help: args.includes("--help") || args.includes("-h"),
|
|
43
106
|
};
|
|
44
107
|
|
|
108
|
+
function takeValue(arr, name) {
|
|
109
|
+
const flagWithEq = arr.find((a) => a.startsWith(name + "="));
|
|
110
|
+
if (flagWithEq) return flagWithEq.slice(name.length + 1);
|
|
111
|
+
const idx = arr.indexOf(name);
|
|
112
|
+
if (idx >= 0 && idx + 1 < arr.length && !arr[idx + 1].startsWith("--")) {
|
|
113
|
+
return arr[idx + 1];
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function pickValue(arr, ...candidates) {
|
|
119
|
+
for (const c of candidates) {
|
|
120
|
+
if (arr.includes(c)) return c;
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
45
125
|
if (flags.help) {
|
|
46
|
-
|
|
126
|
+
const tmplLines = Object.entries(TEMPLATE_REGISTRY).map(
|
|
127
|
+
([k, v]) => ` ${k.padEnd(10)} ${v.blurb} (${v.platforms.join(", ")})`,
|
|
128
|
+
);
|
|
129
|
+
process.stdout.write(`
|
|
130
|
+
Usage: npm create @pylonsync/pylon [name] [options]
|
|
131
|
+
|
|
132
|
+
--template <t> ${TEMPLATES_AVAILABLE.join(" | ")}
|
|
133
|
+
${tmplLines.join("\n")}
|
|
134
|
+
|
|
135
|
+
--platforms <list> comma list: ${PLATFORMS_AVAILABLE.join(",")} (default: web)
|
|
136
|
+
--bun|--pnpm|--yarn|--npm
|
|
137
|
+
--skip-install scaffold only, don't run install
|
|
138
|
+
|
|
139
|
+
Examples:
|
|
140
|
+
npm create @pylonsync/pylon my-app
|
|
141
|
+
npm create @pylonsync/pylon my-app --template todo --platforms web,ios
|
|
142
|
+
npm create @pylonsync/pylon my-app --template b2b --platforms web,mac
|
|
143
|
+
npm create @pylonsync/pylon my-app --template chat --platforms ios,mac,expo
|
|
144
|
+
`);
|
|
47
145
|
exit(0);
|
|
48
146
|
}
|
|
49
147
|
|
|
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
148
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
56
149
|
if (!projectName) {
|
|
57
150
|
projectName = (await rl.question("Project name: ")).trim() || "my-pylon-app";
|
|
58
151
|
}
|
|
152
|
+
if (!flags.template) {
|
|
153
|
+
const lines = Object.entries(TEMPLATE_REGISTRY)
|
|
154
|
+
.map(([k, v]) => ` ${k.padEnd(10)} ${v.blurb}`)
|
|
155
|
+
.join("\n");
|
|
156
|
+
process.stdout.write(`\n${lines}\n`);
|
|
157
|
+
const ans = (
|
|
158
|
+
await rl.question(
|
|
159
|
+
`Template (${TEMPLATES_AVAILABLE.join(", ")}) [todo]: `,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
.trim()
|
|
163
|
+
.toLowerCase();
|
|
164
|
+
flags.template = TEMPLATES_AVAILABLE.includes(ans) ? ans : "todo";
|
|
165
|
+
}
|
|
166
|
+
if (!flags.platforms) {
|
|
167
|
+
const supported = TEMPLATE_REGISTRY[flags.template].platforms.join(", ");
|
|
168
|
+
const ans = (
|
|
169
|
+
await rl.question(
|
|
170
|
+
`Platforms for ${flags.template} (${supported}, comma-separated) [web]: `,
|
|
171
|
+
)
|
|
172
|
+
).trim();
|
|
173
|
+
flags.platforms = ans || "web";
|
|
174
|
+
}
|
|
59
175
|
if (!flags.pm) {
|
|
60
176
|
const detected = detectPackageManager();
|
|
61
177
|
const def = detected ?? "bun";
|
|
@@ -68,6 +184,45 @@ if (!flags.pm) {
|
|
|
68
184
|
}
|
|
69
185
|
rl.close();
|
|
70
186
|
|
|
187
|
+
const platforms = flags.platforms
|
|
188
|
+
.split(",")
|
|
189
|
+
.map((p) => p.trim().toLowerCase())
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
const unknownPlatforms = platforms.filter(
|
|
192
|
+
(p) => !PLATFORMS_AVAILABLE.includes(p),
|
|
193
|
+
);
|
|
194
|
+
if (unknownPlatforms.length > 0) {
|
|
195
|
+
console.error(
|
|
196
|
+
`\nError: unknown platform(s): ${unknownPlatforms.join(", ")}. Valid: ${PLATFORMS_AVAILABLE.join(", ")}\n`,
|
|
197
|
+
);
|
|
198
|
+
exit(1);
|
|
199
|
+
}
|
|
200
|
+
if (platforms.length === 0) {
|
|
201
|
+
console.error(`\nError: at least one platform required.\n`);
|
|
202
|
+
exit(1);
|
|
203
|
+
}
|
|
204
|
+
if (!TEMPLATES_AVAILABLE.includes(flags.template)) {
|
|
205
|
+
console.error(
|
|
206
|
+
`\nError: unknown template "${flags.template}". Valid: ${TEMPLATES_AVAILABLE.join(", ")}\n`,
|
|
207
|
+
);
|
|
208
|
+
exit(1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Reject combos a template doesn't yet support — better to fail loud
|
|
212
|
+
// than to scaffold an incomplete tree (e.g. b2b + expo would skip
|
|
213
|
+
// frontend entirely and leave the user with a half-empty workspace).
|
|
214
|
+
const supportedPlatforms = TEMPLATE_REGISTRY[flags.template].platforms;
|
|
215
|
+
const invalidForTemplate = platforms.filter(
|
|
216
|
+
(p) => !supportedPlatforms.includes(p),
|
|
217
|
+
);
|
|
218
|
+
if (invalidForTemplate.length > 0) {
|
|
219
|
+
console.error(
|
|
220
|
+
`\nError: template "${flags.template}" doesn't support platform(s): ${invalidForTemplate.join(", ")}.\n` +
|
|
221
|
+
` supported: ${supportedPlatforms.join(", ")}\n`,
|
|
222
|
+
);
|
|
223
|
+
exit(1);
|
|
224
|
+
}
|
|
225
|
+
|
|
71
226
|
// Some PMs reject the `workspace:` protocol. Bun/pnpm/yarn berry
|
|
72
227
|
// understand it and rewrite to the local sibling version at install
|
|
73
228
|
// time. npm errors EUNSUPPORTEDPROTOCOL ("Unsupported URL Type").
|
|
@@ -78,1129 +233,219 @@ const usesWorkspaceProtocol = flags.pm !== "npm";
|
|
|
78
233
|
const workspaceDepSpec = usesWorkspaceProtocol ? "workspace:*" : "*";
|
|
79
234
|
|
|
80
235
|
const root = resolve(cwd(), projectName);
|
|
81
|
-
|
|
82
236
|
if (existsSync(root) && readdirSync(root).length > 0) {
|
|
83
237
|
console.error(`\nError: ${root} already exists and is not empty.\n`);
|
|
84
238
|
exit(1);
|
|
85
239
|
}
|
|
240
|
+
mkdirSync(root, { recursive: true });
|
|
86
241
|
|
|
87
|
-
console.log(
|
|
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
|
-
`,
|
|
242
|
+
console.log(
|
|
243
|
+
`\nCreating ${projectName} (${flags.template}, ${platforms.join(" + ")}) in ${root}\n`,
|
|
211
244
|
);
|
|
212
245
|
|
|
213
246
|
// ---------------------------------------------------------------------------
|
|
214
|
-
//
|
|
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
|
-
entity,
|
|
258
|
-
field,
|
|
259
|
-
query,
|
|
260
|
-
action,
|
|
261
|
-
policy,
|
|
262
|
-
buildManifest,
|
|
263
|
-
} from "@pylonsync/sdk";
|
|
264
|
-
|
|
265
|
-
// ---------------------------------------------------------------------------
|
|
266
|
-
// Schema
|
|
267
|
-
// ---------------------------------------------------------------------------
|
|
268
|
-
|
|
269
|
-
const Todo = entity("Todo", {
|
|
270
|
-
title: field.string(),
|
|
271
|
-
done: field.bool(),
|
|
272
|
-
createdAt: field.datetime(),
|
|
273
|
-
// Float position so drag-reorder can insert between two existing
|
|
274
|
-
// rows without renumbering the whole list. Frontend computes
|
|
275
|
-
// (prev.position + next.position) / 2 on drop. Optional for
|
|
276
|
-
// backwards compat with legacy rows.
|
|
277
|
-
position: 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
|
-
input: [{ name: "title", type: "string" }],
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
const toggleTodo = action("toggleTodo", {
|
|
292
|
-
input: [{ name: "id", type: "id(Todo)" }, { name: "done", type: "bool" }],
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
const deleteTodo = action("deleteTodo", {
|
|
296
|
-
input: [{ name: "id", type: "id(Todo)" }],
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
const editTodo = action("editTodo", {
|
|
300
|
-
input: [
|
|
301
|
-
{ name: "id", type: "id(Todo)" },
|
|
302
|
-
{ name: "title", type: "string" },
|
|
303
|
-
],
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
const reorderTodo = action("reorderTodo", {
|
|
307
|
-
input: [
|
|
308
|
-
{ name: "id", type: "id(Todo)" },
|
|
309
|
-
{ name: "position", type: "float" },
|
|
310
|
-
],
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
// ---------------------------------------------------------------------------
|
|
314
|
-
// Policies — wide-open by default. Tighten for production.
|
|
247
|
+
// Substitution table — used by every template file copy. Names that
|
|
248
|
+
// only make sense for some platforms (e.g. PASCAL for Swift) are still
|
|
249
|
+
// computed unconditionally; the unused replacements are no-ops.
|
|
315
250
|
// ---------------------------------------------------------------------------
|
|
316
251
|
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
// every Pylon entry file ends with this console.log line.
|
|
333
|
-
const manifest = buildManifest({
|
|
334
|
-
name: "${projectName}",
|
|
335
|
-
version: "0.0.1",
|
|
336
|
-
entities: [Todo],
|
|
337
|
-
queries: [listTodos],
|
|
338
|
-
actions: [addTodo, toggleTodo, deleteTodo, editTodo, reorderTodo],
|
|
339
|
-
policies: [todoPolicy],
|
|
340
|
-
routes: [],
|
|
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";
|
|
252
|
+
const APP_NAME = projectName;
|
|
253
|
+
const APP_NAME_KEBAB = APP_NAME.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
|
|
254
|
+
const APP_NAME_SNAKE = APP_NAME.replace(/[^a-z0-9]+/gi, "_").toLowerCase();
|
|
255
|
+
const APP_NAME_PASCAL = APP_NAME.replace(/(^|[^a-z0-9])([a-z0-9])/gi, (_, _s, c) =>
|
|
256
|
+
c.toUpperCase(),
|
|
257
|
+
).replace(/[^A-Za-z0-9]/g, "");
|
|
258
|
+
|
|
259
|
+
const SUBS = {
|
|
260
|
+
__APP_NAME__: APP_NAME,
|
|
261
|
+
__APP_NAME_KEBAB__: APP_NAME_KEBAB,
|
|
262
|
+
__APP_NAME_SNAKE__: APP_NAME_SNAKE,
|
|
263
|
+
__APP_NAME_PASCAL__: APP_NAME_PASCAL,
|
|
264
|
+
__PYLON_VERSION__: PYLON_VERSION,
|
|
265
|
+
__WORKSPACE_DEP__: workspaceDepSpec,
|
|
266
|
+
};
|
|
350
267
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const ap =
|
|
362
|
-
typeof a.position === "number"
|
|
363
|
-
? a.position
|
|
364
|
-
: Date.parse(a.createdAt) || 0;
|
|
365
|
-
const bp =
|
|
366
|
-
typeof b.position === "number"
|
|
367
|
-
? b.position
|
|
368
|
-
: Date.parse(b.createdAt) || 0;
|
|
369
|
-
return ap - bp;
|
|
370
|
-
});
|
|
371
|
-
},
|
|
372
|
-
});
|
|
373
|
-
`,
|
|
374
|
-
);
|
|
268
|
+
// Filenames that contain placeholders get renamed AFTER copy. Keeps
|
|
269
|
+
// the loader simple — copy the directory tree raw, then rename any
|
|
270
|
+
// file/dir whose name has a placeholder.
|
|
271
|
+
function substituteString(s) {
|
|
272
|
+
let out = s;
|
|
273
|
+
for (const [k, v] of Object.entries(SUBS)) {
|
|
274
|
+
out = out.split(k).join(v);
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
375
278
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
279
|
+
function substituteFile(absPath) {
|
|
280
|
+
// Skip binary-ish files — we only ever ship text in templates,
|
|
281
|
+
// but be safe in case someone drops a PNG icon in later.
|
|
282
|
+
const buf = readFileSync(absPath);
|
|
283
|
+
for (let i = 0; i < Math.min(buf.length, 8000); i++) {
|
|
284
|
+
if (buf[i] === 0) return;
|
|
285
|
+
}
|
|
286
|
+
const before = buf.toString("utf8");
|
|
287
|
+
const after = substituteString(before);
|
|
288
|
+
if (before !== after) writeFileSync(absPath, after);
|
|
289
|
+
}
|
|
379
290
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
throw ctx.error("EMPTY_TITLE", "title cannot be empty");
|
|
291
|
+
function walkAndSubstitute(dir) {
|
|
292
|
+
for (const entry of readdirSync(dir)) {
|
|
293
|
+
const abs = join(dir, entry);
|
|
294
|
+
const renamed = substituteString(entry);
|
|
295
|
+
let target = abs;
|
|
296
|
+
if (renamed !== entry) {
|
|
297
|
+
target = join(dir, renamed);
|
|
298
|
+
renameSync(abs, target);
|
|
389
299
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
);
|
|
396
|
-
|
|
397
|
-
write(
|
|
398
|
-
"apps/api/functions/reorderTodo.ts",
|
|
399
|
-
`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
|
-
args: { id: v.id("Todo"), position: v.number() },
|
|
408
|
-
async handler(ctx, args: { id: string; position: number }) {
|
|
409
|
-
await ctx.db.update("Todo", args.id, { position: args.position });
|
|
410
|
-
return await ctx.db.get("Todo", args.id);
|
|
411
|
-
},
|
|
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
|
-
args: { id: v.id("Todo"), done: v.bool() },
|
|
426
|
-
async handler(ctx, args: { id: string; done: boolean }) {
|
|
427
|
-
await ctx.db.update("Todo", args.id, { done: args.done });
|
|
428
|
-
return await ctx.db.get("Todo", args.id);
|
|
429
|
-
},
|
|
430
|
-
});
|
|
431
|
-
`,
|
|
432
|
-
);
|
|
433
|
-
|
|
434
|
-
write(
|
|
435
|
-
"apps/api/functions/deleteTodo.ts",
|
|
436
|
-
`import { mutation, v } from "@pylonsync/functions";
|
|
437
|
-
|
|
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
|
-
args: { id: v.id("Todo") },
|
|
444
|
-
async handler(ctx, args: { id: string }) {
|
|
445
|
-
const snapshot = await ctx.db.get("Todo", args.id);
|
|
446
|
-
await ctx.db.delete("Todo", args.id);
|
|
447
|
-
return snapshot;
|
|
448
|
-
},
|
|
449
|
-
});
|
|
450
|
-
`,
|
|
451
|
-
);
|
|
452
|
-
|
|
453
|
-
write(
|
|
454
|
-
"apps/api/functions/addTodo.ts",
|
|
455
|
-
`import { mutation, v } from "@pylonsync/functions";
|
|
300
|
+
const st = statSync(target);
|
|
301
|
+
if (st.isDirectory()) walkAndSubstitute(target);
|
|
302
|
+
else if (st.isFile()) substituteFile(target);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
456
305
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const existing = await ctx.db.query("Todo", {});
|
|
466
|
-
const maxPos = existing.reduce((acc: number, row: any) => {
|
|
467
|
-
const p =
|
|
468
|
-
typeof row.position === "number"
|
|
469
|
-
? row.position
|
|
470
|
-
: Date.parse(row.createdAt) || 0;
|
|
471
|
-
return p > acc ? p : acc;
|
|
472
|
-
}, 0);
|
|
473
|
-
const id = await ctx.db.insert("Todo", {
|
|
474
|
-
title: args.title,
|
|
475
|
-
done: false,
|
|
476
|
-
createdAt: new Date().toISOString(),
|
|
477
|
-
position: maxPos + 1024,
|
|
478
|
-
});
|
|
479
|
-
return await ctx.db.get("Todo", id);
|
|
480
|
-
},
|
|
481
|
-
});
|
|
482
|
-
`,
|
|
483
|
-
);
|
|
306
|
+
function copyTemplate(srcSubpath, destSubpath = "") {
|
|
307
|
+
const src = join(TEMPLATES, srcSubpath);
|
|
308
|
+
if (!existsSync(src)) return false;
|
|
309
|
+
const dest = destSubpath ? join(root, destSubpath) : root;
|
|
310
|
+
mkdirSync(dest, { recursive: true });
|
|
311
|
+
cpSync(src, dest, { recursive: true });
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
484
314
|
|
|
485
315
|
// ---------------------------------------------------------------------------
|
|
486
|
-
//
|
|
316
|
+
// Apply templates in order:
|
|
317
|
+
// 1. _root — gitignore, env.example, README
|
|
318
|
+
// 2. backend/<t> — apps/api/* always present (one per template)
|
|
319
|
+
// 3. ui — packages/ui (only if web is in platforms)
|
|
320
|
+
// 4. <platform>/<t> — one per requested platform under apps/<platform>/
|
|
321
|
+
// 5. Root package.json — generated, not templated
|
|
487
322
|
// ---------------------------------------------------------------------------
|
|
488
323
|
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
});
|
|
531
|
-
|
|
532
|
-
write(
|
|
533
|
-
"packages/ui/src/cn.ts",
|
|
534
|
-
`import { clsx, type ClassValue } from "clsx";
|
|
535
|
-
import { twMerge } from "tailwind-merge";
|
|
324
|
+
copyTemplate("_root");
|
|
325
|
+
copyTemplate(`backend/${flags.template}`);
|
|
536
326
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
return twMerge(clsx(inputs));
|
|
327
|
+
if (platforms.includes("web")) {
|
|
328
|
+
copyTemplate("ui");
|
|
329
|
+
copyTemplate(`web/${flags.template}`);
|
|
545
330
|
}
|
|
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
|
-
default:
|
|
559
|
-
"bg-neutral-100 hover:bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:text-neutral-100",
|
|
560
|
-
primary:
|
|
561
|
-
"bg-neutral-900 hover:bg-neutral-800 text-white dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900",
|
|
562
|
-
ghost:
|
|
563
|
-
"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
|
-
sm: "h-8 px-3 text-[13px]",
|
|
568
|
-
md: "h-9 px-4 text-sm",
|
|
569
|
-
};
|
|
570
|
-
|
|
571
|
-
export interface ButtonProps
|
|
572
|
-
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
573
|
-
variant?: Variant;
|
|
574
|
-
size?: Size;
|
|
331
|
+
for (const p of ["ios", "mac", "expo"]) {
|
|
332
|
+
if (platforms.includes(p)) copyTemplate(`${p}/${flags.template}`);
|
|
575
333
|
}
|
|
576
334
|
|
|
577
|
-
|
|
578
|
-
className,
|
|
579
|
-
variant = "default",
|
|
580
|
-
size = "md",
|
|
581
|
-
...props
|
|
582
|
-
}: ButtonProps) {
|
|
583
|
-
return (
|
|
584
|
-
<button
|
|
585
|
-
className={cn(
|
|
586
|
-
"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
|
-
variants[variant],
|
|
588
|
-
sizes[size],
|
|
589
|
-
className,
|
|
590
|
-
)}
|
|
591
|
-
{...props}
|
|
592
|
-
/>
|
|
593
|
-
);
|
|
594
|
-
}
|
|
595
|
-
`,
|
|
596
|
-
);
|
|
597
|
-
|
|
598
|
-
write(
|
|
599
|
-
"packages/ui/src/input.tsx",
|
|
600
|
-
`import * as React from "react";
|
|
601
|
-
import { cn } from "./cn";
|
|
602
|
-
|
|
603
|
-
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
|
604
|
-
|
|
605
|
-
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
606
|
-
function Input({ className, ...props }, ref) {
|
|
607
|
-
return (
|
|
608
|
-
<input
|
|
609
|
-
ref={ref}
|
|
610
|
-
className={cn(
|
|
611
|
-
"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
|
-
className,
|
|
613
|
-
)}
|
|
614
|
-
{...props}
|
|
615
|
-
/>
|
|
616
|
-
);
|
|
617
|
-
},
|
|
618
|
-
);
|
|
619
|
-
`,
|
|
620
|
-
);
|
|
621
|
-
|
|
622
|
-
write(
|
|
623
|
-
"packages/ui/src/card.tsx",
|
|
624
|
-
`import * as React from "react";
|
|
625
|
-
import { cn } from "./cn";
|
|
335
|
+
walkAndSubstitute(root);
|
|
626
336
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
<div
|
|
633
|
-
className={cn(
|
|
634
|
-
"rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900",
|
|
635
|
-
className,
|
|
636
|
-
)}
|
|
637
|
-
{...props}
|
|
638
|
-
/>
|
|
639
|
-
);
|
|
640
|
-
}
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Root package.json — generated based on selected platforms. Workspace
|
|
339
|
+
// scripts depend on which apps exist + which package manager the user
|
|
340
|
+
// picked (each PM exposes "run X in workspace Y" differently).
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
641
342
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
343
|
+
const wsScripts = pmScripts(flags.pm);
|
|
344
|
+
const devScripts = {};
|
|
345
|
+
// API runs always — every frontend connects to it.
|
|
346
|
+
devScripts["dev:api"] = wsScripts.devApi;
|
|
347
|
+
if (platforms.includes("web")) devScripts["dev:web"] = wsScripts.devWeb;
|
|
348
|
+
if (platforms.includes("expo")) devScripts["dev:expo"] = wsScripts.devExpo;
|
|
349
|
+
if (platforms.includes("ios")) {
|
|
350
|
+
// `xcodegen generate` materializes the .xcodeproj from project.yml,
|
|
351
|
+
// then it's an Xcode-driven flow — no `bun run dev` semantics.
|
|
352
|
+
devScripts["dev:ios"] =
|
|
353
|
+
"echo 'cd apps/ios && xcodegen generate && open *.xcodeproj (or: swift run for a quick macOS preview)'";
|
|
649
354
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
...props
|
|
654
|
-
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
655
|
-
return <div className={cn("p-5", className)} {...props} />;
|
|
355
|
+
if (platforms.includes("mac")) {
|
|
356
|
+
devScripts["dev:mac"] =
|
|
357
|
+
"echo 'cd apps/mac && swift run (or: xcodegen generate && open *.xcodeproj)'";
|
|
656
358
|
}
|
|
657
|
-
`,
|
|
658
|
-
);
|
|
659
359
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
673
|
-
writeJson("apps/web/package.json", {
|
|
674
|
-
name: `@${projectName}/web`,
|
|
675
|
-
version: "0.0.1",
|
|
360
|
+
const parallelDevs = Object.keys(devScripts);
|
|
361
|
+
const rootPkg = {
|
|
362
|
+
name: APP_NAME_KEBAB,
|
|
676
363
|
private: true,
|
|
677
364
|
type: "module",
|
|
365
|
+
workspaces: ["apps/*", "packages/*"].filter((p) => {
|
|
366
|
+
// Only declare packages/* as a workspace if we actually scaffolded
|
|
367
|
+
// packages/ui — otherwise the empty match warns on bun install.
|
|
368
|
+
if (p === "packages/*") return platforms.includes("web");
|
|
369
|
+
return true;
|
|
370
|
+
}),
|
|
678
371
|
scripts: {
|
|
679
|
-
dev:
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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/*"] },
|
|
724
|
-
},
|
|
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
|
-
transpilePackages: [
|
|
753
|
-
"@${projectName}/ui",
|
|
754
|
-
"@pylonsync/sdk",
|
|
755
|
-
"@pylonsync/react",
|
|
756
|
-
"@pylonsync/next",
|
|
757
|
-
"@pylonsync/functions",
|
|
758
|
-
"@pylonsync/sync",
|
|
759
|
-
],
|
|
760
|
-
async rewrites() {
|
|
761
|
-
return [
|
|
762
|
-
{ source: "/api/fn/:path*", destination: \`\${PYLON_API_URL}/api/fn/:path*\` },
|
|
763
|
-
{ source: "/api/auth/:path*", destination: \`\${PYLON_API_URL}/api/auth/:path*\` },
|
|
764
|
-
{ source: "/api/sync/:path*", destination: \`\${PYLON_API_URL}/api/sync/:path*\` },
|
|
765
|
-
{ source: "/api/:path*", destination: \`\${PYLON_API_URL}/api/:path*\` },
|
|
766
|
-
];
|
|
372
|
+
dev:
|
|
373
|
+
parallelDevs.length > 1
|
|
374
|
+
? `npm-run-all --parallel ${parallelDevs.join(" ")}`
|
|
375
|
+
: wsScripts.devApi,
|
|
376
|
+
...devScripts,
|
|
377
|
+
build: wsScripts.build,
|
|
767
378
|
},
|
|
379
|
+
devDependencies: parallelDevs.length > 1 ? { "npm-run-all": "^4.1.5" } : {},
|
|
768
380
|
};
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
);
|
|
773
|
-
|
|
774
|
-
write(
|
|
775
|
-
"apps/web/postcss.config.mjs",
|
|
776
|
-
`/** Tailwind v4 PostCSS pipeline. */
|
|
777
|
-
export default {
|
|
778
|
-
plugins: { "@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
|
-
color-scheme: light dark;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
html, body { height: 100%; }
|
|
793
|
-
body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
|
|
794
|
-
`,
|
|
795
|
-
);
|
|
796
|
-
|
|
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
|
-
title: "${projectName}",
|
|
804
|
-
description: "Realtime app powered by Pylon",
|
|
805
|
-
};
|
|
806
|
-
|
|
807
|
-
export default function RootLayout({
|
|
808
|
-
children,
|
|
809
|
-
}: {
|
|
810
|
-
children: React.ReactNode;
|
|
811
|
-
}) {
|
|
812
|
-
return (
|
|
813
|
-
<html lang="en">
|
|
814
|
-
<body className="antialiased min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
|
|
815
|
-
{children}
|
|
816
|
-
</body>
|
|
817
|
-
</html>
|
|
818
|
-
);
|
|
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
|
-
cookieName: "${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
|
-
id: string;
|
|
854
|
-
title: string;
|
|
855
|
-
done: boolean;
|
|
856
|
-
createdAt: string;
|
|
857
|
-
};
|
|
858
|
-
|
|
859
|
-
export default async function HomePage() {
|
|
860
|
-
const todos = await pylon
|
|
861
|
-
.json<Todo[]>("/api/fn/listTodos", {
|
|
862
|
-
method: "POST",
|
|
863
|
-
body: "{}",
|
|
864
|
-
headers: { "Content-Type": "application/json" },
|
|
865
|
-
})
|
|
866
|
-
.catch(() => [] as Todo[]);
|
|
867
|
-
|
|
868
|
-
return (
|
|
869
|
-
<main className="mx-auto max-w-2xl px-6 py-12 space-y-8">
|
|
870
|
-
<header className="space-y-2">
|
|
871
|
-
<h1 className="text-3xl font-semibold tracking-tight">${projectName}</h1>
|
|
872
|
-
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
873
|
-
A Pylon-powered realtime app. Edit{" "}
|
|
874
|
-
<code className="font-mono text-xs">apps/api/schema.ts</code> to change
|
|
875
|
-
the data model,{" "}
|
|
876
|
-
<code className="font-mono text-xs">apps/api/functions/</code> to add
|
|
877
|
-
handlers, or{" "}
|
|
878
|
-
<code className="font-mono text-xs">
|
|
879
|
-
apps/web/src/app/components/TodoList.tsx
|
|
880
|
-
</code>{" "}
|
|
881
|
-
for the UI.
|
|
882
|
-
</p>
|
|
883
|
-
</header>
|
|
884
|
-
|
|
885
|
-
<TodoList initialTodos={todos} />
|
|
886
|
-
</main>
|
|
887
|
-
);
|
|
888
|
-
}
|
|
889
|
-
`,
|
|
381
|
+
writeFileSync(
|
|
382
|
+
join(root, "package.json"),
|
|
383
|
+
JSON.stringify(rootPkg, null, 2) + "\n",
|
|
890
384
|
);
|
|
891
385
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
import { useState, useTransition, useRef, useEffect } from "react";
|
|
897
|
-
import { Button } from "@${projectName}/ui";
|
|
898
|
-
import { Input } from "@${projectName}/ui";
|
|
899
|
-
import {
|
|
900
|
-
DndContext,
|
|
901
|
-
closestCenter,
|
|
902
|
-
KeyboardSensor,
|
|
903
|
-
PointerSensor,
|
|
904
|
-
useSensor,
|
|
905
|
-
useSensors,
|
|
906
|
-
type DragEndEvent,
|
|
907
|
-
} from "@dnd-kit/core";
|
|
908
|
-
import {
|
|
909
|
-
arrayMove,
|
|
910
|
-
SortableContext,
|
|
911
|
-
sortableKeyboardCoordinates,
|
|
912
|
-
useSortable,
|
|
913
|
-
verticalListSortingStrategy,
|
|
914
|
-
} from "@dnd-kit/sortable";
|
|
915
|
-
import { CSS } from "@dnd-kit/utilities";
|
|
916
|
-
|
|
917
|
-
type Todo = {
|
|
918
|
-
id: string;
|
|
919
|
-
title: string;
|
|
920
|
-
done: boolean;
|
|
921
|
-
createdAt: string;
|
|
922
|
-
position?: 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
|
-
const [todos, setTodos] = useState(initialTodos);
|
|
933
|
-
const [title, setTitle] = useState("");
|
|
934
|
-
const [pending, startTransition] = useTransition();
|
|
935
|
-
const sensors = useSensors(
|
|
936
|
-
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
937
|
-
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
938
|
-
);
|
|
939
|
-
|
|
940
|
-
async function add() {
|
|
941
|
-
if (!title.trim()) return;
|
|
942
|
-
const newTitle = title;
|
|
943
|
-
setTitle("");
|
|
944
|
-
startTransition(async () => {
|
|
945
|
-
const res = await fetch("/api/fn/addTodo", {
|
|
946
|
-
method: "POST",
|
|
947
|
-
headers: { "Content-Type": "application/json" },
|
|
948
|
-
body: JSON.stringify({ title: newTitle }),
|
|
949
|
-
});
|
|
950
|
-
if (res.ok) {
|
|
951
|
-
const todo = (await res.json()) as Todo;
|
|
952
|
-
setTodos((prev) => [...prev, todo]);
|
|
953
|
-
}
|
|
954
|
-
});
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
async function toggle(t: Todo) {
|
|
958
|
-
const next = !t.done;
|
|
959
|
-
setTodos((prev) =>
|
|
960
|
-
prev.map((row) => (row.id === t.id ? { ...row, done: next } : row)),
|
|
961
|
-
);
|
|
962
|
-
startTransition(async () => {
|
|
963
|
-
const res = await fetch("/api/fn/toggleTodo", {
|
|
964
|
-
method: "POST",
|
|
965
|
-
headers: { "Content-Type": "application/json" },
|
|
966
|
-
body: JSON.stringify({ id: t.id, done: next }),
|
|
967
|
-
});
|
|
968
|
-
if (!res.ok) {
|
|
969
|
-
setTodos((prev) =>
|
|
970
|
-
prev.map((row) => (row.id === t.id ? { ...row, done: t.done } : row)),
|
|
971
|
-
);
|
|
972
|
-
}
|
|
973
|
-
});
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
async function remove(t: Todo) {
|
|
977
|
-
const snapshot = todos;
|
|
978
|
-
setTodos((prev) => prev.filter((row) => row.id !== t.id));
|
|
979
|
-
startTransition(async () => {
|
|
980
|
-
const res = await fetch("/api/fn/deleteTodo", {
|
|
981
|
-
method: "POST",
|
|
982
|
-
headers: { "Content-Type": "application/json" },
|
|
983
|
-
body: JSON.stringify({ id: t.id }),
|
|
984
|
-
});
|
|
985
|
-
if (!res.ok) setTodos(snapshot);
|
|
986
|
-
});
|
|
987
|
-
}
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// Optional: install dependencies
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
988
389
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
390
|
+
if (!flags.skipInstall) {
|
|
391
|
+
console.log(`Installing dependencies with ${flags.pm}...`);
|
|
392
|
+
const { spawnSync } = await import("node:child_process");
|
|
393
|
+
const result = spawnSync(flags.pm, ["install"], {
|
|
394
|
+
cwd: root,
|
|
395
|
+
stdio: "inherit",
|
|
396
|
+
});
|
|
397
|
+
if (result.status !== 0) {
|
|
398
|
+
console.warn(
|
|
399
|
+
`\n${flags.pm} install exited with code ${result.status}. Re-run from ${projectName}/.\n`,
|
|
994
400
|
);
|
|
995
|
-
startTransition(async () => {
|
|
996
|
-
const res = await fetch("/api/fn/editTodo", {
|
|
997
|
-
method: "POST",
|
|
998
|
-
headers: { "Content-Type": "application/json" },
|
|
999
|
-
body: JSON.stringify({ id: t.id, title: trimmed }),
|
|
1000
|
-
});
|
|
1001
|
-
if (!res.ok) {
|
|
1002
|
-
setTodos((prev) =>
|
|
1003
|
-
prev.map((row) => (row.id === t.id ? { ...row, title: t.title } : row)),
|
|
1004
|
-
);
|
|
1005
|
-
}
|
|
1006
|
-
});
|
|
1007
401
|
}
|
|
402
|
+
}
|
|
1008
403
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
const oldIndex = todos.findIndex((t) => t.id === active.id);
|
|
1013
|
-
const newIndex = todos.findIndex((t) => t.id === over.id);
|
|
1014
|
-
if (oldIndex < 0 || newIndex < 0) return;
|
|
1015
|
-
const reordered = arrayMove(todos, oldIndex, newIndex);
|
|
1016
|
-
setTodos(reordered);
|
|
1017
|
-
const prev = reordered[newIndex - 1];
|
|
1018
|
-
const next = reordered[newIndex + 1];
|
|
1019
|
-
const prevPos = prev?.position ?? Date.parse(prev?.createdAt ?? "") ?? 0;
|
|
1020
|
-
const nextPos = next?.position ?? Date.parse(next?.createdAt ?? "") ?? 0;
|
|
1021
|
-
let position: number;
|
|
1022
|
-
if (prev && next) position = (prevPos + nextPos) / 2;
|
|
1023
|
-
else if (prev) position = prevPos + 1024;
|
|
1024
|
-
else if (next) position = nextPos - 1024;
|
|
1025
|
-
else position = 1024;
|
|
1026
|
-
const movedId = String(active.id);
|
|
1027
|
-
const snapshot = todos;
|
|
1028
|
-
startTransition(async () => {
|
|
1029
|
-
const res = await fetch("/api/fn/reorderTodo", {
|
|
1030
|
-
method: "POST",
|
|
1031
|
-
headers: { "Content-Type": "application/json" },
|
|
1032
|
-
body: JSON.stringify({ id: movedId, position }),
|
|
1033
|
-
});
|
|
1034
|
-
if (!res.ok) setTodos(snapshot);
|
|
1035
|
-
});
|
|
1036
|
-
}
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// Final instructions
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
1037
407
|
|
|
1038
|
-
|
|
1039
|
-
<div className="space-y-4">
|
|
1040
|
-
<form
|
|
1041
|
-
onSubmit={(e) => {
|
|
1042
|
-
e.preventDefault();
|
|
1043
|
-
add();
|
|
1044
|
-
}}
|
|
1045
|
-
className="flex gap-2"
|
|
1046
|
-
>
|
|
1047
|
-
<Input
|
|
1048
|
-
value={title}
|
|
1049
|
-
onChange={(e) => setTitle(e.target.value)}
|
|
1050
|
-
placeholder="What needs doing?"
|
|
1051
|
-
disabled={pending}
|
|
1052
|
-
className="flex-1"
|
|
1053
|
-
/>
|
|
1054
|
-
<Button
|
|
1055
|
-
type="submit"
|
|
1056
|
-
variant="primary"
|
|
1057
|
-
disabled={pending || !title.trim()}
|
|
1058
|
-
>
|
|
1059
|
-
Add
|
|
1060
|
-
</Button>
|
|
1061
|
-
</form>
|
|
408
|
+
const runDev = flags.pm === "npm" ? "npm run dev" : `${flags.pm} run dev`;
|
|
1062
409
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
onRename={(next) => rename(t, next)}
|
|
1079
|
-
/>
|
|
1080
|
-
))}
|
|
1081
|
-
</ul>
|
|
1082
|
-
</SortableContext>
|
|
1083
|
-
</DndContext>
|
|
1084
|
-
)}
|
|
1085
|
-
</div>
|
|
1086
|
-
);
|
|
410
|
+
const platformLines = [];
|
|
411
|
+
platformLines.push(" → api http://localhost:4321 (Pylon control plane)");
|
|
412
|
+
if (platforms.includes("web"))
|
|
413
|
+
platformLines.push(" → web http://localhost:3000 (Next.js)");
|
|
414
|
+
if (platforms.includes("expo"))
|
|
415
|
+
platformLines.push(` → expo ${flags.pm} run dev:expo (Metro + simulator)`);
|
|
416
|
+
if (platforms.includes("ios"))
|
|
417
|
+
platformLines.push(` → ios cd apps/ios && xcodegen generate && open *.xcodeproj`);
|
|
418
|
+
if (platforms.includes("mac"))
|
|
419
|
+
platformLines.push(` → mac cd apps/mac && swift run (or xcodegen for .app)`);
|
|
420
|
+
|
|
421
|
+
const layoutLines = [" apps/api schema + functions/ handlers"];
|
|
422
|
+
if (platforms.includes("web")) {
|
|
423
|
+
layoutLines.push(" apps/web Next.js 16 + React 19 + Tailwind v4");
|
|
424
|
+
layoutLines.push(" packages/ui shared UI primitives");
|
|
1087
425
|
}
|
|
426
|
+
if (platforms.includes("ios"))
|
|
427
|
+
layoutLines.push(" apps/ios Swift / SwiftUI (iOS)");
|
|
428
|
+
if (platforms.includes("mac"))
|
|
429
|
+
layoutLines.push(" apps/mac Swift / SwiftUI (macOS)");
|
|
430
|
+
if (platforms.includes("expo"))
|
|
431
|
+
layoutLines.push(" apps/expo Expo + React Native");
|
|
1088
432
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
pending: boolean;
|
|
1092
|
-
onToggle: () => void;
|
|
1093
|
-
onRemove: () => void;
|
|
1094
|
-
onRename: (next: string) => void;
|
|
1095
|
-
}) {
|
|
1096
|
-
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
|
1097
|
-
useSortable({ id: todo.id });
|
|
1098
|
-
const style = {
|
|
1099
|
-
transform: CSS.Transform.toString(transform),
|
|
1100
|
-
transition,
|
|
1101
|
-
opacity: isDragging ? 0.4 : 1,
|
|
1102
|
-
};
|
|
1103
|
-
const [editing, setEditing] = useState(false);
|
|
1104
|
-
const [draft, setDraft] = useState(todo.title);
|
|
1105
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
433
|
+
console.log(`
|
|
434
|
+
✓ Created ${projectName}
|
|
1106
435
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
setDraft(todo.title);
|
|
1110
|
-
requestAnimationFrame(() => {
|
|
1111
|
-
inputRef.current?.focus();
|
|
1112
|
-
inputRef.current?.select();
|
|
1113
|
-
});
|
|
1114
|
-
}
|
|
1115
|
-
}, [editing, todo.title]);
|
|
436
|
+
cd ${projectName}
|
|
437
|
+
${runDev}
|
|
1116
438
|
|
|
1117
|
-
|
|
1118
|
-
setEditing(false);
|
|
1119
|
-
onRename(draft);
|
|
1120
|
-
}
|
|
439
|
+
${platformLines.join("\n")}
|
|
1121
440
|
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
ref={setNodeRef}
|
|
1125
|
-
style={style}
|
|
1126
|
-
className="flex items-center gap-3 px-4 py-3 text-sm group bg-white dark:bg-neutral-950"
|
|
1127
|
-
>
|
|
1128
|
-
<button
|
|
1129
|
-
type="button"
|
|
1130
|
-
{...attributes}
|
|
1131
|
-
{...listeners}
|
|
1132
|
-
className="cursor-grab active:cursor-grabbing text-neutral-300 hover:text-neutral-500 select-none touch-none"
|
|
1133
|
-
aria-label="Drag to reorder"
|
|
1134
|
-
tabIndex={-1}
|
|
1135
|
-
>
|
|
1136
|
-
⋮⋮
|
|
1137
|
-
</button>
|
|
1138
|
-
<input
|
|
1139
|
-
type="checkbox"
|
|
1140
|
-
checked={todo.done}
|
|
1141
|
-
onChange={onToggle}
|
|
1142
|
-
disabled={pending}
|
|
1143
|
-
className="size-4 cursor-pointer"
|
|
1144
|
-
aria-label={\\\`Mark "\${todo.title}" as \${todo.done ? "not done" : "done"}\\\`}
|
|
1145
|
-
/>
|
|
1146
|
-
{editing ? (
|
|
1147
|
-
<input
|
|
1148
|
-
ref={inputRef}
|
|
1149
|
-
value={draft}
|
|
1150
|
-
onChange={(e) => setDraft(e.target.value)}
|
|
1151
|
-
onBlur={commit}
|
|
1152
|
-
onKeyDown={(e) => {
|
|
1153
|
-
if (e.key === "Enter") commit();
|
|
1154
|
-
else if (e.key === "Escape") {
|
|
1155
|
-
setEditing(false);
|
|
1156
|
-
setDraft(todo.title);
|
|
1157
|
-
}
|
|
1158
|
-
}}
|
|
1159
|
-
className="flex-1 bg-transparent border-b border-neutral-300 dark:border-neutral-700 outline-none text-sm"
|
|
1160
|
-
aria-label="Edit title"
|
|
1161
|
-
/>
|
|
1162
|
-
) : (
|
|
1163
|
-
<button
|
|
1164
|
-
type="button"
|
|
1165
|
-
onDoubleClick={() => setEditing(true)}
|
|
1166
|
-
className={\\\`flex-1 text-left \${todo.done ? "line-through text-neutral-400" : ""}\\\`}
|
|
1167
|
-
title="Double-click to edit"
|
|
1168
|
-
>
|
|
1169
|
-
{todo.title}
|
|
1170
|
-
</button>
|
|
1171
|
-
)}
|
|
1172
|
-
<button
|
|
1173
|
-
type="button"
|
|
1174
|
-
onClick={() => setEditing(true)}
|
|
1175
|
-
className="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"
|
|
1176
|
-
aria-label={\\\`Edit "\${todo.title}"\\\`}
|
|
1177
|
-
>
|
|
1178
|
-
Edit
|
|
1179
|
-
</button>
|
|
1180
|
-
<button
|
|
1181
|
-
type="button"
|
|
1182
|
-
onClick={onRemove}
|
|
1183
|
-
disabled={pending}
|
|
1184
|
-
className="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-neutral-500 hover:text-red-500"
|
|
1185
|
-
aria-label={\\\`Delete "\${todo.title}"\\\`}
|
|
1186
|
-
>
|
|
1187
|
-
Delete
|
|
1188
|
-
</button>
|
|
1189
|
-
</li>
|
|
1190
|
-
);
|
|
1191
|
-
}
|
|
1192
|
-
`,
|
|
1193
|
-
);
|
|
441
|
+
Layout:
|
|
442
|
+
${layoutLines.join("\n")}
|
|
1194
443
|
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
`/// <reference types="next" />
|
|
1198
|
-
/// <reference types="next/image-types/global" />
|
|
1199
|
-
`,
|
|
1200
|
-
);
|
|
444
|
+
Docs: https://pylonsync.com/docs
|
|
445
|
+
`);
|
|
1201
446
|
|
|
1202
447
|
// ---------------------------------------------------------------------------
|
|
1203
|
-
//
|
|
448
|
+
// Helpers
|
|
1204
449
|
// ---------------------------------------------------------------------------
|
|
1205
450
|
|
|
1206
451
|
function detectPackageManager() {
|
|
@@ -1212,36 +457,27 @@ function detectPackageManager() {
|
|
|
1212
457
|
return null;
|
|
1213
458
|
}
|
|
1214
459
|
|
|
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
460
|
function pmScripts(pm) {
|
|
1228
461
|
switch (pm) {
|
|
1229
462
|
case "bun":
|
|
1230
463
|
return {
|
|
1231
464
|
devApi: "bun run --filter './apps/api' dev",
|
|
1232
465
|
devWeb: "bun run --filter './apps/web' dev",
|
|
466
|
+
devExpo: "bun run --filter './apps/expo' start",
|
|
1233
467
|
build: "bun run --filter '*' build",
|
|
1234
468
|
};
|
|
1235
469
|
case "pnpm":
|
|
1236
470
|
return {
|
|
1237
471
|
devApi: "pnpm --filter './apps/api' run dev",
|
|
1238
472
|
devWeb: "pnpm --filter './apps/web' run dev",
|
|
473
|
+
devExpo: "pnpm --filter './apps/expo' run start",
|
|
1239
474
|
build: "pnpm --filter '*' run build",
|
|
1240
475
|
};
|
|
1241
476
|
case "yarn":
|
|
1242
477
|
return {
|
|
1243
|
-
devApi: `yarn workspace @${
|
|
1244
|
-
devWeb: `yarn workspace @${
|
|
478
|
+
devApi: `yarn workspace @${APP_NAME_KEBAB}/api run dev`,
|
|
479
|
+
devWeb: `yarn workspace @${APP_NAME_KEBAB}/web run dev`,
|
|
480
|
+
devExpo: `yarn workspace @${APP_NAME_KEBAB}/expo run start`,
|
|
1245
481
|
build: "yarn workspaces foreach -A run build",
|
|
1246
482
|
};
|
|
1247
483
|
case "npm":
|
|
@@ -1249,53 +485,8 @@ function pmScripts(pm) {
|
|
|
1249
485
|
return {
|
|
1250
486
|
devApi: "npm --workspace apps/api run dev",
|
|
1251
487
|
devWeb: "npm --workspace apps/web run dev",
|
|
488
|
+
devExpo: "npm --workspace apps/expo run start",
|
|
1252
489
|
build: "npm --workspaces run build --if-present",
|
|
1253
490
|
};
|
|
1254
491
|
}
|
|
1255
492
|
}
|
|
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
|
-
`);
|