Skip to content

Commit 59e7fed

Browse files
authored
Merge pull request #1700 from cachix/devenv-generate
`devenv generate`
2 parents 7f756cd + 4d4973d commit 59e7fed

14 files changed

+2095
-386
lines changed

Cargo.lock

+756-323
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+4-2
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ xtask = { path = "xtask" }
2525

2626
ansiterm = "0.12.2"
2727
blake3 = "1.5.4"
28-
clap = { version = "4.5.1", features = ["derive", "cargo"] }
28+
clap = { version = "4.5.1", features = ["derive", "cargo", "env"] }
2929
cli-table = "0.4.7"
3030
console = "0.15.8"
3131
dotlock = "0.5.0"
32+
dialoguer = "0.11.0"
3233
futures = "0.3.30"
3334
hex = "0.4.3"
3435
include_dir = "0.7.3"
@@ -40,7 +41,7 @@ nix = { version = "0.28.0", features = ["signal"] }
4041
petgraph = "0.6.5"
4142
pretty_assertions = { version = "1.4.0", features = ["unstable"] }
4243
regex = "1.10.3"
43-
reqwest = "0.11.26"
44+
reqwest = { version = "0.11.26", features = ["json", "stream"] }
4445
schemars = "0.8.16"
4546
schematic = { version = "0.14.3", features = [
4647
"schema",
@@ -72,6 +73,7 @@ tokio = { version = "1.39.3", features = [
7273
which = "6.0.0"
7374
whoami = "1.5.1"
7475
xdg = "2.5.2"
76+
tokio-tar = "0.3.1"
7577

7678
# Always build optimized sqlx-macro to speed up query checks
7779
[profile.dev.package.sqlx-macros]

devenv.lock

+37
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,26 @@
1313
},
1414
"parent": []
1515
},
16+
"fenix": {
17+
"inputs": {
18+
"nixpkgs": [
19+
"nixpkgs"
20+
],
21+
"rust-analyzer-src": "rust-analyzer-src"
22+
},
23+
"locked": {
24+
"lastModified": 1731738660,
25+
"owner": "nix-community",
26+
"repo": "fenix",
27+
"rev": "e10ba121773f754a30d31b6163919a3e404a434f",
28+
"type": "github"
29+
},
30+
"original": {
31+
"owner": "nix-community",
32+
"repo": "fenix",
33+
"type": "github"
34+
}
35+
},
1636
"flake-compat": {
1737
"flake": false,
1838
"locked": {
@@ -248,13 +268,30 @@
248268
"root": {
249269
"inputs": {
250270
"devenv": "devenv",
271+
"fenix": "fenix",
251272
"git-hooks": "git-hooks",
252273
"nix": "nix",
253274
"nixpkgs": "nixpkgs_2",
254275
"pre-commit-hooks": [
255276
"git-hooks"
256277
]
257278
}
279+
},
280+
"rust-analyzer-src": {
281+
"flake": false,
282+
"locked": {
283+
"lastModified": 1731827189,
284+
"owner": "rust-lang",
285+
"repo": "rust-analyzer",
286+
"rev": "ba56d9b9b5f7ae7311b4bd1cc47159d87eb033d0",
287+
"type": "github"
288+
},
289+
"original": {
290+
"owner": "rust-lang",
291+
"ref": "nightly",
292+
"repo": "rust-analyzer",
293+
"type": "github"
294+
}
258295
}
259296
},
260297
"root": "root",

devenv.nix

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
languages.nix.enable = true;
2121
# for cli
2222
languages.rust.enable = true;
23+
languages.rust.channel = "stable";
2324
# for docs
2425
languages.python.enable = true;
2526
# it breaks glibc

devenv.yaml

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# yaml-language-server: $schema=./docs/devenv.schema.json
22
inputs:
3-
devenv:
4-
url: path:.?dir=src/modules
3+
fenix:
4+
url: github:nix-community/fenix
5+
inputs:
6+
nixpkgs:
7+
follows: nixpkgs
58
nix:
69
url: github:domenkozar/nix/devenv-2.24
10+
devenv:
11+
url: path:.?dir=src/modules

devenv/Cargo.toml

+5
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ nix-conf-parser.workspace = true
1717
clap.workspace = true
1818
cli-table.workspace = true
1919
console.workspace = true
20+
dialoguer.workspace = true
2021
dotlock.workspace = true
2122
hex.workspace = true
2223
include_dir.workspace = true
@@ -43,4 +44,8 @@ tracing-subscriber.workspace = true
4344
which.workspace = true
4445
whoami.workspace = true
4546
xdg.workspace = true
47+
tokio-tar.workspace = true
48+
tokio-util = { version = "0.7.12", features = ["io"] }
49+
similar = "2.6.0"
50+
binaryornot = "1.0.0"
4651
once_cell = "1.20.2"

devenv/src/cli.rs

+20
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,26 @@ pub enum Commands {
184184
#[command(about = "Scaffold devenv.yaml, devenv.nix, .gitignore and .envrc.")]
185185
Init { target: Option<PathBuf> },
186186

187+
#[command(about = "Generate devenv.yaml and devenv.nix using AI")]
188+
Generate {
189+
#[arg(num_args=0.., trailing_var_arg = true)]
190+
description: Vec<String>,
191+
192+
#[clap(long, default_value = "https://devenv.new")]
193+
host: String,
194+
195+
#[arg(
196+
long,
197+
help = "Paths to exclude during generation.",
198+
value_name = "PATH"
199+
)]
200+
exclude: Vec<PathBuf>,
201+
202+
// https://consoledonottrack.com/
203+
#[clap(long, env = "DO_NOT_TRACK", action = clap::ArgAction::SetTrue)]
204+
disable_telemetry: bool,
205+
},
206+
187207
#[command(about = "Activate the developer environment. https://devenv.sh/basics/")]
188208
Shell {
189209
cmd: Option<String>,

devenv/src/devenv.rs

+165-10
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ use clap::crate_version;
33
use cli_table::Table;
44
use cli_table::{print_stderr, WithTitle};
55
use include_dir::{include_dir, Dir};
6-
use miette::{bail, Result};
6+
use miette::{bail, IntoDiagnostic, Result};
77
use nix::sys::signal;
88
use nix::unistd::Pid;
99
use once_cell::sync::Lazy;
1010
use serde::Deserialize;
1111
use sha2::Digest;
12+
use similar::{ChangeTag, TextDiff};
1213
use std::collections::HashMap;
1314
use std::io::Write;
1415
use std::os::unix::{fs::PermissionsExt, process::CommandExt};
@@ -154,14 +155,6 @@ impl Devenv {
154155
std::fs::create_dir_all(&target).expect("Failed to create target directory");
155156
}
156157

157-
// fails if any of the required files already exists
158-
for filename in REQUIRED_FILES {
159-
let file_path = target.join(filename);
160-
if file_path.exists() && !EXISTING_REQUIRED_FILES.contains(&filename) {
161-
bail!("File already exists {}", file_path.display());
162-
}
163-
}
164-
165158
for filename in REQUIRED_FILES {
166159
info!("Creating {}", filename);
167160

@@ -183,7 +176,15 @@ impl Devenv {
183176
})
184177
.expect("Failed to append to existing file");
185178
} else {
186-
std::fs::write(&target_path, path.contents()).expect("Failed to write file");
179+
if target_path.exists() && !EXISTING_REQUIRED_FILES.contains(&filename) {
180+
if let Some(utf8_contents) = path.contents_utf8() {
181+
confirm_overwrite(&target_path, utf8_contents.to_string())?;
182+
} else {
183+
bail!("Failed to read file contents as UTF-8");
184+
}
185+
} else {
186+
std::fs::write(&target_path, path.contents()).expect("Failed to write file");
187+
}
187188
}
188189
}
189190

@@ -200,6 +201,120 @@ impl Devenv {
200201
Ok(())
201202
}
202203

204+
pub async fn generate(
205+
&mut self,
206+
description: Option<String>,
207+
host: &str,
208+
exclude: Vec<PathBuf>,
209+
disable_telemetry: bool,
210+
) -> Result<()> {
211+
let client = reqwest::Client::new();
212+
let mut request = client
213+
.post(host)
214+
.query(&[("disable_telemetry", disable_telemetry)])
215+
.header(reqwest::header::USER_AGENT, crate_version!());
216+
217+
let (asyncwriter, asyncreader) = tokio::io::duplex(256 * 1024);
218+
let streamreader = tokio_util::io::ReaderStream::new(asyncreader);
219+
220+
let (body_sender, body) = match description {
221+
Some(desc) => {
222+
request = request.query(&[("q", desc)]);
223+
(None, None)
224+
}
225+
None => {
226+
let git_output = std::process::Command::new("git")
227+
.args(["ls-files", "-z"])
228+
.output()
229+
.map_err(|_| {
230+
miette::miette!("Failed to get list of files from git ls-files")
231+
})?;
232+
233+
let files = String::from_utf8_lossy(&git_output.stdout)
234+
.split('\0')
235+
.filter(|s| !s.is_empty())
236+
.filter(|s| !binaryornot::is_binary(s).unwrap_or(false))
237+
.map(PathBuf::from)
238+
.collect::<Vec<_>>();
239+
240+
if files.is_empty() {
241+
warn!("No files found. Are you in a git repository?");
242+
return Ok(());
243+
}
244+
245+
if let Some(stderr) = String::from_utf8(git_output.stderr).ok() {
246+
if !stderr.is_empty() {
247+
warn!("{}", &stderr);
248+
}
249+
}
250+
251+
let body = reqwest::Body::wrap_stream(streamreader);
252+
253+
request = request
254+
.body(body)
255+
.header(reqwest::header::CONTENT_TYPE, "application/x-tar");
256+
257+
(Some(tokio_tar::Builder::new(asyncwriter)), Some(files))
258+
}
259+
};
260+
261+
info!("Generating devenv.nix and devenv.yaml, this should take about a minute ...");
262+
263+
let response_future = request.send();
264+
265+
let tar_task = async {
266+
if let (Some(mut builder), Some(files)) = (body_sender, body) {
267+
for path in files {
268+
if path.is_file() && !exclude.iter().any(|exclude| path.starts_with(exclude)) {
269+
builder.append_path(&path).await?;
270+
}
271+
}
272+
builder.finish().await?;
273+
}
274+
Ok::<(), std::io::Error>(())
275+
};
276+
277+
let (response, _) = tokio::join!(response_future, tar_task);
278+
279+
let response = response.into_diagnostic()?;
280+
let status = response.status();
281+
if !status.is_success() {
282+
let error_text = &response
283+
.text()
284+
.await
285+
.unwrap_or_else(|_| "No error details available".to_string());
286+
bail!(
287+
"Failed to generate (HTTP {}): {}",
288+
&status.as_u16(),
289+
match serde_json::from_str::<serde_json::Value>(error_text) {
290+
Ok(json) => json["message"]
291+
.as_str()
292+
.map(String::from)
293+
.unwrap_or_else(|| error_text.clone()),
294+
Err(_) => error_text.clone(),
295+
}
296+
);
297+
}
298+
299+
let response_json: GenerateResponse = response.json().await.expect("Failed to parse JSON.");
300+
301+
confirm_overwrite(Path::new("devenv.nix"), response_json.devenv_nix)?;
302+
confirm_overwrite(Path::new("devenv.yaml"), response_json.devenv_yaml)?;
303+
304+
info!(
305+
"{}",
306+
indoc::formatdoc!("
307+
Generated devenv.nix and devenv.yaml 🎉
308+
309+
Treat these as templates and open an issue at https://github.com/cachix/devenv/issues if you think we can do better!
310+
311+
Start by running:
312+
313+
$ devenv shell
314+
"));
315+
Ok(())
316+
}
317+
203318
pub fn inputs_add(&mut self, name: &str, url: &str, follows: &[String]) -> Result<()> {
204319
self.config.add_input(name, url, follows);
205320
self.config.write();
@@ -893,6 +1008,46 @@ impl Devenv {
8931008
}
8941009
}
8951010

1011+
fn confirm_overwrite(file: &Path, contents: String) -> Result<()> {
1012+
if std::fs::metadata(file).is_ok() {
1013+
// first output the old version and propose new changes
1014+
let before = std::fs::read_to_string(file).expect("Failed to read file");
1015+
1016+
let diff = TextDiff::from_lines(&before, &contents);
1017+
1018+
println!("\nChanges that will be made to {}:", file.to_string_lossy());
1019+
for change in diff.iter_all_changes() {
1020+
let sign = match change.tag() {
1021+
ChangeTag::Delete => "\x1b[31m-\x1b[0m",
1022+
ChangeTag::Insert => "\x1b[32m+\x1b[0m",
1023+
ChangeTag::Equal => " ",
1024+
};
1025+
print!("{}{}", sign, change);
1026+
}
1027+
1028+
let confirm = dialoguer::Confirm::new()
1029+
.with_prompt(format!(
1030+
"{} already exists. Do you want to overwrite it?",
1031+
file.to_string_lossy()
1032+
))
1033+
.interact()
1034+
.into_diagnostic()?;
1035+
1036+
if confirm {
1037+
std::fs::write(file, contents).into_diagnostic()?;
1038+
}
1039+
} else {
1040+
std::fs::write(file, contents).into_diagnostic()?;
1041+
}
1042+
Ok(())
1043+
}
1044+
1045+
#[derive(Deserialize)]
1046+
struct GenerateResponse {
1047+
devenv_nix: String,
1048+
devenv_yaml: String,
1049+
}
1050+
8961051
pub struct DevEnv {
8971052
output: Vec<u8>,
8981053
gc_root: PathBuf,

devenv/src/main.rs

+19
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,25 @@ async fn main() -> Result<()> {
132132
Ok(())
133133
}
134134
Commands::Init { target } => devenv.init(&target),
135+
Commands::Generate {
136+
description,
137+
host,
138+
exclude,
139+
disable_telemetry,
140+
} => {
141+
devenv
142+
.generate(
143+
if description.is_empty() {
144+
None
145+
} else {
146+
Some(description.join(" "))
147+
},
148+
&host,
149+
exclude,
150+
disable_telemetry,
151+
)
152+
.await
153+
}
135154
Commands::Search { name } => devenv.search(&name).await,
136155
Commands::Gc {} => devenv.gc(),
137156
Commands::Info {} => devenv.info().await,

0 commit comments

Comments
 (0)