blumenjs 0.1.3 → 0.1.5
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/blumen.js +405 -8
- package/dist/cli/commands/create.js +116 -8
- package/dist/cli/commands/deploy.js +346 -0
- package/dist/templates/app/pages/templates/BlumenApi.tsx +489 -0
- package/dist/templates/app/pages/templates/BlumenDashboard.tsx +546 -0
- package/dist/templates/app/pages/templates/BlumenEmpty.tsx +128 -0
- package/package.json +1 -1
package/dist/cli/blumen.js
CHANGED
|
@@ -84,6 +84,24 @@ async function select(question, options) {
|
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
|
+
async function confirm(question, defaultYes = true) {
|
|
88
|
+
const readline = await import("readline");
|
|
89
|
+
const rl = readline.createInterface({
|
|
90
|
+
input: process.stdin,
|
|
91
|
+
output: process.stdout
|
|
92
|
+
});
|
|
93
|
+
const hint = defaultYes ? "Y/n" : "y/N";
|
|
94
|
+
return new Promise((resolve2) => {
|
|
95
|
+
rl.question(` ${c.bold}${question}${c.reset} ${c.dim}(${hint})${c.reset} `, (answer) => {
|
|
96
|
+
rl.close();
|
|
97
|
+
const a = answer.trim().toLowerCase();
|
|
98
|
+
if (a === "")
|
|
99
|
+
resolve2(defaultYes);
|
|
100
|
+
else
|
|
101
|
+
resolve2(a === "y" || a === "yes");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
87
105
|
|
|
88
106
|
// cli/commands/dev.ts
|
|
89
107
|
async function dev() {
|
|
@@ -555,12 +573,96 @@ export function Link({ href, children, onClick, target, ...rest }: LinkProps) {
|
|
|
555
573
|
|
|
556
574
|
export default Link;
|
|
557
575
|
`;
|
|
576
|
+
var DOCKERFILE = `# \u2500\u2500 Stage 1: Build Go server \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
577
|
+
FROM golang:1.22-alpine AS go-builder
|
|
578
|
+
WORKDIR /app
|
|
579
|
+
COPY go-server/ go-server/
|
|
580
|
+
WORKDIR /app/go-server
|
|
581
|
+
RUN go build -o /app/blumen-server main.go
|
|
582
|
+
|
|
583
|
+
# \u2500\u2500 Stage 2: Build Node SSR + client bundle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
584
|
+
FROM node:20-alpine AS node-builder
|
|
585
|
+
WORKDIR /app
|
|
586
|
+
COPY package.json tsconfig.json webpack.config.js ./
|
|
587
|
+
COPY app/ app/
|
|
588
|
+
COPY node-ssr/ node-ssr/
|
|
589
|
+
COPY scripts/ scripts/
|
|
590
|
+
RUN npm install --production=false
|
|
591
|
+
RUN npx tsx scripts/generate-routes.ts
|
|
592
|
+
RUN npx webpack --mode production
|
|
593
|
+
RUN npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm \\
|
|
594
|
+
--outfile=dist/ssr-server.js --external:react --external:react-dom
|
|
595
|
+
|
|
596
|
+
# \u2500\u2500 Stage 3: Production image \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
597
|
+
FROM node:20-alpine
|
|
598
|
+
RUN apk add --no-cache libc6-compat
|
|
599
|
+
WORKDIR /app
|
|
600
|
+
|
|
601
|
+
# Copy Go binary
|
|
602
|
+
COPY --from=go-builder /app/blumen-server ./blumen-server
|
|
603
|
+
|
|
604
|
+
# Copy Node SSR server + production deps
|
|
605
|
+
COPY --from=node-builder /app/dist/ ./dist/
|
|
606
|
+
COPY --from=node-builder /app/static/ ./static/
|
|
607
|
+
COPY --from=node-builder /app/node_modules/ ./node_modules/
|
|
608
|
+
COPY --from=node-builder /app/package.json ./
|
|
609
|
+
|
|
610
|
+
EXPOSE 3000 4000
|
|
611
|
+
|
|
612
|
+
# Start both services (Go on :3000, Node SSR on :4000)
|
|
613
|
+
CMD sh -c './blumen-server & NODE_ENV=production node dist/ssr-server.js'
|
|
614
|
+
`;
|
|
615
|
+
var DOCKER_COMPOSE = `services:
|
|
616
|
+
app:
|
|
617
|
+
build: .
|
|
618
|
+
ports:
|
|
619
|
+
- "3000:3000"
|
|
620
|
+
environment:
|
|
621
|
+
- NODE_ENV=production
|
|
622
|
+
restart: unless-stopped
|
|
623
|
+
healthcheck:
|
|
624
|
+
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
|
|
625
|
+
interval: 30s
|
|
626
|
+
timeout: 5s
|
|
627
|
+
retries: 3
|
|
628
|
+
start_period: 10s
|
|
629
|
+
`;
|
|
630
|
+
var DOCKERIGNORE = `node_modules
|
|
631
|
+
dist
|
|
632
|
+
static/js/bundle.js
|
|
633
|
+
.git
|
|
634
|
+
*.log
|
|
635
|
+
.DS_Store
|
|
636
|
+
`;
|
|
558
637
|
function writeFile(base, relPath, content) {
|
|
559
638
|
const fullPath = path2.join(base, relPath);
|
|
560
639
|
fs3.mkdirSync(path2.dirname(fullPath), { recursive: true });
|
|
561
640
|
fs3.writeFileSync(fullPath, content, "utf-8");
|
|
562
641
|
}
|
|
563
|
-
|
|
642
|
+
var TEMPLATE_MAP = {
|
|
643
|
+
starter: {
|
|
644
|
+
file: "app/pages/BlumenStarter.tsx",
|
|
645
|
+
label: "Starter",
|
|
646
|
+
desc: "Premium landing page with feature cards"
|
|
647
|
+
},
|
|
648
|
+
empty: {
|
|
649
|
+
file: "app/pages/templates/BlumenEmpty.tsx",
|
|
650
|
+
label: "Empty",
|
|
651
|
+
desc: "Minimal blank project \u2014 just a centered greeting"
|
|
652
|
+
},
|
|
653
|
+
dashboard: {
|
|
654
|
+
file: "app/pages/templates/BlumenDashboard.tsx",
|
|
655
|
+
label: "Dashboard",
|
|
656
|
+
desc: "Admin dashboard with sidebar, stats, and activity feed"
|
|
657
|
+
},
|
|
658
|
+
api: {
|
|
659
|
+
file: "app/pages/templates/BlumenApi.tsx",
|
|
660
|
+
label: "API",
|
|
661
|
+
desc: "API explorer with endpoint list and response preview"
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
function getTemplateFiles(projectName, template) {
|
|
665
|
+
const tpl = TEMPLATE_MAP[template] || TEMPLATE_MAP.starter;
|
|
564
666
|
return [
|
|
565
667
|
// Generated config
|
|
566
668
|
["package.json", pkgJson(projectName)],
|
|
@@ -571,8 +673,9 @@ function getTemplateFiles(projectName) {
|
|
|
571
673
|
["app/shared/DefaultApp.tsx", DEFAULT_APP],
|
|
572
674
|
["app/shared/DefaultDocument.tsx", DEFAULT_DOCUMENT],
|
|
573
675
|
["app/shared/Link.tsx", LINK_TSX],
|
|
574
|
-
//
|
|
575
|
-
["app/pages/Home.tsx", readProjectFile(
|
|
676
|
+
// Home page — determined by template choice
|
|
677
|
+
["app/pages/Home.tsx", readProjectFile(tpl.file)],
|
|
678
|
+
// Complex files — copied from the framework source
|
|
576
679
|
["app/shared/RouterContext.tsx", readProjectFile("app/shared/RouterContext.tsx")],
|
|
577
680
|
["app/shared/router.ts", readProjectFile("app/shared/router.ts")],
|
|
578
681
|
["app/client/entry.tsx", readProjectFile("app/client/entry.tsx")],
|
|
@@ -580,7 +683,11 @@ function getTemplateFiles(projectName) {
|
|
|
580
683
|
["go-server/main.go", readProjectFile("go-server/main.go")],
|
|
581
684
|
["scripts/generate-routes.ts", readProjectFile("scripts/generate-routes.ts")],
|
|
582
685
|
// Placeholder
|
|
583
|
-
["static/js/.gitkeep", ""]
|
|
686
|
+
["static/js/.gitkeep", ""],
|
|
687
|
+
// Docker support (production deployment)
|
|
688
|
+
["Dockerfile", DOCKERFILE],
|
|
689
|
+
["docker-compose.yml", DOCKER_COMPOSE],
|
|
690
|
+
[".dockerignore", DOCKERIGNORE]
|
|
584
691
|
];
|
|
585
692
|
}
|
|
586
693
|
async function create(projectName) {
|
|
@@ -590,7 +697,7 @@ async function create(projectName) {
|
|
|
590
697
|
log.error("Please provide a project name.");
|
|
591
698
|
console.log(
|
|
592
699
|
`
|
|
593
|
-
${c.dim}Usage:${c.reset} blumen create ${c.cyan}<project-name>${c.reset}
|
|
700
|
+
${c.dim}Usage:${c.reset} blumen create ${c.cyan}<project-name>${c.reset} ${c.dim}[--template starter|empty|dashboard|api]${c.reset}
|
|
594
701
|
`
|
|
595
702
|
);
|
|
596
703
|
process.exit(1);
|
|
@@ -600,6 +707,25 @@ async function create(projectName) {
|
|
|
600
707
|
log.error(`Directory ${c.bold}${projectName}${c.reset} already exists.`);
|
|
601
708
|
process.exit(1);
|
|
602
709
|
}
|
|
710
|
+
let template = "";
|
|
711
|
+
const templateFlagIdx = process.argv.indexOf("--template");
|
|
712
|
+
if (templateFlagIdx !== -1 && process.argv[templateFlagIdx + 1]) {
|
|
713
|
+
template = process.argv[templateFlagIdx + 1];
|
|
714
|
+
if (!TEMPLATE_MAP[template]) {
|
|
715
|
+
log.error(`Unknown template: ${c.bold}${template}${c.reset}`);
|
|
716
|
+
log.info(`Available: ${Object.keys(TEMPLATE_MAP).join(", ")}`);
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
if (!template) {
|
|
721
|
+
template = await select(
|
|
722
|
+
"Which template do you want to use?",
|
|
723
|
+
Object.entries(TEMPLATE_MAP).map(
|
|
724
|
+
([key, val]) => `${key} \u2014 ${val.desc}`
|
|
725
|
+
)
|
|
726
|
+
);
|
|
727
|
+
template = template.split(" \u2014 ")[0];
|
|
728
|
+
}
|
|
603
729
|
const pkgManager = await select("Which package manager do you want to use?", [
|
|
604
730
|
"npm",
|
|
605
731
|
"yarn",
|
|
@@ -609,8 +735,8 @@ async function create(projectName) {
|
|
|
609
735
|
log.blank();
|
|
610
736
|
divider();
|
|
611
737
|
log.blank();
|
|
612
|
-
log.step(`Creating project in ${c.cyan}${projectName}${c.reset}...`);
|
|
613
|
-
const files = getTemplateFiles(projectName);
|
|
738
|
+
log.step(`Creating project in ${c.cyan}${projectName}${c.reset} with ${c.magenta}${template}${c.reset} template...`);
|
|
739
|
+
const files = getTemplateFiles(projectName, template);
|
|
614
740
|
for (const [relPath, content] of files) {
|
|
615
741
|
writeFile(projectDir, relPath, content);
|
|
616
742
|
}
|
|
@@ -636,7 +762,7 @@ async function create(projectName) {
|
|
|
636
762
|
log.blank();
|
|
637
763
|
divider();
|
|
638
764
|
log.blank();
|
|
639
|
-
log.success(`${c.bold}Project created!${c.reset}`);
|
|
765
|
+
log.success(`${c.bold}Project created!${c.reset} (template: ${c.magenta}${template}${c.reset})`);
|
|
640
766
|
log.blank();
|
|
641
767
|
console.log(` ${c.dim}Next steps:${c.reset}`);
|
|
642
768
|
console.log(` cd ${projectName}`);
|
|
@@ -644,6 +770,271 @@ async function create(projectName) {
|
|
|
644
770
|
log.blank();
|
|
645
771
|
}
|
|
646
772
|
|
|
773
|
+
// cli/commands/deploy.ts
|
|
774
|
+
import { execSync as execSync4, spawnSync } from "child_process";
|
|
775
|
+
import * as fs4 from "fs";
|
|
776
|
+
import * as path3 from "path";
|
|
777
|
+
var PLATFORMS = [
|
|
778
|
+
{ name: "Docker (any cloud)", ok: true, note: "Best option \u2014 multi-stage Dockerfile included" },
|
|
779
|
+
{ name: "Railway", ok: true, note: "Auto-detects Dockerfile, deploy with railway up" },
|
|
780
|
+
{ name: "Fly.io", ok: true, note: "Docker-based deploys via fly deploy" },
|
|
781
|
+
{ name: "Render", ok: true, note: "Docker support, auto-deploy from Git" },
|
|
782
|
+
{ name: "AWS EC2 / GCP / Azure", ok: true, note: "Full control, any runtime" },
|
|
783
|
+
{ name: "DigitalOcean", ok: true, note: "App Platform supports Docker" },
|
|
784
|
+
{ name: "Vercel", ok: false, note: "Node-only, no Go runtime" },
|
|
785
|
+
{ name: "Netlify", ok: false, note: "Static/serverless only, no Go" }
|
|
786
|
+
];
|
|
787
|
+
function hasCommand(cmd) {
|
|
788
|
+
try {
|
|
789
|
+
execSync4(`which ${cmd}`, { stdio: "pipe" });
|
|
790
|
+
return true;
|
|
791
|
+
} catch {
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
function ensureDockerfile() {
|
|
796
|
+
if (!fs4.existsSync("Dockerfile")) {
|
|
797
|
+
log.error("No Dockerfile found in the current directory.");
|
|
798
|
+
log.info(
|
|
799
|
+
`Run ${c.bold}blumen create${c.reset} to scaffold a project with Docker support.`
|
|
800
|
+
);
|
|
801
|
+
process.exit(1);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
async function deployDocker() {
|
|
805
|
+
log.info("Docker Deployment\n");
|
|
806
|
+
ensureDockerfile();
|
|
807
|
+
if (!hasCommand("docker")) {
|
|
808
|
+
log.error("Docker is not installed.");
|
|
809
|
+
log.info(
|
|
810
|
+
`Install Docker: ${c.cyan}https://docs.docker.com/get-docker/${c.reset}`
|
|
811
|
+
);
|
|
812
|
+
process.exit(1);
|
|
813
|
+
}
|
|
814
|
+
const projectName = path3.basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
815
|
+
const imageName = `blumen-${projectName}`;
|
|
816
|
+
log.step(`Building image: ${c.bold}${imageName}${c.reset}...`);
|
|
817
|
+
divider();
|
|
818
|
+
log.blank();
|
|
819
|
+
const buildResult = spawnSync("docker", ["build", "-t", imageName, "."], {
|
|
820
|
+
cwd: process.cwd(),
|
|
821
|
+
stdio: "inherit"
|
|
822
|
+
});
|
|
823
|
+
log.blank();
|
|
824
|
+
divider();
|
|
825
|
+
if (buildResult.status !== 0) {
|
|
826
|
+
log.error("Docker build failed.");
|
|
827
|
+
process.exit(1);
|
|
828
|
+
}
|
|
829
|
+
log.success(`Image ${c.bold}${imageName}${c.reset} built successfully!`);
|
|
830
|
+
log.blank();
|
|
831
|
+
const shouldRun = await confirm("Run the container now?");
|
|
832
|
+
if (shouldRun) {
|
|
833
|
+
log.step(`Starting ${c.bold}${imageName}${c.reset} on port 3000...`);
|
|
834
|
+
log.blank();
|
|
835
|
+
console.log(
|
|
836
|
+
` ${c.dim}\u279C${c.reset} ${c.bold}App${c.reset}: ${c.cyan}http://localhost:3000${c.reset}`
|
|
837
|
+
);
|
|
838
|
+
console.log(
|
|
839
|
+
` ${c.dim}Press ${c.bold}Ctrl+C${c.reset}${c.dim} to stop.${c.reset}`
|
|
840
|
+
);
|
|
841
|
+
log.blank();
|
|
842
|
+
spawnSync("docker", ["run", "--rm", "-p", "3000:3000", imageName], {
|
|
843
|
+
cwd: process.cwd(),
|
|
844
|
+
stdio: "inherit"
|
|
845
|
+
});
|
|
846
|
+
} else {
|
|
847
|
+
log.blank();
|
|
848
|
+
console.log(` ${c.dim}Run manually:${c.reset}`);
|
|
849
|
+
console.log(` docker run -p 3000:3000 ${imageName}`);
|
|
850
|
+
log.blank();
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
async function deployFly() {
|
|
854
|
+
log.info("Fly.io Deployment\n");
|
|
855
|
+
ensureDockerfile();
|
|
856
|
+
const projectName = path3.basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
857
|
+
if (!fs4.existsSync("fly.toml")) {
|
|
858
|
+
log.step("Generating fly.toml...");
|
|
859
|
+
const flyConfig = `# Fly.io configuration for Blumen app
|
|
860
|
+
# Deploy with: fly deploy
|
|
861
|
+
|
|
862
|
+
app = "${projectName}"
|
|
863
|
+
primary_region = "iad"
|
|
864
|
+
|
|
865
|
+
[build]
|
|
866
|
+
|
|
867
|
+
[http_service]
|
|
868
|
+
internal_port = 3000
|
|
869
|
+
force_https = true
|
|
870
|
+
auto_stop_machines = "stop"
|
|
871
|
+
auto_start_machines = true
|
|
872
|
+
min_machines_running = 0
|
|
873
|
+
|
|
874
|
+
[checks]
|
|
875
|
+
[checks.health]
|
|
876
|
+
type = "http"
|
|
877
|
+
port = 3000
|
|
878
|
+
path = "/"
|
|
879
|
+
interval = "30s"
|
|
880
|
+
timeout = "5s"
|
|
881
|
+
|
|
882
|
+
[[vm]]
|
|
883
|
+
memory = "512mb"
|
|
884
|
+
cpu_kind = "shared"
|
|
885
|
+
cpus = 1
|
|
886
|
+
`;
|
|
887
|
+
fs4.writeFileSync("fly.toml", flyConfig);
|
|
888
|
+
log.success("fly.toml created");
|
|
889
|
+
} else {
|
|
890
|
+
log.info("fly.toml already exists, skipping generation.");
|
|
891
|
+
}
|
|
892
|
+
log.blank();
|
|
893
|
+
if (!hasCommand("fly") && !hasCommand("flyctl")) {
|
|
894
|
+
log.warn("Fly CLI is not installed.");
|
|
895
|
+
log.blank();
|
|
896
|
+
console.log(` ${c.dim}Install:${c.reset}`);
|
|
897
|
+
console.log(` curl -L https://fly.io/install.sh | sh`);
|
|
898
|
+
log.blank();
|
|
899
|
+
console.log(` ${c.dim}Then deploy:${c.reset}`);
|
|
900
|
+
console.log(` fly launch`);
|
|
901
|
+
console.log(` fly deploy`);
|
|
902
|
+
log.blank();
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const shouldDeploy = await confirm("Deploy to Fly.io now?");
|
|
906
|
+
if (shouldDeploy) {
|
|
907
|
+
log.step("Deploying to Fly.io...");
|
|
908
|
+
divider();
|
|
909
|
+
log.blank();
|
|
910
|
+
const flyCmd = hasCommand("fly") ? "fly" : "flyctl";
|
|
911
|
+
spawnSync(flyCmd, ["deploy"], {
|
|
912
|
+
cwd: process.cwd(),
|
|
913
|
+
stdio: "inherit"
|
|
914
|
+
});
|
|
915
|
+
log.blank();
|
|
916
|
+
divider();
|
|
917
|
+
log.success("Deployment complete!");
|
|
918
|
+
} else {
|
|
919
|
+
log.blank();
|
|
920
|
+
console.log(` ${c.dim}Deploy later:${c.reset}`);
|
|
921
|
+
console.log(` fly deploy`);
|
|
922
|
+
log.blank();
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
async function deployRailway() {
|
|
926
|
+
log.info("Railway Deployment\n");
|
|
927
|
+
ensureDockerfile();
|
|
928
|
+
if (!fs4.existsSync("railway.toml")) {
|
|
929
|
+
log.step("Generating railway.toml...");
|
|
930
|
+
const railwayConfig = `# Railway configuration for Blumen app
|
|
931
|
+
# Deploy with: railway up
|
|
932
|
+
|
|
933
|
+
[build]
|
|
934
|
+
builder = "DOCKERFILE"
|
|
935
|
+
dockerfilePath = "Dockerfile"
|
|
936
|
+
|
|
937
|
+
[deploy]
|
|
938
|
+
startCommand = ""
|
|
939
|
+
healthcheckPath = "/"
|
|
940
|
+
healthcheckTimeout = 30
|
|
941
|
+
restartPolicyType = "ON_FAILURE"
|
|
942
|
+
restartPolicyMaxRetries = 5
|
|
943
|
+
`;
|
|
944
|
+
fs4.writeFileSync("railway.toml", railwayConfig);
|
|
945
|
+
log.success("railway.toml created");
|
|
946
|
+
} else {
|
|
947
|
+
log.info("railway.toml already exists, skipping generation.");
|
|
948
|
+
}
|
|
949
|
+
log.blank();
|
|
950
|
+
if (!hasCommand("railway")) {
|
|
951
|
+
log.warn("Railway CLI is not installed.");
|
|
952
|
+
log.blank();
|
|
953
|
+
console.log(` ${c.dim}Install:${c.reset}`);
|
|
954
|
+
console.log(` npm install -g @railway/cli`);
|
|
955
|
+
log.blank();
|
|
956
|
+
console.log(` ${c.dim}Then deploy:${c.reset}`);
|
|
957
|
+
console.log(` railway login`);
|
|
958
|
+
console.log(` railway up`);
|
|
959
|
+
log.blank();
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
const shouldDeploy = await confirm("Deploy to Railway now?");
|
|
963
|
+
if (shouldDeploy) {
|
|
964
|
+
log.step("Deploying to Railway...");
|
|
965
|
+
divider();
|
|
966
|
+
log.blank();
|
|
967
|
+
spawnSync("railway", ["up"], {
|
|
968
|
+
cwd: process.cwd(),
|
|
969
|
+
stdio: "inherit"
|
|
970
|
+
});
|
|
971
|
+
log.blank();
|
|
972
|
+
divider();
|
|
973
|
+
log.success("Deployment complete!");
|
|
974
|
+
} else {
|
|
975
|
+
log.blank();
|
|
976
|
+
console.log(` ${c.dim}Deploy later:${c.reset}`);
|
|
977
|
+
console.log(` railway up`);
|
|
978
|
+
log.blank();
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function showInfo() {
|
|
982
|
+
log.info("Hosting Compatibility\n");
|
|
983
|
+
console.log(` Blumen apps require ${c.bold}Go${c.reset} + ${c.bold}Node.js${c.reset} \u2014 use Docker-compatible platforms.
|
|
984
|
+
`);
|
|
985
|
+
const maxName = Math.max(...PLATFORMS.map((p) => p.name.length));
|
|
986
|
+
for (const p of PLATFORMS) {
|
|
987
|
+
const icon = p.ok ? `${c.green}\u2713${c.reset}` : `${c.red}\u2717${c.reset}`;
|
|
988
|
+
const name = p.name.padEnd(maxName + 2);
|
|
989
|
+
const note = `${c.dim}${p.note}${c.reset}`;
|
|
990
|
+
console.log(` ${icon} ${name} ${note}`);
|
|
991
|
+
}
|
|
992
|
+
log.blank();
|
|
993
|
+
divider();
|
|
994
|
+
log.blank();
|
|
995
|
+
console.log(` ${c.bold}Quick start:${c.reset}`);
|
|
996
|
+
log.blank();
|
|
997
|
+
console.log(` ${c.dim}Docker (local):${c.reset} docker compose up`);
|
|
998
|
+
console.log(` ${c.dim}Fly.io:${c.reset} blumen deploy fly`);
|
|
999
|
+
console.log(` ${c.dim}Railway:${c.reset} blumen deploy railway`);
|
|
1000
|
+
log.blank();
|
|
1001
|
+
}
|
|
1002
|
+
async function deploy(subcommand) {
|
|
1003
|
+
banner();
|
|
1004
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
1005
|
+
log.info("Deploy your Blumen app\n");
|
|
1006
|
+
console.log(` ${c.bold}Usage${c.reset} blumen deploy ${c.dim}<target>${c.reset}
|
|
1007
|
+
`);
|
|
1008
|
+
console.log(` ${c.bold}Targets${c.reset}`);
|
|
1009
|
+
console.log(` docker Build Docker image and optionally run`);
|
|
1010
|
+
console.log(` fly Generate config and deploy to Fly.io`);
|
|
1011
|
+
console.log(` railway Generate config and deploy to Railway`);
|
|
1012
|
+
console.log(` info Show hosting compatibility matrix`);
|
|
1013
|
+
console.log("");
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
switch (subcommand) {
|
|
1017
|
+
case "docker":
|
|
1018
|
+
await deployDocker();
|
|
1019
|
+
break;
|
|
1020
|
+
case "fly":
|
|
1021
|
+
await deployFly();
|
|
1022
|
+
break;
|
|
1023
|
+
case "railway":
|
|
1024
|
+
await deployRailway();
|
|
1025
|
+
break;
|
|
1026
|
+
case "info":
|
|
1027
|
+
showInfo();
|
|
1028
|
+
break;
|
|
1029
|
+
default:
|
|
1030
|
+
log.error(`Unknown deploy target: ${c.bold}${subcommand}${c.reset}`);
|
|
1031
|
+
log.info(
|
|
1032
|
+
`Run ${c.bold}blumen deploy --help${c.reset} for available targets.`
|
|
1033
|
+
);
|
|
1034
|
+
process.exit(1);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
647
1038
|
// cli/blumen.ts
|
|
648
1039
|
async function main() {
|
|
649
1040
|
const command = process.argv[2];
|
|
@@ -662,6 +1053,9 @@ async function main() {
|
|
|
662
1053
|
console.log(
|
|
663
1054
|
` create Scaffold a new Blumen project`
|
|
664
1055
|
);
|
|
1056
|
+
console.log(
|
|
1057
|
+
` deploy Deploy to Docker, Fly.io, or Railway`
|
|
1058
|
+
);
|
|
665
1059
|
console.log("");
|
|
666
1060
|
console.log(` ${c.bold}Options${c.reset}`);
|
|
667
1061
|
console.log(` --help Show this help message`);
|
|
@@ -686,6 +1080,9 @@ async function main() {
|
|
|
686
1080
|
case "create":
|
|
687
1081
|
await create(process.argv[3]);
|
|
688
1082
|
break;
|
|
1083
|
+
case "deploy":
|
|
1084
|
+
await deploy(process.argv[3]);
|
|
1085
|
+
break;
|
|
689
1086
|
default:
|
|
690
1087
|
log.error(
|
|
691
1088
|
`Unknown command: ${c.bold}${command}${c.reset}`
|
|
@@ -303,12 +303,96 @@ export function Link({ href, children, onClick, target, ...rest }: LinkProps) {
|
|
|
303
303
|
|
|
304
304
|
export default Link;
|
|
305
305
|
`;
|
|
306
|
+
var DOCKERFILE = `# \u2500\u2500 Stage 1: Build Go server \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
307
|
+
FROM golang:1.22-alpine AS go-builder
|
|
308
|
+
WORKDIR /app
|
|
309
|
+
COPY go-server/ go-server/
|
|
310
|
+
WORKDIR /app/go-server
|
|
311
|
+
RUN go build -o /app/blumen-server main.go
|
|
312
|
+
|
|
313
|
+
# \u2500\u2500 Stage 2: Build Node SSR + client bundle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
314
|
+
FROM node:20-alpine AS node-builder
|
|
315
|
+
WORKDIR /app
|
|
316
|
+
COPY package.json tsconfig.json webpack.config.js ./
|
|
317
|
+
COPY app/ app/
|
|
318
|
+
COPY node-ssr/ node-ssr/
|
|
319
|
+
COPY scripts/ scripts/
|
|
320
|
+
RUN npm install --production=false
|
|
321
|
+
RUN npx tsx scripts/generate-routes.ts
|
|
322
|
+
RUN npx webpack --mode production
|
|
323
|
+
RUN npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm \\
|
|
324
|
+
--outfile=dist/ssr-server.js --external:react --external:react-dom
|
|
325
|
+
|
|
326
|
+
# \u2500\u2500 Stage 3: Production image \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
327
|
+
FROM node:20-alpine
|
|
328
|
+
RUN apk add --no-cache libc6-compat
|
|
329
|
+
WORKDIR /app
|
|
330
|
+
|
|
331
|
+
# Copy Go binary
|
|
332
|
+
COPY --from=go-builder /app/blumen-server ./blumen-server
|
|
333
|
+
|
|
334
|
+
# Copy Node SSR server + production deps
|
|
335
|
+
COPY --from=node-builder /app/dist/ ./dist/
|
|
336
|
+
COPY --from=node-builder /app/static/ ./static/
|
|
337
|
+
COPY --from=node-builder /app/node_modules/ ./node_modules/
|
|
338
|
+
COPY --from=node-builder /app/package.json ./
|
|
339
|
+
|
|
340
|
+
EXPOSE 3000 4000
|
|
341
|
+
|
|
342
|
+
# Start both services (Go on :3000, Node SSR on :4000)
|
|
343
|
+
CMD sh -c './blumen-server & NODE_ENV=production node dist/ssr-server.js'
|
|
344
|
+
`;
|
|
345
|
+
var DOCKER_COMPOSE = `services:
|
|
346
|
+
app:
|
|
347
|
+
build: .
|
|
348
|
+
ports:
|
|
349
|
+
- "3000:3000"
|
|
350
|
+
environment:
|
|
351
|
+
- NODE_ENV=production
|
|
352
|
+
restart: unless-stopped
|
|
353
|
+
healthcheck:
|
|
354
|
+
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
|
|
355
|
+
interval: 30s
|
|
356
|
+
timeout: 5s
|
|
357
|
+
retries: 3
|
|
358
|
+
start_period: 10s
|
|
359
|
+
`;
|
|
360
|
+
var DOCKERIGNORE = `node_modules
|
|
361
|
+
dist
|
|
362
|
+
static/js/bundle.js
|
|
363
|
+
.git
|
|
364
|
+
*.log
|
|
365
|
+
.DS_Store
|
|
366
|
+
`;
|
|
306
367
|
function writeFile(base, relPath, content) {
|
|
307
368
|
const fullPath = path2.join(base, relPath);
|
|
308
369
|
fs2.mkdirSync(path2.dirname(fullPath), { recursive: true });
|
|
309
370
|
fs2.writeFileSync(fullPath, content, "utf-8");
|
|
310
371
|
}
|
|
311
|
-
|
|
372
|
+
var TEMPLATE_MAP = {
|
|
373
|
+
starter: {
|
|
374
|
+
file: "app/pages/BlumenStarter.tsx",
|
|
375
|
+
label: "Starter",
|
|
376
|
+
desc: "Premium landing page with feature cards"
|
|
377
|
+
},
|
|
378
|
+
empty: {
|
|
379
|
+
file: "app/pages/templates/BlumenEmpty.tsx",
|
|
380
|
+
label: "Empty",
|
|
381
|
+
desc: "Minimal blank project \u2014 just a centered greeting"
|
|
382
|
+
},
|
|
383
|
+
dashboard: {
|
|
384
|
+
file: "app/pages/templates/BlumenDashboard.tsx",
|
|
385
|
+
label: "Dashboard",
|
|
386
|
+
desc: "Admin dashboard with sidebar, stats, and activity feed"
|
|
387
|
+
},
|
|
388
|
+
api: {
|
|
389
|
+
file: "app/pages/templates/BlumenApi.tsx",
|
|
390
|
+
label: "API",
|
|
391
|
+
desc: "API explorer with endpoint list and response preview"
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
function getTemplateFiles(projectName, template) {
|
|
395
|
+
const tpl = TEMPLATE_MAP[template] || TEMPLATE_MAP.starter;
|
|
312
396
|
return [
|
|
313
397
|
// Generated config
|
|
314
398
|
["package.json", pkgJson(projectName)],
|
|
@@ -319,8 +403,9 @@ function getTemplateFiles(projectName) {
|
|
|
319
403
|
["app/shared/DefaultApp.tsx", DEFAULT_APP],
|
|
320
404
|
["app/shared/DefaultDocument.tsx", DEFAULT_DOCUMENT],
|
|
321
405
|
["app/shared/Link.tsx", LINK_TSX],
|
|
322
|
-
//
|
|
323
|
-
["app/pages/Home.tsx", readProjectFile(
|
|
406
|
+
// Home page — determined by template choice
|
|
407
|
+
["app/pages/Home.tsx", readProjectFile(tpl.file)],
|
|
408
|
+
// Complex files — copied from the framework source
|
|
324
409
|
["app/shared/RouterContext.tsx", readProjectFile("app/shared/RouterContext.tsx")],
|
|
325
410
|
["app/shared/router.ts", readProjectFile("app/shared/router.ts")],
|
|
326
411
|
["app/client/entry.tsx", readProjectFile("app/client/entry.tsx")],
|
|
@@ -328,7 +413,11 @@ function getTemplateFiles(projectName) {
|
|
|
328
413
|
["go-server/main.go", readProjectFile("go-server/main.go")],
|
|
329
414
|
["scripts/generate-routes.ts", readProjectFile("scripts/generate-routes.ts")],
|
|
330
415
|
// Placeholder
|
|
331
|
-
["static/js/.gitkeep", ""]
|
|
416
|
+
["static/js/.gitkeep", ""],
|
|
417
|
+
// Docker support (production deployment)
|
|
418
|
+
["Dockerfile", DOCKERFILE],
|
|
419
|
+
["docker-compose.yml", DOCKER_COMPOSE],
|
|
420
|
+
[".dockerignore", DOCKERIGNORE]
|
|
332
421
|
];
|
|
333
422
|
}
|
|
334
423
|
async function create(projectName) {
|
|
@@ -338,7 +427,7 @@ async function create(projectName) {
|
|
|
338
427
|
log.error("Please provide a project name.");
|
|
339
428
|
console.log(
|
|
340
429
|
`
|
|
341
|
-
${c.dim}Usage:${c.reset} blumen create ${c.cyan}<project-name>${c.reset}
|
|
430
|
+
${c.dim}Usage:${c.reset} blumen create ${c.cyan}<project-name>${c.reset} ${c.dim}[--template starter|empty|dashboard|api]${c.reset}
|
|
342
431
|
`
|
|
343
432
|
);
|
|
344
433
|
process.exit(1);
|
|
@@ -348,6 +437,25 @@ async function create(projectName) {
|
|
|
348
437
|
log.error(`Directory ${c.bold}${projectName}${c.reset} already exists.`);
|
|
349
438
|
process.exit(1);
|
|
350
439
|
}
|
|
440
|
+
let template = "";
|
|
441
|
+
const templateFlagIdx = process.argv.indexOf("--template");
|
|
442
|
+
if (templateFlagIdx !== -1 && process.argv[templateFlagIdx + 1]) {
|
|
443
|
+
template = process.argv[templateFlagIdx + 1];
|
|
444
|
+
if (!TEMPLATE_MAP[template]) {
|
|
445
|
+
log.error(`Unknown template: ${c.bold}${template}${c.reset}`);
|
|
446
|
+
log.info(`Available: ${Object.keys(TEMPLATE_MAP).join(", ")}`);
|
|
447
|
+
process.exit(1);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (!template) {
|
|
451
|
+
template = await select(
|
|
452
|
+
"Which template do you want to use?",
|
|
453
|
+
Object.entries(TEMPLATE_MAP).map(
|
|
454
|
+
([key, val]) => `${key} \u2014 ${val.desc}`
|
|
455
|
+
)
|
|
456
|
+
);
|
|
457
|
+
template = template.split(" \u2014 ")[0];
|
|
458
|
+
}
|
|
351
459
|
const pkgManager = await select("Which package manager do you want to use?", [
|
|
352
460
|
"npm",
|
|
353
461
|
"yarn",
|
|
@@ -357,8 +465,8 @@ async function create(projectName) {
|
|
|
357
465
|
log.blank();
|
|
358
466
|
divider();
|
|
359
467
|
log.blank();
|
|
360
|
-
log.step(`Creating project in ${c.cyan}${projectName}${c.reset}...`);
|
|
361
|
-
const files = getTemplateFiles(projectName);
|
|
468
|
+
log.step(`Creating project in ${c.cyan}${projectName}${c.reset} with ${c.magenta}${template}${c.reset} template...`);
|
|
469
|
+
const files = getTemplateFiles(projectName, template);
|
|
362
470
|
for (const [relPath, content] of files) {
|
|
363
471
|
writeFile(projectDir, relPath, content);
|
|
364
472
|
}
|
|
@@ -384,7 +492,7 @@ async function create(projectName) {
|
|
|
384
492
|
log.blank();
|
|
385
493
|
divider();
|
|
386
494
|
log.blank();
|
|
387
|
-
log.success(`${c.bold}Project created!${c.reset}`);
|
|
495
|
+
log.success(`${c.bold}Project created!${c.reset} (template: ${c.magenta}${template}${c.reset})`);
|
|
388
496
|
log.blank();
|
|
389
497
|
console.log(` ${c.dim}Next steps:${c.reset}`);
|
|
390
498
|
console.log(` cd ${projectName}`);
|