@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.
- package/bin/create-pylon.js +292 -1157
- package/package.json +4 -3
- package/templates/_root/.env.example +9 -0
- package/templates/_root/README.md +43 -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/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/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/mobile/barebones/apps/mobile/Package.swift +34 -0
- package/templates/mobile/barebones/apps/mobile/Sources/__APP_NAME_PASCAL__/ContentView.swift +98 -0
- package/templates/mobile/barebones/apps/mobile/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
- package/templates/mobile/barebones/apps/mobile/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +34 -0
- package/templates/mobile/barebones/apps/mobile/project.yml +42 -0
- package/templates/mobile/todo/apps/mobile/Package.swift +23 -0
- package/templates/mobile/todo/apps/mobile/Sources/__APP_NAME_PASCAL__/Models.swift +18 -0
- package/templates/mobile/todo/apps/mobile/Sources/__APP_NAME_PASCAL__/TodoListView.swift +230 -0
- package/templates/mobile/todo/apps/mobile/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +28 -0
- package/templates/mobile/todo/apps/mobile/project.yml +32 -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/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/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,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
|
|
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.
|
|
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
|
|
62
|
+
const TEMPLATES_AVAILABLE = ["barebones", "todo"];
|
|
63
|
+
const PLATFORMS_AVAILABLE = ["web", "mobile", "expo"];
|
|
29
64
|
|
|
30
65
|
// ---------------------------------------------------------------------------
|
|
31
|
-
// CLI args + interactive
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
});
|
|
272
|
+
copyTemplate("_root");
|
|
273
|
+
copyTemplate(`backend/${flags.template}`);
|
|
531
274
|
|
|
532
|
-
|
|
533
|
-
"
|
|
534
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
286
|
+
walkAndSubstitute(root);
|
|
604
287
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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:
|
|
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/*"] },
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
-
|
|
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
|
-
\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
|
-
|
|
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
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
1108
|
-
|
|
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
|
-
\
|
|
1118
|
-
\t\tsetEditing(false);
|
|
1119
|
-
\t\tonRename(draft);
|
|
1120
|
-
\t}
|
|
380
|
+
${platformLines.join("\n")}
|
|
1121
381
|
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
\
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
-
|
|
1196
|
-
|
|
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
|
-
//
|
|
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 @${
|
|
1244
|
-
devWeb: `yarn workspace @${
|
|
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
|
-
`);
|