dsh-webui-studio 0.1.0
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/LICENSE +21 -0
- package/PRODUCT.md +74 -0
- package/README.md +213 -0
- package/README.zh-CN.md +196 -0
- package/assets/harmony-icon-mono.png +0 -0
- package/assets/harmony-icon.png +0 -0
- package/dist/bridge.js +13 -0
- package/dist/studio.css +2 -0
- package/dist/studio.js +30429 -0
- package/docs/harmony-api-requirements.md +24 -0
- package/docs/source-baseline.md +55 -0
- package/lib/contracts.d.ts +246 -0
- package/lib/contracts.js +4 -0
- package/lib/host/agent.d.ts +31 -0
- package/lib/host/agent.js +128 -0
- package/lib/host/backend.d.ts +25 -0
- package/lib/host/backend.js +321 -0
- package/lib/host/build.d.ts +26 -0
- package/lib/host/build.js +137 -0
- package/lib/host/drafts.d.ts +23 -0
- package/lib/host/drafts.js +394 -0
- package/lib/host/preview-worker.d.ts +11 -0
- package/lib/host/preview-worker.js +110 -0
- package/lib/host/preview.d.ts +42 -0
- package/lib/host/preview.js +249 -0
- package/lib/host/project-files.d.ts +8 -0
- package/lib/host/project-files.js +110 -0
- package/lib/host/readiness.d.ts +11 -0
- package/lib/host/readiness.js +247 -0
- package/lib/host/routes.d.ts +17 -0
- package/lib/host/routes.js +163 -0
- package/lib/host/runtime-profile.d.ts +16 -0
- package/lib/host/runtime-profile.js +85 -0
- package/lib/host/source-resolution.d.ts +7 -0
- package/lib/host/source-resolution.js +154 -0
- package/lib/host/workspace.d.ts +7 -0
- package/lib/host/workspace.js +66 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +67 -0
- package/package.json +122 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { STUDIO_API_PATH, STUDIO_PATH } from '../contracts.js';
|
|
2
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
3
|
+
function remoteIsLoopback(request) {
|
|
4
|
+
const address = request.socket.remoteAddress;
|
|
5
|
+
return address === '::1' || address === '127.0.0.1' || address?.startsWith('127.') === true
|
|
6
|
+
|| address?.startsWith('::ffff:127.') === true;
|
|
7
|
+
}
|
|
8
|
+
export function isTrustedStudioRequest(request) {
|
|
9
|
+
if (!remoteIsLoopback(request))
|
|
10
|
+
return false;
|
|
11
|
+
const fetchSite = request.headers['sec-fetch-site'];
|
|
12
|
+
return fetchSite === undefined || fetchSite === 'same-origin' || fetchSite === 'none';
|
|
13
|
+
}
|
|
14
|
+
async function readJson(request) {
|
|
15
|
+
const chunks = [];
|
|
16
|
+
let size = 0;
|
|
17
|
+
for await (const chunk of request) {
|
|
18
|
+
const buffer = Buffer.from(chunk);
|
|
19
|
+
size += buffer.length;
|
|
20
|
+
if (size > MAX_BODY_BYTES)
|
|
21
|
+
throw new Error('request body is too large');
|
|
22
|
+
chunks.push(buffer);
|
|
23
|
+
}
|
|
24
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
25
|
+
}
|
|
26
|
+
function sendJson(response, status, value) {
|
|
27
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
28
|
+
response.end(JSON.stringify(value));
|
|
29
|
+
}
|
|
30
|
+
function sendAsset(request, response, contentType, body) {
|
|
31
|
+
response.writeHead(200, {
|
|
32
|
+
'cache-control': 'no-cache',
|
|
33
|
+
'content-length': body.length,
|
|
34
|
+
'content-type': contentType,
|
|
35
|
+
});
|
|
36
|
+
response.end(request.method === 'HEAD' ? undefined : body);
|
|
37
|
+
}
|
|
38
|
+
function documentHtml(token) {
|
|
39
|
+
return `<!doctype html>
|
|
40
|
+
<html lang="zh-CN">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="UTF-8" />
|
|
43
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
44
|
+
<meta name="color-scheme" content="light dark" />
|
|
45
|
+
<meta name="referrer" content="no-referrer" />
|
|
46
|
+
<title>DeepSeek WebUI Studio</title>
|
|
47
|
+
<link rel="stylesheet" href="${STUDIO_PATH}/assets/studio.css" />
|
|
48
|
+
<script>window.__DSH_STUDIO__={token:${JSON.stringify(token)}};</script>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<div id="root"></div>
|
|
52
|
+
<script type="module" src="${STUDIO_PATH}/assets/studio.js"></script>
|
|
53
|
+
</body>
|
|
54
|
+
</html>`;
|
|
55
|
+
}
|
|
56
|
+
function hasStudioCapability(request, security) {
|
|
57
|
+
return request.headers.host === security.host
|
|
58
|
+
&& request.headers.origin === security.origin
|
|
59
|
+
&& request.headers['x-dsh-studio-token'] === security.token;
|
|
60
|
+
}
|
|
61
|
+
function rejectUntrusted(request, response) {
|
|
62
|
+
if (isTrustedStudioRequest(request))
|
|
63
|
+
return false;
|
|
64
|
+
sendJson(response, 403, { error: 'Studio is available from the local machine only.' });
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
export function createStudioRoutes(backend, assets, security) {
|
|
68
|
+
const page = Buffer.from(documentHtml(security.token));
|
|
69
|
+
const apiHandler = async (request, response) => {
|
|
70
|
+
if (rejectUntrusted(request, response))
|
|
71
|
+
return;
|
|
72
|
+
if (!hasStudioCapability(request, security))
|
|
73
|
+
return sendJson(response, 403, { error: 'invalid Studio capability' });
|
|
74
|
+
if (request.method !== 'POST')
|
|
75
|
+
return sendJson(response, 405, { error: 'method not allowed' });
|
|
76
|
+
if ((request.headers['content-type'] ?? '').split(';')[0] !== 'application/json') {
|
|
77
|
+
return sendJson(response, 415, { error: 'content-type must be application/json' });
|
|
78
|
+
}
|
|
79
|
+
const path = new URL(request.url ?? '/', 'http://localhost').pathname;
|
|
80
|
+
const method = path.slice(`${STUDIO_API_PATH}/`.length);
|
|
81
|
+
try {
|
|
82
|
+
const body = await readJson(request);
|
|
83
|
+
const candidate = body;
|
|
84
|
+
if (candidate.type !== 'client-request' || typeof candidate.rpcId !== 'string'
|
|
85
|
+
|| candidate.method !== method) {
|
|
86
|
+
return sendJson(response, 400, { error: 'invalid client-request' });
|
|
87
|
+
}
|
|
88
|
+
sendJson(response, 200, await backend.call(candidate));
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
return [
|
|
95
|
+
{
|
|
96
|
+
kind: 'exact',
|
|
97
|
+
path: STUDIO_PATH,
|
|
98
|
+
handler(request, response) {
|
|
99
|
+
if (rejectUntrusted(request, response))
|
|
100
|
+
return;
|
|
101
|
+
if (request.method !== 'GET' && request.method !== 'HEAD')
|
|
102
|
+
return sendJson(response, 405, { error: 'method not allowed' });
|
|
103
|
+
sendAsset(request, response, 'text/html; charset=utf-8', page);
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
kind: 'exact',
|
|
108
|
+
path: `${STUDIO_PATH}/bridge.js`,
|
|
109
|
+
handler(request, response) {
|
|
110
|
+
if (rejectUntrusted(request, response))
|
|
111
|
+
return;
|
|
112
|
+
if (request.method !== 'GET' && request.method !== 'HEAD')
|
|
113
|
+
return sendJson(response, 405, { error: 'method not allowed' });
|
|
114
|
+
sendAsset(request, response, 'text/javascript; charset=utf-8', assets.bridge);
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
kind: 'exact',
|
|
119
|
+
path: `${STUDIO_PATH}/assets/studio.js`,
|
|
120
|
+
handler(request, response) {
|
|
121
|
+
if (rejectUntrusted(request, response))
|
|
122
|
+
return;
|
|
123
|
+
if (request.method !== 'GET' && request.method !== 'HEAD')
|
|
124
|
+
return sendJson(response, 405, { error: 'method not allowed' });
|
|
125
|
+
sendAsset(request, response, 'text/javascript; charset=utf-8', assets.script);
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
kind: 'exact',
|
|
130
|
+
path: `${STUDIO_PATH}/assets/studio.css`,
|
|
131
|
+
handler(request, response) {
|
|
132
|
+
if (rejectUntrusted(request, response))
|
|
133
|
+
return;
|
|
134
|
+
if (request.method !== 'GET' && request.method !== 'HEAD')
|
|
135
|
+
return sendJson(response, 405, { error: 'method not allowed' });
|
|
136
|
+
sendAsset(request, response, 'text/css; charset=utf-8', assets.style);
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
kind: 'exact',
|
|
141
|
+
path: `${STUDIO_PATH}/assets/harmony-icon.png`,
|
|
142
|
+
handler(request, response) {
|
|
143
|
+
if (rejectUntrusted(request, response))
|
|
144
|
+
return;
|
|
145
|
+
if (request.method !== 'GET' && request.method !== 'HEAD')
|
|
146
|
+
return sendJson(response, 405, { error: 'method not allowed' });
|
|
147
|
+
sendAsset(request, response, 'image/png', assets.icon);
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
kind: 'exact',
|
|
152
|
+
path: `${STUDIO_PATH}/assets/harmony-icon-mono.png`,
|
|
153
|
+
handler(request, response) {
|
|
154
|
+
if (rejectUntrusted(request, response))
|
|
155
|
+
return;
|
|
156
|
+
if (request.method !== 'GET' && request.method !== 'HEAD')
|
|
157
|
+
return sendJson(response, 405, { error: 'method not allowed' });
|
|
158
|
+
sendAsset(request, response, 'image/png', assets.iconMono);
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{ kind: 'prefix', path: STUDIO_API_PATH, handler: apiHandler },
|
|
162
|
+
];
|
|
163
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { StudioDraftRecord } from '../contracts.js';
|
|
2
|
+
import type { StudioCommandRunner } from './drafts.js';
|
|
3
|
+
interface DraftManifest {
|
|
4
|
+
name?: unknown;
|
|
5
|
+
packageManager?: unknown;
|
|
6
|
+
dependencies?: Record<string, unknown>;
|
|
7
|
+
devDependencies?: Record<string, unknown>;
|
|
8
|
+
optionalDependencies?: Record<string, unknown>;
|
|
9
|
+
peerDependencies?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
export declare function bundledPnpmCommand(args: readonly string[]): [string, string[]];
|
|
12
|
+
export declare function terminalCommandLine(cwd: string, command: string, args: readonly string[]): string;
|
|
13
|
+
export declare function assertDraftPackageIdentity(draft: StudioDraftRecord): Promise<DraftManifest>;
|
|
14
|
+
export declare function installDraftDependencies(draft: StudioDraftRecord, commands: StudioCommandRunner, onOutput?: (chunk: string) => void, signal?: AbortSignal): Promise<void>;
|
|
15
|
+
export declare function materializeDraftProfile(draft: StudioDraftRecord, mainProfileDir: string, studioPackageRoot: string, commands: StudioCommandRunner, onOutput?: (chunk: string) => void, signal?: AbortSignal): Promise<string>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { resolvePackageManager } from './build.js';
|
|
5
|
+
const PROFILE_FILES = ['cordis.patch.yml', 'cordis.yml', 'harmony.json', 'pnpm-workspace.yaml'];
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const PNPM_ENTRY = join(dirname(require.resolve('pnpm')), 'bin', 'pnpm.cjs');
|
|
8
|
+
export function bundledPnpmCommand(args) {
|
|
9
|
+
return [process.execPath, [PNPM_ENTRY, ...args]];
|
|
10
|
+
}
|
|
11
|
+
function terminalToken(value) {
|
|
12
|
+
return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
13
|
+
}
|
|
14
|
+
export function terminalCommandLine(cwd, command, args) {
|
|
15
|
+
return `${cwd}\n$ ${[command, ...args].map(terminalToken).join(' ')}\n`;
|
|
16
|
+
}
|
|
17
|
+
function hasDependencies(manifest) {
|
|
18
|
+
return [manifest.dependencies, manifest.devDependencies, manifest.optionalDependencies, manifest.peerDependencies]
|
|
19
|
+
.some(dependencies => dependencies !== undefined && Object.keys(dependencies).length > 0);
|
|
20
|
+
}
|
|
21
|
+
export async function assertDraftPackageIdentity(draft) {
|
|
22
|
+
const manifest = JSON.parse(await readFile(join(draft.root, 'package.json'), 'utf8'));
|
|
23
|
+
if (manifest.name !== draft.name) {
|
|
24
|
+
throw new Error(`Draft package.json name must remain ${JSON.stringify(draft.name)}`);
|
|
25
|
+
}
|
|
26
|
+
return manifest;
|
|
27
|
+
}
|
|
28
|
+
export async function installDraftDependencies(draft, commands, onOutput, signal) {
|
|
29
|
+
signal?.throwIfAborted();
|
|
30
|
+
const manifest = await assertDraftPackageIdentity(draft);
|
|
31
|
+
if (!hasDependencies(manifest))
|
|
32
|
+
return;
|
|
33
|
+
const manager = resolvePackageManager(draft.root, manifest);
|
|
34
|
+
const [command, args] = manager === 'pnpm' ? bundledPnpmCommand(['install']) : [manager, ['install']];
|
|
35
|
+
onOutput?.(terminalCommandLine(draft.root, command, args));
|
|
36
|
+
try {
|
|
37
|
+
await commands.run(command, args, draft.root, onOutput, signal);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
signal?.throwIfAborted();
|
|
41
|
+
const message = (error instanceof Error ? error.message : String(error)).split('\n', 1)[0];
|
|
42
|
+
onOutput?.(`[studio] ${message}\n`);
|
|
43
|
+
throw new Error('Draft dependency installation failed. Check the startup terminal for details.');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function absoluteLink(spec, profileDir) {
|
|
47
|
+
if (!spec.startsWith('link:'))
|
|
48
|
+
return spec;
|
|
49
|
+
const target = spec.slice('link:'.length);
|
|
50
|
+
return `link:${isAbsolute(target) ? target : resolve(profileDir, target)}`;
|
|
51
|
+
}
|
|
52
|
+
export async function materializeDraftProfile(draft, mainProfileDir, studioPackageRoot, commands, onOutput, signal) {
|
|
53
|
+
signal?.throwIfAborted();
|
|
54
|
+
if (draft.profileMode !== 'main-home')
|
|
55
|
+
throw new Error('Custom Draft profiles are not implemented yet');
|
|
56
|
+
const profileDir = join(draft.runtimeHome, 'profiles', 'web');
|
|
57
|
+
await rm(profileDir, { recursive: true, force: true });
|
|
58
|
+
await mkdir(profileDir, { recursive: true });
|
|
59
|
+
const manifest = JSON.parse(await readFile(join(mainProfileDir, 'package.json'), 'utf8'));
|
|
60
|
+
const dependencies = Object.fromEntries(Object.entries(manifest.dependencies ?? {}).map(([name, spec]) => [name, absoluteLink(spec, mainProfileDir)]));
|
|
61
|
+
dependencies[draft.name] = `link:${draft.root}`;
|
|
62
|
+
dependencies['dsh-webui-studio'] = `link:${studioPackageRoot}`;
|
|
63
|
+
await writeFile(join(profileDir, 'package.json'), `${JSON.stringify({ ...manifest, dependencies }, null, 2)}\n`);
|
|
64
|
+
for (const file of PROFILE_FILES) {
|
|
65
|
+
try {
|
|
66
|
+
await cp(join(mainProfileDir, file), join(profileDir, file));
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (error.code !== 'ENOENT')
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const [command, args] = bundledPnpmCommand(['install', '--prefer-offline']);
|
|
74
|
+
onOutput?.(terminalCommandLine(profileDir, command, args));
|
|
75
|
+
try {
|
|
76
|
+
await commands.run(command, args, profileDir, onOutput, signal);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
signal?.throwIfAborted();
|
|
80
|
+
const message = (error instanceof Error ? error.message : String(error)).split('\n', 1)[0];
|
|
81
|
+
onOutput?.(`[studio] ${message}\n`);
|
|
82
|
+
throw new Error('Profile dependency installation failed. Check the startup terminal for details.');
|
|
83
|
+
}
|
|
84
|
+
return profileDir;
|
|
85
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { StudioSourceCandidate, StudioSourceLocation } from '../contracts.js';
|
|
2
|
+
export declare class StudioSourceResolver {
|
|
3
|
+
#private;
|
|
4
|
+
constructor(draftRoot: string, profileDir: string);
|
|
5
|
+
resolve(source: StudioSourceLocation): Promise<StudioSourceCandidate>;
|
|
6
|
+
readDependency(packageName: string, file: string): Promise<string>;
|
|
7
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const MAX_SOURCE_BYTES = 1024 * 1024;
|
|
5
|
+
function inside(root, target) {
|
|
6
|
+
const path = relative(root, target);
|
|
7
|
+
return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path));
|
|
8
|
+
}
|
|
9
|
+
function posix(path) {
|
|
10
|
+
return path.split(sep).join('/');
|
|
11
|
+
}
|
|
12
|
+
function relativeSource(path) {
|
|
13
|
+
const normalized = path.replaceAll('\\', '/').replace(/^\.\//, '');
|
|
14
|
+
if (normalized === '' || normalized.startsWith('/') || normalized.split('/').some(part => part === '' || part === '.' || part === '..')) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
return normalized;
|
|
18
|
+
}
|
|
19
|
+
function sourceReference(file) {
|
|
20
|
+
const trimmed = file.trim();
|
|
21
|
+
if (trimmed.startsWith('file:')) {
|
|
22
|
+
try {
|
|
23
|
+
return { absolute: fileURLToPath(trimmed), generated: false };
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { generated: false };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (trimmed.startsWith('/@fs/'))
|
|
30
|
+
return { absolute: trimmed.slice('/@fs'.length).replace(/[?#].*$/, ''), generated: false };
|
|
31
|
+
if (isAbsolute(trimmed))
|
|
32
|
+
return { absolute: trimmed.replace(/[?#].*$/, ''), generated: false };
|
|
33
|
+
const bundler = trimmed.match(/^(?:webpack|webpack-internal|vite):\/\/(.*)$/);
|
|
34
|
+
if (bundler !== null) {
|
|
35
|
+
const body = bundler[1].replace(/[?#].*$/, '');
|
|
36
|
+
const relativeMarker = body.indexOf('/./');
|
|
37
|
+
if (relativeMarker !== -1) {
|
|
38
|
+
const packageName = body.slice(0, relativeMarker).replace(/^\/+|\/+$/g, '');
|
|
39
|
+
return {
|
|
40
|
+
relative: relativeSource(body.slice(relativeMarker + 3)),
|
|
41
|
+
...(packageName === '' ? {} : { package: packageName }),
|
|
42
|
+
generated: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (body.startsWith('/'))
|
|
46
|
+
return { absolute: body.replace(/^\/{2}/, '/'), generated: true };
|
|
47
|
+
return { generated: true };
|
|
48
|
+
}
|
|
49
|
+
if (/^https?:\/\//.test(trimmed))
|
|
50
|
+
return { generated: true };
|
|
51
|
+
return { relative: relativeSource(trimmed.replace(/[?#].*$/, '')), generated: false };
|
|
52
|
+
}
|
|
53
|
+
async function packageRoot(path, expectedName) {
|
|
54
|
+
try {
|
|
55
|
+
const root = await realpath(path);
|
|
56
|
+
const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
57
|
+
return manifest.name === expectedName ? { name: expectedName, root, kind: 'dependency' } : undefined;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function packageRoots(draftRoot, profileDir) {
|
|
64
|
+
const draft = await realpath(draftRoot);
|
|
65
|
+
const manifest = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'));
|
|
66
|
+
const dependencies = Object.keys(manifest.dependencies ?? {});
|
|
67
|
+
const bundles = manifest.dsh?.profile?.bundles ?? [];
|
|
68
|
+
const roots = [{
|
|
69
|
+
name: JSON.parse(await readFile(join(draft, 'package.json'), 'utf8')).name,
|
|
70
|
+
root: draft,
|
|
71
|
+
kind: 'draft',
|
|
72
|
+
}];
|
|
73
|
+
for (const name of dependencies) {
|
|
74
|
+
const installed = await packageRoot(join(profileDir, 'node_modules', ...name.split('/')), name);
|
|
75
|
+
if (installed !== undefined)
|
|
76
|
+
roots.push(installed);
|
|
77
|
+
}
|
|
78
|
+
for (const name of bundles.filter(name => !dependencies.includes(name))) {
|
|
79
|
+
const installed = await packageRoot(join(profileDir, 'node_modules', ...name.split('/')), name);
|
|
80
|
+
if (installed !== undefined)
|
|
81
|
+
roots.push(installed);
|
|
82
|
+
}
|
|
83
|
+
return roots.filter((item, index, all) => all.findIndex(candidate => candidate.root === item.root) === index);
|
|
84
|
+
}
|
|
85
|
+
function result(source, file, kind, confidence, packageName) {
|
|
86
|
+
return {
|
|
87
|
+
...(packageName === undefined ? {} : { package: packageName }),
|
|
88
|
+
file: file.slice(0, 4_000),
|
|
89
|
+
...(source.line === undefined ? {} : { line: source.line }),
|
|
90
|
+
...(source.column === undefined ? {} : { column: source.column }),
|
|
91
|
+
kind,
|
|
92
|
+
confidence,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async function exactMatch(path, roots) {
|
|
96
|
+
let target;
|
|
97
|
+
try {
|
|
98
|
+
target = await realpath(path);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
const matches = roots.filter(item => inside(item.root, target)).sort((left, right) => right.root.length - left.root.length);
|
|
104
|
+
const root = matches[0];
|
|
105
|
+
return root === undefined ? undefined : { root, file: posix(relative(root.root, target)) };
|
|
106
|
+
}
|
|
107
|
+
export class StudioSourceResolver {
|
|
108
|
+
#roots;
|
|
109
|
+
constructor(draftRoot, profileDir) {
|
|
110
|
+
this.#roots = packageRoots(draftRoot, profileDir);
|
|
111
|
+
}
|
|
112
|
+
async resolve(source) {
|
|
113
|
+
const roots = await this.#roots;
|
|
114
|
+
const reference = sourceReference(source.file);
|
|
115
|
+
if (reference.absolute !== undefined) {
|
|
116
|
+
const match = await exactMatch(reference.absolute, roots);
|
|
117
|
+
if (match !== undefined)
|
|
118
|
+
return result(source, match.file, match.root.kind, 'exact', match.root.name);
|
|
119
|
+
}
|
|
120
|
+
if (reference.relative !== undefined) {
|
|
121
|
+
const candidates = roots.filter(root => reference.package === undefined || root.name === reference.package);
|
|
122
|
+
const matches = (await Promise.all(candidates.map(async (root) => {
|
|
123
|
+
const match = await exactMatch(resolve(root.root, reference.relative), [root]);
|
|
124
|
+
return match === undefined ? undefined : { root, file: match.file };
|
|
125
|
+
}))).filter(match => match !== undefined);
|
|
126
|
+
const unique = matches.filter((match, index, all) => all.findIndex(candidate => candidate.root.root === match.root.root && candidate.file === match.file) === index);
|
|
127
|
+
if (unique.length === 1)
|
|
128
|
+
return result(source, unique[0].file, unique[0].root.kind, 'candidate', unique[0].root.name);
|
|
129
|
+
}
|
|
130
|
+
return result(source, source.file, reference.generated ? 'generated' : 'unknown', 'candidate');
|
|
131
|
+
}
|
|
132
|
+
async readDependency(packageName, file) {
|
|
133
|
+
const relativeFile = relativeSource(file);
|
|
134
|
+
if (packageName === '' || relativeFile === undefined)
|
|
135
|
+
throw new Error('dependency source reference is invalid');
|
|
136
|
+
const roots = (await this.#roots).filter(root => root.kind === 'dependency' && root.name === packageName);
|
|
137
|
+
if (roots.length !== 1)
|
|
138
|
+
throw new Error(`dependency package ${JSON.stringify(packageName)} is not uniquely installed in Preview`);
|
|
139
|
+
const match = await exactMatch(resolve(roots[0].root, relativeFile), roots);
|
|
140
|
+
if (match === undefined || match.root !== roots[0] || match.file !== relativeFile) {
|
|
141
|
+
throw new Error('dependency source escapes its installed package root');
|
|
142
|
+
}
|
|
143
|
+
const target = resolve(match.root.root, match.file);
|
|
144
|
+
const info = await lstat(target);
|
|
145
|
+
if (!info.isFile())
|
|
146
|
+
throw new Error('dependency source is not a file');
|
|
147
|
+
if (info.size > MAX_SOURCE_BYTES)
|
|
148
|
+
throw new Error('dependency source exceeds the 1 MiB Studio limit');
|
|
149
|
+
const content = await readFile(target);
|
|
150
|
+
if (content.includes(0))
|
|
151
|
+
throw new Error('binary dependency sources cannot be read');
|
|
152
|
+
return content.toString('utf8');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { StudioWorkspaceState } from '../contracts.js';
|
|
2
|
+
export declare class StudioWorkspaceStore {
|
|
3
|
+
readonly file: string;
|
|
4
|
+
constructor(dshHome: string);
|
|
5
|
+
read(availableDraftIds: readonly string[]): Promise<StudioWorkspaceState>;
|
|
6
|
+
write(state: StudioWorkspaceState, availableDraftIds: readonly string[]): Promise<StudioWorkspaceState>;
|
|
7
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
function parseWorkspace(value) {
|
|
5
|
+
if (typeof value !== 'object' || value === null)
|
|
6
|
+
throw new Error('Studio workspace state must be an object');
|
|
7
|
+
const candidate = value;
|
|
8
|
+
if (!Array.isArray(candidate.openDraftIds) || candidate.openDraftIds.some(id => typeof id !== 'string')) {
|
|
9
|
+
throw new Error('Studio workspace openDraftIds must be an array of Draft ids');
|
|
10
|
+
}
|
|
11
|
+
if (new Set(candidate.openDraftIds).size !== candidate.openDraftIds.length) {
|
|
12
|
+
throw new Error('Studio workspace openDraftIds must not contain duplicates');
|
|
13
|
+
}
|
|
14
|
+
if (candidate.selectedDraftId !== undefined && typeof candidate.selectedDraftId !== 'string') {
|
|
15
|
+
throw new Error('Studio workspace selectedDraftId must be a Draft id');
|
|
16
|
+
}
|
|
17
|
+
if (candidate.openDraftIds.length === 0 && candidate.selectedDraftId !== undefined) {
|
|
18
|
+
throw new Error('Studio workspace cannot select a closed Draft');
|
|
19
|
+
}
|
|
20
|
+
if (candidate.openDraftIds.length > 0
|
|
21
|
+
&& (candidate.selectedDraftId === undefined || !candidate.openDraftIds.includes(candidate.selectedDraftId))) {
|
|
22
|
+
throw new Error('Studio workspace selectedDraftId must identify an open Draft');
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
openDraftIds: [...candidate.openDraftIds],
|
|
26
|
+
...(candidate.selectedDraftId === undefined ? {} : { selectedDraftId: candidate.selectedDraftId }),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export class StudioWorkspaceStore {
|
|
30
|
+
file;
|
|
31
|
+
constructor(dshHome) {
|
|
32
|
+
this.file = join(dshHome, 'studio', 'workspace.json');
|
|
33
|
+
}
|
|
34
|
+
async read(availableDraftIds) {
|
|
35
|
+
let stored;
|
|
36
|
+
try {
|
|
37
|
+
stored = parseWorkspace(JSON.parse(await readFile(this.file, 'utf8')));
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code === 'ENOENT')
|
|
41
|
+
return { openDraftIds: [] };
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
const available = new Set(availableDraftIds);
|
|
45
|
+
const openDraftIds = stored.openDraftIds.filter(id => available.has(id));
|
|
46
|
+
if (openDraftIds.length === 0)
|
|
47
|
+
return { openDraftIds };
|
|
48
|
+
return {
|
|
49
|
+
openDraftIds,
|
|
50
|
+
selectedDraftId: stored.selectedDraftId !== undefined && openDraftIds.includes(stored.selectedDraftId)
|
|
51
|
+
? stored.selectedDraftId
|
|
52
|
+
: openDraftIds[0],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async write(state, availableDraftIds) {
|
|
56
|
+
const next = parseWorkspace(state);
|
|
57
|
+
const available = new Set(availableDraftIds);
|
|
58
|
+
if (next.openDraftIds.some(id => !available.has(id)))
|
|
59
|
+
throw new Error('Studio workspace references an unknown Draft');
|
|
60
|
+
await mkdir(dirname(this.file), { recursive: true });
|
|
61
|
+
const temporary = join(dirname(this.file), `.workspace.${randomUUID()}.tmp`);
|
|
62
|
+
await writeFile(temporary, `${JSON.stringify(next, null, 2)}\n`, { flag: 'wx' });
|
|
63
|
+
await rename(temporary, this.file);
|
|
64
|
+
return next;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import '@deepseek-ai/dsh-agent';
|
|
3
|
+
import '@deepseek-ai/dsh-host-apiproxy';
|
|
4
|
+
import '@deepseek-ai/dsh-host-webserver';
|
|
5
|
+
import '@deepseek-ai/dsh-system-prompt';
|
|
6
|
+
import '@deepseek-ai/dsh-subprocess';
|
|
7
|
+
import '@deepseek-ai/dsh-tools';
|
|
8
|
+
export declare const name = "harmony-studio";
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
export declare function apply(ctx: Context): void;
|
|
11
|
+
export { StudioBackend } from './host/backend.js';
|
|
12
|
+
export { StudioBuildError, StudioBuildRunner, resolveBuildArgv } from './host/build.js';
|
|
13
|
+
export type { StudioBuildOutput } from './host/build.js';
|
|
14
|
+
export { StudioDraftRegistry, dshHomeFromProfile } from './host/drafts.js';
|
|
15
|
+
export { inspectReadiness, StudioPackRunner } from './host/readiness.js';
|
|
16
|
+
export { StudioPreviewSupervisor } from './host/preview.js';
|
|
17
|
+
export { createStudioRoutes, isTrustedStudioRequest } from './host/routes.js';
|
|
18
|
+
export { StudioWorkspaceStore } from './host/workspace.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import '@deepseek-ai/dsh-agent';
|
|
4
|
+
import '@deepseek-ai/dsh-host-apiproxy';
|
|
5
|
+
import '@deepseek-ai/dsh-host-webserver';
|
|
6
|
+
import '@deepseek-ai/dsh-system-prompt';
|
|
7
|
+
import '@deepseek-ai/dsh-subprocess';
|
|
8
|
+
import '@deepseek-ai/dsh-tools';
|
|
9
|
+
import { STUDIO_PATH } from './contracts.js';
|
|
10
|
+
import { StudioBackend } from './host/backend.js';
|
|
11
|
+
import { dshHomeFromProfile, StudioDraftRegistry, studioCommands } from './host/drafts.js';
|
|
12
|
+
import { applyPreviewWorker } from './host/preview-worker.js';
|
|
13
|
+
import { createStudioRoutes } from './host/routes.js';
|
|
14
|
+
import { StudioWorkspaceStore } from './host/workspace.js';
|
|
15
|
+
export const name = 'harmony-studio';
|
|
16
|
+
export const inject = ['harmony', 'agents', 'tools', 'systemPrompt', 'webServer', 'subprocess'];
|
|
17
|
+
export function apply(ctx) {
|
|
18
|
+
ctx.effect(() => {
|
|
19
|
+
if (ctx.webServer.host !== '127.0.0.1') {
|
|
20
|
+
ctx.logger.warn('harmony-studio: Studio is disabled because dsh web is not bound to 127.0.0.1');
|
|
21
|
+
return () => { };
|
|
22
|
+
}
|
|
23
|
+
const harmony = ctx.harmony;
|
|
24
|
+
const assets = {
|
|
25
|
+
script: readFileSync(new URL('../dist/studio.js', import.meta.url)),
|
|
26
|
+
style: readFileSync(new URL('../dist/studio.css', import.meta.url)),
|
|
27
|
+
bridge: readFileSync(new URL('../dist/bridge.js', import.meta.url)),
|
|
28
|
+
icon: readFileSync(new URL('../assets/harmony-icon.png', import.meta.url)),
|
|
29
|
+
iconMono: readFileSync(new URL('../assets/harmony-icon-mono.png', import.meta.url)),
|
|
30
|
+
};
|
|
31
|
+
const previewRoot = process.env.DSH_STUDIO_PREVIEW_DRAFT_ROOT;
|
|
32
|
+
if (previewRoot !== undefined) {
|
|
33
|
+
const controlToken = process.env.DSH_STUDIO_PREVIEW_CONTROL_TOKEN;
|
|
34
|
+
const parentOrigin = process.env.DSH_STUDIO_PREVIEW_PARENT_ORIGIN;
|
|
35
|
+
const bridgeCapability = process.env.DSH_STUDIO_PREVIEW_BRIDGE_CAPABILITY;
|
|
36
|
+
if (controlToken === undefined || parentOrigin === undefined || bridgeCapability === undefined) {
|
|
37
|
+
throw new Error('harmony-studio: Preview worker environment is incomplete');
|
|
38
|
+
}
|
|
39
|
+
applyPreviewWorker(ctx, harmony, { root: previewRoot, controlToken, parentOrigin, bridgeCapability, bridge: assets.bridge });
|
|
40
|
+
return () => { };
|
|
41
|
+
}
|
|
42
|
+
const token = randomBytes(32).toString('hex');
|
|
43
|
+
const host = `127.0.0.1:${ctx.webServer.port}`;
|
|
44
|
+
const dshHome = dshHomeFromProfile(harmony.profileDir);
|
|
45
|
+
const backend = new StudioBackend(harmony, ctx.agents, ctx.subprocess, new StudioDraftRegistry(dshHome), new StudioWorkspaceStore(dshHome), studioCommands, `http://${host}`);
|
|
46
|
+
const dispose = [
|
|
47
|
+
...createStudioRoutes(backend, assets, { token, host, origin: `http://${host}` }).map(route => ctx.webServer.register(route)),
|
|
48
|
+
ctx.webServer.tapIndex(html => {
|
|
49
|
+
const script = `<script src="${STUDIO_PATH}/bridge.js"></script>`;
|
|
50
|
+
const head = html.indexOf('<head>');
|
|
51
|
+
return head === -1 ? `${script}${html}` : `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`;
|
|
52
|
+
}),
|
|
53
|
+
];
|
|
54
|
+
return async () => {
|
|
55
|
+
for (const stop of dispose.reverse())
|
|
56
|
+
stop();
|
|
57
|
+
await backend.dispose();
|
|
58
|
+
};
|
|
59
|
+
}, 'harmony-studio: routes');
|
|
60
|
+
}
|
|
61
|
+
export { StudioBackend } from './host/backend.js';
|
|
62
|
+
export { StudioBuildError, StudioBuildRunner, resolveBuildArgv } from './host/build.js';
|
|
63
|
+
export { StudioDraftRegistry, dshHomeFromProfile } from './host/drafts.js';
|
|
64
|
+
export { inspectReadiness, StudioPackRunner } from './host/readiness.js';
|
|
65
|
+
export { StudioPreviewSupervisor } from './host/preview.js';
|
|
66
|
+
export { createStudioRoutes, isTrustedStudioRequest } from './host/routes.js';
|
|
67
|
+
export { StudioWorkspaceStore } from './host/workspace.js';
|