@alcedocore/cli 0.0.1-rc.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.
Files changed (70) hide show
  1. package/LICENSE.md +102 -0
  2. package/dist/commands/add-endpoint.js +104 -0
  3. package/dist/commands/add-migration.js +80 -0
  4. package/dist/commands/add-nav-item.js +72 -0
  5. package/dist/commands/add-page.js +64 -0
  6. package/dist/commands/build-frontend.js +126 -0
  7. package/dist/commands/compile-pages.js +120 -0
  8. package/dist/commands/connect.js +122 -0
  9. package/dist/commands/deploy.js +74 -0
  10. package/dist/commands/dev.js +13 -0
  11. package/dist/commands/init-core.js +900 -0
  12. package/dist/commands/init.js +124 -0
  13. package/dist/commands/init.test.js +84 -0
  14. package/dist/commands/migrate.js +22 -0
  15. package/dist/commands/proxy.js +157 -0
  16. package/dist/commands/publish.js +81 -0
  17. package/dist/commands/replay.js +142 -0
  18. package/dist/commands/serve-frontend.js +92 -0
  19. package/dist/config.js +110 -0
  20. package/dist/config.test.js +48 -0
  21. package/dist/index.js +81 -0
  22. package/dist/integration/cli-api.test.js +116 -0
  23. package/dist/utils/ejs-renderer.js +46 -0
  24. package/dist/utils/ejs-renderer.test.js +93 -0
  25. package/dist/utils/formatting.js +65 -0
  26. package/dist/utils/generateTimestamp.js +17 -0
  27. package/dist/utils/logger.js +33 -0
  28. package/dist/utils/validation.js +15 -0
  29. package/package.json +41 -0
  30. package/src/commands/add-endpoint.ts +157 -0
  31. package/src/commands/add-migration.ts +102 -0
  32. package/src/commands/add-nav-item.ts +106 -0
  33. package/src/commands/add-page.ts +100 -0
  34. package/src/commands/build-frontend.ts +148 -0
  35. package/src/commands/connect.ts +98 -0
  36. package/src/commands/deploy.ts +85 -0
  37. package/src/commands/dev.ts +12 -0
  38. package/src/commands/init-core.ts +1019 -0
  39. package/src/commands/init.test.ts +92 -0
  40. package/src/commands/init.ts +171 -0
  41. package/src/commands/migrate.ts +20 -0
  42. package/src/commands/proxy.ts +206 -0
  43. package/src/commands/publish.ts +106 -0
  44. package/src/commands/serve-frontend.ts +103 -0
  45. package/src/config.test.ts +50 -0
  46. package/src/config.ts +125 -0
  47. package/src/index.ts +100 -0
  48. package/src/integration/cli-api.test.ts +143 -0
  49. package/src/utils/ejs-renderer.ts +55 -0
  50. package/src/utils/formatting.ts +62 -0
  51. package/src/utils/generateTimestamp.ts +16 -0
  52. package/src/utils/logger.ts +27 -0
  53. package/src/utils/validation.ts +13 -0
  54. package/templates/endpoint/handler.js.ejs +23 -0
  55. package/templates/endpoint/handler.py.ejs +23 -0
  56. package/templates/migration/down.sql.ejs +6 -0
  57. package/templates/migration/up.sql.ejs +11 -0
  58. package/templates/page/page.vue.ejs +63 -0
  59. package/templates/plugin/Dockerfile.ejs +13 -0
  60. package/templates/plugin/Dockerfile.node.ejs +14 -0
  61. package/templates/plugin/README.md.ejs +19 -0
  62. package/templates/plugin/gitignore.ejs +6 -0
  63. package/templates/plugin/manifest.json.ejs +18 -0
  64. package/templates/plugin/migrations/.gitkeep +0 -0
  65. package/templates/plugin/pages/.gitkeep +0 -0
  66. package/templates/plugin/public/.gitkeep +0 -0
  67. package/templates/plugin/server.js.ejs +27 -0
  68. package/templates/plugin/server.py.ejs +32 -0
  69. package/tsconfig.json +16 -0
  70. package/vitest.config.ts +14 -0
@@ -0,0 +1,1019 @@
1
+ import { Command } from "commander";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import crypto from "node:crypto";
5
+ import { execSync } from "node:child_process";
6
+ import detectPort from "detect-port";
7
+ import checkDiskSpaceModule from "check-disk-space";
8
+ import {
9
+ createSpinner,
10
+ success,
11
+ error as logError,
12
+ info,
13
+ warn,
14
+ } from "../utils/logger";
15
+
16
+ interface CoreConfig {
17
+ platform: "docker" | "k8s";
18
+ adminEmail: string;
19
+ adminPassword: string;
20
+ dbPassword: string;
21
+ corePort: number;
22
+ swarmEnabled: boolean;
23
+ coreImageTag: string;
24
+ postgresVersion: string;
25
+ redisPassword: string;
26
+ sessionTtl: number;
27
+ rateLimitRequests: number;
28
+ rateLimitWindow: number;
29
+ corsOrigins: string;
30
+ enableCors: boolean;
31
+ enableRateLimit: boolean;
32
+ corePublicUrl: string;
33
+ registryPort: number;
34
+ systemPluginsUrl: string;
35
+ localRegistryUrl: string;
36
+ pluginNetwork: string;
37
+ registryEncryptionKey: string;
38
+ }
39
+
40
+ export const initCoreCommand = new Command("init-core")
41
+ .description(
42
+ "Generate configuration for running Alcedo Core (Docker Compose or Kubernetes)",
43
+ )
44
+ .option("-o, --output-dir <path>", "Output directory (default: ./alcedo)")
45
+ .option(
46
+ "-P, --platform <type>",
47
+ "Platform: docker or k8s (default: docker, prompts if interactive)",
48
+ )
49
+ .option(
50
+ "-e, --admin-email <email>",
51
+ "Admin email (skips prompt if provided)",
52
+ )
53
+ .option(
54
+ "-p, --admin-password <password>",
55
+ "Admin password (skips prompt if provided)",
56
+ )
57
+ .option("--port <port>", "Core HTTP port", parseInt)
58
+ .option("--non-interactive", "Skip prompts, use defaults", false)
59
+ .action(
60
+ async (options: {
61
+ outputDir?: string;
62
+ platform?: string;
63
+ adminEmail?: string;
64
+ adminPassword?: string;
65
+ port?: number;
66
+ nonInteractive: boolean;
67
+ }) => {
68
+ const targetDir = path.resolve(options.outputDir || "./alcedo");
69
+
70
+ if (
71
+ fs.existsSync(targetDir) &&
72
+ fs.readdirSync(targetDir).length > 0
73
+ ) {
74
+ logError(
75
+ `Directory already exists and is not empty: ${targetDir}`,
76
+ );
77
+ process.exit(1);
78
+ }
79
+
80
+ const defaults: CoreConfig = {
81
+ platform: (options.platform as "docker" | "k8s") || "docker",
82
+ adminEmail: options.adminEmail || "admin@alcedo.dev",
83
+ adminPassword: options.adminPassword || generatePassword(),
84
+ dbPassword: generatePassword(),
85
+ corePort: options.port || 8080,
86
+ swarmEnabled: false,
87
+ coreImageTag: "alcedocore/core:0.1.0",
88
+ postgresVersion: "16",
89
+ redisPassword: "",
90
+ sessionTtl: 86400,
91
+ rateLimitRequests: 200,
92
+ rateLimitWindow: 60,
93
+ corsOrigins: "*",
94
+ enableCors: true,
95
+ enableRateLimit: true,
96
+ corePublicUrl: "",
97
+ registryPort: 5000,
98
+ systemPluginsUrl: "https://cspm.alcedocore.nl",
99
+ localRegistryUrl: "localhost:5000",
100
+ pluginNetwork: "alcedo_plugins",
101
+ registryEncryptionKey: generateRegistryKey(),
102
+ };
103
+
104
+ // Pre-flight checks (Docker only)
105
+ if (
106
+ defaults.platform === "docker" &&
107
+ !(await preflightCheck(defaults))
108
+ ) {
109
+ logError(
110
+ "Pre-flight checks failed. Please fix the issues above and try again.",
111
+ );
112
+ process.exit(1);
113
+ }
114
+
115
+ try {
116
+ const config = options.nonInteractive
117
+ ? defaults
118
+ : await prompt(defaults);
119
+
120
+ await writeFiles(targetDir, config);
121
+ } catch (err: any) {
122
+ logError(`Failed to generate configuration: ${err.message}`);
123
+ process.exit(1);
124
+ }
125
+ },
126
+ );
127
+
128
+ function generatePassword(length = 24): string {
129
+ return crypto.randomBytes(length).toString("base64url").slice(0, length);
130
+ }
131
+
132
+ function generateRegistryKey(): string {
133
+ return crypto.randomBytes(32).toString("base64");
134
+ }
135
+
136
+ function checkDocker(): boolean {
137
+ try {
138
+ execSync("docker info --format '{{.ServerVersion}}'", {
139
+ stdio: "pipe",
140
+ timeout: 5000,
141
+ });
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ async function checkPort(port: number): Promise<boolean> {
149
+ try {
150
+ const actual = await detectPort(port);
151
+ return actual === port; // if detectPort returns same port, it's free
152
+ } catch {
153
+ return true; // on error, assume free
154
+ }
155
+ }
156
+
157
+ async function checkDiskSpaceFree(
158
+ minGB: number = 1,
159
+ ): Promise<{ ok: boolean; freeGB: number }> {
160
+ try {
161
+ const disk = await checkDiskSpaceModule("/");
162
+ const freeGB = Math.floor(disk.free / 1024 / 1024 / 1024);
163
+ return { ok: freeGB >= minGB, freeGB };
164
+ } catch {
165
+ return { ok: true, freeGB: 99 };
166
+ }
167
+ }
168
+
169
+ async function preflightCheck(config: CoreConfig): Promise<boolean> {
170
+ let ok = true;
171
+
172
+ if (!checkDocker()) {
173
+ logError("Docker is not installed or the daemon is not running.");
174
+ info(" Install Docker: https://docs.docker.com/engine/install/");
175
+ ok = false;
176
+ }
177
+
178
+ const disk = await checkDiskSpaceFree();
179
+ if (!disk.ok) {
180
+ warn(
181
+ `Low disk space: only ${disk.freeGB}GB available (minimum 1GB recommended).`,
182
+ );
183
+ }
184
+
185
+ if (!(await checkPort(config.corePort))) {
186
+ warn(
187
+ `Port ${config.corePort} is already in use. The core may fail to start.`,
188
+ );
189
+ }
190
+ if (!(await checkPort(config.registryPort))) {
191
+ warn(
192
+ `Port ${config.registryPort} is already in use. The registry may fail to start.`,
193
+ );
194
+ }
195
+
196
+ return ok;
197
+ }
198
+
199
+ function detectSwarmState(): "active" | "inactive" | "disabled" {
200
+ try {
201
+ const out = execSync(
202
+ "docker info --format '{{.Swarm.LocalNodeState}}'",
203
+ { encoding: "utf-8", timeout: 5000 },
204
+ ).trim();
205
+ if (out === "active") return "active";
206
+ if (out === "inactive") return "inactive";
207
+ return "disabled";
208
+ } catch {
209
+ return "disabled";
210
+ }
211
+ }
212
+
213
+ async function prompt(config: CoreConfig): Promise<CoreConfig> {
214
+ const inquirer = (await import("inquirer")).default;
215
+
216
+ const DEFAULT_PLUGINS_URL = "https://cspm.alcedocore.nl";
217
+
218
+ // Platform selection
219
+ const platformAnswer = await inquirer.prompt<{ platform: string }>([
220
+ {
221
+ type: "list",
222
+ name: "platform",
223
+ message: "Which platform?",
224
+ choices: [
225
+ {
226
+ name: "Docker Compose (local dev / single host)",
227
+ value: "docker",
228
+ },
229
+ { name: "Kubernetes (K8s cluster)", value: "k8s" },
230
+ ],
231
+ default: config.platform,
232
+ },
233
+ ]);
234
+ config.platform = platformAnswer.platform as "docker" | "k8s";
235
+
236
+ if (config.platform === "docker") {
237
+ // Detect Swarm state automatically
238
+ const swarmState = detectSwarmState();
239
+ if (swarmState === "active") {
240
+ config.swarmEnabled = true;
241
+ info("Docker Swarm is active — Swarm support enabled.");
242
+ } else if (swarmState === "inactive") {
243
+ const initSwarm = await inquirer.prompt<{ confirm: boolean }>([
244
+ {
245
+ type: "confirm",
246
+ name: "confirm",
247
+ message:
248
+ "Docker Swarm is not initialized. Initialize it now?",
249
+ default: false,
250
+ },
251
+ ]);
252
+ if (initSwarm.confirm) {
253
+ try {
254
+ execSync("docker swarm init", {
255
+ stdio: "ignore",
256
+ timeout: 10000,
257
+ });
258
+ config.swarmEnabled = true;
259
+ info("Docker Swarm initialized.");
260
+ } catch {
261
+ info(
262
+ "Failed to initialize Swarm — proceeding without Swarm support.",
263
+ );
264
+ config.swarmEnabled = false;
265
+ }
266
+ } else {
267
+ config.swarmEnabled = false;
268
+ }
269
+ } else {
270
+ info(
271
+ "Docker Swarm is not available on this host — Swarm support disabled.",
272
+ );
273
+ config.swarmEnabled = false;
274
+ }
275
+ }
276
+
277
+ const basic = await inquirer.prompt<{
278
+ adminEmail: string;
279
+ adminPassword: string;
280
+ corePort: number;
281
+ corePublicUrl: string;
282
+ useCustomPluginsUrl: boolean;
283
+ advanced: boolean;
284
+ }>([
285
+ {
286
+ type: "input",
287
+ name: "adminEmail",
288
+ message: "Admin email:",
289
+ default: config.adminEmail,
290
+ validate: (v: string) =>
291
+ v.includes("@") || "Please enter a valid email",
292
+ },
293
+ {
294
+ type: "password",
295
+ name: "adminPassword",
296
+ message: "Admin password:",
297
+ default: config.adminPassword,
298
+ validate: (v: string) =>
299
+ v.length >= 8 || "Password must be at least 8 characters",
300
+ },
301
+ {
302
+ type: "number",
303
+ name: "corePort",
304
+ message: "Core HTTP port:",
305
+ default: config.corePort,
306
+ },
307
+ {
308
+ type: "input",
309
+ name: "corePublicUrl",
310
+ message:
311
+ "Core public URL (leave empty to use http://localhost:<port>):",
312
+ default: config.corePublicUrl,
313
+ },
314
+ {
315
+ type: "confirm",
316
+ name: "useCustomPluginsUrl",
317
+ message: "Do you want to use a custom system plugins manifest URL?",
318
+ default: false,
319
+ },
320
+ {
321
+ type: "confirm",
322
+ name: "advanced",
323
+ message: "Configure advanced options?",
324
+ default: false,
325
+ },
326
+ ]);
327
+
328
+ config.adminEmail = basic.adminEmail;
329
+ config.adminPassword = basic.adminPassword;
330
+ config.corePort = basic.corePort;
331
+ config.corePublicUrl = basic.corePublicUrl;
332
+
333
+ if (basic.useCustomPluginsUrl) {
334
+ const custom = await inquirer.prompt<{ systemPluginsUrl: string }>([
335
+ {
336
+ type: "input",
337
+ name: "systemPluginsUrl",
338
+ message: "Custom system plugins manifest URL:",
339
+ default: config.systemPluginsUrl,
340
+ },
341
+ ]);
342
+ config.systemPluginsUrl = custom.systemPluginsUrl;
343
+ } else {
344
+ config.systemPluginsUrl = DEFAULT_PLUGINS_URL;
345
+ }
346
+
347
+ if (basic.advanced) {
348
+ const advanced = await inquirer.prompt<{
349
+ dbPassword: string;
350
+ redisPassword: string;
351
+ coreImageTag: string;
352
+ postgresVersion: string;
353
+ sessionTtl: number;
354
+ enableCors: boolean;
355
+ corsOrigins: string;
356
+ enableRateLimit: boolean;
357
+ rateLimitRequests: number;
358
+ rateLimitWindow: number;
359
+ pluginNetwork: string;
360
+ registryPort: number;
361
+ localRegistryUrl: string;
362
+ }>([
363
+ {
364
+ type: "input",
365
+ name: "dbPassword",
366
+ message: "PostgreSQL password:",
367
+ default: config.dbPassword,
368
+ },
369
+ {
370
+ type: "input",
371
+ name: "redisPassword",
372
+ message: "Redis password (leave empty for none):",
373
+ default: config.redisPassword,
374
+ },
375
+ {
376
+ type: "input",
377
+ name: "coreImageTag",
378
+ message: "Core Docker image tag:",
379
+ default: config.coreImageTag,
380
+ },
381
+ {
382
+ type: "input",
383
+ name: "postgresVersion",
384
+ message: "PostgreSQL version:",
385
+ default: config.postgresVersion,
386
+ },
387
+ {
388
+ type: "number",
389
+ name: "sessionTtl",
390
+ message: "Session TTL (seconds):",
391
+ default: config.sessionTtl,
392
+ },
393
+ {
394
+ type: "confirm",
395
+ name: "enableCors",
396
+ message: "Enable CORS?",
397
+ default: config.enableCors,
398
+ },
399
+ {
400
+ type: "input",
401
+ name: "corsOrigins",
402
+ message: "Allowed CORS origins (comma-separated or *):",
403
+ default: config.corsOrigins,
404
+ when: (a: any) => a.enableCors,
405
+ },
406
+ {
407
+ type: "confirm",
408
+ name: "enableRateLimit",
409
+ message: "Enable rate limiting?",
410
+ default: config.enableRateLimit,
411
+ },
412
+ {
413
+ type: "number",
414
+ name: "rateLimitRequests",
415
+ message: "Max requests per window:",
416
+ default: config.rateLimitRequests,
417
+ when: (a: any) => a.enableRateLimit,
418
+ },
419
+ {
420
+ type: "number",
421
+ name: "rateLimitWindow",
422
+ message: "Rate limit window (seconds):",
423
+ default: config.rateLimitWindow,
424
+ when: (a: any) => a.enableRateLimit,
425
+ },
426
+ {
427
+ type: "input",
428
+ name: "pluginNetwork",
429
+ message: "Docker network name for plugin containers:",
430
+ default: config.pluginNetwork,
431
+ },
432
+ {
433
+ type: "number",
434
+ name: "registryPort",
435
+ message: "Docker registry port:",
436
+ default: config.registryPort,
437
+ },
438
+ {
439
+ type: "input",
440
+ name: "localRegistryUrl",
441
+ message: "Local registry URL (for plugin image storage):",
442
+ default: config.localRegistryUrl,
443
+ },
444
+ ]);
445
+
446
+ config.dbPassword = advanced.dbPassword;
447
+ config.redisPassword = advanced.redisPassword;
448
+ config.coreImageTag = advanced.coreImageTag;
449
+ config.postgresVersion = advanced.postgresVersion;
450
+ config.sessionTtl = advanced.sessionTtl;
451
+ config.enableCors = advanced.enableCors;
452
+ config.corsOrigins = advanced.corsOrigins;
453
+ config.enableRateLimit = advanced.enableRateLimit;
454
+ config.rateLimitRequests = advanced.rateLimitRequests;
455
+ config.rateLimitWindow = advanced.rateLimitWindow;
456
+ config.pluginNetwork = advanced.pluginNetwork;
457
+ config.registryPort = advanced.registryPort;
458
+ config.localRegistryUrl = advanced.localRegistryUrl;
459
+ }
460
+
461
+ return config;
462
+ }
463
+
464
+ function generateCompose(config: CoreConfig): string {
465
+ const securityVars = `
466
+ SECURITY_COOKIE_SECURE: "false"
467
+ SECURITY_COOKIE_SAMESITE: "lax"
468
+ SECURITY_COOKIE_HTTP_ONLY: "true"
469
+ SECURITY_CORS_ENABLED: "${config.enableCors}"
470
+ SECURITY_CORS_ORIGINS: "${config.corsOrigins}"
471
+ SECURITY_RATE_LIMIT_ENABLED: "${config.enableRateLimit}"
472
+ RATE_LIMIT_AUTH_REQUESTS: "${config.rateLimitRequests}"
473
+ RATE_LIMIT_AUTH_WINDOW: "${config.rateLimitWindow}"
474
+ RATE_LIMIT_API_REQUESTS: "${config.rateLimitRequests}"
475
+ RATE_LIMIT_API_WINDOW: "${config.rateLimitWindow}"
476
+ SECURITY_PASSWORD_MIN_LENGTH: "8"
477
+ SECURITY_SESSION_FIXATION: "true"`;
478
+
479
+ return `services:
480
+ nginx:
481
+ image: nginx:alpine
482
+ ports:
483
+ - "${config.corePort}:${config.corePort}"
484
+ volumes:
485
+ - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
486
+ depends_on:
487
+ - core
488
+ core:
489
+ image: ${config.coreImageTag}
490
+ depends_on:
491
+ postgres:
492
+ condition: service_healthy
493
+ redis:
494
+ condition: service_healthy
495
+ environment:
496
+ DATABASE_URL: postgres://postgres:${config.dbPassword}@postgres:5432/alcedo
497
+ REDIS_URL: redis://:${config.redisPassword || ""}@redis:6379
498
+ CORE_PORT: "8080"
499
+ DEV_MODE: "false"
500
+ RUST_LOG: info
501
+ ADMIN_EMAIL: "${config.adminEmail}"
502
+ ADMIN_PASSWORD: "${config.adminPassword}"
503
+ PLUGIN_NETWORK: ${config.pluginNetwork}
504
+ PLUGINS_DIR: /var/lib/plugin-public
505
+ PLUGINS_DIR: /plugins
506
+ CORE_PUBLIC_URL: "${config.corePublicUrl || `http://localhost:${config.corePort}`}"
507
+ SYSTEM_PLUGINS_URL: "${config.systemPluginsUrl}"
508
+ LOCAL_REGISTRY_URL: "${config.localRegistryUrl}"
509
+ SESSION_TTL_SECONDS: "${config.sessionTtl}"
510
+ REGISTRY_ENCRYPTION_KEY: "${config.registryEncryptionKey}"
511
+ COLLECTION_LOG_FLUSH_MS: "100"${securityVars}
512
+ networks:
513
+ - default
514
+ - ${config.pluginNetwork}
515
+ extra_hosts:
516
+ - "host.docker.internal:host-gateway"
517
+ volumes:
518
+ - /var/run/docker.sock:/var/run/docker.sock
519
+ - plugin-public:/var/lib/plugin-public
520
+ - ./plugins:/plugins
521
+ - ./plugins:/app/plugins
522
+
523
+ registry:
524
+ image: registry:2
525
+ ports:
526
+ - "${config.registryPort}:5000"
527
+ volumes:
528
+ - registry-data:/var/lib/registry
529
+ healthcheck:
530
+ test: ["CMD", "wget", "--spider", "http://localhost:5000/"]
531
+ interval: 10s
532
+ timeout: 5s
533
+ retries: 3
534
+
535
+ postgres:
536
+ image: postgres:${config.postgresVersion}
537
+ environment:
538
+ POSTGRES_USER: postgres
539
+ POSTGRES_PASSWORD: ${config.dbPassword}
540
+ POSTGRES_DB: alcedo
541
+ healthcheck:
542
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
543
+ interval: 5s
544
+ timeout: 5s
545
+ retries: 5
546
+ volumes:
547
+ - postgres-data:/var/lib/postgresql/data
548
+
549
+ redis:
550
+ image: redis:7-alpine
551
+ restart: unless-stopped
552
+ command: ${config.redisPassword ? `["redis-server", "--requirepass", "${config.redisPassword}"]` : '["redis-server"]'}
553
+ healthcheck:
554
+ test: ["CMD", "redis-cli", "ping"]
555
+ interval: 5s
556
+ timeout: 3s
557
+ retries: 5
558
+ volumes:
559
+ - redis-data:/data
560
+
561
+ networks:
562
+ default:
563
+ ${config.pluginNetwork}:
564
+ driver: bridge
565
+
566
+ volumes:
567
+ postgres-data:
568
+ redis-data:
569
+ registry-data:
570
+ plugin-public:
571
+ `;
572
+ }
573
+
574
+ function generateNginxConf(config: CoreConfig): string {
575
+ return `upstream core_backend {
576
+ server core:8080;
577
+ }
578
+
579
+ server {
580
+ listen ${config.corePort};
581
+ server_name _;
582
+ absolute_redirect off;
583
+
584
+ location / {
585
+ proxy_pass http://core_backend;
586
+ proxy_set_header Host \$host;
587
+ proxy_set_header X-Real-IP \$remote_addr;
588
+ proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
589
+ proxy_set_header X-Forwarded-Proto \$scheme;
590
+ proxy_read_timeout 120s;
591
+ proxy_send_timeout 120s;
592
+ }
593
+ }
594
+ `;
595
+ }
596
+
597
+ function generateEnv(config: CoreConfig): string {
598
+ return `# Alcedo Core — Environment Configuration
599
+ # Generated by \`alcedo init-core\`
600
+
601
+ # Admin credentials (used for first-run bootstrap)
602
+ ADMIN_EMAIL=${config.adminEmail}
603
+ ADMIN_PASSWORD=${config.adminPassword}
604
+
605
+ # Database
606
+ POSTGRES_PASSWORD=${config.dbPassword}
607
+ DATABASE_URL=postgres://postgres:${config.dbPassword}@postgres:5432/alcedo
608
+
609
+ # Redis
610
+ ${config.redisPassword ? `REDIS_PASSWORD=${config.redisPassword}\nREDIS_URL=redis://:${config.redisPassword}@redis:6379` : "REDIS_URL=redis://redis:6379"}
611
+
612
+ # Core
613
+ CORE_PORT=${config.corePort}
614
+ CORE_PUBLIC_URL=${config.corePublicUrl || `http://localhost:${config.corePort}`}
615
+ SYSTEM_PLUGINS_URL=${config.systemPluginsUrl}
616
+ LOCAL_REGISTRY_URL=${config.localRegistryUrl}
617
+ SESSION_TTL_SECONDS=${config.sessionTtl}
618
+ SECURITY_CORS_ENABLED=${config.enableCors}
619
+ SECURITY_CORS_ORIGINS=${config.corsOrigins}
620
+ SECURITY_RATE_LIMIT_ENABLED=${config.enableRateLimit}
621
+ RATE_LIMIT_AUTH_REQUESTS=${config.rateLimitRequests}
622
+ RATE_LIMIT_AUTH_WINDOW=${config.rateLimitWindow}
623
+ RATE_LIMIT_API_REQUESTS=${config.rateLimitRequests}
624
+ RATE_LIMIT_API_WINDOW=${config.rateLimitWindow}
625
+
626
+ # Docker Registry
627
+ REGISTRY_PORT=${config.registryPort}
628
+ REGISTRY_ENCRYPTION_KEY=${config.registryEncryptionKey}
629
+ `;
630
+ }
631
+
632
+ function generateK8sManifests(config: CoreConfig): Record<string, string> {
633
+ const namespace = config.pluginNetwork || "alcedo-core";
634
+ const dbName = "plugin_core";
635
+ const dbUrlBase = `postgres://postgres:${config.dbPassword}@postgres:5432/${dbName}`;
636
+
637
+ return {
638
+ "00-namespace.yaml": `apiVersion: v1
639
+ kind: Namespace
640
+ metadata:
641
+ name: ${namespace}
642
+ `,
643
+
644
+ "02-configmap.yaml": `apiVersion: v1
645
+ kind: ConfigMap
646
+ metadata:
647
+ name: core-config
648
+ namespace: ${namespace}
649
+ data:
650
+ CORE_PORT: "${config.corePort}"
651
+ DATABASE_URL: "${dbUrlBase}"
652
+ REDIS_URL: "redis://redis:6379"
653
+ DEV_MODE: "false"
654
+ RUST_LOG: "info"
655
+ PLUGIN_NETWORK: "${namespace}"
656
+ CORE_PUBLIC_URL: "${config.corePublicUrl || `http://localhost:${config.corePort}`}"
657
+ SYSTEM_PLUGINS_URL: "${config.systemPluginsUrl}"
658
+ LOCAL_REGISTRY_URL: "${config.localRegistryUrl}"
659
+ SESSION_TTL_SECONDS: "${config.sessionTtl}"
660
+ COLLECTION_LOG_FLUSH_MS: "100"
661
+ RATE_LIMIT_AUTH_REQUESTS: "${config.rateLimitRequests}"
662
+ RATE_LIMIT_AUTH_WINDOW: "${config.rateLimitWindow}"
663
+ RATE_LIMIT_API_REQUESTS: "${config.rateLimitRequests}"
664
+ RATE_LIMIT_API_WINDOW: "${config.rateLimitWindow}"
665
+ SECURITY_COOKIE_SECURE: "false"
666
+ SECURITY_COOKIE_SAMESITE: "lax"
667
+ SECURITY_COOKIE_HTTP_ONLY: "true"
668
+ SECURITY_SESSION_FIXATION: "true"
669
+ SECURITY_PASSWORD_MIN_LENGTH: "8"
670
+ PLUGINS_DIR: "/plugins"
671
+ PLUGINS_DIR: "/var/lib/plugin-public"
672
+ `,
673
+
674
+ "03-secrets.yaml": `apiVersion: v1
675
+ kind: Secret
676
+ metadata:
677
+ name: core-secrets
678
+ namespace: ${namespace}
679
+ type: Opaque
680
+ stringData:
681
+ REGISTRY_ENCRYPTION_KEY: "${config.registryEncryptionKey}"
682
+ POSTGRES_PASSWORD: "${config.dbPassword}"
683
+ ADMIN_EMAIL: "${config.adminEmail}"
684
+ ADMIN_PASSWORD: "${config.adminPassword}"
685
+ `,
686
+
687
+ "04-postgres.yaml": `apiVersion: apps/v1
688
+ kind: StatefulSet
689
+ metadata:
690
+ name: postgres
691
+ namespace: ${namespace}
692
+ spec:
693
+ serviceName: postgres
694
+ replicas: 1
695
+ selector:
696
+ matchLabels:
697
+ app: postgres
698
+ volumeClaimTemplates:
699
+ - metadata:
700
+ name: postgres-data
701
+ spec:
702
+ accessModes: ["ReadWriteOnce"]
703
+ resources:
704
+ requests:
705
+ storage: 10Gi
706
+ storageClassName: local-path
707
+ template:
708
+ metadata:
709
+ labels:
710
+ app: postgres
711
+ spec:
712
+ containers:
713
+ - name: postgres
714
+ image: postgres:${config.postgresVersion}
715
+ env:
716
+ - name: POSTGRES_USER
717
+ value: postgres
718
+ - name: POSTGRES_PASSWORD
719
+ valueFrom:
720
+ secretKeyRef:
721
+ name: core-secrets
722
+ key: POSTGRES_PASSWORD
723
+ - name: POSTGRES_DB
724
+ value: ${dbName}
725
+ ports:
726
+ - containerPort: 5432
727
+ volumeMounts:
728
+ - name: postgres-data
729
+ mountPath: /var/lib/postgresql/data
730
+ subPath: postgres
731
+ readinessProbe:
732
+ exec:
733
+ command: ["pg_isready", "-U", "postgres"]
734
+ initialDelaySeconds: 5
735
+ periodSeconds: 5
736
+ ---
737
+ apiVersion: v1
738
+ kind: Service
739
+ metadata:
740
+ name: postgres
741
+ namespace: ${namespace}
742
+ spec:
743
+ selector:
744
+ app: postgres
745
+ ports:
746
+ - port: 5432
747
+ targetPort: 5432
748
+ `,
749
+
750
+ "05-redis.yaml": `apiVersion: apps/v1
751
+ kind: Deployment
752
+ metadata:
753
+ name: redis
754
+ namespace: ${namespace}
755
+ spec:
756
+ replicas: 1
757
+ selector:
758
+ matchLabels:
759
+ app: redis
760
+ template:
761
+ metadata:
762
+ labels:
763
+ app: redis
764
+ spec:
765
+ containers:
766
+ - name: redis
767
+ image: redis:7-alpine
768
+ ports:
769
+ - containerPort: 6379
770
+ readinessProbe:
771
+ exec:
772
+ command: ["redis-cli", "ping"]
773
+ initialDelaySeconds: 3
774
+ periodSeconds: 5
775
+ ---
776
+ apiVersion: v1
777
+ kind: Service
778
+ metadata:
779
+ name: redis
780
+ namespace: ${namespace}
781
+ spec:
782
+ selector:
783
+ app: redis
784
+ ports:
785
+ - port: 6379
786
+ targetPort: 6379
787
+ `,
788
+
789
+ "06-core-deployment.yaml": `apiVersion: apps/v1
790
+ kind: Deployment
791
+ metadata:
792
+ name: alcedo-core
793
+ namespace: ${namespace}
794
+ labels:
795
+ app.kubernetes.io/name: alcedo-core
796
+ app.kubernetes.io/managed-by: alcedo-core
797
+ spec:
798
+ replicas: 1
799
+ selector:
800
+ matchLabels:
801
+ app: alcedo-core
802
+ template:
803
+ metadata:
804
+ labels:
805
+ app: alcedo-core
806
+ app.kubernetes.io/name: alcedo-core
807
+ app.kubernetes.io/managed-by: alcedo-core
808
+ spec:
809
+ serviceAccountName: alcedo-core
810
+ securityContext:
811
+ fsGroup: 1001
812
+ initContainers:
813
+ - name: seed-plugins
814
+ image: ${config.coreImageTag}
815
+ imagePullPolicy: Always
816
+ command: ["sh", "-c", "cp -r /plugins/* /data/ 2>/dev/null; chmod -R 755 /data/"]
817
+ securityContext:
818
+ runAsUser: 0
819
+ volumeMounts:
820
+ - name: plugins
821
+ mountPath: /data
822
+ - name: plugin-data
823
+ mountPath: /var/lib/plugin-public
824
+ containers:
825
+ - name: core
826
+ image: ${config.coreImageTag}
827
+ imagePullPolicy: Always
828
+ ports:
829
+ - containerPort: 8080
830
+ name: http
831
+ envFrom:
832
+ - configMapRef:
833
+ name: core-config
834
+ - secretRef:
835
+ name: core-secrets
836
+ env:
837
+ - name: POD_NAMESPACE
838
+ valueFrom:
839
+ fieldRef:
840
+ fieldPath: metadata.namespace
841
+ - name: POD_NAME
842
+ valueFrom:
843
+ fieldRef:
844
+ fieldPath: metadata.name
845
+ volumeMounts:
846
+ - name: plugins
847
+ mountPath: /plugins
848
+ - name: plugin-data
849
+ mountPath: /var/lib/plugin-public
850
+ readinessProbe:
851
+ httpGet:
852
+ path: /health
853
+ port: 8080
854
+ initialDelaySeconds: 5
855
+ periodSeconds: 10
856
+ livenessProbe:
857
+ httpGet:
858
+ path: /health
859
+ port: 8080
860
+ initialDelaySeconds: 15
861
+ periodSeconds: 20
862
+ resources:
863
+ requests:
864
+ cpu: 100m
865
+ memory: 256Mi
866
+ limits:
867
+ cpu: 500m
868
+ memory: 512Mi
869
+ volumes:
870
+ - name: plugins
871
+ emptyDir: {}
872
+ - name: plugin-data
873
+ persistentVolumeClaim:
874
+ claimName: plugin-data
875
+ `,
876
+
877
+ "07-core-service.yaml": `apiVersion: v1
878
+ kind: Service
879
+ metadata:
880
+ name: alcedo-core
881
+ namespace: ${namespace}
882
+ labels:
883
+ app.kubernetes.io/name: alcedo-core
884
+ spec:
885
+ selector:
886
+ app: alcedo-core
887
+ ports:
888
+ - port: 8080
889
+ targetPort: 8080
890
+ name: http
891
+ type: ClusterIP
892
+ `,
893
+
894
+ "08-plugin-data-pvc.yaml": `apiVersion: v1
895
+ kind: PersistentVolumeClaim
896
+ metadata:
897
+ name: plugin-data
898
+ namespace: ${namespace}
899
+ labels:
900
+ app.kubernetes.io/name: alcedo-core
901
+ app.kubernetes.io/managed-by: alcedo-core
902
+ spec:
903
+ accessModes:
904
+ - ReadWriteOnce
905
+ resources:
906
+ requests:
907
+ storage: 1Gi
908
+ `,
909
+ };
910
+ }
911
+
912
+ async function writeFiles(
913
+ targetDir: string,
914
+ config: CoreConfig,
915
+ ): Promise<void> {
916
+ const spinner = createSpinner("Generating Alcedo Core configuration");
917
+
918
+ try {
919
+ fs.mkdirSync(targetDir, { recursive: true });
920
+
921
+ if (config.platform === "k8s") {
922
+ const k8sDir = path.join(targetDir, "k8s");
923
+ fs.mkdirSync(k8sDir, { recursive: true });
924
+
925
+ const manifests = generateK8sManifests(config);
926
+ for (const [filename, content] of Object.entries(manifests)) {
927
+ fs.writeFileSync(path.join(k8sDir, filename), content, "utf-8");
928
+ }
929
+
930
+ // Create plugins directory
931
+ const pluginsDir = path.join(targetDir, "plugins");
932
+ fs.mkdirSync(pluginsDir, { recursive: true });
933
+
934
+ spinner.succeed();
935
+ success(`Alcedo Core K8s configuration generated: ${targetDir}`);
936
+ info("");
937
+ info("To deploy Alcedo Core on Kubernetes:");
938
+ info(` cd ${path.relative(process.cwd(), targetDir)}`);
939
+ info(" kubectl apply -f k8s/");
940
+ info("");
941
+ info("The admin UI will be available after port-forwarding:");
942
+ info(
943
+ " kubectl port-forward -n alcedo-core svc/alcedo-core 8080:8080",
944
+ );
945
+ info(
946
+ ` ${config.corePublicUrl || `http://localhost:${config.corePort}`}/admin`,
947
+ );
948
+ info(` Email: ${config.adminEmail}`);
949
+ info("");
950
+ info("Static plugins directory:");
951
+ info(` ${path.join(targetDir, "plugins")}/`);
952
+ info(" Place static plugin folders (with manifest.json) here.");
953
+ } else {
954
+ // docker-compose.yml
955
+ const compose = generateCompose(config);
956
+ fs.writeFileSync(
957
+ path.join(targetDir, "docker-compose.yml"),
958
+ compose,
959
+ "utf-8",
960
+ );
961
+
962
+ // .env
963
+ const env = generateEnv(config);
964
+ fs.writeFileSync(path.join(targetDir, ".env"), env, "utf-8");
965
+
966
+ const nginx = generateNginxConf(config);
967
+ fs.writeFileSync(
968
+ path.join(targetDir, "nginx.conf"),
969
+ nginx,
970
+ "utf-8",
971
+ );
972
+
973
+ // Create plugins directory for static system plugins (e.g. admin UI)
974
+ const pluginsDir = path.join(targetDir, "plugins");
975
+ fs.mkdirSync(pluginsDir, { recursive: true });
976
+ // Create a README so users know they can place static plugins here
977
+ fs.writeFileSync(
978
+ path.join(pluginsDir, "README.md"),
979
+ "# Static Plugins\n\nPlace static plugin folders here (each with a manifest.json).\n" +
980
+ "The core will load them automatically on startup.\n",
981
+ "utf-8",
982
+ );
983
+
984
+ spinner.succeed();
985
+ success(`Alcedo Core configuration generated: ${targetDir}`);
986
+ info("");
987
+ info("To start Alcedo Core:");
988
+ info(` cd ${path.relative(process.cwd(), targetDir)}`);
989
+ info(" docker compose up -d");
990
+ info("");
991
+ info("The admin UI will be available at:");
992
+ info(
993
+ ` ${config.corePublicUrl || `http://localhost:${config.corePort}`}/admin`,
994
+ );
995
+ info(` Email: ${config.adminEmail}`);
996
+ info("");
997
+ info("Local Docker Registry:");
998
+ info(` http://localhost:${config.registryPort}`);
999
+ info("");
1000
+ info("Static plugins directory:");
1001
+ info(` ${path.join(targetDir, "plugins")}/`);
1002
+ info(" Place static plugin folders (with manifest.json) here.");
1003
+ info("System plugins will be auto-deployed from:");
1004
+ info(` ${config.systemPluginsUrl}`);
1005
+ info("");
1006
+ info("To deploy a plugin:");
1007
+ info(
1008
+ ` curl -X POST http://localhost:${config.corePort}/api/plugins/deploy \\`,
1009
+ );
1010
+ info(' -H "Content-Type: application/json" \\');
1011
+ info(
1012
+ ' -d \'{"slug": "my-plugin", "image": "localhost:5000/my-plugin:latest"\'}',
1013
+ );
1014
+ }
1015
+ } catch (err: any) {
1016
+ spinner.fail();
1017
+ throw err;
1018
+ }
1019
+ }