@openchambery/web 2.0.0-beta.26 → 2.0.0-beta.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentAvatar-FyTdEOMr.js → AgentAvatar-CqD6Pfom.js} +1 -1
- package/dist/assets/{AssistantView-DvOO2Kf5.js → AssistantView-BW9f9vF2.js} +3 -3
- package/dist/assets/{DiagramView-BqjeSOIg.js → DiagramView-BP3WDmFe.js} +1 -1
- package/dist/assets/{FadeInOnReveal-DHDffzHs.js → FadeInOnReveal-ezW_e-yz.js} +1 -1
- package/dist/assets/{Icon-D7NdMmbi.js → Icon-ChOAx1jX.js} +1 -1
- package/dist/assets/{MarkdownRenderer-kpkRqS42.js → MarkdownRenderer-BBHZa1Ts.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-CzBos-Gt.js → MarkdownRendererImpl-DfxEsttZ.js} +2 -2
- package/dist/assets/{MarkstreamRendererImpl-RY_rAgKD.js → MarkstreamRendererImpl-6k-n071l.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-DgtRh0zu.js → MobileDetailNavigation-s48GkQY9.js} +1 -1
- package/dist/assets/{MobileShareBridge-BM3cN-gG.js → MobileShareBridge-DAgShkFE.js} +2 -2
- package/dist/assets/MobileSurface-DZug2f4O.js +2 -0
- package/dist/assets/{MultiRunWindow-Bq4gz78C.js → MultiRunWindow-CIhHw9mo.js} +1 -1
- package/dist/assets/{OnboardingScreen-DMbPMwlp.js → OnboardingScreen-ozxwbzDw.js} +1 -1
- package/dist/assets/{OverlayScrollbar-mI9WVa7E.js → OverlayScrollbar-BL-QnnrN.js} +1 -1
- package/dist/assets/SettingsView-DmUdWSji.js +25 -0
- package/dist/assets/{SettingsWindow-CHPjbEnI.js → SettingsWindow-CuIZwGhM.js} +1 -1
- package/dist/assets/{TerminalView-DCJuL2nP.js → TerminalView-BIJ-ILgl.js} +1 -1
- package/dist/assets/{ToolOutputDialog-Cg_Q1t3X.js → ToolOutputDialog-B6KIxVUo.js} +1 -1
- package/dist/assets/{appThemeRegistry-BxuflaQt.js → appThemeRegistry-BuMGUGUK.js} +1 -1
- package/dist/assets/{button-e8fYWIMM.js → button-DoDvdeG-.js} +1 -1
- package/dist/assets/{collapsible-B6wLXHi3.js → collapsible-CZkyFVK0.js} +1 -1
- package/dist/assets/{context-menu-Bq7W1d7y.js → context-menu-DTjON-Ta.js} +1 -1
- package/dist/assets/{desktopHosts-C8vf9vRY.js → desktopHosts-CP1YHQ4Z.js} +1 -1
- package/dist/assets/{desktopRelayRestore-DDJtljeu.js → desktopRelayRestore-BeOGO7oA.js} +1 -1
- package/dist/assets/{diagram-BBL-LfsC.js → diagram-DUi801df.js} +1 -1
- package/dist/assets/{dialog-MRsJpHzu.js → dialog-DGzGqOhG.js} +1 -1
- package/dist/assets/{es-DvPwAj0y.js → es-DCjl24dV.js} +3 -3
- package/dist/assets/fileMentionTouchSelection-C3A6XKRM.js +2 -0
- package/dist/assets/{fileQueries-CBu-ytXS.js → fileQueries-CAH8-p27.js} +1 -1
- package/dist/assets/{fr-XQsoaNKY.js → fr-DfJ5j0t5.js} +3 -3
- package/dist/assets/{gitApi-CuIUGt04.js → gitApi-B1Nyw0PD.js} +3 -3
- package/dist/assets/{input-DbOe1mdT.js → input-Ct2uaiDc.js} +1 -1
- package/dist/assets/insertionBoundaries-BypTPUfp.js +1 -0
- package/dist/assets/{ja-DslzO96s.js → ja-B_9_w2Xf.js} +3 -3
- package/dist/assets/{ko-C_9XOAL0.js → ko-DlGbo3kV.js} +3 -3
- package/dist/assets/main-BREqmQUm.js +169 -0
- package/dist/assets/{main-Sd4Auh_x.js → main-BodZWzei.js} +2 -2
- package/dist/assets/main-qSeeJRW1.css +1 -0
- package/dist/assets/{markdownHeightCache-B5VSLl79.js → markdownHeightCache-CMoWScss.js} +1 -1
- package/dist/assets/{mcpQueries-BYNrkehM.js → mcpQueries-nScpWEqu.js} +1 -1
- package/dist/assets/{miniChat-Hp9bNmS0.js → miniChat-Ca-kdWf0.js} +2 -2
- package/dist/assets/{mobile-fBLEOx9_.js → mobile-BWi6F8Lj.js} +2 -2
- package/dist/assets/multirun-BdSrR6jN.js +3 -0
- package/dist/assets/persistence-B3_CPgm1.js +3 -0
- package/dist/assets/{pl-Dst9Wk53.js → pl-DjUsS8Ei.js} +3 -3
- package/dist/assets/platform-Bxdc4SbV.js +6 -0
- package/dist/assets/projectMeta-CIKLMweH.js +1 -0
- package/dist/assets/{pt-BR-Dq394BPb.js → pt-BR-B8sV7qo1.js} +3 -3
- package/dist/assets/{renderElectronMiniChatApp-D1xipCO5.js → renderElectronMiniChatApp-DtYCIW4n.js} +2 -2
- package/dist/assets/renderMobileApp-D47vojsP.js +28 -0
- package/dist/assets/{runtimeConfig-pTYamMLy.js → runtimeConfig-u9DrZmkS.js} +1 -1
- package/dist/assets/runtimeEndpointReset-Bn4vJ-gl.js +148 -0
- package/dist/assets/runtimeEndpointReset-D1_Y5I4F.css +1 -0
- package/dist/assets/{sessionChangeSummary-DcnZyuWt.js → sessionChangeSummary-C0OxmHed.js} +2 -2
- package/dist/assets/{sessionLookup-B3t-FerN.js → sessionLookup-D1NHRgWf.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-B0sZ7mm6.js → sortable-tabs-strip-Cp5i5hI-.js} +1 -1
- package/dist/assets/{store-D8fmTWK3.js → store-BTqPn8Uq.js} +3 -3
- package/dist/assets/{terminalApi-CJyWCuc8.js → terminalApi-CqyW3_W3.js} +1 -1
- package/dist/assets/{textarea-B5GMntHk.js → textarea-jEbc6gqf.js} +1 -1
- package/dist/assets/{tooltip-C64SdRvR.js → tooltip-DKYT7xYe.js} +1 -1
- package/dist/assets/{uk-8XAR75Qh.js → uk-Bh7HXyLp.js} +3 -3
- package/dist/assets/{url-DF1NFwt_.js → url-hhmiocZY.js} +1 -1
- package/dist/assets/{useAppFontEffects-BQklk6XB.js → useAppFontEffects-BBX1KjLV.js} +53 -53
- package/dist/assets/{useDesktopWindowControlsLayout-CrzYwZDQ.js → useDesktopWindowControlsLayout-uKYzUX-m.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-Ddx7sZ5A.js → useEffectiveDirectory-BOolveMp.js} +1 -1
- package/dist/assets/{useFontPreferences-CrteQiAp.js → useFontPreferences-BMJQP6t6.js} +1 -1
- package/dist/assets/useMobileNavigationStore-CC8QT7VC.js +415 -0
- package/dist/assets/useSessionAutoCleanup-CydMAlLf.js +2 -0
- package/dist/assets/{useWorkerHighlightedLines-VOtVUGkO.js → useWorkerHighlightedLines-BF1ClmMe.js} +1 -1
- package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
- package/dist/assets/{zh-CN-B-ILfrqC.js → zh-CN-Uzia0gk4.js} +3 -3
- package/dist/assets/{zh-TW-5HeTVP4z.js → zh-TW-CiI7DLdB.js} +3 -3
- package/dist/index.html +8 -8
- package/dist/mini-chat.html +8 -8
- package/dist/mobile.html +8 -8
- package/package.json +3 -3
- package/server/index.js +2 -20
- package/server/lib/git/DOCUMENTATION.md +1 -0
- package/server/lib/git/routes.js +7 -4
- package/server/lib/git/routes.test.js +34 -0
- package/server/lib/git/service.js +3 -1
- package/server/lib/llm/DOCUMENTATION.md +6 -4
- package/server/lib/llm/generate.js +8 -9
- package/server/lib/llm/generate.test.js +33 -6
- package/server/lib/opencode/DOCUMENTATION.md +24 -20
- package/server/lib/opencode/agents.js +13 -22
- package/server/lib/opencode/auth-state-runtime.js +3 -3
- package/server/lib/opencode/auth-state-runtime.test.js +59 -0
- package/server/lib/opencode/auth.d.ts +13 -0
- package/server/lib/opencode/auth.js +74 -64
- package/server/lib/opencode/auth.test.js +57 -0
- package/server/lib/opencode/commands.js +1 -1
- package/server/lib/opencode/config-entities-layering.test.js +72 -0
- package/server/lib/opencode/config-entity-routes.js +9 -122
- package/server/lib/opencode/config-entity-routes.test.js +7 -11
- package/server/lib/opencode/config-files.d.ts +17 -0
- package/server/lib/opencode/config-files.js +187 -0
- package/server/lib/opencode/config-files.test.js +201 -0
- package/server/lib/opencode/core-routes.js +0 -1
- package/server/lib/opencode/core-routes.test.js +0 -15
- package/server/lib/opencode/credential-boundary.d.ts +1 -0
- package/server/lib/opencode/credential-boundary.js +11 -0
- package/server/lib/opencode/ensure-cli.js +4 -1
- package/server/lib/opencode/env-config.js +4 -1
- package/server/lib/opencode/env-config.test.js +28 -0
- package/server/lib/opencode/env-runtime.test.js +2 -1
- package/server/lib/opencode/feature-routes-runtime.js +9 -7
- package/server/lib/opencode/hmr-state-runtime.js +4 -4
- package/server/lib/opencode/lifecycle.js +1 -49
- package/server/lib/opencode/lifecycle.test.js +30 -4
- package/server/lib/opencode/location-release.d.ts +8 -0
- package/server/lib/opencode/location-release.js +32 -0
- package/server/lib/opencode/location-release.test.js +43 -0
- package/server/lib/opencode/mcp.js +1 -1
- package/server/lib/opencode/opencode2-pin.js +2 -1
- package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
- package/server/lib/opencode/plugins.js +12 -60
- package/server/lib/opencode/providers.js +6 -3
- package/server/lib/opencode/proxy.js +7 -0
- package/server/lib/opencode/proxy.test.js +13 -0
- package/server/lib/opencode/routes.js +5 -5
- package/server/lib/opencode/routes.test.js +18 -18
- package/server/lib/opencode/runtime-contract.js +3 -2
- package/server/lib/opencode/runtime-contract.test.js +12 -12
- package/server/lib/opencode/shared.js +6 -175
- package/server/lib/opencode/upgrade-screen.js +4 -11
- package/server/lib/opencode/upgrade-screen.test.js +25 -25
- package/server/lib/opencode/version-alignment.test.js +40 -0
- package/server/lib/quota/DOCUMENTATION.md +9 -0
- package/server/lib/quota/provider-credentials.test.js +29 -0
- package/server/lib/quota/providers/claude.js +4 -4
- package/server/lib/quota/providers/codex.js +4 -4
- package/server/lib/quota/providers/codex.test.js +1 -1
- package/server/lib/quota/providers/copilot.js +5 -5
- package/server/lib/quota/providers/google/auth.js +3 -3
- package/server/lib/quota/providers/google/index.js +2 -2
- package/server/lib/quota/providers/index.js +3 -7
- package/server/lib/quota/providers/index.test.js +2 -2
- package/server/lib/quota/providers/kimi.js +4 -4
- package/server/lib/quota/providers/minimax-shared.js +4 -4
- package/server/lib/quota/providers/nanogpt.js +4 -4
- package/server/lib/quota/providers/openai.js +4 -4
- package/server/lib/quota/providers/openrouter.js +4 -4
- package/server/lib/quota/providers/wafer.js +4 -4
- package/server/lib/quota/providers/zai.js +4 -4
- package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
- package/server/lib/quota/routes.js +1 -1
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +1 -0
- package/server/lib/scheduled-tasks/runtime.js +2 -3
- package/server/lib/session-index/DOCUMENTATION.md +7 -0
- package/server/lib/session-index/event-ingest.js +13 -0
- package/server/lib/session-index/event-ingest.test.js +7 -0
- package/server/lib/session-index/service.js +45 -0
- package/server/lib/session-index/service.test.js +47 -0
- package/server/lib/tts/DOCUMENTATION.md +7 -5
- package/server/lib/tts/credential-source.test.js +23 -0
- package/server/lib/tts/routes.js +2 -2
- package/server/lib/tts/service.js +16 -37
- package/dist/assets/MobileSurface-ocVWenHA.js +0 -2
- package/dist/assets/SettingsView-CIZz40Jg.js +0 -25
- package/dist/assets/fileMentionTouchSelection-Bj67pPo7.js +0 -3
- package/dist/assets/insertionBoundaries-nzs1Q3YR.js +0 -1
- package/dist/assets/main-D2BzpnUt.css +0 -1
- package/dist/assets/main-DjilfYN7.js +0 -169
- package/dist/assets/multirun-D7W7oCj4.js +0 -4
- package/dist/assets/persistence-CMWO3jFo.js +0 -3
- package/dist/assets/platform-B0p43squ.js +0 -6
- package/dist/assets/projectMeta-DrUKrYt0.js +0 -3
- package/dist/assets/renderMobileApp-DSk7Qs-_.js +0 -28
- package/dist/assets/runtimeEndpointReset-B5HEMuiB.js +0 -149
- package/dist/assets/useMobileNavigationStore-i4cXNoOf.js +0 -414
- package/dist/assets/useSessionAutoCleanup-Dzd0fGSe.js +0 -2
- package/dist/assets/vendor-opencode-client-C12gfjag.js +0 -12
- package/server/lib/opencode/snippets.js +0 -242
- package/server/lib/opencode/snippets.test.js +0 -97
|
@@ -38,12 +38,6 @@ const createDependencies = (getCommandSources, configDirectory, getAgentSources
|
|
|
38
38
|
createMcpConfig: vi.fn(),
|
|
39
39
|
updateMcpConfig: vi.fn(),
|
|
40
40
|
deleteMcpConfig: vi.fn(),
|
|
41
|
-
listSnippets: vi.fn(),
|
|
42
|
-
getSnippet: vi.fn(),
|
|
43
|
-
createSnippet: vi.fn(),
|
|
44
|
-
updateSnippet: vi.fn(),
|
|
45
|
-
deleteSnippet: vi.fn(),
|
|
46
|
-
expandSnippets: vi.fn(),
|
|
47
41
|
configDirectory,
|
|
48
42
|
buildOpenCodeUrl: () => 'http://opencode-upstream:4096/',
|
|
49
43
|
getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic example' }),
|
|
@@ -459,6 +453,8 @@ describe('config entity agent metadata route', () => {
|
|
|
459
453
|
});
|
|
460
454
|
|
|
461
455
|
describe('global raw configuration routes', () => {
|
|
456
|
+
beforeEach(() => vi.stubEnv('OPENCODE_CONFIG', ''));
|
|
457
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
462
458
|
it('reads and validates the configured OpenCode and oh-my-opencode files', async () => {
|
|
463
459
|
const app = express();
|
|
464
460
|
app.use(express.json());
|
|
@@ -503,7 +499,7 @@ describe('global raw configuration routes', () => {
|
|
|
503
499
|
await fs.rm(configDirectory, { recursive: true, force: true });
|
|
504
500
|
});
|
|
505
501
|
|
|
506
|
-
it('discovers only existing
|
|
502
|
+
it('discovers only existing targets and edits the winning global JSONC document', async () => {
|
|
507
503
|
const app = express();
|
|
508
504
|
app.use(express.json());
|
|
509
505
|
const configDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-global-config-'));
|
|
@@ -516,20 +512,20 @@ describe('global raw configuration routes', () => {
|
|
|
516
512
|
const available = await request(app).get('/api/config/global').expect(200);
|
|
517
513
|
expect(available.body).toEqual({
|
|
518
514
|
targets: [
|
|
519
|
-
{ target: 'opencode', fileName: 'opencode.
|
|
515
|
+
{ target: 'opencode', fileName: 'opencode.jsonc' },
|
|
520
516
|
{ target: 'oh-my-openagent', fileName: 'oh-my-openagent.jsonc' },
|
|
521
517
|
],
|
|
522
518
|
});
|
|
523
519
|
|
|
524
520
|
const openCode = await request(app).get('/api/config/global/opencode').expect(200);
|
|
525
|
-
expect(openCode.body).toMatchObject({ fileName: 'opencode.
|
|
521
|
+
expect(openCode.body).toMatchObject({ fileName: 'opencode.jsonc', content: '{ "model": "jsonc-model" }' });
|
|
526
522
|
|
|
527
523
|
await request(app)
|
|
528
524
|
.put('/api/config/global/opencode')
|
|
529
525
|
.send({ content: '{ "model": "updated-json-model" }' })
|
|
530
526
|
.expect(200);
|
|
531
|
-
expect(await fs.readFile(path.join(configDirectory, 'opencode.json'), 'utf8')).toContain('
|
|
532
|
-
expect(await fs.readFile(path.join(configDirectory, 'opencode.jsonc'), 'utf8')).toContain('
|
|
527
|
+
expect(await fs.readFile(path.join(configDirectory, 'opencode.json'), 'utf8')).toContain('json-model');
|
|
528
|
+
expect(await fs.readFile(path.join(configDirectory, 'opencode.jsonc'), 'utf8')).toContain('updated-json-model');
|
|
533
529
|
|
|
534
530
|
await fs.rm(configDirectory, { recursive: true, force: true });
|
|
535
531
|
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type ConfigObject = Record<string, unknown>;
|
|
2
|
+
export type ConfigDocument = { path: string; scope: 'user' | 'project' | 'custom'; config: ConfigObject };
|
|
3
|
+
export type ConfigLayers = {
|
|
4
|
+
userConfig: ConfigObject; projectConfig: ConfigObject; customConfig: ConfigObject; mergedConfig: ConfigObject;
|
|
5
|
+
userLayers: ConfigDocument[]; projectLayers: ConfigDocument[]; documents: ConfigDocument[];
|
|
6
|
+
paths: { userPath: string; projectPath: string | null; customPath: string | null };
|
|
7
|
+
};
|
|
8
|
+
export function getGlobalConfigDirectory(): string;
|
|
9
|
+
export function isPlainObject(value: unknown): value is ConfigObject;
|
|
10
|
+
export function readConfigFile(filePath?: string | null): ConfigObject;
|
|
11
|
+
export function readConfigLayers(workingDirectory?: string): ConfigLayers;
|
|
12
|
+
export function readConfig(workingDirectory?: string): ConfigObject;
|
|
13
|
+
export function getConfigForPath(layers: ConfigLayers, targetPath?: string | null): ConfigObject;
|
|
14
|
+
export function getJsonEntrySource(layers: ConfigLayers, sectionKey: string, entryName: string): { section: unknown; config: ConfigObject | null; path: string | null; exists: boolean };
|
|
15
|
+
export function getJsonWriteTarget(layers: ConfigLayers, preferredScope: string): { config: ConfigObject; path: string };
|
|
16
|
+
export function writeConfig(config: ConfigObject, filePath?: string): void;
|
|
17
|
+
export function writeConfigText(content: string, filePath: string): void;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
6
|
+
import { parse, parseTree, findNodeAtLocation, createScanner, SyntaxKind, modify, applyEdits } from 'jsonc-parser';
|
|
7
|
+
|
|
8
|
+
export const getGlobalConfigDirectory = () => process.env.OPENCODE_CONFIG_DIR
|
|
9
|
+
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
|
|
10
|
+
: path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'opencode');
|
|
11
|
+
export const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
12
|
+
|
|
13
|
+
function parseConfigObject(content, filePath) {
|
|
14
|
+
const errors = [];
|
|
15
|
+
const value = parse(content.replace(/^\uFEFF/, ''), errors, { allowTrailingComma: true, allowEmptyContent: true });
|
|
16
|
+
if (errors.length || (value !== undefined && !isPlainObject(value))) {
|
|
17
|
+
throw Object.assign(new Error(`Invalid JSONC configuration: ${filePath}`), { code: 'INVALID_JSONC' });
|
|
18
|
+
}
|
|
19
|
+
return value ?? {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readText(filePath) {
|
|
23
|
+
try {
|
|
24
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error.code === 'ENOENT') return null;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readConfigFile(filePath) {
|
|
32
|
+
if (!filePath) return {};
|
|
33
|
+
const text = readText(filePath);
|
|
34
|
+
return text === null ? {} : parseConfigObject(text, filePath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mergeConfigs(base, override) {
|
|
38
|
+
const result = { ...base };
|
|
39
|
+
for (const [key, value] of Object.entries(override)) {
|
|
40
|
+
Object.defineProperty(result, key, {
|
|
41
|
+
value: isPlainObject(result[key]) && isPlainObject(value) ? mergeConfigs(result[key], value) : value,
|
|
42
|
+
enumerable: true, configurable: true, writable: true,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const names = ['opencode.json', 'opencode.jsonc'];
|
|
49
|
+
|
|
50
|
+
export function readConfigLayers(workingDirectory) {
|
|
51
|
+
const directory = getGlobalConfigDirectory();
|
|
52
|
+
const userPaths = names.map((name) => path.join(directory, name));
|
|
53
|
+
const customPath = process.env.OPENCODE_CONFIG ? path.resolve(process.env.OPENCODE_CONFIG) : null;
|
|
54
|
+
const ancestors = [];
|
|
55
|
+
if (workingDirectory && path.resolve(workingDirectory) !== path.resolve(directory)) {
|
|
56
|
+
let current = path.resolve(workingDirectory);
|
|
57
|
+
while (true) {
|
|
58
|
+
ancestors.unshift(current);
|
|
59
|
+
const parent = path.dirname(current);
|
|
60
|
+
if (parent === current) break;
|
|
61
|
+
current = parent;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const projectPaths = [
|
|
65
|
+
...ancestors.flatMap((dir) => names.map((name) => path.join(dir, name))),
|
|
66
|
+
...ancestors.flatMap((dir) => names.map((name) => path.join(dir, '.opencode', name))),
|
|
67
|
+
].filter((filePath) => !userPaths.includes(filePath));
|
|
68
|
+
const load = (paths, scope) => paths.flatMap((filePath) => {
|
|
69
|
+
const text = readText(filePath);
|
|
70
|
+
return text === null ? [] : [{ path: filePath, scope, config: parseConfigObject(text, filePath) }];
|
|
71
|
+
});
|
|
72
|
+
const userLayers = load(userPaths, 'user');
|
|
73
|
+
const projectLayers = load(projectPaths, 'project');
|
|
74
|
+
const customConfig = readConfigFile(customPath);
|
|
75
|
+
const documents = [...userLayers, ...(customPath ? [{ path: customPath, scope: 'custom', config: customConfig }] : []), ...projectLayers];
|
|
76
|
+
const merge = (layers) => layers.reduce((config, layer) => mergeConfigs(config, layer.config), {});
|
|
77
|
+
return {
|
|
78
|
+
userConfig: merge(userLayers), projectConfig: merge(projectLayers), customConfig,
|
|
79
|
+
mergedConfig: merge(documents), userLayers, projectLayers, documents,
|
|
80
|
+
paths: {
|
|
81
|
+
userPath: userLayers.at(-1)?.path ?? path.join(directory, 'opencode.jsonc'),
|
|
82
|
+
projectPath: projectLayers.at(-1)?.path ?? (workingDirectory ? path.join(workingDirectory, '.opencode', 'opencode.jsonc') : null),
|
|
83
|
+
customPath,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const readConfig = (workingDirectory) => readConfigLayers(workingDirectory).mergedConfig;
|
|
89
|
+
|
|
90
|
+
export function getConfigForPath(layers, targetPath) {
|
|
91
|
+
const filePath = targetPath || layers.paths.userPath;
|
|
92
|
+
return layers.documents.findLast((layer) => layer.path === filePath)?.config ?? readConfigFile(filePath);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getJsonEntrySource(layers, sectionKey, entryName) {
|
|
96
|
+
for (const layer of layers.documents.toReversed()) {
|
|
97
|
+
const section = layer.config?.[sectionKey]?.[entryName];
|
|
98
|
+
if (section !== undefined) return { section, config: layer.config, path: layer.path, exists: true };
|
|
99
|
+
}
|
|
100
|
+
return { section: null, config: null, path: null, exists: false };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getJsonWriteTarget(layers, preferredScope) {
|
|
104
|
+
const filePath = preferredScope === 'project' && layers.paths.projectPath
|
|
105
|
+
? layers.paths.projectPath : layers.paths.customPath || layers.paths.userPath;
|
|
106
|
+
return { config: getConfigForPath(layers, filePath), path: filePath };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function removalEdits(text, propertyPath) {
|
|
110
|
+
const node = findNodeAtLocation(parseTree(text), propertyPath)?.parent;
|
|
111
|
+
const parent = node?.parent;
|
|
112
|
+
if (!node || parent?.type !== 'object') throw new Error('Cannot locate configuration property');
|
|
113
|
+
const siblings = parent.children;
|
|
114
|
+
const index = siblings.indexOf(node);
|
|
115
|
+
const end = node.offset + node.length;
|
|
116
|
+
const scanner = createScanner(text, true);
|
|
117
|
+
const comma = (start, stop) => {
|
|
118
|
+
scanner.setPosition(start);
|
|
119
|
+
return scanner.scan() === SyntaxKind.CommaToken && scanner.getTokenOffset() < stop ? scanner.getTokenOffset() : -1;
|
|
120
|
+
};
|
|
121
|
+
let separator = comma(end, siblings[index + 1]?.offset ?? parent.offset + parent.length - 1);
|
|
122
|
+
if (separator < 0) {
|
|
123
|
+
const previous = siblings[index - 1];
|
|
124
|
+
separator = comma(previous ? previous.offset + previous.length : parent.offset + 1, node.offset);
|
|
125
|
+
}
|
|
126
|
+
return [{ offset: node.offset, length: node.length, content: '' }, ...(separator < 0 ? [] : [{ offset: separator, length: 1, content: '' }])];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function editConfig(existing, desired, filePath) {
|
|
130
|
+
const current = parseConfigObject(existing, filePath);
|
|
131
|
+
const bom = existing.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
132
|
+
let text = existing.slice(bom.length);
|
|
133
|
+
if (!parseTree(text)) return `${existing}${existing && !existing.endsWith('\n') ? '\n' : ''}${JSON.stringify(desired, null, 2)}\n`;
|
|
134
|
+
const indentation = text.match(/\n([\t ]+)"/)?.[1] ?? ' ';
|
|
135
|
+
const formattingOptions = { insertSpaces: !indentation.includes('\t'), tabSize: indentation.length, eol: text.includes('\r\n') ? '\r\n' : '\n' };
|
|
136
|
+
const visit = (before, after, keys) => {
|
|
137
|
+
if (isDeepStrictEqual(before, after)) return;
|
|
138
|
+
if (isPlainObject(before) && isPlainObject(after)) {
|
|
139
|
+
for (const key of Object.keys(before)) {
|
|
140
|
+
if (!Object.hasOwn(after, key)) text = applyEdits(text, removalEdits(text, [...keys, key]));
|
|
141
|
+
}
|
|
142
|
+
for (const key of Object.keys(after)) visit(before[key], after[key], [...keys, key]);
|
|
143
|
+
} else {
|
|
144
|
+
text = applyEdits(text, modify(text, keys, after, { formattingOptions }));
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
visit(current, desired, []);
|
|
148
|
+
if (!isDeepStrictEqual(parseConfigObject(text, filePath), desired)) throw new Error('Configuration edit did not round-trip');
|
|
149
|
+
return bom + text;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Shared by structured entity edits and the raw editor; failure never truncates the original.
|
|
153
|
+
export function writeConfigText(content, filePath) {
|
|
154
|
+
parseConfigObject(content, filePath);
|
|
155
|
+
let exists = false;
|
|
156
|
+
try {
|
|
157
|
+
fs.lstatSync(filePath);
|
|
158
|
+
exists = true;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error.code !== 'ENOENT') throw error;
|
|
161
|
+
}
|
|
162
|
+
// Resolve before staging: rename must replace the dotfile target, not its link.
|
|
163
|
+
// lstat distinguishes new files from dangling links, whose realpath must fail.
|
|
164
|
+
const targetPath = exists ? fs.realpathSync(filePath) : filePath;
|
|
165
|
+
const original = readText(targetPath);
|
|
166
|
+
if (original !== null) parseConfigObject(original, filePath);
|
|
167
|
+
if (content === original) return;
|
|
168
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
169
|
+
const temporaryPath = `${targetPath}.${randomUUID()}.tmp`;
|
|
170
|
+
try {
|
|
171
|
+
const mode = original === null ? 0o600 : fs.statSync(targetPath).mode & 0o777;
|
|
172
|
+
fs.writeFileSync(temporaryPath, content, { encoding: 'utf8', flag: 'wx', mode });
|
|
173
|
+
if (original !== null) {
|
|
174
|
+
fs.chmodSync(temporaryPath, mode);
|
|
175
|
+
fs.copyFileSync(targetPath, `${filePath}.openchamber.backup`);
|
|
176
|
+
}
|
|
177
|
+
fs.renameSync(temporaryPath, targetPath);
|
|
178
|
+
} finally {
|
|
179
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function writeConfig(config, filePath = path.join(getGlobalConfigDirectory(), 'opencode.jsonc')) {
|
|
184
|
+
const desired = JSON.parse(JSON.stringify(config));
|
|
185
|
+
if (!isPlainObject(desired)) throw new Error('Configuration must be an object');
|
|
186
|
+
writeConfigText(editConfig(readText(filePath) ?? '', desired, filePath), filePath);
|
|
187
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { readConfigFile, readConfigLayers, getConfigForPath, getJsonEntrySource, getJsonWriteTarget, writeConfig, writeConfigText } from './config-files.js';
|
|
6
|
+
|
|
7
|
+
let root;
|
|
8
|
+
let globalDir;
|
|
9
|
+
const put = (file, text) => {
|
|
10
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
11
|
+
fs.writeFileSync(file, text);
|
|
12
|
+
return file;
|
|
13
|
+
};
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-config-files-'));
|
|
16
|
+
globalDir = path.join(root, 'xdg', 'opencode');
|
|
17
|
+
vi.stubEnv('XDG_CONFIG_HOME', path.dirname(globalDir));
|
|
18
|
+
vi.stubEnv('OPENCODE_CONFIG', '');
|
|
19
|
+
vi.stubEnv('OPENCODE_CONFIG_DIR', '');
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
vi.unstubAllEnvs();
|
|
24
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('physical configuration layers', () => {
|
|
28
|
+
it('resolves an explicit global directory at call time', () => {
|
|
29
|
+
const customDirectory = path.join(root, 'custom-directory');
|
|
30
|
+
put(path.join(customDirectory, 'opencode.jsonc'), '{"model":"custom-directory"}');
|
|
31
|
+
vi.stubEnv('OPENCODE_CONFIG_DIR', customDirectory);
|
|
32
|
+
expect(readConfigLayers().mergedConfig).toEqual({ model: 'custom-directory' });
|
|
33
|
+
expect(getJsonWriteTarget(readConfigLayers(), 'user').path).toBe(path.join(customDirectory, 'opencode.jsonc'));
|
|
34
|
+
});
|
|
35
|
+
it('merges both global documents and keeps each write target unmerged', () => {
|
|
36
|
+
const json = put(path.join(globalDir, 'opencode.json'), '{"model":"base","agent":{"base":{"model":"a"},"shared":{"model":"base","description":"kept"}}}');
|
|
37
|
+
const jsonc = put(path.join(globalDir, 'opencode.jsonc'), '{ // override\n "model":"override","agent":{"shared":{"model":"override"}},}');
|
|
38
|
+
put(path.join(globalDir, 'config.json'), '{"model":"legacy-not-loaded-by-v2"}');
|
|
39
|
+
const layers = readConfigLayers();
|
|
40
|
+
expect(layers.mergedConfig).toEqual({ model: 'override', agent: { base: { model: 'a' }, shared: { model: 'override', description: 'kept' } } });
|
|
41
|
+
expect(getJsonEntrySource(layers, 'agent', 'base').path).toBe(json);
|
|
42
|
+
expect(getJsonEntrySource(layers, 'agent', 'shared').path).toBe(jsonc);
|
|
43
|
+
expect(getConfigForPath(layers, jsonc)).toEqual({ model: 'override', agent: { shared: { model: 'override' } } });
|
|
44
|
+
expect(getJsonWriteTarget(layers, 'user').path).toBe(jsonc);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('loads explicit config above global and below discovered project documents', () => {
|
|
48
|
+
put(path.join(globalDir, 'opencode.json'), '{"model":"global","global":true}');
|
|
49
|
+
const custom = put(path.join(root, 'custom.jsonc'), '{"model":"explicit","explicit":true}');
|
|
50
|
+
vi.stubEnv('OPENCODE_CONFIG', custom);
|
|
51
|
+
const workspace = path.join(root, 'workspace');
|
|
52
|
+
const nested = path.join(workspace, 'nested');
|
|
53
|
+
put(path.join(workspace, 'opencode.json'), '{"model":"parent","parent":true}');
|
|
54
|
+
put(path.join(nested, 'opencode.jsonc'), '{"model":"child"}');
|
|
55
|
+
put(path.join(workspace, '.opencode', 'opencode.json'), '{"model":"supplementary"}');
|
|
56
|
+
const layers = readConfigLayers(nested);
|
|
57
|
+
expect(layers.mergedConfig).toEqual({ model: 'supplementary', global: true, explicit: true, parent: true });
|
|
58
|
+
expect(getJsonWriteTarget(layers, 'user').path).toBe(custom);
|
|
59
|
+
expect(getJsonWriteTarget(layers, 'project').path).toBe(path.join(workspace, '.opencode', 'opencode.json'));
|
|
60
|
+
vi.stubEnv('OPENCODE_CONFIG', '');
|
|
61
|
+
expect(readConfigLayers().mergedConfig).toEqual({ model: 'global', global: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it.each(['opencode.json', 'opencode.jsonc'])('reads a single %s and treats missing, blank and comments-only documents explicitly as empty', (name) => {
|
|
65
|
+
expect(readConfigLayers().mergedConfig).toEqual({});
|
|
66
|
+
const file = put(path.join(globalDir, name), ' \n');
|
|
67
|
+
expect(readConfigLayers().mergedConfig).toEqual({});
|
|
68
|
+
put(file, '// empty config\n/* documented */');
|
|
69
|
+
expect(readConfigLayers().mergedConfig).toEqual({});
|
|
70
|
+
put(file, '{"model":"one"}');
|
|
71
|
+
expect(readConfigLayers().mergedConfig).toEqual({ model: 'one' });
|
|
72
|
+
expect(getJsonWriteTarget(readConfigLayers(), 'user').path).toBe(file);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it.each(['{"model":', '{"model":"x", broken}', '[]', 'null', 'plain text'])('rejects malformed/non-object content %s without changing it', (text) => {
|
|
76
|
+
const file = put(path.join(globalDir, 'opencode.jsonc'), text);
|
|
77
|
+
expect(() => readConfigLayers()).toThrow('Invalid JSONC');
|
|
78
|
+
expect(() => writeConfig({ model: 'new' }, file)).toThrow('Invalid JSONC');
|
|
79
|
+
expect(() => writeConfigText('{}', file)).toThrow('Invalid JSONC');
|
|
80
|
+
expect(fs.readFileSync(file, 'utf8')).toBe(text);
|
|
81
|
+
expect(fs.existsSync(`${file}.openchamber.backup`)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('propagates read failures instead of returning empty success', () => {
|
|
85
|
+
const original = fs.readFileSync;
|
|
86
|
+
vi.spyOn(fs, 'readFileSync').mockImplementation((file, ...args) => {
|
|
87
|
+
if (String(file) === path.join(globalDir, 'opencode.json')) throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
|
88
|
+
return original(file, ...args);
|
|
89
|
+
});
|
|
90
|
+
expect(() => readConfigLayers()).toThrow('denied');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('structural JSONC writes', () => {
|
|
95
|
+
it.each(['absolute', 'relative'])('preserves a %s dotfiles symlink while updating its target, permissions and backup', (kind) => {
|
|
96
|
+
const original = '{ // dotfiles comment\n "model":"old"\n}';
|
|
97
|
+
const target = put(path.join(root, 'dotfiles', 'config.jsonc'), original);
|
|
98
|
+
fs.chmodSync(target, 0o664);
|
|
99
|
+
fs.mkdirSync(globalDir, { recursive: true });
|
|
100
|
+
const file = path.join(globalDir, 'opencode.jsonc');
|
|
101
|
+
const link = kind === 'relative' ? path.relative(globalDir, target) : target;
|
|
102
|
+
fs.symlinkSync(link, file);
|
|
103
|
+
|
|
104
|
+
writeConfig({ model: 'new' }, file);
|
|
105
|
+
expect(fs.lstatSync(file).isSymbolicLink()).toBe(true);
|
|
106
|
+
expect(fs.readlinkSync(file)).toBe(link);
|
|
107
|
+
expect(readConfigFile(target)).toEqual({ model: 'new' });
|
|
108
|
+
expect(fs.readFileSync(target, 'utf8')).toContain('// dotfiles comment');
|
|
109
|
+
expect(fs.statSync(target).mode & 0o777).toBe(0o664);
|
|
110
|
+
expect(fs.readFileSync(`${file}.openchamber.backup`, 'utf8')).toBe(original);
|
|
111
|
+
expect(fs.existsSync(`${target}.openchamber.backup`)).toBe(false);
|
|
112
|
+
|
|
113
|
+
const structured = fs.readFileSync(target, 'utf8');
|
|
114
|
+
writeConfigText('{"model":"raw"}', file);
|
|
115
|
+
expect(fs.readlinkSync(file)).toBe(link);
|
|
116
|
+
expect(readConfigFile(target)).toEqual({ model: 'raw' });
|
|
117
|
+
expect(fs.statSync(target).mode & 0o777).toBe(0o664);
|
|
118
|
+
expect(fs.readFileSync(`${file}.openchamber.backup`, 'utf8')).toBe(structured);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it.each(['writeFileSync', 'chmodSync', 'copyFileSync', 'renameSync'])('preserves a symlink and its original target when %s fails', (method) => {
|
|
122
|
+
const original = '{ // retained\n "model":"old"\n}';
|
|
123
|
+
const target = put(path.join(root, 'dotfiles', 'config.jsonc'), original);
|
|
124
|
+
fs.chmodSync(target, 0o640);
|
|
125
|
+
fs.mkdirSync(globalDir, { recursive: true });
|
|
126
|
+
const file = path.join(globalDir, 'opencode.jsonc');
|
|
127
|
+
const link = path.relative(globalDir, target);
|
|
128
|
+
fs.symlinkSync(link, file);
|
|
129
|
+
vi.spyOn(fs, method).mockImplementation(() => { throw new Error('simulated IO failure'); });
|
|
130
|
+
expect(() => writeConfig({ model: 'new' }, file)).toThrow('simulated IO failure');
|
|
131
|
+
expect(fs.readlinkSync(file)).toBe(link);
|
|
132
|
+
expect(fs.readFileSync(target, 'utf8')).toBe(original);
|
|
133
|
+
expect(fs.statSync(target).mode & 0o777).toBe(0o640);
|
|
134
|
+
for (const dir of [globalDir, path.dirname(target)]) {
|
|
135
|
+
expect(fs.readdirSync(dir).some((name) => name.endsWith('.tmp'))).toBe(false);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it.each(['structured', 'raw'])('rejects a dangling symlink during a %s write without replacing it', (kind) => {
|
|
140
|
+
fs.mkdirSync(globalDir, { recursive: true });
|
|
141
|
+
const file = path.join(globalDir, 'opencode.jsonc');
|
|
142
|
+
const link = '../../dotfiles/missing.jsonc';
|
|
143
|
+
fs.symlinkSync(link, file);
|
|
144
|
+
const write = () => kind === 'structured' ? writeConfig({ model: 'new' }, file) : writeConfigText('{}', file);
|
|
145
|
+
expect(write).toThrow(expect.objectContaining({ code: 'ENOENT' }));
|
|
146
|
+
expect(fs.readlinkSync(file)).toBe(link);
|
|
147
|
+
expect(fs.existsSync(path.resolve(globalDir, link))).toBe(false);
|
|
148
|
+
expect(fs.readdirSync(globalDir)).toEqual(['opencode.jsonc']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('edits nested values, adds fields and removes fields without losing surrounding comments or unrelated bytes', () => {
|
|
152
|
+
const original = '\uFEFF{\r\n\t// model comment\r\n\t"model": "old",\r\n\t"agent": {"review": {/* owned */ "model":"old", "remove":true, /* trailing */}},\r\n\t// unrelated field\r\n\t"unrelated" : [ 1, 2 ], // leave alone\r\n}\r\n';
|
|
153
|
+
const file = put(path.join(globalDir, 'opencode.jsonc'), original);
|
|
154
|
+
const config = readConfigFile(file);
|
|
155
|
+
config.model = 'new';
|
|
156
|
+
config.agent.review.model = 'new';
|
|
157
|
+
delete config.agent.review.remove;
|
|
158
|
+
config.agent.review.description = 'added';
|
|
159
|
+
writeConfig(config, file);
|
|
160
|
+
const saved = fs.readFileSync(file, 'utf8');
|
|
161
|
+
expect(readConfigFile(file)).toEqual(config);
|
|
162
|
+
for (const comment of ['// model comment', '/* owned */', '/* trailing */', '// unrelated field', '// leave alone']) expect(saved).toContain(comment);
|
|
163
|
+
expect(saved).toContain('\t"unrelated" : [ 1, 2 ], // leave alone\r\n');
|
|
164
|
+
expect(saved.startsWith('\uFEFF')).toBe(true);
|
|
165
|
+
expect(fs.readFileSync(`${file}.openchamber.backup`, 'utf8')).toBe(original);
|
|
166
|
+
writeConfig(config, file);
|
|
167
|
+
expect(fs.readFileSync(file, 'utf8')).toBe(saved);
|
|
168
|
+
expect(fs.readFileSync(`${file}.openchamber.backup`, 'utf8')).toBe(original);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it.each([
|
|
172
|
+
'{/* before */ "only":1, /* after , */}',
|
|
173
|
+
'{"keep":2, /* before */ "only":1 /* after */}',
|
|
174
|
+
'{/* before */ "only":1, /* after */ "keep":2}',
|
|
175
|
+
])('removes a property and its comma from %s', (text) => {
|
|
176
|
+
const file = put(path.join(globalDir, 'opencode.jsonc'), text);
|
|
177
|
+
const desired = readConfigFile(file);
|
|
178
|
+
delete desired.only;
|
|
179
|
+
writeConfig(desired, file);
|
|
180
|
+
expect(readConfigFile(file)).toEqual(desired);
|
|
181
|
+
expect(fs.readFileSync(file, 'utf8')).toContain('/* before */');
|
|
182
|
+
expect(fs.readFileSync(file, 'utf8')).toContain('/* after');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it.each(['', '// notes without newline', '{"old":1}'])('supports new/empty/comment-only/JSON files (%s)', (text) => {
|
|
186
|
+
const file = path.join(globalDir, 'opencode.json');
|
|
187
|
+
if (text) put(file, text);
|
|
188
|
+
writeConfig({ model: 'new', nested: { enabled: true } }, file);
|
|
189
|
+
expect(readConfigFile(file)).toEqual({ model: 'new', nested: { enabled: true } });
|
|
190
|
+
if (text.startsWith('//')) expect(fs.readFileSync(file, 'utf8')).toContain(text);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it.each(['writeFileSync', 'copyFileSync', 'renameSync'])('keeps original content and cleans temporary files when %s fails', (method) => {
|
|
194
|
+
const original = '{ // retained\n "model":"old"\n}';
|
|
195
|
+
const file = put(path.join(globalDir, 'opencode.jsonc'), original);
|
|
196
|
+
vi.spyOn(fs, method).mockImplementation(() => { throw new Error('simulated IO failure'); });
|
|
197
|
+
expect(() => writeConfig({ model: 'new' }, file)).toThrow('simulated IO failure');
|
|
198
|
+
expect(fs.readFileSync(file, 'utf8')).toBe(original);
|
|
199
|
+
expect(fs.readdirSync(globalDir).some((name) => name.endsWith('.tmp'))).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -1179,7 +1179,6 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
|
|
|
1179
1179
|
req.path.startsWith('/api/config/commands') ||
|
|
1180
1180
|
req.path.startsWith('/api/config/global') ||
|
|
1181
1181
|
req.path.startsWith('/api/config/mcp') ||
|
|
1182
|
-
req.path.startsWith('/api/config/snippets') ||
|
|
1183
1182
|
req.path.startsWith('/api/config/settings') ||
|
|
1184
1183
|
req.path.startsWith('/api/config/skills') ||
|
|
1185
1184
|
req.path.startsWith('/api/config/plugins') ||
|
|
@@ -310,21 +310,6 @@ describe('core-routes', () => {
|
|
|
310
310
|
expect(dependencies.gracefulShutdown).not.toHaveBeenCalled();
|
|
311
311
|
});
|
|
312
312
|
|
|
313
|
-
it('should parse JSON bodies for snippet config routes', async () => {
|
|
314
|
-
const app = express();
|
|
315
|
-
registerCommonRequestMiddleware(app, { express });
|
|
316
|
-
app.post('/api/config/snippets/example', (req, res) => {
|
|
317
|
-
res.json({ body: req.body });
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
const response = await request(app)
|
|
321
|
-
.post('/api/config/snippets/example')
|
|
322
|
-
.send({ content: 'Snippet body' })
|
|
323
|
-
.expect(200);
|
|
324
|
-
|
|
325
|
-
expect(response.body).toEqual({ body: { content: 'Snippet body' } });
|
|
326
|
-
});
|
|
327
|
-
|
|
328
313
|
// Global raw config saves send { content: "<jsonc string>" }. Without this
|
|
329
314
|
// prefix in the JSON middleware allowlist, PUT arrives with an empty body and
|
|
330
315
|
// the handler rejects with "Configuration content must be a string".
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function isSensitiveCredentialResponse(method: string, requestPath: string): boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Credential list/create replies contain raw keys in v2. Keep them host-only. */
|
|
2
|
+
export function isSensitiveCredentialResponse(method, requestPath) {
|
|
3
|
+
let pathname;
|
|
4
|
+
try {
|
|
5
|
+
pathname = decodeURIComponent(new URL(requestPath, 'http://openchamber.invalid').pathname).replace(/\/+$/, '').toLowerCase();
|
|
6
|
+
} catch {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
const root = pathname === '/credential' || pathname === '/api/credential';
|
|
10
|
+
return root && ['GET', 'HEAD', 'POST'].includes(String(method).toUpperCase());
|
|
11
|
+
}
|
|
@@ -233,6 +233,9 @@ export async function ensurePinnedOpenCode2Cli(input = {}) {
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
const installedPath = await install({ ...input, version: pin });
|
|
236
|
-
const version = readVersion(installedPath)
|
|
236
|
+
const version = readVersion(installedPath);
|
|
237
|
+
if (version !== pin) {
|
|
238
|
+
throw new Error(`Installed OpenCode version mismatch: expected ${pin}, got ${version || 'unknown'}`);
|
|
239
|
+
}
|
|
237
240
|
return { path: installedPath, version, source: 'installed', installed: true };
|
|
238
241
|
}
|
|
@@ -33,7 +33,10 @@ export const resolveOpenCodeEnvConfig = (options = {}) => {
|
|
|
33
33
|
warnInvalidHost(`must use http or https scheme (got ${JSON.stringify(url.protocol)})`);
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
36
|
-
|
|
36
|
+
// WHATWG URL removes explicit default ports; inspect the authority before normalization.
|
|
37
|
+
const authority = raw.match(/^https?:\/\/([^/?#]+)/i)?.[1] ?? '';
|
|
38
|
+
const explicitPort = authority.match(/:(\d+)$/)?.[1];
|
|
39
|
+
const port = parseInt(url.port || explicitPort || '', 10);
|
|
37
40
|
if (!Number.isFinite(port) || port <= 0) {
|
|
38
41
|
warnInvalidHost('must include an explicit port (example: http://hostname:4096)');
|
|
39
42
|
return null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { resolveOpenCodeEnvConfig } from './env-config.js';
|
|
3
|
+
|
|
4
|
+
describe('explicit OpenCode host ports', () => {
|
|
5
|
+
it.each([
|
|
6
|
+
['http://example.test:80', 'http://example.test', 80],
|
|
7
|
+
['https://example.test:443/', 'https://example.test', 443],
|
|
8
|
+
['http://[::1]:80', 'http://[::1]', 80],
|
|
9
|
+
['https://[::1]:443', 'https://[::1]', 443],
|
|
10
|
+
['http://[::1]:4096', 'http://[::1]:4096', 4096],
|
|
11
|
+
['https://example.test:80', 'https://example.test:80', 80],
|
|
12
|
+
])('preserves the effective port of %s', (host, origin, port) => {
|
|
13
|
+
const config = resolveOpenCodeEnvConfig({ env: { OPENCODE_HOST: host, OPENCODE_PORT: '1234' } });
|
|
14
|
+
expect(config.configuredOpenCodeHost).toEqual({ origin, port });
|
|
15
|
+
expect(config.effectivePort).toBe(port);
|
|
16
|
+
expect(new URL('/api/info', config.configuredOpenCodeHost.origin).href).toBe(`${origin}/api/info`);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it.each(['http://example.test', 'https://example.test', 'http://[::1]', 'http://example.test:',
|
|
20
|
+
'ftp://example.test:80', 'http://example.test:80/path', 'http://example.test:80?q=1',
|
|
21
|
+
'http://example.test:80#hash', 'http://example.test:0', 'http://example.test:65536', 'invalid'])('retains rejection of %s', (host) => {
|
|
22
|
+
const logger = { warn: vi.fn() };
|
|
23
|
+
const config = resolveOpenCodeEnvConfig({ env: { OPENCODE_HOST: host, OPENCODE_PORT: '1234' }, logger });
|
|
24
|
+
expect(config.configuredOpenCodeHost).toBeNull();
|
|
25
|
+
expect(config.effectivePort).toBe(1234);
|
|
26
|
+
expect(logger.warn).toHaveBeenCalledOnce();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -3,6 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
5
|
import { createOpenCodeEnvRuntime } from './env-runtime.js';
|
|
6
|
+
import { PINNED_OPENCODE2_VERSION } from './opencode2-pin.js';
|
|
6
7
|
|
|
7
8
|
const originalOpencodeBinary = process.env.OPENCODE_BINARY;
|
|
8
9
|
const originalComSpec = process.env.ComSpec;
|
|
@@ -305,7 +306,7 @@ describe('OpenCode env runtime', () => {
|
|
|
305
306
|
|
|
306
307
|
it('discovers a previously installed pin from the OpenChamber data dir', () => {
|
|
307
308
|
const dataDir = createTempDir('openchamber-installed-cli-');
|
|
308
|
-
const installedDir = path.join(dataDir, 'opencode-cli',
|
|
309
|
+
const installedDir = path.join(dataDir, 'opencode-cli', PINNED_OPENCODE2_VERSION);
|
|
309
310
|
const installedBinary = path.join(installedDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
|
|
310
311
|
fs.mkdirSync(installedDir, { recursive: true });
|
|
311
312
|
fs.writeFileSync(installedBinary, '#!/bin/sh\nexit 0\n');
|
|
@@ -5,6 +5,8 @@ import { registerSessionGoalRoutes } from '../session-goal/routes.js';
|
|
|
5
5
|
import { registerSessionMetadataRoutes } from '../session-metadata/routes.js';
|
|
6
6
|
import { registerGitHubRoutes } from '../github/routes.js';
|
|
7
7
|
import { registerGitRoutes } from '../git/routes.js';
|
|
8
|
+
import { makeOpenCodeV2Client } from './v2-client.js';
|
|
9
|
+
import { releaseDeletedWorktreeLocation } from './location-release.js';
|
|
8
10
|
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
|
|
9
11
|
import { registerSessionFoldersRoutes } from '../session-folders/routes.js';
|
|
10
12
|
import { registerPermissionAutoAcceptRoutes } from '../permission-auto-accept/runtime.js';
|
|
@@ -31,7 +33,6 @@ import { getProviderSources, removeProviderConfig } from './providers.js';
|
|
|
31
33
|
import { getAgentSources, getAgentConfig, listDisabledAgentOverrides, createAgent, updateAgent, deleteAgent } from './agents.js';
|
|
32
34
|
import { getCommandSources, createCommand, updateCommand, deleteCommand } from './commands.js';
|
|
33
35
|
import { listMcpConfigs, getMcpConfig, createMcpConfig, updateMcpConfig, deleteMcpConfig } from './mcp.js';
|
|
34
|
-
import { listSnippets, getSnippet, createSnippet, updateSnippet, deleteSnippet, expandSnippets } from './snippets.js';
|
|
35
36
|
import {
|
|
36
37
|
listPluginEntries,
|
|
37
38
|
getPluginEntry,
|
|
@@ -359,12 +360,6 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
359
360
|
createMcpConfig,
|
|
360
361
|
updateMcpConfig,
|
|
361
362
|
deleteMcpConfig,
|
|
362
|
-
listSnippets,
|
|
363
|
-
getSnippet,
|
|
364
|
-
createSnippet,
|
|
365
|
-
updateSnippet,
|
|
366
|
-
deleteSnippet,
|
|
367
|
-
expandSnippets,
|
|
368
363
|
});
|
|
369
364
|
|
|
370
365
|
registerPluginRoutes(app, {
|
|
@@ -492,6 +487,13 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
492
487
|
registerGitRoutes(app, {
|
|
493
488
|
messageQueueService,
|
|
494
489
|
broadcastWorktreeTopologyChanged,
|
|
490
|
+
releaseWorktreeLocation: (directory) => releaseDeletedWorktreeLocation({
|
|
491
|
+
directory,
|
|
492
|
+
getClient: () => makeOpenCodeV2Client({
|
|
493
|
+
baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
|
|
494
|
+
authHeaders: getOpenCodeAuthHeaders(),
|
|
495
|
+
}),
|
|
496
|
+
}),
|
|
495
497
|
});
|
|
496
498
|
registerMagicPromptRoutes(app, {
|
|
497
499
|
fsPromises,
|
|
@@ -35,9 +35,9 @@ export const createHmrStateRuntime = (dependencies) => {
|
|
|
35
35
|
if (typeof hmrState.userProvidedOpenCodePassword !== 'undefined') {
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
|
-
const initialPassword =
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
const initialPassword = processLike.env.OPENCODE_PASSWORD?.trim()
|
|
39
|
+
|| processLike.env.OPENCODE_SERVER_PASSWORD?.trim()
|
|
40
|
+
|| '';
|
|
41
41
|
hmrState.userProvidedOpenCodePassword = initialPassword || null;
|
|
42
42
|
};
|
|
43
43
|
|
|
@@ -51,7 +51,7 @@ export const createHmrStateRuntime = (dependencies) => {
|
|
|
51
51
|
openCodeAuthPassword:
|
|
52
52
|
typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0
|
|
53
53
|
? hmrState.openCodeAuthPassword
|
|
54
|
-
: userProvidedOpenCodePassword,
|
|
54
|
+
: hmrState.openCodeAuthSource === 'shared-service' ? null : userProvidedOpenCodePassword,
|
|
55
55
|
openCodeAuthSource:
|
|
56
56
|
typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0
|
|
57
57
|
? hmrState.openCodeAuthSource
|