@vantran-se/openchamber-web 1.22.1 → 1.22.2
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/README.md +2 -3
- package/bin/cli.js +4 -1
- package/bin/cli.test.js +92 -0
- package/bin/lib/cli-startup.js +49 -0
- package/bin/lib/commands-startup.js +41 -6
- package/dist/assets/{CodeMirrorCommentWidgets-C53Q7pbX.js → CodeMirrorCommentWidgets-CV1HNV5l.js} +1 -1
- package/dist/assets/{DiffView-Dh1ey2x9.js → DiffView-DkkSa30n.js} +3 -3
- package/dist/assets/DirtyBranchSwitchDialog-DW4WaXPc.js +1 -0
- package/dist/assets/FilesView-DDu8zDy5.js +17 -0
- package/dist/assets/GitView-1Ixo8z-M.js +6 -0
- package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-DvCLL7dT.js} +1 -1
- package/dist/assets/LinearIssuesView-Bs1rZrF2.js +1 -0
- package/dist/assets/{MarkdownImageGallery-bA0PS-tn.js → MarkdownImageGallery-DyJUc6h9.js} +1 -1
- package/dist/assets/MarkdownRendererImpl-Bdm8vMkS.js +12 -0
- package/dist/assets/{OnboardingScreen-D3kYJIb6.js → OnboardingScreen-Dg66B6oB.js} +1 -1
- package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-CAduNjfZ.js} +1 -1
- package/dist/assets/{PierreDiffViewer-Ct2A13NO.js → PierreDiffViewer-CTIb1GYD.js} +2 -2
- package/dist/assets/PlanView-trDaszfq.js +5 -0
- package/dist/assets/SettingsView-DStMfBmy.js +28 -0
- package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DQQFnrSZ.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-SjY2XZlA.js} +6 -6
- package/dist/assets/ToolPartDiffPreview-DJdzUQPE.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-Iehm4iMH.js} +1 -1
- package/dist/assets/{WalkthroughView-AX-u2pFg.js → WalkthroughView-BALp2d9O.js} +2 -2
- package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-BKZA43vN.js} +2 -2
- package/dist/assets/{contextPanelEmbeddedChat-C9EcRSDF.js → contextPanelEmbeddedChat-D2PM_pEv.js} +1 -1
- package/dist/assets/{de-DqVn5vme.js → de-DlSNLAcy.js} +6 -5
- package/dist/assets/{es-hj43Z53i.js → es-D1GbowZ3.js} +6 -5
- package/dist/assets/{fr-BmkZt2mF.js → fr-BPOLXIAY.js} +6 -5
- package/dist/assets/{index-DmhujXbT.js → index-Ce0LR32V.js} +1 -1
- package/dist/assets/index-D42fPvt4.css +1 -0
- package/dist/assets/index-DlFbCkEy.js +39 -0
- package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-DaB2u_Kv.js} +1 -1
- package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-yJZa2IAC.js} +1 -1
- package/dist/assets/{ja-DioMIEGF.js → ja-Dm2-m9io.js} +7 -6
- package/dist/assets/{ko-BqPgaQJb.js → ko-CIXlzQ5-.js} +7 -6
- package/dist/assets/main-B7qTsd_w.js +930 -0
- package/dist/assets/{main-B2a_JfKZ.js → main-CyQrD9dG.js} +2 -2
- package/dist/assets/{markdownCore-CWRCqSH2.js → markdownCore-CeEwGgEK.js} +2 -2
- package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-7cPEx4sV.js} +2 -2
- package/dist/assets/{mobile-QNcFIVCW.js → mobile-QtzkPTM8.js} +2 -2
- package/dist/assets/{patchFileDiff-BpMQZnYu.js → patchFileDiff-No55fDiD.js} +1 -1
- package/dist/assets/pl-B2zmkOpw.js +16 -0
- package/dist/assets/{pt-BR-BqH8fuCH.js → pt-BR-C-JE5H6y.js} +6 -5
- package/dist/assets/{renderElectronMiniChatApp-D77bmLqo.js → renderElectronMiniChatApp-ePTkBJNS.js} +2 -2
- package/dist/assets/renderMobileApp-D9RjB9DX.js +25 -0
- package/dist/assets/runtimeEndpointReset-DfJIWwwo.js +17 -0
- package/dist/assets/{scheduledTasksApi-BSRCtP6T.js → scheduledTasksApi-DuhdyYEa.js} +1 -1
- package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-EHEg58-n.js} +1 -1
- package/dist/assets/tr-MJmRIzEY.js +7 -0
- package/dist/assets/{uk-BkkfxbOs.js → uk-BmD7eM8k.js} +6 -5
- package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-CuJPKtI1.js} +151 -180
- package/dist/assets/useInlineCommentController-DD7_7OP_.js +2 -0
- package/dist/assets/{useRootScrollLock-C0N9mCwz.js → useRootScrollLock-DRGAe8PN.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-9ZzoH0o0.js} +2 -2
- package/dist/assets/vendor-zod-CK21ihK-.js +39 -0
- package/dist/assets/{web-components-BNMwa0qm.js → web-components-PttKBrhi.js} +1 -1
- package/dist/assets/{zh-CN-C6FMdVgv.js → zh-CN-JJHl4ehT.js} +6 -5
- package/dist/assets/{zh-TW-CdcMjDxv.js → zh-TW-DPc4DEEW.js} +6 -5
- package/dist/index.html +5 -5
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +8 -4
- package/server/index.js +38 -5
- package/server/lib/agent-tool/runtime.js +2 -19
- package/server/lib/changelog/update-notes.js +82 -0
- package/server/lib/changelog/update-notes.test.js +59 -0
- package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
- package/server/lib/dev-tunnel/runtime.js +4 -4
- package/server/lib/dev-tunnel/tunnel.test.js +20 -0
- package/server/lib/fs/DOCUMENTATION.md +2 -0
- package/server/lib/fs/routes.js +32 -21
- package/server/lib/fs/routes.test.js +77 -0
- package/server/lib/git/DOCUMENTATION.md +3 -1
- package/server/lib/git/routes.js +16 -0
- package/server/lib/git/service.js +107 -17
- package/server/lib/git/service.test.js +171 -0
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
- package/server/lib/mcp-reconnect/runtime.js +122 -0
- package/server/lib/mcp-reconnect/runtime.test.js +140 -0
- package/server/lib/message-queue/DOCUMENTATION.md +158 -0
- package/server/lib/message-queue/runtime.js +870 -0
- package/server/lib/message-queue/runtime.test.js +482 -0
- package/server/lib/openchamber-sessions/routes.js +102 -0
- package/server/lib/openchamber-sessions/routes.test.js +88 -0
- package/server/lib/opencode/DOCUMENTATION.md +13 -5
- package/server/lib/opencode/config-paths.test.js +116 -0
- package/server/lib/opencode/core-routes.js +1 -0
- package/server/lib/opencode/feature-routes-runtime.js +5 -0
- package/server/lib/opencode/input-history-scope.js +14 -0
- package/server/lib/opencode/managed-plugin-config.js +35 -0
- package/server/lib/opencode/openchamber-routes.test.js +2 -2
- package/server/lib/opencode/plugins.js +2 -2
- package/server/lib/opencode/providers.js +44 -2
- package/server/lib/opencode/providers.test.js +131 -4
- package/server/lib/opencode/routes.js +4 -4
- package/server/lib/opencode/server-startup-runtime.js +1 -1
- package/server/lib/opencode/settings-helpers.js +28 -0
- package/server/lib/opencode/settings-helpers.test.js +177 -0
- package/server/lib/opencode/settings-runtime.js +2 -2
- package/server/lib/opencode/settings-runtime.test.js +29 -0
- package/server/lib/opencode/shared.js +4 -1
- package/server/lib/opencode/shutdown-runtime.js +2 -0
- package/server/lib/opencode/skill-routes.js +2 -1
- package/server/lib/opencode/snippets.js +1 -2
- package/server/lib/package-manager.js +4 -29
- package/server/lib/package-manager.test.js +2 -2
- package/server/lib/quota/DOCUMENTATION.md +2 -1
- package/server/lib/quota/credentials/providers.js +4 -0
- package/server/lib/quota/credentials/store.js +1 -1
- package/server/lib/quota/credentials/store.test.js +4 -4
- package/server/lib/quota/providers/exe-dev.js +75 -0
- package/server/lib/quota/providers/exe-dev.test.js +47 -0
- package/server/lib/quota/providers/index.js +7 -0
- package/server/lib/quota/providers/opencode-go.js +1 -0
- package/server/lib/quota/providers/opencode-go.test.js +5 -1
- package/server/lib/quota/routes.js +2 -0
- package/server/lib/quota/utils/auth.js +1 -1
- package/server/lib/relay/DOCUMENTATION.md +1 -1
- package/server/lib/relay/host-client.test.js +9 -1
- package/server/lib/relay/tunnel-host.js +3 -1
- package/server/lib/relay/tunnel-host.test.js +9 -1
- package/server/lib/security/request-security.js +21 -6
- package/server/lib/security/request-security.test.js +42 -8
- package/server/lib/session-assist/runtime.js +1 -0
- package/server/lib/session-goal/DOCUMENTATION.md +20 -7
- package/server/lib/session-goal/create.js +3 -1
- package/server/lib/session-goal/runtime.js +77 -11
- package/server/lib/session-goal/runtime.test.js +426 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
- package/server/lib/skills-catalog/install.js +3 -2
- package/server/lib/small-model/DOCUMENTATION.md +4 -0
- package/server/lib/small-model/call.js +8 -2
- package/server/lib/small-model/call.test.js +23 -0
- package/server/lib/small-model/index.js +2 -1
- package/server/lib/small-model/routes.js +2 -1
- package/server/lib/system-prompt/runtime.js +2 -21
- package/server/lib/terminal/DOCUMENTATION.md +11 -7
- package/server/lib/terminal/runtime.js +180 -26
- package/server/lib/terminal/runtime.test.js +553 -26
- package/server/lib/terminal/shells.js +17 -0
- package/server/lib/terminal/shells.test.js +32 -1
- package/server/lib/ui-auth/ui-auth.js +4 -1
- package/server/lib/ui-auth/ui-auth.test.js +16 -0
- package/server/lib/walkthrough/index.js +1 -0
- package/server/lib/walkthrough/routes.test.js +26 -5
- package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
- package/dist/assets/FilesView-3ZYP72ki.js +0 -17
- package/dist/assets/GitView-CMuN7z2Z.js +0 -6
- package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
- package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
- package/dist/assets/PlanView-6AUjzNQn.js +0 -5
- package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
- package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
- package/dist/assets/index-L_ncV-6H.css +0 -1
- package/dist/assets/index-qES19dlJ.js +0 -7
- package/dist/assets/main-CYgSu6dx.js +0 -928
- package/dist/assets/pl-slULBF4R.js +0 -15
- package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
- package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
- package/dist/assets/tr-CjEbw1Aw.js +0 -6
- package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
- package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { createProjectIdFromPath } from '../projects/project-id.js';
|
|
3
3
|
import fs from 'fs';
|
|
4
|
-
import os from 'os';
|
|
5
4
|
import path from 'path';
|
|
6
5
|
import {
|
|
7
6
|
buildDeferredRestartResponse,
|
|
8
7
|
} from './config-mutation-response.js';
|
|
9
8
|
import { getClaudeCliAuthStatus } from './claude-cli-auth.js';
|
|
9
|
+
import { OPENCODE_CONFIG_DIR } from './shared.js';
|
|
10
10
|
|
|
11
11
|
export const registerOpenCodeRoutes = (app, dependencies) => {
|
|
12
12
|
const {
|
|
@@ -807,7 +807,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
|
|
|
807
807
|
});
|
|
808
808
|
|
|
809
809
|
// Behavior / Global AGENTS.md endpoints
|
|
810
|
-
const AGENTS_MD_PATH = path.join(
|
|
810
|
+
const AGENTS_MD_PATH = path.join(OPENCODE_CONFIG_DIR, 'AGENTS.md');
|
|
811
811
|
const MAX_BEHAVIOR_PROMPT_SIZE = 1024 * 1024; // 1 MB
|
|
812
812
|
|
|
813
813
|
app.get('/api/behavior/agents-md', async (_req, res) => {
|
|
@@ -815,10 +815,10 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
|
|
|
815
815
|
try {
|
|
816
816
|
await fs.promises.access(AGENTS_MD_PATH);
|
|
817
817
|
} catch {
|
|
818
|
-
return res.json({ content: '', exists: false });
|
|
818
|
+
return res.json({ content: '', exists: false, path: AGENTS_MD_PATH });
|
|
819
819
|
}
|
|
820
820
|
const content = await fs.promises.readFile(AGENTS_MD_PATH, 'utf8');
|
|
821
|
-
return res.json({ content, exists: true });
|
|
821
|
+
return res.json({ content, exists: true, path: AGENTS_MD_PATH });
|
|
822
822
|
} catch (error) {
|
|
823
823
|
console.error('Failed to read AGENTS.md:', error);
|
|
824
824
|
return res.status(500).json({ error: 'Failed to read AGENTS.md' });
|
|
@@ -138,7 +138,7 @@ export const createServerStartupRuntime = (dependencies) => {
|
|
|
138
138
|
// Cover every signal a shell or dev harness may use to stop/restart us, so
|
|
139
139
|
// the managed OpenCode child is always torn down gracefully instead of
|
|
140
140
|
// orphaned: SIGINT/SIGQUIT (Ctrl+C/Ctrl+\), SIGTERM (kill/default), SIGHUP
|
|
141
|
-
// (terminal close), SIGUSR2 (nodemon restart
|
|
141
|
+
// (terminal close), SIGUSR2 (nodemon restart outside Windows).
|
|
142
142
|
process.on('SIGTERM', handleSignal);
|
|
143
143
|
process.on('SIGINT', handleSignal);
|
|
144
144
|
process.on('SIGQUIT', handleSignal);
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { isAgentMemoryFeatureAvailable } from '../agent-memory/feature-flag.js';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_INPUT_HISTORY_LIMIT,
|
|
4
|
+
DEFAULT_INPUT_HISTORY_SCOPE,
|
|
5
|
+
isInputHistoryLimit,
|
|
6
|
+
isInputHistoryScope,
|
|
7
|
+
} from './input-history-scope.js';
|
|
2
8
|
|
|
3
9
|
export const createSettingsHelpers = (dependencies) => {
|
|
4
10
|
const {
|
|
@@ -140,6 +146,12 @@ export const createSettingsHelpers = (dependencies) => {
|
|
|
140
146
|
if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) {
|
|
141
147
|
result.themeVariant = candidate.themeVariant;
|
|
142
148
|
}
|
|
149
|
+
if (typeof candidate.inputHistoryScope === 'string' && isInputHistoryScope(candidate.inputHistoryScope)) {
|
|
150
|
+
result.inputHistoryScope = candidate.inputHistoryScope;
|
|
151
|
+
}
|
|
152
|
+
if (isInputHistoryLimit(candidate.inputHistoryLimit)) {
|
|
153
|
+
result.inputHistoryLimit = candidate.inputHistoryLimit;
|
|
154
|
+
}
|
|
143
155
|
if (typeof candidate.useSystemTheme === 'boolean') {
|
|
144
156
|
result.useSystemTheme = candidate.useSystemTheme;
|
|
145
157
|
}
|
|
@@ -519,6 +531,12 @@ export const createSettingsHelpers = (dependencies) => {
|
|
|
519
531
|
if (typeof candidate.inputSpellcheckEnabled === 'boolean') {
|
|
520
532
|
result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled;
|
|
521
533
|
}
|
|
534
|
+
if (candidate.enterToSend === true || candidate.enterToSend === false) {
|
|
535
|
+
result.enterToSend = candidate.enterToSend;
|
|
536
|
+
}
|
|
537
|
+
if (candidate.enterToSendConfigured === true || candidate.enterToSendConfigured === false) {
|
|
538
|
+
result.enterToSendConfigured = candidate.enterToSendConfigured;
|
|
539
|
+
}
|
|
522
540
|
if (typeof candidate.showOpenCodeUpdateNotifications === 'boolean') {
|
|
523
541
|
result.showOpenCodeUpdateNotifications = candidate.showOpenCodeUpdateNotifications;
|
|
524
542
|
}
|
|
@@ -687,6 +705,12 @@ export const createSettingsHelpers = (dependencies) => {
|
|
|
687
705
|
result.gitChangesViewMode = mode;
|
|
688
706
|
}
|
|
689
707
|
}
|
|
708
|
+
if (typeof candidate.toolJsonViewMode === 'string') {
|
|
709
|
+
const mode = candidate.toolJsonViewMode.trim();
|
|
710
|
+
if (mode === 'summary' || mode === 'formatted' || mode === 'raw') {
|
|
711
|
+
result.toolJsonViewMode = mode;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
690
714
|
if (typeof candidate.directoryShowHidden === 'boolean') {
|
|
691
715
|
result.directoryShowHidden = candidate.directoryShowHidden;
|
|
692
716
|
}
|
|
@@ -927,6 +951,8 @@ export const createSettingsHelpers = (dependencies) => {
|
|
|
927
951
|
const pwaAppName = normalizePwaAppName(settings?.pwaAppName, '');
|
|
928
952
|
const pwaOrientation = normalizePwaOrientation(settings?.pwaOrientation, 'system');
|
|
929
953
|
const mobileKeyboardMode = normalizeMobileKeyboardMode(settings?.mobileKeyboardMode, 'native');
|
|
954
|
+
const inputHistoryScope = sanitized.inputHistoryScope ?? DEFAULT_INPUT_HISTORY_SCOPE;
|
|
955
|
+
const inputHistoryLimit = sanitized.inputHistoryLimit ?? DEFAULT_INPUT_HISTORY_LIMIT;
|
|
930
956
|
|
|
931
957
|
return {
|
|
932
958
|
...sanitized,
|
|
@@ -937,6 +963,8 @@ export const createSettingsHelpers = (dependencies) => {
|
|
|
937
963
|
...(pwaAppName ? { pwaAppName } : {}),
|
|
938
964
|
pwaOrientation,
|
|
939
965
|
mobileKeyboardMode,
|
|
966
|
+
inputHistoryScope,
|
|
967
|
+
inputHistoryLimit,
|
|
940
968
|
securityScopedBookmarks: bookmarks,
|
|
941
969
|
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
|
|
942
970
|
typographySizes: sanitizeTypographySizesPartial(settings.typographySizes),
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
1
6
|
import { describe, expect, it } from 'vitest';
|
|
2
7
|
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_INPUT_HISTORY_LIMIT,
|
|
10
|
+
} from './input-history-scope.js';
|
|
3
11
|
import { createSettingsHelpers } from './settings-helpers.js';
|
|
4
12
|
import { createSettingsNormalizationRuntime } from './settings-normalization-runtime.js';
|
|
5
13
|
|
|
14
|
+
const testFilePath = fileURLToPath(import.meta.url);
|
|
15
|
+
const packagesWebDir = join(dirname(testFilePath), '..', '..', '..');
|
|
16
|
+
|
|
6
17
|
const createTestHelpers = () => createSettingsHelpers({
|
|
7
18
|
normalizePathForPersistence: (value) => value,
|
|
8
19
|
normalizeDirectoryPath: (value) => value,
|
|
@@ -58,6 +69,66 @@ const createTestHelpersWithRealSanitizers = () => {
|
|
|
58
69
|
};
|
|
59
70
|
|
|
60
71
|
describe('settings helpers', () => {
|
|
72
|
+
it('imports from the packed @openchamber/web tarball without escaping the published package', async () => {
|
|
73
|
+
const tempRoot = mkdtempSync(join(tmpdir(), 'settings-helpers-pack-'));
|
|
74
|
+
const packDir = join(tempRoot, 'pack');
|
|
75
|
+
const extractDir = join(tempRoot, 'extract');
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
mkdirSync(packDir);
|
|
79
|
+
mkdirSync(extractDir);
|
|
80
|
+
execFileSync('npm', ['pack', '--silent', '--pack-destination', packDir], {
|
|
81
|
+
cwd: packagesWebDir,
|
|
82
|
+
stdio: 'pipe',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const tarballName = readdirSync(packDir).find((entry) => entry.endsWith('.tgz'));
|
|
86
|
+
expect(tarballName).toBeTruthy();
|
|
87
|
+
|
|
88
|
+
execFileSync('tar', ['-xzf', join(packDir, tarballName), '-C', extractDir], {
|
|
89
|
+
stdio: 'pipe',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const extractedModule = await import(
|
|
93
|
+
pathToFileURL(join(extractDir, 'package', 'server', 'lib', 'opencode', 'settings-helpers.js')).href
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const helpers = extractedModule.createSettingsHelpers({
|
|
97
|
+
normalizePathForPersistence: (value) => value,
|
|
98
|
+
normalizeDirectoryPath: (value) => value,
|
|
99
|
+
normalizeTunnelBootstrapTtlMs: (value) => value,
|
|
100
|
+
normalizeTunnelSessionTtlMs: (value) => value,
|
|
101
|
+
normalizeTunnelProvider: (value) => value,
|
|
102
|
+
normalizeTunnelMode: (value) => value,
|
|
103
|
+
normalizeOptionalPath: (value) => value,
|
|
104
|
+
normalizeManagedRemoteTunnelHostname: (value) => value,
|
|
105
|
+
normalizeManagedRemoteTunnelPresets: () => undefined,
|
|
106
|
+
normalizeManagedRemoteTunnelPresetTokens: () => undefined,
|
|
107
|
+
sanitizeTypographySizesPartial: () => undefined,
|
|
108
|
+
normalizeStringArray: (input) => input,
|
|
109
|
+
sanitizeModelRefs: () => undefined,
|
|
110
|
+
sanitizeSkillCatalogs: () => undefined,
|
|
111
|
+
sanitizeProjects: () => undefined,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'global' })).toEqual({
|
|
115
|
+
inputHistoryScope: 'global',
|
|
116
|
+
});
|
|
117
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'session' })).toEqual({
|
|
118
|
+
inputHistoryScope: 'session',
|
|
119
|
+
});
|
|
120
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 40 })).toEqual({
|
|
121
|
+
inputHistoryLimit: 40,
|
|
122
|
+
});
|
|
123
|
+
expect(helpers.formatSettingsResponse({})).toMatchObject({
|
|
124
|
+
inputHistoryScope: 'session',
|
|
125
|
+
inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT,
|
|
126
|
+
});
|
|
127
|
+
} finally {
|
|
128
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
61
132
|
it('accepts only booleans for draft starter visibility', () => {
|
|
62
133
|
const helpers = createTestHelpers();
|
|
63
134
|
|
|
@@ -66,6 +137,14 @@ describe('settings helpers', () => {
|
|
|
66
137
|
expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: 'false' })).toEqual({});
|
|
67
138
|
});
|
|
68
139
|
|
|
140
|
+
it('sanitizes both Enter settings independently', () => {
|
|
141
|
+
const helpers = createTestHelpers();
|
|
142
|
+
|
|
143
|
+
expect(helpers.sanitizeSettingsUpdate({ enterToSend: true })).toEqual({ enterToSend: true });
|
|
144
|
+
expect(helpers.sanitizeSettingsUpdate({ enterToSendConfigured: false })).toEqual({ enterToSendConfigured: false });
|
|
145
|
+
expect(helpers.sanitizeSettingsUpdate({ enterToSend: 'true', enterToSendConfigured: 1 })).toEqual({});
|
|
146
|
+
});
|
|
147
|
+
|
|
69
148
|
it('sanitizes shared sidebar display preferences', () => {
|
|
70
149
|
const helpers = createTestHelpers();
|
|
71
150
|
|
|
@@ -88,6 +167,15 @@ describe('settings helpers', () => {
|
|
|
88
167
|
})).toEqual({});
|
|
89
168
|
});
|
|
90
169
|
|
|
170
|
+
it('persists valid tool JSON view modes', () => {
|
|
171
|
+
const helpers = createTestHelpers();
|
|
172
|
+
|
|
173
|
+
expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'summary' })).toEqual({ toolJsonViewMode: 'summary' });
|
|
174
|
+
expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'formatted' })).toEqual({ toolJsonViewMode: 'formatted' });
|
|
175
|
+
expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'raw' })).toEqual({ toolJsonViewMode: 'raw' });
|
|
176
|
+
expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'unknown' })).toEqual({});
|
|
177
|
+
});
|
|
178
|
+
|
|
91
179
|
it('accepts only booleans for wide chat layout', () => {
|
|
92
180
|
const helpers = createTestHelpers();
|
|
93
181
|
|
|
@@ -134,6 +222,74 @@ describe('settings helpers', () => {
|
|
|
134
222
|
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'websocket' })).toEqual({});
|
|
135
223
|
});
|
|
136
224
|
|
|
225
|
+
it('accepts inputHistoryScope as a persisted shared setting', () => {
|
|
226
|
+
const helpers = createTestHelpers();
|
|
227
|
+
|
|
228
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'global' })).toEqual({
|
|
229
|
+
inputHistoryScope: 'global',
|
|
230
|
+
});
|
|
231
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'session' })).toEqual({
|
|
232
|
+
inputHistoryScope: 'session',
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('accepts valid inputHistoryLimit values as a persisted shared setting', () => {
|
|
237
|
+
const helpers = createTestHelpers();
|
|
238
|
+
|
|
239
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 1 })).toEqual({
|
|
240
|
+
inputHistoryLimit: 1,
|
|
241
|
+
});
|
|
242
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 40 })).toEqual({
|
|
243
|
+
inputHistoryLimit: 40,
|
|
244
|
+
});
|
|
245
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 100 })).toEqual({
|
|
246
|
+
inputHistoryLimit: 100,
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('rejects invalid inputHistoryLimit values', () => {
|
|
251
|
+
const helpers = createTestHelpers();
|
|
252
|
+
|
|
253
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 0 })).toEqual({});
|
|
254
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 101 })).toEqual({});
|
|
255
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 1.5 })).toEqual({});
|
|
256
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: '40' })).toEqual({});
|
|
257
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: Number.NaN })).toEqual({});
|
|
258
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: Number.POSITIVE_INFINITY })).toEqual({});
|
|
259
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: Number.NEGATIVE_INFINITY })).toEqual({});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('rejects invalid inputHistoryScope values', () => {
|
|
263
|
+
const helpers = createTestHelpers();
|
|
264
|
+
|
|
265
|
+
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'workspace' })).toEqual({});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('defaults inputHistoryScope to session in formatted settings responses', () => {
|
|
269
|
+
const helpers = createTestHelpers();
|
|
270
|
+
|
|
271
|
+
expect(helpers.formatSettingsResponse({ inputHistoryScope: 'global' })).toMatchObject({
|
|
272
|
+
inputHistoryScope: 'global',
|
|
273
|
+
});
|
|
274
|
+
expect(helpers.formatSettingsResponse({})).toMatchObject({
|
|
275
|
+
inputHistoryScope: 'session',
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('defaults missing inputHistoryLimit to 40 in formatted settings responses and preserves valid values', () => {
|
|
280
|
+
const helpers = createTestHelpers();
|
|
281
|
+
|
|
282
|
+
expect(helpers.formatSettingsResponse({})).toMatchObject({
|
|
283
|
+
inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT,
|
|
284
|
+
});
|
|
285
|
+
expect(helpers.formatSettingsResponse({ inputHistoryLimit: 1 })).toMatchObject({
|
|
286
|
+
inputHistoryLimit: 1,
|
|
287
|
+
});
|
|
288
|
+
expect(helpers.formatSettingsResponse({ inputHistoryLimit: 100 })).toMatchObject({
|
|
289
|
+
inputHistoryLimit: 100,
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
137
293
|
it('sanitizes the persisted terminal shell', () => {
|
|
138
294
|
const helpers = createTestHelpers();
|
|
139
295
|
|
|
@@ -316,6 +472,27 @@ describe('settings helpers', () => {
|
|
|
316
472
|
});
|
|
317
473
|
});
|
|
318
474
|
|
|
475
|
+
it('accepts and rejects invalid Enter-to-send preference values', () => {
|
|
476
|
+
const helpers = createTestHelpers();
|
|
477
|
+
|
|
478
|
+
expect(helpers.sanitizeSettingsUpdate({ enterToSend: true, enterToSendConfigured: true })).toEqual({
|
|
479
|
+
enterToSend: true,
|
|
480
|
+
enterToSendConfigured: true,
|
|
481
|
+
});
|
|
482
|
+
expect(helpers.sanitizeSettingsUpdate({ enterToSend: false, enterToSendConfigured: true })).toEqual({
|
|
483
|
+
enterToSend: false,
|
|
484
|
+
enterToSendConfigured: true,
|
|
485
|
+
});
|
|
486
|
+
expect(helpers.sanitizeSettingsUpdate({ enterToSend: false, enterToSendConfigured: false })).toEqual({
|
|
487
|
+
enterToSend: false,
|
|
488
|
+
enterToSendConfigured: false,
|
|
489
|
+
});
|
|
490
|
+
expect(helpers.sanitizeSettingsUpdate({ enterToSend: 'true' })).toEqual({});
|
|
491
|
+
expect(helpers.sanitizeSettingsUpdate({ enterToSend: 1 })).toEqual({});
|
|
492
|
+
expect(helpers.sanitizeSettingsUpdate({ enterToSendConfigured: 'true' })).toEqual({});
|
|
493
|
+
expect(helpers.sanitizeSettingsUpdate({ enterToSendConfigured: 1 })).toEqual({});
|
|
494
|
+
});
|
|
495
|
+
|
|
319
496
|
it('accepts dismissed OpenCode update toast version as a persisted shared setting', () => {
|
|
320
497
|
const helpers = createTestHelpers();
|
|
321
498
|
|
|
@@ -692,8 +692,8 @@ export const createSettingsRuntime = (deps) => {
|
|
|
692
692
|
return { settings, changed: false };
|
|
693
693
|
}
|
|
694
694
|
|
|
695
|
-
const defaultLight = '
|
|
696
|
-
const defaultDark = '
|
|
695
|
+
const defaultLight = 'openchamber-light';
|
|
696
|
+
const defaultDark = 'openchamber-dark';
|
|
697
697
|
|
|
698
698
|
let nextLightThemeId = hasLight ? settings.lightThemeId : undefined;
|
|
699
699
|
let nextDarkThemeId = hasDark ? settings.darkThemeId : undefined;
|
|
@@ -39,6 +39,35 @@ const createRuntime = async () => {
|
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
describe('settings runtime', () => {
|
|
42
|
+
it('uses OpenChamber themes when a new install has no theme preferences', async () => {
|
|
43
|
+
const { runtime, cleanup } = await createRuntime();
|
|
44
|
+
try {
|
|
45
|
+
await expect(runtime.readSettingsFromDiskMigrated()).resolves.toMatchObject({
|
|
46
|
+
lightThemeId: 'openchamber-light',
|
|
47
|
+
darkThemeId: 'openchamber-dark',
|
|
48
|
+
});
|
|
49
|
+
} finally {
|
|
50
|
+
await cleanup();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('preserves existing theme preferences during theme migration', async () => {
|
|
55
|
+
const { runtime, settingsFilePath, cleanup } = await createRuntime();
|
|
56
|
+
try {
|
|
57
|
+
await fsPromises.writeFile(settingsFilePath, JSON.stringify({
|
|
58
|
+
lightThemeId: 'flexoki-light',
|
|
59
|
+
darkThemeId: 'flexoki-dark',
|
|
60
|
+
}), 'utf8');
|
|
61
|
+
|
|
62
|
+
await expect(runtime.readSettingsFromDiskMigrated()).resolves.toMatchObject({
|
|
63
|
+
lightThemeId: 'flexoki-light',
|
|
64
|
+
darkThemeId: 'flexoki-dark',
|
|
65
|
+
});
|
|
66
|
+
} finally {
|
|
67
|
+
await cleanup();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
42
71
|
it('round-trips shared sidebar preferences through settings.json', async () => {
|
|
43
72
|
const { runtime, settingsFilePath, cleanup } = await createRuntime();
|
|
44
73
|
const preferences = {
|
|
@@ -6,7 +6,10 @@ import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
|
|
|
6
6
|
|
|
7
7
|
// ============== PATH CONSTANTS ==============
|
|
8
8
|
|
|
9
|
-
const OPENCODE_CONFIG_DIR = path.join(
|
|
9
|
+
const OPENCODE_CONFIG_DIR = path.join(
|
|
10
|
+
process.env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), '.config'),
|
|
11
|
+
'opencode',
|
|
12
|
+
);
|
|
10
13
|
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
|
|
11
14
|
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
|
|
12
15
|
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
|
|
@@ -11,6 +11,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
|
|
11
11
|
sessionAssistRuntime,
|
|
12
12
|
sessionGoalRuntime,
|
|
13
13
|
contextObligatoryRuntime,
|
|
14
|
+
messageQueueRuntime,
|
|
14
15
|
scheduledTasksRuntime,
|
|
15
16
|
getHealthCheckInterval,
|
|
16
17
|
clearHealthCheckInterval,
|
|
@@ -47,6 +48,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
|
|
47
48
|
sessionAssistRuntime?.stop?.();
|
|
48
49
|
sessionGoalRuntime?.stop?.();
|
|
49
50
|
contextObligatoryRuntime?.stop?.();
|
|
51
|
+
messageQueueRuntime?.stop?.();
|
|
50
52
|
scheduledTasksRuntime?.stop?.();
|
|
51
53
|
|
|
52
54
|
const healthCheckInterval = getHealthCheckInterval();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
|
2
2
|
import { buildDeferredRestartResponse } from './config-mutation-response.js';
|
|
3
|
+
import { OPENCODE_CONFIG_DIR } from './shared.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Matches how OpenCode reads its own boolean env flags: any value other than
|
|
@@ -110,7 +111,7 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|
|
110
111
|
}
|
|
111
112
|
|
|
112
113
|
const userRoots = [
|
|
113
|
-
|
|
114
|
+
OPENCODE_CONFIG_DIR,
|
|
114
115
|
path.join(home, '.opencode'),
|
|
115
116
|
path.join(home, '.claude', 'skills'),
|
|
116
117
|
path.join(home, '.agents', 'skills'),
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
3
|
import yaml from 'yaml';
|
|
4
|
+
import { OPENCODE_CONFIG_DIR } from './shared.js';
|
|
5
5
|
|
|
6
|
-
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
|
|
7
6
|
const GLOBAL_SNIPPET_DIR = path.join(OPENCODE_CONFIG_DIR, 'snippet');
|
|
8
7
|
const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets');
|
|
9
8
|
const SNIPPET_EXTENSION = '.md';
|
|
@@ -4,15 +4,15 @@ import fs from 'fs';
|
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
|
+
import { fetchUpdateNotes } from './changelog/update-notes.js';
|
|
7
8
|
|
|
8
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
10
|
const __dirname = path.dirname(__filename);
|
|
10
11
|
|
|
11
12
|
const PACKAGE_NAME = '@vantran-se/openchamber-web';
|
|
12
13
|
const PACKAGE_PATH_SEGMENTS = PACKAGE_NAME.split('/');
|
|
13
|
-
const PACKAGE_REGISTRY_URL = 'https://
|
|
14
|
+
const PACKAGE_REGISTRY_URL = 'https://registry.npmjs.org';
|
|
14
15
|
const UPSTREAM_NPM_REGISTRY_URL = 'https://registry.npmjs.org/@openchamber%2Fweb';
|
|
15
|
-
const CHANGELOG_URL = 'https://raw.githubusercontent.com/openchamber/openchamber/main/CHANGELOG.md';
|
|
16
16
|
const FORK_RELEASES_URL = 'https://github.com/vantran-se/openchamber/releases';
|
|
17
17
|
const FORK_LATEST_RELEASE_API_URL = 'https://api.github.com/repos/vantran-se/openchamber/releases/latest';
|
|
18
18
|
const UPSTREAM_RELEASES_URL = 'https://github.com/openchamber/openchamber/releases';
|
|
@@ -767,34 +767,9 @@ function compareVersions(left, right) {
|
|
|
767
767
|
return 0;
|
|
768
768
|
}
|
|
769
769
|
|
|
770
|
-
/**
|
|
771
|
-
* Fetch changelog notes between versions
|
|
772
|
-
*/
|
|
770
|
+
/** Release notes between the installed and the offered version, or undefined. */
|
|
773
771
|
async function fetchChangelogNotes(fromVersion, toVersion) {
|
|
774
|
-
|
|
775
|
-
const response = await fetch(CHANGELOG_URL, {
|
|
776
|
-
signal: AbortSignal.timeout(10000),
|
|
777
|
-
});
|
|
778
|
-
|
|
779
|
-
if (!response.ok) return undefined;
|
|
780
|
-
|
|
781
|
-
const changelog = await response.text();
|
|
782
|
-
const sections = changelog.split(/^## /m).slice(1);
|
|
783
|
-
|
|
784
|
-
const relevantSections = sections.filter((section) => {
|
|
785
|
-
const match = section.match(/^\[(\d+\.\d+\.\d+)\]/);
|
|
786
|
-
if (!match) return false;
|
|
787
|
-
return compareVersions(match[1], fromVersion) > 0 && compareVersions(match[1], toVersion) <= 0;
|
|
788
|
-
});
|
|
789
|
-
|
|
790
|
-
if (relevantSections.length === 0) return undefined;
|
|
791
|
-
|
|
792
|
-
return relevantSections
|
|
793
|
-
.map((s) => '## ' + s.trim())
|
|
794
|
-
.join('\n\n');
|
|
795
|
-
} catch {
|
|
796
|
-
return undefined;
|
|
797
|
-
}
|
|
772
|
+
return (await fetchUpdateNotes(fromVersion, toVersion, compareVersions)) ?? undefined;
|
|
798
773
|
}
|
|
799
774
|
|
|
800
775
|
export async function checkForUpdates(options = {}) {
|
|
@@ -163,9 +163,9 @@ describe('checkForUpdates', () => {
|
|
|
163
163
|
});
|
|
164
164
|
|
|
165
165
|
describe('getUpdateCommand', () => {
|
|
166
|
-
it('installs the fork package from
|
|
166
|
+
it('installs the public fork package from npm', () => {
|
|
167
167
|
expect(getUpdateCommand('npm')).toBe(
|
|
168
|
-
'npm install -g @vantran-se/openchamber-web@latest --registry=https://
|
|
168
|
+
'npm install -g @vantran-se/openchamber-web@latest --registry=https://registry.npmjs.org',
|
|
169
169
|
);
|
|
170
170
|
});
|
|
171
171
|
});
|
|
@@ -23,6 +23,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
|
|
23
23
|
| `cursor` | Cursor | `providers/cursor.js` | Environment/token files, OpenChamber-managed credentials, or explicit one-time Cursor import |
|
|
24
24
|
| `crof` | CrofAI | `providers/crof.js` | `crof` (API key under `key` or `token`) |
|
|
25
25
|
| `deepseek` | DeepSeek | `providers/deepseek.js` | `deepseek` (API key under `key` or `token`) |
|
|
26
|
+
| `exe-dev` | exe.dev | `providers/exe-dev.js` | Usage API token stored under `~/.config/openchamber/quota/` |
|
|
26
27
|
| `google` | Google | `providers/google/index.js` | `google`, `google.oauth`, Antigravity accounts file |
|
|
27
28
|
| `github-copilot` | GitHub Copilot | `providers/copilot.js` | `github-copilot`, `copilot` |
|
|
28
29
|
| `github-copilot-addon` | GitHub Copilot Add-on | `providers/copilot.js` | `github-copilot`, `copilot` |
|
|
@@ -51,7 +52,7 @@ All providers should return results via shared helpers to preserve API shape:
|
|
|
51
52
|
Provider modules must export `providerId`, `providerName`, `aliases`, `isConfigured(auth?)`, and `fetchQuota()`.
|
|
52
53
|
`fetchQuota()` should return a quota result with `usage.windows` keyed by window name (for example `5h`, `7d`, `daily`) and optional provider-specific `usage.models` data.
|
|
53
54
|
|
|
54
|
-
Ollama Cloud and Cursor credentials are explicitly managed through Settings. OpenCode Go usage uses `GET https://opencode.ai/zen/go/v1/usage` with the `opencode-go` API key from OpenCode `auth.json` as a bearer token. The server validates managed credentials before atomic `0600` writes and never returns secrets through its API. OpenChamber never scans browser cookie stores or automatically reads Cursor storage; Cursor import is an explicit one-time user action and never modifies Cursor's database.
|
|
55
|
+
exe.dev, Ollama Cloud, and Cursor credentials are explicitly managed through Settings. exe.dev usage uses a separately generated HTTPS API token restricted to `billing credits usage` and aggregates every `exe-*` model provider into one monthly credit window. Generate the token with `ssh exe.dev "ssh-key generate-api-key --label=openchamber --exp=30d --cmds='billing credits usage'"`. OpenCode Go usage uses `GET https://opencode.ai/zen/go/v1/usage` with the `opencode-go` API key from OpenCode `auth.json` as a bearer token and the stable `x-opencode-session: openchamber-usage` workload id. The server validates managed credentials before atomic `0600` writes and never returns secrets through its API. OpenChamber never scans browser cookie stores or automatically reads Cursor storage; Cursor import is an explicit one-time user action and never modifies Cursor's database.
|
|
55
56
|
|
|
56
57
|
Command Code usage resolves account scope through `GET /alpha/whoami`, then reads server-backed credit balances and five-hour/weekly limits from `GET /alpha/billing/credits?orgId=...`. Personal accounts return `org: null` and use `/alpha/billing/credits` without an `orgId`; organization accounts include their organization id. Web/Electron and VS Code read the standard `command-code` OpenCode auth entry (including OAuth `access`) or `COMMAND_CODE_API_KEY`; credentials remain in the owning runtime and are never returned to shared UI.
|
|
57
58
|
|
|
@@ -3,6 +3,10 @@ import { deleteQuotaCredential, readQuotaCredential, writeQuotaCredential } from
|
|
|
3
3
|
const clean = (value) => typeof value === 'string' && !/[\r\n]/.test(value) ? value.trim() : '';
|
|
4
4
|
|
|
5
5
|
export const normalizers = {
|
|
6
|
+
'exe-dev': (value) => {
|
|
7
|
+
const usageToken = clean(value?.usageToken);
|
|
8
|
+
return usageToken ? { usageToken } : null;
|
|
9
|
+
},
|
|
6
10
|
'ollama-cloud': (value) => {
|
|
7
11
|
const cookie = clean(value?.cookie);
|
|
8
12
|
return cookie ? { cookie } : null;
|
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
5
|
-
const MANAGED_QUOTA_PROVIDERS = new Set(['ollama-cloud', 'cursor']);
|
|
5
|
+
const MANAGED_QUOTA_PROVIDERS = new Set(['exe-dev', 'ollama-cloud', 'cursor']);
|
|
6
6
|
|
|
7
7
|
const credentialsDirectory = () => path.join(
|
|
8
8
|
process.env.OPENCHAMBER_DATA_DIR
|
|
@@ -10,12 +10,12 @@ process.env.OPENCHAMBER_DATA_DIR = temporaryDirectory;
|
|
|
10
10
|
|
|
11
11
|
describe('quota credential store', () => {
|
|
12
12
|
it('uses owner-only permissions and rejects arbitrary provider paths', () => {
|
|
13
|
-
writeQuotaCredential('
|
|
13
|
+
writeQuotaCredential('exe-dev', { usageToken: 'secret' });
|
|
14
14
|
expect(fs.statSync(path.join(temporaryDirectory, 'quota')).mode & 0o777).toBe(0o700);
|
|
15
|
-
expect(fs.statSync(path.join(temporaryDirectory, 'quota', '
|
|
16
|
-
expect(readQuotaCredential('
|
|
15
|
+
expect(fs.statSync(path.join(temporaryDirectory, 'quota', 'exe-dev.json')).mode & 0o777).toBe(0o600);
|
|
16
|
+
expect(readQuotaCredential('exe-dev', (value) => value)).toEqual({ usageToken: 'secret' });
|
|
17
17
|
expect(() => writeQuotaCredential('../escape', {})).toThrow('Unsupported credential provider');
|
|
18
|
-
deleteQuotaCredential('
|
|
18
|
+
deleteQuotaCredential('exe-dev');
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
it('removes the obsolete OpenCode Go credential without parsing it', () => {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readManagedCredential } from '../credentials/providers.js';
|
|
2
|
+
import { asObject, buildResult, formatMoney, toNumber, toTimestamp, toUsageWindow } from '../utils/index.js';
|
|
3
|
+
|
|
4
|
+
export const providerId = 'exe-dev';
|
|
5
|
+
export const providerName = 'exe.dev';
|
|
6
|
+
export const aliases = ['exe-dev'];
|
|
7
|
+
const EXEC_URL = 'https://exe.dev/exec';
|
|
8
|
+
const USAGE_COMMAND = 'billing credits usage --group=day --json';
|
|
9
|
+
|
|
10
|
+
export const parseExeDevUsage = (payload) => {
|
|
11
|
+
const data = asObject(payload);
|
|
12
|
+
if (!data) return null;
|
|
13
|
+
|
|
14
|
+
const totalCost = toNumber(data.total_cost_usd);
|
|
15
|
+
const monthlyAllowance = toNumber(data.monthly_allowance_usd);
|
|
16
|
+
const resetAt = toTimestamp(data.period_end);
|
|
17
|
+
if (totalCost === null || monthlyAllowance === null || monthlyAllowance < 0 || resetAt === null) return null;
|
|
18
|
+
|
|
19
|
+
const usedPercent = monthlyAllowance > 0
|
|
20
|
+
? Math.min(100, Math.max(0, (totalCost / monthlyAllowance) * 100))
|
|
21
|
+
: null;
|
|
22
|
+
const spent = formatMoney(totalCost);
|
|
23
|
+
const allowance = formatMoney(monthlyAllowance);
|
|
24
|
+
if (spent === null || allowance === null) return null;
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
monthly: toUsageWindow({
|
|
28
|
+
usedPercent,
|
|
29
|
+
windowSeconds: null,
|
|
30
|
+
resetAt,
|
|
31
|
+
valueLabel: `$${spent} / $${allowance}`,
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const fetchExeDevUsage = async (credential, fetchImpl = fetch) => {
|
|
37
|
+
const response = await fetchImpl(EXEC_URL, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: {
|
|
40
|
+
Accept: 'application/json',
|
|
41
|
+
Authorization: `Bearer ${credential.usageToken}`,
|
|
42
|
+
'Content-Type': 'text/plain',
|
|
43
|
+
'User-Agent': 'OpenChamber quota provider',
|
|
44
|
+
},
|
|
45
|
+
body: USAGE_COMMAND,
|
|
46
|
+
signal: AbortSignal.timeout(15_000),
|
|
47
|
+
});
|
|
48
|
+
if (response.status === 401 || response.status === 403) throw new Error('exe.dev authentication failed');
|
|
49
|
+
if (!response.ok) throw new Error(`exe.dev usage API returned HTTP ${response.status}`);
|
|
50
|
+
const windows = parseExeDevUsage(await response.json().catch(() => null));
|
|
51
|
+
if (!windows) throw new Error('exe.dev usage data could not be parsed');
|
|
52
|
+
return windows;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const isConfigured = () => Boolean(readManagedCredential(providerId));
|
|
56
|
+
|
|
57
|
+
export const fetchQuota = async () => {
|
|
58
|
+
const credential = readManagedCredential(providerId);
|
|
59
|
+
if (!credential) {
|
|
60
|
+
return buildResult({ providerId, providerName, ok: false, configured: false, error: 'Not configured' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const windows = await fetchExeDevUsage(credential);
|
|
65
|
+
return buildResult({ providerId, providerName, ok: true, configured: true, usage: { windows } });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return buildResult({
|
|
68
|
+
providerId,
|
|
69
|
+
providerName,
|
|
70
|
+
ok: false,
|
|
71
|
+
configured: true,
|
|
72
|
+
error: error instanceof Error ? error.message : 'Request failed',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
};
|