create-fiyuu-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +194 -0
- package/bin/create-fiyuu-app.mjs +1770 -0
- package/package.json +8 -0
|
@@ -0,0 +1,1770 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
4
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import readline from "node:readline/promises";
|
|
8
|
+
import { emitKeypressEvents } from "node:readline";
|
|
9
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
10
|
+
|
|
11
|
+
const useColor = output.isTTY;
|
|
12
|
+
const ui = {
|
|
13
|
+
reset: color(0),
|
|
14
|
+
olive: color(38, 5, 65),
|
|
15
|
+
moss: color(38, 5, 71),
|
|
16
|
+
cream: color(38, 5, 230),
|
|
17
|
+
muted: color(38, 5, 245),
|
|
18
|
+
success: color(38, 5, 78),
|
|
19
|
+
border: color(38, 5, 101),
|
|
20
|
+
bold: color(1),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const [, , projectName, ...flags] = process.argv;
|
|
24
|
+
|
|
25
|
+
if (!projectName) {
|
|
26
|
+
console.error("Usage: create-fiyuu-app <project-name> [--local] [--yes]");
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const useLocal = flags.includes("--local");
|
|
31
|
+
const useDefaults = flags.includes("--yes");
|
|
32
|
+
const currentDirectory = process.cwd();
|
|
33
|
+
const targetDirectory = path.resolve(currentDirectory, projectName);
|
|
34
|
+
const packageName = toPackageName(projectName);
|
|
35
|
+
|
|
36
|
+
if (existsSync(targetDirectory)) {
|
|
37
|
+
console.error(`Target directory already exists: ${targetDirectory}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const frameworkRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
42
|
+
const dependencyVersion = resolveFrameworkDependency(frameworkRoot, useLocal);
|
|
43
|
+
const answers = await collectAnswers(useDefaults);
|
|
44
|
+
|
|
45
|
+
await mkdir(targetDirectory, { recursive: true });
|
|
46
|
+
await createProject(targetDirectory, packageName, dependencyVersion, answers);
|
|
47
|
+
|
|
48
|
+
renderSuccess(packageName, targetDirectory, answers);
|
|
49
|
+
|
|
50
|
+
async function collectAnswers(useDefaultsFlag) {
|
|
51
|
+
const defaults = {
|
|
52
|
+
sockets: false,
|
|
53
|
+
database: false,
|
|
54
|
+
encryption: true,
|
|
55
|
+
skills: true,
|
|
56
|
+
selectedSkills: ["product-strategist", "seo-optimizer"],
|
|
57
|
+
theming: true,
|
|
58
|
+
authHints: false,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (useDefaultsFlag) {
|
|
62
|
+
return defaults;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const rl = readline.createInterface({ input, output });
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
renderSetupIntro();
|
|
69
|
+
console.log(`${ui.bold}${ui.moss}Features (space separated)${ui.reset}`);
|
|
70
|
+
console.log(`${ui.muted}Type one or more keys, then Enter. Example: f1 socket${ui.reset}`);
|
|
71
|
+
|
|
72
|
+
const result = {
|
|
73
|
+
sockets: defaults.sockets,
|
|
74
|
+
database: defaults.database,
|
|
75
|
+
authHints: defaults.authHints,
|
|
76
|
+
encryption: defaults.encryption,
|
|
77
|
+
skills: defaults.skills,
|
|
78
|
+
selectedSkills: [...defaults.selectedSkills],
|
|
79
|
+
theming: defaults.theming,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const selectedFeatures = await askInteractiveSelect("Enable optional features", [
|
|
83
|
+
{ key: "f1", label: "F1 database" },
|
|
84
|
+
{ key: "socket", label: "Socket route" },
|
|
85
|
+
{ key: "auth", label: "Auth starter" },
|
|
86
|
+
{ key: "encryption", label: "Request encryption" },
|
|
87
|
+
{ key: "theme", label: "Integrated theme toggle" },
|
|
88
|
+
], [
|
|
89
|
+
...(defaults.encryption ? ["encryption"] : []),
|
|
90
|
+
...(defaults.theming ? ["theme"] : []),
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
result.database = selectedFeatures.has("f1");
|
|
94
|
+
result.sockets = selectedFeatures.has("socket");
|
|
95
|
+
result.authHints = selectedFeatures.has("auth");
|
|
96
|
+
result.encryption = selectedFeatures.has("encryption");
|
|
97
|
+
result.theming = selectedFeatures.has("theme");
|
|
98
|
+
|
|
99
|
+
console.log("");
|
|
100
|
+
console.log(`${ui.bold}${ui.moss}AI Skills (space separated)${ui.reset}`);
|
|
101
|
+
const selectedSkills = await askInteractiveSelect("Choose starter skills", [
|
|
102
|
+
{ key: "product-strategist", label: "Product strategist" },
|
|
103
|
+
{ key: "support-triage", label: "Support triage" },
|
|
104
|
+
{ key: "seo-optimizer", label: "SEO optimizer" },
|
|
105
|
+
], defaults.selectedSkills);
|
|
106
|
+
result.selectedSkills = [...selectedSkills];
|
|
107
|
+
result.skills = result.selectedSkills.length > 0;
|
|
108
|
+
|
|
109
|
+
renderAnswerSummary(result);
|
|
110
|
+
return result;
|
|
111
|
+
} finally {
|
|
112
|
+
rl.close();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function askMultiSelect(rl, question, options, defaultKeys = []) {
|
|
117
|
+
const optionsText = options
|
|
118
|
+
.map((option) => `${ui.cream}${option.key}${ui.reset}=${ui.muted}${option.label}${ui.reset}`)
|
|
119
|
+
.join(` ${ui.border}|${ui.reset} `);
|
|
120
|
+
const defaultText = defaultKeys.length > 0 ? defaultKeys.join(" ") : "none";
|
|
121
|
+
const prompt = `${ui.border}•${ui.reset} ${ui.cream}${question}${ui.reset}\n ${optionsText}\n ${ui.muted}default: ${defaultText}${ui.reset}\n > `;
|
|
122
|
+
const answer = (await rl.question(prompt)).trim().toLowerCase();
|
|
123
|
+
const tokens = answer.length === 0 ? [...defaultKeys] : answer.split(/\s+/g).filter(Boolean);
|
|
124
|
+
const valid = new Set(options.map((option) => option.key));
|
|
125
|
+
const selected = new Set(tokens.filter((token) => valid.has(token)));
|
|
126
|
+
return selected;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function askInteractiveSelect(question, options, defaultKeys = []) {
|
|
130
|
+
if (!input.isTTY || !output.isTTY) {
|
|
131
|
+
const fakeRl = readline.createInterface({ input, output });
|
|
132
|
+
try {
|
|
133
|
+
return await askMultiSelect(fakeRl, question, options, defaultKeys);
|
|
134
|
+
} finally {
|
|
135
|
+
fakeRl.close();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
emitKeypressEvents(input);
|
|
140
|
+
if (typeof input.setRawMode === "function") {
|
|
141
|
+
input.setRawMode(true);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let index = 0;
|
|
145
|
+
const selected = new Set(defaultKeys);
|
|
146
|
+
|
|
147
|
+
return await new Promise((resolve) => {
|
|
148
|
+
function render() {
|
|
149
|
+
output.write("\x1Bc");
|
|
150
|
+
console.log("");
|
|
151
|
+
console.log(`${ui.bold}${ui.olive}Fiyuu Setup${ui.reset}`);
|
|
152
|
+
console.log(`${ui.border}${"─".repeat(44)}${ui.reset}`);
|
|
153
|
+
console.log(`${ui.bold}${ui.moss}${question}${ui.reset}`);
|
|
154
|
+
console.log(`${ui.muted}Use ↑/↓ to move, space to toggle, enter to confirm.${ui.reset}`);
|
|
155
|
+
console.log("");
|
|
156
|
+
options.forEach((option, optionIndex) => {
|
|
157
|
+
const pointer = optionIndex === index ? `${ui.moss}›${ui.reset}` : " ";
|
|
158
|
+
const mark = selected.has(option.key) ? `${ui.success}[x]${ui.reset}` : `${ui.muted}[ ]${ui.reset}`;
|
|
159
|
+
console.log(`${pointer} ${mark} ${ui.cream}${option.key}${ui.reset} ${ui.muted}- ${option.label}${ui.reset}`);
|
|
160
|
+
});
|
|
161
|
+
console.log("");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function cleanup() {
|
|
165
|
+
input.off("keypress", onKeypress);
|
|
166
|
+
if (typeof input.setRawMode === "function") {
|
|
167
|
+
input.setRawMode(false);
|
|
168
|
+
}
|
|
169
|
+
console.log("");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function onKeypress(_str, key) {
|
|
173
|
+
if (key?.name === "up") {
|
|
174
|
+
index = (index - 1 + options.length) % options.length;
|
|
175
|
+
render();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (key?.name === "down") {
|
|
179
|
+
index = (index + 1) % options.length;
|
|
180
|
+
render();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (key?.name === "space") {
|
|
184
|
+
const current = options[index];
|
|
185
|
+
if (current) {
|
|
186
|
+
if (selected.has(current.key)) {
|
|
187
|
+
selected.delete(current.key);
|
|
188
|
+
} else {
|
|
189
|
+
selected.add(current.key);
|
|
190
|
+
}
|
|
191
|
+
render();
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (key?.name === "return") {
|
|
196
|
+
cleanup();
|
|
197
|
+
resolve(new Set(selected));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (key?.ctrl && key?.name === "c") {
|
|
201
|
+
cleanup();
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
render();
|
|
207
|
+
input.on("keypress", onKeypress);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function renderSetupIntro() {
|
|
212
|
+
console.log("");
|
|
213
|
+
console.log(`${ui.bold}${ui.olive}Fiyuu Setup${ui.reset}`);
|
|
214
|
+
console.log(`${ui.border}${"─".repeat(44)}${ui.reset}`);
|
|
215
|
+
console.log(`${ui.muted}Shape your AI-first fullstack starter in a few steps.${ui.reset}`);
|
|
216
|
+
console.log("");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function renderAnswerSummary(answers) {
|
|
220
|
+
const entries = [
|
|
221
|
+
["One-page home", true],
|
|
222
|
+
["Extra routes", false],
|
|
223
|
+
["F1 Database", answers.database],
|
|
224
|
+
["Sockets", answers.sockets],
|
|
225
|
+
["Auth", answers.authHints],
|
|
226
|
+
["Request Security", answers.encryption],
|
|
227
|
+
["Theme", answers.theming],
|
|
228
|
+
["AI Skills", answers.skills],
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
console.log("");
|
|
232
|
+
console.log(`${ui.bold}${ui.olive}Starter Profile${ui.reset}`);
|
|
233
|
+
for (const [label, enabled] of entries) {
|
|
234
|
+
console.log(`${ui.border}•${ui.reset} ${ui.cream}${label}${ui.reset} ${enabled ? `${ui.success}enabled${ui.reset}` : `${ui.muted}disabled${ui.reset}`}`);
|
|
235
|
+
}
|
|
236
|
+
console.log(`${ui.border}•${ui.reset} ${ui.cream}Skill set${ui.reset} ${ui.muted}${answers.selectedSkills.join(", ") || "none"}${ui.reset}`);
|
|
237
|
+
console.log("");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function renderSuccess(packageName, targetDirectory, answers) {
|
|
241
|
+
console.log(`${ui.bold}${ui.success}Created ${packageName}${ui.reset} ${ui.muted}at${ui.reset} ${ui.cream}${targetDirectory}${ui.reset}`);
|
|
242
|
+
console.log("");
|
|
243
|
+
console.log(`${ui.bold}${ui.olive}Next steps${ui.reset}`);
|
|
244
|
+
console.log(`${ui.border}1.${ui.reset} ${ui.cream}cd ${targetDirectory}${ui.reset}`);
|
|
245
|
+
console.log(`${ui.border}2.${ui.reset} ${ui.cream}npm install${ui.reset}`);
|
|
246
|
+
console.log(`${ui.border}3.${ui.reset} ${ui.cream}npm run dev${ui.reset}`);
|
|
247
|
+
console.log("");
|
|
248
|
+
console.log(`${ui.bold}${ui.olive}Selected${ui.reset}`);
|
|
249
|
+
console.log(`${ui.border}•${ui.reset} ${ui.muted}One-page home:${ui.reset} ${ui.success}on${ui.reset}`);
|
|
250
|
+
console.log(`${ui.border}•${ui.reset} ${ui.muted}Extra routes:${ui.reset} ${ui.muted}off${ui.reset}`);
|
|
251
|
+
console.log(`${ui.border}•${ui.reset} ${ui.muted}F1:${ui.reset} ${answers.database ? `${ui.success}on${ui.reset}` : `${ui.muted}off${ui.reset}`}`);
|
|
252
|
+
console.log(`${ui.border}•${ui.reset} ${ui.muted}Socket:${ui.reset} ${answers.sockets ? `${ui.success}on${ui.reset}` : `${ui.muted}off${ui.reset}`}`);
|
|
253
|
+
console.log(`${ui.border}•${ui.reset} ${ui.muted}Theme:${ui.reset} ${answers.theming ? `${ui.success}on${ui.reset}` : `${ui.muted}off${ui.reset}`}`);
|
|
254
|
+
console.log(`${ui.border}•${ui.reset} ${ui.muted}AI:${ui.reset} ${answers.skills ? `${ui.success}on${ui.reset}` : `${ui.muted}off${ui.reset}`}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function color(...codes) {
|
|
258
|
+
return useColor ? `\u001b[${codes.join(";")}m` : "";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function resolveFrameworkDependency(frameworkRoot, useLocalFlag) {
|
|
262
|
+
const packageJsonPath = path.join(frameworkRoot, "package.json");
|
|
263
|
+
const canUseLocal = useLocalFlag || existsSync(packageJsonPath);
|
|
264
|
+
|
|
265
|
+
if (!canUseLocal) {
|
|
266
|
+
return "latest";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return `file:${realpathSync(frameworkRoot).split(path.sep).join("/")}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function createProject(targetDirectory, packageName, dependencyVersion, answers) {
|
|
273
|
+
const files = new Map([
|
|
274
|
+
["package.json", createPackageJson(packageName, dependencyVersion)],
|
|
275
|
+
["tsconfig.json", createTsConfig()],
|
|
276
|
+
[".gitignore", "node_modules/\n.fiyuu/\ndist/\n"],
|
|
277
|
+
["README.md", createReadme(packageName, answers)],
|
|
278
|
+
["fiyuu.config.ts", createFiyuuConfig(answers)],
|
|
279
|
+
["app/layout.tsx", createRootLayout()],
|
|
280
|
+
["app/layout.meta.ts", createRootLayoutMeta()],
|
|
281
|
+
["app/not-found.tsx", createNotFoundPage()],
|
|
282
|
+
["app/error.tsx", createErrorPage()],
|
|
283
|
+
["app/api/health/route.ts", createHealthApiRoute()],
|
|
284
|
+
["app/meta.ts", createHomeMeta(answers)],
|
|
285
|
+
["app/query.ts", createHomeQuery(answers)],
|
|
286
|
+
["app/schema.ts", createHomeSchema(answers)],
|
|
287
|
+
["app/page.tsx", createHomePage(answers)],
|
|
288
|
+
[".fiyuu/README.md", createDotFiyuuReadme()],
|
|
289
|
+
[".fiyuu/PROJECT.md", createDotFiyuuProject(answers)],
|
|
290
|
+
[".fiyuu/PATHS.md", createDotFiyuuPaths(answers)],
|
|
291
|
+
[".fiyuu/STATES.md", createDotFiyuuStates(answers)],
|
|
292
|
+
[".fiyuu/FEATURES.md", createDotFiyuuFeatures(answers)],
|
|
293
|
+
[".fiyuu/env", createDotFiyuuEnv()],
|
|
294
|
+
[".fiyuu/SECRET", "replace-this-with-a-server-only-secret\n"],
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
if (answers.selectedSkills.includes("product-strategist")) {
|
|
298
|
+
files.set("skills/product-strategist.md", createProductStrategistSkill());
|
|
299
|
+
}
|
|
300
|
+
if (answers.selectedSkills.includes("support-triage")) {
|
|
301
|
+
files.set("skills/support-triage.md", createSupportTriageSkill());
|
|
302
|
+
}
|
|
303
|
+
if (answers.selectedSkills.includes("seo-optimizer")) {
|
|
304
|
+
files.set("skills/seo-optimizer.md", createSeoOptimizerSkill());
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (answers.database) {
|
|
308
|
+
files.set("server/f1/schema.f1", createF1Schema());
|
|
309
|
+
files.set("server/f1/index.ts", createF1Index());
|
|
310
|
+
files.set("app/requests/meta.ts", createRequestsMeta(answers));
|
|
311
|
+
files.set("app/requests/query.ts", createRequestsQuery());
|
|
312
|
+
files.set("app/requests/schema.ts", createRequestsSchema());
|
|
313
|
+
files.set("app/requests/page.tsx", createRequestsPage());
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (answers.authHints) {
|
|
317
|
+
files.set("app/auth/meta.ts", createAuthMeta());
|
|
318
|
+
files.set("app/auth/query.ts", createAuthQuery(answers));
|
|
319
|
+
files.set("app/auth/schema.ts", createAuthSchema());
|
|
320
|
+
files.set("app/auth/action.ts", createAuthAction(answers));
|
|
321
|
+
files.set("app/auth/page.tsx", createAuthPage());
|
|
322
|
+
files.set("app/api/auth/session/route.ts", createAuthSessionApiRoute(answers));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (answers.sockets) {
|
|
326
|
+
files.set("server/socket.ts", createSocketServer());
|
|
327
|
+
files.set("app/live/meta.ts", createLiveMeta(answers));
|
|
328
|
+
files.set("app/live/query.ts", createLiveQuery());
|
|
329
|
+
files.set("app/live/schema.ts", createLiveSchema());
|
|
330
|
+
files.set("app/live/page.tsx", createLivePage());
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (answers.encryption) {
|
|
334
|
+
files.set("server/crypto.ts", createServerCrypto());
|
|
335
|
+
files.set("lib/client-crypto.ts", createClientCrypto());
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const [relativePath, content] of files) {
|
|
339
|
+
const absolutePath = path.join(targetDirectory, relativePath);
|
|
340
|
+
await mkdir(path.dirname(absolutePath), { recursive: true });
|
|
341
|
+
await writeFile(absolutePath, content);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function createPackageJson(projectName, dependencyVersion) {
|
|
346
|
+
const usesLocalFramework = dependencyVersion.startsWith("file:");
|
|
347
|
+
const includeSockets = false;
|
|
348
|
+
|
|
349
|
+
return `${JSON.stringify(
|
|
350
|
+
{
|
|
351
|
+
name: projectName,
|
|
352
|
+
version: "0.1.0",
|
|
353
|
+
private: true,
|
|
354
|
+
type: "module",
|
|
355
|
+
scripts: {
|
|
356
|
+
dev: usesLocalFramework ? "node ./node_modules/fiyuu/bin/fiyuu.mjs dev" : "fiyuu dev",
|
|
357
|
+
build: usesLocalFramework ? "node ./node_modules/fiyuu/bin/fiyuu.mjs build" : "fiyuu build",
|
|
358
|
+
start: usesLocalFramework ? "node ./node_modules/fiyuu/bin/fiyuu.mjs start" : "fiyuu start",
|
|
359
|
+
},
|
|
360
|
+
dependencies: {
|
|
361
|
+
fiyuu: dependencyVersion,
|
|
362
|
+
"@geajs/core": "^1.0.12",
|
|
363
|
+
...(includeSockets ? { ws: "^8.18.1" } : {}),
|
|
364
|
+
zod: "^3.24.2",
|
|
365
|
+
},
|
|
366
|
+
devDependencies: {
|
|
367
|
+
"@types/node": "^22.13.10",
|
|
368
|
+
...(includeSockets ? { "@types/ws": "^8.5.14" } : {}),
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
null,
|
|
372
|
+
2,
|
|
373
|
+
)}\n`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function toPackageName(projectName) {
|
|
377
|
+
const rawName = path.basename(projectName);
|
|
378
|
+
return rawName.toLowerCase().replace(/[^a-z0-9-_]/g, "-");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function createTsConfig() {
|
|
382
|
+
return `{
|
|
383
|
+
"compilerOptions": {
|
|
384
|
+
"target": "ES2022",
|
|
385
|
+
"module": "NodeNext",
|
|
386
|
+
"moduleResolution": "NodeNext",
|
|
387
|
+
"jsx": "preserve",
|
|
388
|
+
"jsxImportSource": "@geajs/core",
|
|
389
|
+
"strict": true,
|
|
390
|
+
"skipLibCheck": true,
|
|
391
|
+
"outDir": "dist",
|
|
392
|
+
"rootDir": "."
|
|
393
|
+
},
|
|
394
|
+
"include": ["app/**/*.ts", "app/**/*.tsx", "server/**/*.ts", "lib/**/*.ts", "fiyuu.config.ts"]
|
|
395
|
+
}\n`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function createReadme(projectName, answers) {
|
|
399
|
+
const optionalLines = [];
|
|
400
|
+
|
|
401
|
+
if (answers.sockets) optionalLines.push("- /live -> websocket-powered live counter example");
|
|
402
|
+
if (answers.database) optionalLines.push("- /requests -> F1-backed global request list example");
|
|
403
|
+
if (answers.authHints) optionalLines.push("- /auth -> F1-backed auth starter example");
|
|
404
|
+
optionalLines.push("- /api/health -> app router backend endpoint");
|
|
405
|
+
if (answers.database) optionalLines.push("- server/f1 -> lightweight F1 data layer scaffold");
|
|
406
|
+
if (answers.database) optionalLines.push("- .fiyuu/data/f1.json -> persistent lightweight database file");
|
|
407
|
+
if (answers.sockets) optionalLines.push("- server/socket.ts -> realtime server scaffold");
|
|
408
|
+
if (answers.encryption) optionalLines.push("- server/crypto.ts and lib/client-crypto.ts -> request protection helpers");
|
|
409
|
+
if (answers.skills) optionalLines.push("- skills/ -> AI prompts for product and support workflows");
|
|
410
|
+
if (answers.theming) optionalLines.push("- built-in light/dark theme toggle with localStorage persistence");
|
|
411
|
+
|
|
412
|
+
return `# ${projectName}
|
|
413
|
+
|
|
414
|
+
Generated with create-fiyuu-app.
|
|
415
|
+
|
|
416
|
+
## Commands
|
|
417
|
+
|
|
418
|
+
- npm run dev
|
|
419
|
+
- npm run build
|
|
420
|
+
- npm run start
|
|
421
|
+
- npx fiyuu feat list
|
|
422
|
+
- npx fiyuu feat socket on|off
|
|
423
|
+
|
|
424
|
+
## Starter Routes
|
|
425
|
+
|
|
426
|
+
- / -> Fiyuu one-page home
|
|
427
|
+
${optionalLines.join("\n")}
|
|
428
|
+
|
|
429
|
+
## Notes
|
|
430
|
+
|
|
431
|
+
- Folder-based routing lives directly under app/
|
|
432
|
+
- This starter ships with only the root page by default
|
|
433
|
+
- Root and nested layouts are supported with app/layout.tsx and layout.meta.ts
|
|
434
|
+
- Custom fallback views can be edited at app/not-found.tsx and app/error.tsx
|
|
435
|
+
- Backend route handlers live under app/api/**/route.ts
|
|
436
|
+
- Middleware is optional and can be added later under app/middleware.ts
|
|
437
|
+
- Optional features can be toggled later with fiyuu feat ...
|
|
438
|
+
- Runtime environment lives in .fiyuu/env and .fiyuu/SECRET
|
|
439
|
+
- AI-readable markdown docs live in .fiyuu/PROJECT.md, .fiyuu/PATHS.md, .fiyuu/STATES.md, and .fiyuu/FEATURES.md
|
|
440
|
+
- Client-visible transport obfuscation reduces readability, but absolute secrecy still requires server-only keys and HTTPS
|
|
441
|
+
- UI layer is Gea-first (@geajs/core) with compile-time JSX output
|
|
442
|
+
`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function createFiyuuConfig(answers) {
|
|
446
|
+
return `export default {
|
|
447
|
+
app: {
|
|
448
|
+
name: "Fiyuu App",
|
|
449
|
+
runtime: "node",
|
|
450
|
+
port: 4050,
|
|
451
|
+
},
|
|
452
|
+
ai: {
|
|
453
|
+
enabled: ${String(answers.skills)},
|
|
454
|
+
skillsDirectory: "./skills",
|
|
455
|
+
defaultSkills: ${JSON.stringify(answers.selectedSkills)},
|
|
456
|
+
graphContext: true,
|
|
457
|
+
},
|
|
458
|
+
fullstack: {
|
|
459
|
+
client: true,
|
|
460
|
+
serverActions: true,
|
|
461
|
+
serverQueries: true,
|
|
462
|
+
sockets: ${String(answers.sockets)},
|
|
463
|
+
},
|
|
464
|
+
data: {
|
|
465
|
+
driver: ${JSON.stringify(answers.database ? "f1" : "none")},
|
|
466
|
+
path: "./server/f1/schema.f1",
|
|
467
|
+
},
|
|
468
|
+
security: {
|
|
469
|
+
requestEncryption: ${String(answers.encryption)},
|
|
470
|
+
serverSecretFile: "./.fiyuu/SECRET",
|
|
471
|
+
},
|
|
472
|
+
middleware: {
|
|
473
|
+
enabled: ${String(answers.authHints)},
|
|
474
|
+
},
|
|
475
|
+
websocket: {
|
|
476
|
+
enabled: ${String(answers.sockets)},
|
|
477
|
+
path: "/__fiyuu/ws",
|
|
478
|
+
heartbeatMs: 15000,
|
|
479
|
+
maxPayloadBytes: 65536,
|
|
480
|
+
},
|
|
481
|
+
developerTools: {
|
|
482
|
+
enabled: true,
|
|
483
|
+
renderTiming: true,
|
|
484
|
+
},
|
|
485
|
+
observability: {
|
|
486
|
+
requestId: true,
|
|
487
|
+
warningsAsOverlay: true,
|
|
488
|
+
},
|
|
489
|
+
auth: {
|
|
490
|
+
enabled: ${String(answers.authHints)},
|
|
491
|
+
sessionStrategy: "cookie",
|
|
492
|
+
},
|
|
493
|
+
analytics: {
|
|
494
|
+
enabled: true,
|
|
495
|
+
provider: "console",
|
|
496
|
+
},
|
|
497
|
+
featureFlags: {
|
|
498
|
+
enabled: true,
|
|
499
|
+
defaults: {
|
|
500
|
+
onboardingRevamp: true,
|
|
501
|
+
realtimeCounter: ${String(answers.sockets)},
|
|
502
|
+
requestInspector: ${String(answers.database)},
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
} as const;
|
|
506
|
+
`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function createAppMiddleware(answers) {
|
|
510
|
+
return `type MiddlewareContext = {
|
|
511
|
+
url: URL;
|
|
512
|
+
responseHeaders: Record<string, string>;
|
|
513
|
+
requestId: string;
|
|
514
|
+
warnings: string[];
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
type MiddlewareNext = () => Promise<void>;
|
|
518
|
+
|
|
519
|
+
const requestHeaders = async (context: MiddlewareContext, next: MiddlewareNext) => {
|
|
520
|
+
context.responseHeaders["x-fiyuu-route"] = context.url.pathname;
|
|
521
|
+
if (context.requestId) {
|
|
522
|
+
context.responseHeaders["x-fiyuu-request-id"] = context.requestId;
|
|
523
|
+
}
|
|
524
|
+
await next();
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const authGuard = async (context: MiddlewareContext, next: MiddlewareNext) => {
|
|
528
|
+
${answers.authHints ? 'if (context.url.pathname.startsWith("/requests")) {\n context.responseHeaders["x-fiyuu-guard"] = "active";\n }' : ""}
|
|
529
|
+
await next();
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const warningsHeader = async (context: MiddlewareContext, next: MiddlewareNext) => {
|
|
533
|
+
if (context.warnings.length > 0) {
|
|
534
|
+
context.responseHeaders["x-fiyuu-warnings"] = String(context.warnings.length);
|
|
535
|
+
}
|
|
536
|
+
await next();
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
export const middleware = [requestHeaders, authGuard, warningsHeader];
|
|
540
|
+
`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function createRootLayout() {
|
|
544
|
+
return `import { Component } from "@geajs/core";
|
|
545
|
+
import { defineLayout, html, type LayoutProps } from "fiyuu/client";
|
|
546
|
+
|
|
547
|
+
export const layout = defineLayout({
|
|
548
|
+
name: "root",
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
export default class RootLayout extends Component<LayoutProps> {
|
|
552
|
+
template({ children }: LayoutProps = this.props) {
|
|
553
|
+
return html\`<div class="min-h-screen bg-[#f7f3ea] text-[#33412f] dark:bg-[#111513] dark:text-[#e8f1ea]">\${children ?? ""}</div>\`;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
`;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function createRootLayoutMeta() {
|
|
560
|
+
return `import { defineMeta } from "fiyuu/client";
|
|
561
|
+
|
|
562
|
+
export default defineMeta({
|
|
563
|
+
intent: "Root layout for the Fiyuu starter application",
|
|
564
|
+
title: "Fiyuu",
|
|
565
|
+
seo: {
|
|
566
|
+
title: "Fiyuu - AI-first fullstack framework",
|
|
567
|
+
description: "Fiyuu starter with layouts, metadata, realtime, auth, and API routes.",
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
`;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function createNotFoundPage() {
|
|
574
|
+
return `import { Component } from "@geajs/core";
|
|
575
|
+
import { escapeHtml, html } from "fiyuu/client";
|
|
576
|
+
|
|
577
|
+
type NotFoundData = {
|
|
578
|
+
title?: string;
|
|
579
|
+
route?: string;
|
|
580
|
+
method?: string;
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
export default class NotFoundPage extends Component<{ data?: NotFoundData }> {
|
|
584
|
+
template({ data }: { data?: NotFoundData } = this.props) {
|
|
585
|
+
return html\`
|
|
586
|
+
<main class="min-h-screen w-full px-5 py-8 text-[#30402a]">
|
|
587
|
+
<section class="w-full rounded-2xl border border-[#7a8f6b]/20 bg-[#f8f4ec] p-6">
|
|
588
|
+
<p class="text-xs uppercase tracking-[0.22em] text-[#627356]">404</p>
|
|
589
|
+
<h1 class="mt-3 text-3xl font-semibold text-[#24311f]">\${escapeHtml(data?.title ?? "Page not found")}</h1>
|
|
590
|
+
<p class="mt-3 text-sm text-[#5a6753]">The requested route is not available in this Fiyuu app.</p>
|
|
591
|
+
<p class="mt-4 text-sm text-[#5a6753]">Route: \${escapeHtml(data?.route ?? "/")}</p>
|
|
592
|
+
<p class="mt-1 text-sm text-[#5a6753]">Method: \${escapeHtml(data?.method ?? "GET")}</p>
|
|
593
|
+
</section>
|
|
594
|
+
</main>
|
|
595
|
+
\`;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function createErrorPage() {
|
|
602
|
+
return `import { Component } from "@geajs/core";
|
|
603
|
+
import { escapeHtml, html } from "fiyuu/client";
|
|
604
|
+
|
|
605
|
+
type ErrorData = {
|
|
606
|
+
message?: string;
|
|
607
|
+
route?: string;
|
|
608
|
+
method?: string;
|
|
609
|
+
stack?: string;
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
export default class ErrorPage extends Component<{ data?: ErrorData }> {
|
|
613
|
+
template({ data }: { data?: ErrorData } = this.props) {
|
|
614
|
+
const stack = data?.stack ? html\`<pre class="mt-4 overflow-auto rounded-xl border border-[#8f5f5f]/20 bg-[#2a1717] p-3 text-xs text-[#ffe9e9]">\${escapeHtml(data.stack)}</pre>\` : "";
|
|
615
|
+
return html\`
|
|
616
|
+
<main class="min-h-screen w-full px-5 py-8 text-[#3d2b2b]">
|
|
617
|
+
<section class="w-full rounded-2xl border border-[#8f5f5f]/24 bg-[#f7ece7] p-6">
|
|
618
|
+
<p class="text-xs uppercase tracking-[0.22em] text-[#8f5f5f]">500</p>
|
|
619
|
+
<h1 class="mt-3 text-3xl font-semibold text-[#3a2020]">Application error</h1>
|
|
620
|
+
<p class="mt-3 text-sm text-[#684545]">\${escapeHtml(data?.message ?? "Unknown error")}</p>
|
|
621
|
+
<p class="mt-4 text-sm text-[#684545]">Route: \${escapeHtml(data?.route ?? "/")}</p>
|
|
622
|
+
<p class="mt-1 text-sm text-[#684545]">Method: \${escapeHtml(data?.method ?? "GET")}</p>
|
|
623
|
+
\${stack}
|
|
624
|
+
</section>
|
|
625
|
+
</main>
|
|
626
|
+
\`;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
`;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function createHealthApiRoute() {
|
|
633
|
+
return `export async function GET() {
|
|
634
|
+
return {
|
|
635
|
+
ok: true,
|
|
636
|
+
service: "fiyuu-app",
|
|
637
|
+
timestamp: new Date().toISOString(),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
`;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function createAuthSessionApiRoute(answers) {
|
|
644
|
+
const source = answers.database ? 'import { listSessions } from "../../../../server/f1/index.js";\n' : "";
|
|
645
|
+
const body = answers.database ? ' return { sessions: await listSessions() };\n' : ' return { sessions: [] };\n';
|
|
646
|
+
return `${source}export async function GET() {
|
|
647
|
+
${body}}
|
|
648
|
+
`;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function createAnalyticsModule() {
|
|
652
|
+
return `export function track(event: string, payload: Record<string, unknown> = {}) {
|
|
653
|
+
if (typeof window !== "undefined") {
|
|
654
|
+
console.info("[fiyuu:analytics]", event, payload);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
console.info("[fiyuu:analytics:server]", event, payload);
|
|
659
|
+
}
|
|
660
|
+
`;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function createFeatureFlagsModule() {
|
|
664
|
+
return `const flags = {
|
|
665
|
+
onboardingRevamp: true,
|
|
666
|
+
realtimeCounter: true,
|
|
667
|
+
requestInspector: true,
|
|
668
|
+
} as const;
|
|
669
|
+
|
|
670
|
+
export function isFeatureEnabled(name: keyof typeof flags): boolean {
|
|
671
|
+
return flags[name];
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export function listFeatureFlags() {
|
|
675
|
+
return flags;
|
|
676
|
+
}
|
|
677
|
+
`;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function createDotFiyuuEnv() {
|
|
681
|
+
return `APP_NAME=Fiyuu App
|
|
682
|
+
APP_ENV=development
|
|
683
|
+
FIYUU_PUBLIC_APP_NAME=Fiyuu App
|
|
684
|
+
`;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function createDotFiyuuReadme() {
|
|
688
|
+
return `# .fiyuu
|
|
689
|
+
|
|
690
|
+
This directory stores Fiyuu-managed runtime artifacts and server-only secrets.
|
|
691
|
+
|
|
692
|
+
- env -> environment variables loaded by Fiyuu
|
|
693
|
+
- SECRET -> server-only secret material
|
|
694
|
+
- PROJECT.md -> AI-readable project summary
|
|
695
|
+
- PATHS.md -> AI-readable route and file map
|
|
696
|
+
- STATES.md -> AI-readable structural state map
|
|
697
|
+
- FEATURES.md -> AI-readable startup feature inventory
|
|
698
|
+
- graph.json -> generated project graph
|
|
699
|
+
- build.json -> generated runtime build manifest
|
|
700
|
+
|
|
701
|
+
Do not expose SECRET to the browser bundle.
|
|
702
|
+
`;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function createDotFiyuuFeatures(answers) {
|
|
706
|
+
return `# Features
|
|
707
|
+
|
|
708
|
+
## Startup Defaults
|
|
709
|
+
|
|
710
|
+
- analytics: enabled
|
|
711
|
+
- feature flags: enabled
|
|
712
|
+
- developer tools: enabled in dev
|
|
713
|
+
- request timing: enabled
|
|
714
|
+
- middleware/auth guard: ${answers.authHints ? "enabled" : "disabled"}
|
|
715
|
+
- websocket example: ${answers.sockets ? "enabled" : "disabled"}
|
|
716
|
+
- f1 requests example: ${answers.database ? "enabled" : "disabled"}
|
|
717
|
+
`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function createDotFiyuuProject(answers) {
|
|
721
|
+
return `# Project
|
|
722
|
+
|
|
723
|
+
AI-readable project summary for the starter app.
|
|
724
|
+
|
|
725
|
+
## Identity
|
|
726
|
+
|
|
727
|
+
- framework: Fiyuu
|
|
728
|
+
- style: AI-first fullstack framework
|
|
729
|
+
- primary route: /
|
|
730
|
+
|
|
731
|
+
## Capabilities
|
|
732
|
+
|
|
733
|
+
- websocket: ${answers.sockets ? "enabled" : "disabled"}
|
|
734
|
+
- f1 database: ${answers.database ? "enabled" : "disabled"}
|
|
735
|
+
- request protection helpers: ${answers.encryption ? "enabled" : "disabled"}
|
|
736
|
+
- skills: ${answers.skills ? "enabled" : "disabled"}
|
|
737
|
+
- middleware/auth guards: ${answers.authHints ? "enabled" : "disabled"}
|
|
738
|
+
`;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function createDotFiyuuPaths(answers) {
|
|
742
|
+
return `# Paths
|
|
743
|
+
|
|
744
|
+
## Route Folders
|
|
745
|
+
|
|
746
|
+
- / -> app/
|
|
747
|
+
${answers.sockets ? "- /live -> app/live/" : ""}
|
|
748
|
+
${answers.database ? "- /requests -> app/requests/" : ""}
|
|
749
|
+
${answers.authHints ? "- /auth -> app/auth/" : ""}
|
|
750
|
+
|
|
751
|
+
## Fixed Files
|
|
752
|
+
|
|
753
|
+
- page.tsx -> route UI entry
|
|
754
|
+
- meta.ts -> route metadata and render mode
|
|
755
|
+
- schema.ts -> input/output contract
|
|
756
|
+
- query.ts -> read-side logic
|
|
757
|
+
- action.ts -> write-side logic
|
|
758
|
+
|
|
759
|
+
## Backend Paths
|
|
760
|
+
|
|
761
|
+
- server/socket.ts -> websocket scaffold ${answers.sockets ? "present" : "optional"}
|
|
762
|
+
- server/f1/ -> F1 data scaffold ${answers.database ? "present" : "optional"}
|
|
763
|
+
- server/crypto.ts -> server request protection ${answers.encryption ? "present" : "optional"}
|
|
764
|
+
- lib/client-crypto.ts -> client request protection ${answers.encryption ? "present" : "optional"}
|
|
765
|
+
`;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function createDotFiyuuStates(answers) {
|
|
769
|
+
return `# States
|
|
770
|
+
|
|
771
|
+
## Runtime
|
|
772
|
+
|
|
773
|
+
- default port: 4050
|
|
774
|
+
- render mode for /: csr
|
|
775
|
+
- websocket configured: ${answers.sockets ? "yes" : "no"}
|
|
776
|
+
- live websocket example route: ${answers.sockets ? "/live" : "not generated"}
|
|
777
|
+
- f1 requests example route: ${answers.database ? "/requests" : "not generated"}
|
|
778
|
+
- auth example route: ${answers.authHints ? "/auth" : "not generated"}
|
|
779
|
+
|
|
780
|
+
## Security
|
|
781
|
+
|
|
782
|
+
- secret source: .fiyuu/SECRET
|
|
783
|
+
- env source: .fiyuu/env
|
|
784
|
+
- request obfuscation helpers: ${answers.encryption ? "yes" : "no"}
|
|
785
|
+
|
|
786
|
+
## AI
|
|
787
|
+
|
|
788
|
+
- graph context: yes
|
|
789
|
+
- skills directory: ${answers.skills ? "skills/" : "not enabled"}
|
|
790
|
+
`;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function createProductStrategistSkill() {
|
|
794
|
+
return `# Product Strategist
|
|
795
|
+
|
|
796
|
+
Use this skill when planning new features inside a Fiyuu app.
|
|
797
|
+
|
|
798
|
+
## Focus
|
|
799
|
+
|
|
800
|
+
- map goals to route folders
|
|
801
|
+
- define intent before implementation
|
|
802
|
+
- outline query and action contracts first
|
|
803
|
+
- preserve AI-readable structure
|
|
804
|
+
`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function createSupportTriageSkill() {
|
|
808
|
+
return `# Support Triage
|
|
809
|
+
|
|
810
|
+
Use this skill when debugging incidents or handling customer-facing issues.
|
|
811
|
+
|
|
812
|
+
## Focus
|
|
813
|
+
|
|
814
|
+
- inspect the route folder first
|
|
815
|
+
- separate schema, query, action, and UI failures
|
|
816
|
+
- keep fixes deterministic
|
|
817
|
+
- document intent changes when behavior changes
|
|
818
|
+
`;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function createSeoOptimizerSkill() {
|
|
822
|
+
return `# SEO Optimizer
|
|
823
|
+
|
|
824
|
+
Use this skill when improving discoverability and social previews.
|
|
825
|
+
|
|
826
|
+
## Focus
|
|
827
|
+
|
|
828
|
+
- check every route for seo.title and seo.description
|
|
829
|
+
- keep titles specific and concise
|
|
830
|
+
- keep descriptions clear and action-oriented
|
|
831
|
+
- flag duplicate title/description combinations
|
|
832
|
+
- suggest schema-ready copy improvements
|
|
833
|
+
`;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function createHomeAction(answers) {
|
|
837
|
+
const maybeF1 = answers.database ? 'import { saveTodoDraft } from "../server/f1/index.js";\n' : "";
|
|
838
|
+
const maybeBody = answers.database
|
|
839
|
+
? " await saveTodoDraft(input.title);\n"
|
|
840
|
+
: "";
|
|
841
|
+
|
|
842
|
+
return `import { z } from "zod";
|
|
843
|
+
import { defineAction } from "fiyuu/client";
|
|
844
|
+
${maybeF1}
|
|
845
|
+
export const action = defineAction({
|
|
846
|
+
input: z.object({
|
|
847
|
+
title: z.string().min(1),
|
|
848
|
+
}),
|
|
849
|
+
output: z.object({
|
|
850
|
+
success: z.boolean(),
|
|
851
|
+
message: z.string(),
|
|
852
|
+
}),
|
|
853
|
+
description: "Accepts a new todo title for future server-side persistence",
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
export async function execute({ input }: { input: { title: string } }) {
|
|
857
|
+
${maybeBody} return {
|
|
858
|
+
success: true,
|
|
859
|
+
message: \`Queued todo: \${input.title}\`,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
`;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function createHomeMeta(answers) {
|
|
866
|
+
return `import { defineMeta } from "fiyuu/client";
|
|
867
|
+
|
|
868
|
+
export default defineMeta({
|
|
869
|
+
intent: "Home page introducing the Fiyuu framework with a calm starter experience",
|
|
870
|
+
title: "Home",
|
|
871
|
+
render: "ssr",
|
|
872
|
+
seo: {
|
|
873
|
+
title: "Fiyuu Starter",
|
|
874
|
+
description: "Gea-first one-page starter for AI-readable fullstack projects.",
|
|
875
|
+
},
|
|
876
|
+
});
|
|
877
|
+
`;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function createHomeQuery(answers) {
|
|
881
|
+
const skills = answers.selectedSkills;
|
|
882
|
+
|
|
883
|
+
return `import { z } from "zod";
|
|
884
|
+
import { defineQuery } from "fiyuu/client";
|
|
885
|
+
|
|
886
|
+
export const query = defineQuery({
|
|
887
|
+
input: z.object({}),
|
|
888
|
+
output: z.object({
|
|
889
|
+
stats: z.array(
|
|
890
|
+
z.object({
|
|
891
|
+
label: z.string(),
|
|
892
|
+
value: z.string(),
|
|
893
|
+
}),
|
|
894
|
+
),
|
|
895
|
+
skills: z.array(z.string()),
|
|
896
|
+
}),
|
|
897
|
+
description: "Loads the starter content for the Fiyuu home page",
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
export async function execute() {
|
|
901
|
+
return {
|
|
902
|
+
stats: [
|
|
903
|
+
{ label: "Layout", value: "One page" },
|
|
904
|
+
{ label: "Viewport", value: "100% screen" },
|
|
905
|
+
{ label: "Render", value: "SSR" },
|
|
906
|
+
{ label: "Devtools", value: "Built in" },
|
|
907
|
+
],
|
|
908
|
+
skills: ${JSON.stringify(skills)},
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
`;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function createLiveMeta(answers) {
|
|
915
|
+
return `import { defineMeta } from "fiyuu/client";
|
|
916
|
+
|
|
917
|
+
export default defineMeta({
|
|
918
|
+
intent: "Live counter page demonstrating websocket updates",
|
|
919
|
+
title: "Live",
|
|
920
|
+
render: "csr",
|
|
921
|
+
seo: {
|
|
922
|
+
title: "Live Counter - Fiyuu",
|
|
923
|
+
description: "Realtime websocket example built with Fiyuu.",
|
|
924
|
+
},
|
|
925
|
+
});
|
|
926
|
+
`;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function createLiveQuery() {
|
|
930
|
+
return `import { z } from "zod";
|
|
931
|
+
import { defineQuery } from "fiyuu/client";
|
|
932
|
+
|
|
933
|
+
export const query = defineQuery({
|
|
934
|
+
input: z.object({}),
|
|
935
|
+
output: z.object({
|
|
936
|
+
initialCount: z.number(),
|
|
937
|
+
channel: z.string(),
|
|
938
|
+
}),
|
|
939
|
+
description: "Loads starter websocket metadata for the live counter route",
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
export async function execute() {
|
|
943
|
+
return {
|
|
944
|
+
initialCount: 0,
|
|
945
|
+
channel: "updates",
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
`;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function createLiveSchema() {
|
|
952
|
+
return `import { z } from "zod";
|
|
953
|
+
|
|
954
|
+
export const input = z.object({});
|
|
955
|
+
|
|
956
|
+
export const output = z.object({
|
|
957
|
+
initialCount: z.number(),
|
|
958
|
+
channel: z.string(),
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
export const description = "Loads starter websocket metadata for the live counter route";
|
|
962
|
+
`;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function createLivePage() {
|
|
966
|
+
return `import { Component } from "@geajs/core";
|
|
967
|
+
import { definePage, escapeHtml, html, type PageProps } from "fiyuu/client";
|
|
968
|
+
|
|
969
|
+
type LiveData = {
|
|
970
|
+
initialCount: number;
|
|
971
|
+
channel: string;
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
export const page = definePage({
|
|
975
|
+
intent: "Live counter page demonstrating websocket updates",
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
export default class Page extends Component<PageProps<LiveData>> {
|
|
979
|
+
template({ data }: PageProps<LiveData> = this.props) {
|
|
980
|
+
const script = "const count=document.getElementById('fiyuu-live-count');const status=document.getElementById('fiyuu-live-status');const protocol=location.protocol==='https:'?'wss':'ws';const path='__FIYUU_WS_PATH__'.replace('__FIYUU_WS_PATH__','/__fiyuu/ws');const socket=new WebSocket(protocol+'://'+location.host+path);const fail=()=>{if(status)status.textContent='unavailable';};const timeout=setTimeout(fail,3500);socket.addEventListener('open',()=>{clearTimeout(timeout);if(status)status.textContent='connected';});socket.addEventListener('error',()=>{clearTimeout(timeout);fail();});socket.addEventListener('close',()=>{if(status&&status.textContent!=='unavailable')status.textContent='closed';});socket.addEventListener('message',(event)=>{try{const payload=JSON.parse(event.data);if(payload&&payload.type==='counter:tick'&&typeof payload.count==='number'&&count){count.textContent=String(payload.count);}}catch{if(status)status.textContent='message-error';}});";
|
|
981
|
+
return html\`
|
|
982
|
+
<main class="min-h-screen w-full px-6 py-12 text-[#31402b]">
|
|
983
|
+
<div class="w-full rounded-[2rem] border border-[#7a8f6b]/20 bg-white/70 p-8">
|
|
984
|
+
<div class="text-xs uppercase tracking-[0.24em] text-[#6d805f]">Realtime Example</div>
|
|
985
|
+
<h1 class="mt-4 text-4xl font-semibold text-[#24311f]">Live Counter</h1>
|
|
986
|
+
<p class="mt-4 max-w-2xl text-lg leading-8 text-[#5f6d58]">This route listens to the starter websocket server and updates a counter in real time.</p>
|
|
987
|
+
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
|
988
|
+
<div class="rounded-3xl bg-[#31402b] p-8 text-[#f7f3ea]"><div class="text-xs uppercase tracking-[0.2em] text-[#cdd7c6]">Channel</div><div class="mt-3 text-3xl font-semibold">\${escapeHtml(data?.channel ?? "updates")}</div></div>
|
|
989
|
+
<div class="rounded-3xl border border-[#7a8f6b]/20 bg-[#fcfaf5] p-8"><div class="text-xs uppercase tracking-[0.2em] text-[#7a8b71]">Live Count</div><div id="fiyuu-live-count" class="mt-3 text-5xl font-semibold text-[#24311f]">\${String(data?.initialCount ?? 0)}</div><div class="mt-3 text-sm text-[#61705b]">Socket status: <span id="fiyuu-live-status">connecting</span></div></div>
|
|
990
|
+
</div>
|
|
991
|
+
</div>
|
|
992
|
+
</main>
|
|
993
|
+
<script type="module">\${script}</script>
|
|
994
|
+
\`;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
`;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function createRequestsMeta(answers) {
|
|
1001
|
+
return `import { defineMeta } from "fiyuu/client";
|
|
1002
|
+
|
|
1003
|
+
export default defineMeta({
|
|
1004
|
+
intent: "Request list page showing records from the F1 starter store",
|
|
1005
|
+
title: "Requests",
|
|
1006
|
+
render: "ssr",
|
|
1007
|
+
seo: {
|
|
1008
|
+
title: "Requests - Fiyuu",
|
|
1009
|
+
description: "F1-backed request list example in the Fiyuu starter.",
|
|
1010
|
+
},
|
|
1011
|
+
});
|
|
1012
|
+
`;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function createRequestsQuery() {
|
|
1016
|
+
return `import { z } from "zod";
|
|
1017
|
+
import { defineQuery } from "fiyuu/client";
|
|
1018
|
+
import { listRequests } from "../../server/f1/index.js";
|
|
1019
|
+
|
|
1020
|
+
export const query = defineQuery({
|
|
1021
|
+
input: z.object({}),
|
|
1022
|
+
output: z.object({
|
|
1023
|
+
requests: z.array(
|
|
1024
|
+
z.object({
|
|
1025
|
+
id: z.string(),
|
|
1026
|
+
route: z.string(),
|
|
1027
|
+
method: z.string(),
|
|
1028
|
+
source: z.string(),
|
|
1029
|
+
}),
|
|
1030
|
+
),
|
|
1031
|
+
}),
|
|
1032
|
+
description: "Loads request records from the F1 starter store",
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
export async function execute() {
|
|
1036
|
+
return {
|
|
1037
|
+
requests: await listRequests(),
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
`;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function createRequestsSchema() {
|
|
1044
|
+
return `import { z } from "zod";
|
|
1045
|
+
|
|
1046
|
+
export const input = z.object({});
|
|
1047
|
+
|
|
1048
|
+
export const output = z.object({
|
|
1049
|
+
requests: z.array(
|
|
1050
|
+
z.object({
|
|
1051
|
+
id: z.string(),
|
|
1052
|
+
route: z.string(),
|
|
1053
|
+
method: z.string(),
|
|
1054
|
+
source: z.string(),
|
|
1055
|
+
}),
|
|
1056
|
+
),
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
export const description = "Loads request records from the F1 starter store";
|
|
1060
|
+
`;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function createRequestsPage() {
|
|
1064
|
+
return `import { Component } from "@geajs/core";
|
|
1065
|
+
import { definePage, escapeHtml, html, type PageProps } from "fiyuu/client";
|
|
1066
|
+
|
|
1067
|
+
type RequestsData = {
|
|
1068
|
+
requests: Array<{
|
|
1069
|
+
id: string;
|
|
1070
|
+
route: string;
|
|
1071
|
+
method: string;
|
|
1072
|
+
source: string;
|
|
1073
|
+
}>;
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
export const page = definePage({
|
|
1077
|
+
intent: "Request list page showing records from the F1 starter store",
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
export default class Page extends Component<PageProps<RequestsData>> {
|
|
1081
|
+
template({ data }: PageProps<RequestsData> = this.props) {
|
|
1082
|
+
const baseRequests = data?.requests ?? [];
|
|
1083
|
+
const rows = Array.from({ length: 400 }, (_, index) =>
|
|
1084
|
+
baseRequests[index % (baseRequests.length || 1)] ?? { id: "n/a", route: "/", method: "GET", source: "empty" },
|
|
1085
|
+
);
|
|
1086
|
+
const rowsHtml = rows
|
|
1087
|
+
.map(
|
|
1088
|
+
(request, index) => html\`<div class="grid grid-cols-[1.2fr_1fr_1fr_1fr] gap-4 border-b border-[#7a8f6b]/10 px-6 py-4 text-sm text-[#364330]"><div>\${escapeHtml(request.id)}-\${index}</div><div>\${escapeHtml(request.route)}</div><div>\${escapeHtml(request.method)}</div><div>\${escapeHtml(request.source)}</div></div>\`,
|
|
1089
|
+
)
|
|
1090
|
+
.join("");
|
|
1091
|
+
|
|
1092
|
+
return html\`
|
|
1093
|
+
<main class="min-h-screen bg-[#f7f3ea] px-6 py-12 text-[#31402b]">
|
|
1094
|
+
<div class="mx-auto max-w-6xl rounded-[2rem] border border-[#7a8f6b]/20 bg-white/70 p-8">
|
|
1095
|
+
<div class="text-xs uppercase tracking-[0.24em] text-[#6d805f]">F1 Example</div>
|
|
1096
|
+
<h1 class="mt-4 text-4xl font-semibold text-[#24311f]">Global Request List</h1>
|
|
1097
|
+
<p class="mt-4 max-w-2xl text-lg leading-8 text-[#5f6d58]">This route reads starter records from the lightweight F1 store and renders a deterministic list in Gea mode.</p>
|
|
1098
|
+
<div class="mt-6 rounded-2xl bg-[#edf3e7] px-4 py-4 text-sm text-[#4d5d47]">Rows rendered: \${rows.length}</div>
|
|
1099
|
+
<div class="mt-8 overflow-hidden rounded-3xl border border-[#7a8f6b]/15 bg-[#fcfaf5]">
|
|
1100
|
+
<div class="grid grid-cols-[1.2fr_1fr_1fr_1fr] gap-4 border-b border-[#7a8f6b]/10 px-6 py-4 text-xs uppercase tracking-[0.2em] text-[#7a8b71]"><div>ID</div><div>Route</div><div>Method</div><div>Source</div></div>
|
|
1101
|
+
\${rowsHtml}
|
|
1102
|
+
</div>
|
|
1103
|
+
</div>
|
|
1104
|
+
</main>
|
|
1105
|
+
\`;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
`;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function createAuthMeta() {
|
|
1112
|
+
return `import { defineMeta } from "fiyuu/client";
|
|
1113
|
+
|
|
1114
|
+
export default defineMeta({
|
|
1115
|
+
intent: "Auth page demonstrating F1-backed users and sessions",
|
|
1116
|
+
title: "Auth",
|
|
1117
|
+
render: "ssr",
|
|
1118
|
+
seo: {
|
|
1119
|
+
title: "Auth - Fiyuu",
|
|
1120
|
+
description: "Working username and password auth example with F1 sessions.",
|
|
1121
|
+
},
|
|
1122
|
+
});
|
|
1123
|
+
`;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function createAuthQuery(answers) {
|
|
1127
|
+
const source = answers.database
|
|
1128
|
+
? 'import { listSessions, listUsers } from "../../server/f1/index.js";\n'
|
|
1129
|
+
: "";
|
|
1130
|
+
const body = answers.database
|
|
1131
|
+
? ' return {\n users: await listUsers(),\n sessions: await listSessions(),\n hint: { username: "founder", password: "fiyuu123" },\n };\n'
|
|
1132
|
+
: ' return { users: [], sessions: [], hint: { username: "founder", password: "fiyuu123" } };\n';
|
|
1133
|
+
|
|
1134
|
+
return `import { z } from "zod";
|
|
1135
|
+
import { defineQuery } from "fiyuu/client";
|
|
1136
|
+
${source}
|
|
1137
|
+
export const query = defineQuery({
|
|
1138
|
+
input: z.object({}),
|
|
1139
|
+
output: z.object({
|
|
1140
|
+
users: z.array(z.object({ id: z.string(), username: z.string(), role: z.string() })),
|
|
1141
|
+
sessions: z.array(z.object({ id: z.string(), userId: z.string(), status: z.string() })),
|
|
1142
|
+
hint: z.object({ username: z.string(), password: z.string() }),
|
|
1143
|
+
}),
|
|
1144
|
+
description: "Loads starter auth data from the F1 store",
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
export async function execute() {
|
|
1148
|
+
${body}}
|
|
1149
|
+
`;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function createAuthSchema() {
|
|
1153
|
+
return `import { z } from "zod";
|
|
1154
|
+
|
|
1155
|
+
export const input = z.object({});
|
|
1156
|
+
|
|
1157
|
+
export const output = z.object({
|
|
1158
|
+
users: z.array(z.object({ id: z.string(), username: z.string(), role: z.string() })),
|
|
1159
|
+
sessions: z.array(z.object({ id: z.string(), userId: z.string(), status: z.string() })),
|
|
1160
|
+
hint: z.object({ username: z.string(), password: z.string() }),
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
export const description = "Loads starter auth data from the F1 store";
|
|
1164
|
+
`;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function createAuthAction(answers) {
|
|
1168
|
+
const source = answers.database ? 'import { signIn } from "../../server/f1/index.js";\n' : "";
|
|
1169
|
+
const body = answers.database
|
|
1170
|
+
? ' return signIn(input.username, input.password);\n'
|
|
1171
|
+
: ' return { success: true, message: "Signed in successfully", session: { id: "demo-session", userId: "demo-user", status: "active" }, user: { id: "demo-user", username: input.username, role: "member" } };\n';
|
|
1172
|
+
|
|
1173
|
+
return `import { z } from "zod";
|
|
1174
|
+
import { defineAction } from "fiyuu/client";
|
|
1175
|
+
${source}
|
|
1176
|
+
export const action = defineAction({
|
|
1177
|
+
input: z.object({
|
|
1178
|
+
username: z.string().min(1),
|
|
1179
|
+
password: z.string().min(1),
|
|
1180
|
+
}),
|
|
1181
|
+
output: z.object({
|
|
1182
|
+
success: z.boolean(),
|
|
1183
|
+
message: z.string(),
|
|
1184
|
+
session: z.object({ id: z.string(), userId: z.string(), status: z.string() }).nullable(),
|
|
1185
|
+
user: z.object({ id: z.string(), username: z.string(), role: z.string() }).nullable(),
|
|
1186
|
+
}),
|
|
1187
|
+
description: "Creates a starter auth session with username and password",
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
export async function execute({ input }: { input: { username: string; password: string } }) {
|
|
1191
|
+
${body}}
|
|
1192
|
+
`;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function createAuthPage() {
|
|
1196
|
+
return `import { Component } from "@geajs/core";
|
|
1197
|
+
import { definePage, escapeHtml, html, type PageProps } from "fiyuu/client";
|
|
1198
|
+
|
|
1199
|
+
type AuthData = {
|
|
1200
|
+
users: Array<{ id: string; username: string; role: string }>;
|
|
1201
|
+
sessions: Array<{ id: string; userId: string; status: string }>;
|
|
1202
|
+
hint: { username: string; password: string };
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
export const page = definePage({
|
|
1206
|
+
intent: "Auth page demonstrating F1-backed users and sessions",
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
export default class Page extends Component<PageProps<AuthData>> {
|
|
1210
|
+
template({ data }: PageProps<AuthData> = this.props) {
|
|
1211
|
+
const usersHtml = (data?.users ?? [])
|
|
1212
|
+
.map((user) => html\`<div class="rounded-2xl border border-[#7a8f6b]/10 px-4 py-4 text-sm"><div class="font-medium text-[#24311f]">\${escapeHtml(user.username)}</div><div class="mt-1 text-[#61705b]">\${escapeHtml(user.role)} · \${escapeHtml(user.id)}</div></div>\`)
|
|
1213
|
+
.join("");
|
|
1214
|
+
const sessionsHtml = (data?.sessions ?? [])
|
|
1215
|
+
.map((session) => html\`<div class="rounded-2xl border border-white/10 px-4 py-4 text-sm"><div class="font-medium">\${escapeHtml(session.id)}</div><div class="mt-1 text-[#dbe5d4]">\${escapeHtml(session.userId)} · \${escapeHtml(session.status)}</div></div>\`)
|
|
1216
|
+
.join("");
|
|
1217
|
+
const script = "const form=document.getElementById('fiyuu-auth-form');const username=document.getElementById('fiyuu-auth-username');const password=document.getElementById('fiyuu-auth-password');const result=document.getElementById('fiyuu-auth-result');const submit=document.getElementById('fiyuu-auth-submit');form&&form.addEventListener('submit',async(event)=>{event.preventDefault();if(!username||!password||!result||!submit)return;submit.setAttribute('disabled','true');result.textContent='Signing in...';const response=await fetch('/auth',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({username:username.value,password:password.value})});const payload=await response.json();result.textContent=payload.message||'Finished';submit.removeAttribute('disabled');if(payload.success){location.reload();}});";
|
|
1218
|
+
|
|
1219
|
+
return html\`
|
|
1220
|
+
<main class="min-h-screen w-full bg-[#f7f3ea] px-6 py-12 text-[#31402b]">
|
|
1221
|
+
<div class="w-full rounded-[2rem] border border-[#7a8f6b]/20 bg-white/70 p-8">
|
|
1222
|
+
<div class="text-xs uppercase tracking-[0.24em] text-[#6d805f]">Auth Example</div>
|
|
1223
|
+
<h1 class="mt-4 text-4xl font-semibold text-[#24311f]">F1-backed Auth Starter</h1>
|
|
1224
|
+
<p class="mt-4 max-w-2xl text-lg leading-8 text-[#5f6d58]">This route shows how user and session records can live in the F1 store while your UI stays inside the same deterministic feature structure.</p>
|
|
1225
|
+
<div class="mt-8 grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
|
1226
|
+
<section class="rounded-3xl border border-[#7a8f6b]/15 bg-[#fcfaf5] p-6"><h2 class="text-lg font-semibold text-[#24311f]">Sign in</h2><p class="mt-2 text-sm text-[#61705b]">Use the default starter account to test a working username/password flow.</p><div class="mt-4 rounded-2xl bg-[#eef4e8] px-4 py-4 text-sm text-[#44513f]">username: <strong>\${escapeHtml(data?.hint.username ?? "founder")}</strong><br/>password: <strong>\${escapeHtml(data?.hint.password ?? "fiyuu123")}</strong></div><form id="fiyuu-auth-form" class="mt-5 space-y-3"><input id="fiyuu-auth-username" name="username" value="\${escapeHtml(data?.hint.username ?? "founder")}" placeholder="Username" class="w-full rounded-2xl border border-[#7a8f6b]/20 bg-white px-4 py-3 outline-none"/><input id="fiyuu-auth-password" name="password" value="\${escapeHtml(data?.hint.password ?? "fiyuu123")}" type="password" placeholder="Password" class="w-full rounded-2xl border border-[#7a8f6b]/20 bg-white px-4 py-3 outline-none"/><button id="fiyuu-auth-submit" type="submit" class="rounded-2xl bg-[#31402b] px-5 py-3 text-sm font-medium text-[#f7f3ea]">Sign in</button></form><div id="fiyuu-auth-result" class="mt-4 text-sm text-[#55654e]"></div></section>
|
|
1227
|
+
<section class="rounded-3xl border border-[#7a8f6b]/15 bg-[#fcfaf5] p-6"><h2 class="text-lg font-semibold text-[#24311f]">Users</h2><div class="mt-4 space-y-3">\${usersHtml}</div></section>
|
|
1228
|
+
<section class="rounded-3xl bg-[#31402b] p-6 text-[#f7f3ea]"><h2 class="text-lg font-semibold">Sessions</h2><div class="mt-4 space-y-3">\${sessionsHtml}</div></section>
|
|
1229
|
+
</div>
|
|
1230
|
+
</div>
|
|
1231
|
+
</main>
|
|
1232
|
+
<script type="module">\${script}</script>
|
|
1233
|
+
\`;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
`;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function createHomeSchema() {
|
|
1240
|
+
return `import { z } from "zod";
|
|
1241
|
+
|
|
1242
|
+
export const input = z.object({});
|
|
1243
|
+
|
|
1244
|
+
export const output = z.object({
|
|
1245
|
+
stats: z.array(
|
|
1246
|
+
z.object({
|
|
1247
|
+
label: z.string(),
|
|
1248
|
+
value: z.string(),
|
|
1249
|
+
}),
|
|
1250
|
+
),
|
|
1251
|
+
skills: z.array(z.string()),
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
export const description = "Loads the starter content for the Fiyuu home page";
|
|
1255
|
+
`;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function createHomePage(answers) {
|
|
1259
|
+
const themeMainClasses = answers.theming
|
|
1260
|
+
? 'min-h-screen bg-[linear-gradient(180deg,#f7f3ea_0%,#f1ebde_58%,#e9e0d0_100%)] px-4 py-4 text-[#33412f] dark:bg-[linear-gradient(180deg,#121614_0%,#171f1a_100%)] dark:text-[#e6efe8] sm:px-6 sm:py-5'
|
|
1261
|
+
: 'min-h-screen bg-[linear-gradient(180deg,#f7f3ea_0%,#f1ebde_58%,#e9e0d0_100%)] px-4 py-4 text-[#33412f] sm:px-6 sm:py-5';
|
|
1262
|
+
const themeSectionClasses = answers.theming
|
|
1263
|
+
? 'flex min-h-[calc(100vh-2rem)] w-full flex-col justify-between rounded-[1.5rem] border border-[#7a8f6b]/20 bg-[#f8f4ec]/80 p-5 dark:border-[#4f6756]/35 dark:bg-[#1b241f]/90 sm:min-h-[calc(100vh-2.5rem)] sm:p-7'
|
|
1264
|
+
: 'flex min-h-[calc(100vh-2rem)] w-full flex-col justify-between rounded-[1.5rem] border border-[#7a8f6b]/20 bg-[#f8f4ec]/80 p-5 sm:min-h-[calc(100vh-2.5rem)] sm:p-7';
|
|
1265
|
+
const themeNav = answers.theming
|
|
1266
|
+
? '<nav class="flex items-center justify-between"><p class="text-xs uppercase tracking-[0.22em] text-[#627356] dark:text-[#95b39d]">Fiyuu starter</p><button id="fiyuu-theme-toggle" type="button" class="rounded-full border border-[#7a8f6b]/20 px-3 py-1 text-xs text-[#43523f] dark:border-[#6f8d77]/30 dark:text-[#d1e3d6]">Dark</button></nav>'
|
|
1267
|
+
: '<p class="text-xs uppercase tracking-[0.22em] text-[#627356]">Fiyuu starter</p>';
|
|
1268
|
+
const themeScript = answers.theming
|
|
1269
|
+
? "const root=document.documentElement;const button=document.getElementById('fiyuu-theme-toggle');const saved=localStorage.getItem('fiyuu-theme');const initial=saved||'light';if(initial==='dark'){root.classList.add('dark');}if(button){button.textContent=root.classList.contains('dark')?'Light':'Dark';button.addEventListener('click',()=>{const next=root.classList.contains('dark')?'light':'dark';root.classList.toggle('dark',next==='dark');localStorage.setItem('fiyuu-theme',next);button.textContent=next==='dark'?'Light':'Dark';});}"
|
|
1270
|
+
: "";
|
|
1271
|
+
return `import { Component } from "@geajs/core";
|
|
1272
|
+
import { definePage, escapeHtml, html, type PageProps } from "fiyuu/client";
|
|
1273
|
+
|
|
1274
|
+
type HomeData = {
|
|
1275
|
+
stats: Array<{
|
|
1276
|
+
label: string;
|
|
1277
|
+
value: string;
|
|
1278
|
+
}>;
|
|
1279
|
+
skills: string[];
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
export const page = definePage({
|
|
1283
|
+
intent: "Minimal one-page home route for a focused Fiyuu starter",
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
export default class Page extends Component<PageProps<HomeData>> {
|
|
1287
|
+
template({ data }: PageProps<HomeData> = this.props) {
|
|
1288
|
+
const statsHtml = (data?.stats ?? [])
|
|
1289
|
+
.map(
|
|
1290
|
+
(item) => html\`<div class="rounded-xl border border-[#7a8f6b]/18 bg-[#fcfaf5] px-4 py-3"><p class="text-[11px] uppercase tracking-[0.2em] text-[#708067]">\${escapeHtml(item.label)}</p><p class="mt-1 text-2xl font-semibold text-[#263320]">\${escapeHtml(item.value)}</p></div>\`,
|
|
1291
|
+
)
|
|
1292
|
+
.join("");
|
|
1293
|
+
const skillsHtml = (data?.skills ?? [])
|
|
1294
|
+
.map((skill) => html\`<span class="rounded-full border border-[#7a8f6b]/20 px-3 py-1 text-xs text-[#44513f]">\${escapeHtml(skill)}</span>\`)
|
|
1295
|
+
.join("");
|
|
1296
|
+
const explainHtml = [
|
|
1297
|
+
{ title: "Single structure", body: "Routes, queries, actions, and metadata live in predictable folders." },
|
|
1298
|
+
{ title: "AI-readable", body: "Project docs and contracts stay explicit so assistants can reason safely." },
|
|
1299
|
+
{ title: "Gea-first runtime", body: "Rendering is optimized for Gea components with deterministic behavior." },
|
|
1300
|
+
]
|
|
1301
|
+
.map((item) => html\`<article class="rounded-xl border border-[#7a8f6b]/16 bg-[#fcfaf5] px-4 py-4"><h2 class="text-sm font-semibold text-[#24311f]">\${item.title}</h2><p class="mt-2 text-sm leading-6 text-[#5c6955]">\${item.body}</p></article>\`)
|
|
1302
|
+
.join("");
|
|
1303
|
+
return html\`
|
|
1304
|
+
<main class="${themeMainClasses}">
|
|
1305
|
+
<section class="${themeSectionClasses}">
|
|
1306
|
+
${themeNav}
|
|
1307
|
+
<header>
|
|
1308
|
+
<h1 class="mt-3 text-4xl font-semibold tracking-tight text-[#24311f] dark:text-[#ecf5ef] sm:text-5xl lg:text-6xl">Fiyuu is a structured fullstack framework for humans and AI.</h1>
|
|
1309
|
+
<p class="mt-4 max-w-4xl text-base leading-7 text-[#56654e] dark:text-[#b9cabc] sm:text-lg">It keeps route UI, server logic, and metadata in one deterministic layout so teams ship faster without losing clarity.</p>
|
|
1310
|
+
</header>
|
|
1311
|
+
<div class="mt-5 grid gap-3 lg:grid-cols-3">\${explainHtml}</div>
|
|
1312
|
+
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">\${statsHtml}</div>
|
|
1313
|
+
<footer class="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-[#7a8f6b]/15 pt-4">
|
|
1314
|
+
<p class="text-sm text-[#5f6d58] dark:text-[#acc1b1]">AI-first fullstack framework structure with deterministic routing.</p>
|
|
1315
|
+
<div class="flex flex-wrap gap-2">\${skillsHtml}</div>
|
|
1316
|
+
</footer>
|
|
1317
|
+
</section>
|
|
1318
|
+
</main>
|
|
1319
|
+
${answers.theming ? `<script type="module">${themeScript}</script>` : ''}
|
|
1320
|
+
\`;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
`;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function createF1Schema() {
|
|
1327
|
+
return `message TodoDraft {
|
|
1328
|
+
string id = 1;
|
|
1329
|
+
string title = 2;
|
|
1330
|
+
int64 createdAt = 3;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
message RequestRecord {
|
|
1334
|
+
string id = 1;
|
|
1335
|
+
string route = 2;
|
|
1336
|
+
string method = 3;
|
|
1337
|
+
string source = 4;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
message UserRecord {
|
|
1341
|
+
string id = 1;
|
|
1342
|
+
string username = 2;
|
|
1343
|
+
string role = 3;
|
|
1344
|
+
string passwordHash = 4;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
message SessionRecord {
|
|
1348
|
+
string id = 1;
|
|
1349
|
+
string userId = 2;
|
|
1350
|
+
string status = 3;
|
|
1351
|
+
}
|
|
1352
|
+
`;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function createF1Index() {
|
|
1356
|
+
return `import { existsSync } from "node:fs";
|
|
1357
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1358
|
+
import path from "node:path";
|
|
1359
|
+
|
|
1360
|
+
type TodoDraft = {
|
|
1361
|
+
id: string;
|
|
1362
|
+
title: string;
|
|
1363
|
+
createdAt: number;
|
|
1364
|
+
};
|
|
1365
|
+
|
|
1366
|
+
type RequestRecord = {
|
|
1367
|
+
id: string;
|
|
1368
|
+
route: string;
|
|
1369
|
+
method: string;
|
|
1370
|
+
source: string;
|
|
1371
|
+
};
|
|
1372
|
+
|
|
1373
|
+
type UserRecord = {
|
|
1374
|
+
id: string;
|
|
1375
|
+
username: string;
|
|
1376
|
+
role: string;
|
|
1377
|
+
passwordHash: string;
|
|
1378
|
+
};
|
|
1379
|
+
|
|
1380
|
+
type SessionRecord = {
|
|
1381
|
+
id: string;
|
|
1382
|
+
userId: string;
|
|
1383
|
+
status: string;
|
|
1384
|
+
};
|
|
1385
|
+
|
|
1386
|
+
type F1DatabaseShape = {
|
|
1387
|
+
drafts: TodoDraft[];
|
|
1388
|
+
requests: RequestRecord[];
|
|
1389
|
+
users: UserRecord[];
|
|
1390
|
+
sessions: SessionRecord[];
|
|
1391
|
+
};
|
|
1392
|
+
|
|
1393
|
+
const databasePath = path.resolve(process.cwd(), ".fiyuu", "data", "f1.json");
|
|
1394
|
+
let writeQueue = Promise.resolve();
|
|
1395
|
+
|
|
1396
|
+
class F1Table<TRecord extends { id: string }> {
|
|
1397
|
+
constructor(private readonly tableName: keyof F1DatabaseShape) {}
|
|
1398
|
+
|
|
1399
|
+
async findMany(): Promise<TRecord[]> {
|
|
1400
|
+
const database = await loadDatabase();
|
|
1401
|
+
return structuredClone(database[this.tableName]) as unknown as TRecord[];
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
async findById(id: string): Promise<TRecord | null> {
|
|
1405
|
+
const rows = await this.findMany();
|
|
1406
|
+
return rows.find((row) => row.id === id) ?? null;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
async findFirst(predicate: (record: TRecord) => boolean): Promise<TRecord | null> {
|
|
1410
|
+
const rows = await this.findMany();
|
|
1411
|
+
return rows.find(predicate) ?? null;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
async insert(record: TRecord): Promise<TRecord> {
|
|
1415
|
+
await mutateDatabase((database) => {
|
|
1416
|
+
database[this.tableName].unshift(record as never);
|
|
1417
|
+
});
|
|
1418
|
+
return record;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
async replace(records: TRecord[]): Promise<void> {
|
|
1422
|
+
await mutateDatabase((database) => {
|
|
1423
|
+
database[this.tableName] = records as never;
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
export function createF1Database() {
|
|
1429
|
+
return {
|
|
1430
|
+
table<TRecord extends { id: string }>(tableName: keyof F1DatabaseShape) {
|
|
1431
|
+
return new F1Table<TRecord>(tableName);
|
|
1432
|
+
},
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const f1 = createF1Database();
|
|
1437
|
+
|
|
1438
|
+
async function ensureDatabase(): Promise<void> {
|
|
1439
|
+
if (existsSync(databasePath)) {
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
await mkdir(path.dirname(databasePath), { recursive: true });
|
|
1444
|
+
await writeFile(databasePath, \`\${JSON.stringify(createSeedDatabase(), null, 2)}\n\`);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
async function loadDatabase(): Promise<F1DatabaseShape> {
|
|
1448
|
+
await ensureDatabase();
|
|
1449
|
+
const content = await readFile(databasePath, "utf8");
|
|
1450
|
+
return JSON.parse(content) as F1DatabaseShape;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
async function mutateDatabase(mutator: (database: F1DatabaseShape) => void): Promise<void> {
|
|
1454
|
+
writeQueue = writeQueue.then(async () => {
|
|
1455
|
+
const database = await loadDatabase();
|
|
1456
|
+
mutator(database);
|
|
1457
|
+
await mkdir(path.dirname(databasePath), { recursive: true });
|
|
1458
|
+
await writeFile(databasePath, \`\${JSON.stringify(database, null, 2)}\n\`);
|
|
1459
|
+
});
|
|
1460
|
+
await writeQueue;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function createSeedDatabase(): F1DatabaseShape {
|
|
1464
|
+
return {
|
|
1465
|
+
drafts: [],
|
|
1466
|
+
requests: [
|
|
1467
|
+
{ id: "req_001", route: "/", method: "GET", source: "starter-home" },
|
|
1468
|
+
{ id: "req_002", route: "/requests", method: "GET", source: "f1-store" },
|
|
1469
|
+
{ id: "req_003", route: "/live", method: "WS", source: "socket-feed" },
|
|
1470
|
+
],
|
|
1471
|
+
users: [
|
|
1472
|
+
{ id: "usr_001", username: "founder", role: "admin", passwordHash: hashPassword("fiyuu123") },
|
|
1473
|
+
{ id: "usr_002", username: "ops", role: "operator", passwordHash: hashPassword("ops12345") },
|
|
1474
|
+
],
|
|
1475
|
+
sessions: [
|
|
1476
|
+
{ id: "ses_001", userId: "usr_001", status: "active" },
|
|
1477
|
+
{ id: "ses_002", userId: "usr_002", status: "idle" },
|
|
1478
|
+
],
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
export async function saveTodoDraft(title: string): Promise<TodoDraft> {
|
|
1483
|
+
const drafts = f1.table<TodoDraft>("drafts");
|
|
1484
|
+
const draft = {
|
|
1485
|
+
id: \`f1_\${Date.now()}\`,
|
|
1486
|
+
title,
|
|
1487
|
+
createdAt: Date.now(),
|
|
1488
|
+
};
|
|
1489
|
+
return drafts.insert(draft);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
export async function listTodoDrafts(): Promise<TodoDraft[]> {
|
|
1493
|
+
return f1.table<TodoDraft>("drafts").findMany();
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
export async function listRequests(): Promise<RequestRecord[]> {
|
|
1497
|
+
return f1.table<RequestRecord>("requests").findMany();
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
export async function listUsers(): Promise<Array<{ id: string; username: string; role: string }>> {
|
|
1501
|
+
const users = await f1.table<UserRecord>("users").findMany();
|
|
1502
|
+
return users.map(({ passwordHash, ...user }) => user);
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
export async function listSessions(): Promise<SessionRecord[]> {
|
|
1506
|
+
return f1.table<SessionRecord>("sessions").findMany();
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
export async function createSession(username: string): Promise<SessionRecord> {
|
|
1510
|
+
const users = f1.table<UserRecord>("users");
|
|
1511
|
+
const sessions = f1.table<SessionRecord>("sessions");
|
|
1512
|
+
const user = await users.findFirst((entry) => entry.username === username);
|
|
1513
|
+
if (!user) {
|
|
1514
|
+
throw new Error("User not found");
|
|
1515
|
+
}
|
|
1516
|
+
const session = { id: \`ses_\${Date.now()}\`, userId: user.id, status: "active" };
|
|
1517
|
+
return sessions.insert(session);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
export async function signIn(username: string, password: string) {
|
|
1521
|
+
const users = f1.table<UserRecord>("users");
|
|
1522
|
+
const user = await users.findFirst((entry) => entry.username === username);
|
|
1523
|
+
if (!user) {
|
|
1524
|
+
return { success: false, message: "Unknown username", session: null, user: null };
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
if (user.passwordHash !== hashPassword(password)) {
|
|
1528
|
+
return { success: false, message: "Invalid password", session: null, user: null };
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
const session = await createSession(username);
|
|
1532
|
+
const { passwordHash, ...safeUser } = user;
|
|
1533
|
+
return { success: true, message: "Signed in successfully", session, user: safeUser };
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function hashPassword(password: string) {
|
|
1537
|
+
let hash = 2166136261;
|
|
1538
|
+
for (let index = 0; index < password.length; index += 1) {
|
|
1539
|
+
hash ^= password.charCodeAt(index);
|
|
1540
|
+
hash = Math.imul(hash, 16777619);
|
|
1541
|
+
}
|
|
1542
|
+
return \`f1_\${(hash >>> 0).toString(16)}\`;
|
|
1543
|
+
}
|
|
1544
|
+
`;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
function createSocketServer() {
|
|
1548
|
+
return `import type { WebSocket } from "ws";
|
|
1549
|
+
|
|
1550
|
+
export function registerSocketServer() {
|
|
1551
|
+
return {
|
|
1552
|
+
namespace: "updates",
|
|
1553
|
+
events: ["counter:tick", "socket:echo"],
|
|
1554
|
+
onConnect(socket: WebSocket) {
|
|
1555
|
+
socket.send(JSON.stringify({ type: "socket:connected", channel: "updates" }));
|
|
1556
|
+
let count = 0;
|
|
1557
|
+
const interval = setInterval(() => {
|
|
1558
|
+
count += 1;
|
|
1559
|
+
socket.send(JSON.stringify({ type: "counter:tick", count }));
|
|
1560
|
+
}, 1000);
|
|
1561
|
+
|
|
1562
|
+
socket.on("close", () => {
|
|
1563
|
+
clearInterval(interval);
|
|
1564
|
+
});
|
|
1565
|
+
},
|
|
1566
|
+
onMessage(socket: WebSocket, message: string) {
|
|
1567
|
+
socket.send(JSON.stringify({ type: "socket:echo", message }));
|
|
1568
|
+
},
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
`;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function createServerCrypto() {
|
|
1575
|
+
return `import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
1576
|
+
|
|
1577
|
+
type Envelope = {
|
|
1578
|
+
seed: string;
|
|
1579
|
+
payload: string;
|
|
1580
|
+
map: Record<string, string>;
|
|
1581
|
+
noise: Record<string, string>;
|
|
1582
|
+
};
|
|
1583
|
+
|
|
1584
|
+
function deriveKey(secret: string): Buffer {
|
|
1585
|
+
return createHash("sha256").update(secret).digest();
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function toBase64Url(input: string): string {
|
|
1589
|
+
return Buffer.from(input, "utf8").toString("base64url");
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
function fromBase64Url(input: string): string {
|
|
1593
|
+
return Buffer.from(input, "base64url").toString("utf8");
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function randomToken(size = 10): string {
|
|
1597
|
+
return randomBytes(size).toString("hex");
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
function createNoise(seed: string) {
|
|
1601
|
+
return {
|
|
1602
|
+
[randomToken(4)]: toBase64Url(seed.slice(0, 8)),
|
|
1603
|
+
[randomToken(4)]: toBase64Url(randomToken(6)),
|
|
1604
|
+
[randomToken(4)]: toBase64Url(\`\${Date.now()}\`),
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function obfuscateObject(value: Record<string, unknown>) {
|
|
1609
|
+
const map: Record<string, string> = {};
|
|
1610
|
+
const output: Record<string, unknown> = {};
|
|
1611
|
+
|
|
1612
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1613
|
+
const alias = randomToken(6);
|
|
1614
|
+
map[alias] = key;
|
|
1615
|
+
output[alias] = entry;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
return { map, output };
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
function restoreObject(value: Record<string, unknown>, map: Record<string, string>) {
|
|
1622
|
+
const output: Record<string, unknown> = {};
|
|
1623
|
+
|
|
1624
|
+
for (const [alias, entry] of Object.entries(value)) {
|
|
1625
|
+
const key = map[alias];
|
|
1626
|
+
if (key) {
|
|
1627
|
+
output[key] = entry;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
return output;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
export function encryptPayload(payload: Record<string, unknown>, secret: string): Envelope {
|
|
1635
|
+
const seed = randomToken(8);
|
|
1636
|
+
const { map, output } = obfuscateObject(payload);
|
|
1637
|
+
const iv = randomBytes(16);
|
|
1638
|
+
const cipher = createCipheriv("aes-256-cbc", deriveKey(secret), iv);
|
|
1639
|
+
const encoded = JSON.stringify(output);
|
|
1640
|
+
const encrypted = Buffer.concat([cipher.update(encoded, "utf8"), cipher.final()]);
|
|
1641
|
+
|
|
1642
|
+
return {
|
|
1643
|
+
seed,
|
|
1644
|
+
payload: \`\${iv.toString("hex")}\.\${encrypted.toString("base64url")}\`,
|
|
1645
|
+
map,
|
|
1646
|
+
noise: createNoise(seed),
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
export function decryptPayload(input: Envelope, secret: string) {
|
|
1651
|
+
const [ivHex, value] = input.payload.split(".");
|
|
1652
|
+
const decipher = createDecipheriv("aes-256-cbc", deriveKey(secret), Buffer.from(ivHex, "hex"));
|
|
1653
|
+
const decrypted = Buffer.concat([decipher.update(Buffer.from(value, "base64url")), decipher.final()]).toString("utf8");
|
|
1654
|
+
const parsed = JSON.parse(decrypted) as Record<string, unknown>;
|
|
1655
|
+
return restoreObject(parsed, input.map);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
export function hashValue(value: string, secret: string) {
|
|
1659
|
+
return createHash("sha256").update(\`\${secret}:\${value}\`).digest("hex");
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
export function encodeResponseMeta(meta: Record<string, string>) {
|
|
1663
|
+
return Object.fromEntries(Object.entries(meta).map(([key, value]) => [randomToken(5), toBase64Url(\`\${key}:\${value}\`)]));
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
export function decodeResponseMeta(meta: Record<string, string>) {
|
|
1667
|
+
const output: Record<string, string> = {};
|
|
1668
|
+
|
|
1669
|
+
for (const value of Object.values(meta)) {
|
|
1670
|
+
const decoded = fromBase64Url(value);
|
|
1671
|
+
const [key, entry] = decoded.split(":");
|
|
1672
|
+
output[key] = entry;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
return output;
|
|
1676
|
+
}
|
|
1677
|
+
`;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function createClientCrypto() {
|
|
1681
|
+
return `type Envelope = {
|
|
1682
|
+
seed: string;
|
|
1683
|
+
payload: string;
|
|
1684
|
+
map: Record<string, string>;
|
|
1685
|
+
noise: Record<string, string>;
|
|
1686
|
+
};
|
|
1687
|
+
|
|
1688
|
+
function randomToken(size = 12) {
|
|
1689
|
+
const bytes = new Uint8Array(size);
|
|
1690
|
+
crypto.getRandomValues(bytes);
|
|
1691
|
+
return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function toBase64Url(input: string) {
|
|
1695
|
+
return btoa(input).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/g, "");
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
function fromBase64Url(input: string) {
|
|
1699
|
+
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
1700
|
+
return atob(normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "="));
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function obfuscateObject(value: Record<string, unknown>) {
|
|
1704
|
+
const map: Record<string, string> = {};
|
|
1705
|
+
const output: Record<string, unknown> = {};
|
|
1706
|
+
|
|
1707
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1708
|
+
const alias = randomToken(6);
|
|
1709
|
+
map[alias] = key;
|
|
1710
|
+
output[alias] = entry;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
return { map, output };
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function restoreObject(value: Record<string, unknown>, map: Record<string, string>) {
|
|
1717
|
+
const output: Record<string, unknown> = {};
|
|
1718
|
+
|
|
1719
|
+
for (const [alias, entry] of Object.entries(value)) {
|
|
1720
|
+
const key = map[alias];
|
|
1721
|
+
if (key) {
|
|
1722
|
+
output[key] = entry;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
return output;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
async function importAesKey(secret: string) {
|
|
1730
|
+
const secretBytes = new TextEncoder().encode(secret);
|
|
1731
|
+
const digest = await crypto.subtle.digest("SHA-256", secretBytes);
|
|
1732
|
+
return crypto.subtle.importKey("raw", digest, { name: "AES-CBC" }, false, ["encrypt", "decrypt"]);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
export async function obfuscateRequest(payload: Record<string, unknown>, secret: string): Promise<Envelope> {
|
|
1736
|
+
const seed = randomToken(8);
|
|
1737
|
+
const { map, output } = obfuscateObject(payload);
|
|
1738
|
+
const iv = crypto.getRandomValues(new Uint8Array(16));
|
|
1739
|
+
const key = await importAesKey(secret);
|
|
1740
|
+
const encoded = new TextEncoder().encode(JSON.stringify(output));
|
|
1741
|
+
const encrypted = await crypto.subtle.encrypt({ name: "AES-CBC", iv }, key, encoded);
|
|
1742
|
+
|
|
1743
|
+
return {
|
|
1744
|
+
seed,
|
|
1745
|
+
payload: \`\${Array.from(iv, (value) => value.toString(16).padStart(2, "0")).join("")}\.\${toBase64Url(String.fromCharCode(...new Uint8Array(encrypted)))}\`,
|
|
1746
|
+
map,
|
|
1747
|
+
noise: {
|
|
1748
|
+
[randomToken(4)]: toBase64Url(seed.slice(0, 8)),
|
|
1749
|
+
[randomToken(4)]: toBase64Url(randomToken(6)),
|
|
1750
|
+
},
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
export async function readObfuscatedResponse(input: Envelope, secret: string) {
|
|
1755
|
+
const [ivHex, payload] = input.payload.split(".");
|
|
1756
|
+
const iv = new Uint8Array(ivHex.match(/.{1,2}/g)?.map((value) => parseInt(value, 16)) ?? []);
|
|
1757
|
+
const encrypted = Uint8Array.from(fromBase64Url(payload), (char) => char.charCodeAt(0));
|
|
1758
|
+
const key = await importAesKey(secret);
|
|
1759
|
+
const decrypted = await crypto.subtle.decrypt({ name: "AES-CBC", iv }, key, encrypted);
|
|
1760
|
+
const parsed = JSON.parse(new TextDecoder().decode(decrypted)) as Record<string, unknown>;
|
|
1761
|
+
return restoreObject(parsed, input.map);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
export async function signRequestBody(body: string) {
|
|
1765
|
+
const bytes = new TextEncoder().encode(body);
|
|
1766
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
1767
|
+
return Array.from(new Uint8Array(digest)).map((value) => value.toString(16).padStart(2, "0")).join("");
|
|
1768
|
+
}
|
|
1769
|
+
`;
|
|
1770
|
+
}
|