Skip to main content

chipzen_sdk/
scaffold.rs

1//! `chipzen-sdk init <name>` — scaffold a new Chipzen bot project.
2//!
3//! Mirrors the Python and JavaScript scaffold shape. Emits a Cargo
4//! project that depends on `chipzen-bot`, with a starter `MyBot` impl
5//! and a Dockerfile placeholder (replaced with the real IP-protected
6//! recipe in Phase 3 PR 3).
7
8use anyhow::{anyhow, bail, Context, Result};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Default, Clone)]
13pub struct ScaffoldOptions {
14    /// Where to create the project. `None` means current directory.
15    pub parent_dir: Option<PathBuf>,
16}
17
18pub fn scaffold_bot(name: &str, opts: &ScaffoldOptions) -> Result<PathBuf> {
19    if !is_valid_project_name(name) {
20        bail!(
21            "Invalid project name {name:?}. Use ASCII letters, digits, underscores, and dashes only."
22        );
23    }
24    let parent = match &opts.parent_dir {
25        Some(p) => p.clone(),
26        None => std::env::current_dir().context("could not resolve current directory")?,
27    };
28    let project_dir = parent.join(name);
29
30    if project_dir.exists() {
31        return Err(anyhow!(
32            "Directory already exists: {}",
33            project_dir.display()
34        ));
35    }
36
37    fs::create_dir_all(&project_dir)
38        .with_context(|| format!("creating {}", project_dir.display()))?;
39    fs::create_dir_all(project_dir.join("src"))
40        .with_context(|| format!("creating {}/src", project_dir.display()))?;
41
42    write_file(&project_dir.join("Cargo.toml"), &cargo_toml(name))?;
43    write_file(&project_dir.join("src").join("main.rs"), MAIN_RS_TEMPLATE)?;
44    write_file(&project_dir.join(".gitignore"), GITIGNORE_TEMPLATE)?;
45    write_file(&project_dir.join(".dockerignore"), DOCKERIGNORE_TEMPLATE)?;
46    write_file(&project_dir.join("README.md"), &readme_template(name))?;
47    write_file(&project_dir.join("Dockerfile"), DOCKERFILE_TEMPLATE)?;
48
49    Ok(project_dir)
50}
51
52fn write_file(path: &Path, contents: &str) -> Result<()> {
53    fs::write(path, contents).with_context(|| format!("writing {}", path.display()))
54}
55
56fn is_valid_project_name(name: &str) -> bool {
57    !name.is_empty()
58        && name
59            .chars()
60            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
61}
62
63fn cargo_toml(name: &str) -> String {
64    format!(
65        r#"[package]
66name = "{name}"
67version = "0.1.0"
68edition = "2021"
69
70# The release binary is named `bot` so the IP-protected Dockerfile can
71# `cp target/release/bot /build/bot` without knowing the package name.
72[[bin]]
73name = "bot"
74path = "src/main.rs"
75
76[dependencies]
77chipzen-bot = "0.3"
78tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
79
80[profile.release]
81opt-level = 3
82lto = "thin"
83strip = "symbols"
84codegen-units = 1
85"#
86    )
87}
88
89const MAIN_RS_TEMPLATE: &str = r#"//! Chipzen starter bot — replace `decide` with your strategy.
90//! The SDK handles WebSocket, handshake, ping/pong, retries, and reconnect.
91//!
92//! Two ways to play:
93//!   * Default (containerized / direct match URL): the platform's executor
94//!     runs this binary in a container and injects CHIPZEN_WS_URL +
95//!     CHIPZEN_TOKEN. `cargo run` with those set, or pass the URL as the
96//!     first argument, to test locally.
97//!   * `bot run-external [--env staging] [--max-matches 1]`: external-API
98//!     remote-play — run on YOUR machine with a `cz_extbot_` token and let
99//!     the platform match + dispatch you. Reads token/bot_id/url from a
100//!     `chipzen.toml` (or pass --token / --bot-id). See the SDK docs.
101
102use chipzen_bot::{
103    run_bot, run_external_cli, Action, Bot, EnvName, GameState, RunBotOptions, RunExternalArgs,
104};
105
106struct MyBot;
107
108impl Bot for MyBot {
109    fn decide(&mut self, state: &GameState) -> Action {
110        // Return one of: Action::Fold, Action::Check, Action::Call,
111        // Action::Raise(amount), Action::AllIn. The chosen action's
112        // wire-form must be in state.valid_actions; raises must satisfy
113        // state.min_raise <= amount <= state.max_raise.
114        if state.valid_actions.iter().any(|a| a == "check") {
115            Action::Check
116        } else {
117            Action::Fold
118        }
119    }
120}
121
122#[tokio::main]
123async fn main() -> Result<(), chipzen_bot::Error> {
124    let args: Vec<String> = std::env::args().skip(1).collect();
125
126    if args.first().map(String::as_str) == Some("run-external") {
127        // External-API remote-play: your machine connects to the lobby and
128        // the platform matches + dispatches you. Flags mirror the Python CLI.
129        return run_external_mode(&args[1..]).await.map(|_| ());
130    }
131
132    // Default: containerized / direct-match path. The platform injects
133    // CHIPZEN_WS_URL and CHIPZEN_TOKEN (or CHIPZEN_TICKET) at launch; for
134    // local testing set them yourself or pass the URL as the first argument.
135    let url = args
136        .first()
137        .cloned()
138        .or_else(|| std::env::var("CHIPZEN_WS_URL").ok())
139        .unwrap_or_else(|| {
140            eprintln!("error: CHIPZEN_WS_URL not set and no URL passed on the command line");
141            std::process::exit(1);
142        });
143
144    let options = RunBotOptions {
145        token: std::env::var("CHIPZEN_TOKEN").ok(),
146        ticket: std::env::var("CHIPZEN_TICKET").ok(),
147        ..Default::default()
148    };
149
150    run_bot(&url, MyBot, options).await.map(|_| ())
151}
152
153/// Parse `run-external` flags and play. A fresh `MyBot` per match.
154async fn run_external_mode(flags: &[String]) -> Result<(), chipzen_bot::Error> {
155    let mut args = RunExternalArgs::new();
156    let mut i = 0;
157    while i < flags.len() {
158        match flags[i].as_str() {
159            "--env" => {
160                i += 1;
161                args.env = flags.get(i).and_then(|e| EnvName::parse(e));
162            }
163            "--token" => {
164                i += 1;
165                args.token = flags.get(i).cloned();
166            }
167            "--bot-id" => {
168                i += 1;
169                args.bot_id = flags.get(i).cloned();
170            }
171            "--max-matches" => {
172                i += 1;
173                args.max_matches = flags.get(i).and_then(|v| v.parse().ok());
174            }
175            "--no-safe-mode" => args.safe_mode = false,
176            other => eprintln!("warning: ignoring unknown flag {other:?}"),
177        }
178        i += 1;
179    }
180    let results = run_external_cli(|| MyBot, args).await?;
181    eprintln!("played {} match(es)", results.len());
182    Ok(())
183}
184"#;
185
186const GITIGNORE_TEMPLATE: &str = "target/\nCargo.lock\n.env\n.env.*\n.DS_Store\n";
187
188const DOCKERIGNORE_TEMPLATE: &str =
189    "target/\n.git/\n.gitignore\n.env\n.env.*\n*.md\nREADME*\nLICENSE*\n.DS_Store\n";
190
191// Kept identical to packages/rust/starters/rust/Dockerfile so a
192// scaffolded project and the canonical starter directory ship the same
193// recipe. A test enforces this byte-identity invariant.
194const DOCKERFILE_TEMPLATE: &str = r#"# syntax=docker/dockerfile:1.7
195#
196# IP-protected Chipzen Rust bot image.
197#
198# Multi-stage build that compiles the bot to a single, statically-
199# linked release binary in the builder stage, then ships only that
200# binary in the runtime stage. The runtime image contains no readable
201# Rust source for your strategy code — only the stripped binary.
202#
203# See ../../IP-PROTECTION.md for what this protects (and what it doesn't).
204#
205# Build:   docker build -t my-bot:test .
206# Export:  docker save my-bot:test | gzip > my-bot.tar.gz
207#
208# Build context for this directory should be small (Cargo.toml +
209# src/ + this file). The .dockerignore alongside this file keeps the
210# target/ build cache and editor metadata out.
211
212# -----------------------------------------------------------------------------
213# Stage 1: cargo build --release. The .rs source lives only in this stage and
214# is discarded before the runtime stage starts.
215# -----------------------------------------------------------------------------
216# Base pinned by tag — Dependabot can rotate to digest pinning later. Tag:
217# rust:1-slim (debian-bookworm-based; the runtime stage below also uses
218# debian-bookworm so glibc + libssl line up for the compiled binary).
219FROM rust:1-slim AS builder
220
221WORKDIR /build
222
223# Build tools needed by tokio-tungstenite's `native-tls` feature
224# (links against system openssl). pkg-config tells the openssl-sys
225# build script where libssl + libcrypto live.
226RUN apt-get update \
227    && apt-get install -y --no-install-recommends \
228        pkg-config \
229        libssl-dev \
230    && rm -rf /var/lib/apt/lists/*
231
232# Bring in the bot source + manifest. Only these are copied — keep
233# the build context narrow so the .dockerignore is the only allowlist.
234COPY Cargo.toml ./
235COPY src/ ./src/
236
237# Build the release binary. The starter's [profile.release] is already
238# tuned for a small, symbol-stripped binary (lto=thin, opt-level=3,
239# codegen-units=1).
240RUN cargo build --release --bin bot \
241    && cp target/release/bot /build/bot \
242    && rm -rf src/ target/ Cargo.toml
243
244# -----------------------------------------------------------------------------
245# Stage 2: Runtime. Only the compiled binary + ENTRYPOINT.
246# No .rs source for the bot's strategy is present.
247# -----------------------------------------------------------------------------
248# Base pinned by tag — Dependabot can rotate to digest pinning later. Tag:
249# debian:12-slim. Matches the glibc the builder stage links against and
250# carries libssl3 + ca-certs for outbound TLS to the platform's wss://.
251FROM debian:12-slim
252
253RUN apt-get update \
254    && apt-get install -y --no-install-recommends \
255        ca-certificates \
256        libssl3 \
257        dumb-init \
258    && rm -rf /var/lib/apt/lists/*
259
260WORKDIR /bot
261
262# Copy ONLY the compiled binary from the builder stage.
263COPY --from=builder /build/bot /bot/bot
264RUN chmod +x /bot/bot
265
266# Run as non-root (defense in depth — the platform also applies seccomp
267# and cap-drop on top of this).
268RUN groupadd --system --gid 10001 bot \
269    && useradd --system --uid 10001 --gid bot --home-dir /bot --shell /usr/sbin/nologin bot \
270    && chown -R bot:bot /bot
271USER 10001
272
273ENTRYPOINT ["dumb-init", "/bot/bot"]
274"#;
275pub const _DOCKERFILE_TEMPLATE_FOR_TEST: &str = DOCKERFILE_TEMPLATE;
276
277fn readme_template(name: &str) -> String {
278    format!(
279        r#"# {name}
280
281A poker bot for the [Chipzen](https://chipzen.ai) platform.
282
283## Quick start
284
285```bash
286cargo build
287```
288
289Edit `src/main.rs` to implement your strategy in the `decide` method.
290
291Validate before uploading:
292
293```bash
294chipzen-sdk validate .
295```
296
297Build and export the upload tarball:
298
299```bash
300docker build -t {name}:v1 .
301docker save {name}:v1 | gzip > {name}.tar.gz
302```
303
304Then upload via the Chipzen platform UI.
305"#
306    )
307}