feat: release Dever 0.1.4 with enforceable project guidance
This commit is contained in:
1 parent
201bdee372
commit
89629c2197
86 files changed
+2798
-338
No files matched your search
@@ -18,7 +18,11 @@ mod worker;
|
||||
#[path = "../../../test/dever-cli-tests/build_arguments.rs"]
|
||||
mod build_arguments;
|
||||
|
||||
const USAGE: &str = "Usage:\n dever check <project-root>\n dever api <project-root> [--output <new-file>]\n dever fmt <project-root> [--check]\n dever test <project-root>\n dever run <project-root> [-- <component>.<domain>.<cmd> '<json-object>']\n dever run <project-root> --tenant <positive-tenant-id> -- <component>.<domain>.<cmd> '<json-object>'\n dever build <project-root> --output <new-file> [--target linux-x86_64|linux-aarch64]\n dever clean <project-root>\n dever tenant migrate <project-root> <positive-tenant-id>\n dever tenant owner <project-root> <positive-tenant-id> <site> <positive-user-id>\n dever tenant component enable <project-root> <positive-tenant-id> <component>\n dever tenant component disable <project-root> <positive-tenant-id> <component>\n dever lib add|install|list|update|remove|doctor <project-root> [--target linux-x86_64|linux-aarch64] [spec ...]\n dever package add|list|update|remove|doctor <project-root> [spec ...]\n\nApplications use API, Job, and CMD declarations under module/ and config/setting.json. Native builds and tests are offline.";
|
||||
#[cfg(test)]
|
||||
#[path = "../../../test/dever-cli-tests/test_arguments.rs"]
|
||||
mod test_arguments;
|
||||
|
||||
const USAGE: &str = "Usage:\n dever check <project-root>\n dever api <project-root> [--output <new-file>]\n dever fmt <project-root> [--check]\n dever test <project-root> [--filter <component[.domain[.case]]>] [--timeout-ms <positive-ms>]\n dever run <project-root> [-- <component>.<domain>.<cmd> '<json-object>']\n dever run <project-root> --tenant <positive-tenant-id> -- <component>.<domain>.<cmd> '<json-object>'\n dever build <project-root> --output <new-file> [--target linux-x86_64|linux-aarch64]\n dever clean <project-root>\n dever tenant migrate <project-root> <positive-tenant-id>\n dever tenant owner <project-root> <positive-tenant-id> <site> <positive-user-id>\n dever tenant component enable <project-root> <positive-tenant-id> <component>\n dever tenant component disable <project-root> <positive-tenant-id> <component>\n dever lib add|install|list|update|remove|doctor <project-root> [--target linux-x86_64|linux-aarch64] [spec ...]\n dever package add|list|update|remove|doctor <project-root> [spec ...]\n\nApplications use API, Job, and CMD declarations under module/ and config/setting.json. Native builds and tests are offline.";
|
||||
|
||||
enum Action {
|
||||
Check,
|
||||
@@ -28,7 +32,7 @@ enum Action {
|
||||
Format {
|
||||
check: bool,
|
||||
},
|
||||
Test,
|
||||
Test(test_runner::Options),
|
||||
Run {
|
||||
tenant_id: Option<i64>,
|
||||
arguments: Vec<OsString>,
|
||||
@@ -175,7 +179,7 @@ fn execute() -> Result<ExitCode, String> {
|
||||
};
|
||||
let sources = SourceMap::load_with_packages(
|
||||
&source_root,
|
||||
matches!(action, Action::Test | Action::Format { .. })
|
||||
matches!(action, Action::Test(_) | Action::Format { .. })
|
||||
.then(|| root.join("test"))
|
||||
.as_deref(),
|
||||
&packages,
|
||||
@@ -220,28 +224,52 @@ fn execute() -> Result<ExitCode, String> {
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>()
|
||||
})?;
|
||||
// 基线先于生产检查;延后传播失败,让未被硬错误覆盖的告警仍能输出。
|
||||
let application_errors = if matches!(action, Action::Api { .. }) {
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
api::check_baseline(&program, &root).and_then(|()| {
|
||||
if matches!(action, Action::Check) || action.compiles_application() {
|
||||
program.application_errors()
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
})
|
||||
};
|
||||
for warning in program.warnings() {
|
||||
if SourceMap::is_standard(warning.primary.source) {
|
||||
let reported_as_error = matches!(warning.code, "W003" | "W004" | "W005")
|
||||
&& !warning.related.is_empty()
|
||||
&& application_errors
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|error| {
|
||||
error.code == "C016"
|
||||
&& error.primary == warning.primary
|
||||
&& error
|
||||
.related
|
||||
.iter()
|
||||
.map(|label| label.span)
|
||||
.eq(warning.related.iter().map(|label| label.span))
|
||||
});
|
||||
if SourceMap::is_standard(warning.primary.source) || reported_as_error {
|
||||
continue;
|
||||
}
|
||||
write!(io::stderr().lock(), "{}", warning.render(&sources))
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
let errors = application_errors?;
|
||||
if let Action::Api { output } = action {
|
||||
return api::write(&program, output.as_deref());
|
||||
}
|
||||
api::check_baseline(&program, &root)?;
|
||||
if matches!(action, Action::Check) || action.compiles_application() {
|
||||
let errors = program.application_errors()?;
|
||||
if !errors.is_empty() {
|
||||
return Err(errors.iter().map(|error| error.render(&sources)).collect());
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Err(errors.iter().map(|error| error.render(&sources)).collect());
|
||||
}
|
||||
if matches!(action, Action::Check) {
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
if matches!(action, Action::Test) {
|
||||
return test_runner::execute(&program, &sources);
|
||||
if let Action::Test(options) = &action {
|
||||
return test_runner::execute(&program, &sources, options);
|
||||
}
|
||||
let target = match &action {
|
||||
Action::Build { target, .. } => *target,
|
||||
@@ -258,7 +286,7 @@ fn execute() -> Result<ExitCode, String> {
|
||||
Action::Package { .. } => unreachable!("package returned before source checking"),
|
||||
Action::Api { .. } => unreachable!("api returned before baseline checking"),
|
||||
Action::Format { .. } => unreachable!("fmt returned before checking"),
|
||||
Action::Test => unreachable!("test returned before application compilation"),
|
||||
Action::Test(_) => unreachable!("test returned before application compilation"),
|
||||
Action::Run { .. }
|
||||
| Action::Build { .. }
|
||||
| Action::TenantMigrate { .. }
|
||||
@@ -346,7 +374,7 @@ fn execute() -> Result<ExitCode, String> {
|
||||
Action::Check
|
||||
| Action::Api { .. }
|
||||
| Action::Format { .. }
|
||||
| Action::Test
|
||||
| Action::Test(_)
|
||||
| Action::Lib { .. }
|
||||
| Action::Clean => {
|
||||
unreachable!("check and fmt returned before native compilation")
|
||||
@@ -506,7 +534,43 @@ fn arguments_for(arguments: &[OsString]) -> Result<(PathBuf, Action), String> {
|
||||
},
|
||||
))
|
||||
}
|
||||
[command, root] if command == "test" => Ok((root.into(), Action::Test)),
|
||||
[command, root, rest @ ..] if command == "test" => {
|
||||
let mut filter = None;
|
||||
let mut timeout = None;
|
||||
let mut options = rest.iter();
|
||||
while let Some(option) = options.next() {
|
||||
match option.to_str() {
|
||||
Some("--filter") if filter.is_none() => {
|
||||
let value = option_value(&mut options, "--filter")?
|
||||
.to_str()
|
||||
.ok_or("test filter must be UTF-8")?;
|
||||
test_runner::validate_filter(value)?;
|
||||
filter = Some(value.to_owned());
|
||||
}
|
||||
Some("--timeout-ms") if timeout.is_none() => {
|
||||
let value = option_value(&mut options, "--timeout-ms")?
|
||||
.to_str()
|
||||
.ok_or("test timeout must be a positive integer in milliseconds")?;
|
||||
let millis = value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit())
|
||||
.then(|| value.parse::<u64>().ok())
|
||||
.flatten()
|
||||
.filter(|millis| *millis > 0)
|
||||
.ok_or("test timeout must be a positive integer in milliseconds")?;
|
||||
timeout = Some(std::time::Duration::from_millis(millis));
|
||||
}
|
||||
_ => return Err("test accepts --filter and --timeout-ms exactly once; unknown or duplicate option".into()),
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
root.into(),
|
||||
Action::Test(test_runner::Options {
|
||||
filter,
|
||||
timeout: timeout.unwrap_or(test_runner::DEFAULT_TIMEOUT),
|
||||
}),
|
||||
))
|
||||
}
|
||||
[command, root] if command == "clean" => Ok((root.into(), Action::Clean)),
|
||||
[command, root, rest @ ..] if command == "build" => {
|
||||
let mut output = None;
|
||||
|
||||
@@ -3,21 +3,85 @@ use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use dever_core::hir::{Program, TestCase};
|
||||
use dever_core::source::SourceMap;
|
||||
|
||||
pub(super) fn execute(program: &Program, sources: &SourceMap) -> Result<ExitCode, String> {
|
||||
mod process;
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
#[path = "../../../test/dever-cli-tests/test_process.rs"]
|
||||
mod tests;
|
||||
|
||||
pub(super) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
pub(super) struct Options {
|
||||
pub filter: Option<String>,
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
pub(super) fn validate_filter(filter: &str) -> Result<(), String> {
|
||||
let segments: Vec<_> = filter.split('.').collect();
|
||||
if segments.len() <= 3
|
||||
&& segments.iter().all(|segment| {
|
||||
segment.starts_with(|character: char| character.is_ascii_lowercase())
|
||||
&& segment.split('_').all(|word| {
|
||||
!word.is_empty()
|
||||
&& word
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
|
||||
})
|
||||
})
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err("test filter must be component[.domain[.case]] using lower_snake_case names".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_cases<'a>(
|
||||
tests: &'a [TestCase],
|
||||
filter: Option<&str>,
|
||||
) -> Result<Vec<(usize, &'a TestCase)>, String> {
|
||||
let selected: Vec<_> = tests
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, test)| {
|
||||
filter.is_none_or(|filter| {
|
||||
let identity = test.name().replace('/', ".");
|
||||
identity == filter
|
||||
|| identity
|
||||
.strip_prefix(filter)
|
||||
.is_some_and(|rest| rest.starts_with('.'))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if selected.is_empty()
|
||||
&& let Some(filter) = filter
|
||||
{
|
||||
return Err(format!("no tests match filter '{filter}'"));
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
pub(super) fn execute(
|
||||
program: &Program,
|
||||
sources: &SourceMap,
|
||||
options: &Options,
|
||||
) -> Result<ExitCode, String> {
|
||||
let selected = selected_cases(program.tests(), options.filter.as_deref())?;
|
||||
let not_run = program.tests().len() - selected.len();
|
||||
let mut stdout = io::stdout().lock();
|
||||
writeln!(stdout, "running {} tests", program.tests().len()).map_err(io_error)?;
|
||||
writeln!(stdout, "running {} tests", selected.len()).map_err(io_error)?;
|
||||
|
||||
let mut passed = 0;
|
||||
let mut failures = Vec::new();
|
||||
if !program.tests().is_empty() {
|
||||
if !selected.is_empty() {
|
||||
match crate::compile::test_suite(program, sources) {
|
||||
Ok(native) => {
|
||||
for (index, test) in program.tests().iter().enumerate() {
|
||||
match run_case(program, test, &native, index) {
|
||||
for (index, test) in &selected {
|
||||
match run_case(program, test, &native, *index, options.timeout) {
|
||||
Ok(()) => {
|
||||
passed += 1;
|
||||
writeln!(stdout, "test {} ... ok", test.name()).map_err(io_error)?;
|
||||
@@ -31,7 +95,7 @@ pub(super) fn execute(program: &Program, sources: &SourceMap) -> Result<ExitCode
|
||||
}
|
||||
}
|
||||
Err(message) => {
|
||||
for test in program.tests() {
|
||||
for (_, test) in &selected {
|
||||
writeln!(stdout, "test {} ... FAILED", test.name()).map_err(io_error)?;
|
||||
failures.push((test.name(), CaseFailure::message(message.clone())));
|
||||
}
|
||||
@@ -50,7 +114,7 @@ pub(super) fn execute(program: &Program, sources: &SourceMap) -> Result<ExitCode
|
||||
let status = if failed == 0 { "ok" } else { "FAILED" };
|
||||
writeln!(
|
||||
stdout,
|
||||
"\ntest result: {status}. {passed} passed; {failed} failed; 0 not run"
|
||||
"\ntest result: {status}. {passed} passed; {failed} failed; {not_run} not run"
|
||||
)
|
||||
.map_err(io_error)?;
|
||||
Ok(if failed == 0 {
|
||||
@@ -65,6 +129,7 @@ fn run_case(
|
||||
test: &TestCase,
|
||||
native: &dever_core::native::NativeProgram,
|
||||
index: usize,
|
||||
timeout: Duration,
|
||||
) -> Result<(), CaseFailure> {
|
||||
let project = TemporaryProject::new().map_err(CaseFailure::message)?;
|
||||
if test.uses_database() {
|
||||
@@ -90,20 +155,14 @@ fn run_case(
|
||||
executable.display()
|
||||
))
|
||||
})?;
|
||||
let output = Command::new(&executable)
|
||||
.arg(index.to_string())
|
||||
.current_dir(project.path())
|
||||
.output()
|
||||
.map_err(|error| CaseFailure::message(format!("cannot run test process: {error}")))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CaseFailure {
|
||||
message: format!("test process exited with {}", output.status),
|
||||
stdout: output.stdout,
|
||||
stderr: output.stderr,
|
||||
})
|
||||
}
|
||||
process::run(
|
||||
Command::new(&executable)
|
||||
.arg(index.to_string())
|
||||
.env_clear()
|
||||
.current_dir(project.path()),
|
||||
project.path(),
|
||||
timeout,
|
||||
)
|
||||
}
|
||||
|
||||
fn write_settings(root: &Path, test: &TestCase) -> Result<(), String> {
|
||||
@@ -145,6 +204,7 @@ fn io_error(error: io::Error) -> String {
|
||||
error.to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CaseFailure {
|
||||
message: String,
|
||||
stdout: Vec<u8>,
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::CaseFailure;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) use linux::run;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(super) fn run(
|
||||
_command: &mut Command,
|
||||
_directory: &Path,
|
||||
_timeout: Duration,
|
||||
) -> Result<(), CaseFailure> {
|
||||
Err(CaseFailure::message(
|
||||
"application test processes require a supported Linux host".into(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use super::*;
|
||||
use std::fs::{self, File};
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::{Child, ExitStatus, Stdio};
|
||||
use std::time::Instant;
|
||||
|
||||
use rustix::io::Errno;
|
||||
use rustix::process::{self, Pid, Signal, WaitId, WaitIdOptions, WaitOptions};
|
||||
|
||||
pub(in crate::test_runner) fn run(
|
||||
command: &mut Command,
|
||||
directory: &Path,
|
||||
timeout: Duration,
|
||||
) -> Result<(), CaseFailure> {
|
||||
let stdout = directory.join("stdout");
|
||||
let stderr = directory.join("stderr");
|
||||
// 文件捕获不依赖 EOF;后代持有输出描述符也不会阻塞测试结束。
|
||||
let capture = |path: &Path| {
|
||||
File::create_new(path).map(Stdio::from).map_err(|error| {
|
||||
CaseFailure::message(format!(
|
||||
"cannot capture test output '{}': {error}",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
};
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(capture(&stdout)?)
|
||||
.stderr(capture(&stderr)?)
|
||||
.process_group(0);
|
||||
let subreaper = Subreaper::acquire().map_err(CaseFailure::message)?;
|
||||
let started = Instant::now();
|
||||
let child = command
|
||||
.spawn()
|
||||
.map_err(|error| CaseFailure::message(format!("cannot run test process: {error}")))?;
|
||||
let group = Pid::from_raw(child.id() as i32).expect("spawned child has a positive PID");
|
||||
let mut process = CaseProcess {
|
||||
child,
|
||||
group,
|
||||
cleaned: false,
|
||||
};
|
||||
let outcome = process.wait_until_exit(started, timeout);
|
||||
let (status, mut cleanup) = process.cleanup();
|
||||
if let Err(error) = subreaper.restore() {
|
||||
cleanup.push(error);
|
||||
}
|
||||
let mut errors: Vec<String> = outcome.err().into_iter().collect();
|
||||
if errors.is_empty()
|
||||
&& let Some(status) = status
|
||||
&& !status.success()
|
||||
{
|
||||
errors.push(format!("test process exited with {status}"));
|
||||
}
|
||||
errors.extend(cleanup);
|
||||
let mut read = |path: &Path| match fs::read(path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
errors.push(format!(
|
||||
"cannot read test output '{}': {error}",
|
||||
path.display()
|
||||
));
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let stdout = read(&stdout);
|
||||
let stderr = read(&stderr);
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CaseFailure {
|
||||
message: errors.join("\n"),
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct CaseProcess {
|
||||
child: Child,
|
||||
group: Pid,
|
||||
cleaned: bool,
|
||||
}
|
||||
|
||||
impl CaseProcess {
|
||||
fn wait_until_exit(&self, started: Instant, timeout: Duration) -> Result<(), String> {
|
||||
loop {
|
||||
// NOWAIT 保留 leader,组清理前不会释放它的 PID 给其它进程。
|
||||
match process::waitid(
|
||||
WaitId::Pid(self.group),
|
||||
WaitIdOptions::EXITED | WaitIdOptions::NOHANG | WaitIdOptions::NOWAIT,
|
||||
) {
|
||||
Ok(Some(_)) => return Ok(()),
|
||||
Ok(None) | Err(Errno::INTR) => {}
|
||||
Err(error) => return Err(format!("cannot observe test process: {error}")),
|
||||
}
|
||||
let elapsed = started.elapsed();
|
||||
if elapsed >= timeout {
|
||||
return Err(format!(
|
||||
"test process timed out after {} ms",
|
||||
timeout.as_millis()
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5).min(timeout - elapsed));
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup(&mut self) -> (Option<ExitStatus>, Vec<String>) {
|
||||
let mut errors = Vec::new();
|
||||
let killed = match process::kill_process_group(self.group, Signal::KILL) {
|
||||
Ok(()) | Err(Errno::SRCH) => true,
|
||||
Err(error) => {
|
||||
errors.push(format!("cannot terminate test process group: {error}"));
|
||||
if let Err(error) = self.child.kill() {
|
||||
errors.push(format!("cannot terminate test process: {error}"));
|
||||
}
|
||||
false
|
||||
}
|
||||
};
|
||||
let status = match self.child.wait() {
|
||||
Ok(status) => Some(status),
|
||||
Err(error) => {
|
||||
errors.push(format!("cannot reap test process: {error}"));
|
||||
None
|
||||
}
|
||||
};
|
||||
if killed {
|
||||
loop {
|
||||
// 只回收本例的进程组,不能消费编译器或其它工具的子进程。
|
||||
match process::waitpgid(self.group, WaitOptions::empty()) {
|
||||
Ok(Some(_)) | Err(Errno::INTR) => {}
|
||||
Err(Errno::CHILD) => break,
|
||||
Ok(None) => unreachable!("blocking waitpgid returns a child or ECHILD"),
|
||||
Err(error) => {
|
||||
errors.push(format!("cannot reap test descendant: {error}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.cleaned = true;
|
||||
(status, errors)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CaseProcess {
|
||||
fn drop(&mut self) {
|
||||
if !self.cleaned {
|
||||
let _ = self.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 测试串行执行;接管孤儿后代才能在 CLI 返回前完成 wait,而非交给 PID 1。
|
||||
struct Subreaper(Option<Pid>);
|
||||
|
||||
impl Subreaper {
|
||||
fn acquire() -> Result<Self, String> {
|
||||
let previous = process::child_subreaper()
|
||||
.map_err(|error| format!("cannot inspect test child reaper: {error}"))?;
|
||||
process::set_child_subreaper(Some(process::getpid()))
|
||||
.map_err(|error| format!("cannot acquire test child reaper: {error}"))?;
|
||||
Ok(Self(previous))
|
||||
}
|
||||
|
||||
fn restore(self) -> Result<(), String> {
|
||||
process::set_child_subreaper(self.0)
|
||||
.map_err(|error| format!("cannot restore test child reaper: {error}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Subreaper {
|
||||
fn drop(&mut self) {
|
||||
let _ = process::set_child_subreaper(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::process::Command;
|
||||
|
||||
use super::release::{Layout, MachineManager, Version, platform_contract};
|
||||
|
||||
const USAGE: &str = "Usage:\n dever install <version|latest>\n dever update\n dever target add linux-aarch64\n dever use <version>\n dever uninstall <version>\n dever version [project-root]\n dever cache status\n dever cache clean\n dever lib add|list|update|install|remove|doctor <project-root> [--target linux-x86_64|linux-aarch64] [spec ...]\n dever package add|list|update|remove|doctor <project-root> [spec ...]\n dever check <project-root>\n dever api <project-root> [--output <new-file>]\n dever fmt <project-root> [--check]\n dever test <project-root>\n dever run <project-root> [-- <component>.<domain>.<cmd> '<json-object>']\n dever build <project-root> --output <new-file> [--target linux-x86_64|linux-aarch64]\n dever clean <project-root>\n dever tenant ...";
|
||||
const USAGE: &str = "Usage:\n dever install <version|latest>\n dever update\n dever target add linux-aarch64\n dever use <version>\n dever uninstall <version>\n dever version [project-root]\n dever cache status\n dever cache clean\n dever lib add|list|update|install|remove|doctor <project-root> [--target linux-x86_64|linux-aarch64] [spec ...]\n dever package add|list|update|remove|doctor <project-root> [spec ...]\n dever check <project-root>\n dever api <project-root> [--output <new-file>]\n dever fmt <project-root> [--check]\n dever test <project-root> [--filter <component[.domain[.case]]>] [--timeout-ms <positive-ms>]\n dever run <project-root> [-- <component>.<domain>.<cmd> '<json-object>']\n dever build <project-root> --output <new-file> [--target linux-x86_64|linux-aarch64]\n dever clean <project-root>\n dever tenant ...";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CommandResult {
|
||||
@@ -106,13 +106,18 @@ pub fn execute_in(layout: &Layout, arguments: &[OsString]) -> Result<CommandResu
|
||||
manager.resolve_skill(&version)?.display().to_string(),
|
||||
))
|
||||
}
|
||||
[skill, install, directory] if skill == "skill" && install == "install" => {
|
||||
super::skill::install(&manager, Path::new(directory))?;
|
||||
[skill, install] if skill == "skill" && install == "install" => {
|
||||
let root = std::env::current_dir()
|
||||
.map_err(|error| format!("cannot locate current project: {error}"))?;
|
||||
super::skill::install(&manager, &root)?;
|
||||
Ok(CommandResult::Message(format!(
|
||||
"installed dever-language skill entry in '{}'",
|
||||
Path::new(directory).display()
|
||||
"updated Dever development rules in '{}'",
|
||||
root.join("AGENTS.md").display()
|
||||
)))
|
||||
}
|
||||
[skill, install, ..] if skill == "skill" && install == "install" => {
|
||||
Err("run 'dever skill install' in the project root; it accepts no arguments".into())
|
||||
}
|
||||
[command, ..] if command == "new" => {
|
||||
let version = manager
|
||||
.active_version()?
|
||||
@@ -120,7 +125,7 @@ pub fn execute_in(layout: &Layout, arguments: &[OsString]) -> Result<CommandResu
|
||||
dispatch_core(&manager, &version, arguments)
|
||||
}
|
||||
[help] if help == "--help" => Ok(CommandResult::Message(format!(
|
||||
"{USAGE}\n dever new <new-directory> [--markdown]\n dever skill path [project-root]\n dever skill install <new-directory>"
|
||||
"{USAGE}\n dever new <new-directory> [--markdown]\n dever skill path [project-root]\n dever skill install"
|
||||
))),
|
||||
[] => Err(USAGE.into()),
|
||||
_ => dispatch_project_command(&manager, arguments),
|
||||
@@ -175,7 +180,7 @@ fn dispatch_core(
|
||||
Ok(CommandResult::Exited(status.code().unwrap_or(1)))
|
||||
}
|
||||
|
||||
fn select_version(manager: &MachineManager, project_root: &Path) -> Result<Version, String> {
|
||||
pub(super) fn select_version(manager: &MachineManager, project_root: &Path) -> Result<Version, String> {
|
||||
let version = match project_version(project_root)? {
|
||||
Some(version) => version,
|
||||
None => manager
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
//! A stable AI entry resolves the same active-version journal as the launcher.
|
||||
//! Project rules come from the same signed release as the project's compiler.
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::ops::Range;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use super::release::MachineManager;
|
||||
|
||||
const START: &str = "<!-- dever-language:start -->";
|
||||
const END: &str = "<!-- dever-language:end -->";
|
||||
static NEXT_STAGING: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub(super) fn path(manager: &MachineManager) -> Result<PathBuf, String> {
|
||||
@@ -17,75 +20,141 @@ pub(super) fn path(manager: &MachineManager) -> Result<PathBuf, String> {
|
||||
manager.resolve_skill(&version)
|
||||
}
|
||||
|
||||
pub(super) fn install(manager: &MachineManager, destination: &Path) -> Result<(), String> {
|
||||
path(manager)?;
|
||||
let launcher = manager.layout().bin().join("dever");
|
||||
let launcher = launcher
|
||||
.to_str()
|
||||
.ok_or("launcher path must be UTF-8 for an AI skill")?;
|
||||
if launcher.chars().any(char::is_control) || launcher.contains('`') {
|
||||
return Err("launcher path cannot contain control characters or backticks".into());
|
||||
pub(super) fn install(manager: &MachineManager, root: &Path) -> Result<(), String> {
|
||||
let module = root.join("module");
|
||||
let metadata = fs::symlink_metadata(&module)
|
||||
.map_err(|error| format!("run 'dever skill install' in a project root with module/: {error}"))?;
|
||||
if !metadata.is_dir() {
|
||||
return Err("run 'dever skill install' in a project root with a real module/ directory".into());
|
||||
}
|
||||
let quoted = format!("'{}'", launcher.replace('\'', "'\\''"));
|
||||
let content = format!(
|
||||
"---\nname: dever-language\ndescription: Develop, review and debug Dever language applications (.dever and .dever.md), manage Dever Packages and Libs, and install or update Dever. Not the Go Dever framework.\n---\n\n# Dever language\n\nBefore every Dever task, run this read-only command:\n\n```sh\n{quoted} skill path\n```\n\nRead `SKILL.md` in the returned directory completely, then follow its instructions. Resolve its reference and asset paths against that directory. This entry follows the machine's active Dever version, so `dever update` and `dever use` update the compiler and development guidance together. If resolution fails, report the error; do not use stale guidance or infer language syntax. This entry grants no permission to install, update or change a project.\n"
|
||||
);
|
||||
let name = destination
|
||||
.file_name()
|
||||
.ok_or("skill destination requires a new directory name")?;
|
||||
let parent = destination
|
||||
.parent()
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."));
|
||||
let parent = fs::canonicalize(parent)
|
||||
.map_err(|error| format!("cannot resolve skill destination parent: {error}"))?;
|
||||
let destination = parent.join(name);
|
||||
let staging = stage(&parent)?;
|
||||
let entry = staging.join("SKILL.md");
|
||||
let result = (|| {
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&entry)
|
||||
.map_err(|error| format!("cannot create skill entry: {error}"))?;
|
||||
file.write_all(content.as_bytes())
|
||||
.and_then(|()| file.sync_all())
|
||||
.map_err(|error| format!("cannot persist skill entry: {error}"))?;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use rustix::fs::{CWD, RenameFlags, renameat_with};
|
||||
renameat_with(CWD, &staging, CWD, &destination, RenameFlags::NOREPLACE)
|
||||
.map_err(|error| format!("cannot publish new skill directory: {error}"))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
Err("skill installation is not available for this platform".into())
|
||||
}
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&entry);
|
||||
let _ = fs::remove_dir(&staging);
|
||||
let version = super::launcher::select_version(manager, root)?;
|
||||
let resources = super::SignedResources::load(manager.layout(), &version)?;
|
||||
resources.read("skills/dever-language/SKILL.md")?;
|
||||
let template = String::from_utf8(resources.read("skills/dever-language/assets/template/AGENTS.md")?)
|
||||
.map_err(|error| format!("signed AGENTS.md template must be UTF-8: {error}"))?;
|
||||
let block = managed_block(&template)?.ok_or("signed AGENTS.md template has no Dever rules block")?;
|
||||
if !template[..block.start].trim().is_empty() || !template[block.end..].trim().is_empty() {
|
||||
return Err("signed AGENTS.md template must contain only its Dever rules block".into());
|
||||
}
|
||||
result
|
||||
|
||||
let destination = root.join("AGENTS.md");
|
||||
let original = read_project_rules(&destination)?;
|
||||
let current = original
|
||||
.as_ref()
|
||||
.map(|(text, _)| text.as_str())
|
||||
.unwrap_or("");
|
||||
let content = match managed_block(current)? {
|
||||
Some(range) => format!(
|
||||
"{}{}{}",
|
||||
¤t[..range.start],
|
||||
&template[block],
|
||||
¤t[range.end..]
|
||||
),
|
||||
None if current.is_empty() => template,
|
||||
None => {
|
||||
let separator = if current.ends_with('\n') { "\n" } else { "\n\n" };
|
||||
format!("{current}{separator}{template}")
|
||||
}
|
||||
};
|
||||
if content == current {
|
||||
return Ok(());
|
||||
}
|
||||
let staging = Staging::create(root)?;
|
||||
let entry = staging.0.join("AGENTS.md");
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&entry)
|
||||
.map_err(|error| format!("cannot stage project rules: {error}"))?;
|
||||
file.write_all(content.as_bytes())
|
||||
.map_err(|error| format!("cannot write project rules: {error}"))?;
|
||||
if let Some((_, permissions)) = &original {
|
||||
file.set_permissions(permissions.clone())
|
||||
.map_err(|error| format!("cannot preserve AGENTS.md permissions: {error}"))?;
|
||||
}
|
||||
file.sync_all()
|
||||
.map_err(|error| format!("cannot persist project rules: {error}"))?;
|
||||
// As with source formatting, an intervening edit invalidates the prepared replacement.
|
||||
if read_project_rules(&destination)? != original {
|
||||
return Err("AGENTS.md changed during skill installation; run the command again".into());
|
||||
}
|
||||
publish(&entry, &destination, original.is_some())
|
||||
}
|
||||
|
||||
fn stage(parent: &Path) -> Result<PathBuf, String> {
|
||||
loop {
|
||||
let path = parent.join(format!(
|
||||
".dever-skill-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_STAGING.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let mut builder = fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
builder.mode(0o700);
|
||||
}
|
||||
match builder.create(&path) {
|
||||
Ok(()) => return Ok(path),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(format!("cannot stage skill entry: {error}")),
|
||||
fn read_project_rules(path: &Path) -> Result<Option<(String, fs::Permissions)>, String> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(format!("cannot inspect AGENTS.md: {error}")),
|
||||
};
|
||||
if !metadata.is_file() {
|
||||
return Err("AGENTS.md must be a regular file, not a symlink or directory".into());
|
||||
}
|
||||
if metadata.permissions().readonly() {
|
||||
return Err("AGENTS.md is read-only".into());
|
||||
}
|
||||
let text = fs::read_to_string(path)
|
||||
.map_err(|error| format!("cannot read AGENTS.md as UTF-8: {error}"))?;
|
||||
Ok(Some((text, metadata.permissions())))
|
||||
}
|
||||
|
||||
fn managed_block(text: &str) -> Result<Option<Range<usize>>, String> {
|
||||
let starts: Vec<_> = text.match_indices(START).map(|(offset, _)| offset).collect();
|
||||
let ends: Vec<_> = text.match_indices(END).map(|(offset, _)| offset).collect();
|
||||
match (starts.as_slice(), ends.as_slice()) {
|
||||
([], []) => Ok(None),
|
||||
([start], [end]) if start < end => Ok(Some(*start..end + END.len())),
|
||||
_ => Err("AGENTS.md must have exactly one complete Dever rules block or no block".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(entry: &Path, destination: &Path, replace: bool) -> Result<(), String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use rustix::fs::{CWD, RenameFlags, renameat_with};
|
||||
let flags = if replace {
|
||||
RenameFlags::empty()
|
||||
} else {
|
||||
RenameFlags::NOREPLACE
|
||||
};
|
||||
renameat_with(CWD, entry, CWD, destination, flags)
|
||||
.map_err(|error| format!("cannot publish AGENTS.md: {error}"))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = (entry, destination, replace);
|
||||
Err("skill installation is not available for this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
struct Staging(PathBuf);
|
||||
|
||||
impl Staging {
|
||||
fn create(parent: &Path) -> Result<Self, String> {
|
||||
loop {
|
||||
let path = parent.join(format!(
|
||||
".dever-skill-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_STAGING.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let mut builder = fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
builder.mode(0o700);
|
||||
}
|
||||
match builder.create(&path) {
|
||||
Ok(()) => return Ok(Self(path)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(format!("cannot stage project rules: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Staging {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(self.0.join("AGENTS.md"));
|
||||
let _ = fs::remove_dir(&self.0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user