outrig_cli/image_setup/
init.rs1use std::path::Path;
15
16use crate::error::{OutrigError, Result};
17use crate::image_setup::render::{self, BaseImage, McpServer};
18use crate::paths::write_atomic;
19
20pub fn run(cwd: &Path, dir: Option<&Path>, force: bool) -> Result<()> {
28 let target_dir = dir.map_or_else(|| cwd.to_path_buf(), |d| cwd.join(d));
29 let name = derive_name(&target_dir)?;
30
31 let dockerfile_path = target_dir.join("Dockerfile");
32 let image_toml_path = target_dir.join("image.toml");
33 let readme_path = target_dir.join("README.md");
34
35 if !force {
36 let existing: Vec<String> = [&dockerfile_path, &image_toml_path, &readme_path]
37 .into_iter()
38 .filter(|p| p.exists())
39 .map(|p| display_rel(p, cwd).to_string())
40 .collect();
41 if !existing.is_empty() {
42 return Err(OutrigError::Configuration(format!(
43 "{} already present; pass --force to overwrite.",
44 existing.join(", ")
45 ))
46 .into());
47 }
48 }
49
50 write_and_report(&dockerfile_path, &render_dockerfile(), cwd)?;
51 write_and_report(&image_toml_path, &render_image_toml(&name), cwd)?;
52 write_and_report(&readme_path, &render_readme(&name), cwd)?;
53
54 eprintln!("[outrig] next: build this image with `outrig image build`");
55 Ok(())
56}
57
58fn derive_name(target_dir: &Path) -> Result<String> {
63 let name = target_dir
64 .file_name()
65 .and_then(|s| s.to_str())
66 .ok_or_else(|| {
67 OutrigError::Configuration(format!(
68 "could not derive a project name from {}; pass a directory name, \
69 e.g. `outrig image init rust-dev`",
70 target_dir.display()
71 ))
72 })?;
73 if !is_valid_project_name(name) {
74 return Err(OutrigError::Configuration(format!(
75 "{name:?} is not a valid image name (must match ^[a-zA-Z][a-zA-Z0-9_-]*$); \
76 rename the directory or pass a valid name."
77 ))
78 .into());
79 }
80 Ok(name.to_string())
81}
82
83fn is_valid_project_name(name: &str) -> bool {
87 let mut chars = name.chars();
88 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic())
89 && chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
90}
91
92fn render_dockerfile() -> String {
95 render::render(BaseImage::DebianBookwormSlim, &[], &[McpServer::Fs])
96}
97
98fn render_image_toml(name: &str) -> String {
99 format!(
100 "# OutRig standalone image config. `outrig image build` validates this file\n\
101 # and stamps its MCP config into OCI labels. Required: [image].ref and a non-empty [mcp].\n\
102 \n\
103 [image]\n\
104 ref = \"{name}\"\n\
105 # description = \"...\"\n\
106 # version = \"0.1.0\"\n\
107 # tags = [\"...\"]\n\
108 \n\
109 [mcp]\n\
110 fs = {{ command = [\"mcp-server-filesystem\", \"/workspace\"] }}\n"
111 )
112}
113
114fn render_readme(name: &str) -> String {
115 format!(
118 "# {name}\n\
119 \n\
120 An OutRig standalone toolset image. Building this project produces a container image (`{name}`) that bundles the agent's tools and MCP servers, ready to reference from any repo's OutRig config.\n\
121 \n\
122 ## Files\n\
123 \n\
124 - `Dockerfile`: the image definition. Follows OutRig conventions -- no `USER`, ends with `CMD [\"sleep\", \"infinity\"]`, and installs the host-UID bootstrap packages.\n\
125 - `image.toml`: image metadata plus the `[mcp]` table. With no `[build]` section it builds the sibling `Dockerfile` with context `.`. `outrig image build` stamps this config into OCI labels.\n\
126 - `README.md`: this file.\n\
127 \n\
128 ## Build\n\
129 \n\
130 ```sh\n\
131 outrig image build\n\
132 ```\n\
133 \n\
134 Run from this directory. It builds the `Dockerfile`, tags the image as `{name}`, stamps the `image.toml` config into OCI labels, and verifies the labeled MCP servers.\n\
135 \n\
136 ## Use it from a repo\n\
137 \n\
138 Reference the built image from a repo's `.agents/outrig/config.toml` with `image-name`. The MCP servers are declared by the image labels, so no `[images.{name}.mcp]` block is needed:\n\
139 \n\
140 ```toml\n\
141 [images.{name}]\n\
142 image-name = \"{name}\"\n\
143 ```\n"
144 )
145}
146
147fn write_and_report(path: &Path, contents: &str, cwd: &Path) -> Result<()> {
148 write_atomic(path, contents)?;
149 eprintln!("[outrig] wrote {}", display_rel(path, cwd));
150 Ok(())
151}
152
153fn display_rel<'a>(path: &'a Path, root: &Path) -> std::path::Display<'a> {
154 path.strip_prefix(root).unwrap_or(path).display()
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 use outrig::config::Config;
162 use outrig::container::embedded::parse_standalone_image_toml;
163
164 use crate::mcp_self::validate::{validate_dockerfile, validate_image_toml};
165
166 fn read(path: &Path) -> String {
167 std::fs::read_to_string(path).unwrap()
168 }
169
170 #[test]
171 fn init_with_dir_arg_creates_three_files() {
172 let tmp = tempfile::tempdir().unwrap();
173 run(tmp.path(), Some(Path::new("rust-dev")), false).unwrap();
174
175 let proj = tmp.path().join("rust-dev");
176 assert!(proj.join("Dockerfile").is_file());
177 assert!(proj.join("image.toml").is_file());
178 assert!(proj.join("README.md").is_file());
179 assert!(read(&proj.join("image.toml")).contains("ref = \"rust-dev\""));
180 }
181
182 #[test]
183 fn name_defaults_to_cwd_basename() {
184 let tmp = tempfile::tempdir().unwrap();
185 let cwd = tmp.path().join("web-tools");
186 run(&cwd, None, false).unwrap();
188 assert!(read(&cwd.join("image.toml")).contains("ref = \"web-tools\""));
189 }
190
191 #[test]
192 fn init_dot_uses_cwd_basename() {
193 let tmp = tempfile::tempdir().unwrap();
194 let cwd = tmp.path().join("api-image");
195 std::fs::create_dir_all(&cwd).unwrap();
196 run(&cwd, Some(Path::new(".")), false).unwrap();
197 assert!(read(&cwd.join("image.toml")).contains("ref = \"api-image\""));
198 }
199
200 #[test]
201 fn parent_dir_arg_errors() {
202 let tmp = tempfile::tempdir().unwrap();
203 let cwd = tmp.path().join("proj");
204 std::fs::create_dir_all(&cwd).unwrap();
205 let err = run(&cwd, Some(Path::new("..")), false).unwrap_err();
206 assert!(
207 err.to_string().contains("could not derive a project name"),
208 "{err}"
209 );
210 }
211
212 #[test]
213 fn invalid_directory_name_errors() {
214 let tmp = tempfile::tempdir().unwrap();
215 let err = run(tmp.path(), Some(Path::new("123-foo")), false).unwrap_err();
216 assert!(err.to_string().contains("not a valid image name"), "{err}");
217 }
218
219 #[test]
220 fn generated_image_toml_passes_validator() {
221 let tmp = tempfile::tempdir().unwrap();
222 run(tmp.path(), Some(Path::new("rust-dev")), false).unwrap();
223 let image_toml = read(&tmp.path().join("rust-dev/image.toml"));
224
225 let result = validate_image_toml(&image_toml);
226 assert!(result.valid, "{result:?}");
227 assert!(parse_standalone_image_toml(&image_toml).is_ok());
228 }
229
230 #[test]
231 fn generated_dockerfile_passes_validator() {
232 let tmp = tempfile::tempdir().unwrap();
233 run(tmp.path(), Some(Path::new("rust-dev")), false).unwrap();
234 let dockerfile = read(&tmp.path().join("rust-dev/Dockerfile"));
235
236 assert_eq!(validate_dockerfile(&dockerfile).warnings, Vec::new());
237 assert!(dockerfile.starts_with("FROM docker.io/library/debian:bookworm-slim"));
238 assert!(dockerfile.contains("npm install -g @modelcontextprotocol/server-filesystem"));
239 assert!(!dockerfile.contains("COPY image.toml"), "{dockerfile}");
240
241 assert!(
242 dockerfile
243 .trim_end()
244 .ends_with("CMD [\"sleep\", \"infinity\"]")
245 );
246 assert!(!dockerfile.contains("USER "), "{dockerfile}");
247 }
248
249 #[test]
250 fn render_footer_token_is_unique() {
251 let body = render::render(BaseImage::DebianBookwormSlim, &[], &[McpServer::Fs]);
252 assert_eq!(body.matches("WORKDIR /workspace").count(), 1, "{body}");
253 }
254
255 #[test]
256 fn refuses_to_overwrite_without_force() {
257 let tmp = tempfile::tempdir().unwrap();
258 run(tmp.path(), Some(Path::new("rust-dev")), false).unwrap();
259
260 let err = run(tmp.path(), Some(Path::new("rust-dev")), false).unwrap_err();
261 let msg = err.to_string();
262 assert!(msg.contains("--force"), "{msg}");
263 assert!(msg.contains("Dockerfile"), "{msg}");
264 }
265
266 #[test]
267 fn force_overwrites_only_generated_files() {
268 let tmp = tempfile::tempdir().unwrap();
269 run(tmp.path(), Some(Path::new("rust-dev")), false).unwrap();
270
271 let proj = tmp.path().join("rust-dev");
272 let sentinel = proj.join("keep.txt");
273 std::fs::write(&sentinel, "keep me").unwrap();
274 std::fs::write(proj.join("image.toml"), "garbage").unwrap();
276
277 run(tmp.path(), Some(Path::new("rust-dev")), true).unwrap();
278
279 assert_eq!(read(&sentinel), "keep me");
280 assert!(read(&proj.join("image.toml")).contains("ref = \"rust-dev\""));
281 }
282
283 #[test]
284 fn readme_documents_image_name_consuming_config() {
285 let readme = render_readme("rust-dev");
286 assert!(
289 readme.contains("[images.rust-dev]\nimage-name = \"rust-dev\""),
290 "{readme}"
291 );
292 assert!(
293 readme.contains("stamps this config into OCI labels"),
294 "{readme}"
295 );
296 }
297
298 #[test]
299 fn consuming_repo_config_is_valid() {
300 let snippet = "[images.rust-dev]\nimage-name = \"rust-dev\"\n";
302 let cfg = Config::load_from_str(snippet).expect("config parses");
303 cfg.validate(None).expect("config validates");
304 }
305}