Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
242b80605c | ||
|
|
c9f553dc60 |
No files matched your search
@@ -3,6 +3,17 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
until 1.0, minor versions may contain breaking changes.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- File manager: *Open chat here* in a folder's menu starts a chat in that folder.
|
||||||
|
|
||||||
|
### 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
|
## [v202611-00002] – 2026-11-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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
|
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,
|
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
|
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
|
(`${NAME}` references to environment variables still work).
|
||||||
their values into the file).
|
|
||||||
|
|
||||||
**Update:** `docker compose -f docker-compose.release.yml pull && docker compose -f docker-compose.release.yml up -d`.
|
**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
|
Releases are named `vYYYYMM-NNNNN` (a counter per month). Pin one with
|
||||||
|
|||||||
+1
-2
@@ -10,8 +10,7 @@
|
|||||||
#
|
#
|
||||||
# Keys and tokens are entered in the web UI (Settings) and stored here, readable
|
# 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
|
# for admins and included in backups. Any string value may also reference an
|
||||||
# environment variable as ${NAME}; Settings → Config file moves such values
|
# environment variable as ${NAME}.
|
||||||
# into this file.
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/folders/{id}/access", put(set_folder_access))
|
.route("/folders/{id}/access", put(set_folder_access))
|
||||||
.route("/config", get(get_config).patch(patch_config))
|
.route("/config", get(get_config).patch(patch_config))
|
||||||
.route("/config/raw", get(get_config_raw).put(put_config_raw))
|
.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("/providers/reload", post(reload_providers))
|
||||||
.route("/models", get(models))
|
.route("/models", get(models))
|
||||||
.route("/agents", get(agents))
|
.route("/agents", get(agents))
|
||||||
@@ -290,24 +289,6 @@ struct RawBody {
|
|||||||
text: String,
|
text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which ${VAR} references the config still has (and whether they are set).
|
|
||||||
async fn inline_env_preview(Extension(user): Extension<User>, State(app): State<AppState>) -> 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<User>, State(app): State<AppState>) -> 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<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
|
async fn put_config_raw(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
|
||||||
require_admin(&user)?;
|
require_admin(&user)?;
|
||||||
app.save_config(&body.text).await?;
|
app.save_config(&body.text).await?;
|
||||||
|
|||||||
@@ -470,57 +470,6 @@ pub fn load_or_init(path: &Path) -> Result<(Config, String)> {
|
|||||||
Ok((cfg, text))
|
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<String>, Vec<String>)> {
|
|
||||||
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<String> {
|
|
||||||
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<String>) {
|
|
||||||
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<String>) {
|
|
||||||
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.
|
/// A structured edit coming from the UI. Applied with toml_edit so comments survive.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ConfigPatch {
|
pub struct ConfigPatch {
|
||||||
@@ -667,26 +616,6 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value {
|
|||||||
v
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Markdown } from "./Markdown";
|
|||||||
import { GitPanel, type GitStatus } from "./GitPanel";
|
import { GitPanel, type GitStatus } from "./GitPanel";
|
||||||
import { FilePreview, previewKind, type PreviewKind } from "./FilePreview";
|
import { FilePreview, previewKind, type PreviewKind } from "./FilePreview";
|
||||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
|
||||||
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
|
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
|
||||||
|
|
||||||
@@ -186,6 +187,16 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** New chat with the folder as working directory (default model and mode). */
|
||||||
|
const chatHere = async (e: Entry) => {
|
||||||
|
try {
|
||||||
|
const s = await post<{ id: string }>("/sessions", { cwd: e.path });
|
||||||
|
navigate(`/s/${s.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const rename = async (e: Entry) => {
|
const rename = async (e: Entry) => {
|
||||||
const name = await promptDialog({ message: t("files.renameTo"), defaultValue: e.name });
|
const name = await promptDialog({ message: t("files.renameTo"), defaultValue: e.name });
|
||||||
if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` }));
|
if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` }));
|
||||||
@@ -324,6 +335,9 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
<details className="entry-menu">
|
<details className="entry-menu">
|
||||||
<summary aria-label={t("files.actions")}><Icon name="more" /></summary>
|
<summary aria-label={t("files.actions")}><Icon name="more" /></summary>
|
||||||
<div className="menu">
|
<div className="menu">
|
||||||
|
{e.is_dir && (
|
||||||
|
<button onClick={() => void chatHere(e)}>{t("files.chatHere")}</button>
|
||||||
|
)}
|
||||||
<button onClick={() => rename(e)}>{t("files.rename")}</button>
|
<button onClick={() => rename(e)}>{t("files.rename")}</button>
|
||||||
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
|
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
|
||||||
{!e.is_dir && (
|
{!e.is_dir && (
|
||||||
|
|||||||
@@ -1014,56 +1014,6 @@ function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void
|
|||||||
|
|
||||||
// --- raw ----------------------------------------------------------------------
|
// --- 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 (
|
|
||||||
<div className="panel" style={{ marginBottom: 12 }}>
|
|
||||||
<h3>
|
|
||||||
<Icon name="file-text" /> {t("settings.envTitle")}
|
|
||||||
</h3>
|
|
||||||
<div className="hint" style={{ marginBottom: 8 }}>
|
|
||||||
{t("settings.envHint")}
|
|
||||||
</div>
|
|
||||||
{data.set.length > 0 && (
|
|
||||||
<div className="row" style={{ gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
|
||||||
{data.set.map((n) => (
|
|
||||||
<span key={n} className="chip mono">
|
|
||||||
{n}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<span className="spacer" />
|
|
||||||
<button className="primary small" onClick={apply} disabled={busy}>
|
|
||||||
{t("settings.envApply")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{data.unset.length > 0 && (
|
|
||||||
<div className="hint" style={{ marginTop: 8 }}>
|
|
||||||
{t("settings.envUnset")} <span className="mono">{data.unset.join(", ")}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Raw({ reload }: { reload: () => void }) {
|
function Raw({ reload }: { reload: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [text, setText] = useState<string>();
|
const [text, setText] = useState<string>();
|
||||||
@@ -1074,7 +1024,6 @@ function Raw({ reload }: { reload: () => void }) {
|
|||||||
if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
|
if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EnvRefs onDone={() => get<{ text: string }>("/config/raw").then((r) => setText(r.text))} />
|
|
||||||
<div className="notice">{t("settings.rawHint")}</div>
|
<div className="notice">{t("settings.rawHint")}</div>
|
||||||
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
|
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
|
||||||
{view}
|
{view}
|
||||||
|
|||||||
+1
-6
@@ -65,12 +65,6 @@ export const de: Messages = {
|
|||||||
"settings.budgetWarning": "Warnen ab",
|
"settings.budgetWarning": "Warnen ab",
|
||||||
"settings.adminKey": "Admin-Key (optional)",
|
"settings.adminKey": "Admin-Key (optional)",
|
||||||
"settings.adminKeyHint": "Anthropic Admin-API-Key (sk-ant-admin01-…), um die echten, organisationsweiten Monatskosten anzuzeigen.",
|
"settings.adminKeyHint": "Anthropic Admin-API-Key (sk-ant-admin01-…), um die echten, organisationsweiten Monatskosten anzuzeigen.",
|
||||||
"settings.envTitle": "Einstellungen aus Umgebungsvariablen",
|
|
||||||
"settings.envHint": "Diese ${NAME}-Verweise holen ihre Werte aus der Server-Umgebung (.env). Übernimm die Werte in die Konfiguration, damit alles hier eingestellt ist – danach können die Variablen aus der .env raus.",
|
|
||||||
"settings.envApply": "In die Konfiguration übernehmen",
|
|
||||||
"settings.envConfirm": "Die aktuellen Werte von {names} in die Konfigurationsdatei schreiben? Die Keys liegen dann in der Config (und in ihren Backups) statt in der Umgebung.",
|
|
||||||
"settings.envDone": "{n} Werte in die Konfiguration übernommen. Du kannst sie jetzt aus der .env entfernen.",
|
|
||||||
"settings.envUnset": "Verwendet, aber nicht gesetzt (bleiben unverändert):",
|
|
||||||
"settings.fetchPrices": "Preise abrufen",
|
"settings.fetchPrices": "Preise abrufen",
|
||||||
"settings.pricesFetched": "Preise für {n} Modelle aktualisiert – zum Übernehmen speichern.",
|
"settings.pricesFetched": "Preise für {n} Modelle aktualisiert – zum Übernehmen speichern.",
|
||||||
"stats.perMillion": "pro 1 Mio. Tok.",
|
"stats.perMillion": "pro 1 Mio. Tok.",
|
||||||
@@ -91,6 +85,7 @@ export const de: Messages = {
|
|||||||
"files.newFolderName": "Ordnername:",
|
"files.newFolderName": "Ordnername:",
|
||||||
"files.renameTo": "Neuer Name:",
|
"files.renameTo": "Neuer Name:",
|
||||||
"files.copyTo": "Name der Kopie:",
|
"files.copyTo": "Name der Kopie:",
|
||||||
|
"files.chatHere": "Chat hier öffnen",
|
||||||
"files.rename": "Umbenennen",
|
"files.rename": "Umbenennen",
|
||||||
"files.duplicate": "Duplizieren",
|
"files.duplicate": "Duplizieren",
|
||||||
"files.download": "Herunterladen",
|
"files.download": "Herunterladen",
|
||||||
|
|||||||
+1
-6
@@ -64,12 +64,6 @@ export const en = {
|
|||||||
"settings.budgetWarning": "Warn at",
|
"settings.budgetWarning": "Warn at",
|
||||||
"settings.adminKey": "Admin key (optional)",
|
"settings.adminKey": "Admin key (optional)",
|
||||||
"settings.adminKeyHint": "Anthropic Admin API key (sk-ant-admin01-…) to show the real, organisation-wide monthly cost.",
|
"settings.adminKeyHint": "Anthropic Admin API key (sk-ant-admin01-…) to show the real, organisation-wide monthly cost.",
|
||||||
"settings.envTitle": "Settings from environment variables",
|
|
||||||
"settings.envHint": "These ${NAME} references take their values from the server's environment (.env). Move the values into the configuration so everything is set here – then remove the variables from .env.",
|
|
||||||
"settings.envApply": "Move into the configuration",
|
|
||||||
"settings.envConfirm": "Write the current values of {names} into the configuration file? Keys are then stored in the config (and in its backups) instead of the environment.",
|
|
||||||
"settings.envDone": "{n} values moved into the configuration. You can now remove them from .env.",
|
|
||||||
"settings.envUnset": "Referenced but not set (left as they are):",
|
|
||||||
"settings.fetchPrices": "Fetch prices",
|
"settings.fetchPrices": "Fetch prices",
|
||||||
"settings.pricesFetched": "Prices updated for {n} models – save to keep them.",
|
"settings.pricesFetched": "Prices updated for {n} models – save to keep them.",
|
||||||
"stats.perMillion": "per 1M tok",
|
"stats.perMillion": "per 1M tok",
|
||||||
@@ -90,6 +84,7 @@ export const en = {
|
|||||||
"files.newFolderName": "Folder name:",
|
"files.newFolderName": "Folder name:",
|
||||||
"files.renameTo": "New name:",
|
"files.renameTo": "New name:",
|
||||||
"files.copyTo": "Name of the copy:",
|
"files.copyTo": "Name of the copy:",
|
||||||
|
"files.chatHere": "Open chat here",
|
||||||
"files.rename": "Rename",
|
"files.rename": "Rename",
|
||||||
"files.duplicate": "Duplicate",
|
"files.duplicate": "Duplicate",
|
||||||
"files.download": "Download",
|
"files.download": "Download",
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||||
*/
|
*/
|
||||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||||
|
unreleased: {
|
||||||
|
en: ["File manager: “Open chat here” in a folder's menu"],
|
||||||
|
de: ["Dateimanager: „Chat hier öffnen“ im Ordner-Menü"],
|
||||||
|
},
|
||||||
"202611-00002": {
|
"202611-00002": {
|
||||||
en: [
|
en: [
|
||||||
"opencode: “Fetch prices” fills in prices including long-context and peak-hour pricing",
|
"opencode: “Fetch prices” fills in prices including long-context and peak-hour pricing",
|
||||||
|
|||||||
Reference in new issue
Block a user