1use std::path::Path;
13
14use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value};
15
16use crate::error::{OutrigError, Result};
17use crate::image_setup::render::{self, BaseImage, McpServer, Toolchain};
18use crate::init::prompt::{self, Field, PromptSource};
19use crate::init::repo as init_repo;
20use crate::paths::{global_config_path, image_dir, image_dir_rel, repo_config_path, write_atomic};
21
22pub async fn run(
30 cwd: &Path,
31 global_override: Option<&Path>,
32 name: Option<String>,
33 force: bool,
34) -> Result<()> {
35 let global_path = global_config_path(global_override);
36 let mut prompt = prompt::auto();
37 let mut hf = crate::hf::auto();
38 let (repo_root, bootstrapped_name) =
39 init_repo::resolve_or_bootstrap(cwd, &global_path, &mut prompt, &mut hf).await?;
40 let effective = name.or(bootstrapped_name);
43 run_with(&repo_root, effective, force, &mut prompt).await
44}
45
46pub async fn run_with(
52 repo_root: &Path,
53 name_arg: Option<String>,
54 force: bool,
55 prompt: &mut impl PromptSource,
56) -> Result<()> {
57 let cfg_path = repo_config_path(repo_root);
58
59 let name = match name_arg {
60 Some(n) => n,
61 None => {
62 let default = init_repo::default_image_name(repo_root);
63 prompt.ask_string(&NAME_FIELD, &default).await?
64 }
65 };
66
67 let dockerfile_path = image_dir(repo_root, &name).join("Dockerfile");
68 let mut doc = load_doc(&cfg_path)?;
69
70 if !force {
71 if dockerfile_path.exists() {
72 return Err(OutrigError::Configuration(format!(
73 "{} already exists; pass --force to overwrite.",
74 dockerfile_path.display()
75 ))
76 .into());
77 }
78 if image_block_exists(&doc, &name) {
79 return Err(OutrigError::Configuration(format!(
80 "[images.{name}] already exists in {}; pass --force to overwrite.",
81 cfg_path.display()
82 ))
83 .into());
84 }
85 }
86
87 let base_idx = prompt.ask_select(&BASE_FIELD, 0).await?;
88 let base = BaseImage::ALL[base_idx];
89
90 let toolchain_indices = prompt.ask_multiselect(&TOOLCHAIN_FIELD, &[]).await?;
91 let toolchains: Vec<Toolchain> = toolchain_indices
92 .iter()
93 .map(|&i| Toolchain::ALL[i])
94 .collect();
95
96 let default_mcps: Vec<usize> = vec![DEFAULT_MCP_INDEX];
98 let mcp_indices = prompt.ask_multiselect(&MCP_FIELD, &default_mcps).await?;
99 let mcps: Vec<McpServer> = mcp_indices.iter().map(|&i| McpServer::ALL[i]).collect();
100
101 let dockerfile = render::render(base, &toolchains, &mcps);
102 write_atomic(&dockerfile_path, &dockerfile)?;
103 eprintln!(
104 "[outrig] wrote {}",
105 display_rel(&dockerfile_path, repo_root)
106 );
107
108 insert_image_block(&mut doc, &name, &mcps);
109 write_atomic(&cfg_path, &doc.to_string())?;
110 eprintln!(
111 "[outrig] added [images.{name}] block to {}",
112 display_rel(&cfg_path, repo_root)
113 );
114 if mcps.is_empty() {
115 eprintln!("[outrig] [images.{name}.mcp] is empty");
116 } else {
117 let names: Vec<&str> = mcps.iter().map(|m| m.as_str()).collect();
118 eprintln!(
119 "[outrig] added [images.{name}.mcp] entries: {}",
120 names.join(", ")
121 );
122 }
123
124 Ok(())
125}
126
127pub(crate) const NAME_FIELD: Field = Field {
130 name: "Image name",
131 description: "Used as the [images.<name>] key. Must match `^[a-zA-Z][a-zA-Z0-9_-]*$`.",
132 options: &[],
133 doc_link: "doc/usage/image.md",
134};
135
136const BASES: &[(&str, &str)] = &[
137 (
138 BaseImage::DebianBookwormSlim.as_str(),
139 BaseImage::DebianBookwormSlim.description(),
140 ),
141 (
142 BaseImage::Ubuntu24_04.as_str(),
143 BaseImage::Ubuntu24_04.description(),
144 ),
145 (
146 BaseImage::AlpineLatest.as_str(),
147 BaseImage::AlpineLatest.description(),
148 ),
149 (
150 BaseImage::Node20BookwormSlim.as_str(),
151 BaseImage::Node20BookwormSlim.description(),
152 ),
153 (
154 BaseImage::Python3_12Slim.as_str(),
155 BaseImage::Python3_12Slim.description(),
156 ),
157];
158
159const BASE_FIELD: Field = Field {
160 name: "Base image",
161 description: "The Dockerfile's `FROM` line. Pick one of the curated starting points.",
162 options: BASES,
163 doc_link: "doc/usage/image.md",
164};
165
166const TOOLCHAINS: &[(&str, &str)] = &[
167 (
168 "rust",
169 "rustup + stable toolchain (cargo, rustfmt, clippy).",
170 ),
171 ("node", "Node 20 LTS via the base image's package manager."),
172 ("python", "CPython 3 with pip and venv."),
173 ("go", "Go 1.22."),
174 ("none", "Just the base image -- nothing extra installed."),
175];
176
177const TOOLCHAIN_FIELD: Field = Field {
178 name: "Language toolchains",
179 description: "Pick zero or more language toolchains to install in the image. \
180 The Dockerfile template adds the corresponding install steps; \
181 you can edit the file afterwards.",
182 options: TOOLCHAINS,
183 doc_link: "doc/usage/image.md",
184};
185
186const MCPS: &[(&str, &str)] = &[
187 (McpServer::Fs.as_str(), McpServer::Fs.description()),
188 (McpServer::Git.as_str(), McpServer::Git.description()),
189];
190
191const MCP_FIELD: Field = Field {
192 name: "MCP servers",
193 description: "Pick zero or more MCP servers to install in the image. The \
194 Dockerfile installs each server's package and the matching \
195 [images.<name>.mcp] entry is appended to config.toml.",
196 options: MCPS,
197 doc_link: "doc/concepts/mcp-servers.md",
198};
199
200pub const DOC_SYNC_FIELDS: &[&Field] = &[&NAME_FIELD, &BASE_FIELD, &TOOLCHAIN_FIELD, &MCP_FIELD];
202
203const DEFAULT_MCP_INDEX: usize = 0;
207const _: () = assert!(matches!(McpServer::ALL[DEFAULT_MCP_INDEX], McpServer::Fs));
208
209fn display_rel<'a>(path: &'a Path, root: &Path) -> std::path::Display<'a> {
212 path.strip_prefix(root).unwrap_or(path).display()
213}
214
215fn load_doc(cfg_path: &Path) -> Result<DocumentMut> {
216 let text = match std::fs::read_to_string(cfg_path) {
217 Ok(t) => t,
218 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
219 Err(e) => return Err(e.into()),
220 };
221 text.parse::<DocumentMut>().map_err(|e| {
222 OutrigError::Configuration(format!("parsing {}: {e}", cfg_path.display())).into()
223 })
224}
225
226fn image_block_exists(doc: &DocumentMut, name: &str) -> bool {
227 doc.get("images")
228 .and_then(|c| c.as_table_like())
229 .is_some_and(|t| t.contains_key(name))
230}
231
232fn insert_image_block(doc: &mut DocumentMut, name: &str, mcps: &[McpServer]) {
233 let rel = image_dir_rel(name);
234 let dockerfile = rel.join("Dockerfile").to_string_lossy().into_owned();
235 let context = rel.to_string_lossy().into_owned();
236
237 let mut entry = Table::new();
238 entry.insert("dockerfile", Item::Value(Value::from(dockerfile)));
239 entry.insert("context", Item::Value(Value::from(context)));
240
241 let mut mcp = Table::new();
242 for server in mcps {
243 mcp.insert(server.as_str(), mcp_value(*server));
244 }
245 entry.insert("mcp", Item::Table(mcp));
246
247 let images = doc
248 .entry("images")
249 .or_insert_with(|| {
250 let mut t = Table::new();
251 t.set_implicit(true);
252 Item::Table(t)
253 })
254 .as_table_mut()
255 .expect("images must be a table");
256 images.insert(name, Item::Table(entry));
257}
258
259fn mcp_value(server: McpServer) -> Item {
260 let mut cmd = Array::new();
261 for arg in server.command_args() {
262 cmd.push(*arg);
263 }
264 let mut full = InlineTable::new();
265 full.insert("command", Value::Array(cmd));
266 Item::Value(Value::InlineTable(full))
267}