@pylonsync/create-pylon 0.3.51 → 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 +290 -1155
- 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.
|
|
26
52
|
// ---------------------------------------------------------------------------
|
|
27
53
|
|
|
28
|
-
const PYLON_VERSION =
|
|
54
|
+
const PYLON_VERSION = JSON.parse(
|
|
55
|
+
readFileSync(resolve(HERE, "..", "package.json"), "utf8"),
|
|
56
|
+
).version;
|
|
29
57
|
|
|
30
58
|
// ---------------------------------------------------------------------------
|
|
31
|
-
//
|
|
59
|
+
// Templates + platforms registry
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
const TEMPLATES_AVAILABLE = ["barebones", "todo"];
|
|
63
|
+
const PLATFORMS_AVAILABLE = ["web", "mobile", "expo"];
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
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
|
-
//
|
|
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.
|
|
215
196
|
// ---------------------------------------------------------------------------
|
|
216
197
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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.
|
|
315
|
-
// ---------------------------------------------------------------------------
|
|
316
|
-
|
|
317
|
-
const todoPolicy = policy({
|
|
318
|
-
name: "todo_open",
|
|
319
|
-
entity: "Todo",
|
|
320
|
-
allowRead: "true",
|
|
321
|
-
allowInsert: "true",
|
|
322
|
-
allowUpdate: "true",
|
|
323
|
-
allowDelete: "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
|
-
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";
|
|
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
|
+
};
|
|
350
213
|
|
|
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
|
-
);
|
|
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
|
+
}
|
|
375
224
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
+
}
|
|
379
236
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
throw ctx.error("EMPTY_TITLE", "title cannot be empty");
|
|
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);
|
|
389
245
|
}
|
|
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";
|
|
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
|
-
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
|
-
);
|
|
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
|
-
});
|
|
531
|
-
|
|
532
|
-
write(
|
|
533
|
-
"packages/ui/src/cn.ts",
|
|
534
|
-
`import { clsx, type ClassValue } from "clsx";
|
|
535
|
-
import { twMerge } from "tailwind-merge";
|
|
272
|
+
copyTemplate("_root");
|
|
273
|
+
copyTemplate(`backend/${flags.template}`);
|
|
536
274
|
|
|
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));
|
|
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
|
-
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;
|
|
279
|
+
if (platforms.includes("mobile")) {
|
|
280
|
+
copyTemplate(`mobile/${flags.template}`);
|
|
575
281
|
}
|
|
576
|
-
|
|
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
|
-
);
|
|
282
|
+
if (platforms.includes("expo")) {
|
|
283
|
+
copyTemplate(`expo/${flags.template}`);
|
|
594
284
|
}
|
|
595
|
-
`,
|
|
596
|
-
);
|
|
597
285
|
|
|
598
|
-
|
|
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
|
-
);
|
|
286
|
+
walkAndSubstitute(root);
|
|
621
287
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
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
|
-
);
|
|
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
|
-
return (
|
|
647
|
-
<div className={cn("p-5 border-b border-neutral-200 dark:border-neutral-800", className)} {...props} />
|
|
648
|
-
);
|
|
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/*"] },
|
|
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
|
-
];
|
|
327
|
+
dev:
|
|
328
|
+
parallelDevs.length > 1
|
|
329
|
+
? `npm-run-all --parallel ${parallelDevs.join(" ")}`
|
|
330
|
+
: wsScripts.devApi,
|
|
331
|
+
...devScripts,
|
|
332
|
+
build: wsScripts.build,
|
|
767
333
|
},
|
|
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
|
-
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
|
-
`,
|
|
336
|
+
writeFileSync(
|
|
337
|
+
join(root, "package.json"),
|
|
338
|
+
JSON.stringify(rootPkg, null, 2) + "\n",
|
|
890
339
|
);
|
|
891
340
|
|
|
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
|
-
}
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Optional: install dependencies
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
988
344
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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`,
|
|
994
355
|
);
|
|
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
356
|
}
|
|
357
|
+
}
|
|
1008
358
|
|
|
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
|
-
}
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// Final instructions
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
1037
362
|
|
|
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>
|
|
363
|
+
const runDev = flags.pm === "npm" ? "npm run dev" : `${flags.pm} run dev`;
|
|
1062
364
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
{todos.map((t) => (
|
|
1072
|
-
<SortableRow
|
|
1073
|
-
key={t.id}
|
|
1074
|
-
todo={t}
|
|
1075
|
-
pending={pending}
|
|
1076
|
-
onToggle={() => toggle(t)}
|
|
1077
|
-
onRemove={() => remove(t)}
|
|
1078
|
-
onRename={(next) => rename(t, next)}
|
|
1079
|
-
/>
|
|
1080
|
-
))}
|
|
1081
|
-
</ul>
|
|
1082
|
-
</SortableContext>
|
|
1083
|
-
</DndContext>
|
|
1084
|
-
)}
|
|
1085
|
-
</div>
|
|
1086
|
-
);
|
|
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
|
-
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);
|
|
374
|
+
console.log(`
|
|
375
|
+
✓ Created ${projectName}
|
|
1106
376
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
setDraft(todo.title);
|
|
1110
|
-
requestAnimationFrame(() => {
|
|
1111
|
-
inputRef.current?.focus();
|
|
1112
|
-
inputRef.current?.select();
|
|
1113
|
-
});
|
|
1114
|
-
}
|
|
1115
|
-
}, [editing, todo.title]);
|
|
377
|
+
cd ${projectName}
|
|
378
|
+
${runDev}
|
|
1116
379
|
|
|
1117
|
-
|
|
1118
|
-
setEditing(false);
|
|
1119
|
-
onRename(draft);
|
|
1120
|
-
}
|
|
380
|
+
${platformLines.join("\n")}
|
|
1121
381
|
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
-
);
|
|
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
|
-
`);
|