Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions autumn-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ enum Commands {
/// wires a managed local Postgres provider (implies a daemon app).
#[arg(long = "bundled-pg")]
bundled_pg: bool,
/// JSON-first API starter: a lean skeleton with no HTML/CSS/Tailwind
/// artifacts. Handlers return JSON; the view stack (maud/htmx/tailwind)
/// is dropped. Keeps the database/migrations. Composes with --with-i18n
/// and --with-seed; not combinable with --daemon or --bundled-pg.
#[arg(long)]
api: bool,
},
/// Pre-render static routes to dist/
Build {
Expand Down Expand Up @@ -2629,6 +2635,7 @@ fn run_command(command: Commands) {
with_seed,
daemon,
bundled_pg,
api,
} => {
if list_starters {
starters::print_list();
Expand All @@ -2644,11 +2651,11 @@ fn run_command(command: Commands) {
if let Some(starter) = starter {
// A starter brings a complete composition; the base-project
// scaffolding toggles do not apply.
if with_i18n || with_seed || daemon || bundled_pg {
if with_i18n || with_seed || daemon || bundled_pg || api {
eprintln!(
"Error: --starter cannot be combined with --with-i18n, \
--with-seed, --daemon, or --bundled-pg (a starter brings \
its own composition)"
--with-seed, --daemon, --bundled-pg, or --api (a starter \
brings its own composition)"
);
std::process::exit(1);
}
Expand All @@ -2668,6 +2675,7 @@ fn run_command(command: Commands) {
// --bundled-pg is a daemon flavor that keeps the database.
with_daemon: daemon || bundled_pg,
with_bundled_pg: bundled_pg,
with_api: api,
},
);
}
Expand Down
254 changes: 230 additions & 24 deletions autumn-cli/src/new.rs

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions autumn-cli/src/templates/Cargo.api.toml.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
[package]
name = "{{project_name}}"
version = "0.1.0"
edition = "2024"

[dependencies]
# JSON-first API project (`autumn new --api`): the HTML/CSS view stack is
# disabled via `default-features = false`, leaving a lean JSON feature set.
# Enable more optional subsystems by extending the features list below, e.g.
# "mail" or "redis".
autumn-web = "{{autumn_version}}"
diesel_migrations = "2"

[features]
# Flash messages carry one-shot notices across redirects; kept on for parity
# with the fullstack scaffold (harmless for pure-JSON apps).
default = ["flash"]
flash = ["autumn-web/flash"]
# Enable single-binary asset embedding (autumn build --embed). Only meaningful
# once you add embeddable assets (e.g. i18n locale bundles via --with-i18n).
embed-assets = ["autumn-web/embed-assets"]

[dev-dependencies]
# tokio runtime macros are needed for #[tokio::test] in integration tests.
tokio = { version = "1", features = ["rt", "macros"] }

[workspace]
53 changes: 53 additions & 0 deletions autumn-cli/src/templates/Dockerfile.api.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
FROM rust:{{rust_version}}-bookworm AS builder

WORKDIR /app

COPY Cargo.toml autumn.toml build.rs ./
COPY src ./src
COPY migrations ./migrations
# __AUTUMN_I18N_BUILDER_COPY__

# Git/build provenance passthrough. The build context excludes `/.git` (see
# .dockerignore), so the generated build.rs cannot shell out to git here.
# Supply provenance explicitly and surface it as ENV, which build.rs prefers
# over git and bakes into the binary for `/actuator/info`. Populate from CI, e.g:
# docker build \
# --build-arg AUTUMN_BUILD_GIT_SHA=$(git rev-parse HEAD) \
# --build-arg AUTUMN_BUILD_GIT_SHA_SHORT=$(git rev-parse --short HEAD) \
# --build-arg AUTUMN_BUILD_GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) \
# --build-arg AUTUMN_BUILD_GIT_DIRTY=false \
# --build-arg AUTUMN_BUILD_TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) .
# Unset args stay empty and are ignored, so `/actuator/info` reports `null`.
ARG AUTUMN_BUILD_GIT_SHA=
ARG AUTUMN_BUILD_GIT_SHA_SHORT=
ARG AUTUMN_BUILD_GIT_BRANCH=
ARG AUTUMN_BUILD_GIT_DIRTY=
ARG AUTUMN_BUILD_TIMESTAMP=
ENV AUTUMN_BUILD_GIT_SHA=${AUTUMN_BUILD_GIT_SHA} \
AUTUMN_BUILD_GIT_SHA_SHORT=${AUTUMN_BUILD_GIT_SHA_SHORT} \
AUTUMN_BUILD_GIT_BRANCH=${AUTUMN_BUILD_GIT_BRANCH} \
AUTUMN_BUILD_GIT_DIRTY=${AUTUMN_BUILD_GIT_DIRTY} \
AUTUMN_BUILD_TIMESTAMP=${AUTUMN_BUILD_TIMESTAMP}

RUN cargo build --release

FROM debian:bookworm-slim AS runtime

RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --create-home --uid 10001 autumn

WORKDIR /app

COPY --from=builder /app/target/release/{{project_name}} /usr/local/bin/{{project_name}}
COPY --from=builder /app/autumn.toml /app/autumn.toml
COPY --from=builder /app/migrations /app/migrations

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Copy i18n locales into API images

When a project is scaffolded with autumn new <app> --api --with-i18n, inject_i18n_api makes src/main.rs call .i18n_auto(), and this Dockerfile builds without the embed-assets feature, so the app falls back to loading /app/i18n/en.ftl at startup. This copy block never carries the i18n/ sidecar into the runtime image (and the builder stage does not copy it either), so that generated Docker image panics on startup with the default locale missing.

Useful? React with 👍 / 👎.

# __AUTUMN_I18N_RUNTIME_COPY__

ENV AUTUMN_PROFILE=prod
EXPOSE 3000

USER autumn

CMD ["{{project_name}}"]
233 changes: 233 additions & 0 deletions autumn-cli/src/templates/build.api.rs.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
fn main() {
println!("cargo:rerun-if-changed=src/");

// Bake build + git provenance into the binary so `/actuator/info` can
// report exactly which commit/build is running (deploy/rollback checks).
// The JSON-first API scaffold has no Tailwind/CSS compilation step.
emit_build_provenance();
}

/// Emit `AUTUMN_BUILD_*` compile-time env vars consumed by
/// `#[autumn_web::main]` and surfaced on `/actuator/info`.
///
/// Provenance is sourced with a two-tier strategy so both local checkouts and
/// containerized (production) builds report real values:
///
/// 1. **Build-arg passthrough** — if the corresponding `AUTUMN_BUILD_*` env var
/// is set (e.g. Docker `--build-arg` surfaced as `ENV`), it wins. The
/// scaffolded Dockerfile deliberately excludes `/.git` from the build
/// context, so a container build has no repository to inspect; the deploy/CI
/// supplies the SHA/branch/etc. explicitly instead.
/// 2. **Git fallback** — otherwise fields are best-effort from `git`. Outside a
/// checkout (tarball / CI cache with no passthrough) they are simply omitted
/// and the framework reports them as `null` rather than failing the build.
///
/// The build timestamp is always emitted (passthrough `AUTUMN_BUILD_TIMESTAMP`
/// wins, else `SOURCE_DATE_EPOCH` for reproducible builds, else wall clock).
///
/// The `dirty` flag is three-state: an explicit `AUTUMN_BUILD_GIT_DIRTY` build
/// arg wins; else the real `git status` (a *clean* tree is `false`); else — no
/// arg and no git — it is genuinely UNKNOWN and no var is emitted, so
/// `/actuator/info` reports `null` instead of a misleading `false`.
///
/// Note: the git-derived `dirty` flag is a best-effort snapshot taken when this
/// script last re-ran. Cargo only re-runs it on the declared `rerun-if-changed`
/// inputs, so after an edit that doesn't touch a watched path the baked flag may
/// lag until the next rebuild of a watched input.
fn emit_build_provenance() {
// Re-run whenever a passthrough build arg changes so a new deploy re-bakes
// the provenance instead of reusing a cached, stale value.
for var in [
"AUTUMN_BUILD_GIT_SHA",
"AUTUMN_BUILD_GIT_SHA_SHORT",
"AUTUMN_BUILD_GIT_BRANCH",
"AUTUMN_BUILD_GIT_DIRTY",
"AUTUMN_BUILD_TIMESTAMP",
] {
println!("cargo:rerun-if-env-changed={var}");
}

let timestamp = build_arg("AUTUMN_BUILD_TIMESTAMP").unwrap_or_else(build_timestamp);
println!("cargo:rustc-env=AUTUMN_BUILD_TIMESTAMP={timestamp}");

// Prefer the passthrough SHA (container build); fall back to git (local
// checkout). If neither yields a SHA, emit no git fields → reported `null`.
if let Some(sha) = build_arg("AUTUMN_BUILD_GIT_SHA").or_else(|| git(&["rev-parse", "HEAD"])) {
let short = build_arg("AUTUMN_BUILD_GIT_SHA_SHORT")
.or_else(|| git(&["rev-parse", "--short", "HEAD"]))
.unwrap_or_else(|| sha.chars().take(7).collect());
let branch = build_arg("AUTUMN_BUILD_GIT_BRANCH")
.or_else(|| git(&["rev-parse", "--abbrev-ref", "HEAD"]))
.unwrap_or_else(|| "unknown".into());
// Dirty is a THREE-state value: an explicit passthrough arg wins;
// otherwise the real `git status` result (a *clean* tree is `false`, not
// unknown); otherwise — no arg AND no git, the common case for a
// container build with `/.git` excluded — the dirty state is genuinely
// UNKNOWN. In that case emit no `AUTUMN_BUILD_GIT_DIRTY` at all, so
// `/actuator/info` reports `null` rather than a misleading `false`.
let dirty =
build_arg("AUTUMN_BUILD_GIT_DIRTY").or_else(|| git_dirty().map(|d| d.to_string()));
println!("cargo:rustc-env=AUTUMN_BUILD_GIT_SHA={sha}");
println!("cargo:rustc-env=AUTUMN_BUILD_GIT_SHA_SHORT={short}");
println!("cargo:rustc-env=AUTUMN_BUILD_GIT_BRANCH={branch}");
if let Some(dirty) = dirty {
println!("cargo:rustc-env=AUTUMN_BUILD_GIT_DIRTY={dirty}");
}
}

// Re-run when the ref is checked out (HEAD), the index changes (staging /
// index), or HEAD *moves* — commit, `--amend`, `reset --soft` all rewrite
// `logs/HEAD` even when `HEAD` itself is untouched.
emit_git_rerun_triggers();

// Reproducible builds: re-run if the source date is pinned or changed.
println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH");
}

/// Read a provenance build arg passed through the environment (Docker
/// `ARG`/`ENV`), trimming surrounding whitespace. Unset or blank values return
/// `None` so detection falls through to git — an unpopulated `ARG` surfaces as
/// an empty string, which must not shadow the git fallback.
fn build_arg(name: &str) -> Option<String> {
let value = std::env::var(name).ok()?;
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}

/// Register `rerun-if-changed` triggers on the git ref state so a commit /
/// amend / reset re-runs this script and re-bakes the SHA and dirty flag.
///
/// The gitdir is resolved by asking git itself (`git rev-parse --git-dir`)
/// rather than looking for a `.git` entry in the package root. That matters for
/// apps generated *inside* an existing repo/monorepo: the package root has no
/// `.git`, but `git` still resolves the parent checkout — so we must register
/// the parent's `HEAD`/`logs/HEAD`/`index`, or Cargo would never re-run after a
/// commit and `/actuator/info` would report a stale SHA.
///
/// `--git-common-dir` covers linked worktrees, where per-worktree state lives
/// in `--git-dir` but shared history (`logs/HEAD`) lives in the common dir. In
/// a linked worktree a `commit --amend`/`reset` rewrites the *per-worktree*
/// reflog (`<git-dir>/logs/HEAD`) while leaving the common-dir reflog untouched,
/// so both reflogs are watched (deduped for a normal, non-worktree checkout).
///
/// Graceful degradation: if git is absent (tarball / CI cache), `git rev-parse`
/// fails, we register no triggers, and the build still succeeds.
fn emit_git_rerun_triggers() {
// git prints paths relative to the current dir; resolve them against the
// crate dir so they are valid regardless of Cargo's working directory.
let crate_dir = std::env::var_os("CARGO_MANIFEST_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_default();
let resolve = |flag: &str| -> Option<std::path::PathBuf> {
let raw = git(&["rev-parse", flag])?;
let path = std::path::PathBuf::from(&raw);
Some(if path.is_absolute() {
path
} else {
crate_dir.join(path)
})
};

let Some(git_dir) = resolve("--git-dir") else {
return;
};
// Linked worktrees keep shared `logs/HEAD` in the common dir; when there is
// no separate common dir this is the same as `git_dir`.
let common_dir = resolve("--git-common-dir").unwrap_or_else(|| git_dir.clone());

let mut seen: Vec<std::path::PathBuf> = Vec::new();
for path in [
git_dir.join("HEAD"),
git_dir.join("index"),
// Per-worktree reflog: `--amend`/`reset` in a linked worktree rewrites
// `<git-dir>/logs/HEAD` but leaves the shared common-dir reflog
// untouched, so watch both. Deduped when git_dir == common_dir.
git_dir.join("logs/HEAD"),
common_dir.join("logs/HEAD"),
] {
if seen.contains(&path) {
continue; // dedupe when git_dir == common_dir
}
if path.exists() {
println!("cargo:rerun-if-changed={}", path.display());
}
seen.push(path);
}
}

/// Best-effort working-tree dirtiness, distinguishing *clean* from *unknown*.
///
/// Returns `Some(true)`/`Some(false)` only when `git status` actually ran — a
/// clean tree is `Some(false)`, uncommitted changes are `Some(true)`. Returns
/// `None` when git is absent or the command fails (e.g. a container build with
/// `/.git` excluded), so the caller can represent an *unknown* dirty state as
/// absent (`/actuator/info` → `null`) rather than a misleading `false`.
///
/// This cannot reuse `git()`, which collapses empty stdout to `None` and would
/// make a clean tree indistinguishable from git being unavailable.
fn git_dirty() -> Option<bool> {
let output = std::process::Command::new("git")
.args(["status", "--porcelain"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8(output.stdout).ok()?;
Some(!text.trim().is_empty())
}

/// Run a git subcommand, returning trimmed stdout on success.
fn git(args: &[&str]) -> Option<String> {
let output = std::process::Command::new("git").args(args).output().ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8(output.stdout).ok()?.trim().to_string();
if text.is_empty() { None } else { Some(text) }
}

/// Build timestamp as ISO-8601 UTC. Honors `SOURCE_DATE_EPOCH` (a Unix seconds
/// count) when set so reproducible builds get a deterministic timestamp;
/// otherwise falls back to the current wall-clock time.
fn build_timestamp() -> String {
let secs = std::env::var("SOURCE_DATE_EPOCH")
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.unwrap_or_else(unix_now_secs);
iso8601_utc(secs)
}

/// Current time as whole Unix seconds (0 if the clock predates the epoch).
fn unix_now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}

/// Format Unix `secs` as ISO-8601 UTC (`YYYY-MM-DDTHH:MM:SSZ`), computed with
/// no external crates via the civil-from-days algorithm.
fn iso8601_utc(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);

// Howard Hinnant's civil_from_days, epoch 1970-01-01.
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let day = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
let month = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
let year = if month <= 2 { year + 1 } else { year };

format!("{year:04}-{month:02}-{day:02}T{hh:02}:{mm:02}:{ss:02}Z")
}
36 changes: 36 additions & 0 deletions autumn-cli/src/templates/main.api.rs.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use autumn_web::migrate::{EmbeddedMigrations, embed_migrations};
use autumn_web::prelude::*;
use autumn_web::reexports::serde_json;

const MIGRATIONS: EmbeddedMigrations = embed_migrations!();

// JSON-first API scaffold (`autumn new --api`): handlers return `Json<...>`,
// there is no HTML/CSS view layer. Add typed request/response structs with
// `#[derive(serde::Serialize)]` (via `autumn_web::reexports::serde`) as your
// API grows.

#[get("/")]
async fn index() -> Json<serde_json::Value> {
Json(serde_json::json!({
"name": "{{project_name}}",
"message": "Welcome to {{project_name}}!",
}))
}

#[get("/hello/{name}")]
async fn hello_name(name: autumn_web::extract::Path<String>) -> Json<serde_json::Value> {
Json(serde_json::json!({
"message": format!("Hello, {}!", *name),
}))
}

#[autumn_web::main]
async fn main() {
let app = autumn_web::app()
.routes(routes![index, hello_name])
.migrations(MIGRATIONS);

app
.run()
.await;
}
Loading
Loading