Skip to main content

edgeguard/
scaffold.rs

1//! Project scaffolding for `edgeguard init`.
2//!
3//! The first five minutes decide whether someone adopts a front door for their (often
4//! generated) app. `init` removes the "read the whole README, hand-write a config and a
5//! Dockerfile" step: it detects the upstream app's runtime from the files in the working
6//! directory and renders a starter `edgeguard.toml` plus a `Dockerfile` that wraps the app
7//! behind EdgeGuard with `--wrap`.
8//!
9//! This module is the pure core (detection + templating) so it's unit-testable; the CLI in
10//! `main.rs` does the filesystem side (scan the directory, write the files, print next steps).
11
12/// The annotated, secure-by-default config reference, embedded at compile time so the scaffold
13/// `edgeguard.toml` is byte-identical to the documented reference and can never drift from it.
14pub const EDGEGUARD_TOML: &str = include_str!("../edgeguard.toml");
15
16/// A detected upstream-app runtime. Drives the default app port, the `--wrap` start command, and
17/// the base image in the generated Dockerfile.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Runtime {
20    Node,
21    Python,
22    Go,
23    Rust,
24    /// Nothing recognized — emit a generic template the user fills in.
25    Unknown,
26}
27
28impl Runtime {
29    /// Detect the runtime from the set of file names present in the project root. Priority order
30    /// (most-common-for-a-vibe-coded-app first) resolves a repo that carries more than one marker.
31    pub fn detect(entries: &[String]) -> Runtime {
32        let has = |name: &str| entries.iter().any(|e| e == name);
33        if has("package.json") {
34            Runtime::Node
35        } else if has("requirements.txt") || has("pyproject.toml") || has("Pipfile") {
36            Runtime::Python
37        } else if has("go.mod") {
38            Runtime::Go
39        } else if has("Cargo.toml") {
40            Runtime::Rust
41        } else {
42            Runtime::Unknown
43        }
44    }
45
46    pub fn label(self) -> &'static str {
47        match self {
48            Runtime::Node => "Node.js",
49            Runtime::Python => "Python",
50            Runtime::Go => "Go",
51            Runtime::Rust => "Rust",
52            Runtime::Unknown => "unknown",
53        }
54    }
55
56    /// The port the wrapped app is told to listen on (`APP_PORT`). EdgeGuard takes the public
57    /// port and proxies to this one on localhost.
58    pub fn default_app_port(self) -> u16 {
59        match self {
60            // Conventional defaults per ecosystem; the generated config/Dockerfile make it
61            // explicit so the user can change it in one place.
62            Runtime::Python => 8000,
63            _ => 3000,
64        }
65    }
66
67    /// The default `--wrap` start command for this runtime (a placeholder the user adjusts to
68    /// their actual entrypoint).
69    pub fn default_start_cmd(self) -> &'static str {
70        match self {
71            Runtime::Node => "npm start",
72            Runtime::Python => "python app.py",
73            Runtime::Go => "./app",
74            Runtime::Rust => "./app",
75            Runtime::Unknown => "<your app start command>",
76        }
77    }
78}
79
80/// Render a `Dockerfile` that wraps the detected app behind EdgeGuard: it copies the static
81/// `edgeguard` binary from the published image, keeps the app's own base image, and makes
82/// EdgeGuard the entrypoint (binding the platform `$PORT`, running the app on `APP_PORT`).
83pub fn dockerfile(runtime: Runtime) -> String {
84    let app_port = runtime.default_app_port();
85    let start = runtime.default_start_cmd();
86    // The app's base build stage differs per runtime; everything after the EdgeGuard COPY is
87    // shared. We keep this deliberately simple — a starting point the user tailors to their app.
88    let (base, build_notes) = match runtime {
89        Runtime::Node => (
90            "node:22-slim",
91            "# Install deps and copy your app source:\n\
92             # COPY package*.json ./\n\
93             # RUN npm ci --omit=dev\n\
94             # COPY . .",
95        ),
96        Runtime::Python => (
97            "python:3.12-slim",
98            "# Install deps and copy your app source:\n\
99             # COPY requirements.txt ./\n\
100             # RUN pip install --no-cache-dir -r requirements.txt\n\
101             # COPY . .",
102        ),
103        Runtime::Go => (
104            "debian:bookworm-slim",
105            "# Copy your prebuilt binary (or add a Go build stage):\n\
106             # COPY ./app ./app",
107        ),
108        Runtime::Rust => (
109            "debian:bookworm-slim",
110            "# Copy your prebuilt binary (or add a Rust build stage):\n\
111             # COPY ./app ./app",
112        ),
113        Runtime::Unknown => (
114            "debian:bookworm-slim",
115            "# Install deps and copy your app source here.",
116        ),
117    };
118
119    format!(
120        "# Generated by `edgeguard init` ({label} app).\n\
121         # EdgeGuard is the entrypoint: it binds the platform's $PORT and runs your app on\n\
122         # APP_PORT (localhost only), adding auth + rate-limit + hardened headers with no app\n\
123         # code changes. Review the TODOs below and wire in your real build steps.\n\
124         FROM {base}\n\
125         WORKDIR /app\n\
126         \n\
127         # --- the EdgeGuard binary (static musl, distroless-built) ---\n\
128         COPY --from=mancube/eggrd:latest /usr/local/bin/edgeguard /usr/local/bin/edgeguard\n\
129         COPY edgeguard.toml /app/edgeguard.toml\n\
130         \n\
131         {build_notes}\n\
132         \n\
133         # EdgeGuard listens on $PORT (default 8080) and runs your app on APP_PORT.\n\
134         ENV PORT=8080 APP_PORT={app_port}\n\
135         EXPOSE 8080\n\
136         ENTRYPOINT [\"/usr/local/bin/edgeguard\", \"--config\", \"/app/edgeguard.toml\", \
137         \"--wrap\", \"{start}\"]\n",
138        label = runtime.label(),
139        base = base,
140        build_notes = build_notes,
141        app_port = app_port,
142        start = start,
143    )
144}
145
146/// The next-steps blurb printed after `init` writes its files: how to set a credential and run.
147pub fn next_steps(runtime: Runtime) -> String {
148    let app_port = runtime.default_app_port();
149    format!(
150        "Next steps:\n\
151         1. Set a real credential — the shipped edgeguard.toml ships a non-working placeholder:\n\
152         \x20    echo -n 'your-password' | edgeguard --hash\n\
153         \x20    # paste the $argon2id$… string as the user's value in edgeguard.toml\n\
154         2. Review edgeguard.toml (auth mode, rate limits, CORS if your frontend is on another origin).\n\
155         3. Validate it:    edgeguard doctor --config edgeguard.toml\n\
156         4. Run locally:    PORT=8080 APP_PORT={app_port} edgeguard --config edgeguard.toml --wrap \"{start}\"\n\
157         5. Or build the generated Dockerfile and deploy it as your container entrypoint.\n",
158        app_port = app_port,
159        start = runtime.default_start_cmd(),
160    )
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn names(v: &[&str]) -> Vec<String> {
168        v.iter().map(|s| s.to_string()).collect()
169    }
170
171    #[test]
172    fn detects_each_runtime() {
173        assert_eq!(Runtime::detect(&names(&["package.json"])), Runtime::Node);
174        assert_eq!(
175            Runtime::detect(&names(&["requirements.txt"])),
176            Runtime::Python
177        );
178        assert_eq!(
179            Runtime::detect(&names(&["pyproject.toml"])),
180            Runtime::Python
181        );
182        assert_eq!(Runtime::detect(&names(&["go.mod"])), Runtime::Go);
183        assert_eq!(Runtime::detect(&names(&["Cargo.toml"])), Runtime::Rust);
184        assert_eq!(Runtime::detect(&names(&["README.md"])), Runtime::Unknown);
185    }
186
187    #[test]
188    fn node_wins_over_other_markers() {
189        // A polyglot repo (e.g. a JS frontend + Cargo tooling) resolves to the most common app
190        // runtime first.
191        assert_eq!(
192            Runtime::detect(&names(&["Cargo.toml", "package.json"])),
193            Runtime::Node
194        );
195    }
196
197    #[test]
198    fn embedded_config_is_the_real_reference() {
199        // Guards the include_str path: the scaffold config must be the annotated reference.
200        assert!(EDGEGUARD_TOML.contains("[server]"));
201        assert!(EDGEGUARD_TOML.contains("EdgeGuard configuration"));
202    }
203
204    #[test]
205    fn dockerfile_wires_entrypoint_and_app_port() {
206        let df = dockerfile(Runtime::Python);
207        assert!(df.contains("APP_PORT=8000"), "{df}");
208        assert!(df.contains("mancube/eggrd:latest"), "{df}");
209        assert!(df.contains("--wrap"), "{df}");
210        assert!(df.contains("python app.py"), "{df}");
211    }
212}