diff --git a/CHANGELOG.md b/CHANGELOG.md index a1d38fb..ae27515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Removed + +- *Settings → Config file* no longer offers to move `${NAME}` values into the configuration (a + one-time migration); `${NAME}` references keep working. + ## [v202611-00002] – 2026-11-09 ### Added diff --git a/README.md b/README.md index f3244d9..ca5aead 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,7 @@ docker compose -f docker-compose.release.yml logs codecraft # one-time setup c Open `http://localhost:8787`, enter the setup code and create the admin account. Then add a provider under *Settings → Providers & models*. `.env` only holds deployment settings; keys, tokens and everything else are set in the UI and stored in the configuration file -(`${NAME}` references to environment variables still work, and *Settings → Config file* moves -their values into the file). +(`${NAME}` references to environment variables still work). **Update:** `docker compose -f docker-compose.release.yml pull && docker compose -f docker-compose.release.yml up -d`. Releases are named `vYYYYMM-NNNNN` (a counter per month). Pin one with diff --git a/config.example.toml b/config.example.toml index 9d64b75..a9e9ef8 100644 --- a/config.example.toml +++ b/config.example.toml @@ -10,8 +10,7 @@ # # Keys and tokens are entered in the web UI (Settings) and stored here, readable # for admins and included in backups. Any string value may also reference an -# environment variable as ${NAME}; Settings → Config file moves such values -# into this file. +# environment variable as ${NAME}. # ============================================================================= [server] diff --git a/server/src/api.rs b/server/src/api.rs index c646f06..cec735b 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -66,7 +66,6 @@ pub fn router(app: AppState) -> Router { .route("/folders/{id}/access", put(set_folder_access)) .route("/config", get(get_config).patch(patch_config)) .route("/config/raw", get(get_config_raw).put(put_config_raw)) - .route("/config/inline-env", get(inline_env_preview).post(inline_env_apply)) .route("/providers/reload", post(reload_providers)) .route("/models", get(models)) .route("/agents", get(agents)) @@ -290,24 +289,6 @@ struct RawBody { text: String, } -/// Which ${VAR} references the config still has (and whether they are set). -async fn inline_env_preview(Extension(user): Extension, State(app): State) -> ApiResult { - require_admin(&user)?; - let (_, set, unset) = config::inline_env(&app.config_text()?)?; - Ok(Json(json!({"set": set, "unset": unset}))) -} - -/// Move the values of set environment variables into the config file. -async fn inline_env_apply(Extension(user): Extension, State(app): State) -> ApiResult { - require_admin(&user)?; - let (text, set, unset) = config::inline_env(&app.config_text()?)?; - if !set.is_empty() { - app.save_config(&text).await?; - app.audit(Some(&user), "config.inline_env", set.join(", "), ""); - } - Ok(Json(json!({"set": set, "unset": unset}))) -} - async fn put_config_raw(Extension(user): Extension, State(app): State, Json(body): Json) -> ApiResult { require_admin(&user)?; app.save_config(&body.text).await?; diff --git a/server/src/config.rs b/server/src/config.rs index 81af166..dcc9ad6 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -470,57 +470,6 @@ pub fn load_or_init(path: &Path) -> Result<(Config, String)> { Ok((cfg, text)) } -/// Replace `${NAME}` references in all string values with the environment's values, -/// so the settings live in the config only. Comments and formatting stay; references -/// to unset variables are kept. Returns (new text, replaced names, unset names). -pub fn inline_env(text: &str) -> Result<(String, Vec, Vec)> { - use std::collections::BTreeSet; - let mut doc: toml_edit::DocumentMut = text.parse().context("config is not valid TOML")?; - let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap(); - let (mut done, mut missing) = (BTreeSet::new(), BTreeSet::new()); - let mut fix = |s: &str| -> Option { - if !re.is_match(s) { - return None; - } - let out = re.replace_all(s, |c: ®ex::Captures| match std::env::var(&c[1]).ok().filter(|v| !v.is_empty()) { - Some(v) => { - done.insert(c[1].to_string()); - v - } - None => { - missing.insert(c[1].to_string()); - c[0].to_string() - } - }); - (out != s).then(|| out.into_owned()) - }; - fn walk_value(v: &mut toml_edit::Value, fix: &mut dyn FnMut(&str) -> Option) { - match v { - toml_edit::Value::String(f) => { - if let Some(new) = fix(f.value()) { - let decor = f.decor().clone(); - let mut nv = toml_edit::Formatted::new(new); - *nv.decor_mut() = decor; - *f = nv; - } - } - toml_edit::Value::Array(a) => a.iter_mut().for_each(|x| walk_value(x, fix)), - toml_edit::Value::InlineTable(t) => t.iter_mut().for_each(|(_, x)| walk_value(x, fix)), - _ => {} - } - } - fn walk_item(i: &mut toml_edit::Item, fix: &mut dyn FnMut(&str) -> Option) { - match i { - toml_edit::Item::Value(v) => walk_value(v, fix), - toml_edit::Item::Table(t) => t.iter_mut().for_each(|(_, x)| walk_item(x, fix)), - toml_edit::Item::ArrayOfTables(a) => a.iter_mut().for_each(|t| t.iter_mut().for_each(|(_, x)| walk_item(x, fix))), - toml_edit::Item::None => {} - } - } - walk_item(doc.as_item_mut(), &mut fix); - Ok((doc.to_string(), done.into_iter().collect(), missing.into_iter().collect())) -} - /// A structured edit coming from the UI. Applied with toml_edit so comments survive. #[derive(Debug, Deserialize)] pub struct ConfigPatch { @@ -667,26 +616,6 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value { v } -#[cfg(test)] -mod inline_env_tests { - #[test] - fn replaces_set_variables_and_keeps_comments() { - // SAFETY: test-only, the variable names are unique to this test. - unsafe { - std::env::set_var("CC_TEST_INLINE_KEY", "sk-123"); - std::env::remove_var("CC_TEST_INLINE_UNSET"); - } - let text = "# keys: use ${NAME}\n[providers.a]\napi_key = \"${CC_TEST_INLINE_KEY}\" # stored\nheaders = { Authorization = \"Bearer ${CC_TEST_INLINE_KEY}\" }\nother = \"${CC_TEST_INLINE_UNSET}\"\n"; - let (out, done, missing) = super::inline_env(text).unwrap(); - assert!(out.contains("api_key = \"sk-123\" # stored"), "{out}"); - assert!(out.contains("Bearer sk-123")); - assert!(out.contains("# keys: use ${NAME}"), "comments untouched"); - assert!(out.contains("${CC_TEST_INLINE_UNSET}")); - assert_eq!(done, vec!["CC_TEST_INLINE_KEY"]); - assert_eq!(missing, vec!["CC_TEST_INLINE_UNSET"]); - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/web/src/components/Settings.tsx b/web/src/components/Settings.tsx index 4020a9e..02d6d8b 100644 --- a/web/src/components/Settings.tsx +++ b/web/src/components/Settings.tsx @@ -1014,56 +1014,6 @@ function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void // --- raw ---------------------------------------------------------------------- -/** ${VAR} references left in the config: move their values from the environment into it. */ -function EnvRefs({ onDone }: { onDone: () => void }) { - const t = useT(); - const { data, reload } = useFetch<{ set: string[]; unset: string[] }>("/config/inline-env"); - const [busy, setBusy] = useState(false); - if (!data || (data.set.length === 0 && data.unset.length === 0)) return null; - const apply = async () => { - if (!(await confirmDialog({ title: t("settings.envTitle"), message: t("settings.envConfirm", { names: data.set.join(", ") }), confirmLabel: t("settings.envApply") }))) return; - setBusy(true); - try { - const r = await post<{ set: string[] }>("/config/inline-env"); - toast(t("settings.envDone", { n: r.set.length }), "success"); - reload(); - onDone(); - } catch (e: any) { - toast(e.message); - } finally { - setBusy(false); - } - }; - return ( -
-

- {t("settings.envTitle")} -

-
- {t("settings.envHint")} -
- {data.set.length > 0 && ( -
- {data.set.map((n) => ( - - {n} - - ))} - - -
- )} - {data.unset.length > 0 && ( -
- {t("settings.envUnset")} {data.unset.join(", ")} -
- )} -
- ); -} - function Raw({ reload }: { reload: () => void }) { const t = useT(); const [text, setText] = useState(); @@ -1074,7 +1024,6 @@ function Raw({ reload }: { reload: () => void }) { if (text === undefined) return
{t("common.loading")}
; return ( <> - get<{ text: string }>("/config/raw").then((r) => setText(r.text))} />
{t("settings.rawHint")}