@tulipes/core 0.1.6 → 0.1.8

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/dist/cli/init.js CHANGED
@@ -96,6 +96,15 @@ function renderProject(name, coreVersion) {
96
96
  sync: "tulipes sync",
97
97
  check: "tulipes env:check",
98
98
  typecheck: "tsc --noEmit",
99
+ script: "tsx",
100
+ "pm2:start": "pm2 start ecosystem.config.cjs",
101
+ "pm2:start:staging": "pm2 start ecosystem.config.cjs --env staging",
102
+ "pm2:start:production": "pm2 start ecosystem.config.cjs --env production",
103
+ "pm2:stop": "pm2 stop ecosystem.config.cjs",
104
+ "pm2:restart": "pm2 restart ecosystem.config.cjs",
105
+ "pm2:delete": "pm2 delete ecosystem.config.cjs",
106
+ "pm2:logs": "pm2 logs",
107
+ "pm2:status": "pm2 status",
99
108
  },
100
109
  dependencies: {
101
110
  "@tulipes/core": core,
@@ -104,6 +113,8 @@ function renderProject(name, coreVersion) {
104
113
  devDependencies: {
105
114
  "@types/express": "^5",
106
115
  "@types/node": "^24",
116
+ // Local convenience: servers usually install pm2 globally instead.
117
+ pm2: "^6",
107
118
  tsx: "^4",
108
119
  typescript: "^7",
109
120
  },
@@ -121,8 +132,71 @@ function renderProject(name, coreVersion) {
121
132
  forceConsistentCasingInFileNames: true,
122
133
  noEmit: true,
123
134
  },
124
- include: ["app.ts", "worker.ts", "types", "modules", "config"],
135
+ include: ["app.ts", "worker.ts", "types", "modules", "config", "scripts"],
125
136
  })],
137
+ ["ecosystem.config.cjs", [
138
+ `// PM2 definitions for both Tulipes processes in all three modes.`,
139
+ `//`,
140
+ `// yarn pm2:start development`,
141
+ `// yarn pm2:start:staging staging`,
142
+ `// yarn pm2:start:production production`,
143
+ `// yarn pm2:stop | pm2:restart | pm2:delete | pm2:logs | pm2:status`,
144
+ `//`,
145
+ `// CommonJS on purpose: this app is an ESM package, and PM2 reads its`,
146
+ `// config with require().`,
147
+ ``,
148
+ `/**`,
149
+ ` * Each block repeats both variables rather than relying on PM2 merging`,
150
+ ` * env_<name> over env: a half-applied environment (right APP_ENV, wrong`,
151
+ ` * PROCESS_MODE) would start the wrong process against the wrong config,`,
152
+ ` * and the repetition is cheap insurance against that.`,
153
+ ` */`,
154
+ `const modes = (processMode) => ({`,
155
+ ` env: { APP_ENV: "development", PROCESS_MODE: processMode },`,
156
+ ` env_staging: { APP_ENV: "staging", PROCESS_MODE: processMode },`,
157
+ ` env_production: { APP_ENV: "production", PROCESS_MODE: processMode },`,
158
+ `});`,
159
+ ``,
160
+ `/** Shared by both apps. */`,
161
+ `const common = {`,
162
+ ` cwd: __dirname,`,
163
+ ` // The app runs from TypeScript, so PM2 drives it through tsx. Point`,
164
+ ` // script at the compiled entrypoint instead once you add a build.`,
165
+ ` interpreter: "./node_modules/.bin/tsx",`,
166
+ ` // Fork, not cluster: Socket.IO needs sticky sessions or the Redis`,
167
+ ` // adapter before a second instance is safe, and bootstrap tasks would`,
168
+ ` // run once per instance. Scale out only after wiring the adapter.`,
169
+ ` exec_mode: "fork",`,
170
+ ` instances: 1,`,
171
+ ` autorestart: true,`,
172
+ ` max_memory_restart: "512M",`,
173
+ ` // Graceful shutdown drains queues and closes mongo and redis. PM2's`,
174
+ ` // default 1.6s SIGKILL would cut that short and lose in-flight jobs.`,
175
+ ` kill_timeout: 10000,`,
176
+ `};`,
177
+ ``,
178
+ `module.exports = {`,
179
+ ` apps: [`,
180
+ ` {`,
181
+ ` ...common,`,
182
+ ` name: ${JSON.stringify(name)},`,
183
+ ` script: "app.ts",`,
184
+ ` error_file: "logs/backend-error.log",`,
185
+ ` out_file: "logs/backend-out.log",`,
186
+ ` ...modes("backend"),`,
187
+ ` },`,
188
+ ` {`,
189
+ ` ...common,`,
190
+ ` name: ${JSON.stringify(name + "-worker")},`,
191
+ ` script: "worker.ts",`,
192
+ ` error_file: "logs/worker-error.log",`,
193
+ ` out_file: "logs/worker-out.log",`,
194
+ ` ...modes("worker"),`,
195
+ ` },`,
196
+ ` ],`,
197
+ `};`,
198
+ ``,
199
+ ].join("\n")],
126
200
  [".yarnrc.yml", [
127
201
  `nodeLinker: node-modules`,
128
202
  ``,
@@ -140,6 +214,7 @@ function renderProject(name, coreVersion) {
140
214
  [".gitignore", [
141
215
  "node_modules/",
142
216
  "dist/",
217
+ "logs/",
143
218
  "*.log",
144
219
  "",
145
220
  "# local env overrides — the committed .envs/.env.<mode> files hold",
@@ -272,7 +347,14 @@ function renderProject(name, coreVersion) {
272
347
  ` * these instead of guessing the host from a request.`,
273
348
  ` */`,
274
349
  ` urls: {`,
275
- ` api: \`http://localhost:\${Environment.get("PORT")}\`,`,
350
+ ` // Built from PUBLIC_DOMAIN so links generated in production —`,
351
+ ` // reset emails, webhook callbacks — point at the real host`,
352
+ ` // instead of a localhost URL that is broken everywhere but this`,
353
+ ` // machine. The "localhost" sentinel separates the two cases.`,
354
+ ` api:`,
355
+ ` Environment.get("PUBLIC_DOMAIN") === "localhost"`,
356
+ ` ? \`http://localhost:\${Environment.get("PORT")}\``,
357
+ ` : \`https://\${Environment.get("PUBLIC_DOMAIN")}\`,`,
276
358
  ` frontend: "http://localhost:5173",`,
277
359
  ` },`,
278
360
  ``,
@@ -333,6 +415,386 @@ function renderProject(name, coreVersion) {
333
415
  `MONGO_URI=mongodb://127.0.0.1:27017/${name}`,
334
416
  `REDIS_URL=redis://127.0.0.1:6379`,
335
417
  ``,
418
+ `# Public hostname. "localhost" means not deployed; the nginx deploy`,
419
+ `# script refuses until this is a real domain.`,
420
+ `PUBLIC_DOMAIN=localhost`,
421
+ ``,
422
+ ].join("\n")],
423
+ [".envs/.env.staging", [
424
+ `# Staging. Committed like the development file: it holds structure and`,
425
+ `# non-secret defaults, never credentials.`,
426
+ `#`,
427
+ `# The blanks below are intentional. Real deployments inject MONGO_URI,`,
428
+ `# REDIS_URL and any secret through the container environment, which`,
429
+ `# always wins over this file. Booting without them fails closed with a`,
430
+ `# report naming every missing variable — run \`yarn check\` to see it`,
431
+ `# without starting the app.`,
432
+ ``,
433
+ `PORT=3000`,
434
+ `LOG_LEVEL=info`,
435
+ ``,
436
+ `# The hostname nginx serves this app from; also the filename the`,
437
+ `# deploy script writes into sites-available.`,
438
+ `PUBLIC_DOMAIN=`,
439
+ `CORS_ORIGINS=`,
440
+ ``,
441
+ `# MONGO_URI=`,
442
+ `# REDIS_URL=`,
443
+ ``,
444
+ ].join("\n")],
445
+ [".envs/.env.production", [
446
+ `# Production. Committed like the development file: it holds structure`,
447
+ `# and non-secret defaults, never credentials.`,
448
+ `#`,
449
+ `# The blanks below are intentional. Real deployments inject MONGO_URI,`,
450
+ `# REDIS_URL and any secret through the container environment, which`,
451
+ `# always wins over this file. Booting without them fails closed with a`,
452
+ `# report naming every missing variable — run \`yarn check\` to see it`,
453
+ `# without starting the app.`,
454
+ ``,
455
+ `PORT=3000`,
456
+ `LOG_LEVEL=info`,
457
+ ``,
458
+ `# The hostname nginx serves this app from; also the filename the`,
459
+ `# deploy script writes into sites-available.`,
460
+ `PUBLIC_DOMAIN=`,
461
+ `CORS_ORIGINS=`,
462
+ ``,
463
+ `# MONGO_URI=`,
464
+ `# REDIS_URL=`,
465
+ ``,
466
+ ].join("\n")],
467
+ ["nginx/development.conf", [
468
+ `# {{DOMAIN}} — rendered from nginx/development.conf by scripts/deploy-nginx.ts.`,
469
+ `#`,
470
+ `# Development rarely needs nginx at all: \`yarn dev\` already serves on`,
471
+ `# {{PORT}}. This exists so the proxy layer itself can be exercised locally —`,
472
+ `# websocket upgrades and forwarded headers behave differently through a`,
473
+ `# proxy, and finding that out in staging is finding out late.`,
474
+ `#`,
475
+ `# The deploy script refuses while PUBLIC_DOMAIN is still "localhost", so`,
476
+ `# using this means pointing a real hostname at the machine first.`,
477
+ ``,
478
+ `server {`,
479
+ ` listen 80;`,
480
+ ` server_name {{DOMAIN}};`,
481
+ ``,
482
+ ` access_log /var/log/nginx/{{DOMAIN}}.access.log;`,
483
+ ` error_log /var/log/nginx/{{DOMAIN}}.error.log;`,
484
+ ``,
485
+ ` client_max_body_size 1m;`,
486
+ ``,
487
+ ` location / {`,
488
+ ` proxy_pass http://127.0.0.1:{{PORT}};`,
489
+ ` proxy_http_version 1.1;`,
490
+ ``,
491
+ ` proxy_set_header Upgrade $http_upgrade;`,
492
+ ` proxy_set_header Connection $http_connection;`,
493
+ ``,
494
+ ` proxy_set_header Host $host;`,
495
+ ` proxy_set_header X-Real-IP $remote_addr;`,
496
+ ` proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`,
497
+ ` proxy_set_header X-Forwarded-Proto $scheme;`,
498
+ ``,
499
+ ` proxy_read_timeout 3600s;`,
500
+ ` proxy_send_timeout 3600s;`,
501
+ ` }`,
502
+ `}`,
503
+ ``,
504
+ ].join("\n")],
505
+ ["nginx/staging.conf", [
506
+ `# {{DOMAIN}} — rendered from nginx/staging.conf by scripts/deploy-nginx.ts.`,
507
+ `# Placeholders are substituted at deploy time; edit this template, never the`,
508
+ `# copy in /etc/nginx/sites-available.`,
509
+ `#`,
510
+ `# Identical to production except for the noindex header below.`,
511
+ ``,
512
+ `server {`,
513
+ ` listen 80;`,
514
+ ` listen [::]:80;`,
515
+ ` server_name {{DOMAIN}};`,
516
+ ``,
517
+ ` access_log /var/log/nginx/{{DOMAIN}}.access.log;`,
518
+ ` error_log /var/log/nginx/{{DOMAIN}}.error.log;`,
519
+ ``,
520
+ ` # Staging must never reach a search index, even if a link leaks.`,
521
+ ` add_header X-Robots-Tag "noindex, nofollow" always;`,
522
+ ``,
523
+ ` location /.well-known/acme-challenge/ {`,
524
+ ` root /var/www/html;`,
525
+ ` }`,
526
+ ``,
527
+ ` client_max_body_size 1m;`,
528
+ ``,
529
+ ` location / {`,
530
+ ` proxy_pass http://127.0.0.1:{{PORT}};`,
531
+ ` proxy_http_version 1.1;`,
532
+ ``,
533
+ ` proxy_set_header Upgrade $http_upgrade;`,
534
+ ` proxy_set_header Connection $http_connection;`,
535
+ ``,
536
+ ` proxy_set_header Host $host;`,
537
+ ` proxy_set_header X-Real-IP $remote_addr;`,
538
+ ` proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`,
539
+ ` proxy_set_header X-Forwarded-Proto $scheme;`,
540
+ ``,
541
+ ` proxy_read_timeout 3600s;`,
542
+ ` proxy_send_timeout 3600s;`,
543
+ ` }`,
544
+ `}`,
545
+ ``,
546
+ `# TLS: see nginx/production.conf — same block, same reason it ships`,
547
+ `# commented out.`,
548
+ ``,
549
+ ].join("\n")],
550
+ ["nginx/production.conf", [
551
+ `# {{DOMAIN}} — rendered from nginx/production.conf by scripts/deploy-nginx.ts.`,
552
+ `# Placeholders are substituted at deploy time; edit this template, never the`,
553
+ `# copy in /etc/nginx/sites-available.`,
554
+ `#`,
555
+ `# Debian/Ubuntu layout (sites-available + sites-enabled).`,
556
+ ``,
557
+ `server {`,
558
+ ` listen 80;`,
559
+ ` listen [::]:80;`,
560
+ ` server_name {{DOMAIN}};`,
561
+ ``,
562
+ ` access_log /var/log/nginx/{{DOMAIN}}.access.log;`,
563
+ ` error_log /var/log/nginx/{{DOMAIN}}.error.log;`,
564
+ ``,
565
+ ` # Certbot's HTTP-01 challenge, kept above the proxy so certificate`,
566
+ ` # issuance and renewal work without touching this file.`,
567
+ ` location /.well-known/acme-challenge/ {`,
568
+ ` root /var/www/html;`,
569
+ ` }`,
570
+ ``,
571
+ ` # Keep in step with config.app.ts http.bodyLimit. If nginx is stricter,`,
572
+ ` # oversized requests die here as an nginx HTML page instead of the`,
573
+ ` # API's JSON 413.`,
574
+ ` client_max_body_size 1m;`,
575
+ ``,
576
+ ` location / {`,
577
+ ` proxy_pass http://127.0.0.1:{{PORT}};`,
578
+ ` proxy_http_version 1.1;`,
579
+ ``,
580
+ ` # Socket.IO upgrades. Without these the handshake still succeeds and`,
581
+ ` # every connection silently degrades to HTTP polling.`,
582
+ ` proxy_set_header Upgrade $http_upgrade;`,
583
+ ` # Passing the client's own Connection header rather than a mapped`,
584
+ ` # variable: a \`map\` block here would collide the moment a second`,
585
+ ` # site on this server defines the same variable.`,
586
+ ` proxy_set_header Connection $http_connection;`,
587
+ ``,
588
+ ` proxy_set_header Host $host;`,
589
+ ` proxy_set_header X-Real-IP $remote_addr;`,
590
+ ` proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`,
591
+ ` proxy_set_header X-Forwarded-Proto $scheme;`,
592
+ ``,
593
+ ` # Set config.http.trustProxy to true so the app reads the real`,
594
+ ` # client IP from the headers above.`,
595
+ ``,
596
+ ` # Long-lived websockets must outlive nginx's 60s default.`,
597
+ ` proxy_read_timeout 3600s;`,
598
+ ` proxy_send_timeout 3600s;`,
599
+ ` }`,
600
+ `}`,
601
+ ``,
602
+ `# ── TLS ────────────────────────────────────────────────────────────────────`,
603
+ `# Commented out on purpose: nginx -t fails on certificate paths that do not`,
604
+ `# exist yet, which would make the very first deploy impossible. Install the`,
605
+ `# certificate, then uncomment this block, redeploy with --force, and add a`,
606
+ `# 301 redirect to the :80 server above.`,
607
+ `#`,
608
+ `# server {`,
609
+ `# listen 443 ssl;`,
610
+ `# listen [::]:443 ssl;`,
611
+ `# http2 on;`,
612
+ `# server_name {{DOMAIN}};`,
613
+ `#`,
614
+ `# ssl_certificate /etc/letsencrypt/live/{{DOMAIN}}/fullchain.pem;`,
615
+ `# ssl_certificate_key /etc/letsencrypt/live/{{DOMAIN}}/privkey.pem;`,
616
+ `#`,
617
+ `# # …repeat the location / block from above…`,
618
+ `# }`,
619
+ ``,
620
+ ].join("\n")],
621
+ ["scripts/deploy-nginx.ts", [
622
+ `/**`,
623
+ ` * Install this app's nginx site config.`,
624
+ ` *`,
625
+ ` * sudo -E APP_ENV=production yarn script scripts/deploy-nginx.ts`,
626
+ ` * APP_ENV=production yarn script scripts/deploy-nginx.ts --dry-run`,
627
+ ` *`,
628
+ ` * APP_ENV picks everything at once — which template is rendered, and the`,
629
+ ` * domain and port rendered into it — so the three can never disagree.`,
630
+ ` *`,
631
+ ` * It boots in script mode like every other script, which means the database`,
632
+ ` * and redis must be reachable from wherever you run it. That is the price`,
633
+ ` * of every script looking the same; run --dry-run from anywhere to preview.`,
634
+ ` *`,
635
+ ` * Debian/Ubuntu only: it writes sites-available and links sites-enabled.`,
636
+ ` * Certificates are yours to install — the rendered conf is HTTP-only, with`,
637
+ ` * a commented TLS block to uncomment once they exist.`,
638
+ ` */`,
639
+ `import { execFileSync } from "node:child_process";`,
640
+ `import { existsSync, lstatSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from "node:fs";`,
641
+ `import { join } from "node:path";`,
642
+ ``,
643
+ `import { boot } from "@tulipes/core/boot";`,
644
+ ``,
645
+ `const SITES_AVAILABLE = "/etc/nginx/sites-available";`,
646
+ `const SITES_ENABLED = "/etc/nginx/sites-enabled";`,
647
+ ``,
648
+ `const flags = new Set(process.argv.slice(2));`,
649
+ `const dryRun = flags.has("--dry-run");`,
650
+ `const force = flags.has("--force");`,
651
+ ``,
652
+ `const rootDir = join(import.meta.dirname, "..");`,
653
+ `const handle = await boot({ rootDir, mode: "script" });`,
654
+ ``,
655
+ `try {`,
656
+ ` await deploy();`,
657
+ `} finally {`,
658
+ ` // Without this the open mongo and redis handles keep the process alive`,
659
+ ` // and the script appears to hang after doing its work.`,
660
+ ` await handle.shutdown();`,
661
+ `}`,
662
+ ``,
663
+ `async function deploy(): Promise<void> {`,
664
+ ` const { appEnv } = handle.env;`,
665
+ ` const domain = String(handle.env.get("PUBLIC_DOMAIN")).trim();`,
666
+ ` const port = Number(handle.env.get("PORT"));`,
667
+ ``,
668
+ ` const templatePath = join(rootDir, "nginx", \`\${appEnv}.conf\`);`,
669
+ ` if (!existsSync(templatePath)) {`,
670
+ ` return fail(\`no template for APP_ENV=\${appEnv} — expected nginx/\${appEnv}.conf\`);`,
671
+ ` }`,
672
+ ``,
673
+ ` const conf = readFileSync(templatePath, "utf8")`,
674
+ ` .replaceAll("{{DOMAIN}}", domain)`,
675
+ ` .replaceAll("{{PORT}}", String(port));`,
676
+ ``,
677
+ ` // Preview stays available everywhere, including a laptop with no nginx`,
678
+ ` // and a domain still set to localhost.`,
679
+ ` if (dryRun) {`,
680
+ ` console.log(\`# \${templatePath} → \${SITES_AVAILABLE}/\${domain}\\n\`);`,
681
+ ` console.log(conf);`,
682
+ ` return;`,
683
+ ` }`,
684
+ ``,
685
+ ` if (domain === "localhost" || domain === "") {`,
686
+ ` return fail(`,
687
+ ` \`PUBLIC_DOMAIN is "\${domain}" — set a real hostname in .envs/.env.\${appEnv} before deploying\`,`,
688
+ ` );`,
689
+ ` }`,
690
+ ``,
691
+ ` // getuid is absent on Windows; its absence is itself a "not supported here".`,
692
+ ` if (process.getuid?.() !== 0) {`,
693
+ ` return fail("must run as root to write /etc/nginx — re-run with sudo -E");`,
694
+ ` }`,
695
+ ``,
696
+ ` if (!existsSync(SITES_AVAILABLE)) {`,
697
+ ` return fail(`,
698
+ ` \`\${SITES_AVAILABLE} does not exist — this script targets the Debian/Ubuntu nginx layout\`,`,
699
+ ` );`,
700
+ ` }`,
701
+ ``,
702
+ ` const availablePath = join(SITES_AVAILABLE, domain);`,
703
+ ` if (existsSync(availablePath) && !force) {`,
704
+ ` return fail(`,
705
+ ` \`\${availablePath} already exists — re-run with --force to overwrite it (it may have been hand-tuned on this box)\`,`,
706
+ ` );`,
707
+ ` }`,
708
+ ``,
709
+ ` writeFileSync(availablePath, conf);`,
710
+ ` console.log(\`wrote \${availablePath}\`);`,
711
+ ``,
712
+ ` const enabledPath = join(SITES_ENABLED, domain);`,
713
+ ` if (!linkSite(availablePath, enabledPath)) return;`,
714
+ ``,
715
+ ` if (!run("nginx", ["-t"], "nginx rejected the configuration — nothing was reloaded")) {`,
716
+ ` return;`,
717
+ ` }`,
718
+ ` if (!run("systemctl", ["reload", "nginx"], "nginx reload failed")) return;`,
719
+ ``,
720
+ ` console.log(\`\\n\${domain} → 127.0.0.1:\${port} (\${appEnv}) — nginx reloaded\`);`,
721
+ `}`,
722
+ ``,
723
+ `/** Idempotent: a link already pointing at the right file is the goal state. */`,
724
+ `function linkSite(target: string, link: string): boolean {`,
725
+ ` if (existsSync(link) || isSymlink(link)) {`,
726
+ ` const current = isSymlink(link) ? readlinkSync(link) : "(a regular file)";`,
727
+ ` if (current === target) {`,
728
+ ` console.log(\`link \${link} already correct\`);`,
729
+ ` return true;`,
730
+ ` }`,
731
+ ` fail(\`\${link} exists and points at \${current} — remove it and re-run\`);`,
732
+ ` return false;`,
733
+ ` }`,
734
+ ``,
735
+ ` symlinkSync(target, link);`,
736
+ ` console.log(\`linked \${link}\`);`,
737
+ ` return true;`,
738
+ `}`,
739
+ ``,
740
+ `function isSymlink(path: string): boolean {`,
741
+ ` try {`,
742
+ ` return lstatSync(path).isSymbolicLink();`,
743
+ ` } catch {`,
744
+ ` return false;`,
745
+ ` }`,
746
+ `}`,
747
+ ``,
748
+ `function run(command: string, args: string[], message: string): boolean {`,
749
+ ` try {`,
750
+ ` execFileSync(command, args, { stdio: "inherit" });`,
751
+ ` return true;`,
752
+ ` } catch (error) {`,
753
+ ` // A missing binary is not a rejected config: reporting the latter would`,
754
+ ` // send you hunting a syntax error that does not exist.`,
755
+ ` const missing = (error as NodeJS.ErrnoException).code === "ENOENT";`,
756
+ ` fail(missing ? \`could not run "\${command}" — is it installed and on PATH?\` : message);`,
757
+ ` return false;`,
758
+ ` }`,
759
+ `}`,
760
+ ``,
761
+ `function fail(message: string): void {`,
762
+ ` console.error(\`deploy-nginx: \${message}\`);`,
763
+ ` process.exitCode = 1;`,
764
+ `}`,
765
+ ``,
766
+ ].join("\n")],
767
+ ["scripts/count-greetings.ts", [
768
+ `/**`,
769
+ ` * Example maintenance script. Run it with:`,
770
+ ` *`,
771
+ ` * yarn script scripts/count-greetings.ts`,
772
+ ` *`,
773
+ ` * Script mode runs every phase the backend runs — environment, modules,`,
774
+ ` * config, ACL, database, models, queue producers — and then starts nothing.`,
775
+ ` * So a script gets exactly what a route handler gets, minus the server.`,
776
+ ` *`,
777
+ ` * The one thing to always copy from this file is the try/finally: the mongo`,
778
+ ` * and redis connections hold the event loop open, so a script that skips`,
779
+ ` * shutdown() prints its output and then hangs forever.`,
780
+ ` */`,
781
+ `import { join } from "node:path";`,
782
+ ``,
783
+ `import { boot } from "@tulipes/core/boot";`,
784
+ ``,
785
+ `const handle = await boot({ rootDir: join(import.meta.dirname, ".."), mode: "script" });`,
786
+ ``,
787
+ `try {`,
788
+ ` const { models, config } = handle.ctx;`,
789
+ ``,
790
+ ` const greetings = await models!.get("Greeting").find().select("name timesUsed -_id").lean();`,
791
+ ``,
792
+ ` console.log(\`\${config.app?.name} — \${greetings.length} greeting(s)\\n\`);`,
793
+ ` console.table(greetings);`,
794
+ `} finally {`,
795
+ ` await handle.shutdown();`,
796
+ `}`,
797
+ ``,
336
798
  ].join("\n")],
337
799
  // ── modules/core — sys tier, priority 0: the very first router ─────────
338
800
  ["modules/core/package.json", json({
@@ -368,6 +830,13 @@ function renderProject(name, coreVersion) {
368
830
  required: true,
369
831
  description: "Redis connection string (queues, cache)",
370
832
  },
833
+ {
834
+ name: "PUBLIC_DOMAIN",
835
+ type: "string",
836
+ group: "deploy",
837
+ description: 'Public hostname this API is served from; "localhost" means not deployed',
838
+ default: "localhost",
839
+ },
371
840
  ],
372
841
  })],
373
842
  ["modules/core/module.acl.ts", [
@@ -909,6 +1378,82 @@ function renderProject(name, coreVersion) {
909
1378
  `| \`yarn tulipes new module <name>\` | scaffold a module |`,
910
1379
  `| \`yarn tulipes update\` | upgrade the framework everywhere it is declared |`,
911
1380
  ``,
1381
+ `## Scripts`,
1382
+ ``,
1383
+ `\`scripts/\` holds one-off tasks — backfills, exports, admin chores.`,
1384
+ `They boot the app in **script mode**, which runs every phase the`,
1385
+ `backend runs and then starts no server, so a script gets exactly what`,
1386
+ `a route handler gets: config, ACL, models, queue producers.`,
1387
+ ``,
1388
+ "```sh",
1389
+ `yarn script scripts/count-greetings.ts`,
1390
+ "```",
1391
+ ``,
1392
+ `Copy the try/finally from \`scripts/count-greetings.ts\`: mongo and`,
1393
+ `redis hold the event loop open, so a script that forgets`,
1394
+ `\`handle.shutdown()\` prints its output and then hangs.`,
1395
+ ``,
1396
+ `## Deploying nginx`,
1397
+ ``,
1398
+ `\`nginx/{development,staging,production}.conf\` are templates with`,
1399
+ `\`{{DOMAIN}}\` and \`{{PORT}}\` placeholders. The deploy script renders`,
1400
+ `the one matching \`APP_ENV\`, using that same environment's`,
1401
+ `\`PUBLIC_DOMAIN\` and \`PORT\`, so template, domain and port can never`,
1402
+ `disagree:`,
1403
+ ``,
1404
+ "```sh",
1405
+ `APP_ENV=production yarn script scripts/deploy-nginx.ts --dry-run`,
1406
+ `sudo -E APP_ENV=production yarn script scripts/deploy-nginx.ts`,
1407
+ "```",
1408
+ ``,
1409
+ `It writes \`sites-available/<domain>\`, links \`sites-enabled\`, runs`,
1410
+ `\`nginx -t\`, then reloads. It refuses when not root, while`,
1411
+ `\`PUBLIC_DOMAIN\` is still \`localhost\`, or when the conf already exists`,
1412
+ `(pass \`--force\` to overwrite one). Debian/Ubuntu layout.`,
1413
+ ``,
1414
+ `The rendered conf is HTTP-only with the TLS block commented out —`,
1415
+ `\`nginx -t\` fails on certificate paths that do not exist yet, which`,
1416
+ `would make the first deploy impossible. Install the certificate,`,
1417
+ `uncomment the block, redeploy with \`--force\`.`,
1418
+ ``,
1419
+ `## Running under PM2`,
1420
+ ``,
1421
+ `\`ecosystem.config.cjs\` defines both processes — the backend and the`,
1422
+ `queue worker — in all three modes:`,
1423
+ ``,
1424
+ "```sh",
1425
+ `yarn pm2:start # development`,
1426
+ `yarn pm2:start:staging`,
1427
+ `yarn pm2:start:production`,
1428
+ ``,
1429
+ `yarn pm2:status # what is running`,
1430
+ `yarn pm2:logs # tail both processes`,
1431
+ `yarn pm2:restart # reload code`,
1432
+ `yarn pm2:stop # stop, keep them in the process list`,
1433
+ `yarn pm2:delete # remove from the process list`,
1434
+ "```",
1435
+ ``,
1436
+ `Switching environment requires a restart carrying the flag, because`,
1437
+ `PM2 remembers the environment a process was started with:`,
1438
+ `\`pm2 restart ecosystem.config.cjs --env production\`.`,
1439
+ ``,
1440
+ `\`.envs/\` holds one file per mode. All three are committed because they`,
1441
+ `carry structure and non-secret defaults only — connection strings and`,
1442
+ `secrets are injected by the server or container, which always wins over`,
1443
+ `the file. \`yarn check\` validates the current environment without`,
1444
+ `booting, so a deploy can fail on configuration before it fails on`,
1445
+ `traffic.`,
1446
+ ``,
1447
+ `PM2 is a devDependency so the scripts work straight after install;`,
1448
+ `servers usually install it globally instead (\`npm i -g pm2\`), in`,
1449
+ `which case the same scripts still work.`,
1450
+ ``,
1451
+ `Two defaults worth knowing: processes run in **fork** mode with a`,
1452
+ `single instance, because Socket.IO needs sticky sessions or the Redis`,
1453
+ `adapter before a second instance is safe; and \`kill_timeout\` is 10s so`,
1454
+ `graceful shutdown can drain queues and close mongo and redis before`,
1455
+ `PM2 sends SIGKILL.`,
1456
+ ``,
912
1457
  ].join("\n")]);
913
1458
  return files;
914
1459
  }