@ran-sh/dsh-crew 0.3.7 → 0.3.8
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 +36 -67
- package/README.zh.md +37 -68
- package/lib/client.js +3446 -3446
- package/official-web-bridge/cordis.patch.yml +4 -0
- package/official-web-bridge/entry.mjs +1 -0
- package/official-web-bridge/lib/client.js +3446 -0
- package/official-web-bridge/package.json +26 -0
- package/package.json +5 -3
- package/scripts/build-client.mjs +5 -1
- package/scripts/verify-official-bridge-e2e.mjs +159 -0
- package/src/dsh-cli-runtime.mjs +219 -208
- package/src/install/npx-lifecycle.mjs +67 -3
- package/src/install/official-web.mjs +132 -0
- package/src/official-web-bridge.mjs +196 -0
- package/src/runtime-identity.mjs +1 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {
|
|
2
|
+
copyFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { ensurePluginRegistration, removeCrewPluginRegistration } from '../dsh-cli-runtime.mjs';
|
|
14
|
+
|
|
15
|
+
export const OFFICIAL_BRIDGE_PACKAGE = '@ran-sh/dsh-crew-web-bridge';
|
|
16
|
+
const STATE_FILENAME = 'official-web.json';
|
|
17
|
+
|
|
18
|
+
export function officialWebProfileDir({ home = homedir() } = {}) {
|
|
19
|
+
return join(home, '.dsh', 'profiles', 'web');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function officialWebIntegrationStateFile({ home = homedir() } = {}) {
|
|
23
|
+
return join(home, '.config', 'dsh-crew', STATE_FILENAME);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readState(home) {
|
|
27
|
+
try {
|
|
28
|
+
const value = JSON.parse(readFileSync(officialWebIntegrationStateFile({ home }), 'utf8'));
|
|
29
|
+
return value && typeof value === 'object' ? value : null;
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeState(home, value) {
|
|
34
|
+
const file = officialWebIntegrationStateFile({ home });
|
|
35
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
36
|
+
writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validOfficialManifest(file) {
|
|
40
|
+
if (!existsSync(file)) return { ok: false, code: 'OFFICIAL_WEB_PROFILE_NOT_FOUND' };
|
|
41
|
+
try {
|
|
42
|
+
const raw = readFileSync(file, 'utf8');
|
|
43
|
+
const manifest = JSON.parse(raw);
|
|
44
|
+
if (!manifest || typeof manifest !== 'object'
|
|
45
|
+
|| (manifest.dependencies !== undefined && (!manifest.dependencies || typeof manifest.dependencies !== 'object' || Array.isArray(manifest.dependencies)))
|
|
46
|
+
|| !Array.isArray(manifest.dsh?.profile?.bundles)) {
|
|
47
|
+
return { ok: false, code: 'OFFICIAL_WEB_PROFILE_INVALID' };
|
|
48
|
+
}
|
|
49
|
+
return { ok: true, raw, manifest };
|
|
50
|
+
} catch { return { ok: false, code: 'OFFICIAL_WEB_PROFILE_INVALID' }; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeBackup({ home, profileManifest, previous }) {
|
|
54
|
+
if (previous?.backup_file && existsSync(previous.backup_file)) return previous.backup_file;
|
|
55
|
+
const backupDir = join(home, '.config', 'dsh-crew', 'backups');
|
|
56
|
+
mkdirSync(backupDir, { recursive: true });
|
|
57
|
+
const backupFile = join(backupDir, `official-web-package-${Date.now()}.json`);
|
|
58
|
+
copyFileSync(profileManifest, backupFile);
|
|
59
|
+
return backupFile;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function ensureOfficialWebIntegration({ home = homedir(), releaseDir } = {}) {
|
|
63
|
+
let resolvedRelease;
|
|
64
|
+
try { resolvedRelease = realpathSync(releaseDir); } catch { return { ok: false, code: 'RELEASE_NOT_FOUND' }; }
|
|
65
|
+
const bridgeRoot = join(resolvedRelease, 'official-web-bridge');
|
|
66
|
+
const profileRoot = officialWebProfileDir({ home });
|
|
67
|
+
const profileManifest = join(profileRoot, 'package.json');
|
|
68
|
+
const profile = validOfficialManifest(profileManifest);
|
|
69
|
+
if (!profile.ok) return profile;
|
|
70
|
+
const previous = readState(home);
|
|
71
|
+
const backupFile = makeBackup({ home, profileManifest, previous });
|
|
72
|
+
const registration = ensurePluginRegistration({
|
|
73
|
+
profileRoot,
|
|
74
|
+
root: bridgeRoot,
|
|
75
|
+
name: OFFICIAL_BRIDGE_PACKAGE,
|
|
76
|
+
createProfile: false,
|
|
77
|
+
});
|
|
78
|
+
if (!registration.ok) {
|
|
79
|
+
return { ok: false, code: registration.code === 'CREW_PROFILE_METADATA_INVALID' ? 'OFFICIAL_WEB_PROFILE_INVALID' : registration.code };
|
|
80
|
+
}
|
|
81
|
+
const stateChanged = previous?.enabled !== true || previous?.release_dir !== resolvedRelease || previous?.backup_file !== backupFile;
|
|
82
|
+
writeState(home, {
|
|
83
|
+
enabled: true,
|
|
84
|
+
release_dir: resolvedRelease,
|
|
85
|
+
backup_file: backupFile,
|
|
86
|
+
package: OFFICIAL_BRIDGE_PACKAGE,
|
|
87
|
+
});
|
|
88
|
+
return { ...registration, changed: registration.changed || stateChanged, backupFile };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function officialWebIntegrationStatus({ home = homedir(), releaseDir } = {}) {
|
|
92
|
+
const state = readState(home);
|
|
93
|
+
if (state?.enabled !== true) return { enabled: false, healthy: false, state };
|
|
94
|
+
const expectedRelease = releaseDir ?? state.release_dir;
|
|
95
|
+
let expectedBridge;
|
|
96
|
+
try { expectedBridge = realpathSync(join(expectedRelease, 'official-web-bridge')); } catch {
|
|
97
|
+
return { enabled: true, healthy: false, code: 'BRIDGE_RELEASE_MISSING', state };
|
|
98
|
+
}
|
|
99
|
+
const profileRoot = officialWebProfileDir({ home });
|
|
100
|
+
const profile = validOfficialManifest(join(profileRoot, 'package.json'));
|
|
101
|
+
if (!profile.ok) return { enabled: true, healthy: false, code: profile.code, state };
|
|
102
|
+
const dependency = profile.manifest.dependencies?.[OFFICIAL_BRIDGE_PACKAGE];
|
|
103
|
+
const bundled = profile.manifest.dsh.profile.bundles.includes(OFFICIAL_BRIDGE_PACKAGE);
|
|
104
|
+
const linkPath = join(profileRoot, 'node_modules', ...OFFICIAL_BRIDGE_PACKAGE.split('/'));
|
|
105
|
+
let linked = false;
|
|
106
|
+
try { linked = lstatSync(linkPath).isSymbolicLink() && realpathSync(linkPath) === expectedBridge; } catch {}
|
|
107
|
+
return { enabled: true, healthy: Boolean(dependency && bundled && linked), state, linkPath, expectedBridge };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function removeOfficialWebIntegration({ home = homedir(), remember = true, preserveIntent = false } = {}) {
|
|
111
|
+
const profileRoot = officialWebProfileDir({ home });
|
|
112
|
+
const profileManifest = join(profileRoot, 'package.json');
|
|
113
|
+
const profile = validOfficialManifest(profileManifest);
|
|
114
|
+
if (!profile.ok && profile.code !== 'OFFICIAL_WEB_PROFILE_NOT_FOUND') return profile;
|
|
115
|
+
let removed = false;
|
|
116
|
+
if (profile.ok) {
|
|
117
|
+
const result = removeCrewPluginRegistration({ home, name: OFFICIAL_BRIDGE_PACKAGE, profileRoot });
|
|
118
|
+
if (!result.ok) return { ok: false, code: result.code === 'CREW_PROFILE_METADATA_INVALID' ? 'OFFICIAL_WEB_PROFILE_INVALID' : result.code };
|
|
119
|
+
removed = result.removed;
|
|
120
|
+
}
|
|
121
|
+
const previous = readState(home);
|
|
122
|
+
if (remember) writeState(home, {
|
|
123
|
+
...(previous ?? {}),
|
|
124
|
+
enabled: preserveIntent ? previous?.enabled === true : false,
|
|
125
|
+
package: OFFICIAL_BRIDGE_PACKAGE,
|
|
126
|
+
});
|
|
127
|
+
else {
|
|
128
|
+
const stateFile = officialWebIntegrationStateFile({ home });
|
|
129
|
+
if (existsSync(stateFile)) unlinkSync(stateFile);
|
|
130
|
+
}
|
|
131
|
+
return { ok: true, removed };
|
|
132
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { crewDshHome } from './install/install.mjs';
|
|
6
|
+
import { crewDshRuntimeModule } from './dsh-cli-runtime.mjs';
|
|
7
|
+
|
|
8
|
+
export const CREW_BRIDGE_PREFIX = '/_dsh/dsh-crew';
|
|
9
|
+
export const CREW_BRIDGE_TARGET = 'http://127.0.0.1:3210';
|
|
10
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
11
|
+
const HOP_BY_HOP = new Set([
|
|
12
|
+
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
13
|
+
'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export function isLoopbackAddress(address) {
|
|
17
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isLocalHostname(hostname) {
|
|
21
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isTrustedLocalRequest(req) {
|
|
25
|
+
if (!isLoopbackAddress(req?.socket?.remoteAddress)) return false;
|
|
26
|
+
const host = typeof req?.headers?.host === 'string' ? req.headers.host.trim().toLowerCase() : '';
|
|
27
|
+
if (!host) return false;
|
|
28
|
+
let authority;
|
|
29
|
+
try { authority = new URL(`http://${host}`); } catch { return false; }
|
|
30
|
+
if (!isLocalHostname(authority.hostname.toLowerCase())) return false;
|
|
31
|
+
const fetchSite = String(req?.headers?.['sec-fetch-site'] ?? '').toLowerCase();
|
|
32
|
+
if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return false;
|
|
33
|
+
const origin = req?.headers?.origin;
|
|
34
|
+
if (origin !== undefined) {
|
|
35
|
+
if (typeof origin !== 'string') return false;
|
|
36
|
+
let parsedOrigin;
|
|
37
|
+
try { parsedOrigin = new URL(origin); } catch { return false; }
|
|
38
|
+
if (!isLocalHostname(parsedOrigin.hostname.toLowerCase()) || parsedOrigin.host.toLowerCase() !== host) return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function safeHeaders(source) {
|
|
44
|
+
const result = {};
|
|
45
|
+
for (const [rawName, rawValue] of Object.entries(source ?? {})) {
|
|
46
|
+
const name = rawName.toLowerCase();
|
|
47
|
+
if (HOP_BY_HOP.has(name) || rawValue === undefined) continue;
|
|
48
|
+
result[name] = Array.isArray(rawValue) ? rawValue.join(', ') : String(rawValue);
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sendJson(res, status, value) {
|
|
54
|
+
const body = Buffer.from(JSON.stringify(value));
|
|
55
|
+
res.writeHead(status, {
|
|
56
|
+
'content-type': 'application/json; charset=utf-8',
|
|
57
|
+
'content-length': String(body.length),
|
|
58
|
+
'cache-control': 'no-store',
|
|
59
|
+
'x-content-type-options': 'nosniff',
|
|
60
|
+
'x-dsh-crew-bridge': '3080-to-3210',
|
|
61
|
+
});
|
|
62
|
+
res.end(body);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readBoundedBody(req, limit = MAX_BODY_BYTES) {
|
|
66
|
+
const chunks = [];
|
|
67
|
+
let size = 0;
|
|
68
|
+
for await (const chunk of req) {
|
|
69
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
70
|
+
size += value.length;
|
|
71
|
+
if (size > limit) throw Object.assign(new Error('request too large'), { code: 'BODY_TOO_LARGE' });
|
|
72
|
+
chunks.push(value);
|
|
73
|
+
}
|
|
74
|
+
return Buffer.concat(chunks);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function defaultHealthCheck(fetchImpl = globalThis.fetch) {
|
|
78
|
+
try {
|
|
79
|
+
const response = await fetchImpl(`${CREW_BRIDGE_TARGET}${CREW_BRIDGE_PREFIX}/ping`, {
|
|
80
|
+
signal: AbortSignal.timeout(1_500),
|
|
81
|
+
headers: { accept: 'application/json' },
|
|
82
|
+
});
|
|
83
|
+
return response.ok;
|
|
84
|
+
} catch { return false; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
88
|
+
|
|
89
|
+
export function createCrewSidecarSupervisor({
|
|
90
|
+
home = homedir(),
|
|
91
|
+
exists = existsSync,
|
|
92
|
+
healthCheck = () => defaultHealthCheck(),
|
|
93
|
+
spawnImpl = spawn,
|
|
94
|
+
wait = delay,
|
|
95
|
+
maxAttempts = 120,
|
|
96
|
+
pollInterval = 250,
|
|
97
|
+
} = {}) {
|
|
98
|
+
let starting = null;
|
|
99
|
+
let runningChild = null;
|
|
100
|
+
const runtime = crewDshRuntimeModule({ home });
|
|
101
|
+
const dshHome = crewDshHome({ home });
|
|
102
|
+
|
|
103
|
+
async function start() {
|
|
104
|
+
if (await healthCheck()) return { ok: true, started: false };
|
|
105
|
+
if (!exists(runtime)) return { ok: false, code: 'CREW_RUNTIME_NOT_INSTALLED' };
|
|
106
|
+
const childAlive = runningChild && runningChild.killed !== true && runningChild.exitCode == null;
|
|
107
|
+
if (!childAlive) {
|
|
108
|
+
runningChild = spawnImpl(process.execPath, [
|
|
109
|
+
runtime, '--profile', 'dsh-crew', '--host', '127.0.0.1', '--port', '3210',
|
|
110
|
+
], {
|
|
111
|
+
cwd: dshHome,
|
|
112
|
+
env: { ...process.env, DSH_HOME: dshHome },
|
|
113
|
+
detached: true,
|
|
114
|
+
stdio: 'ignore',
|
|
115
|
+
windowsHide: true,
|
|
116
|
+
});
|
|
117
|
+
const ownedChild = runningChild;
|
|
118
|
+
ownedChild.once?.('exit', () => { if (runningChild === ownedChild) runningChild = null; });
|
|
119
|
+
ownedChild.unref?.();
|
|
120
|
+
}
|
|
121
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
122
|
+
if (await healthCheck()) return { ok: true, started: true };
|
|
123
|
+
await wait(pollInterval);
|
|
124
|
+
}
|
|
125
|
+
return { ok: false, code: 'CREW_BACKEND_START_TIMEOUT' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
ensure() {
|
|
130
|
+
if (!starting) starting = start().finally(() => { starting = null; });
|
|
131
|
+
return starting;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const processSupervisor = createCrewSidecarSupervisor();
|
|
137
|
+
|
|
138
|
+
export async function proxyCrewRequest(req, res, {
|
|
139
|
+
fetchImpl = globalThis.fetch,
|
|
140
|
+
ensureBackend = () => processSupervisor.ensure(),
|
|
141
|
+
} = {}) {
|
|
142
|
+
if (!isTrustedLocalRequest(req)) {
|
|
143
|
+
sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
let pathname;
|
|
147
|
+
try { pathname = new URL(req.url, 'http://127.0.0.1:3080').pathname; } catch { pathname = ''; }
|
|
148
|
+
if (pathname !== CREW_BRIDGE_PREFIX && !pathname.startsWith(`${CREW_BRIDGE_PREFIX}/`)) {
|
|
149
|
+
sendJson(res, 404, { ok: false, code: 'NOT_FOUND' });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const backend = await ensureBackend();
|
|
154
|
+
if (backend?.ok === false) throw new Error('backend unavailable');
|
|
155
|
+
const method = String(req.method ?? 'GET').toUpperCase();
|
|
156
|
+
const bodyBuffer = method === 'GET' || method === 'HEAD' ? null : await readBoundedBody(req);
|
|
157
|
+
const response = await fetchImpl(`${CREW_BRIDGE_TARGET}${req.url}`, {
|
|
158
|
+
method,
|
|
159
|
+
headers: safeHeaders(req.headers),
|
|
160
|
+
body: bodyBuffer === null ? undefined : new Blob([bodyBuffer]),
|
|
161
|
+
signal: AbortSignal.timeout(120_000),
|
|
162
|
+
});
|
|
163
|
+
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
164
|
+
const headers = safeHeaders(Object.fromEntries(response.headers.entries()));
|
|
165
|
+
headers['content-length'] = String(responseBody.length);
|
|
166
|
+
headers['x-dsh-crew-bridge'] = '3080-to-3210';
|
|
167
|
+
res.writeHead(response.status, headers);
|
|
168
|
+
res.end(responseBody);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error?.code === 'BODY_TOO_LARGE') sendJson(res, 413, { ok: false, code: 'REQUEST_TOO_LARGE' });
|
|
171
|
+
else sendJson(res, 503, { ok: false, code: 'CREW_BACKEND_UNAVAILABLE' });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function registerOfficialWebBridge(ctx, options = {}) {
|
|
176
|
+
return ctx.inject(['webServer'], (webCtx) => {
|
|
177
|
+
const disposeStatus = webCtx.webServer.register({
|
|
178
|
+
kind: 'exact',
|
|
179
|
+
path: `${CREW_BRIDGE_PREFIX}/bridge-status`,
|
|
180
|
+
handler: (req, res) => {
|
|
181
|
+
if (!isTrustedLocalRequest(req)) return sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
|
|
182
|
+
return sendJson(res, 200, { ok: true, mode: 'official-3080-isolated-3210' });
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
const disposeProxy = webCtx.webServer.register({
|
|
186
|
+
kind: 'prefix',
|
|
187
|
+
path: CREW_BRIDGE_PREFIX,
|
|
188
|
+
handler: (req, res) => proxyCrewRequest(req, res, options),
|
|
189
|
+
});
|
|
190
|
+
return () => { disposeProxy?.(); disposeStatus?.(); };
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function apply(ctx) {
|
|
195
|
+
registerOfficialWebBridge(ctx);
|
|
196
|
+
}
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Keep this module pure and dependency-free so Hub, MCP and tests all use the
|
|
9
9
|
// exact same compatibility rules.
|
|
10
10
|
|
|
11
|
-
export const RUNTIME_VERSION = '0.3.
|
|
11
|
+
export const RUNTIME_VERSION = '0.3.8';
|
|
12
12
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
13
13
|
|
|
14
14
|
export const HUB_CAPABILITIES = Object.freeze([
|