@openchamber/web 1.11.5 → 1.11.7
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 +6 -0
- package/bin/cli.js +443 -2
- package/dist/assets/{MarkdownRendererImpl-C3-ZpwEx.js → MarkdownRendererImpl-DaF15QNC.js} +1 -1
- package/dist/assets/{MultiRunWindow-BDfPzMDy.js → MultiRunWindow-Cl7wS_CB.js} +1 -1
- package/dist/assets/{OnboardingScreen-DGgh4IXB.js → OnboardingScreen-DTv6YJI1.js} +2 -2
- package/dist/assets/{SettingsWindow-B8QKr5dB.js → SettingsWindow-_c3TTL2z.js} +1 -1
- package/dist/assets/{TerminalView-D7IIkSGJ.js → TerminalView-CuXkDROt.js} +4 -4
- package/dist/assets/es-CYoUf2D-.js +15 -0
- package/dist/assets/{index-DHluop4D.js → index-3WXrN3AX.js} +1 -1
- package/dist/assets/index-BREIbhcb.css +1 -0
- package/dist/assets/ko-2tM0fIna.js +15 -0
- package/dist/assets/main-BF3kWAJ9.js +239 -0
- package/dist/assets/{main-VVcyjpiF.js → main-o8ZERrmU.js} +2 -2
- package/dist/assets/miniChat-BZQjpK23.js +2 -0
- package/dist/assets/{modelPrefsAutoSave-Ctdc3cCY.js → modelPrefsAutoSave-wwnbqBk7.js} +109 -107
- package/dist/assets/pl-Dq8uAotM.js +15 -0
- package/dist/assets/pt-BR-nh9s9DFT.js +15 -0
- package/dist/assets/{renderElectronMiniChatApp-CsddCM3q.js → renderElectronMiniChatApp-C-Ezew9P.js} +2 -2
- package/dist/assets/uk-BZtz0wUV.js +15 -0
- package/dist/assets/{vendor-.bun-Bum-iBXX.js → vendor-.bun-CV3tusA8.js} +1 -1
- package/dist/assets/zh-CN-j_nYMchE.js +15 -0
- package/dist/assets/zh-TW-B11UpkDJ.js +15 -0
- package/dist/index.html +11 -28
- package/dist/mini-chat.html +4 -4
- package/package.json +1 -1
- package/server/index.js +2 -0
- package/server/lib/cloudflare-tunnel.js +3 -5
- package/server/lib/fs/routes.js +5 -0
- package/server/lib/fs/routes.test.js +61 -1
- package/server/lib/git/DOCUMENTATION.md +1 -0
- package/server/lib/git/routes.js +82 -1
- package/server/lib/git/service.js +338 -19
- package/server/lib/git/service.test.js +414 -8
- package/server/lib/ngrok-tunnel.js +209 -0
- package/server/lib/opencode/core-routes.js +1 -0
- package/server/lib/opencode/env-runtime.js +52 -4
- package/server/lib/opencode/env-runtime.test.js +82 -6
- package/server/lib/opencode/feature-routes-runtime.js +35 -0
- package/server/lib/opencode/index.js +19 -0
- package/server/lib/opencode/npm-registry.js +157 -0
- package/server/lib/opencode/npm-registry.test.js +179 -0
- package/server/lib/opencode/openchamber-routes.js +9 -7
- package/server/lib/opencode/plugin-routes.js +373 -0
- package/server/lib/opencode/plugin-routes.test.js +384 -0
- package/server/lib/opencode/plugin-spec.js +107 -0
- package/server/lib/opencode/plugin-spec.test.js +154 -0
- package/server/lib/opencode/plugins.js +393 -0
- package/server/lib/opencode/plugins.test.js +176 -0
- package/server/lib/opencode/settings-helpers.js +6 -0
- package/server/lib/opencode/settings-helpers.test.js +11 -0
- package/server/lib/opencode/settings-runtime.js +39 -1
- package/server/lib/opencode/settings-runtime.test.js +39 -0
- package/server/lib/skills-catalog/source.js +1 -1
- package/server/lib/tunnels/DOCUMENTATION.md +1 -0
- package/server/lib/tunnels/providers/ngrok.js +117 -0
- package/server/lib/tunnels/types.js +2 -0
- package/dist/assets/es-dIVpApmS.js +0 -15
- package/dist/assets/index-Bk9IWJe1.css +0 -1
- package/dist/assets/ko-Cqf3E9-d.js +0 -15
- package/dist/assets/main-D45l3Dxw.js +0 -232
- package/dist/assets/miniChat-a9w7WM0c.js +0 -2
- package/dist/assets/pl-C577DpsX.js +0 -15
- package/dist/assets/pt-BR-BeeF6VlK.js +0 -15
- package/dist/assets/uk-CZ7XVz_D.js +0 -15
- package/dist/assets/zh-CN-BMSSqdyO.js +0 -15
|
@@ -51,6 +51,30 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|
|
51
51
|
}
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
+
const resolveWindowsExecutablePath = (candidate) => {
|
|
55
|
+
if (process.platform !== 'win32' || typeof candidate !== 'string' || candidate.trim().length === 0) {
|
|
56
|
+
return candidate;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const trimmed = candidate.trim();
|
|
60
|
+
const ext = path.extname(trimmed).toLowerCase();
|
|
61
|
+
if (ext) {
|
|
62
|
+
return isExecutable(trimmed) ? trimmed : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const pathExt = process.env.PATHEXT || process.env.PathExt || '.COM;.EXE;.BAT;.CMD';
|
|
66
|
+
for (const rawExt of pathExt.split(';')) {
|
|
67
|
+
const normalizedExt = rawExt.trim();
|
|
68
|
+
if (!normalizedExt) continue;
|
|
69
|
+
const withExt = `${trimmed}${normalizedExt.startsWith('.') ? normalizedExt : `.${normalizedExt}`}`;
|
|
70
|
+
if (isExecutable(withExt)) {
|
|
71
|
+
return withExt;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return isExecutable(trimmed) ? trimmed : null;
|
|
76
|
+
};
|
|
77
|
+
|
|
54
78
|
const searchPathFor = (binaryName) => {
|
|
55
79
|
const trimmed = typeof binaryName === 'string' ? binaryName.trim() : '';
|
|
56
80
|
if (!trimmed) {
|
|
@@ -59,7 +83,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|
|
59
83
|
|
|
60
84
|
const current = process.env.PATH || '';
|
|
61
85
|
const parts = current.split(path.delimiter).filter(Boolean);
|
|
62
|
-
const candidateNames = [
|
|
86
|
+
const candidateNames = [];
|
|
63
87
|
|
|
64
88
|
if (process.platform === 'win32' && !path.extname(trimmed)) {
|
|
65
89
|
const pathExt = process.env.PATHEXT || process.env.PathExt || '.COM;.EXE;.BAT;.CMD';
|
|
@@ -73,6 +97,8 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|
|
73
97
|
}
|
|
74
98
|
}
|
|
75
99
|
|
|
100
|
+
candidateNames.push(trimmed);
|
|
101
|
+
|
|
76
102
|
for (const dir of parts) {
|
|
77
103
|
for (const candidateName of candidateNames) {
|
|
78
104
|
const candidate = path.join(dir, candidateName);
|
|
@@ -649,6 +675,9 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|
|
649
675
|
if (!trimmed) {
|
|
650
676
|
return null;
|
|
651
677
|
}
|
|
678
|
+
if (process.platform === 'win32') {
|
|
679
|
+
return resolveWindowsExecutablePath(trimmed);
|
|
680
|
+
}
|
|
652
681
|
return isExecutable(trimmed) ? trimmed : null;
|
|
653
682
|
};
|
|
654
683
|
|
|
@@ -669,10 +698,20 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|
|
669
698
|
return null;
|
|
670
699
|
}
|
|
671
700
|
|
|
701
|
+
const packageShim = path.join(nodeModulesDir, 'opencode-ai', 'bin', 'opencode.exe');
|
|
702
|
+
if (isExecutable(packageShim)) {
|
|
703
|
+
return packageShim;
|
|
704
|
+
}
|
|
705
|
+
|
|
672
706
|
for (const packageName of getWindowsNativeOpencodePackageNames()) {
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
|
|
707
|
+
const candidates = [
|
|
708
|
+
path.join(nodeModulesDir, packageName, 'bin', 'opencode.exe'),
|
|
709
|
+
path.join(nodeModulesDir, 'opencode-ai', 'node_modules', packageName, 'bin', 'opencode.exe'),
|
|
710
|
+
];
|
|
711
|
+
for (const candidate of candidates) {
|
|
712
|
+
if (isExecutable(candidate)) {
|
|
713
|
+
return candidate;
|
|
714
|
+
}
|
|
676
715
|
}
|
|
677
716
|
}
|
|
678
717
|
|
|
@@ -816,6 +855,15 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|
|
816
855
|
|
|
817
856
|
const directBinary = normalizeExecutableCandidate(candidate);
|
|
818
857
|
if (directBinary) {
|
|
858
|
+
const directExt = path.extname(directBinary).toLowerCase();
|
|
859
|
+
if (WINDOWS_BATCH_EXTENSIONS.has(directExt)) {
|
|
860
|
+
return {
|
|
861
|
+
binary: process.env.ComSpec || 'cmd.exe',
|
|
862
|
+
args: ['/d', '/s', '/c', 'call', directBinary],
|
|
863
|
+
wrapperType: 'cmd-wrapper',
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
819
867
|
return {
|
|
820
868
|
binary: directBinary,
|
|
821
869
|
args: [],
|
|
@@ -5,6 +5,11 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|
|
5
5
|
import { createOpenCodeEnvRuntime } from './env-runtime.js';
|
|
6
6
|
|
|
7
7
|
const originalOpencodeBinary = process.env.OPENCODE_BINARY;
|
|
8
|
+
const originalComSpec = process.env.ComSpec;
|
|
9
|
+
const originalPath = process.env.PATH;
|
|
10
|
+
const originalSystemRoot = process.env.SystemRoot;
|
|
11
|
+
const originalWslBinary = process.env.WSL_BINARY;
|
|
12
|
+
const originalOpenChamberWslBinary = process.env.OPENCHAMBER_WSL_BINARY;
|
|
8
13
|
const originalPlatform = process.platform;
|
|
9
14
|
const tempDirs = [];
|
|
10
15
|
const itIf = (condition) => condition ? it : it.skip;
|
|
@@ -32,9 +37,39 @@ afterEach(() => {
|
|
|
32
37
|
|
|
33
38
|
if (typeof originalOpencodeBinary === 'string') {
|
|
34
39
|
process.env.OPENCODE_BINARY = originalOpencodeBinary;
|
|
35
|
-
|
|
40
|
+
} else {
|
|
41
|
+
delete process.env.OPENCODE_BINARY;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof originalComSpec === 'string') {
|
|
45
|
+
process.env.ComSpec = originalComSpec;
|
|
46
|
+
} else {
|
|
47
|
+
delete process.env.ComSpec;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof originalPath === 'string') {
|
|
51
|
+
process.env.PATH = originalPath;
|
|
52
|
+
} else {
|
|
53
|
+
delete process.env.PATH;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (typeof originalSystemRoot === 'string') {
|
|
57
|
+
process.env.SystemRoot = originalSystemRoot;
|
|
58
|
+
} else {
|
|
59
|
+
delete process.env.SystemRoot;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (typeof originalWslBinary === 'string') {
|
|
63
|
+
process.env.WSL_BINARY = originalWslBinary;
|
|
64
|
+
} else {
|
|
65
|
+
delete process.env.WSL_BINARY;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (typeof originalOpenChamberWslBinary === 'string') {
|
|
69
|
+
process.env.OPENCHAMBER_WSL_BINARY = originalOpenChamberWslBinary;
|
|
70
|
+
} else {
|
|
71
|
+
delete process.env.OPENCHAMBER_WSL_BINARY;
|
|
36
72
|
}
|
|
37
|
-
delete process.env.OPENCODE_BINARY;
|
|
38
73
|
});
|
|
39
74
|
|
|
40
75
|
const createRuntime = (settings) => {
|
|
@@ -103,14 +138,55 @@ describe('OpenCode env runtime', () => {
|
|
|
103
138
|
});
|
|
104
139
|
});
|
|
105
140
|
|
|
106
|
-
it('does not classify
|
|
141
|
+
it('does not classify WSL settings as a native invalid configured binary in strict mode', async () => {
|
|
107
142
|
setPlatform('win32');
|
|
143
|
+
const dir = createTempDir('openchamber-no-wsl-');
|
|
144
|
+
process.env.PATH = dir;
|
|
145
|
+
process.env.SystemRoot = dir;
|
|
146
|
+
process.env.WSL_BINARY = path.join(dir, 'missing-wsl.exe');
|
|
147
|
+
process.env.OPENCHAMBER_WSL_BINARY = path.join(dir, 'missing-openchamber-wsl.exe');
|
|
108
148
|
const { runtime } = createRuntime({ opencodeBinary: 'wsl:/usr/local/bin/opencode' });
|
|
109
149
|
|
|
110
150
|
const rejection = runtime.applyOpencodeBinaryFromSettings({ strict: true });
|
|
111
151
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
152
|
+
try {
|
|
153
|
+
await rejection;
|
|
154
|
+
expect(runtime.resolveManagedOpenCodeLaunchSpec('opencode').wrapperType).not.toBe('cmd-wrapper');
|
|
155
|
+
} catch (error) {
|
|
156
|
+
expect(error.message).toContain('uses WSL');
|
|
157
|
+
expect(error.code).toBeUndefined();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('launches Windows cmd shims through cmd call without embedded quotes', () => {
|
|
162
|
+
setPlatform('win32');
|
|
163
|
+
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe';
|
|
164
|
+
const dir = createTempDir('openchamber-opencode-cmd-');
|
|
165
|
+
const shim = path.join(dir, 'opencode.cmd');
|
|
166
|
+
fs.writeFileSync(shim, '@echo off\r\nexit /b 0\r\n');
|
|
167
|
+
const { runtime } = createRuntime({});
|
|
168
|
+
|
|
169
|
+
expect(runtime.resolveManagedOpenCodeLaunchSpec(shim)).toEqual({
|
|
170
|
+
binary: 'C:\\Windows\\System32\\cmd.exe',
|
|
171
|
+
args: ['/d', '/s', '/c', 'call', shim],
|
|
172
|
+
wrapperType: 'cmd-wrapper',
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('resolves npm OpenCode cmd shims to the packaged Windows executable', () => {
|
|
177
|
+
setPlatform('win32');
|
|
178
|
+
const npmDir = createTempDir('openchamber-opencode-npm-');
|
|
179
|
+
const shim = path.join(npmDir, 'opencode.cmd');
|
|
180
|
+
const nativeBinary = path.join(npmDir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe');
|
|
181
|
+
fs.mkdirSync(path.dirname(nativeBinary), { recursive: true });
|
|
182
|
+
fs.writeFileSync(nativeBinary, '');
|
|
183
|
+
fs.writeFileSync(shim, '@ECHO off\r\n"%dp0%\\node_modules\\opencode-ai\\bin\\opencode.exe" %*\r\n');
|
|
184
|
+
const { runtime } = createRuntime({});
|
|
185
|
+
|
|
186
|
+
expect(runtime.resolveManagedOpenCodeLaunchSpec(shim)).toEqual({
|
|
187
|
+
binary: nativeBinary,
|
|
188
|
+
args: [],
|
|
189
|
+
wrapperType: 'native-wrapper',
|
|
190
|
+
});
|
|
115
191
|
});
|
|
116
192
|
});
|
|
@@ -9,6 +9,9 @@ import { registerSettingsUtilityRoutes } from './core-routes.js';
|
|
|
9
9
|
import { registerProjectIconRoutes } from './project-icon-routes.js';
|
|
10
10
|
import { registerScheduledTaskRoutes } from '../scheduled-tasks/routes.js';
|
|
11
11
|
import { registerSkillRoutes } from './skill-routes.js';
|
|
12
|
+
import { registerPluginRoutes } from './plugin-routes.js';
|
|
13
|
+
import { getNpmInfo, clearCache as clearNpmCache } from './npm-registry.js';
|
|
14
|
+
import { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
|
|
12
15
|
import { registerOpenCodeRoutes } from './routes.js';
|
|
13
16
|
|
|
14
17
|
export const createFeatureRoutesRuntime = (dependencies) => {
|
|
@@ -129,6 +132,17 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
129
132
|
updateSnippet,
|
|
130
133
|
deleteSnippet,
|
|
131
134
|
expandSnippets,
|
|
135
|
+
listPluginEntries,
|
|
136
|
+
getPluginEntry,
|
|
137
|
+
createPluginEntry,
|
|
138
|
+
updatePluginEntry,
|
|
139
|
+
deletePluginEntry,
|
|
140
|
+
listPluginDirFiles,
|
|
141
|
+
readPluginDirFile,
|
|
142
|
+
writePluginDirFile,
|
|
143
|
+
deletePluginDirFile,
|
|
144
|
+
encodePluginId,
|
|
145
|
+
decodePluginId,
|
|
132
146
|
} = await import('./index.js');
|
|
133
147
|
|
|
134
148
|
registerConfigEntityRoutes(app, {
|
|
@@ -158,6 +172,27 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
158
172
|
expandSnippets,
|
|
159
173
|
});
|
|
160
174
|
|
|
175
|
+
registerPluginRoutes(app, {
|
|
176
|
+
resolveOptionalProjectDirectory,
|
|
177
|
+
refreshOpenCodeAfterConfigChange,
|
|
178
|
+
clientReloadDelayMs,
|
|
179
|
+
listPluginEntries,
|
|
180
|
+
getPluginEntry,
|
|
181
|
+
createPluginEntry,
|
|
182
|
+
updatePluginEntry,
|
|
183
|
+
deletePluginEntry,
|
|
184
|
+
listPluginDirFiles,
|
|
185
|
+
readPluginDirFile,
|
|
186
|
+
writePluginDirFile,
|
|
187
|
+
deletePluginDirFile,
|
|
188
|
+
encodePluginId,
|
|
189
|
+
decodePluginId,
|
|
190
|
+
getNpmInfo,
|
|
191
|
+
parseNpmSpec,
|
|
192
|
+
parsePathSpec,
|
|
193
|
+
isExactSemver,
|
|
194
|
+
});
|
|
195
|
+
|
|
161
196
|
const {
|
|
162
197
|
getSkillSources,
|
|
163
198
|
discoverSkills,
|
|
@@ -66,6 +66,22 @@ export {
|
|
|
66
66
|
deleteMcpConfig,
|
|
67
67
|
} from './mcp.js';
|
|
68
68
|
|
|
69
|
+
export {
|
|
70
|
+
listPluginEntries,
|
|
71
|
+
getPluginEntry,
|
|
72
|
+
createPluginEntry,
|
|
73
|
+
updatePluginEntry,
|
|
74
|
+
deletePluginEntry,
|
|
75
|
+
listPluginDirFiles,
|
|
76
|
+
readPluginDirFile,
|
|
77
|
+
writePluginDirFile,
|
|
78
|
+
deletePluginDirFile,
|
|
79
|
+
encodePluginId,
|
|
80
|
+
decodePluginId,
|
|
81
|
+
parsePluginRaw,
|
|
82
|
+
serializePluginEntry,
|
|
83
|
+
} from './plugins.js';
|
|
84
|
+
|
|
69
85
|
export {
|
|
70
86
|
listSnippets,
|
|
71
87
|
getSnippet,
|
|
@@ -74,3 +90,6 @@ export {
|
|
|
74
90
|
deleteSnippet,
|
|
75
91
|
expandSnippets,
|
|
76
92
|
} from './snippets.js';
|
|
93
|
+
|
|
94
|
+
export { getNpmInfo, lookupNpmPackage, clearCache as clearNpmCache } from './npm-registry.js';
|
|
95
|
+
export { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
export const NPM_CACHE_TTL_MS = 3_600_000;
|
|
6
|
+
export const NPM_FETCH_TIMEOUT_MS = 5_000;
|
|
7
|
+
export const NPM_REGISTRY_BASE = 'https://registry.npmjs.org';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} NpmPackagePayload
|
|
11
|
+
* @property {true} ok
|
|
12
|
+
* @property {string|null} latest
|
|
13
|
+
* @property {string[]} versions
|
|
14
|
+
* @property {Record<string, string>} distTags
|
|
15
|
+
*
|
|
16
|
+
* @typedef {Object} NpmLookupError
|
|
17
|
+
* @property {false} ok
|
|
18
|
+
* @property {number|'network'} status
|
|
19
|
+
* @property {string} error
|
|
20
|
+
*
|
|
21
|
+
* @typedef {NpmPackagePayload | NpmLookupError} NpmLookupResult
|
|
22
|
+
* @typedef {{ forceRefresh?: boolean }} NpmInfoOptions
|
|
23
|
+
* @typedef {{ fetchedAt: number, payload: NpmLookupResult }} CacheEntry
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** @type {Map<string, CacheEntry>} */
|
|
27
|
+
const _cache = new Map();
|
|
28
|
+
|
|
29
|
+
/** @type {Map<string, Promise<NpmLookupResult>>} */
|
|
30
|
+
const _inFlight = new Map();
|
|
31
|
+
|
|
32
|
+
/** @type {string | null} */
|
|
33
|
+
let _userAgent = null;
|
|
34
|
+
|
|
35
|
+
function _getPackageJsonPath() {
|
|
36
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
return path.resolve(__dirname, '..', '..', '..', '..', '..', 'package.json');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function _getUserAgent() {
|
|
41
|
+
if (_userAgent) return _userAgent;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const pkg = JSON.parse(fs.readFileSync(_getPackageJsonPath(), 'utf8'));
|
|
45
|
+
_userAgent = `openchamber-server/${typeof pkg.version === 'string' ? pkg.version : '0.0.0'}`;
|
|
46
|
+
} catch {
|
|
47
|
+
_userAgent = 'openchamber-server/dev';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return _userAgent;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function encodeName(name) {
|
|
54
|
+
return encodeURIComponent(name).replace(/^%40/, '@');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseDistTags(value) {
|
|
58
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return Object.fromEntries(
|
|
63
|
+
Object.entries(value)
|
|
64
|
+
.filter((entry) => typeof entry[1] === 'string'),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseVersions(value) {
|
|
69
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return Object.keys(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function cacheResult(name, payload) {
|
|
77
|
+
if (payload.ok || payload.status === 404) {
|
|
78
|
+
_cache.set(name, { fetchedAt: Date.now(), payload });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fetch package metadata directly from the npm registry.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} name npm package name
|
|
86
|
+
* @returns {Promise<NpmLookupResult>}
|
|
87
|
+
*/
|
|
88
|
+
export async function lookupNpmPackage(name) {
|
|
89
|
+
try {
|
|
90
|
+
const response = await fetch(`${NPM_REGISTRY_BASE}/${encodeName(name)}`, {
|
|
91
|
+
headers: {
|
|
92
|
+
'User-Agent': _getUserAgent(),
|
|
93
|
+
Accept: 'application/json',
|
|
94
|
+
},
|
|
95
|
+
signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (response.ok) {
|
|
99
|
+
const data = await response.json();
|
|
100
|
+
const distTags = parseDistTags(data?.['dist-tags']);
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
latest: distTags.latest ?? null,
|
|
104
|
+
versions: parseVersions(data?.versions),
|
|
105
|
+
distTags,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (response.status === 404) {
|
|
110
|
+
return { ok: false, status: 404, error: 'Package not found' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { ok: false, status: response.status, error: `Registry returned ${response.status}` };
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return { ok: false, status: 'network', error: String(error?.message ?? error) };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Fetch package metadata with TTL cache and in-flight request deduplication.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} name npm package name
|
|
123
|
+
* @param {NpmInfoOptions} [options]
|
|
124
|
+
* @returns {Promise<NpmLookupResult>}
|
|
125
|
+
*/
|
|
126
|
+
export async function getNpmInfo(name, options = {}) {
|
|
127
|
+
const { forceRefresh = false } = options;
|
|
128
|
+
const cached = _cache.get(name);
|
|
129
|
+
if (cached && !forceRefresh && Date.now() - cached.fetchedAt < NPM_CACHE_TTL_MS) {
|
|
130
|
+
return cached.payload;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const existing = _inFlight.get(name);
|
|
134
|
+
if (existing && !forceRefresh) {
|
|
135
|
+
return existing;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const lookup = (async () => {
|
|
139
|
+
const result = await lookupNpmPackage(name);
|
|
140
|
+
cacheResult(name, result);
|
|
141
|
+
return result;
|
|
142
|
+
})();
|
|
143
|
+
|
|
144
|
+
_inFlight.set(name, lookup);
|
|
145
|
+
try {
|
|
146
|
+
return await lookup;
|
|
147
|
+
} finally {
|
|
148
|
+
if (_inFlight.get(name) === lookup) {
|
|
149
|
+
_inFlight.delete(name);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function clearCache() {
|
|
155
|
+
_cache.clear();
|
|
156
|
+
_inFlight.clear();
|
|
157
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import * as npm from './npm-registry.js';
|
|
3
|
+
|
|
4
|
+
const originalFetch = globalThis.fetch;
|
|
5
|
+
const originalDateNow = Date.now;
|
|
6
|
+
|
|
7
|
+
let fetchMock;
|
|
8
|
+
|
|
9
|
+
function jsonResponse(body, status = 200) {
|
|
10
|
+
return Promise.resolve(new Response(JSON.stringify(body), {
|
|
11
|
+
status,
|
|
12
|
+
headers: { 'content-type': 'application/json' },
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('npm registry client', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
npm.clearCache();
|
|
19
|
+
Date.now = originalDateNow;
|
|
20
|
+
fetchMock = mock(() => jsonResponse({}));
|
|
21
|
+
globalThis.fetch = fetchMock;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
npm.clearCache();
|
|
26
|
+
globalThis.fetch = originalFetch;
|
|
27
|
+
Date.now = originalDateNow;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('200 success returns latest versions and dist tags', async () => {
|
|
31
|
+
fetchMock.mockImplementation(() => jsonResponse({
|
|
32
|
+
'dist-tags': { latest: '1.2.0' },
|
|
33
|
+
versions: { '1.0.0': {}, '1.2.0': {} },
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const result = await npm.lookupNpmPackage('foo');
|
|
37
|
+
|
|
38
|
+
expect(result).toEqual({
|
|
39
|
+
ok: true,
|
|
40
|
+
latest: '1.2.0',
|
|
41
|
+
versions: ['1.0.0', '1.2.0'],
|
|
42
|
+
distTags: { latest: '1.2.0' },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('200 success handles missing dist-tags and versions', async () => {
|
|
47
|
+
fetchMock.mockImplementation(() => jsonResponse({}));
|
|
48
|
+
|
|
49
|
+
const result = await npm.lookupNpmPackage('foo');
|
|
50
|
+
|
|
51
|
+
expect(result).toEqual({ ok: true, latest: null, versions: [], distTags: {} });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('404 returns package not found', async () => {
|
|
55
|
+
fetchMock.mockImplementation(() => jsonResponse({}, 404));
|
|
56
|
+
|
|
57
|
+
const result = await npm.lookupNpmPackage('missing');
|
|
58
|
+
|
|
59
|
+
expect(result).toEqual({ ok: false, status: 404, error: 'Package not found' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('500 returns registry error', async () => {
|
|
63
|
+
fetchMock.mockImplementation(() => jsonResponse({}, 500));
|
|
64
|
+
|
|
65
|
+
const result = await npm.lookupNpmPackage('foo');
|
|
66
|
+
|
|
67
|
+
expect(result).toEqual({ ok: false, status: 500, error: 'Registry returned 500' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('network error returns network status', async () => {
|
|
71
|
+
fetchMock.mockImplementation(() => Promise.reject(new Error('socket closed')));
|
|
72
|
+
|
|
73
|
+
const result = await npm.lookupNpmPackage('foo');
|
|
74
|
+
|
|
75
|
+
expect(result.ok).toBe(false);
|
|
76
|
+
expect(result.status).toBe('network');
|
|
77
|
+
expect(result.error).toBe('socket closed');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('timeout plumbs AbortSignal to fetch', async () => {
|
|
81
|
+
fetchMock.mockImplementation((_url, init) => {
|
|
82
|
+
expect(init.signal).toBeInstanceOf(AbortSignal);
|
|
83
|
+
return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const result = await npm.lookupNpmPackage('foo');
|
|
87
|
+
|
|
88
|
+
expect(result.ok).toBe(false);
|
|
89
|
+
expect(result.status).toBe('network');
|
|
90
|
+
expect(result.error).toContain('aborted');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('cache hit reuses definitive success', async () => {
|
|
94
|
+
fetchMock.mockImplementation(() => jsonResponse({ 'dist-tags': { latest: '1.0.0' }, versions: { '1.0.0': {} } }));
|
|
95
|
+
|
|
96
|
+
const first = await npm.getNpmInfo('foo');
|
|
97
|
+
const second = await npm.getNpmInfo('foo');
|
|
98
|
+
|
|
99
|
+
expect(first).toEqual(second);
|
|
100
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('cache miss after ttl fetches again', async () => {
|
|
104
|
+
let now = 1_000;
|
|
105
|
+
Date.now = mock(() => now);
|
|
106
|
+
fetchMock.mockImplementation(() => jsonResponse({ versions: {} }));
|
|
107
|
+
|
|
108
|
+
await npm.getNpmInfo('foo');
|
|
109
|
+
now += 3_600_001;
|
|
110
|
+
await npm.getNpmInfo('foo');
|
|
111
|
+
|
|
112
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('forceRefresh bypasses cache', async () => {
|
|
116
|
+
fetchMock.mockImplementation(() => jsonResponse({ versions: {} }));
|
|
117
|
+
|
|
118
|
+
await npm.getNpmInfo('foo');
|
|
119
|
+
await npm.getNpmInfo('foo', { forceRefresh: true });
|
|
120
|
+
|
|
121
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('in-flight requests dedup by package name', async () => {
|
|
125
|
+
let release;
|
|
126
|
+
const wait = new Promise((resolve) => {
|
|
127
|
+
release = resolve;
|
|
128
|
+
});
|
|
129
|
+
fetchMock.mockImplementation(async () => {
|
|
130
|
+
await wait;
|
|
131
|
+
return new Response(JSON.stringify({ versions: { '1.0.0': {} } }), { status: 200 });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const requests = Promise.all([
|
|
135
|
+
npm.getNpmInfo('foo'),
|
|
136
|
+
npm.getNpmInfo('foo'),
|
|
137
|
+
npm.getNpmInfo('foo'),
|
|
138
|
+
]);
|
|
139
|
+
release();
|
|
140
|
+
const results = await requests;
|
|
141
|
+
|
|
142
|
+
expect(results.every((result) => result.ok)).toBe(true);
|
|
143
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('network failure is not cached', async () => {
|
|
147
|
+
fetchMock
|
|
148
|
+
.mockImplementationOnce(() => Promise.reject(new Error('down')))
|
|
149
|
+
.mockImplementationOnce(() => jsonResponse({ versions: {} }));
|
|
150
|
+
|
|
151
|
+
const first = await npm.getNpmInfo('foo');
|
|
152
|
+
const second = await npm.getNpmInfo('foo');
|
|
153
|
+
|
|
154
|
+
expect(first.status).toBe('network');
|
|
155
|
+
expect(second.ok).toBe(true);
|
|
156
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('404 is cached', async () => {
|
|
160
|
+
fetchMock.mockImplementation(() => jsonResponse({}, 404));
|
|
161
|
+
|
|
162
|
+
await npm.getNpmInfo('missing');
|
|
163
|
+
await npm.getNpmInfo('missing');
|
|
164
|
+
|
|
165
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('scoped names encode slash in registry url', async () => {
|
|
169
|
+
await npm.getNpmInfo('@scope/pkg');
|
|
170
|
+
|
|
171
|
+
expect(fetchMock.mock.calls[0][0]).toBe('https://registry.npmjs.org/@scope%2Fpkg');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('user-agent header is present', async () => {
|
|
175
|
+
await npm.getNpmInfo('foo');
|
|
176
|
+
|
|
177
|
+
expect(fetchMock.mock.calls[0][1].headers['User-Agent']).toMatch(/^openchamber-server\//);
|
|
178
|
+
});
|
|
179
|
+
});
|