@openchamber/web 1.11.5 → 1.11.6

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 (48) hide show
  1. package/dist/assets/{MarkdownRendererImpl-C3-ZpwEx.js → MarkdownRendererImpl-COdbjw73.js} +3 -3
  2. package/dist/assets/{MultiRunWindow-BDfPzMDy.js → MultiRunWindow-BKSHxjMq.js} +1 -1
  3. package/dist/assets/{OnboardingScreen-DGgh4IXB.js → OnboardingScreen-Chjg337p.js} +1 -1
  4. package/dist/assets/{SettingsWindow-B8QKr5dB.js → SettingsWindow-C0lRRW8M.js} +1 -1
  5. package/dist/assets/{TerminalView-D7IIkSGJ.js → TerminalView-Bvil3j1u.js} +4 -4
  6. package/dist/assets/es-BZIAUghG.js +15 -0
  7. package/dist/assets/index-UcCH2KN9.css +1 -0
  8. package/dist/assets/ko-DU9l-zox.js +15 -0
  9. package/dist/assets/{main-VVcyjpiF.js → main-Blhx9Fp5.js} +2 -2
  10. package/dist/assets/main-d2-dY4er.js +232 -0
  11. package/dist/assets/miniChat-CJ7-rZFl.js +2 -0
  12. package/dist/assets/{modelPrefsAutoSave-Ctdc3cCY.js → modelPrefsAutoSave-DRJSYigo.js} +96 -96
  13. package/dist/assets/{pl-C577DpsX.js → pl-CdqzokG-.js} +1 -1
  14. package/dist/assets/pt-BR-Bknbr_Y3.js +15 -0
  15. package/dist/assets/{renderElectronMiniChatApp-CsddCM3q.js → renderElectronMiniChatApp-BxZRI73j.js} +2 -2
  16. package/dist/assets/uk-Be4E8ZNO.js +15 -0
  17. package/dist/assets/zh-CN-qpPiaZMg.js +15 -0
  18. package/dist/index.html +3 -3
  19. package/dist/mini-chat.html +3 -3
  20. package/package.json +1 -1
  21. package/server/index.js +2 -0
  22. package/server/lib/cloudflare-tunnel.js +3 -5
  23. package/server/lib/ngrok-tunnel.js +209 -0
  24. package/server/lib/opencode/core-routes.js +1 -0
  25. package/server/lib/opencode/feature-routes-runtime.js +35 -0
  26. package/server/lib/opencode/index.js +19 -0
  27. package/server/lib/opencode/npm-registry.js +157 -0
  28. package/server/lib/opencode/npm-registry.test.js +179 -0
  29. package/server/lib/opencode/plugin-routes.js +373 -0
  30. package/server/lib/opencode/plugin-routes.test.js +384 -0
  31. package/server/lib/opencode/plugin-spec.js +107 -0
  32. package/server/lib/opencode/plugin-spec.test.js +154 -0
  33. package/server/lib/opencode/plugins.js +393 -0
  34. package/server/lib/opencode/plugins.test.js +176 -0
  35. package/server/lib/opencode/settings-helpers.js +3 -0
  36. package/server/lib/opencode/settings-helpers.test.js +11 -0
  37. package/server/lib/tunnels/DOCUMENTATION.md +1 -0
  38. package/server/lib/tunnels/providers/ngrok.js +117 -0
  39. package/server/lib/tunnels/types.js +2 -0
  40. package/dist/assets/es-dIVpApmS.js +0 -15
  41. package/dist/assets/index-Bk9IWJe1.css +0 -1
  42. package/dist/assets/ko-Cqf3E9-d.js +0 -15
  43. package/dist/assets/main-D45l3Dxw.js +0 -232
  44. package/dist/assets/miniChat-a9w7WM0c.js +0 -2
  45. package/dist/assets/pt-BR-BeeF6VlK.js +0 -15
  46. package/dist/assets/uk-CZ7XVz_D.js +0 -15
  47. package/dist/assets/zh-CN-BMSSqdyO.js +0 -15
  48. /package/dist/assets/{index-DHluop4D.js → index-B9LvUHdG.js} +0 -0
package/dist/index.html CHANGED
@@ -532,10 +532,10 @@
532
532
  pointer-events: none;
533
533
  }
534
534
  </style>
535
- <script type="module" crossorigin src="/assets/main-VVcyjpiF.js"></script>
536
- <link rel="modulepreload" crossorigin href="/assets/index-DHluop4D.js">
535
+ <script type="module" crossorigin src="/assets/main-Blhx9Fp5.js"></script>
536
+ <link rel="modulepreload" crossorigin href="/assets/index-B9LvUHdG.js">
537
537
  <link rel="modulepreload" crossorigin href="/assets/vendor-.bun-Bum-iBXX.js">
538
- <link rel="stylesheet" crossorigin href="/assets/index-Bk9IWJe1.css">
538
+ <link rel="stylesheet" crossorigin href="/assets/index-UcCH2KN9.css">
539
539
  <link rel="stylesheet" crossorigin href="/assets/vendor--V65Sl9C2.css">
540
540
  </head>
541
541
  <body class="h-full bg-background text-foreground">
@@ -4,10 +4,10 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
6
6
  <title>OpenChamber Mini Chat</title>
7
- <script type="module" crossorigin src="/assets/miniChat-a9w7WM0c.js"></script>
8
- <link rel="modulepreload" crossorigin href="/assets/index-DHluop4D.js">
7
+ <script type="module" crossorigin src="/assets/miniChat-CJ7-rZFl.js"></script>
8
+ <link rel="modulepreload" crossorigin href="/assets/index-B9LvUHdG.js">
9
9
  <link rel="modulepreload" crossorigin href="/assets/vendor-.bun-Bum-iBXX.js">
10
- <link rel="stylesheet" crossorigin href="/assets/index-Bk9IWJe1.css">
10
+ <link rel="stylesheet" crossorigin href="/assets/index-UcCH2KN9.css">
11
11
  <link rel="stylesheet" crossorigin href="/assets/vendor--V65Sl9C2.css">
12
12
  </head>
13
13
  <body class="h-full bg-background text-foreground">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchamber/web",
3
- "version": "1.11.5",
3
+ "version": "1.11.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
package/server/index.js CHANGED
@@ -14,6 +14,7 @@ import { createTunnelAuth } from './lib/opencode/tunnel-auth.js';
14
14
  import { createManagedTunnelConfigRuntime } from './lib/tunnels/managed-config.js';
15
15
  import { createTunnelProviderRegistry } from './lib/tunnels/registry.js';
16
16
  import { createCloudflareTunnelProvider } from './lib/tunnels/providers/cloudflare.js';
17
+ import { createNgrokTunnelProvider } from './lib/tunnels/providers/ngrok.js';
17
18
  import { createRequestSecurityRuntime } from './lib/security/request-security.js';
18
19
  import {
19
20
  TUNNEL_MODE_MANAGED_LOCAL,
@@ -449,6 +450,7 @@ let activeTunnelController = null;
449
450
  let globalWatcherStartPromise = null;
450
451
  const tunnelProviderRegistry = createTunnelProviderRegistry([
451
452
  createCloudflareTunnelProvider(),
453
+ createNgrokTunnelProvider(),
452
454
  ]);
453
455
  tunnelProviderRegistry.seal();
454
456
  const tunnelAuthController = createTunnelAuth();
@@ -637,14 +637,12 @@ export async function startCloudflareTunnel({ originUrl, port }) {
637
637
 
638
638
  export function printTunnelWarning() {
639
639
  console.log(`
640
- ⚠️ Cloudflare Quick Tunnel Limitations:
640
+ ⚠️ Quick Tunnel Limitations:
641
641
 
642
- Maximum 200 concurrent requests
643
- • Server-Sent Events (SSE) are NOT supported
642
+ Provider limits may apply
644
643
  • URLs are temporary and will expire when the tunnel stops
645
644
  • Password protection is required for tunnel access
646
645
 
647
- For production use, set up a managed remote Cloudflare Tunnel:
648
- https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/
646
+ For production use, set up a persistent provider tunnel or static domain.
649
647
  `);
650
648
  }
@@ -0,0 +1,209 @@
1
+ import { spawn, spawnSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ const DEFAULT_STARTUP_TIMEOUT_MS = 30000;
6
+ const NGROK_API_URL = 'http://127.0.0.1:4040/api/tunnels';
7
+ const NGROK_INSTALL_HELP = 'brew install ngrok';
8
+ const NGROK_AUTHTOKEN_HELP = 'Run: ngrok config add-authtoken <your-ngrok-token>';
9
+
10
+ async function searchPathFor(command) {
11
+ const pathValue = process.env.PATH || '';
12
+ const segments = pathValue.split(path.delimiter).filter(Boolean);
13
+ const WINDOWS_EXTENSIONS = process.platform === 'win32'
14
+ ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
15
+ .split(';')
16
+ .map((ext) => ext.trim().toLowerCase())
17
+ .filter(Boolean)
18
+ .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
19
+ : [''];
20
+
21
+ for (const dir of segments) {
22
+ for (const ext of WINDOWS_EXTENSIONS) {
23
+ const fileName = process.platform === 'win32' ? `${command}${ext}` : command;
24
+ const candidate = path.join(dir, fileName);
25
+ try {
26
+ const stats = fs.statSync(candidate);
27
+ if (!stats.isFile()) {
28
+ continue;
29
+ }
30
+ if (process.platform !== 'win32') {
31
+ try {
32
+ fs.accessSync(candidate, fs.constants.X_OK);
33
+ } catch {
34
+ continue;
35
+ }
36
+ }
37
+ return candidate;
38
+ } catch {
39
+ continue;
40
+ }
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ export async function checkNgrokAvailable() {
47
+ const ngrokPath = await searchPathFor('ngrok');
48
+ if (ngrokPath) {
49
+ try {
50
+ const result = spawnSync(ngrokPath, ['version'], {
51
+ encoding: 'utf8',
52
+ stdio: ['pipe', 'pipe', 'pipe'],
53
+ windowsHide: true,
54
+ });
55
+ if (result.status === 0) {
56
+ return { available: true, path: ngrokPath, version: result.stdout.trim() || result.stderr.trim() };
57
+ }
58
+ } catch {
59
+ // Ignore and report unavailable below.
60
+ }
61
+ }
62
+ return { available: false, path: null, version: null };
63
+ }
64
+
65
+ export async function checkNgrokAuthtokenConfigured(ngrokPath = null) {
66
+ if (typeof process.env.NGROK_AUTHTOKEN === 'string' && process.env.NGROK_AUTHTOKEN.trim().length > 0) {
67
+ return { configured: true, detail: 'NGROK_AUTHTOKEN is set.' };
68
+ }
69
+
70
+ const resolvedPath = ngrokPath || await searchPathFor('ngrok');
71
+ if (!resolvedPath) {
72
+ return { configured: false, detail: `ngrok is not installed. Install it with: ${NGROK_INSTALL_HELP}` };
73
+ }
74
+
75
+ try {
76
+ const result = spawnSync(resolvedPath, ['config', 'check'], {
77
+ encoding: 'utf8',
78
+ stdio: ['pipe', 'pipe', 'pipe'],
79
+ windowsHide: true,
80
+ });
81
+ const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
82
+ if (result.status === 0) {
83
+ return { configured: true, detail: output || 'ngrok config is valid.' };
84
+ }
85
+ return { configured: false, detail: output || NGROK_AUTHTOKEN_HELP };
86
+ } catch (error) {
87
+ return {
88
+ configured: false,
89
+ detail: error instanceof Error ? error.message : String(error),
90
+ };
91
+ }
92
+ }
93
+
94
+ export async function checkNgrokApiReachability({ fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {
95
+ if (typeof fetchImpl !== 'function') {
96
+ return { reachable: false, status: null, error: 'Fetch API is unavailable in this runtime.' };
97
+ }
98
+
99
+ const controller = new AbortController();
100
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
101
+ try {
102
+ const response = await fetchImpl('https://api.ngrok.com/', {
103
+ method: 'GET',
104
+ signal: controller.signal,
105
+ });
106
+ return { reachable: true, status: response.status, error: null };
107
+ } catch (error) {
108
+ return {
109
+ reachable: false,
110
+ status: null,
111
+ error: error instanceof Error ? error.message : String(error),
112
+ };
113
+ } finally {
114
+ clearTimeout(timeout);
115
+ }
116
+ }
117
+
118
+ const spawnNgrok = (args, resolvedBinaryPath = 'ngrok') => spawn(resolvedBinaryPath, args, {
119
+ stdio: ['ignore', 'pipe', 'pipe'],
120
+ windowsHide: true,
121
+ env: process.env,
122
+ killSignal: 'SIGINT',
123
+ });
124
+
125
+ async function fetchNgrokPublicUrl(fetchImpl = globalThis.fetch) {
126
+ if (typeof fetchImpl !== 'function') {
127
+ return null;
128
+ }
129
+ try {
130
+ const response = await fetchImpl(NGROK_API_URL, { method: 'GET' });
131
+ if (!response.ok) {
132
+ return null;
133
+ }
134
+ const payload = await response.json();
135
+ const tunnels = Array.isArray(payload?.tunnels) ? payload.tunnels : [];
136
+ const httpsTunnel = tunnels.find((entry) => entry?.proto === 'https' && typeof entry?.public_url === 'string');
137
+ const fallbackTunnel = tunnels.find((entry) => typeof entry?.public_url === 'string');
138
+ return httpsTunnel?.public_url || fallbackTunnel?.public_url || null;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ export async function startNgrokQuickTunnel({ port }) {
145
+ const ngrokCheck = await checkNgrokAvailable();
146
+ if (!ngrokCheck.available) {
147
+ throw new Error(`ngrok is not installed. Install it with: ${NGROK_INSTALL_HELP}`);
148
+ }
149
+
150
+ const authtokenCheck = await checkNgrokAuthtokenConfigured(ngrokCheck.path);
151
+ if (!authtokenCheck.configured) {
152
+ throw new Error(`ngrok authtoken is not configured. ${NGROK_AUTHTOKEN_HELP}`);
153
+ }
154
+
155
+ if (!Number.isFinite(port)) {
156
+ throw new Error('A local port is required to start an ngrok tunnel');
157
+ }
158
+
159
+ const child = spawnNgrok(['http', String(port)], ngrokCheck.path);
160
+ let publicUrl = null;
161
+
162
+ child.stdout.on('data', () => {
163
+ // Keep stream drained; ngrok exposes the URL via its local API.
164
+ });
165
+
166
+ child.stderr.on('data', (chunk) => {
167
+ process.stderr.write(chunk.toString('utf8'));
168
+ });
169
+
170
+ child.on('error', (error) => {
171
+ console.error(`Ngrok error: ${error.message}`);
172
+ });
173
+
174
+ await new Promise((resolve, reject) => {
175
+ const timeout = setTimeout(() => {
176
+ clearInterval(checkReady);
177
+ try { child.kill('SIGINT'); } catch { /* ignore */ }
178
+ reject(new Error('Ngrok tunnel URL not received within 30 seconds'));
179
+ }, DEFAULT_STARTUP_TIMEOUT_MS);
180
+
181
+ const checkReady = setInterval(async () => {
182
+ publicUrl = await fetchNgrokPublicUrl();
183
+ if (publicUrl) {
184
+ clearTimeout(timeout);
185
+ clearInterval(checkReady);
186
+ resolve(null);
187
+ }
188
+ }, 250);
189
+
190
+ child.once('exit', (code) => {
191
+ clearTimeout(timeout);
192
+ clearInterval(checkReady);
193
+ reject(new Error(`Ngrok exited while starting (code ${code ?? 'unknown'})`));
194
+ });
195
+ });
196
+
197
+ return {
198
+ mode: 'quick',
199
+ stop: () => {
200
+ try {
201
+ child.kill('SIGINT');
202
+ } catch {
203
+ // Ignore.
204
+ }
205
+ },
206
+ process: child,
207
+ getPublicUrl: () => publicUrl,
208
+ };
209
+ }
@@ -521,6 +521,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
521
521
  req.path.startsWith('/api/config/snippets') ||
522
522
  req.path.startsWith('/api/config/settings') ||
523
523
  req.path.startsWith('/api/config/skills') ||
524
+ req.path.startsWith('/api/config/plugins') ||
524
525
  req.path.startsWith('/api/projects') ||
525
526
  req.path.startsWith('/api/fs') ||
526
527
  req.path.startsWith('/api/git') ||
@@ -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
+ }