@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.
Files changed (65) hide show
  1. package/README.md +6 -0
  2. package/bin/cli.js +443 -2
  3. package/dist/assets/{MarkdownRendererImpl-C3-ZpwEx.js → MarkdownRendererImpl-DaF15QNC.js} +1 -1
  4. package/dist/assets/{MultiRunWindow-BDfPzMDy.js → MultiRunWindow-Cl7wS_CB.js} +1 -1
  5. package/dist/assets/{OnboardingScreen-DGgh4IXB.js → OnboardingScreen-DTv6YJI1.js} +2 -2
  6. package/dist/assets/{SettingsWindow-B8QKr5dB.js → SettingsWindow-_c3TTL2z.js} +1 -1
  7. package/dist/assets/{TerminalView-D7IIkSGJ.js → TerminalView-CuXkDROt.js} +4 -4
  8. package/dist/assets/es-CYoUf2D-.js +15 -0
  9. package/dist/assets/{index-DHluop4D.js → index-3WXrN3AX.js} +1 -1
  10. package/dist/assets/index-BREIbhcb.css +1 -0
  11. package/dist/assets/ko-2tM0fIna.js +15 -0
  12. package/dist/assets/main-BF3kWAJ9.js +239 -0
  13. package/dist/assets/{main-VVcyjpiF.js → main-o8ZERrmU.js} +2 -2
  14. package/dist/assets/miniChat-BZQjpK23.js +2 -0
  15. package/dist/assets/{modelPrefsAutoSave-Ctdc3cCY.js → modelPrefsAutoSave-wwnbqBk7.js} +109 -107
  16. package/dist/assets/pl-Dq8uAotM.js +15 -0
  17. package/dist/assets/pt-BR-nh9s9DFT.js +15 -0
  18. package/dist/assets/{renderElectronMiniChatApp-CsddCM3q.js → renderElectronMiniChatApp-C-Ezew9P.js} +2 -2
  19. package/dist/assets/uk-BZtz0wUV.js +15 -0
  20. package/dist/assets/{vendor-.bun-Bum-iBXX.js → vendor-.bun-CV3tusA8.js} +1 -1
  21. package/dist/assets/zh-CN-j_nYMchE.js +15 -0
  22. package/dist/assets/zh-TW-B11UpkDJ.js +15 -0
  23. package/dist/index.html +11 -28
  24. package/dist/mini-chat.html +4 -4
  25. package/package.json +1 -1
  26. package/server/index.js +2 -0
  27. package/server/lib/cloudflare-tunnel.js +3 -5
  28. package/server/lib/fs/routes.js +5 -0
  29. package/server/lib/fs/routes.test.js +61 -1
  30. package/server/lib/git/DOCUMENTATION.md +1 -0
  31. package/server/lib/git/routes.js +82 -1
  32. package/server/lib/git/service.js +338 -19
  33. package/server/lib/git/service.test.js +414 -8
  34. package/server/lib/ngrok-tunnel.js +209 -0
  35. package/server/lib/opencode/core-routes.js +1 -0
  36. package/server/lib/opencode/env-runtime.js +52 -4
  37. package/server/lib/opencode/env-runtime.test.js +82 -6
  38. package/server/lib/opencode/feature-routes-runtime.js +35 -0
  39. package/server/lib/opencode/index.js +19 -0
  40. package/server/lib/opencode/npm-registry.js +157 -0
  41. package/server/lib/opencode/npm-registry.test.js +179 -0
  42. package/server/lib/opencode/openchamber-routes.js +9 -7
  43. package/server/lib/opencode/plugin-routes.js +373 -0
  44. package/server/lib/opencode/plugin-routes.test.js +384 -0
  45. package/server/lib/opencode/plugin-spec.js +107 -0
  46. package/server/lib/opencode/plugin-spec.test.js +154 -0
  47. package/server/lib/opencode/plugins.js +393 -0
  48. package/server/lib/opencode/plugins.test.js +176 -0
  49. package/server/lib/opencode/settings-helpers.js +6 -0
  50. package/server/lib/opencode/settings-helpers.test.js +11 -0
  51. package/server/lib/opencode/settings-runtime.js +39 -1
  52. package/server/lib/opencode/settings-runtime.test.js +39 -0
  53. package/server/lib/skills-catalog/source.js +1 -1
  54. package/server/lib/tunnels/DOCUMENTATION.md +1 -0
  55. package/server/lib/tunnels/providers/ngrok.js +117 -0
  56. package/server/lib/tunnels/types.js +2 -0
  57. package/dist/assets/es-dIVpApmS.js +0 -15
  58. package/dist/assets/index-Bk9IWJe1.css +0 -1
  59. package/dist/assets/ko-Cqf3E9-d.js +0 -15
  60. package/dist/assets/main-D45l3Dxw.js +0 -232
  61. package/dist/assets/miniChat-a9w7WM0c.js +0 -2
  62. package/dist/assets/pl-C577DpsX.js +0 -15
  63. package/dist/assets/pt-BR-BeeF6VlK.js +0 -15
  64. package/dist/assets/uk-CZ7XVz_D.js +0 -15
  65. 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 = [trimmed];
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 candidate = path.join(nodeModulesDir, packageName, 'bin', 'opencode.exe');
674
- if (isExecutable(candidate)) {
675
- return candidate;
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
- return;
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 failed WSL resolution as an invalid configured binary in strict mode', async () => {
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
- await expect(rejection).rejects.toThrow('uses WSL');
113
- const error = await rejection.catch((caught) => caught);
114
- expect(error.code).toBeUndefined();
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
+ });