dsh-webui-studio 0.1.0 → 0.2.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/PRODUCT.md +11 -7
- package/README.md +69 -21
- package/README.zh-CN.md +64 -20
- package/dist/bridge.js +10 -10
- package/dist/studio.css +1 -1
- package/dist/studio.js +16691 -10202
- package/docs/bidirectional-connection-handoff.md +729 -0
- package/docs/harmony-api-requirements.md +17 -13
- package/docs/remote-development.md +80 -0
- package/lib/bridge/element-style-selector.d.ts +1 -0
- package/lib/bridge/element-style-selector.js +53 -0
- package/lib/contracts.d.ts +152 -83
- package/lib/contracts.js +0 -2
- package/lib/host/agent.d.ts +15 -13
- package/lib/host/agent.js +213 -56
- package/lib/host/automatic-patch.d.ts +9 -0
- package/lib/host/automatic-patch.js +433 -0
- package/lib/host/backend.d.ts +134 -4
- package/lib/host/backend.js +465 -114
- package/lib/host/drafts.d.ts +1 -1
- package/lib/host/drafts.js +65 -15
- package/lib/host/element-source.d.ts +9 -0
- package/lib/host/element-source.js +295 -0
- package/lib/host/mcp.d.ts +4 -0
- package/lib/host/mcp.js +97 -0
- package/lib/host/preview-draft.d.ts +22 -0
- package/lib/host/preview-draft.js +162 -0
- package/lib/host/preview-port.d.ts +8 -0
- package/lib/host/preview-port.js +32 -0
- package/lib/host/preview-worker.d.ts +65 -2
- package/lib/host/preview-worker.js +203 -76
- package/lib/host/preview.d.ts +26 -5
- package/lib/host/preview.js +130 -49
- package/lib/host/readiness.d.ts +2 -2
- package/lib/host/readiness.js +14 -17
- package/lib/host/routes.d.ts +1 -7
- package/lib/host/routes.js +41 -50
- package/lib/host/runtime-profile.d.ts +2 -1
- package/lib/host/runtime-profile.js +30 -8
- package/lib/host/source-resolution.d.ts +11 -1
- package/lib/host/source-resolution.js +69 -24
- package/lib/host/studio-service.d.ts +129 -0
- package/lib/host/studio-service.js +53 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +64 -30
- package/lib/studio-remote.d.ts +126 -0
- package/lib/studio-remote.js +188 -0
- package/lib/variable-tree.d.ts +2 -0
- package/lib/variable-tree.js +13 -0
- package/package.json +62 -25
- package/studio.patch.yml +12 -0
package/lib/host/preview.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
3
5
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
6
|
+
import { NodePeerClient } from 'the-binding-of-dsh';
|
|
7
|
+
import { STUDIO_PREVIEW_REMOTE, } from './preview-worker.js';
|
|
8
|
+
import { studioPreviewPortPool } from './preview-port.js';
|
|
9
|
+
import { buildDraft, installDraftDependencies, materializeDraftProfile, terminalCommandLine } from './runtime-profile.js';
|
|
10
|
+
const START_TIMEOUT_MS = 60_000;
|
|
7
11
|
const LOG_LIMIT = 64_000;
|
|
8
12
|
function appendLog(current, chunk) {
|
|
9
13
|
return `${current}${chunk.toString()}`.slice(-LOG_LIMIT);
|
|
@@ -11,6 +15,22 @@ function appendLog(current, chunk) {
|
|
|
11
15
|
function studioPackageRoot() {
|
|
12
16
|
return fileURLToPath(new URL('../../', import.meta.url));
|
|
13
17
|
}
|
|
18
|
+
function harmonyPackageRoot(harmonyBinEntry) {
|
|
19
|
+
return dirname(dirname(harmonyBinEntry));
|
|
20
|
+
}
|
|
21
|
+
export function dshPackageModules(harmonyBinEntry) {
|
|
22
|
+
const configured = process.env.DSH_HARMONY_DSH_ENTRY;
|
|
23
|
+
const dshEntry = configured === undefined
|
|
24
|
+
? createRequire(harmonyBinEntry).resolve('@deepseek-ai/dsh/lib/bin.js')
|
|
25
|
+
: resolve(configured);
|
|
26
|
+
let directory = dirname(dshEntry);
|
|
27
|
+
while (dirname(directory) !== directory) {
|
|
28
|
+
if (basename(directory) === 'node_modules')
|
|
29
|
+
return directory;
|
|
30
|
+
directory = dirname(directory);
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`harmony-studio: cannot locate node_modules for ${JSON.stringify(dshEntry)}`);
|
|
33
|
+
}
|
|
14
34
|
function waitForExit(child, timeoutMs) {
|
|
15
35
|
if (child.exitCode !== null || child.signalCode !== null)
|
|
16
36
|
return Promise.resolve(true);
|
|
@@ -33,18 +53,21 @@ export class StudioPreviewSupervisor {
|
|
|
33
53
|
commands;
|
|
34
54
|
harmonyBinEntry;
|
|
35
55
|
stopTimeoutMs;
|
|
56
|
+
portPool;
|
|
36
57
|
child;
|
|
37
|
-
|
|
58
|
+
peerClient;
|
|
38
59
|
runtime = { state: 'stopped', log: '' };
|
|
39
60
|
startAbort;
|
|
40
61
|
startPromise;
|
|
41
|
-
|
|
62
|
+
previewPort;
|
|
63
|
+
constructor(draft, mainProfileDir, parentOrigin, commands, harmonyBinEntry, stopTimeoutMs = 5_000, portPool = studioPreviewPortPool) {
|
|
42
64
|
this.draft = draft;
|
|
43
65
|
this.mainProfileDir = mainProfileDir;
|
|
44
66
|
this.parentOrigin = parentOrigin;
|
|
45
67
|
this.commands = commands;
|
|
46
68
|
this.harmonyBinEntry = harmonyBinEntry;
|
|
47
69
|
this.stopTimeoutMs = stopTimeoutMs;
|
|
70
|
+
this.portPool = portPool;
|
|
48
71
|
}
|
|
49
72
|
snapshot() {
|
|
50
73
|
return { ...this.runtime };
|
|
@@ -72,22 +95,22 @@ export class StudioPreviewSupervisor {
|
|
|
72
95
|
this.runtime = { state: 'starting', log: '[studio] Preparing Draft dependencies and isolated profile\n' };
|
|
73
96
|
try {
|
|
74
97
|
await installDraftDependencies(this.draft, this.commands, chunk => { this.runtime.log = appendLog(this.runtime.log, chunk); }, signal);
|
|
75
|
-
await
|
|
98
|
+
await buildDraft(this.draft, this.commands, chunk => { this.runtime.log = appendLog(this.runtime.log, chunk); }, signal);
|
|
99
|
+
await materializeDraftProfile(this.draft, this.mainProfileDir, studioPackageRoot(), harmonyPackageRoot(this.harmonyBinEntry), this.commands, chunk => { this.runtime.log = appendLog(this.runtime.log, chunk); }, signal);
|
|
76
100
|
signal.throwIfAborted();
|
|
77
|
-
|
|
101
|
+
this.previewPort = this.portPool.claim();
|
|
102
|
+
const hostArgs = [this.harmonyBinEntry, 'web', '--port', String(this.previewPort ?? 0), '--no-open'];
|
|
78
103
|
this.runtime.log = appendLog(this.runtime.log, `[studio] Profile dependencies ready\n[studio] Starting Preview Host\nDSH_HOME=${this.draft.runtimeHome}\n${terminalCommandLine(this.draft.worktreeDir, process.execPath, hostArgs)}`);
|
|
79
|
-
const controlToken = randomBytes(32).toString('hex');
|
|
80
104
|
const bridgeCapability = randomBytes(24).toString('base64url');
|
|
81
|
-
this.controlToken = controlToken;
|
|
82
105
|
const child = spawn(process.execPath, hostArgs, {
|
|
83
106
|
cwd: this.draft.worktreeDir,
|
|
84
107
|
env: {
|
|
85
108
|
...process.env,
|
|
86
109
|
DSH_HOME: this.draft.runtimeHome,
|
|
87
110
|
DSH_STUDIO_PREVIEW_DRAFT_ROOT: this.draft.root,
|
|
88
|
-
DSH_STUDIO_PREVIEW_CONTROL_TOKEN: controlToken,
|
|
89
111
|
DSH_STUDIO_PREVIEW_PARENT_ORIGIN: this.parentOrigin,
|
|
90
112
|
DSH_STUDIO_PREVIEW_BRIDGE_CAPABILITY: bridgeCapability,
|
|
113
|
+
DSH_STUDIO_PREVIEW_PACKAGE_DIRS: JSON.stringify([dshPackageModules(this.harmonyBinEntry)]),
|
|
91
114
|
DSH_HARMONY_REACT_TRACE: '1',
|
|
92
115
|
},
|
|
93
116
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -97,12 +120,11 @@ export class StudioPreviewSupervisor {
|
|
|
97
120
|
this.runtime.log = appendLog(this.runtime.log, chunk);
|
|
98
121
|
const match = this.runtime.log.match(/dsh web:\s+(http:\/\/127\.0\.0\.1:\d+)/);
|
|
99
122
|
if (match?.[1] !== undefined) {
|
|
123
|
+
const { error: _error, ...runtime } = this.runtime;
|
|
100
124
|
this.runtime = {
|
|
101
|
-
...
|
|
102
|
-
state: 'running',
|
|
125
|
+
...runtime,
|
|
103
126
|
previewUrl: `${match[1]}/#dsh-studio-preview=${encodeURIComponent(bridgeCapability)}`,
|
|
104
127
|
bridgeCapability,
|
|
105
|
-
error: undefined,
|
|
106
128
|
};
|
|
107
129
|
}
|
|
108
130
|
});
|
|
@@ -111,18 +133,28 @@ export class StudioPreviewSupervisor {
|
|
|
111
133
|
if (this.child !== child)
|
|
112
134
|
return;
|
|
113
135
|
this.child = undefined;
|
|
114
|
-
|
|
136
|
+
const peerClient = this.peerClient;
|
|
137
|
+
this.peerClient = undefined;
|
|
138
|
+
void peerClient?.close();
|
|
139
|
+
this.releasePreviewPort();
|
|
115
140
|
if (this.runtime.state === 'stopped')
|
|
116
141
|
return;
|
|
117
142
|
const error = `Preview Host exited (${signal ?? code ?? 'unknown'})`;
|
|
118
143
|
this.runtime = { state: 'failed', error, log: this.runtime.log };
|
|
119
144
|
});
|
|
120
|
-
await this.
|
|
145
|
+
await this.waitForPreviewUrl(child, signal);
|
|
146
|
+
const peerClient = new NodePeerClient({
|
|
147
|
+
baseUrl: new URL('/', this.runtime.previewUrl),
|
|
148
|
+
contribution: STUDIO_PREVIEW_REMOTE,
|
|
149
|
+
});
|
|
150
|
+
this.peerClient = peerClient;
|
|
121
151
|
await this.waitForWorker(child, signal);
|
|
122
|
-
await this.
|
|
152
|
+
await this.invoke(this.remote().state(signal));
|
|
153
|
+
this.runtime = { ...this.runtime, state: 'running' };
|
|
123
154
|
return this.snapshot();
|
|
124
155
|
}
|
|
125
156
|
catch (error) {
|
|
157
|
+
await this.closePeer();
|
|
126
158
|
await this.terminateChild();
|
|
127
159
|
if (signal.aborted) {
|
|
128
160
|
this.runtime = { state: 'stopped', log: this.runtime.log };
|
|
@@ -135,8 +167,8 @@ export class StudioPreviewSupervisor {
|
|
|
135
167
|
async stop() {
|
|
136
168
|
this.startAbort?.abort(new Error('Preview start canceled'));
|
|
137
169
|
const start = this.startPromise;
|
|
138
|
-
this.controlToken = undefined;
|
|
139
170
|
this.runtime = { state: 'stopped', log: this.runtime.log };
|
|
171
|
+
await this.closePeer();
|
|
140
172
|
await this.terminateChild();
|
|
141
173
|
if (start !== undefined) {
|
|
142
174
|
try {
|
|
@@ -144,7 +176,6 @@ export class StudioPreviewSupervisor {
|
|
|
144
176
|
}
|
|
145
177
|
catch { }
|
|
146
178
|
}
|
|
147
|
-
this.controlToken = undefined;
|
|
148
179
|
this.runtime = { state: 'stopped', log: this.runtime.log };
|
|
149
180
|
return this.snapshot();
|
|
150
181
|
}
|
|
@@ -163,72 +194,122 @@ export class StudioPreviewSupervisor {
|
|
|
163
194
|
}
|
|
164
195
|
if (this.child === child)
|
|
165
196
|
this.child = undefined;
|
|
197
|
+
this.releasePreviewPort();
|
|
198
|
+
}
|
|
199
|
+
releasePreviewPort() {
|
|
200
|
+
this.portPool.release(this.previewPort);
|
|
201
|
+
this.previewPort = undefined;
|
|
166
202
|
}
|
|
167
203
|
async state() {
|
|
168
|
-
return (
|
|
204
|
+
return this.invoke(this.remote().state());
|
|
169
205
|
}
|
|
170
206
|
async activate(graphRev) {
|
|
171
|
-
return (
|
|
207
|
+
return this.invoke(this.remote().activate(graphRev));
|
|
172
208
|
}
|
|
173
209
|
async applyBuild() {
|
|
174
|
-
|
|
210
|
+
await this.commands.run(process.execPath, [this.harmonyBinEntry, 'harmony', 'reload', this.draft.name], this.draft.worktreeDir, chunk => { this.runtime.log = appendLog(this.runtime.log, chunk); }, undefined, { ...process.env, DSH_HOME: this.draft.runtimeHome });
|
|
211
|
+
await this.reconnectPeer();
|
|
212
|
+
return this.invoke(this.remote().applyBuild(randomUUID()));
|
|
175
213
|
}
|
|
176
214
|
async inspect(input = {}) {
|
|
177
|
-
return this.
|
|
215
|
+
return this.invoke(this.remote().inspect(input));
|
|
216
|
+
}
|
|
217
|
+
async profile() {
|
|
218
|
+
return this.invoke(this.remote().profile());
|
|
219
|
+
}
|
|
220
|
+
async updateProfile(input) {
|
|
221
|
+
const operation = { ...input, operationId: randomUUID() };
|
|
222
|
+
try {
|
|
223
|
+
return await this.invoke(this.remote().updateProfile(operation));
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (!this.isGenerationLoss(error))
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
await this.reconnectPeer();
|
|
230
|
+
return this.invoke(this.remote().updateProfile(operation));
|
|
178
231
|
}
|
|
179
232
|
async resolveSource(source) {
|
|
180
|
-
return this.
|
|
233
|
+
return this.invoke(this.remote().resolveSource(source));
|
|
181
234
|
}
|
|
182
235
|
async readDependencySource(packageName, file) {
|
|
183
|
-
return this.
|
|
236
|
+
return this.invoke(this.remote().readSource(packageName, file));
|
|
237
|
+
}
|
|
238
|
+
async readPatchTarget(packageName, file) {
|
|
239
|
+
return this.invoke(this.remote().readPatchTarget(packageName, file));
|
|
184
240
|
}
|
|
185
241
|
async dispose() {
|
|
186
242
|
await this.stop();
|
|
187
243
|
}
|
|
188
|
-
async
|
|
244
|
+
async waitForPreviewUrl(child, signal) {
|
|
189
245
|
const started = Date.now();
|
|
190
|
-
while (this.child === child && this.runtime.
|
|
246
|
+
while (this.child === child && this.runtime.previewUrl === undefined && Date.now() - started < START_TIMEOUT_MS) {
|
|
191
247
|
await this.delay(50, signal);
|
|
192
248
|
}
|
|
193
249
|
signal.throwIfAborted();
|
|
194
|
-
if (this.runtime.
|
|
195
|
-
throw new Error(this.runtime.error ?? 'Preview Host did not publish its URL before timeout');
|
|
250
|
+
if (this.runtime.previewUrl === undefined) {
|
|
251
|
+
throw new Error(`${this.runtime.error ?? 'Preview Host did not publish its URL before timeout'}\n${this.runtime.log}`);
|
|
252
|
+
}
|
|
196
253
|
}
|
|
197
254
|
async waitForWorker(child, signal) {
|
|
198
255
|
const started = Date.now();
|
|
199
|
-
let lastError = 'worker
|
|
256
|
+
let lastError = 'Preview worker is still preparing the Draft';
|
|
200
257
|
while (this.child === child && Date.now() - started < START_TIMEOUT_MS) {
|
|
201
258
|
try {
|
|
202
259
|
const requestSignal = AbortSignal.any([signal, AbortSignal.timeout(1_000)]);
|
|
203
|
-
await this.
|
|
204
|
-
|
|
260
|
+
await this.peerClient?.connect(requestSignal);
|
|
261
|
+
const health = await this.invoke(this.remote().health(requestSignal));
|
|
262
|
+
if (health.ready)
|
|
263
|
+
return;
|
|
264
|
+
if (health.error !== undefined)
|
|
265
|
+
throw new Error(health.error);
|
|
205
266
|
}
|
|
206
267
|
catch (error) {
|
|
207
268
|
signal.throwIfAborted();
|
|
208
269
|
lastError = error instanceof Error ? error.message : String(error);
|
|
209
|
-
await this.delay(50, signal);
|
|
210
270
|
}
|
|
271
|
+
await this.delay(50, signal);
|
|
211
272
|
}
|
|
212
273
|
signal.throwIfAborted();
|
|
213
274
|
throw new Error(`Preview worker did not become ready before timeout: ${lastError}`);
|
|
214
275
|
}
|
|
215
|
-
|
|
216
|
-
if (this.
|
|
276
|
+
remote() {
|
|
277
|
+
if (this.peerClient === undefined)
|
|
217
278
|
throw new Error('Preview Host is not running');
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
279
|
+
return this.peerClient.remote.studioPreviewWorker;
|
|
280
|
+
}
|
|
281
|
+
async invoke(result) {
|
|
282
|
+
const settled = await result;
|
|
283
|
+
if (settled.ok)
|
|
284
|
+
return settled.value;
|
|
285
|
+
throw new Error(`${settled.error.code}: ${settled.error.message}`);
|
|
286
|
+
}
|
|
287
|
+
async closePeer() {
|
|
288
|
+
const peerClient = this.peerClient;
|
|
289
|
+
this.peerClient = undefined;
|
|
290
|
+
await peerClient?.close();
|
|
291
|
+
}
|
|
292
|
+
isGenerationLoss(error) {
|
|
293
|
+
return error instanceof Error && error.message.includes('Node peer connection closed');
|
|
294
|
+
}
|
|
295
|
+
async reconnectPeer() {
|
|
296
|
+
const peerClient = this.peerClient;
|
|
297
|
+
if (peerClient === undefined || this.runtime.state !== 'running') {
|
|
298
|
+
throw new Error('Preview Host is not running');
|
|
299
|
+
}
|
|
300
|
+
const signal = AbortSignal.timeout(START_TIMEOUT_MS);
|
|
301
|
+
let lastError;
|
|
302
|
+
while (!signal.aborted) {
|
|
303
|
+
try {
|
|
304
|
+
await peerClient.connect(signal);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
lastError = error;
|
|
309
|
+
}
|
|
310
|
+
await this.delay(50, signal);
|
|
311
|
+
}
|
|
312
|
+
throw new Error('Preview peer did not reconnect after Harmony reload', { cause: lastError });
|
|
232
313
|
}
|
|
233
314
|
async delay(milliseconds, signal) {
|
|
234
315
|
signal.throwIfAborted();
|
package/lib/host/readiness.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess';
|
|
2
|
-
import type { StudioHarmonyInspection, StudioPackResult, StudioReadinessReport
|
|
3
|
-
export declare function inspectReadiness(root: string, projectName: string, inspection: StudioHarmonyInspection, profileDir: string
|
|
2
|
+
import type { StudioHarmonyInspection, StudioPackResult, StudioReadinessReport } from '../contracts.js';
|
|
3
|
+
export declare function inspectReadiness(root: string, projectName: string, inspection: StudioHarmonyInspection, profileDir: string): StudioReadinessReport;
|
|
4
4
|
export declare class StudioPackRunner {
|
|
5
5
|
private readonly subprocess;
|
|
6
6
|
private readonly timeoutMs;
|
package/lib/host/readiness.js
CHANGED
|
@@ -43,7 +43,7 @@ function artifactFinding(root, path, label) {
|
|
|
43
43
|
function readManifest(root) {
|
|
44
44
|
return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
45
45
|
}
|
|
46
|
-
export function inspectReadiness(root, projectName, inspection, profileDir
|
|
46
|
+
export function inspectReadiness(root, projectName, inspection, profileDir) {
|
|
47
47
|
const manifest = readManifest(root);
|
|
48
48
|
const findings = [];
|
|
49
49
|
if (typeof manifest.name !== 'string' || manifest.name === '') {
|
|
@@ -110,7 +110,6 @@ export function inspectReadiness(root, projectName, inspection, profileDir, depe
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
const declared = new Set([...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})]);
|
|
113
|
-
const declaredAfter = new Set(stringArray(harmony?.after));
|
|
114
113
|
for (const dependency of stringArray(manifest.dsh?.client?.inject)) {
|
|
115
114
|
if (!declared.has(dependency)) {
|
|
116
115
|
findings.push(finding('warning', 'ambient-client-service', `Client inject ${JSON.stringify(dependency)} is supplied by the current profile but is not declared as a dependency or peer dependency`));
|
|
@@ -123,28 +122,26 @@ export function inspectReadiness(root, projectName, inspection, profileDir, depe
|
|
|
123
122
|
}
|
|
124
123
|
const patches = inspection.patches.filter(patch => patch.owner === projectName);
|
|
125
124
|
for (const patch of patches) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
const file = patch.targets[0]?.file;
|
|
126
|
+
const targets = new Map(patch.targets.map(target => [`${target.package}\0${target.version ?? ''}`, target]));
|
|
127
|
+
for (const target of targets.values()) {
|
|
128
|
+
if (!declared.has(target.package)) {
|
|
129
|
+
findings.push(finding('warning', 'ambient-patch-target', `Patch target ${JSON.stringify(target.package)} is available in this Preview but is not declared as a dependency or peer dependency`, { patch: patch.key }));
|
|
130
|
+
}
|
|
131
|
+
if (target.version === undefined) {
|
|
132
|
+
findings.push(finding('warning', 'unbounded-target-version', `Patch ${JSON.stringify(patch.key)} does not constrain target package ${JSON.stringify(target.package)}`, { patch: patch.key }));
|
|
133
|
+
}
|
|
131
134
|
}
|
|
132
135
|
if (patch.state === 'failed') {
|
|
133
|
-
findings.push(finding('error', 'patch-failed', patch.error ?? `Patch ${JSON.stringify(patch.key)} failed against the current provider stack`, { patch: patch.key, file
|
|
136
|
+
findings.push(finding('error', 'patch-failed', patch.error ?? `Patch ${JSON.stringify(patch.key)} failed against the current provider stack`, { patch: patch.key, file }));
|
|
134
137
|
}
|
|
135
138
|
else if (patch.state === 'disabled') {
|
|
136
|
-
findings.push(finding('warning', 'patch-disabled', `Patch ${JSON.stringify(patch.key)} is disabled in the current profile`, { patch: patch.key, file
|
|
139
|
+
findings.push(finding('warning', 'patch-disabled', `Patch ${JSON.stringify(patch.key)} is disabled in the current profile`, { patch: patch.key, file }));
|
|
137
140
|
}
|
|
138
|
-
else if (patch.state === 'pending'
|
|
139
|
-
findings.push(finding('warning', 'patch-unverified', `Patch ${JSON.stringify(patch.key)} has not been exercised by the current Preview`, { patch: patch.key, file
|
|
141
|
+
else if (patch.state === 'pending') {
|
|
142
|
+
findings.push(finding('warning', 'patch-unverified', `Patch ${JSON.stringify(patch.key)} has not been exercised by the current Preview`, { patch: patch.key, file }));
|
|
140
143
|
}
|
|
141
144
|
}
|
|
142
|
-
for (const dependency of dependencies) {
|
|
143
|
-
const explicit = dependency.providerCandidates.filter(provider => declared.has(provider) && declaredAfter.has(provider));
|
|
144
|
-
if (dependency.providerCandidates.length === 1 && explicit.length === 1)
|
|
145
|
-
continue;
|
|
146
|
-
findings.push(finding('warning', 'differential-provider-stack', `Patch ${JSON.stringify(dependency.patch)} fails against the base target but succeeds against the current transformed stack. Earlier provider candidates: ${dependency.providerCandidates.map(provider => JSON.stringify(provider)).join(', ')}. Inspect the ordered Patch steps before declaring a dependency or dsh.harmony.after relationship`, { patch: dependency.patch, file: dependency.target.file }));
|
|
147
|
-
}
|
|
148
145
|
const orderPath = join(profileDir, 'harmony.json');
|
|
149
146
|
if (existsSync(orderPath)) {
|
|
150
147
|
const order = JSON.parse(readFileSync(orderPath, 'utf8')).order;
|
package/lib/host/routes.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage } from 'node:http';
|
|
2
2
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
|
|
3
|
-
import type { StudioBackend } from './backend.js';
|
|
4
3
|
export interface StudioAssets {
|
|
5
4
|
script: Buffer;
|
|
6
5
|
style: Buffer;
|
|
@@ -8,10 +7,5 @@ export interface StudioAssets {
|
|
|
8
7
|
icon: Buffer;
|
|
9
8
|
iconMono: Buffer;
|
|
10
9
|
}
|
|
11
|
-
export interface StudioRouteSecurity {
|
|
12
|
-
token: string;
|
|
13
|
-
origin: string;
|
|
14
|
-
host: string;
|
|
15
|
-
}
|
|
16
10
|
export declare function isTrustedStudioRequest(request: IncomingMessage): boolean;
|
|
17
|
-
export declare function createStudioRoutes(
|
|
11
|
+
export declare function createStudioRoutes(assets: StudioAssets, ready?: () => boolean): WebRoute[];
|
package/lib/host/routes.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
const MAX_BODY_BYTES = 1024 * 1024;
|
|
1
|
+
import { STUDIO_PATH } from '../contracts.js';
|
|
3
2
|
function remoteIsLoopback(request) {
|
|
4
3
|
const address = request.socket.remoteAddress;
|
|
5
4
|
return address === '::1' || address === '127.0.0.1' || address?.startsWith('127.') === true
|
|
@@ -11,18 +10,6 @@ export function isTrustedStudioRequest(request) {
|
|
|
11
10
|
const fetchSite = request.headers['sec-fetch-site'];
|
|
12
11
|
return fetchSite === undefined || fetchSite === 'same-origin' || fetchSite === 'none';
|
|
13
12
|
}
|
|
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
13
|
function sendJson(response, status, value) {
|
|
27
14
|
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
28
15
|
response.end(JSON.stringify(value));
|
|
@@ -35,9 +22,9 @@ function sendAsset(request, response, contentType, body) {
|
|
|
35
22
|
});
|
|
36
23
|
response.end(request.method === 'HEAD' ? undefined : body);
|
|
37
24
|
}
|
|
38
|
-
function documentHtml(
|
|
25
|
+
function documentHtml() {
|
|
39
26
|
return `<!doctype html>
|
|
40
|
-
<html lang="
|
|
27
|
+
<html lang="en">
|
|
41
28
|
<head>
|
|
42
29
|
<meta charset="UTF-8" />
|
|
43
30
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
@@ -45,7 +32,6 @@ function documentHtml(token) {
|
|
|
45
32
|
<meta name="referrer" content="no-referrer" />
|
|
46
33
|
<title>DeepSeek WebUI Studio</title>
|
|
47
34
|
<link rel="stylesheet" href="${STUDIO_PATH}/assets/studio.css" />
|
|
48
|
-
<script>window.__DSH_STUDIO__={token:${JSON.stringify(token)}};</script>
|
|
49
35
|
</head>
|
|
50
36
|
<body>
|
|
51
37
|
<div id="root"></div>
|
|
@@ -53,10 +39,30 @@ function documentHtml(token) {
|
|
|
53
39
|
</body>
|
|
54
40
|
</html>`;
|
|
55
41
|
}
|
|
56
|
-
function
|
|
57
|
-
return
|
|
58
|
-
|
|
59
|
-
|
|
42
|
+
function harmonySetupHtml() {
|
|
43
|
+
return `<!doctype html>
|
|
44
|
+
<html lang="en">
|
|
45
|
+
<head>
|
|
46
|
+
<meta charset="UTF-8" />
|
|
47
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
48
|
+
<meta name="color-scheme" content="light dark" />
|
|
49
|
+
<title>DeepSeek WebUI Studio</title>
|
|
50
|
+
<style>html,body,iframe{width:100%;height:100%;margin:0;border:0}body{overflow:hidden}</style>
|
|
51
|
+
</head>
|
|
52
|
+
<body>
|
|
53
|
+
<iframe src="/" title="Install Harmony for DeepSeek WebUI Studio"></iframe>
|
|
54
|
+
<script>
|
|
55
|
+
const waitForHarmony = async () => {
|
|
56
|
+
try {
|
|
57
|
+
const status = await fetch('/dsh-harmony/runtime').then(response => response.json())
|
|
58
|
+
if (status.state === 'active') return location.reload()
|
|
59
|
+
} catch {}
|
|
60
|
+
setTimeout(waitForHarmony, 750)
|
|
61
|
+
}
|
|
62
|
+
waitForHarmony()
|
|
63
|
+
</script>
|
|
64
|
+
</body>
|
|
65
|
+
</html>`;
|
|
60
66
|
}
|
|
61
67
|
function rejectUntrusted(request, response) {
|
|
62
68
|
if (isTrustedStudioRequest(request))
|
|
@@ -64,43 +70,29 @@ function rejectUntrusted(request, response) {
|
|
|
64
70
|
sendJson(response, 403, { error: 'Studio is available from the local machine only.' });
|
|
65
71
|
return true;
|
|
66
72
|
}
|
|
67
|
-
export function createStudioRoutes(
|
|
68
|
-
const page = Buffer.from(documentHtml(
|
|
69
|
-
const
|
|
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
|
-
};
|
|
73
|
+
export function createStudioRoutes(assets, ready = () => true) {
|
|
74
|
+
const page = Buffer.from(documentHtml());
|
|
75
|
+
const setupPage = Buffer.from(harmonySetupHtml());
|
|
94
76
|
return [
|
|
77
|
+
{
|
|
78
|
+
kind: 'prefix',
|
|
79
|
+
path: `${STUDIO_PATH}/api`,
|
|
80
|
+
handler(_request, response) {
|
|
81
|
+
sendJson(response, 404, { error: 'not found' });
|
|
82
|
+
},
|
|
83
|
+
},
|
|
95
84
|
{
|
|
96
85
|
kind: 'exact',
|
|
97
86
|
path: STUDIO_PATH,
|
|
98
87
|
handler(request, response) {
|
|
99
88
|
if (rejectUntrusted(request, response))
|
|
100
89
|
return;
|
|
90
|
+
if (new URL(request.url ?? '/', 'http://localhost').pathname !== STUDIO_PATH) {
|
|
91
|
+
return sendJson(response, 404, { error: 'not found' });
|
|
92
|
+
}
|
|
101
93
|
if (request.method !== 'GET' && request.method !== 'HEAD')
|
|
102
94
|
return sendJson(response, 405, { error: 'method not allowed' });
|
|
103
|
-
sendAsset(request, response, 'text/html; charset=utf-8', page);
|
|
95
|
+
sendAsset(request, response, 'text/html; charset=utf-8', ready() ? page : setupPage);
|
|
104
96
|
},
|
|
105
97
|
},
|
|
106
98
|
{
|
|
@@ -158,6 +150,5 @@ export function createStudioRoutes(backend, assets, security) {
|
|
|
158
150
|
sendAsset(request, response, 'image/png', assets.iconMono);
|
|
159
151
|
},
|
|
160
152
|
},
|
|
161
|
-
{ kind: 'prefix', path: STUDIO_API_PATH, handler: apiHandler },
|
|
162
153
|
];
|
|
163
154
|
}
|
|
@@ -12,5 +12,6 @@ export declare function bundledPnpmCommand(args: readonly string[]): [string, st
|
|
|
12
12
|
export declare function terminalCommandLine(cwd: string, command: string, args: readonly string[]): string;
|
|
13
13
|
export declare function assertDraftPackageIdentity(draft: StudioDraftRecord): Promise<DraftManifest>;
|
|
14
14
|
export declare function installDraftDependencies(draft: StudioDraftRecord, commands: StudioCommandRunner, onOutput?: (chunk: string) => void, signal?: AbortSignal): Promise<void>;
|
|
15
|
-
export declare function
|
|
15
|
+
export declare function buildDraft(draft: StudioDraftRecord, commands: StudioCommandRunner, onOutput?: (chunk: string) => void, signal?: AbortSignal): Promise<void>;
|
|
16
|
+
export declare function materializeDraftProfile(draft: StudioDraftRecord, mainProfileDir: string, studioPackageRoot: string, harmonyPackageRoot: string, commands: StudioCommandRunner, onOutput?: (chunk: string) => void, signal?: AbortSignal): Promise<string>;
|
|
16
17
|
export {};
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
-
import { resolvePackageManager } from './build.js';
|
|
4
|
+
import { resolveBuildArgv, resolvePackageManager } from './build.js';
|
|
5
5
|
const PROFILE_FILES = ['cordis.patch.yml', 'cordis.yml', 'harmony.json', 'pnpm-workspace.yaml'];
|
|
6
6
|
const require = createRequire(import.meta.url);
|
|
7
7
|
const PNPM_ENTRY = join(dirname(require.resolve('pnpm')), 'bin', 'pnpm.cjs');
|
|
8
|
+
const BINDING_PACKAGE_ROOT = process.env.DSH_STUDIO_BINDING_ROOT ?? dirname(require.resolve('the-binding-of-dsh/package.json'));
|
|
8
9
|
export function bundledPnpmCommand(args) {
|
|
9
10
|
return [process.execPath, [PNPM_ENTRY, ...args]];
|
|
10
11
|
}
|
|
@@ -31,7 +32,7 @@ export async function installDraftDependencies(draft, commands, onOutput, signal
|
|
|
31
32
|
if (!hasDependencies(manifest))
|
|
32
33
|
return;
|
|
33
34
|
const manager = resolvePackageManager(draft.root, manifest);
|
|
34
|
-
const [command, args] = manager === 'pnpm' ? bundledPnpmCommand(['install']) : [manager, ['install']];
|
|
35
|
+
const [command, args] = manager === 'pnpm' ? bundledPnpmCommand(['install', '--prefer-offline']) : [manager, ['install']];
|
|
35
36
|
onOutput?.(terminalCommandLine(draft.root, command, args));
|
|
36
37
|
try {
|
|
37
38
|
await commands.run(command, args, draft.root, onOutput, signal);
|
|
@@ -43,27 +44,48 @@ export async function installDraftDependencies(draft, commands, onOutput, signal
|
|
|
43
44
|
throw new Error('Draft dependency installation failed. Check the startup terminal for details.');
|
|
44
45
|
}
|
|
45
46
|
}
|
|
47
|
+
export async function buildDraft(draft, commands, onOutput, signal) {
|
|
48
|
+
signal?.throwIfAborted();
|
|
49
|
+
const [manager, ...args] = resolveBuildArgv(draft.root);
|
|
50
|
+
const [command, commandArgs] = manager === 'pnpm' ? bundledPnpmCommand(args) : [manager, args];
|
|
51
|
+
onOutput?.(terminalCommandLine(draft.root, command, commandArgs));
|
|
52
|
+
try {
|
|
53
|
+
await commands.run(command, commandArgs, draft.root, onOutput, signal);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
signal?.throwIfAborted();
|
|
57
|
+
const message = (error instanceof Error ? error.message : String(error)).split('\n', 1)[0];
|
|
58
|
+
onOutput?.(`[studio] ${message}\n`);
|
|
59
|
+
throw new Error('Initial Draft build failed. Check the startup terminal for details.');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
46
62
|
function absoluteLink(spec, profileDir) {
|
|
47
63
|
if (!spec.startsWith('link:'))
|
|
48
64
|
return spec;
|
|
49
65
|
const target = spec.slice('link:'.length);
|
|
50
66
|
return `link:${isAbsolute(target) ? target : resolve(profileDir, target)}`;
|
|
51
67
|
}
|
|
52
|
-
export async function materializeDraftProfile(draft, mainProfileDir, studioPackageRoot, commands, onOutput, signal) {
|
|
68
|
+
export async function materializeDraftProfile(draft, mainProfileDir, studioPackageRoot, harmonyPackageRoot, commands, onOutput, signal) {
|
|
53
69
|
signal?.throwIfAborted();
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
let sourceProfileDir = mainProfileDir;
|
|
71
|
+
if (draft.profileMode === 'custom') {
|
|
72
|
+
if (draft.profileDirectory === undefined)
|
|
73
|
+
throw new Error('Custom Draft profile folder is missing');
|
|
74
|
+
sourceProfileDir = draft.profileDirectory;
|
|
75
|
+
}
|
|
56
76
|
const profileDir = join(draft.runtimeHome, 'profiles', 'web');
|
|
57
77
|
await rm(profileDir, { recursive: true, force: true });
|
|
58
78
|
await mkdir(profileDir, { recursive: true });
|
|
59
|
-
const manifest = JSON.parse(await readFile(join(
|
|
60
|
-
const dependencies = Object.fromEntries(Object.entries(manifest.dependencies ?? {}).map(([name, spec]) => [name, absoluteLink(spec,
|
|
79
|
+
const manifest = JSON.parse(await readFile(join(sourceProfileDir, 'package.json'), 'utf8'));
|
|
80
|
+
const dependencies = Object.fromEntries(Object.entries(manifest.dependencies ?? {}).map(([name, spec]) => [name, absoluteLink(spec, sourceProfileDir)]));
|
|
61
81
|
dependencies[draft.name] = `link:${draft.root}`;
|
|
62
82
|
dependencies['dsh-webui-studio'] = `link:${studioPackageRoot}`;
|
|
83
|
+
dependencies['dsh-harmony'] = `link:${harmonyPackageRoot}`;
|
|
84
|
+
dependencies['the-binding-of-dsh'] = `link:${BINDING_PACKAGE_ROOT}`;
|
|
63
85
|
await writeFile(join(profileDir, 'package.json'), `${JSON.stringify({ ...manifest, dependencies }, null, 2)}\n`);
|
|
64
86
|
for (const file of PROFILE_FILES) {
|
|
65
87
|
try {
|
|
66
|
-
await cp(join(
|
|
88
|
+
await cp(join(sourceProfileDir, file), join(profileDir, file));
|
|
67
89
|
}
|
|
68
90
|
catch (error) {
|
|
69
91
|
if (error.code !== 'ENOENT')
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import type { StudioSourceCandidate, StudioSourceLocation } from '../contracts.js';
|
|
2
2
|
export declare class StudioSourceResolver {
|
|
3
3
|
#private;
|
|
4
|
-
|
|
4
|
+
private readonly draftRoot;
|
|
5
|
+
private readonly profileDir;
|
|
6
|
+
private readonly packageDirs;
|
|
7
|
+
constructor(draftRoot: string | undefined, profileDir: string, packageDirs?: string[]);
|
|
8
|
+
private roots;
|
|
5
9
|
resolve(source: StudioSourceLocation): Promise<StudioSourceCandidate>;
|
|
6
10
|
readDependency(packageName: string, file: string): Promise<string>;
|
|
11
|
+
readDependencyTarget(packageName: string, file: string): Promise<{
|
|
12
|
+
package: string;
|
|
13
|
+
file: string;
|
|
14
|
+
version: string;
|
|
15
|
+
source: string;
|
|
16
|
+
}>;
|
|
7
17
|
}
|