@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.
- package/dist/assets/{MarkdownRendererImpl-C3-ZpwEx.js → MarkdownRendererImpl-COdbjw73.js} +3 -3
- package/dist/assets/{MultiRunWindow-BDfPzMDy.js → MultiRunWindow-BKSHxjMq.js} +1 -1
- package/dist/assets/{OnboardingScreen-DGgh4IXB.js → OnboardingScreen-Chjg337p.js} +1 -1
- package/dist/assets/{SettingsWindow-B8QKr5dB.js → SettingsWindow-C0lRRW8M.js} +1 -1
- package/dist/assets/{TerminalView-D7IIkSGJ.js → TerminalView-Bvil3j1u.js} +4 -4
- package/dist/assets/es-BZIAUghG.js +15 -0
- package/dist/assets/index-UcCH2KN9.css +1 -0
- package/dist/assets/ko-DU9l-zox.js +15 -0
- package/dist/assets/{main-VVcyjpiF.js → main-Blhx9Fp5.js} +2 -2
- package/dist/assets/main-d2-dY4er.js +232 -0
- package/dist/assets/miniChat-CJ7-rZFl.js +2 -0
- package/dist/assets/{modelPrefsAutoSave-Ctdc3cCY.js → modelPrefsAutoSave-DRJSYigo.js} +96 -96
- package/dist/assets/{pl-C577DpsX.js → pl-CdqzokG-.js} +1 -1
- package/dist/assets/pt-BR-Bknbr_Y3.js +15 -0
- package/dist/assets/{renderElectronMiniChatApp-CsddCM3q.js → renderElectronMiniChatApp-BxZRI73j.js} +2 -2
- package/dist/assets/uk-Be4E8ZNO.js +15 -0
- package/dist/assets/zh-CN-qpPiaZMg.js +15 -0
- package/dist/index.html +3 -3
- package/dist/mini-chat.html +3 -3
- package/package.json +1 -1
- package/server/index.js +2 -0
- package/server/lib/cloudflare-tunnel.js +3 -5
- package/server/lib/ngrok-tunnel.js +209 -0
- package/server/lib/opencode/core-routes.js +1 -0
- 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/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 +3 -0
- package/server/lib/opencode/settings-helpers.test.js +11 -0
- 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/pt-BR-BeeF6VlK.js +0 -15
- package/dist/assets/uk-CZ7XVz_D.js +0 -15
- package/dist/assets/zh-CN-BMSSqdyO.js +0 -15
- /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-
|
|
536
|
-
<link rel="modulepreload" crossorigin href="/assets/index-
|
|
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-
|
|
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">
|
package/dist/mini-chat.html
CHANGED
|
@@ -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-
|
|
8
|
-
<link rel="modulepreload" crossorigin href="/assets/index-
|
|
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-
|
|
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
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
|
-
⚠️
|
|
640
|
+
⚠️ Quick Tunnel Limitations:
|
|
641
641
|
|
|
642
|
-
•
|
|
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
|
|
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
|
+
}
|