mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
feat(mcode): let users choose the provider key instead of a random UUID
MiniMax Code shows custom_provider:<key>/<model> in its model picker, so the UUID CC Switch used as the key leaked into the UI. New MCode providers now take a user-entered key, prefilled from the preset, like OpenCode. Existing providers keep their UUID and the key is locked when editing, since MCode's default-model references embed it. - Duplicating an MCode provider generates an unused <key>-copy key. - Adding rejects a key already used by a database row or any live custom_provider node, including MCode's own account providers. The check and save run under the MCode switch lock, and the live write re-checks the key while holding config.yaml.lock so it never replaces a node MCode created in the meantime.
This commit is contained in:
1 parent
6064fc1c5c
commit
8f4a87d0cd
8 files changed
+340
-10
No files matched your search
@@ -103,6 +103,15 @@ pub(crate) fn get_providers() -> Result<IndexMap<String, Value>, AppError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Any `custom_provider` node under this key, including MCode's own account providers.
|
||||
pub(crate) fn provider_key_exists(id: &str) -> Result<bool, AppError> {
|
||||
let document = read(&config_path())?;
|
||||
Ok(document
|
||||
.get("custom_provider")
|
||||
.and_then(serde_yaml::Value::as_mapping)
|
||||
.is_some_and(|providers| providers.contains_key(serde_yaml::Value::from(id))))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_provider(id: &str, config: &Value) -> Result<(), AppError> {
|
||||
if id.is_empty()
|
||||
|| id
|
||||
@@ -162,6 +171,16 @@ impl Drop for ConfigLock {
|
||||
}
|
||||
|
||||
fn update(path: &Path, id: &str, provider: Option<Value>) -> Result<(), AppError> {
|
||||
write_entry(path, id, provider, false)
|
||||
}
|
||||
|
||||
/// `create_only` rejects a key that already exists, checked while holding MCode's lock.
|
||||
fn write_entry(
|
||||
path: &Path,
|
||||
id: &str,
|
||||
provider: Option<Value>,
|
||||
create_only: bool,
|
||||
) -> Result<(), AppError> {
|
||||
fs::create_dir_all(path.parent().expect("MCode configuration directory"))
|
||||
.map_err(|e| AppError::Message(format!("Cannot create MCode directory: {e}")))?;
|
||||
let path = if path.exists() {
|
||||
@@ -221,6 +240,11 @@ fn update(path: &Path, id: &str, provider: Option<Value>) -> Result<(), AppError
|
||||
let providers = entry
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| AppError::Config("Invalid MCode custom_provider configuration".into()))?;
|
||||
if create_only && providers.contains_key(serde_yaml::Value::from(id)) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"MCode provider key '{id}' already exists"
|
||||
)));
|
||||
}
|
||||
if providers
|
||||
.get(serde_yaml::Value::from(id))
|
||||
.and_then(|value| value.get("kind"))
|
||||
@@ -249,6 +273,11 @@ pub(crate) fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
||||
validate_provider(id, &config)?;
|
||||
update(&config_path(), id, Some(config))
|
||||
}
|
||||
/// Like `set_provider`, but never replaces an existing node.
|
||||
pub(crate) fn add_provider(id: &str, config: Value) -> Result<(), AppError> {
|
||||
validate_provider(id, &config)?;
|
||||
write_entry(&config_path(), id, Some(config), true)
|
||||
}
|
||||
pub(crate) fn remove_provider(id: &str) -> Result<(), AppError> {
|
||||
if !config_path().exists() {
|
||||
return Ok(());
|
||||
@@ -295,6 +324,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_only_writes_never_replace_a_node_that_appeared_meanwhile() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
let text = "custom_provider:\n raced:\n name: Theirs\n";
|
||||
fs::write(&path, text).unwrap();
|
||||
assert!(write_entry(&path, "raced", Some(json!({"name":"Ours"})), true).is_err());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), text);
|
||||
assert!(!path.with_extension("yaml.lock").exists());
|
||||
write_entry(&path, "fresh", Some(json!({"name":"Ours"})), true).unwrap();
|
||||
assert_eq!(
|
||||
read(&path).unwrap()["custom_provider"]["fresh"]["name"],
|
||||
"Ours"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_provider_can_omit_api_format() {
|
||||
let mut provider = json!({"options":{"baseURL":"https://example.com","apiKey":"test-key"},"models":{"model":{}}});
|
||||
|
||||
@@ -4814,13 +4814,18 @@ impl ProviderService {
|
||||
state: &AppState,
|
||||
provider: &Provider,
|
||||
write_live: bool,
|
||||
create: bool,
|
||||
) -> Result<bool, AppError> {
|
||||
let previous = state.db.get_provider_by_id(&provider.id, "mcode")?;
|
||||
state.db.save_provider("mcode", provider)?;
|
||||
if write_live {
|
||||
if let Err(error) =
|
||||
crate::mcode_config::set_provider(&provider.id, provider.settings_config.clone())
|
||||
{
|
||||
let config = provider.settings_config.clone();
|
||||
let written = if create {
|
||||
crate::mcode_config::add_provider(&provider.id, config)
|
||||
} else {
|
||||
crate::mcode_config::set_provider(&provider.id, config)
|
||||
};
|
||||
if let Err(error) = written {
|
||||
match previous {
|
||||
Some(previous) => state.db.save_provider("mcode", &previous)?,
|
||||
None => state.db.delete_provider("mcode", &provider.id)?,
|
||||
@@ -4859,7 +4864,23 @@ impl ProviderService {
|
||||
}
|
||||
|
||||
if app_type == AppType::Mcode {
|
||||
return Self::save_mcode_provider(state, &provider, add_to_live);
|
||||
// The key is user-chosen, and saving would overwrite an existing row or live node.
|
||||
// Adds are serialized here; the live write repeats the check under MCode's file lock.
|
||||
let _guard = futures::executor::block_on(
|
||||
state.proxy_service.lock_switch_for_app(app_type.as_str()),
|
||||
);
|
||||
if state
|
||||
.db
|
||||
.get_provider_by_id(&provider.id, "mcode")?
|
||||
.is_some()
|
||||
|| crate::mcode_config::provider_key_exists(&provider.id)?
|
||||
{
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"MCode provider key '{}' already exists",
|
||||
provider.id
|
||||
)));
|
||||
}
|
||||
return Self::save_mcode_provider(state, &provider, add_to_live, true);
|
||||
}
|
||||
|
||||
// 还没有当前供应商时这一家会被写进 live:和进入代理、切换互斥(同 `update`)。
|
||||
@@ -5494,7 +5515,7 @@ impl ProviderService {
|
||||
Self::set_provider_live_config_managed(&mut provider, live_config_managed);
|
||||
|
||||
if app_type == AppType::Mcode {
|
||||
return Self::save_mcode_provider(state, &provider, live_config_managed);
|
||||
return Self::save_mcode_provider(state, &provider, live_config_managed, false);
|
||||
}
|
||||
|
||||
// Save to database after live-config presence is resolved so parse errors
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use cc_switch_lib::{AppType, Prompt, PromptService};
|
||||
use cc_switch_lib::{AppType, Prompt, PromptService, Provider, ProviderService};
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
|
||||
@@ -41,3 +41,89 @@ fn failed_mcode_prompt_writes_preserve_state_and_can_be_retried() {
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "updated");
|
||||
assert!(state.db.get_prompts("mcode").unwrap()[&active.id].enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_an_mcode_provider_rejects_a_key_already_in_use() {
|
||||
let _guard = test_mutex().lock().unwrap();
|
||||
reset_test_fs();
|
||||
let state = create_test_state().unwrap();
|
||||
let path = ensure_test_home().join(".minimax/config.yaml");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let native = "custom_provider:\n handwritten:\n name: Mine\n models: {}\n minimax:\n kind: minimax\n name: Account\n";
|
||||
fs::write(&path, native).unwrap();
|
||||
let provider = |id: &str| {
|
||||
Provider::with_id(
|
||||
id.into(),
|
||||
"Test".into(),
|
||||
json!({"name":"Test", "kind":"custom", "enabled":true, "api":"openai-completions",
|
||||
"options":{"baseURL":"https://api.example.com/v1","apiKey":"test-key"},
|
||||
"models":{"model":{"name":"Model"}}}),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
// Live nodes, custom or owned by MCode, are never overwritten.
|
||||
for id in ["handwritten", "minimax"] {
|
||||
assert!(ProviderService::add(&state, AppType::Mcode, provider(id), true).is_err());
|
||||
}
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), native);
|
||||
assert!(state.db.get_all_providers("mcode").unwrap().is_empty());
|
||||
|
||||
// Neither is a row that only lives in the database.
|
||||
ProviderService::add(&state, AppType::Mcode, provider("fresh"), false).unwrap();
|
||||
let mut renamed = provider("fresh");
|
||||
renamed.name = "Other".into();
|
||||
assert!(ProviderService::add(&state, AppType::Mcode, renamed, true).is_err());
|
||||
assert_eq!(
|
||||
state
|
||||
.db
|
||||
.get_provider_by_id("fresh", "mcode")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.name,
|
||||
"Test"
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), native);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_mcode_adds_with_one_key_leave_exactly_one_winner() {
|
||||
let _guard = test_mutex().lock().unwrap();
|
||||
reset_test_fs();
|
||||
let state = create_test_state().unwrap();
|
||||
let successes = std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = (0..16)
|
||||
.map(|index| {
|
||||
let state = &state;
|
||||
scope.spawn(move || {
|
||||
let provider = Provider::with_id(
|
||||
"same".into(),
|
||||
format!("Racer {index}"),
|
||||
json!({"name":"Racer", "kind":"custom", "enabled":true,
|
||||
"api":"openai-completions",
|
||||
"options":{"baseURL":"https://api.example.com/v1","apiKey":"test-key"},
|
||||
"models":{"model":{"name":"Model"}}}),
|
||||
None,
|
||||
);
|
||||
ProviderService::add(state, AppType::Mcode, provider, false)
|
||||
.is_ok()
|
||||
.then_some(index)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles
|
||||
.into_iter()
|
||||
.filter_map(|handle| handle.join().unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert_eq!(successes.len(), 1);
|
||||
assert_eq!(
|
||||
state
|
||||
.db
|
||||
.get_provider_by_id("same", "mcode")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.name,
|
||||
format!("Racer {}", successes[0])
|
||||
);
|
||||
}
|
||||
@@ -887,6 +887,13 @@ function App() {
|
||||
existingKeys,
|
||||
);
|
||||
duplicatedProvider.addToLive = false;
|
||||
} else if (activeApp === "mcode") {
|
||||
// The MCode list already includes its live custom nodes; the backend
|
||||
// rejects a key that MCode itself owns.
|
||||
duplicatedProvider.providerKey = generateUniqueProviderCopyKey(
|
||||
provider.id,
|
||||
Object.keys(providers),
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.sortIndex !== undefined) {
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
import { useProvidersQuery } from "@/lib/query/queries";
|
||||
import type { ProviderFormData } from "@/lib/schemas/provider";
|
||||
import type { OpenCodeModel, OpenCodeProviderOptions } from "@/types";
|
||||
import { mcodeProviderPresets } from "@/config/mcodeProviderPresets";
|
||||
@@ -62,6 +64,9 @@ const configSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
type McodeConfig = z.infer<typeof configSchema>;
|
||||
const KEY_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
const normalizeKey = (value: string) =>
|
||||
value.toLowerCase().replace(/[^a-z0-9-]/g, "");
|
||||
|
||||
export function McodeProviderForm({
|
||||
providerId,
|
||||
@@ -95,6 +100,19 @@ export function McodeProviderForm({
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
// The key becomes `custom_provider.<key>` and MCode's default-model
|
||||
// references embed it, so it is fixed once the provider exists.
|
||||
const isEdit = Boolean(initialData);
|
||||
const [providerKey, setProviderKey] = useState(providerId ?? "");
|
||||
const { data: existing } = useProvidersQuery("mcode");
|
||||
const keyTaken =
|
||||
!isEdit &&
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
existing?.providers ?? {},
|
||||
providerKey,
|
||||
);
|
||||
const keyInvalid =
|
||||
!isEdit && providerKey !== "" && !KEY_PATTERN.test(providerKey);
|
||||
const form = useForm<ProviderFormData>({
|
||||
defaultValues: {
|
||||
name: initialData?.name ?? "",
|
||||
@@ -109,6 +127,7 @@ export function McodeProviderForm({
|
||||
const ready = Boolean(
|
||||
jsonValid &&
|
||||
name.trim() &&
|
||||
(isEdit || (providerKey && !keyInvalid && !keyTaken)) &&
|
||||
config.options?.baseURL?.trim() &&
|
||||
config.options?.apiKey?.trim() &&
|
||||
Object.keys(config.models ?? {}).length,
|
||||
@@ -136,6 +155,7 @@ export function McodeProviderForm({
|
||||
};
|
||||
update(next);
|
||||
setExtraOptions(toOpencodeExtraOptions(next.options));
|
||||
setProviderKey(selected?.providerKey ?? "");
|
||||
form.reset({
|
||||
name: selected?.name ?? "",
|
||||
notes: "",
|
||||
@@ -166,7 +186,7 @@ export function McodeProviderForm({
|
||||
...identity,
|
||||
name: identity.name.trim(),
|
||||
meta: initialData?.meta,
|
||||
providerKey: providerId,
|
||||
providerKey: isEdit ? providerId : providerKey,
|
||||
presetCategory: category,
|
||||
settingsConfig: JSON.stringify({
|
||||
...config,
|
||||
@@ -206,7 +226,45 @@ export function McodeProviderForm({
|
||||
disabled={busy || !jsonValid}
|
||||
className="min-w-0 space-y-6 border-0 p-0 disabled:opacity-50"
|
||||
>
|
||||
<BasicFormFields form={form} />
|
||||
<BasicFormFields
|
||||
form={form}
|
||||
beforeNameSlot={
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mcode-provider-key">
|
||||
{t("opencode.providerKey")}
|
||||
<span aria-hidden="true" className="text-destructive ml-1">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="mcode-provider-key"
|
||||
value={providerKey}
|
||||
onChange={(event) =>
|
||||
setProviderKey(normalizeKey(event.target.value))
|
||||
}
|
||||
disabled={isEdit}
|
||||
placeholder={t("opencode.providerKeyPlaceholder")}
|
||||
autoComplete="off"
|
||||
className={keyTaken || keyInvalid ? "border-destructive" : ""}
|
||||
/>
|
||||
<p
|
||||
className={
|
||||
keyTaken || keyInvalid
|
||||
? "text-xs text-destructive"
|
||||
: "text-xs text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{keyTaken
|
||||
? t("opencode.providerKeyDuplicate")
|
||||
: keyInvalid
|
||||
? t("opencode.providerKeyInvalid")
|
||||
: isEdit
|
||||
? t("opencode.providerKeyLockedHint")
|
||||
: t("opencode.providerKeyHint")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<OpenCodeFormFields
|
||||
apiFormats={API_FORMATS}
|
||||
npm={config.api ?? "anthropic-messages"}
|
||||
|
||||
@@ -67,7 +67,8 @@ export const useAddProviderMutation = (appId: AppId) => {
|
||||
appId === "opencode" ||
|
||||
appId === "openclaw" ||
|
||||
appId === "hermes" ||
|
||||
appId === "pi"
|
||||
appId === "pi" ||
|
||||
appId === "mcode"
|
||||
) {
|
||||
if (
|
||||
providerInput.category === "omo" ||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
fireEvent,
|
||||
render as rtlRender,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { McodeProviderForm } from "@/components/providers/forms/McodeProviderForm";
|
||||
import { mcodeProviderPresets } from "@/config/mcodeProviderPresets";
|
||||
import { piProviderPresets } from "@/config/piProviderPresets";
|
||||
import { setProviders } from "../msw/state";
|
||||
import { createTestQueryClient } from "../utils/testQueryClient";
|
||||
|
||||
vi.mock("@/components/JsonEditor", () => ({
|
||||
default: ({
|
||||
@@ -22,6 +31,13 @@ vi.mock("@/components/JsonEditor", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const render = (ui: ReactElement) =>
|
||||
rtlRender(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
{ui}
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const original = {
|
||||
kind: "custom",
|
||||
api: "anthropic-messages",
|
||||
@@ -113,6 +129,66 @@ describe("McodeProviderForm", () => {
|
||||
expect(saved.options.apiKey).toBe("test-key");
|
||||
expect(saved).not.toHaveProperty("npm");
|
||||
expect(saved.models).toHaveProperty("MiniMax-M3");
|
||||
expect(submit.mock.calls[0][0].providerKey).toBe("cc-switch-mini-max");
|
||||
});
|
||||
|
||||
it("requires a well-formed, unused provider key for new providers", async () => {
|
||||
setProviders("mcode", {
|
||||
taken: { id: "taken", name: "Taken", settingsConfig: original },
|
||||
});
|
||||
const submit = vi.fn();
|
||||
render(
|
||||
<McodeProviderForm
|
||||
appId="mcode"
|
||||
submitLabel="Save"
|
||||
onSubmit={submit}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Minimax MiniMax$/ }));
|
||||
fireEvent.change(screen.getByLabelText("API Key"), {
|
||||
target: { value: "test-key" },
|
||||
});
|
||||
const key = screen.getByLabelText(/opencode\.providerKey/);
|
||||
const save = screen.getByRole("button", { name: "Save" });
|
||||
expect(key).toHaveValue("cc-switch-mini-max");
|
||||
expect(save).toBeEnabled();
|
||||
|
||||
fireEvent.change(key, { target: { value: "" } });
|
||||
expect(save).toBeDisabled();
|
||||
fireEvent.change(key, { target: { value: "-bad-" } });
|
||||
expect(screen.getByText("opencode.providerKeyInvalid")).toBeInTheDocument();
|
||||
expect(save).toBeDisabled();
|
||||
fireEvent.change(key, { target: { value: "taken" } });
|
||||
expect(
|
||||
await screen.findByText("opencode.providerKeyDuplicate"),
|
||||
).toBeInTheDocument();
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
fireEvent.change(key, { target: { value: "My Key!" } });
|
||||
expect(key).toHaveValue("mykey");
|
||||
fireEvent.click(save);
|
||||
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
|
||||
expect(submit.mock.calls[0][0].providerKey).toBe("mykey");
|
||||
});
|
||||
|
||||
it("locks the provider key when editing", () => {
|
||||
render(
|
||||
<McodeProviderForm
|
||||
appId="mcode"
|
||||
providerId="existing"
|
||||
initialData={{ name: "Existing", settingsConfig: original }}
|
||||
submitLabel="Save"
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
const key = screen.getByLabelText(/opencode\.providerKey/);
|
||||
expect(key).toHaveValue("existing");
|
||||
expect(key).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText("opencode.providerKeyLockedHint"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps invalid JSON drafts from breaking the structured fields", () => {
|
||||
|
||||
@@ -390,6 +390,42 @@ describe("App integration with MSW", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("duplicates MiniMax Code providers under a generated unused key", async () => {
|
||||
localStorage.setItem("cc-switch-last-app", "mcode");
|
||||
const provider = (id: string, name: string) => ({
|
||||
id,
|
||||
name,
|
||||
settingsConfig: {},
|
||||
category: "custom" as const,
|
||||
sortIndex: 0,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
setProviders("mcode", {
|
||||
kimi: provider("kimi", "Kimi"),
|
||||
"kimi-copy": provider("kimi-copy", "Kimi copy"),
|
||||
});
|
||||
setCurrentProviderId("mcode", "kimi");
|
||||
|
||||
const { default: App } = await import("@/App");
|
||||
renderApp(App);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("provider-list").textContent).toContain(
|
||||
"kimi-copy",
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByText("duplicate"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("provider-list").textContent).toContain(
|
||||
"kimi-copy-2",
|
||||
),
|
||||
);
|
||||
expect(toastErrorMock).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Provider key is required for mcode"),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes MiniMax Code provider membership after removing it from live config", async () => {
|
||||
localStorage.setItem("cc-switch-last-app", "mcode");
|
||||
let liveConfigManaged = true;
|
||||
|
||||
Reference in new issue
Block a user