mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 11:12:17 +03:00
fix(cli): preserve OpenCode JSONC configs (#10246)
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* omniroute setup-opencode — Remote-aware OpenCode provider generator
|
||||
* (openai-compatible). Distinct from `omniroute setup opencode` (which wires the
|
||||
* @omniroute/opencode-plugin). This writes the `omniroute` provider into
|
||||
* ~/.config/opencode/opencode.json with every catalog model, so you can run
|
||||
* the active OpenCode JSON/JSONC config with every catalog model, so you can run
|
||||
* `opencode -m omniroute/<model>`.
|
||||
*
|
||||
* Reuses the proven server-side generator (config-generator/opencode.ts) for the
|
||||
@@ -10,12 +10,13 @@
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { basename, dirname } from "node:path";
|
||||
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
const ENV_KEY_REF = "{env:OMNIROUTE_API_KEY}";
|
||||
const JSON_FORMATTING_OPTIONS = { insertSpaces: true, tabSize: 2 };
|
||||
|
||||
/** Resolve baseUrl + (literal) apiKey from flags → active context → localhost. */
|
||||
export function resolveOpencodeTarget(opts = {}) {
|
||||
@@ -29,7 +30,8 @@ export function resolveOpencodeTarget(opts = {}) {
|
||||
} catch {
|
||||
/* no context */
|
||||
}
|
||||
if (!baseUrl) baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
if (!baseUrl)
|
||||
baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
@@ -48,32 +50,61 @@ export function resolveOpencodeTarget(opts = {}) {
|
||||
/**
|
||||
* Post-process the generator output: reference the API key by env var (keep the
|
||||
* secret off disk) and optionally keep only models whose id matches `only`.
|
||||
* Pure + testable. Returns the final JSON string.
|
||||
* Pure + testable. Returns the final JSONC string while preserving comments
|
||||
* outside the OmniRoute-managed fields.
|
||||
*
|
||||
* @param {string} rawJson output of generateOpencodeConfig
|
||||
* @param {{ only?: string[] }} [opts]
|
||||
* @returns {{ json: string, modelCount: number }}
|
||||
*/
|
||||
export function postProcessOpencodeConfig(rawJson, opts = {}) {
|
||||
const config = JSON.parse(rawJson);
|
||||
const prov = config.provider?.omniroute;
|
||||
if (prov?.options) prov.options.apiKey = ENV_KEY_REF;
|
||||
const errors = [];
|
||||
const config = parse(rawJson, errors, { allowTrailingComma: true, disallowComments: false });
|
||||
if (errors.length > 0 || !config || typeof config !== "object" || Array.isArray(config)) {
|
||||
const details = errors
|
||||
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
|
||||
.join(", ");
|
||||
throw new Error(`Failed to parse generated OpenCode config${details ? `: ${details}` : ""}`);
|
||||
}
|
||||
|
||||
const prov = config.provider?.omniroute;
|
||||
let json = rawJson;
|
||||
if (prov?.options) {
|
||||
json = applyEdits(
|
||||
json,
|
||||
modify(json, ["provider", "omniroute", "options", "apiKey"], ENV_KEY_REF, {
|
||||
formattingOptions: JSON_FORMATTING_OPTIONS,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
let models = prov?.models;
|
||||
if (opts.only && opts.only.length && prov?.models) {
|
||||
const kept = {};
|
||||
for (const [id, entry] of Object.entries(prov.models)) {
|
||||
if (opts.only.some((f) => id.includes(f))) kept[id] = entry;
|
||||
}
|
||||
prov.models = kept;
|
||||
models = kept;
|
||||
json = applyEdits(
|
||||
json,
|
||||
modify(json, ["provider", "omniroute", "models"], kept, {
|
||||
formattingOptions: JSON_FORMATTING_OPTIONS,
|
||||
})
|
||||
);
|
||||
}
|
||||
const modelCount = prov?.models ? Object.keys(prov.models).length : 0;
|
||||
return { json: JSON.stringify(config, null, 2) + "\n", modelCount };
|
||||
const modelCount = models ? Object.keys(models).length : 0;
|
||||
return { json: json.endsWith("\n") ? json : `${json}\n`, modelCount };
|
||||
}
|
||||
|
||||
export async function runSetupOpencodeCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveOpencodeTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
||||
const only = opts.only
|
||||
? opts.only
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
|
||||
printHeading("OmniRoute → OpenCode provider (openai-compatible)");
|
||||
printInfo(`Connecting to ${baseUrl} …`);
|
||||
@@ -81,20 +112,28 @@ export async function runSetupOpencodeCommand(opts = {}) {
|
||||
// Deferred import: opencode.ts is TypeScript; tsx is registered by
|
||||
// bin/omniroute.mjs before any command runs, so importing here is safe.
|
||||
let raw;
|
||||
let configPath;
|
||||
try {
|
||||
const { generateOpencodeConfig } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
raw = await generateOpencodeConfig({ baseUrl, apiKey, model: opts.model, providerId: "omniroute" });
|
||||
const { generateOpencodeConfig } =
|
||||
await import("../../../src/lib/cli-helper/config-generator/opencode.ts");
|
||||
const { resolveOpencodeConfigPath } =
|
||||
await import("../../../src/shared/services/opencodeConfigPath.ts");
|
||||
configPath = resolveOpencodeConfigPath();
|
||||
raw = await generateOpencodeConfig({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model: opts.model,
|
||||
providerId: "omniroute",
|
||||
configPath,
|
||||
});
|
||||
} catch (err) {
|
||||
printError(`Failed to generate opencode.json: ${err?.message || err}`);
|
||||
printError(`Failed to generate OpenCode config: ${err?.message || err}`);
|
||||
printInfo("Make sure OmniRoute is running and --remote/--api-key are correct.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { json, modelCount } = postProcessOpencodeConfig(raw, { only });
|
||||
const configDir = join(os.homedir(), ".config", "opencode");
|
||||
const configPath = join(configDir, "opencode.json");
|
||||
const configDir = dirname(configPath);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(json.length > 4000 ? json.slice(0, 4000) + "\n… (truncated)" : json);
|
||||
@@ -104,7 +143,9 @@ export async function runSetupOpencodeCommand(opts = {}) {
|
||||
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(configPath, json, "utf8");
|
||||
printSuccess(`opencode.json updated at ${configPath} (${modelCount} models under 'omniroute')`);
|
||||
printSuccess(
|
||||
`${basename(configPath)} updated at ${configPath} (${modelCount} models under 'omniroute')`
|
||||
);
|
||||
printInfo('Use it: opencode -m omniroute/<model> "..." (export OMNIROUTE_API_KEY first)');
|
||||
return 0;
|
||||
}
|
||||
@@ -113,7 +154,7 @@ export function registerSetupOpencode(program) {
|
||||
program
|
||||
.command("setup-opencode")
|
||||
.description(
|
||||
"Generate the OmniRoute openai-compatible provider in ~/.config/opencode/opencode.json " +
|
||||
"Generate the OmniRoute openai-compatible provider in the active OpenCode config " +
|
||||
"from the live model catalog (local or remote VPS)"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
|
||||
1
changelog.d/fixes/pending-opencode-jsonc-config.md
Normal file
1
changelog.d/fixes/pending-opencode-jsonc-config.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(cli):** recognize native `opencode.jsonc` files in OpenCode detection, generated-provider setup, and dashboard save/apply flows; preserve unrelated JSONC comments and provider settings, write updates back to the selected file, and refuse to overwrite invalid config ([#10227](https://github.com/diegosouzapw/OmniRoute/issues/10227)) — thanks @tito13kfm
|
||||
@@ -17,7 +17,6 @@ const applySchema = z.object({
|
||||
const TOOL_CONFIG_PATHS: Record<string, string> = {
|
||||
claude: path.join(os.homedir(), ".claude", "settings.json"),
|
||||
codex: path.join(os.homedir(), ".codex", "config.yaml"),
|
||||
opencode: path.join(os.homedir(), ".config", "opencode", "opencode.json"),
|
||||
cline: path.join(os.homedir(), ".cline", "data", "globalState.json"),
|
||||
kilocode: path.join(os.homedir(), ".config", "kilocode", "settings.json"),
|
||||
continue: path.join(os.homedir(), ".continue", "config.yaml"),
|
||||
@@ -65,7 +64,7 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const configPath = TOOL_CONFIG_PATHS[toolId];
|
||||
const configPath = toolId === "opencode" ? result.configPath : TOOL_CONFIG_PATHS[toolId];
|
||||
if (!configPath) {
|
||||
return NextResponse.json({ error: `Unknown tool: ${toolId}` }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function POST(request, { params }) {
|
||||
return await saveContinueConfig({ baseUrl, apiKey, model });
|
||||
case "opencode":
|
||||
// (#524) OpenCode config was never saved because only 'continue' was handled here.
|
||||
// OpenCode reads ~/.config/opencode/opencode.json — write the OmniRoute settings there.
|
||||
// OpenCode reads opencode.jsonc/opencode.json — update the active native config.
|
||||
return await saveOpenCodeConfig({ baseUrl, apiKey, model, models, modelLabels });
|
||||
case "hermes":
|
||||
return await saveHermesConfig({ baseUrl, apiKey, model });
|
||||
@@ -161,7 +161,7 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save OpenCode config to ~/.config/opencode/opencode.json on ALL platforms
|
||||
* Save OpenCode config to the active opencode.jsonc/opencode.json on ALL platforms
|
||||
* (XDG_CONFIG_HOME aware). OpenCode uses XDG `~/.config` even on Windows
|
||||
* (%USERPROFILE%\.config), NOT %APPDATA% (#3330).
|
||||
*
|
||||
@@ -182,8 +182,9 @@ async function saveOpenCodeConfig({ baseUrl, apiKey, model, models, modelLabels
|
||||
let existingConfigText = "";
|
||||
try {
|
||||
existingConfigText = await fs.readFile(configPath, "utf-8");
|
||||
} catch {
|
||||
// File doesn't exist — start fresh
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
// File doesn't exist — start fresh.
|
||||
}
|
||||
|
||||
const nextConfigText = mergeOpenCodeConfigText(existingConfigText, {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { generateHermesConfig } from "./hermes";
|
||||
import { generateHermesAgentConfig, type HermesAgentConfigPayload } from "./hermes-agent";
|
||||
import { generateKilocodeConfig } from "./kilocode";
|
||||
import { generateOpencodeConfig } from "./opencode";
|
||||
import { resolveOpencodeConfigPath } from "../../../shared/services/opencodeConfigPath";
|
||||
|
||||
export interface GenerateOptions {
|
||||
baseUrl: string;
|
||||
@@ -42,7 +43,6 @@ function expandHome(p: string): string {
|
||||
const STATIC_TOOL_CONFIG_PATHS: Record<string, string> = {
|
||||
claude: path.join(os.homedir(), ".claude", "settings.json"),
|
||||
codex: path.join(os.homedir(), ".codex", "config.yaml"),
|
||||
opencode: path.join(os.homedir(), ".config", "opencode", "opencode.json"),
|
||||
cline: path.join(os.homedir(), ".cline", "data", "globalState.json"),
|
||||
kilocode: path.join(os.homedir(), ".config", "kilocode", "settings.json"),
|
||||
continue: path.join(os.homedir(), ".continue", "config.yaml"),
|
||||
@@ -58,6 +58,9 @@ function getToolConfigPath(toolId: string): string {
|
||||
if (toolId === "hermes" || toolId === "hermes-agent") {
|
||||
return getHermesConfigPath();
|
||||
}
|
||||
if (toolId === "opencode") {
|
||||
return resolveOpencodeConfigPath();
|
||||
}
|
||||
return STATIC_TOOL_CONFIG_PATHS[toolId] ?? "";
|
||||
}
|
||||
|
||||
@@ -95,8 +98,11 @@ export async function generateConfig(
|
||||
if (!generate) {
|
||||
return { success: false, configPath: "", error: `Unknown tool: ${toolId}` };
|
||||
}
|
||||
const content = await generate(options);
|
||||
const configPath = getToolConfigPath(toolId);
|
||||
const content =
|
||||
toolId === "opencode"
|
||||
? await generateOpencodeConfig({ ...options, configPath })
|
||||
: await generate(options);
|
||||
return { success: true, configPath, content };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
import { applyEdits, modify, parse, printParseErrorCode, type ParseError } from "jsonc-parser";
|
||||
import {
|
||||
parseOutboundUrl,
|
||||
isCloudMetadataHost,
|
||||
OutboundUrlGuardError,
|
||||
} from "../../../shared/network/outboundUrlGuard";
|
||||
import { resolveOpencodeConfigPath } from "../../../shared/services/opencodeConfigPath";
|
||||
|
||||
const CONFIG_PATH = path.join(os.homedir(), ".config", "opencode", "opencode.json");
|
||||
const JSON_FORMATTING_OPTIONS = { insertSpaces: true, tabSize: 2 } as const;
|
||||
|
||||
/**
|
||||
* SSRF guard for the catalog fetch (CodeQL js/request-forgery #326). The catalog
|
||||
@@ -185,7 +185,8 @@ function deriveOpenCodeCapabilities(
|
||||
catalog: CatalogModelEntry | undefined,
|
||||
existing: ExistingModelEntry | undefined
|
||||
): Pick<ExistingModelEntry, "attachment" | "reasoning" | "temperature" | "tool_call"> {
|
||||
const result: Pick<ExistingModelEntry, "attachment" | "reasoning" | "temperature" | "tool_call"> = {};
|
||||
const result: Pick<ExistingModelEntry, "attachment" | "reasoning" | "temperature" | "tool_call"> =
|
||||
{};
|
||||
|
||||
// attachment: explicit user flag wins, then catalog attachment, then vision, then image modality.
|
||||
if (typeof existing?.attachment === "boolean") {
|
||||
@@ -324,26 +325,81 @@ function buildModelEntry(
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the user's current opencode.json (if any) so we can preserve names,
|
||||
* capability flags, and explicit `limit.context` overrides. JSONC comments
|
||||
* are not supported — we parse as plain JSON. If parsing fails, we fall
|
||||
* back to an empty config; the resulting write will lose comments, but
|
||||
* that matches the existing CLI behavior of `config set opencode`.
|
||||
* Load the user's current OpenCode config so we can preserve names,
|
||||
* capability flags, explicit `limit.context` overrides, and JSONC source text.
|
||||
* Existing invalid files are a hard stop: replacing one with a regenerated
|
||||
* document would silently lose comments, unrelated providers, and settings.
|
||||
*/
|
||||
function loadExistingConfig(): ExistingConfig {
|
||||
function loadExistingConfig(configPath: string): { config: ExistingConfig; source: string | null } {
|
||||
if (!fs.existsSync(configPath)) return { config: {}, source: null };
|
||||
|
||||
let source: string;
|
||||
try {
|
||||
if (!fs.existsSync(CONFIG_PATH)) return {};
|
||||
const raw = fs.readFileSync(CONFIG_PATH, "utf8");
|
||||
return JSON.parse(raw) as ExistingConfig;
|
||||
} catch {
|
||||
return {};
|
||||
source = fs.readFileSync(configPath, "utf8");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to read existing OpenCode config at ${configPath}: ${message}`);
|
||||
}
|
||||
|
||||
const errors: ParseError[] = [];
|
||||
const parsed = parse(source, errors, { allowTrailingComma: true, disallowComments: false });
|
||||
if (errors.length > 0 || !parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
const detail = errors[0] ? printParseErrorCode(errors[0].error) : "root must be an object";
|
||||
throw new Error(
|
||||
`Existing OpenCode config at ${configPath} is invalid JSONC (${detail}); refusing to overwrite it.`
|
||||
);
|
||||
}
|
||||
|
||||
return { config: parsed as ExistingConfig, source };
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the generated values into an existing JSONC document without replacing
|
||||
* its comments or unrelated keys. The generated object is authoritative for
|
||||
* the fields the generator manages; everything else remains byte-for-byte
|
||||
* unless jsonc-parser must adjust nearby whitespace for an edit.
|
||||
*/
|
||||
function mergeGeneratedConfigText(
|
||||
existingSource: string | null,
|
||||
existingConfig: ExistingConfig,
|
||||
generatedConfig: Record<string, unknown>,
|
||||
providerId: string
|
||||
): string {
|
||||
if (existingSource === null) return JSON.stringify(generatedConfig, null, 2);
|
||||
|
||||
let nextText = existingSource;
|
||||
const schemaEdits = modify(nextText, ["$schema"], generatedConfig.$schema, {
|
||||
formattingOptions: JSON_FORMATTING_OPTIONS,
|
||||
});
|
||||
nextText = applyEdits(nextText, schemaEdits);
|
||||
|
||||
const generatedProvider = (
|
||||
generatedConfig.provider as Record<string, ExistingProviderEntry> | undefined
|
||||
)?.[providerId];
|
||||
const providerEdits = modify(nextText, ["provider", providerId], generatedProvider, {
|
||||
formattingOptions: JSON_FORMATTING_OPTIONS,
|
||||
});
|
||||
nextText = applyEdits(nextText, providerEdits);
|
||||
|
||||
if (generatedConfig.model !== existingConfig.model) {
|
||||
const modelEdits = modify(nextText, ["model"], generatedConfig.model, {
|
||||
formattingOptions: JSON_FORMATTING_OPTIONS,
|
||||
});
|
||||
nextText = applyEdits(nextText, modelEdits);
|
||||
}
|
||||
|
||||
return nextText;
|
||||
}
|
||||
|
||||
export interface GenerateOpencodeOptions {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
/**
|
||||
* Pre-resolved destination used by the API generator/apply pipeline so the
|
||||
* file read for merging is guaranteed to be the file later written.
|
||||
*/
|
||||
configPath?: string;
|
||||
/**
|
||||
* Override the default `provider.id` used in the generated config.
|
||||
* Defaults to `"omniroute"`.
|
||||
@@ -389,6 +445,8 @@ export async function generateOpencodeConfig(options: GenerateOpencodeOptions):
|
||||
const providerId = options.providerId?.trim() || "omniroute";
|
||||
const fetchCatalog = options.fetchCatalog !== false;
|
||||
const timeoutMs = options.catalogTimeoutMs ?? 5_000;
|
||||
const configPath = options.configPath ?? resolveOpencodeConfigPath();
|
||||
const { config: existing, source: existingSource } = loadExistingConfig(configPath);
|
||||
|
||||
// Fetch live catalog. The catalog is the source of truth — if it fails,
|
||||
// we refuse to write an opencode.json that could mislead OpenCode into
|
||||
@@ -407,7 +465,6 @@ export async function generateOpencodeConfig(options: GenerateOpencodeOptions):
|
||||
|
||||
// Load existing config so we preserve names, capability flags, and any
|
||||
// explicit `limit.context` overrides the user has set.
|
||||
const existing = loadExistingConfig();
|
||||
const existingProvider = existing.provider?.[providerId];
|
||||
const existingModels = (existingProvider?.models ?? {}) as Record<string, ExistingModelEntry>;
|
||||
|
||||
@@ -465,7 +522,7 @@ export async function generateOpencodeConfig(options: GenerateOpencodeOptions):
|
||||
config.small_model = existing.small_model;
|
||||
}
|
||||
|
||||
return JSON.stringify(config, null, 2);
|
||||
return mergeGeneratedConfigText(existingSource, existing, config, providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
locateCommand,
|
||||
shouldUseShellForCommand,
|
||||
} from "../../shared/services/cliRuntime";
|
||||
import { resolveOpencodeConfigPath } from "../../shared/services/opencodeConfigPath";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
let execFileImpl = execFileAsync;
|
||||
@@ -44,7 +45,7 @@ export interface DetectedTool {
|
||||
const TOOLS = [
|
||||
{ id: "claude", name: "Claude Code", configPath: "~/.claude/settings.json" },
|
||||
{ id: "codex", name: "Codex CLI", configPath: "~/.codex/config.yaml" },
|
||||
{ id: "opencode", name: "OpenCode", configPath: "~/.config/opencode/opencode.json" },
|
||||
{ id: "opencode", name: "OpenCode", configPath: resolveOpencodeConfigPath },
|
||||
{ id: "cline", name: "Cline", configPath: "~/.cline/data/globalState.json" },
|
||||
{ id: "kilocode", name: "Kilo Code", configPath: "~/.config/kilocode/settings.json" },
|
||||
{ id: "continue", name: "Continue", configPath: "~/.continue/config.yaml" },
|
||||
@@ -149,8 +150,9 @@ export async function detectTool(id: string): Promise<DetectedTool | null> {
|
||||
if (!tool) return null;
|
||||
|
||||
const { installed, version } = await detectBinary(tool.id);
|
||||
const configPath = expandHome(tool.configPath);
|
||||
const configContents = await readConfigFile(tool.configPath);
|
||||
const configPath =
|
||||
typeof tool.configPath === "function" ? tool.configPath() : expandHome(tool.configPath);
|
||||
const configContents = await readConfigFile(configPath);
|
||||
const configured = !!configContents && isConfigured(configContents, "http://localhost:20128");
|
||||
|
||||
const result: DetectedTool = {
|
||||
|
||||
@@ -281,7 +281,7 @@ export const CLI_TOOLS: Record<string, CliCatalogEntry> = {
|
||||
notes: [
|
||||
{
|
||||
type: "warning",
|
||||
text: "Config path: ~/.config/opencode/opencode.json on all platforms (Windows: %USERPROFILE%\\\\.config\\\\opencode\\\\opencode.json)",
|
||||
text: "Config paths: ~/.config/opencode/opencode.jsonc (preferred when present) or opencode.json on all platforms (Windows: %USERPROFILE%\\\\.config\\\\opencode\\\\opencode.jsonc or opencode.json)",
|
||||
},
|
||||
{
|
||||
type: "warning",
|
||||
|
||||
@@ -9,6 +9,10 @@ import { withSettingsFallback } from "./cliInstallFallback";
|
||||
import { GROK_BUILD_RUNTIME_ENTRY, AMP_RUNTIME_ENTRY } from "./cliRuntimeGrokBuild";
|
||||
import { isLocationTrusted, findKnownPathMatch } from "./cliRuntimeKnownPath";
|
||||
import { buildHealthcheckPath } from "./cliRuntimeHealthcheckPath";
|
||||
import {
|
||||
resolveOpencodeConfigDir as resolveOpenCodeConfigDir,
|
||||
resolveOpencodeConfigPath as resolveOpenCodeConfigPath,
|
||||
} from "./opencodeConfigPath";
|
||||
const VALID_RUNTIME_MODES = new Set(["auto", "host", "container"]);
|
||||
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
|
||||
|
||||
@@ -979,15 +983,14 @@ export const resolveOpencodeConfigDir = (
|
||||
// `%APPDATA%`. Writing to %APPDATA% on Windows put the file where OpenCode
|
||||
// never looks, so dashboard-saved config silently had no effect. `_platform`
|
||||
// is kept in the signature for call-site/test compatibility.
|
||||
const xdgConfigHome = String(env.XDG_CONFIG_HOME || "").trim();
|
||||
return xdgConfigHome || path.join(homeDir, ".config");
|
||||
return path.dirname(resolveOpenCodeConfigDir(env, homeDir));
|
||||
};
|
||||
|
||||
export const resolveOpencodeConfigPath = (
|
||||
platform = process.platform,
|
||||
_platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homeDir = os.homedir()
|
||||
) => path.join(resolveOpencodeConfigDir(platform, env, homeDir), "opencode", "opencode.json");
|
||||
) => resolveOpenCodeConfigPath(env, homeDir);
|
||||
|
||||
export const getOpenCodeConfigPath = () => resolveOpencodeConfigPath();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { applyEdits, modify, parse } from "jsonc-parser";
|
||||
import { applyEdits, modify, parse, printParseErrorCode, type ParseError } from "jsonc-parser";
|
||||
|
||||
type OpenCodeConfigInput = {
|
||||
baseUrl?: string;
|
||||
@@ -113,11 +113,16 @@ export const mergeOpenCodeConfigText = (
|
||||
return JSON.stringify(buildOpenCodeConfigDocument(input), null, 2);
|
||||
}
|
||||
|
||||
const errors: { error: number }[] = [];
|
||||
const errors: ParseError[] = [];
|
||||
const parsed = parse(content, errors, { allowTrailingComma: true, disallowComments: false });
|
||||
|
||||
if (errors.length > 0 || !parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return JSON.stringify(mergeOpenCodeConfig({}, input), null, 2);
|
||||
const detail = errors[0]
|
||||
? `${printParseErrorCode(errors[0].error)} at offset ${errors[0].offset}`
|
||||
: "root must be an object";
|
||||
throw new Error(
|
||||
`Existing OpenCode config is invalid JSONC (${detail}); refusing to overwrite it.`
|
||||
);
|
||||
}
|
||||
|
||||
let nextText = content;
|
||||
|
||||
37
src/shared/services/opencodeConfigPath.ts
Normal file
37
src/shared/services/opencodeConfigPath.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export const OPENCODE_JSON_FILENAME = "opencode.json";
|
||||
export const OPENCODE_JSONC_FILENAME = "opencode.jsonc";
|
||||
|
||||
export const resolveOpencodeConfigDir = (
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homeDir = os.homedir()
|
||||
): string => {
|
||||
const xdgConfigHome = String(env.XDG_CONFIG_HOME || "").trim();
|
||||
return path.join(xdgConfigHome || path.join(homeDir, ".config"), "opencode");
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the global OpenCode config file that a write should update.
|
||||
*
|
||||
* OpenCode treats `opencode.jsonc` as the preferred writable global config and
|
||||
* merges it after `opencode.json` when both exist. Match that precedence so an
|
||||
* existing JSONC document is never shadowed by a newly-created JSON file. Keep
|
||||
* `opencode.json` as OmniRoute's creation default for backwards compatibility
|
||||
* when neither native filename exists.
|
||||
*/
|
||||
export const resolveOpencodeConfigPath = (
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homeDir = os.homedir()
|
||||
): string => {
|
||||
const configDir = resolveOpencodeConfigDir(env, homeDir);
|
||||
const jsoncPath = path.join(configDir, OPENCODE_JSONC_FILENAME);
|
||||
if (fs.existsSync(jsoncPath)) return jsoncPath;
|
||||
|
||||
const jsonPath = path.join(configDir, OPENCODE_JSON_FILENAME);
|
||||
if (fs.existsSync(jsonPath)) return jsonPath;
|
||||
|
||||
return jsonPath;
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import fs, { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse } from "jsonc-parser";
|
||||
import * as generator from "../../../src/lib/cli-helper/config-generator/index.ts";
|
||||
|
||||
// The UI's HERMES_ROLES catalog (HermesAgentToolCard.tsx) is a "use client" component
|
||||
@@ -261,7 +262,7 @@ describe("config-generator", () => {
|
||||
{ role: "delegation", model: "claude-3-5-sonnet" },
|
||||
{ role: "vision", model: "gpt-4o" },
|
||||
],
|
||||
});
|
||||
} as any);
|
||||
|
||||
assert.ok(!result.error);
|
||||
assert.ok(typeof result.yaml === "string");
|
||||
@@ -291,7 +292,7 @@ describe("config-generator", () => {
|
||||
const result = await hermesAgent.generateHermesAgentConfig({
|
||||
baseUrl: "",
|
||||
selections: [{ role: "default", model: "x" }],
|
||||
} as any);
|
||||
});
|
||||
|
||||
assert.ok(result.error);
|
||||
assert.ok(result.error.includes("baseUrl"));
|
||||
@@ -513,9 +514,8 @@ describe("config-generator", () => {
|
||||
])
|
||||
);
|
||||
try {
|
||||
const { generateOpencodeConfig } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
const { generateOpencodeConfig } =
|
||||
await import("../../../src/lib/cli-helper/config-generator/opencode.ts");
|
||||
const out = await generateOpencodeConfig({
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
@@ -653,5 +653,70 @@ describe("config-generator", () => {
|
||||
mock.restoreAll();
|
||||
}
|
||||
});
|
||||
|
||||
it("loads comments and trailing commas from opencode.jsonc and returns its real path (#10227)", async () => {
|
||||
const existingJsonc = `{
|
||||
// preserve this native OpenCode file instead of ignoring it
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"custom": {
|
||||
// keep comments inside unrelated providers too
|
||||
"name": "Custom Provider"
|
||||
},
|
||||
"omniroute": {
|
||||
"models": {
|
||||
"manual-model": { "name": "Manual", "limit": { "context": 77777, }, },
|
||||
},
|
||||
},
|
||||
},
|
||||
}\n`;
|
||||
let readPath = "";
|
||||
mock.method(fs, "existsSync", (candidate) => String(candidate).endsWith("opencode.jsonc"));
|
||||
mock.method(fs, "readFileSync", (candidate) => {
|
||||
readPath = String(candidate);
|
||||
return existingJsonc;
|
||||
});
|
||||
const stub = stubFetchOnce(
|
||||
makeCatalogResponse([{ id: "manual-model", context_length: 131072 }])
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await generator.generateConfig("opencode", {
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.match(result.configPath, /opencode\.jsonc$/);
|
||||
assert.strictEqual(readPath, result.configPath);
|
||||
assert.match(result.content || "", /preserve this native OpenCode file/);
|
||||
assert.match(result.content || "", /keep comments inside unrelated providers too/);
|
||||
const config = parse(result.content || "");
|
||||
assert.deepStrictEqual(config.provider.custom, { name: "Custom Provider" });
|
||||
assert.strictEqual(config.provider.omniroute.models["manual-model"].limit.context, 77777);
|
||||
} finally {
|
||||
stub.restore();
|
||||
mock.restoreAll();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to replace an invalid existing opencode.jsonc (#10227)", async () => {
|
||||
mock.method(fs, "existsSync", (candidate) => String(candidate).endsWith("opencode.jsonc"));
|
||||
mock.method(fs, "readFileSync", () => "{ invalid jsonc");
|
||||
const stub = stubFetchOnce(makeCatalogResponse([{ id: "catalog-model", context_length: 8 }]));
|
||||
|
||||
try {
|
||||
const result = await generator.generateConfig("opencode", {
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.match(result.error || "", /invalid.*JSONC|refus/i);
|
||||
} finally {
|
||||
stub.restore();
|
||||
mock.restoreAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import * as toolDetector from "../../../src/lib/cli-helper/tool-detector.ts";
|
||||
|
||||
test("detectTool reports an existing opencode.jsonc as the real config path (#10227)", async () => {
|
||||
const xdgRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-detector-jsonc-"));
|
||||
const configDir = path.join(xdgRoot, "opencode");
|
||||
const configPath = path.join(configDir, "opencode.jsonc");
|
||||
const previousXdg = process.env.XDG_CONFIG_HOME;
|
||||
|
||||
toolDetector.__setExecFileImpl(async () => ({ stdout: "v1.0.0\n", stderr: "" }));
|
||||
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
// OpenCode accepts JSONC
|
||||
"provider": {
|
||||
"omniroute": { "options": { "baseURL": "http://localhost:20128/v1" } },
|
||||
},
|
||||
}\n`
|
||||
);
|
||||
process.env.XDG_CONFIG_HOME = xdgRoot;
|
||||
|
||||
const result = await toolDetector.detectTool("opencode");
|
||||
|
||||
assert.ok(result !== null);
|
||||
assert.equal(result.configPath, configPath);
|
||||
assert.equal(result.configured, true);
|
||||
assert.match(result.configContents || "", /OpenCode accepts JSONC/);
|
||||
} finally {
|
||||
if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
||||
else process.env.XDG_CONFIG_HOME = previousXdg;
|
||||
fs.rmSync(xdgRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -322,4 +322,38 @@ describe("resolveOpencodeConfigPath — cross-platform", () => {
|
||||
);
|
||||
assert.equal(result, path.join("D:\\xdg", "opencode", "opencode.json"));
|
||||
});
|
||||
|
||||
it("selects an existing opencode.jsonc instead of inventing opencode.json (#10227)", () => {
|
||||
const xdgRoot = createTempDir();
|
||||
const configDir = path.join(xdgRoot, "opencode");
|
||||
const jsoncPath = path.join(configDir, "opencode.jsonc");
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(jsoncPath, "{\n // native OpenCode config\n}\n");
|
||||
|
||||
const result = resolveOpencodeConfigPathFn(
|
||||
process.platform,
|
||||
{ XDG_CONFIG_HOME: xdgRoot },
|
||||
os.homedir()
|
||||
);
|
||||
|
||||
assert.equal(result, jsoncPath);
|
||||
});
|
||||
|
||||
it("prefers opencode.jsonc when both native filenames exist (#10227)", () => {
|
||||
const xdgRoot = createTempDir();
|
||||
const configDir = path.join(xdgRoot, "opencode");
|
||||
const jsonPath = path.join(configDir, "opencode.json");
|
||||
const jsoncPath = path.join(configDir, "opencode.jsonc");
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(jsonPath, "{}\n");
|
||||
fs.writeFileSync(jsoncPath, "{}\n");
|
||||
|
||||
const result = resolveOpencodeConfigPathFn(
|
||||
process.platform,
|
||||
{ XDG_CONFIG_HOME: xdgRoot },
|
||||
os.homedir()
|
||||
);
|
||||
|
||||
assert.equal(result, jsoncPath);
|
||||
});
|
||||
});
|
||||
|
||||
139
tests/unit/cli-tools-apply-opencode-jsonc.test.ts
Normal file
139
tests/unit/cli-tools-apply-opencode-jsonc.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { SignJWT } from "jose";
|
||||
import { parse } from "jsonc-parser";
|
||||
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
const databaseRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-apply-jsonc-db-"));
|
||||
process.env.DATA_DIR = databaseRoot;
|
||||
const applyRoute = await import("../../src/app/api/cli-tools/apply/route.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalJwtSecret = process.env.JWT_SECRET;
|
||||
const originalApiKeySecret = process.env.API_KEY_SECRET;
|
||||
const originalXdg = process.env.XDG_CONFIG_HOME;
|
||||
const testRoots = new Set<string>();
|
||||
|
||||
async function createAuthCookie(): Promise<string> {
|
||||
process.env.JWT_SECRET = "test-cli-tools-apply-secret";
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
const token = await new SignJWT({ sub: "test-user" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("1h")
|
||||
.sign(secret);
|
||||
return `auth_token=${token}`;
|
||||
}
|
||||
|
||||
async function postApply(): Promise<Response> {
|
||||
const cookie = await createAuthCookie();
|
||||
return applyRoute.POST(
|
||||
new Request("http://localhost/api/cli-tools/apply", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", cookie },
|
||||
body: JSON.stringify({
|
||||
toolId: "opencode",
|
||||
baseUrl: "http://localhost:20128",
|
||||
apiKey: "sk-test",
|
||||
model: "catalog-model",
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-apply-jsonc-"));
|
||||
testRoots.add(root);
|
||||
process.env.XDG_CONFIG_HOME = root;
|
||||
process.env.API_KEY_SECRET = "test-secret";
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: "catalog-model",
|
||||
context_length: 131072,
|
||||
max_output_tokens: 8192,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalJwtSecret === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = originalJwtSecret;
|
||||
if (originalApiKeySecret === undefined) delete process.env.API_KEY_SECRET;
|
||||
else process.env.API_KEY_SECRET = originalApiKeySecret;
|
||||
if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
||||
else process.env.XDG_CONFIG_HOME = originalXdg;
|
||||
for (const root of testRoots) await fs.rm(root, { recursive: true, force: true });
|
||||
testRoots.clear();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
await fs.rm(databaseRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("apply writes back to the selected opencode.jsonc and does not create opencode.json (#10227)", async () => {
|
||||
const configDir = path.join(process.env.XDG_CONFIG_HOME!, "opencode");
|
||||
const jsoncPath = path.join(configDir, "opencode.jsonc");
|
||||
const jsonPath = path.join(configDir, "opencode.json");
|
||||
const original = `{
|
||||
// this comment must survive the merge
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"custom": {
|
||||
// keep comments inside unrelated providers too
|
||||
"name": "Custom Provider"
|
||||
},
|
||||
},
|
||||
}\n`;
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(jsoncPath, original, "utf-8");
|
||||
|
||||
const response = await postApply();
|
||||
const body = (await response.json()) as {
|
||||
success?: boolean;
|
||||
configPath?: string;
|
||||
backupPath?: string;
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.success, true);
|
||||
assert.equal(body.configPath, jsoncPath);
|
||||
assert.equal(body.backupPath, path.join(configDir, ".omniroute.bak", "opencode.jsonc.bak"));
|
||||
await assert.rejects(fs.access(jsonPath));
|
||||
|
||||
const updatedText = await fs.readFile(jsoncPath, "utf-8");
|
||||
assert.match(updatedText, /this comment must survive the merge/);
|
||||
assert.match(updatedText, /keep comments inside unrelated providers too/);
|
||||
const updated = parse(updatedText);
|
||||
assert.deepEqual(updated.provider.custom, { name: "Custom Provider" });
|
||||
assert.equal(updated.provider.omniroute.models["catalog-model"].limit.context, 131072);
|
||||
assert.equal(await fs.readFile(body.backupPath!, "utf-8"), original);
|
||||
});
|
||||
|
||||
test("apply leaves an invalid opencode.jsonc untouched instead of overwriting it (#10227)", async () => {
|
||||
const configDir = path.join(process.env.XDG_CONFIG_HOME!, "opencode");
|
||||
const jsoncPath = path.join(configDir, "opencode.jsonc");
|
||||
const jsonPath = path.join(configDir, "opencode.json");
|
||||
const invalid = "{ invalid jsonc\n";
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(jsoncPath, invalid, "utf-8");
|
||||
|
||||
const response = await postApply();
|
||||
const body = (await response.json()) as { error?: string };
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(body.error || "", /invalid.*JSONC|refus/i);
|
||||
assert.equal(await fs.readFile(jsoncPath, "utf-8"), invalid);
|
||||
await assert.rejects(fs.access(jsonPath));
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parse } from "jsonc-parser";
|
||||
import {
|
||||
postProcessOpencodeConfig,
|
||||
resolveOpencodeTarget,
|
||||
@@ -50,6 +51,31 @@ test("postProcessOpencodeConfig preserves $schema, provider name and npm", () =>
|
||||
assert.equal(cfg.provider.omniroute.npm, "@ai-sdk/openai-compatible");
|
||||
});
|
||||
|
||||
test("postProcessOpencodeConfig preserves JSONC comments outside managed fields", () => {
|
||||
const rawJsonc = `{
|
||||
// top-level user comment
|
||||
"provider": {
|
||||
"custom": {
|
||||
// nested provider comment
|
||||
"npm": "@ai-sdk/custom",
|
||||
},
|
||||
"omniroute": {
|
||||
"options": { "apiKey": "sk-secret-literal" },
|
||||
"models": { "openai/gpt-4o": { "name": "GPT-4o" } },
|
||||
},
|
||||
},
|
||||
}`;
|
||||
|
||||
const { json, modelCount } = postProcessOpencodeConfig(rawJsonc);
|
||||
|
||||
assert.match(json, /\/\/ top-level user comment/);
|
||||
assert.match(json, /\/\/ nested provider comment/);
|
||||
const config = parse(json);
|
||||
assert.equal(config.provider.custom.npm, "@ai-sdk/custom");
|
||||
assert.equal(config.provider.omniroute.options.apiKey, "{env:OMNIROUTE_API_KEY}");
|
||||
assert.equal(modelCount, 1);
|
||||
});
|
||||
|
||||
test("resolveOpencodeTarget: --remote wins and trailing slashes are trimmed", () => {
|
||||
const { baseUrl } = resolveOpencodeTarget({ remote: "http://vps:20128/" });
|
||||
assert.equal(baseUrl, "http://vps:20128");
|
||||
|
||||
@@ -11,6 +11,7 @@ const guideSettingsRoute =
|
||||
|
||||
const DUMMY_HOME = path.join(os.tmpdir(), "omniroute-guide-settings-test-" + Date.now());
|
||||
const OPENCODE_CONFIG_PATH = path.join(DUMMY_HOME, ".config", "opencode", "opencode.json");
|
||||
const OPENCODE_JSONC_CONFIG_PATH = path.join(DUMMY_HOME, ".config", "opencode", "opencode.jsonc");
|
||||
// cliRuntime.ts hermes entry maps to .config/hermes/config.json (not .hermes/config.yaml)
|
||||
const HERMES_CONFIG_PATH = path.join(DUMMY_HOME, ".config", "hermes", "config.json");
|
||||
const originalXDG = process.env.XDG_CONFIG_HOME;
|
||||
@@ -201,3 +202,24 @@ test("guide-settings POST preserves existing OpenCode config fields while only u
|
||||
"opencode-go/kimi-k2.6": { name: "Kimi K2.6" },
|
||||
});
|
||||
});
|
||||
|
||||
test("guide-settings POST refuses to overwrite an invalid opencode.jsonc (#10227)", async () => {
|
||||
const invalidJsonc = "{ invalid jsonc\n";
|
||||
await fs.mkdir(path.dirname(OPENCODE_JSONC_CONFIG_PATH), { recursive: true });
|
||||
await fs.writeFile(OPENCODE_JSONC_CONFIG_PATH, invalidJsonc, "utf-8");
|
||||
|
||||
const req = await buildRequest("opencode", {
|
||||
baseUrl: "http://my-omni/v1",
|
||||
apiKey: "sk-123",
|
||||
models: ["cx/gpt-5.6-sol"],
|
||||
});
|
||||
const response = (await guideSettingsRoute.POST(req, {
|
||||
params: { toolId: "opencode" },
|
||||
})) as Response;
|
||||
const data = (await response.json()) as { error?: string };
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.match(data.error || "", /invalid JSONC.*refusing to overwrite/i);
|
||||
assert.equal(await fs.readFile(OPENCODE_JSONC_CONFIG_PATH, "utf-8"), invalidJsonc);
|
||||
await assert.rejects(fs.access(OPENCODE_CONFIG_PATH));
|
||||
});
|
||||
|
||||
@@ -20,7 +20,9 @@ test("T40: OpenCode card documents config paths and --variant usage", () => {
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
assert.match(notesText, /\.config\/opencode\/opencode\.json/);
|
||||
assert.match(notesText, /or opencode\.json\b/);
|
||||
assert.match(notesText, /\.config\/opencode\/opencode\.jsonc/);
|
||||
assert.match(notesText, /preferred when present/);
|
||||
// #3330: OpenCode uses ~/.config on all platforms (incl. Windows) — the note
|
||||
// must no longer point Windows users at %APPDATA%.
|
||||
assert.doesNotMatch(notesText, /%appdata%/);
|
||||
|
||||
Reference in New Issue
Block a user