@tulipes/core 0.1.3 → 0.1.4
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/README.md +17 -1
- package/dist/cli/init.d.ts +17 -8
- package/dist/cli/init.js +490 -65
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/main.js +6 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/http/error-handler.js +21 -0
- package/dist/http/error-handler.js.map +1 -1
- package/package.json +3 -2
- package/templates/CLAUDE.md +72 -0
- package/templates/claude/skills/tulipes-boot-errors/SKILL.md +64 -0
- package/templates/claude/skills/tulipes-endpoint/SKILL.md +82 -0
- package/templates/claude/skills/tulipes-env-variable/SKILL.md +78 -0
- package/templates/claude/skills/tulipes-model/SKILL.md +75 -0
- package/templates/claude/skills/tulipes-module/SKILL.md +60 -0
- package/templates/claude/skills/tulipes-permissions/SKILL.md +61 -0
- package/templates/claude/skills/tulipes-queue/SKILL.md +62 -0
- package/templates/claude/skills/tulipes-socket/SKILL.md +55 -0
package/dist/cli/init.js
CHANGED
|
@@ -1,33 +1,40 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
/**
|
|
5
|
-
* `tulipes init [dir]` — scaffold a complete application
|
|
6
|
-
* ZERO infrastructure: the generated core module declares no MONGO_URI or
|
|
7
|
-
* REDIS_URL, and with no models and no queue files the pipeline skips both
|
|
8
|
-
* engines legitimately. `yarn install && yarn dev` must always succeed on a
|
|
9
|
-
* bare machine; databases arrive later as one meta.variables.json entry.
|
|
5
|
+
* `tulipes init [dir] [--full]` — scaffold a complete application.
|
|
10
6
|
*
|
|
11
7
|
* Three modules ship: core (sys 0 — env contract, roles, request ids),
|
|
12
|
-
* security (sys 10 —
|
|
13
|
-
*
|
|
8
|
+
* security (sys 10 — helmet, cors, logging, body parsing) and hello, a
|
|
9
|
+
* worked example of every module contract.
|
|
10
|
+
*
|
|
11
|
+
* By default the app boots with ZERO infrastructure: no MONGO_URI or
|
|
12
|
+
* REDIS_URL is declared, and with no models and no queue files the
|
|
13
|
+
* pipeline skips both engines legitimately, so `yarn install && yarn dev`
|
|
14
|
+
* always succeeds on a bare machine.
|
|
15
|
+
*
|
|
16
|
+
* `--full` adds the contracts that cannot work without infrastructure —
|
|
17
|
+
* models, bootstrap tasks and queues — along with the variables they
|
|
18
|
+
* require. That app needs mongo and redis running to boot.
|
|
14
19
|
*/
|
|
15
|
-
export async function runInit(rootDir, target) {
|
|
20
|
+
export async function runInit(rootDir, target, options = {}) {
|
|
16
21
|
const dir = resolve(rootDir, target ?? ".");
|
|
17
22
|
const name = sanitizeName(basename(dir));
|
|
23
|
+
const full = options.full ?? false;
|
|
18
24
|
if (existsSync(dir) && readdirSync(dir).some((entry) => entry !== ".git")) {
|
|
19
25
|
console.error(`refusing to init: ${dir} is not empty`);
|
|
20
26
|
process.exitCode = 1;
|
|
21
27
|
return;
|
|
22
28
|
}
|
|
23
29
|
const coreVersion = ownVersion();
|
|
24
|
-
const files = renderProject(name, coreVersion);
|
|
30
|
+
const files = renderProject(name, coreVersion, full);
|
|
25
31
|
for (const [relPath, content] of files) {
|
|
26
32
|
const filePath = join(dir, relPath);
|
|
27
33
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
28
34
|
writeFileSync(filePath, content);
|
|
29
35
|
console.log(` create ${relPath}`);
|
|
30
36
|
}
|
|
37
|
+
copyAgentGuides(dir);
|
|
31
38
|
console.log([
|
|
32
39
|
"",
|
|
33
40
|
`Project "${name}" is ready. Next:`,
|
|
@@ -39,8 +46,9 @@ export async function runInit(rootDir, target) {
|
|
|
39
46
|
"",
|
|
40
47
|
" → http://localhost:3000/hello",
|
|
41
48
|
"",
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
full
|
|
50
|
+
? "Needs mongo and redis running (MONGO_URI / REDIS_URL in .envs/.env.development).\nRun `yarn worker` in a second terminal to consume queued jobs."
|
|
51
|
+
: "No database or redis needed to boot — add MONGO_URI / REDIS_URL to\nmodules/core/meta.variables.json when your first model or queue lands,\nor scaffold them now with `tulipes init --full`.",
|
|
44
52
|
].filter((line) => line !== undefined).join("\n"));
|
|
45
53
|
}
|
|
46
54
|
/**
|
|
@@ -49,18 +57,47 @@ export async function runInit(rootDir, target) {
|
|
|
49
57
|
* the exports map deliberately doesn't expose package.json.
|
|
50
58
|
*/
|
|
51
59
|
function ownVersion() {
|
|
52
|
-
const
|
|
53
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
60
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8"));
|
|
54
61
|
return pkg.version;
|
|
55
62
|
}
|
|
63
|
+
function packageRoot() {
|
|
64
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Instructions for AI coding agents working in the generated app: an
|
|
68
|
+
* always-loaded CLAUDE.md plus task-scoped skills. They ship as real
|
|
69
|
+
* markdown in the package (templates/) rather than string literals here,
|
|
70
|
+
* so they stay editable and reviewable as documents.
|
|
71
|
+
*
|
|
72
|
+
* The source tree uses `claude/` and is written out as `.claude/`: npm
|
|
73
|
+
* has a long history of mangling dot-prefixed paths inside published
|
|
74
|
+
* tarballs, and this sidesteps it entirely.
|
|
75
|
+
*/
|
|
76
|
+
function copyAgentGuides(dir) {
|
|
77
|
+
const templates = join(packageRoot(), "templates");
|
|
78
|
+
if (!existsSync(templates))
|
|
79
|
+
return; // tolerate a stripped install
|
|
80
|
+
const claudeMd = join(templates, "CLAUDE.md");
|
|
81
|
+
if (existsSync(claudeMd)) {
|
|
82
|
+
cpSync(claudeMd, join(dir, "CLAUDE.md"));
|
|
83
|
+
console.log(" create CLAUDE.md");
|
|
84
|
+
}
|
|
85
|
+
const skills = join(templates, "claude");
|
|
86
|
+
if (existsSync(skills)) {
|
|
87
|
+
cpSync(skills, join(dir, ".claude"), { recursive: true });
|
|
88
|
+
for (const skill of readdirSync(join(skills, "skills"))) {
|
|
89
|
+
console.log(` create .claude/skills/${skill}/SKILL.md`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
56
93
|
function sanitizeName(raw) {
|
|
57
94
|
const name = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
58
95
|
return name || "tulipes-app";
|
|
59
96
|
}
|
|
60
97
|
const json = (value) => JSON.stringify(value, null, 2) + "\n";
|
|
61
|
-
function renderProject(name, coreVersion) {
|
|
98
|
+
function renderProject(name, coreVersion, full) {
|
|
62
99
|
const core = `^${coreVersion}`;
|
|
63
|
-
|
|
100
|
+
const files = [
|
|
64
101
|
["package.json", json({
|
|
65
102
|
name,
|
|
66
103
|
private: true,
|
|
@@ -305,6 +342,17 @@ function renderProject(name, coreVersion) {
|
|
|
305
342
|
`PORT=3000`,
|
|
306
343
|
`LOG_LEVEL=debug`,
|
|
307
344
|
``,
|
|
345
|
+
`# Browser origins allowed to call this API. Empty (the default) allows`,
|
|
346
|
+
`# none; "*" allows any but forbids credentials.`,
|
|
347
|
+
`CORS_ORIGINS=http://localhost:5173`,
|
|
348
|
+
``,
|
|
349
|
+
...(full
|
|
350
|
+
? [
|
|
351
|
+
`MONGO_URI=mongodb://127.0.0.1:27017/${name}`,
|
|
352
|
+
`REDIS_URL=redis://127.0.0.1:6379`,
|
|
353
|
+
``,
|
|
354
|
+
]
|
|
355
|
+
: []),
|
|
308
356
|
].join("\n")],
|
|
309
357
|
// ── modules/core — sys tier, priority 0: the very first router ─────────
|
|
310
358
|
["modules/core/package.json", json({
|
|
@@ -324,6 +372,26 @@ function renderProject(name, coreVersion) {
|
|
|
324
372
|
description: "HTTP port the web process listens on",
|
|
325
373
|
default: 3000,
|
|
326
374
|
},
|
|
375
|
+
// Infra connection strings live in the sys core module because every
|
|
376
|
+
// feature shares them — one owner per variable, no duplicates.
|
|
377
|
+
...(full
|
|
378
|
+
? [
|
|
379
|
+
{
|
|
380
|
+
name: "MONGO_URI",
|
|
381
|
+
type: "url",
|
|
382
|
+
group: "database",
|
|
383
|
+
required: true,
|
|
384
|
+
description: "MongoDB connection string",
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
name: "REDIS_URL",
|
|
388
|
+
type: "url",
|
|
389
|
+
group: "redis",
|
|
390
|
+
required: true,
|
|
391
|
+
description: "Redis connection string (queues, cache)",
|
|
392
|
+
},
|
|
393
|
+
]
|
|
394
|
+
: []),
|
|
327
395
|
],
|
|
328
396
|
})],
|
|
329
397
|
["modules/core/module.acl.ts", [
|
|
@@ -367,11 +435,14 @@ function renderProject(name, coreVersion) {
|
|
|
367
435
|
tulipes: { tier: "sys", priority: 10 },
|
|
368
436
|
dependencies: {
|
|
369
437
|
"@tulipes/core": core,
|
|
438
|
+
cors: "^2",
|
|
370
439
|
express: "^5",
|
|
440
|
+
helmet: "^8",
|
|
371
441
|
pino: "^9",
|
|
372
442
|
"pino-http": "^10",
|
|
373
443
|
"pino-pretty": "^13",
|
|
374
444
|
},
|
|
445
|
+
devDependencies: { "@types/cors": "^2" },
|
|
375
446
|
})],
|
|
376
447
|
["modules/security/meta.variables.json", json({
|
|
377
448
|
variables: [
|
|
@@ -383,28 +454,85 @@ function renderProject(name, coreVersion) {
|
|
|
383
454
|
description: "Minimum pino log level",
|
|
384
455
|
default: "info",
|
|
385
456
|
},
|
|
457
|
+
{
|
|
458
|
+
name: "CORS_ORIGINS",
|
|
459
|
+
type: "string",
|
|
460
|
+
group: "cors",
|
|
461
|
+
description: 'Comma-separated browser origins allowed to call this API; "*" allows any, empty allows none (same-origin only)',
|
|
462
|
+
default: "",
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
name: "CORS_CREDENTIALS",
|
|
466
|
+
type: "boolean",
|
|
467
|
+
group: "cors",
|
|
468
|
+
description: "Allow cookies and Authorization headers on cross-origin requests (cannot be combined with CORS_ORIGINS=*)",
|
|
469
|
+
default: false,
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
name: "HSTS_MAX_AGE",
|
|
473
|
+
type: "number",
|
|
474
|
+
group: "hardening",
|
|
475
|
+
description: "Strict-Transport-Security max-age in seconds; 0 disables the header (browsers only honour it over HTTPS)",
|
|
476
|
+
default: 15_552_000,
|
|
477
|
+
},
|
|
386
478
|
],
|
|
387
479
|
})],
|
|
388
|
-
["modules/security/helpers/
|
|
389
|
-
`import type {
|
|
480
|
+
["modules/security/helpers/hardening.ts", [
|
|
481
|
+
`import type { CorsOptions } from "cors";`,
|
|
482
|
+
`import type { HelmetOptions } from "helmet";`,
|
|
483
|
+
`import type { Environment } from "@tulipes/core/env";`,
|
|
390
484
|
``,
|
|
391
485
|
`/**`,
|
|
392
|
-
` *
|
|
393
|
-
` *
|
|
394
|
-
` *
|
|
486
|
+
` * Helmet tuned for an API rather than a web page. Its defaults assume a`,
|
|
487
|
+
` * site serving HTML and its own assets; this one serves JSON and`,
|
|
488
|
+
` * nothing else, so the policy can be far stricter.`,
|
|
395
489
|
` */`,
|
|
396
|
-
`export function
|
|
397
|
-
`
|
|
398
|
-
|
|
399
|
-
`
|
|
400
|
-
`
|
|
401
|
-
` //
|
|
402
|
-
`
|
|
403
|
-
`
|
|
404
|
-
`
|
|
490
|
+
`export function helmetOptions(environment: Environment): HelmetOptions {`,
|
|
491
|
+
` const maxAge = Number(environment.get("HSTS_MAX_AGE"));`,
|
|
492
|
+
``,
|
|
493
|
+
` return {`,
|
|
494
|
+
` // An API serves no active content: deny every resource type`,
|
|
495
|
+
` // outright, which defuses any HTML-ish response a bug produces.`,
|
|
496
|
+
` contentSecurityPolicy: {`,
|
|
497
|
+
` useDefaults: false,`,
|
|
498
|
+
` directives: { "default-src": ["'none'"], "frame-ancestors": ["'none'"] },`,
|
|
499
|
+
` },`,
|
|
500
|
+
` // Browsers only honour HSTS over HTTPS, so it is inert in local dev`,
|
|
501
|
+
` // — but a max-age of 0 lets a deployment switch it off deliberately.`,
|
|
502
|
+
` hsts: maxAge > 0 ? { maxAge, includeSubDomains: true } : false,`,
|
|
503
|
+
` // Helmet defaults to SAMEORIGIN; an API is never framed at all.`,
|
|
504
|
+
` frameguard: { action: "deny" },`,
|
|
505
|
+
` // Irrelevant without a browsing context, and each costs a header.`,
|
|
506
|
+
` originAgentCluster: false,`,
|
|
507
|
+
` crossOriginEmbedderPolicy: false,`,
|
|
405
508
|
` };`,
|
|
406
509
|
`}`,
|
|
407
510
|
``,
|
|
511
|
+
`/**`,
|
|
512
|
+
` * CORS from the environment, because allowed origins differ per`,
|
|
513
|
+
` * deployment. Fail-closed: the default is an empty list, meaning no`,
|
|
514
|
+
` * cross-origin browser may call this API until one is named.`,
|
|
515
|
+
` */`,
|
|
516
|
+
`export function corsOptions(environment: Environment): CorsOptions {`,
|
|
517
|
+
` const raw = String(environment.get("CORS_ORIGINS")).trim();`,
|
|
518
|
+
` const credentials = Boolean(environment.get("CORS_CREDENTIALS"));`,
|
|
519
|
+
``,
|
|
520
|
+
` if (raw === "*") {`,
|
|
521
|
+
` // "*" and credentials are mutually exclusive per the CORS spec — the`,
|
|
522
|
+
` // browser rejects the response, so refuse the combination at boot`,
|
|
523
|
+
` // rather than shipping an API that silently fails in production.`,
|
|
524
|
+
` if (credentials) {`,
|
|
525
|
+
` throw new Error(`,
|
|
526
|
+
` 'CORS_ORIGINS="*" cannot be combined with CORS_CREDENTIALS=true — list the origins explicitly',`,
|
|
527
|
+
` );`,
|
|
528
|
+
` }`,
|
|
529
|
+
` return { origin: true, credentials: false };`,
|
|
530
|
+
` }`,
|
|
531
|
+
``,
|
|
532
|
+
` const origins = raw.split(",").map((origin) => origin.trim()).filter(Boolean);`,
|
|
533
|
+
` return { origin: origins, credentials };`,
|
|
534
|
+
`}`,
|
|
535
|
+
``,
|
|
408
536
|
].join("\n")],
|
|
409
537
|
["modules/security/helpers/logger.ts", [
|
|
410
538
|
`import { pino, type Logger } from "pino";`,
|
|
@@ -434,26 +562,35 @@ function renderProject(name, coreVersion) {
|
|
|
434
562
|
``,
|
|
435
563
|
].join("\n")],
|
|
436
564
|
["modules/security/routes/security.routes.ts", [
|
|
565
|
+
`import cors from "cors";`,
|
|
437
566
|
`import express from "express";`,
|
|
567
|
+
`import helmet from "helmet";`,
|
|
438
568
|
`import { pinoHttp } from "pino-http";`,
|
|
439
569
|
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
440
570
|
``,
|
|
441
|
-
`import {
|
|
571
|
+
`import { corsOptions, helmetOptions } from "../helpers/hardening.js";`,
|
|
442
572
|
`import { logger } from "../helpers/logger.js";`,
|
|
443
573
|
``,
|
|
444
574
|
`/**`,
|
|
445
575
|
` * The app's request-hardening stack. Sys tier, so this mounts ahead of`,
|
|
446
576
|
` * every app-tier router — the framework core mounts no middleware of`,
|
|
447
577
|
` * its own; this module IS the pipeline's head.`,
|
|
578
|
+
` *`,
|
|
579
|
+
` * Order matters:`,
|
|
580
|
+
` * helmet — headers on every response, including errors and preflights`,
|
|
581
|
+
` * cors — must answer OPTIONS preflights before anything parses a body`,
|
|
582
|
+
` * pino — logs the request once the two above have had their say`,
|
|
583
|
+
` * json — parsing last, so a rejected origin never reaches the parser`,
|
|
448
584
|
` */`,
|
|
449
|
-
`export default function securityRoutes({ app, Environment }: Ctx): void {`,
|
|
450
|
-
` app!.use(
|
|
585
|
+
`export default function securityRoutes({ app, Environment, config }: Ctx): void {`,
|
|
586
|
+
` app!.use(helmet(helmetOptions(Environment)));`,
|
|
587
|
+
` app!.use(cors(corsOptions(Environment)));`,
|
|
451
588
|
``,
|
|
452
589
|
` app!.use(`,
|
|
453
590
|
` pinoHttp({`,
|
|
454
591
|
` logger: logger(Environment),`,
|
|
455
|
-
` // The core module (priority 0) stamps X-Request-Id
|
|
456
|
-
` // it so
|
|
592
|
+
` // The core module (priority 0) stamps X-Request-Id before this`,
|
|
593
|
+
` // runs — reuse it so logs and response headers tell one story.`,
|
|
457
594
|
` genReqId: (_req, res) => String(res.getHeader("X-Request-Id") ?? ""),`,
|
|
458
595
|
` serializers: {`,
|
|
459
596
|
` req: (req: { method: string; url: string }) => ({`,
|
|
@@ -466,64 +603,352 @@ function renderProject(name, coreVersion) {
|
|
|
466
603
|
` );`,
|
|
467
604
|
``,
|
|
468
605
|
` // Body-size cap is a security control too — a parser without a limit`,
|
|
469
|
-
` // is a memory-exhaustion invitation.`,
|
|
470
|
-
` app!.use(express.json({ limit: "1mb" }));`,
|
|
606
|
+
` // is a memory-exhaustion invitation. Tuned from config/app.config.ts.`,
|
|
607
|
+
` app!.use(express.json({ limit: config.http?.bodyLimit ?? "1mb" }));`,
|
|
471
608
|
`}`,
|
|
472
609
|
``,
|
|
473
610
|
].join("\n")],
|
|
474
|
-
// ── modules/hello — app tier: the proof route ──────────────────────────
|
|
475
611
|
["modules/hello/package.json", json({
|
|
476
612
|
name: `@app/hello`,
|
|
477
613
|
version: "0.0.0",
|
|
478
614
|
private: true,
|
|
479
615
|
type: "module",
|
|
480
616
|
tulipes: { tier: "app", priority: 100, dependsOn: [] },
|
|
481
|
-
dependencies: {
|
|
617
|
+
dependencies: {
|
|
618
|
+
"@tulipes/core": core,
|
|
619
|
+
express: "^5",
|
|
620
|
+
...(full ? { mongoose: "^8" } : {}),
|
|
621
|
+
},
|
|
482
622
|
})],
|
|
623
|
+
["modules/hello/meta.variables.json", json({
|
|
624
|
+
variables: [
|
|
625
|
+
{
|
|
626
|
+
name: "HELLO_GREETING",
|
|
627
|
+
type: "string",
|
|
628
|
+
group: "hello",
|
|
629
|
+
description: "Word this module greets callers with",
|
|
630
|
+
default: "Hello",
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
})],
|
|
634
|
+
["modules/hello/module.config.ts", [
|
|
635
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
636
|
+
``,
|
|
637
|
+
`/**`,
|
|
638
|
+
` * Runs in boot phase 6. Whatever this returns is stored at`,
|
|
639
|
+
` * \`config.hello\` and typed by \`tulipes sync\`, so every other module`,
|
|
640
|
+
` * reads it without importing anything from here.`,
|
|
641
|
+
` */`,
|
|
642
|
+
`export default function helloConfig({ Environment }: Ctx) {`,
|
|
643
|
+
` return {`,
|
|
644
|
+
` greeting: Environment.get("HELLO_GREETING"),`,
|
|
645
|
+
` };`,
|
|
646
|
+
`}`,
|
|
647
|
+
``,
|
|
648
|
+
`/** After the process is fully up (server listening / worker consuming). */`,
|
|
649
|
+
`export async function onReady({ config }: Ctx): Promise<void> {`,
|
|
650
|
+
` console.log(\`[hello] ready — greeting with "\${config.hello?.greeting}"\`);`,
|
|
651
|
+
`}`,
|
|
652
|
+
``,
|
|
653
|
+
`/**`,
|
|
654
|
+
` * Graceful shutdown. Hooks run in REVERSE load order, so a module can`,
|
|
655
|
+
` * rely on its dependencies still being alive while it cleans up.`,
|
|
656
|
+
` */`,
|
|
657
|
+
`export async function onShutdown(): Promise<void> {`,
|
|
658
|
+
` // Close anything this module owns: timers, external clients, streams.`,
|
|
659
|
+
`}`,
|
|
660
|
+
``,
|
|
661
|
+
].join("\n")],
|
|
662
|
+
["modules/hello/module.acl.ts", [
|
|
663
|
+
`import type { AclBuilder } from "@tulipes/core/acl";`,
|
|
664
|
+
``,
|
|
665
|
+
`/**`,
|
|
666
|
+
` * Roles are global and defined once by the sys core module; a feature`,
|
|
667
|
+
` * module only attaches grants, and always on resources namespaced by`,
|
|
668
|
+
` * its own name ("hello:*"). Granting the same role+resource from two`,
|
|
669
|
+
` * modules crashes the boot.`,
|
|
670
|
+
` */`,
|
|
671
|
+
`export default function helloAcl(acl: AclBuilder): void {`,
|
|
672
|
+
` acl.allow("user", "hello:read");`,
|
|
673
|
+
`}`,
|
|
674
|
+
``,
|
|
675
|
+
].join("\n")],
|
|
676
|
+
["modules/hello/helpers/greeting.ts", [
|
|
677
|
+
`/**`,
|
|
678
|
+
` * Pure logic: no Express, no database, no framework. Trivial to unit`,
|
|
679
|
+
` * test, and the framework never scans this folder — helpers are plain`,
|
|
680
|
+
` * imports. Anything two modules need moves up to lib/.`,
|
|
681
|
+
` */`,
|
|
682
|
+
`export function buildGreeting(greeting: string, name: string): string {`,
|
|
683
|
+
` return \`\${greeting}, \${name}!\`;`,
|
|
684
|
+
`}`,
|
|
685
|
+
``,
|
|
686
|
+
].join("\n")],
|
|
687
|
+
["modules/hello/controllers/hello.controllers.ts", [
|
|
688
|
+
`import type { Request, Response } from "express";`,
|
|
689
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
690
|
+
``,
|
|
691
|
+
`import { buildGreeting } from "../helpers/greeting.js";`,
|
|
692
|
+
``,
|
|
693
|
+
`/**`,
|
|
694
|
+
` * Controllers hold the work so routes stay a table of contents. The`,
|
|
695
|
+
` * framework does not scan this folder — routes import these directly,`,
|
|
696
|
+
` * which keeps the wiring explicit.`,
|
|
697
|
+
` *`,
|
|
698
|
+
` * Each controller takes ctx and RETURNS the handler. That closure is`,
|
|
699
|
+
` * what makes it testable: pass a stub context, get a plain function.`,
|
|
700
|
+
` */`,
|
|
701
|
+
`export function greet({ config }: Ctx) {`,
|
|
702
|
+
` return (req: Request, res: Response): void => {`,
|
|
703
|
+
` const name = String(req.query.name ?? "world");`,
|
|
704
|
+
` res.json({`,
|
|
705
|
+
` message: buildGreeting(String(config.hello?.greeting), name),`,
|
|
706
|
+
` app: config.app?.name,`,
|
|
707
|
+
` });`,
|
|
708
|
+
` };`,
|
|
709
|
+
`}`,
|
|
710
|
+
``,
|
|
711
|
+
].join("\n")],
|
|
483
712
|
["modules/hello/routes/hello.routes.ts", [
|
|
484
713
|
`import { Router } from "express";`,
|
|
485
714
|
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
715
|
+
`import { HttpError } from "@tulipes/core/http";`,
|
|
716
|
+
``,
|
|
717
|
+
`import { greet } from "../controllers/hello.controllers.js";`,
|
|
486
718
|
``,
|
|
487
|
-
`export default function helloRoutes(
|
|
719
|
+
`export default function helloRoutes(ctx: Ctx): Router {`,
|
|
488
720
|
` const router = Router();`,
|
|
721
|
+
` const { acl, config } = ctx;`,
|
|
722
|
+
``,
|
|
723
|
+
` // Built from the shared prefix, so versioning the whole API is a`,
|
|
724
|
+
` // one-line change in config/app.config.ts.`,
|
|
725
|
+
` const base = String(config.api?.prefix ?? "");`,
|
|
726
|
+
``,
|
|
727
|
+
` router.get(\`\${base}/hello\`, greet(ctx));`,
|
|
728
|
+
``,
|
|
729
|
+
` // Literal paths must be registered before parameterised ones, or`,
|
|
730
|
+
` // "/hello/:name" would swallow this.`,
|
|
731
|
+
` router.get(\`\${base}/hello/secret\`, (req, res) => {`,
|
|
732
|
+
` // A real app resolves the role in an auth module and puts it on the`,
|
|
733
|
+
` // request; this reads a header purely so the demo is curl-able.`,
|
|
734
|
+
` const role = String(req.header("x-demo-role") ?? "guest");`,
|
|
735
|
+
` if (!acl!.can(role, "hello:read")) {`,
|
|
736
|
+
` throw new HttpError(403, \`role "\${role}" may not read hello\`);`,
|
|
737
|
+
` }`,
|
|
738
|
+
` res.json({ secret: "only roles granted hello:read see this" });`,
|
|
739
|
+
` });`,
|
|
489
740
|
``,
|
|
490
|
-
`
|
|
491
|
-
`
|
|
741
|
+
` // Throwing is the whole error story — the framework's terminal`,
|
|
742
|
+
` // handler renders it. Express 5 forwards async rejections there too.`,
|
|
743
|
+
` router.get(\`\${base}/hello/:name\`, async (req, res) => {`,
|
|
744
|
+
` if (req.params.name === "nobody") {`,
|
|
745
|
+
` throw new HttpError(404, "nobody is not a greetable name");`,
|
|
746
|
+
` }`,
|
|
747
|
+
` res.json({ message: \`\${config.hello?.greeting}, \${req.params.name}!\` });`,
|
|
492
748
|
` });`,
|
|
493
749
|
``,
|
|
494
750
|
` return router;`,
|
|
495
751
|
`}`,
|
|
496
752
|
``,
|
|
497
753
|
].join("\n")],
|
|
498
|
-
["
|
|
499
|
-
|
|
754
|
+
["modules/hello/sockets/hello.sockets.ts", [
|
|
755
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
756
|
+
`import type { SocketRegistry } from "@tulipes/core/sockets";`,
|
|
500
757
|
``,
|
|
501
|
-
|
|
758
|
+
`/**`,
|
|
759
|
+
` * Web mode only. A namespace is claimed by exactly one module; emit`,
|
|
760
|
+
` * from anywhere with \`ctx.sockets!.of("/hello")\`.`,
|
|
761
|
+
` *`,
|
|
762
|
+
` * Try it: wscat -c ws://localhost:3000/hello`,
|
|
763
|
+
` */`,
|
|
764
|
+
`export default function helloSockets({ config }: Ctx, sockets: SocketRegistry): void {`,
|
|
765
|
+
` sockets.namespace("/hello", (nsp) => {`,
|
|
766
|
+
` nsp.on("connection", (socket) => {`,
|
|
767
|
+
` socket.emit("greeting", {`,
|
|
768
|
+
` message: config.hello?.greeting,`,
|
|
769
|
+
` online: nsp.sockets.size,`,
|
|
770
|
+
` });`,
|
|
771
|
+
``,
|
|
772
|
+
` socket.on("ping", (ack: (reply: string) => void) => ack("pong"));`,
|
|
773
|
+
` });`,
|
|
774
|
+
` });`,
|
|
775
|
+
`}`,
|
|
502
776
|
``,
|
|
503
|
-
|
|
777
|
+
].join("\n")],
|
|
778
|
+
];
|
|
779
|
+
// ── --full only: the contracts that need a database or redis ───────────
|
|
780
|
+
if (full) {
|
|
781
|
+
files.push(["modules/hello/models/greeting.model.ts", [
|
|
782
|
+
`import { Schema } from "mongoose";`,
|
|
783
|
+
`import type { ModelDef } from "@tulipes/core/db";`,
|
|
504
784
|
``,
|
|
505
|
-
|
|
506
|
-
`
|
|
507
|
-
`
|
|
508
|
-
`
|
|
509
|
-
|
|
785
|
+
`/**`,
|
|
786
|
+
` * A model file is a DECLARATION, never a registration: the framework`,
|
|
787
|
+
` * compiles the schema on its own connection and puts it in the model`,
|
|
788
|
+
` * store. Never call mongoose.model() yourself.`,
|
|
789
|
+
` *`,
|
|
790
|
+
` * Read it anywhere the context reaches: models!.get("Greeting").`,
|
|
791
|
+
` */`,
|
|
792
|
+
`const greetingSchema = new Schema(`,
|
|
793
|
+
` {`,
|
|
794
|
+
` name: { type: String, required: true, unique: true, lowercase: true, trim: true },`,
|
|
795
|
+
` message: { type: String, required: true },`,
|
|
796
|
+
` timesUsed: { type: Number, default: 0, min: 0 },`,
|
|
797
|
+
` },`,
|
|
798
|
+
` { timestamps: true },`,
|
|
799
|
+
`);`,
|
|
510
800
|
``,
|
|
511
|
-
`
|
|
512
|
-
`module asks for. When your first model lands, declare \`MONGO_URI\``,
|
|
513
|
-
`(type \`url\`, required) in \`modules/core/meta.variables.json\` and set`,
|
|
514
|
-
`it in \`.envs/.env.development\`; same with \`REDIS_URL\` for queues.`,
|
|
801
|
+
`export default { name: "Greeting", schema: greetingSchema } satisfies ModelDef;`,
|
|
515
802
|
``,
|
|
516
|
-
|
|
803
|
+
].join("\n")], ["modules/hello/bootstrap/seed-greetings.bootstrap.ts", [
|
|
804
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
517
805
|
``,
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
806
|
+
`/**`,
|
|
807
|
+
` * Runs after models are registered and before the server accepts`,
|
|
808
|
+
` * traffic — the place for indexes, seed rows and defaults.`,
|
|
809
|
+
` *`,
|
|
810
|
+
` * MUST be idempotent: it runs on every boot of every process, web and`,
|
|
811
|
+
` * worker alike. Upsert; never blind-insert.`,
|
|
812
|
+
` */`,
|
|
813
|
+
`export default async function seedGreetings({ models }: Ctx): Promise<void> {`,
|
|
814
|
+
` const Greeting = models!.get("Greeting");`,
|
|
525
815
|
``,
|
|
526
|
-
|
|
527
|
-
|
|
816
|
+
` await Greeting.updateOne(`,
|
|
817
|
+
` { name: "world" },`,
|
|
818
|
+
` { $setOnInsert: { name: "world", message: "Hello, world!" } },`,
|
|
819
|
+
` { upsert: true },`,
|
|
820
|
+
` );`,
|
|
821
|
+
`}`,
|
|
822
|
+
``,
|
|
823
|
+
].join("\n")], ["modules/hello/queues/hello.queues.ts", [
|
|
824
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
825
|
+
`import type { QueueRegistry } from "@tulipes/core/queues";`,
|
|
826
|
+
``,
|
|
827
|
+
`/**`,
|
|
828
|
+
` * ONE file describes both sides. The web process registers the queue`,
|
|
829
|
+
` * so routes can produce into it; \`yarn worker\` turns the processor`,
|
|
830
|
+
` * into a live consumer. Never duplicate the definition.`,
|
|
831
|
+
` *`,
|
|
832
|
+
` * Queue names use "." — BullMQ reserves ":" as its redis separator.`,
|
|
833
|
+
` */`,
|
|
834
|
+
`export default function helloQueues({ models }: Ctx, queues: QueueRegistry): void {`,
|
|
835
|
+
` queues.define("hello.count-greeting");`,
|
|
836
|
+
``,
|
|
837
|
+
` queues.process("hello.count-greeting", async (job) => {`,
|
|
838
|
+
` // Jobs retry, so processors must be idempotent-safe. Pass ids in`,
|
|
839
|
+
` // job.data and re-read state here rather than shipping documents.`,
|
|
840
|
+
` const { name } = job.data as { name: string };`,
|
|
841
|
+
` await models!.get("Greeting").updateOne({ name }, { $inc: { timesUsed: 1 } });`,
|
|
842
|
+
` return { counted: name };`,
|
|
843
|
+
` });`,
|
|
844
|
+
`}`,
|
|
845
|
+
``,
|
|
846
|
+
].join("\n")], ["modules/hello/routes/greetings.routes.ts", [
|
|
847
|
+
`import { Router } from "express";`,
|
|
848
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
849
|
+
`import { HttpError } from "@tulipes/core/http";`,
|
|
850
|
+
``,
|
|
851
|
+
`/**`,
|
|
852
|
+
` * A module may ship several routes files; each is discovered and`,
|
|
853
|
+
` * mounted independently. This one shows the database and the queue.`,
|
|
854
|
+
` */`,
|
|
855
|
+
`export default function greetingsRoutes({ config, models, queues }: Ctx): Router {`,
|
|
856
|
+
` const router = Router();`,
|
|
857
|
+
` const base = String(config.api?.prefix ?? "");`,
|
|
858
|
+
``,
|
|
859
|
+
` router.get(\`\${base}/greetings\`, async (req, res) => {`,
|
|
860
|
+
` // Pagination bounds are app-wide, so every list endpoint clamps`,
|
|
861
|
+
` // the same way and no caller can ask for the whole table.`,
|
|
862
|
+
` const { defaultLimit = 20, maxLimit = 100 } = config.pagination ?? {};`,
|
|
863
|
+
` const asked = Number(req.query.limit ?? defaultLimit);`,
|
|
864
|
+
` const limit = Math.min(Number.isFinite(asked) ? asked : defaultLimit, maxLimit);`,
|
|
865
|
+
``,
|
|
866
|
+
` const greetings = await models!`,
|
|
867
|
+
` .get("Greeting")`,
|
|
868
|
+
` .find()`,
|
|
869
|
+
` .select("name message timesUsed -_id")`,
|
|
870
|
+
` .limit(limit)`,
|
|
871
|
+
` .lean();`,
|
|
872
|
+
``,
|
|
873
|
+
` res.json({ count: greetings.length, limit, greetings });`,
|
|
874
|
+
` });`,
|
|
875
|
+
``,
|
|
876
|
+
` router.post(\`\${base}/greetings/:name/use\`, async (req, res) => {`,
|
|
877
|
+
` const { name } = req.params;`,
|
|
878
|
+
` const greeting = await models!.get("Greeting").findOne({ name }).lean();`,
|
|
879
|
+
` if (!greeting) throw new HttpError(404, \`no greeting named "\${name}"\`);`,
|
|
880
|
+
``,
|
|
881
|
+
` // Slow or retryable work belongs in a job, not in the request.`,
|
|
882
|
+
` // Run \`yarn worker\` in another terminal to see it processed.`,
|
|
883
|
+
` await queues!.add("hello.count-greeting", "count", { name });`,
|
|
884
|
+
` res.status(202).json({ name, queued: true });`,
|
|
885
|
+
` });`,
|
|
886
|
+
``,
|
|
887
|
+
` return router;`,
|
|
888
|
+
`}`,
|
|
889
|
+
``,
|
|
890
|
+
].join("\n")]);
|
|
891
|
+
}
|
|
892
|
+
files.push(["README.md", [
|
|
893
|
+
`# ${name}`,
|
|
894
|
+
``,
|
|
895
|
+
`A [Tulipes](https://www.npmjs.com/package/@tulipes/core) application.`,
|
|
896
|
+
``,
|
|
897
|
+
`## Run`,
|
|
898
|
+
``,
|
|
899
|
+
"```sh",
|
|
900
|
+
`corepack enable # once per machine — activates the pinned yarn 4`,
|
|
901
|
+
`yarn install`,
|
|
902
|
+
`yarn dev # boot the web process → http://localhost:3000/hello`,
|
|
903
|
+
"```",
|
|
904
|
+
``,
|
|
905
|
+
...(full
|
|
906
|
+
? [
|
|
907
|
+
`Needs mongo and redis running — see \`MONGO_URI\` and \`REDIS_URL\``,
|
|
908
|
+
`in \`.envs/.env.development\`. Run \`yarn worker\` in a second`,
|
|
909
|
+
`terminal to consume queued jobs.`,
|
|
910
|
+
]
|
|
911
|
+
: [
|
|
912
|
+
`Boots with no database and no redis: the pipeline skips engines no`,
|
|
913
|
+
`module asks for. When your first model lands, declare \`MONGO_URI\``,
|
|
914
|
+
`(type \`url\`, required) in \`modules/core/meta.variables.json\` and`,
|
|
915
|
+
`set it in \`.envs/.env.development\`; same with \`REDIS_URL\` for`,
|
|
916
|
+
`queues. \`tulipes init --full\` scaffolds all of that up front.`,
|
|
917
|
+
]),
|
|
918
|
+
``,
|
|
919
|
+
`## The hello module`,
|
|
920
|
+
``,
|
|
921
|
+
`\`modules/hello\` is a worked example of every module contract — read it`,
|
|
922
|
+
`before writing your own:`,
|
|
923
|
+
``,
|
|
924
|
+
`| File | Contract |`,
|
|
925
|
+
`|---|---|`,
|
|
926
|
+
`| \`meta.variables.json\` | the module's environment contract |`,
|
|
927
|
+
`| \`module.config.ts\` | config factory + \`onReady\` / \`onShutdown\` hooks |`,
|
|
928
|
+
`| \`module.acl.ts\` | permission grants on \`hello:*\` |`,
|
|
929
|
+
`| \`routes/*.routes.ts\` | endpoints, \`HttpError\`, ACL checks |`,
|
|
930
|
+
`| \`controllers/\` | the work; imported by routes, never scanned |`,
|
|
931
|
+
`| \`helpers/\` | pure logic, no framework |`,
|
|
932
|
+
`| \`sockets/*.sockets.ts\` | a claimed Socket.IO namespace |`,
|
|
933
|
+
...(full
|
|
934
|
+
? [
|
|
935
|
+
`| \`models/*.model.ts\` | a schema declaration for the model store |`,
|
|
936
|
+
`| \`bootstrap/*.bootstrap.ts\` | idempotent seeding, before traffic |`,
|
|
937
|
+
`| \`queues/*.queues.ts\` | a job definition and its processor |`,
|
|
938
|
+
]
|
|
939
|
+
: []),
|
|
940
|
+
``,
|
|
941
|
+
`## Daily commands`,
|
|
942
|
+
``,
|
|
943
|
+
`| command | does |`,
|
|
944
|
+
`|---|---|`,
|
|
945
|
+
`| \`yarn dev\` | regenerate types, run web process under tsx watch |`,
|
|
946
|
+
`| \`yarn worker\` | run the queue-consumer process (needs a queue first) |`,
|
|
947
|
+
`| \`yarn sync\` | regenerate \`types/config.d.ts\` + \`.env.example\` |`,
|
|
948
|
+
`| \`yarn check\` | validate env against every module contract — CI gate |`,
|
|
949
|
+
`| \`yarn tulipes new module <name>\` | scaffold a module |`,
|
|
950
|
+
``,
|
|
951
|
+
].join("\n")]);
|
|
952
|
+
return files;
|
|
528
953
|
}
|
|
529
954
|
//# sourceMappingURL=init.js.map
|