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,249 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { STUDIO_PREVIEW_API_PATH } from '../contracts.js';
|
|
5
|
+
import { installDraftDependencies, materializeDraftProfile, terminalCommandLine } from './runtime-profile.js';
|
|
6
|
+
const START_TIMEOUT_MS = 30_000;
|
|
7
|
+
const LOG_LIMIT = 64_000;
|
|
8
|
+
function appendLog(current, chunk) {
|
|
9
|
+
return `${current}${chunk.toString()}`.slice(-LOG_LIMIT);
|
|
10
|
+
}
|
|
11
|
+
function studioPackageRoot() {
|
|
12
|
+
return fileURLToPath(new URL('../../', import.meta.url));
|
|
13
|
+
}
|
|
14
|
+
function waitForExit(child, timeoutMs) {
|
|
15
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
16
|
+
return Promise.resolve(true);
|
|
17
|
+
return new Promise(resolve => {
|
|
18
|
+
const exited = () => {
|
|
19
|
+
clearTimeout(timeout);
|
|
20
|
+
resolve(true);
|
|
21
|
+
};
|
|
22
|
+
const timeout = setTimeout(() => {
|
|
23
|
+
child.removeListener('exit', exited);
|
|
24
|
+
resolve(false);
|
|
25
|
+
}, timeoutMs);
|
|
26
|
+
child.once('exit', exited);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export class StudioPreviewSupervisor {
|
|
30
|
+
draft;
|
|
31
|
+
mainProfileDir;
|
|
32
|
+
parentOrigin;
|
|
33
|
+
commands;
|
|
34
|
+
harmonyBinEntry;
|
|
35
|
+
stopTimeoutMs;
|
|
36
|
+
child;
|
|
37
|
+
controlToken;
|
|
38
|
+
runtime = { state: 'stopped', log: '' };
|
|
39
|
+
startAbort;
|
|
40
|
+
startPromise;
|
|
41
|
+
constructor(draft, mainProfileDir, parentOrigin, commands, harmonyBinEntry, stopTimeoutMs = 5_000) {
|
|
42
|
+
this.draft = draft;
|
|
43
|
+
this.mainProfileDir = mainProfileDir;
|
|
44
|
+
this.parentOrigin = parentOrigin;
|
|
45
|
+
this.commands = commands;
|
|
46
|
+
this.harmonyBinEntry = harmonyBinEntry;
|
|
47
|
+
this.stopTimeoutMs = stopTimeoutMs;
|
|
48
|
+
}
|
|
49
|
+
snapshot() {
|
|
50
|
+
return { ...this.runtime };
|
|
51
|
+
}
|
|
52
|
+
async start() {
|
|
53
|
+
if (this.runtime.state === 'running')
|
|
54
|
+
return this.snapshot();
|
|
55
|
+
if (this.startPromise !== undefined)
|
|
56
|
+
return this.startPromise;
|
|
57
|
+
const abort = new AbortController();
|
|
58
|
+
const startPromise = this.startRuntime(abort.signal);
|
|
59
|
+
this.startAbort = abort;
|
|
60
|
+
this.startPromise = startPromise;
|
|
61
|
+
try {
|
|
62
|
+
return await startPromise;
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
if (this.startPromise === startPromise) {
|
|
66
|
+
this.startAbort = undefined;
|
|
67
|
+
this.startPromise = undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async startRuntime(signal) {
|
|
72
|
+
this.runtime = { state: 'starting', log: '[studio] Preparing Draft dependencies and isolated profile\n' };
|
|
73
|
+
try {
|
|
74
|
+
await installDraftDependencies(this.draft, this.commands, chunk => { this.runtime.log = appendLog(this.runtime.log, chunk); }, signal);
|
|
75
|
+
await materializeDraftProfile(this.draft, this.mainProfileDir, studioPackageRoot(), this.commands, chunk => { this.runtime.log = appendLog(this.runtime.log, chunk); }, signal);
|
|
76
|
+
signal.throwIfAborted();
|
|
77
|
+
const hostArgs = [this.harmonyBinEntry, 'web', '--port', '0'];
|
|
78
|
+
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
|
+
const bridgeCapability = randomBytes(24).toString('base64url');
|
|
81
|
+
this.controlToken = controlToken;
|
|
82
|
+
const child = spawn(process.execPath, hostArgs, {
|
|
83
|
+
cwd: this.draft.worktreeDir,
|
|
84
|
+
env: {
|
|
85
|
+
...process.env,
|
|
86
|
+
DSH_HOME: this.draft.runtimeHome,
|
|
87
|
+
DSH_STUDIO_PREVIEW_DRAFT_ROOT: this.draft.root,
|
|
88
|
+
DSH_STUDIO_PREVIEW_CONTROL_TOKEN: controlToken,
|
|
89
|
+
DSH_STUDIO_PREVIEW_PARENT_ORIGIN: this.parentOrigin,
|
|
90
|
+
DSH_STUDIO_PREVIEW_BRIDGE_CAPABILITY: bridgeCapability,
|
|
91
|
+
DSH_HARMONY_REACT_TRACE: '1',
|
|
92
|
+
},
|
|
93
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
94
|
+
});
|
|
95
|
+
this.child = child;
|
|
96
|
+
child.stdout?.on('data', chunk => {
|
|
97
|
+
this.runtime.log = appendLog(this.runtime.log, chunk);
|
|
98
|
+
const match = this.runtime.log.match(/dsh web:\s+(http:\/\/127\.0\.0\.1:\d+)/);
|
|
99
|
+
if (match?.[1] !== undefined) {
|
|
100
|
+
this.runtime = {
|
|
101
|
+
...this.runtime,
|
|
102
|
+
state: 'running',
|
|
103
|
+
previewUrl: `${match[1]}/#dsh-studio-preview=${encodeURIComponent(bridgeCapability)}`,
|
|
104
|
+
bridgeCapability,
|
|
105
|
+
error: undefined,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
child.stderr?.on('data', chunk => { this.runtime.log = appendLog(this.runtime.log, chunk); });
|
|
110
|
+
child.once('exit', (code, signal) => {
|
|
111
|
+
if (this.child !== child)
|
|
112
|
+
return;
|
|
113
|
+
this.child = undefined;
|
|
114
|
+
this.controlToken = undefined;
|
|
115
|
+
if (this.runtime.state === 'stopped')
|
|
116
|
+
return;
|
|
117
|
+
const error = `Preview Host exited (${signal ?? code ?? 'unknown'})`;
|
|
118
|
+
this.runtime = { state: 'failed', error, log: this.runtime.log };
|
|
119
|
+
});
|
|
120
|
+
await this.waitUntilRunning(child, signal);
|
|
121
|
+
await this.waitForWorker(child, signal);
|
|
122
|
+
await this.worker('state', {}, signal);
|
|
123
|
+
return this.snapshot();
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
await this.terminateChild();
|
|
127
|
+
if (signal.aborted) {
|
|
128
|
+
this.runtime = { state: 'stopped', log: this.runtime.log };
|
|
129
|
+
throw signal.reason;
|
|
130
|
+
}
|
|
131
|
+
this.runtime = { state: 'failed', error: error instanceof Error ? error.message : String(error), log: this.runtime.log };
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async stop() {
|
|
136
|
+
this.startAbort?.abort(new Error('Preview start canceled'));
|
|
137
|
+
const start = this.startPromise;
|
|
138
|
+
this.controlToken = undefined;
|
|
139
|
+
this.runtime = { state: 'stopped', log: this.runtime.log };
|
|
140
|
+
await this.terminateChild();
|
|
141
|
+
if (start !== undefined) {
|
|
142
|
+
try {
|
|
143
|
+
await start;
|
|
144
|
+
}
|
|
145
|
+
catch { }
|
|
146
|
+
}
|
|
147
|
+
this.controlToken = undefined;
|
|
148
|
+
this.runtime = { state: 'stopped', log: this.runtime.log };
|
|
149
|
+
return this.snapshot();
|
|
150
|
+
}
|
|
151
|
+
async terminateChild() {
|
|
152
|
+
const child = this.child;
|
|
153
|
+
if (child !== undefined && child.exitCode === null) {
|
|
154
|
+
child.kill('SIGTERM');
|
|
155
|
+
if (!await waitForExit(child, this.stopTimeoutMs)) {
|
|
156
|
+
child.kill('SIGKILL');
|
|
157
|
+
if (!await waitForExit(child, this.stopTimeoutMs)) {
|
|
158
|
+
const error = 'Preview Host did not exit after SIGTERM and SIGKILL';
|
|
159
|
+
this.runtime = { state: 'failed', error, log: this.runtime.log };
|
|
160
|
+
throw new Error(error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (this.child === child)
|
|
165
|
+
this.child = undefined;
|
|
166
|
+
}
|
|
167
|
+
async state() {
|
|
168
|
+
return (await this.worker('state', {})).project;
|
|
169
|
+
}
|
|
170
|
+
async activate(graphRev) {
|
|
171
|
+
return (await this.worker('activate', { graphRev })).project;
|
|
172
|
+
}
|
|
173
|
+
async applyBuild() {
|
|
174
|
+
return (await this.worker('apply-build', {})).project;
|
|
175
|
+
}
|
|
176
|
+
async inspect(input = {}) {
|
|
177
|
+
return this.worker('inspect', input);
|
|
178
|
+
}
|
|
179
|
+
async resolveSource(source) {
|
|
180
|
+
return this.worker('resolve-source', { source });
|
|
181
|
+
}
|
|
182
|
+
async readDependencySource(packageName, file) {
|
|
183
|
+
return this.worker('read-source', { package: packageName, file });
|
|
184
|
+
}
|
|
185
|
+
async dispose() {
|
|
186
|
+
await this.stop();
|
|
187
|
+
}
|
|
188
|
+
async waitUntilRunning(child, signal) {
|
|
189
|
+
const started = Date.now();
|
|
190
|
+
while (this.child === child && this.runtime.state === 'starting' && Date.now() - started < START_TIMEOUT_MS) {
|
|
191
|
+
await this.delay(50, signal);
|
|
192
|
+
}
|
|
193
|
+
signal.throwIfAborted();
|
|
194
|
+
if (this.runtime.state !== 'running')
|
|
195
|
+
throw new Error(this.runtime.error ?? 'Preview Host did not publish its URL before timeout');
|
|
196
|
+
}
|
|
197
|
+
async waitForWorker(child, signal) {
|
|
198
|
+
const started = Date.now();
|
|
199
|
+
let lastError = 'worker route was not reachable';
|
|
200
|
+
while (this.child === child && Date.now() - started < START_TIMEOUT_MS) {
|
|
201
|
+
try {
|
|
202
|
+
const requestSignal = AbortSignal.any([signal, AbortSignal.timeout(1_000)]);
|
|
203
|
+
await this.worker('health', {}, requestSignal);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
signal.throwIfAborted();
|
|
208
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
209
|
+
await this.delay(50, signal);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
signal.throwIfAborted();
|
|
213
|
+
throw new Error(`Preview worker did not become ready before timeout: ${lastError}`);
|
|
214
|
+
}
|
|
215
|
+
async worker(method, payload, signal) {
|
|
216
|
+
if (this.runtime.previewUrl === undefined || this.controlToken === undefined)
|
|
217
|
+
throw new Error('Preview Host is not running');
|
|
218
|
+
const endpoint = new URL(`${STUDIO_PREVIEW_API_PATH}/${method}`, this.runtime.previewUrl);
|
|
219
|
+
const response = await fetch(endpoint, {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
headers: { authorization: `Bearer ${this.controlToken}`, 'content-type': 'application/json' },
|
|
222
|
+
body: JSON.stringify(payload),
|
|
223
|
+
signal,
|
|
224
|
+
});
|
|
225
|
+
const text = await response.text();
|
|
226
|
+
if (text === '')
|
|
227
|
+
throw new Error(`Preview worker returned an empty HTTP ${response.status} response`);
|
|
228
|
+
const body = JSON.parse(text);
|
|
229
|
+
if (!response.ok || !body.ok)
|
|
230
|
+
throw new Error(body.ok ? `Preview worker failed with HTTP ${response.status}` : body.error);
|
|
231
|
+
return body.value;
|
|
232
|
+
}
|
|
233
|
+
async delay(milliseconds, signal) {
|
|
234
|
+
signal.throwIfAborted();
|
|
235
|
+
await new Promise((resolve, reject) => {
|
|
236
|
+
const timeout = setTimeout(done, milliseconds);
|
|
237
|
+
const aborted = () => done(signal.reason);
|
|
238
|
+
function done(error) {
|
|
239
|
+
clearTimeout(timeout);
|
|
240
|
+
signal.removeEventListener('abort', aborted);
|
|
241
|
+
if (error === undefined)
|
|
242
|
+
resolve();
|
|
243
|
+
else
|
|
244
|
+
reject(error);
|
|
245
|
+
}
|
|
246
|
+
signal.addEventListener('abort', aborted, { once: true });
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface StudioProjectFile {
|
|
2
|
+
path: string;
|
|
3
|
+
size: number;
|
|
4
|
+
}
|
|
5
|
+
export declare function listProjectFiles(root: string): Promise<StudioProjectFile[]>;
|
|
6
|
+
export declare function readProjectFile(root: string, path: string): Promise<string>;
|
|
7
|
+
export declare function writeProjectFile(root: string, path: string, content: string): Promise<void>;
|
|
8
|
+
export declare function applyProjectPatch(root: string, path: string, before: string, after: string): Promise<'created' | 'updated'>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, readFile, readdir, realpath, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
5
|
+
const SKIP_DIRECTORIES = new Set(['.git', 'node_modules']);
|
|
6
|
+
function inside(root, target) {
|
|
7
|
+
const path = relative(root, target);
|
|
8
|
+
return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path));
|
|
9
|
+
}
|
|
10
|
+
function relativePath(input) {
|
|
11
|
+
if (input === '' || isAbsolute(input) || input.includes('\\'))
|
|
12
|
+
throw new Error('path must be a relative project path');
|
|
13
|
+
const parts = input.split('/');
|
|
14
|
+
if (parts.some(part => part === '' || part === '.' || part === '..'))
|
|
15
|
+
throw new Error('path contains an invalid segment');
|
|
16
|
+
return parts.join(sep);
|
|
17
|
+
}
|
|
18
|
+
async function projectRoot(root) {
|
|
19
|
+
return realpath(root);
|
|
20
|
+
}
|
|
21
|
+
async function existingPath(root, input) {
|
|
22
|
+
const base = await projectRoot(root);
|
|
23
|
+
const target = await realpath(resolve(base, relativePath(input)));
|
|
24
|
+
if (!inside(base, target))
|
|
25
|
+
throw new Error('path escapes the Draft root');
|
|
26
|
+
return target;
|
|
27
|
+
}
|
|
28
|
+
export async function listProjectFiles(root) {
|
|
29
|
+
const base = await projectRoot(root);
|
|
30
|
+
const files = [];
|
|
31
|
+
const walk = async (directory) => {
|
|
32
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
33
|
+
if (entry.isSymbolicLink())
|
|
34
|
+
continue;
|
|
35
|
+
const absolute = join(directory, entry.name);
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
if (!SKIP_DIRECTORIES.has(entry.name))
|
|
38
|
+
await walk(absolute);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!entry.isFile())
|
|
42
|
+
continue;
|
|
43
|
+
const info = await lstat(absolute);
|
|
44
|
+
files.push({ path: relative(base, absolute).split(sep).join('/'), size: info.size });
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
await walk(base);
|
|
48
|
+
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
49
|
+
}
|
|
50
|
+
export async function readProjectFile(root, path) {
|
|
51
|
+
const target = await existingPath(root, path);
|
|
52
|
+
const info = await lstat(target);
|
|
53
|
+
if (!info.isFile())
|
|
54
|
+
throw new Error('path does not point to a file');
|
|
55
|
+
if (info.size > MAX_FILE_BYTES)
|
|
56
|
+
throw new Error('file exceeds the 1 MiB Studio limit');
|
|
57
|
+
const content = await readFile(target);
|
|
58
|
+
if (content.includes(0))
|
|
59
|
+
throw new Error('binary files cannot be opened in Studio');
|
|
60
|
+
return content.toString('utf8');
|
|
61
|
+
}
|
|
62
|
+
export async function writeProjectFile(root, path, content) {
|
|
63
|
+
if (Buffer.byteLength(content) > MAX_FILE_BYTES)
|
|
64
|
+
throw new Error('file exceeds the 1 MiB Studio limit');
|
|
65
|
+
const base = await projectRoot(root);
|
|
66
|
+
const target = resolve(base, relativePath(path));
|
|
67
|
+
if (!inside(base, target))
|
|
68
|
+
throw new Error('path escapes the Draft root');
|
|
69
|
+
const parent = await realpath(resolve(target, '..'));
|
|
70
|
+
if (!inside(base, parent))
|
|
71
|
+
throw new Error('path parent escapes the Draft root');
|
|
72
|
+
try {
|
|
73
|
+
if ((await lstat(target)).isSymbolicLink())
|
|
74
|
+
throw new Error('Studio does not write through symbolic links');
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error.code !== 'ENOENT')
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
const temporary = join(parent, `.${randomUUID()}.dsh-studio.tmp`);
|
|
81
|
+
try {
|
|
82
|
+
await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' });
|
|
83
|
+
await rename(temporary, target);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
await unlink(temporary).catch(() => undefined);
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export async function applyProjectPatch(root, path, before, after) {
|
|
91
|
+
let source;
|
|
92
|
+
try {
|
|
93
|
+
source = await readProjectFile(root, path);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error.code !== 'ENOENT')
|
|
97
|
+
throw error;
|
|
98
|
+
if (before !== '')
|
|
99
|
+
throw new Error('cannot patch a missing file unless before is empty');
|
|
100
|
+
await writeProjectFile(root, path, after);
|
|
101
|
+
return 'created';
|
|
102
|
+
}
|
|
103
|
+
const first = source.indexOf(before);
|
|
104
|
+
if (first === -1)
|
|
105
|
+
throw new Error('patch before text was not found');
|
|
106
|
+
if (source.indexOf(before, first + before.length) !== -1)
|
|
107
|
+
throw new Error('patch before text is not unique');
|
|
108
|
+
await writeProjectFile(root, path, `${source.slice(0, first)}${after}${source.slice(first + before.length)}`);
|
|
109
|
+
return 'updated';
|
|
110
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess';
|
|
2
|
+
import type { StudioHarmonyInspection, StudioPackResult, StudioReadinessReport, StudioPatchDependency } from '../contracts.js';
|
|
3
|
+
export declare function inspectReadiness(root: string, projectName: string, inspection: StudioHarmonyInspection, profileDir: string, dependencies?: StudioPatchDependency[]): StudioReadinessReport;
|
|
4
|
+
export declare class StudioPackRunner {
|
|
5
|
+
private readonly subprocess;
|
|
6
|
+
private readonly timeoutMs;
|
|
7
|
+
private active?;
|
|
8
|
+
constructor(subprocess: SubprocessRuntime, timeoutMs?: number);
|
|
9
|
+
run(root: string): Promise<StudioPackResult>;
|
|
10
|
+
dispose(): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
|
+
const PACK_TIMEOUT_MS = 120_000;
|
|
4
|
+
const OUTPUT_LIMIT_BYTES = 256 * 1024;
|
|
5
|
+
function finding(level, code, message, fields = {}) {
|
|
6
|
+
return { level, code, message, ...fields };
|
|
7
|
+
}
|
|
8
|
+
function stringArray(value) {
|
|
9
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
10
|
+
}
|
|
11
|
+
function exportPath(value) {
|
|
12
|
+
if (typeof value === 'string')
|
|
13
|
+
return value;
|
|
14
|
+
if (typeof value !== 'object' || value === null)
|
|
15
|
+
return undefined;
|
|
16
|
+
const record = value;
|
|
17
|
+
return exportPath(record.import) ?? exportPath(record.default) ?? exportPath(record.require);
|
|
18
|
+
}
|
|
19
|
+
function clientExport(manifest) {
|
|
20
|
+
if (typeof manifest.exports !== 'object' || manifest.exports === null)
|
|
21
|
+
return undefined;
|
|
22
|
+
return exportPath(manifest.exports['./client']);
|
|
23
|
+
}
|
|
24
|
+
function rootExport(manifest) {
|
|
25
|
+
if (typeof manifest.main === 'string')
|
|
26
|
+
return manifest.main;
|
|
27
|
+
if (typeof manifest.exports !== 'object' || manifest.exports === null)
|
|
28
|
+
return undefined;
|
|
29
|
+
return exportPath(manifest.exports['.']);
|
|
30
|
+
}
|
|
31
|
+
function within(root, path) {
|
|
32
|
+
const remainder = relative(root, path);
|
|
33
|
+
return remainder === '' || (!remainder.startsWith('..') && !isAbsolute(remainder));
|
|
34
|
+
}
|
|
35
|
+
function artifactFinding(root, path, label) {
|
|
36
|
+
const target = resolve(root, path);
|
|
37
|
+
if (!within(root, target))
|
|
38
|
+
return finding('error', 'artifact-escapes-root', `${label} ${JSON.stringify(path)} escapes the Draft root`, { file: path });
|
|
39
|
+
if (!existsSync(target))
|
|
40
|
+
return finding('error', 'artifact-missing', `${label} ${JSON.stringify(path)} does not exist; build the Draft before publishing`, { file: path });
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
function readManifest(root) {
|
|
44
|
+
return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
45
|
+
}
|
|
46
|
+
export function inspectReadiness(root, projectName, inspection, profileDir, dependencies = []) {
|
|
47
|
+
const manifest = readManifest(root);
|
|
48
|
+
const findings = [];
|
|
49
|
+
if (typeof manifest.name !== 'string' || manifest.name === '') {
|
|
50
|
+
findings.push(finding('error', 'manifest-name', 'package.json must declare a non-empty name', { file: 'package.json' }));
|
|
51
|
+
}
|
|
52
|
+
else if (manifest.name !== projectName) {
|
|
53
|
+
findings.push(finding('error', 'manifest-identity', `package.json name must remain ${JSON.stringify(projectName)}`, { file: 'package.json' }));
|
|
54
|
+
}
|
|
55
|
+
if (typeof manifest.version !== 'string' || manifest.version === '') {
|
|
56
|
+
findings.push(finding('error', 'manifest-version', 'package.json must declare a publishable version', { file: 'package.json' }));
|
|
57
|
+
}
|
|
58
|
+
if (manifest.dsh?.client?.platform !== 'web') {
|
|
59
|
+
findings.push(finding('error', 'client-platform', 'dsh.client.platform must be "web"', { file: 'package.json' }));
|
|
60
|
+
}
|
|
61
|
+
if (typeof manifest.scripts?.build !== 'string' || manifest.scripts.build.trim() === '') {
|
|
62
|
+
findings.push(finding('error', 'build-script', 'package.json must declare a non-empty scripts.build', { file: 'package.json' }));
|
|
63
|
+
}
|
|
64
|
+
const client = clientExport(manifest);
|
|
65
|
+
if (client === undefined) {
|
|
66
|
+
findings.push(finding('error', 'client-export', 'package.json must export "./client"', { file: 'package.json' }));
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const missing = artifactFinding(root, client, 'Client export');
|
|
70
|
+
if (missing !== undefined)
|
|
71
|
+
findings.push(missing);
|
|
72
|
+
}
|
|
73
|
+
if (typeof manifest.exports !== 'object' || manifest.exports === null
|
|
74
|
+
|| manifest.exports['./package.json'] === undefined) {
|
|
75
|
+
findings.push(finding('error', 'package-json-export', 'package.json must export "./package.json" for DSH Client discovery', { file: 'package.json' }));
|
|
76
|
+
}
|
|
77
|
+
const host = rootExport(manifest);
|
|
78
|
+
if (host === undefined) {
|
|
79
|
+
findings.push(finding('error', 'host-export', 'package.json must expose a root Host entry through main or exports["."]', { file: 'package.json' }));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const missing = artifactFinding(root, host, 'Host export');
|
|
83
|
+
if (missing !== undefined)
|
|
84
|
+
findings.push(missing);
|
|
85
|
+
}
|
|
86
|
+
for (const [path, label] of [[manifest.types, 'Types entry']]) {
|
|
87
|
+
if (typeof path !== 'string')
|
|
88
|
+
continue;
|
|
89
|
+
const missing = artifactFinding(root, path, label);
|
|
90
|
+
if (missing !== undefined)
|
|
91
|
+
findings.push(missing);
|
|
92
|
+
}
|
|
93
|
+
const harmony = manifest.dsh?.harmony;
|
|
94
|
+
const patchFiles = stringArray(harmony?.patches);
|
|
95
|
+
if (harmony?.patches !== undefined && (!Array.isArray(harmony.patches) || patchFiles.length !== harmony.patches.length)) {
|
|
96
|
+
findings.push(finding('error', 'patch-manifest', 'dsh.harmony.patches must contain only file paths', { file: 'package.json' }));
|
|
97
|
+
}
|
|
98
|
+
for (const path of patchFiles) {
|
|
99
|
+
const target = resolve(root, path);
|
|
100
|
+
if (!within(root, target)) {
|
|
101
|
+
findings.push(finding('error', 'patch-escapes-root', `Harmony patch ${JSON.stringify(path)} escapes the Draft root`, { file: path }));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!existsSync(target)) {
|
|
105
|
+
findings.push(finding('error', 'patch-file-missing', `Harmony patch ${JSON.stringify(path)} does not exist`, { file: path }));
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!within(realpathSync(root), realpathSync(target))) {
|
|
109
|
+
findings.push(finding('error', 'patch-symlink-escape', `Harmony patch ${JSON.stringify(path)} resolves outside the Draft root`, { file: path }));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const declared = new Set([...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})]);
|
|
113
|
+
const declaredAfter = new Set(stringArray(harmony?.after));
|
|
114
|
+
for (const dependency of stringArray(manifest.dsh?.client?.inject)) {
|
|
115
|
+
if (!declared.has(dependency)) {
|
|
116
|
+
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`));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const dependency of [...stringArray(harmony?.before), ...stringArray(harmony?.after), ...stringArray(harmony?.conflicts)]) {
|
|
120
|
+
if (!declared.has(dependency)) {
|
|
121
|
+
findings.push(finding('warning', 'ambient-provider', `Harmony provider ${JSON.stringify(dependency)} affects ordering or compatibility but is not declared as a dependency or peer dependency`));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const patches = inspection.patches.filter(patch => patch.owner === projectName);
|
|
125
|
+
for (const patch of patches) {
|
|
126
|
+
if (!declared.has(patch.target.package)) {
|
|
127
|
+
findings.push(finding('warning', 'ambient-patch-target', `Patch target ${JSON.stringify(patch.target.package)} is available in this Preview but is not declared as a dependency or peer dependency`, { patch: patch.key }));
|
|
128
|
+
}
|
|
129
|
+
if (patch.target.version === undefined) {
|
|
130
|
+
findings.push(finding('warning', 'unbounded-target-version', `Patch ${JSON.stringify(patch.key)} does not constrain the target package version`, { patch: patch.key }));
|
|
131
|
+
}
|
|
132
|
+
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: patch.file }));
|
|
134
|
+
}
|
|
135
|
+
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: patch.file }));
|
|
137
|
+
}
|
|
138
|
+
else if (patch.state === 'pending' || !patch.loaded) {
|
|
139
|
+
findings.push(finding('warning', 'patch-unverified', `Patch ${JSON.stringify(patch.key)} has not been exercised by the current Preview`, { patch: patch.key, file: patch.file }));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
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
|
+
const orderPath = join(profileDir, 'harmony.json');
|
|
149
|
+
if (existsSync(orderPath)) {
|
|
150
|
+
const order = JSON.parse(readFileSync(orderPath, 'utf8')).order;
|
|
151
|
+
if (Array.isArray(order) && order.every((item) => typeof item === 'string')) {
|
|
152
|
+
const position = new Map(order.map((name, index) => [name, index]));
|
|
153
|
+
for (const target of stringArray(harmony?.before)) {
|
|
154
|
+
if ((position.get(projectName) ?? -1) > (position.get(target) ?? Number.MAX_SAFE_INTEGER)) {
|
|
155
|
+
findings.push(finding('info', 'effective-order', `Current Preview places ${projectName} after ${target}, contrary to its before declaration`));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const target of stringArray(harmony?.after)) {
|
|
159
|
+
if ((position.get(target) ?? -1) > (position.get(projectName) ?? Number.MAX_SAFE_INTEGER)) {
|
|
160
|
+
findings.push(finding('info', 'effective-order', `Current Preview places ${projectName} before ${target}, contrary to its after declaration`));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const rank = { error: 0, warning: 1, info: 2 };
|
|
166
|
+
findings.sort((left, right) => rank[left.level] - rank[right.level] || left.code.localeCompare(right.code) || left.message.localeCompare(right.message));
|
|
167
|
+
return { findings };
|
|
168
|
+
}
|
|
169
|
+
function outputOf(handle, argv) {
|
|
170
|
+
const stdout = handle.collected.stdout?.readFrom(0);
|
|
171
|
+
const stderr = handle.collected.stderr?.readFrom(0);
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
argv,
|
|
175
|
+
files: [],
|
|
176
|
+
stdout: stdout?.text ?? '',
|
|
177
|
+
stderr: stderr?.text ?? '',
|
|
178
|
+
truncated: stdout?.lossy === true || stderr?.lossy === true,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
export class StudioPackRunner {
|
|
182
|
+
subprocess;
|
|
183
|
+
timeoutMs;
|
|
184
|
+
active;
|
|
185
|
+
constructor(subprocess, timeoutMs = PACK_TIMEOUT_MS) {
|
|
186
|
+
this.subprocess = subprocess;
|
|
187
|
+
this.timeoutMs = timeoutMs;
|
|
188
|
+
}
|
|
189
|
+
async run(root) {
|
|
190
|
+
if (this.active !== undefined)
|
|
191
|
+
throw new Error('a package dry-run is already running');
|
|
192
|
+
const controller = new AbortController();
|
|
193
|
+
const active = { controller };
|
|
194
|
+
this.active = active;
|
|
195
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
196
|
+
const argv = ['npm', 'pack', '--dry-run', '--json', '--ignore-scripts'];
|
|
197
|
+
let handle;
|
|
198
|
+
try {
|
|
199
|
+
argv[0] = await this.subprocess.resolveExecutable('npm', undefined, controller.signal);
|
|
200
|
+
handle = this.subprocess.spawn({
|
|
201
|
+
argv,
|
|
202
|
+
cwd: root,
|
|
203
|
+
stdio: {
|
|
204
|
+
stdin: 'ignore',
|
|
205
|
+
stdout: { maxBytes: OUTPUT_LIMIT_BYTES },
|
|
206
|
+
stderr: { maxBytes: OUTPUT_LIMIT_BYTES },
|
|
207
|
+
},
|
|
208
|
+
graceMs: 2_000,
|
|
209
|
+
signal: controller.signal,
|
|
210
|
+
});
|
|
211
|
+
active.handle = handle;
|
|
212
|
+
const outcome = await handle.done;
|
|
213
|
+
const output = outputOf(handle, argv);
|
|
214
|
+
if (outcome.exitCode !== 0)
|
|
215
|
+
return output;
|
|
216
|
+
try {
|
|
217
|
+
const records = JSON.parse(output.stdout);
|
|
218
|
+
if (!Array.isArray(records) || records.length !== 1 || !Array.isArray(records[0]?.files)) {
|
|
219
|
+
return { ...output, stderr: `${output.stderr}${output.stderr === '' ? '' : '\n'}npm pack returned an unexpected JSON result` };
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
...output,
|
|
223
|
+
ok: true,
|
|
224
|
+
files: records[0].files.flatMap(file => typeof file.path === 'string' ? [file.path] : []).sort(),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
return { ...output, stderr: `${output.stderr}${output.stderr === '' ? '' : '\n'}${error instanceof Error ? error.message : String(error)}` };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
clearTimeout(timeout);
|
|
233
|
+
if (controller.signal.aborted && handle !== undefined)
|
|
234
|
+
await handle.waitForExit();
|
|
235
|
+
if (this.active === active)
|
|
236
|
+
this.active = undefined;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async dispose() {
|
|
240
|
+
const active = this.active;
|
|
241
|
+
if (active === undefined)
|
|
242
|
+
return;
|
|
243
|
+
active.controller.abort();
|
|
244
|
+
if (active.handle !== undefined)
|
|
245
|
+
await active.handle.waitForExit();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
|
|
3
|
+
import type { StudioBackend } from './backend.js';
|
|
4
|
+
export interface StudioAssets {
|
|
5
|
+
script: Buffer;
|
|
6
|
+
style: Buffer;
|
|
7
|
+
bridge: Buffer;
|
|
8
|
+
icon: Buffer;
|
|
9
|
+
iconMono: Buffer;
|
|
10
|
+
}
|
|
11
|
+
export interface StudioRouteSecurity {
|
|
12
|
+
token: string;
|
|
13
|
+
origin: string;
|
|
14
|
+
host: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function isTrustedStudioRequest(request: IncomingMessage): boolean;
|
|
17
|
+
export declare function createStudioRoutes(backend: StudioBackend, assets: StudioAssets, security: StudioRouteSecurity): WebRoute[];
|