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/drafts.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { StudioCreateDraftInput, StudioDraftRecord } from '../contracts.js';
|
|
2
2
|
export interface StudioCommandRunner {
|
|
3
|
-
run(command: string, args: string[], cwd?: string, onOutput?: (chunk: string) => void, signal?: AbortSignal): Promise<void>;
|
|
3
|
+
run(command: string, args: string[], cwd?: string, onOutput?: (chunk: string) => void, signal?: AbortSignal, env?: NodeJS.ProcessEnv): Promise<void>;
|
|
4
4
|
}
|
|
5
5
|
export declare const studioCommands: StudioCommandRunner;
|
|
6
6
|
export declare class StudioDraftRegistry {
|
package/lib/host/drafts.js
CHANGED
|
@@ -5,9 +5,9 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod
|
|
|
5
5
|
const PACKAGE_NAME = /^(?:@[a-z0-9._~-]+\/)?[a-z0-9._~-]+$/;
|
|
6
6
|
const COMMAND_OUTPUT_LIMIT = 2 * 1024 * 1024;
|
|
7
7
|
export const studioCommands = {
|
|
8
|
-
run(command, args, cwd, onOutput, signal) {
|
|
8
|
+
run(command, args, cwd, onOutput, signal, env) {
|
|
9
9
|
return new Promise((resolve, reject) => {
|
|
10
|
-
const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], signal });
|
|
10
|
+
const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], signal });
|
|
11
11
|
let output = '';
|
|
12
12
|
const read = (chunk) => {
|
|
13
13
|
const text = chunk.toString();
|
|
@@ -32,18 +32,41 @@ function templateManifest(name) {
|
|
|
32
32
|
version: '0.1.0',
|
|
33
33
|
private: true,
|
|
34
34
|
type: 'module',
|
|
35
|
-
packageManager: '
|
|
35
|
+
packageManager: 'pnpm@10.34.5',
|
|
36
36
|
exports: { '.': './lib/index.js', './client': './lib/client.js', './package.json': './package.json' },
|
|
37
|
-
scripts: { build: '
|
|
38
|
-
dsh: { client: { platform: 'web' }, harmony: { patches: [] } },
|
|
37
|
+
scripts: { build: 'npm run build:client && npm run build:host', 'build:client': 'tsdown --config-loader unrun', 'build:host': 'tsc -p tsconfig.host.json' },
|
|
38
|
+
dsh: { client: { immediately: true, inject: ['@deepseek-ai/dsh-client-runtime'], platform: 'web' }, harmony: { patches: [] } },
|
|
39
|
+
dependencies: { 'dsh-harmony-react': '^0.3.0' },
|
|
40
|
+
peerDependencies: { '@deepseek-ai/dsh-client-runtime': '0.1.0-rc.7', react: '^18.3.1' },
|
|
41
|
+
devDependencies: {
|
|
42
|
+
'@deepseek-ai/dsh-client-runtime': '0.1.0-rc.7', '@tsdown/css': '0.22.14', '@types/react': '~18.3.1', react: '^18.3.1',
|
|
43
|
+
tsdown: '0.22.14', typescript: '^6.0.3', unrun: '0.3.1',
|
|
44
|
+
},
|
|
39
45
|
}, null, 2)}\n`;
|
|
40
46
|
}
|
|
41
|
-
function templateClient(
|
|
42
|
-
return `
|
|
47
|
+
function templateClient() {
|
|
48
|
+
return `import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
49
|
+
|
|
50
|
+
export function apply(_ctx: ClientContext): void {}
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
function templateTsdown(name) {
|
|
54
|
+
return `import { defineConfig } from 'tsdown'
|
|
55
|
+
|
|
56
|
+
const moduleHeader = \`window.__ModuleLoader__.load({
|
|
43
57
|
id: ${JSON.stringify(name)},
|
|
44
|
-
factory: () =>
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
factory: (require) => {
|
|
59
|
+
const module = { exports: {} };
|
|
60
|
+
const exports = module.exports;\`
|
|
61
|
+
const moduleFooter = \`return module.exports;
|
|
62
|
+
},
|
|
63
|
+
});\`
|
|
64
|
+
|
|
65
|
+
export default defineConfig({
|
|
66
|
+
entry: { client: 'src/client.tsx' }, tsconfig: 'tsconfig.client.json', format: 'cjs', platform: 'browser', target: 'es2023',
|
|
67
|
+
deps: { alwaysBundle: ['dsh-harmony-react/studio'], neverBundle: ['react', 'react/jsx-runtime'], onlyBundle: ['dsh-harmony-react'] },
|
|
68
|
+
outDir: 'lib', clean: true, fixedExtension: false, outExtensions: () => ({ js: '.js' }), hash: false, sourcemap: true, dts: false,
|
|
69
|
+
banner: { js: moduleHeader }, footer: { js: moduleFooter },
|
|
47
70
|
})
|
|
48
71
|
`;
|
|
49
72
|
}
|
|
@@ -196,11 +219,38 @@ async function validateDestinationDirectory(input, studioRoot) {
|
|
|
196
219
|
}
|
|
197
220
|
return target;
|
|
198
221
|
}
|
|
222
|
+
async function validateProfileDirectory(mode, input) {
|
|
223
|
+
if (mode === 'main-home') {
|
|
224
|
+
if (input !== undefined)
|
|
225
|
+
throw new Error('Only custom Draft profiles can specify a profile folder');
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
if (typeof input !== 'string' || input.trim() === '')
|
|
229
|
+
throw new Error('Custom profile folder is required');
|
|
230
|
+
if (!isAbsolute(input.trim()))
|
|
231
|
+
throw new Error('Custom profile folder must be an absolute path');
|
|
232
|
+
try {
|
|
233
|
+
const directory = await realpath(input.trim());
|
|
234
|
+
if (!(await lstat(directory)).isDirectory())
|
|
235
|
+
throw new Error('Custom profile path must be a directory');
|
|
236
|
+
const manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8'));
|
|
237
|
+
if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest))
|
|
238
|
+
throw new Error('package.json must contain an object');
|
|
239
|
+
return directory;
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
throw new Error(`Custom profile folder must contain a readable package.json: ${error instanceof Error ? error.message : String(error)}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
199
245
|
async function initializeRepository(root, name, commands) {
|
|
200
|
-
await mkdir(join(root, '
|
|
246
|
+
await mkdir(join(root, 'src'), { recursive: true });
|
|
201
247
|
await writeFile(join(root, 'package.json'), templateManifest(name));
|
|
202
|
-
await writeFile(join(root, '
|
|
203
|
-
await writeFile(join(root, '
|
|
248
|
+
await writeFile(join(root, 'src/index.ts'), `export const name = ${JSON.stringify(name)}\nexport function apply(): void {}\n`);
|
|
249
|
+
await writeFile(join(root, 'src/client.tsx'), templateClient());
|
|
250
|
+
await writeFile(join(root, 'tsdown.config.ts'), templateTsdown(name));
|
|
251
|
+
await writeFile(join(root, 'tsconfig.client.json'), `${JSON.stringify({ compilerOptions: { target: 'ES2023', module: 'ESNext', moduleResolution: 'Bundler', jsx: 'react-jsx', strict: true, noUncheckedIndexedAccess: true, skipLibCheck: true, types: ['react'] }, include: ['src/client.tsx', 'src/**/*.js'] }, null, 2)}\n`);
|
|
252
|
+
await writeFile(join(root, 'tsconfig.host.json'), `${JSON.stringify({ compilerOptions: { target: 'ES2023', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, declaration: true, outDir: 'lib', rootDir: 'src', skipLibCheck: true }, include: ['src/index.ts'] }, null, 2)}\n`);
|
|
253
|
+
await writeFile(join(root, '.gitignore'), 'lib/\nnode_modules/\n');
|
|
204
254
|
await writeFile(join(root, 'README.md'), `# ${name}\n\nCreated by dsh-webui-studio.\n`);
|
|
205
255
|
await commands.run('git', ['init', '--initial-branch=main'], root);
|
|
206
256
|
await commands.run('git', ['add', '.'], root);
|
|
@@ -241,8 +291,7 @@ export class StudioDraftRegistry {
|
|
|
241
291
|
return JSON.parse(await readFile(join(this.recordsDir, `${id}.json`), 'utf8'));
|
|
242
292
|
}
|
|
243
293
|
async create(input) {
|
|
244
|
-
|
|
245
|
-
throw new Error('Custom Draft profiles are not implemented yet');
|
|
294
|
+
const profileDirectory = await validateProfileDirectory(input.profileMode, input.profileDirectory);
|
|
246
295
|
const id = randomUUID();
|
|
247
296
|
const repositoryDir = join(this.repositoriesDir, id);
|
|
248
297
|
const worktreeDir = join(this.worktreesDir, id);
|
|
@@ -309,6 +358,7 @@ export class StudioDraftRegistry {
|
|
|
309
358
|
root,
|
|
310
359
|
runtimeHome,
|
|
311
360
|
profileMode: input.profileMode,
|
|
361
|
+
...(profileDirectory === undefined ? {} : { profileDirectory }),
|
|
312
362
|
createdAt: new Date().toISOString(),
|
|
313
363
|
};
|
|
314
364
|
await writeFile(join(this.recordsDir, `${id}.json`), `${JSON.stringify(record, null, 2)}\n`, { flag: 'wx' });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { StudioElementSnapshot } from '../contracts.js';
|
|
2
|
+
import type { StudioElementStyleSource } from '../contracts.js';
|
|
3
|
+
export declare function saveElementsDefaults(root: string, elements: readonly StudioElementSnapshot[]): Promise<{
|
|
4
|
+
files: string[];
|
|
5
|
+
}>;
|
|
6
|
+
export declare function readElementsStyles(root: string, elements: readonly StudioElementSnapshot[]): Promise<StudioElementStyleSource[]>;
|
|
7
|
+
export declare function saveElementsSource(root: string, elements: readonly StudioElementSnapshot[], styles: readonly StudioElementStyleSource[]): Promise<{
|
|
8
|
+
files: string[];
|
|
9
|
+
}>;
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { unlink } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, extname, join, posix } from 'node:path';
|
|
4
|
+
import ts from 'typescript';
|
|
5
|
+
import { readProjectFile, writeProjectFile } from './project-files.js';
|
|
6
|
+
import { flattenVariableTree } from '../variable-tree.js';
|
|
7
|
+
import { compileElementStyleSelector } from '../bridge/element-style-selector.js';
|
|
8
|
+
function count(source, needle) {
|
|
9
|
+
let matches = 0;
|
|
10
|
+
let offset = source.indexOf(needle);
|
|
11
|
+
while (offset !== -1) {
|
|
12
|
+
matches += 1;
|
|
13
|
+
offset = source.indexOf(needle, offset + needle.length);
|
|
14
|
+
}
|
|
15
|
+
return matches;
|
|
16
|
+
}
|
|
17
|
+
function quoteString(value, current, id) {
|
|
18
|
+
const quote = current[0];
|
|
19
|
+
if (quote !== "'" && quote !== '"') {
|
|
20
|
+
throw new Error(`Element variable ${id} default is not a quoted string literal`);
|
|
21
|
+
}
|
|
22
|
+
const stringLiteral = quote === "'" ? /^'(?:\\.|[^'\\\r\n])*'$/ : /^"(?:\\.|[^"\\\r\n])*"$/;
|
|
23
|
+
if (!stringLiteral.test(current))
|
|
24
|
+
throw new Error(`Element variable ${id} default is not a complete string literal`);
|
|
25
|
+
if (quote === '"')
|
|
26
|
+
return JSON.stringify(value);
|
|
27
|
+
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
|
|
28
|
+
}
|
|
29
|
+
function serializeDefault(definition, value, current) {
|
|
30
|
+
const expression = current.trim();
|
|
31
|
+
if (definition.control === 'number') {
|
|
32
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
33
|
+
throw new Error(`Element variable ${definition.id} must be a finite number`);
|
|
34
|
+
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(expression)) {
|
|
35
|
+
throw new Error(`Element variable ${definition.id} default is not a number literal`);
|
|
36
|
+
}
|
|
37
|
+
return String(value);
|
|
38
|
+
}
|
|
39
|
+
if (definition.control === 'boolean') {
|
|
40
|
+
if (typeof value !== 'boolean')
|
|
41
|
+
throw new Error(`Element variable ${definition.id} must be boolean`);
|
|
42
|
+
if (expression !== 'true' && expression !== 'false') {
|
|
43
|
+
throw new Error(`Element variable ${definition.id} default is not a boolean literal`);
|
|
44
|
+
}
|
|
45
|
+
return String(value);
|
|
46
|
+
}
|
|
47
|
+
if (typeof value !== 'string')
|
|
48
|
+
throw new Error(`Element variable ${definition.id} must be a string`);
|
|
49
|
+
return quoteString(value, expression, definition.id);
|
|
50
|
+
}
|
|
51
|
+
function replacementsForFile(source, variables) {
|
|
52
|
+
const replacements = [];
|
|
53
|
+
for (const { definition, value } of variables) {
|
|
54
|
+
const anchor = definition.defaultSource;
|
|
55
|
+
if (anchor === undefined)
|
|
56
|
+
continue;
|
|
57
|
+
const start = source.indexOf(anchor.before);
|
|
58
|
+
if (start === -1 || count(source, anchor.before) !== 1) {
|
|
59
|
+
throw new Error(`Element variable ${definition.id} default source prefix is not unique`);
|
|
60
|
+
}
|
|
61
|
+
const valueStart = start + anchor.before.length;
|
|
62
|
+
const end = source.indexOf(anchor.after, valueStart);
|
|
63
|
+
if (end === -1)
|
|
64
|
+
throw new Error(`Element variable ${definition.id} default source suffix was not found`);
|
|
65
|
+
const between = source.slice(valueStart, end);
|
|
66
|
+
const leading = between.match(/^\s*/)?.[0].length ?? 0;
|
|
67
|
+
const trailing = between.match(/\s*$/)?.[0].length ?? 0;
|
|
68
|
+
const literalStart = valueStart + leading;
|
|
69
|
+
const literalEnd = end - trailing;
|
|
70
|
+
const current = source.slice(literalStart, literalEnd);
|
|
71
|
+
if (current === '')
|
|
72
|
+
throw new Error(`Element variable ${definition.id} default source value is empty`);
|
|
73
|
+
replacements.push({
|
|
74
|
+
start: literalStart,
|
|
75
|
+
end: literalEnd,
|
|
76
|
+
value: serializeDefault(definition, value, current),
|
|
77
|
+
id: definition.id,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
replacements.sort((left, right) => left.start - right.start);
|
|
81
|
+
for (let index = 1; index < replacements.length; index += 1) {
|
|
82
|
+
if (replacements[index - 1].end > replacements[index].start) {
|
|
83
|
+
throw new Error(`Element variable default source anchors overlap (${replacements[index - 1].id}, ${replacements[index].id})`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return replacements;
|
|
87
|
+
}
|
|
88
|
+
export async function saveElementsDefaults(root, elements) {
|
|
89
|
+
const grouped = new Map();
|
|
90
|
+
for (const element of elements) {
|
|
91
|
+
for (const definition of flattenVariableTree(element.element.variables ?? [])) {
|
|
92
|
+
if (definition.defaultSource === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
const variable = { definition, value: element.values[definition.id] };
|
|
95
|
+
const list = grouped.get(definition.defaultSource.file);
|
|
96
|
+
if (list === undefined)
|
|
97
|
+
grouped.set(definition.defaultSource.file, [variable]);
|
|
98
|
+
else
|
|
99
|
+
list.push(variable);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (grouped.size === 0)
|
|
103
|
+
throw new Error('This Draft has no source-backed Element defaults');
|
|
104
|
+
const updates = [];
|
|
105
|
+
for (const [file, fileVariables] of grouped) {
|
|
106
|
+
const source = await readProjectFile(root, file);
|
|
107
|
+
const replacements = replacementsForFile(source, fileVariables);
|
|
108
|
+
let content = source;
|
|
109
|
+
for (const replacement of [...replacements].reverse()) {
|
|
110
|
+
content = `${content.slice(0, replacement.start)}${replacement.value}${content.slice(replacement.end)}`;
|
|
111
|
+
}
|
|
112
|
+
updates.push({ file, original: source, content });
|
|
113
|
+
}
|
|
114
|
+
const written = [];
|
|
115
|
+
try {
|
|
116
|
+
for (const update of updates) {
|
|
117
|
+
await writeProjectFile(root, update.file, update.content);
|
|
118
|
+
written.push(update);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const rollbackErrors = [];
|
|
123
|
+
for (const update of written.reverse()) {
|
|
124
|
+
try {
|
|
125
|
+
await writeProjectFile(root, update.file, update.original);
|
|
126
|
+
}
|
|
127
|
+
catch (rollbackError) {
|
|
128
|
+
rollbackErrors.push(rollbackError);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (rollbackErrors.length > 0)
|
|
132
|
+
throw new AggregateError([error, ...rollbackErrors], 'Element default save rollback failed');
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
return { files: updates.map(update => update.file) };
|
|
136
|
+
}
|
|
137
|
+
const STYLE_HEADER = '/* dsh-webui-studio:element-styles ';
|
|
138
|
+
function styleFile(sourceFile, owner, elementId) {
|
|
139
|
+
const extension = extname(sourceFile);
|
|
140
|
+
const stem = basename(sourceFile, extension);
|
|
141
|
+
const hash = createHash('sha256').update(`${owner}\0${elementId}`).digest('hex').slice(0, 10);
|
|
142
|
+
return posix.join(dirname(sourceFile).split('\\').join('/'), `${stem}.dsh-studio-${hash}.css`);
|
|
143
|
+
}
|
|
144
|
+
function cssString(value) {
|
|
145
|
+
return JSON.stringify(value).replaceAll('</', '<\\/');
|
|
146
|
+
}
|
|
147
|
+
function validateRules(rules) {
|
|
148
|
+
const selectors = new Set();
|
|
149
|
+
for (const rule of rules) {
|
|
150
|
+
compileElementStyleSelector(rule.selector, '[data-dsh-studio-root]');
|
|
151
|
+
if (selectors.has(rule.selector))
|
|
152
|
+
throw new Error(`Element CSS selector ${JSON.stringify(rule.selector)} is duplicated`);
|
|
153
|
+
selectors.add(rule.selector);
|
|
154
|
+
const properties = new Set();
|
|
155
|
+
for (const declaration of rule.declarations) {
|
|
156
|
+
if (!/^(?:--)?[a-zA-Z][a-zA-Z0-9-]*$/.test(declaration.property) || declaration.value.trim() === ''
|
|
157
|
+
|| /[{}]/.test(declaration.value)) {
|
|
158
|
+
throw new Error(`Element CSS declaration ${JSON.stringify(declaration.property)} is invalid`);
|
|
159
|
+
}
|
|
160
|
+
if (properties.has(declaration.property))
|
|
161
|
+
throw new Error(`Element CSS property ${JSON.stringify(declaration.property)} is duplicated`);
|
|
162
|
+
properties.add(declaration.property);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function serializedStyles(element, rules) {
|
|
167
|
+
validateRules(rules);
|
|
168
|
+
const metadata = Buffer.from(JSON.stringify(rules), 'utf8').toString('base64url');
|
|
169
|
+
const boundary = element.element.boundary;
|
|
170
|
+
const root = `[data-ui-surface=${cssString(boundary.surfaceId)}][data-ui-surface-path=${cssString(JSON.stringify(boundary.path))}]`;
|
|
171
|
+
const blocks = rules.flatMap(rule => rule.declarations.length === 0 ? [] : [
|
|
172
|
+
`${compileElementStyleSelector(rule.selector, root)} {\n${rule.declarations.map(item => ` ${item.property}: ${item.value};`).join('\n')}\n}`,
|
|
173
|
+
]);
|
|
174
|
+
return `${STYLE_HEADER}${metadata} */\n${blocks.join('\n\n')}${blocks.length === 0 ? '' : '\n'}`;
|
|
175
|
+
}
|
|
176
|
+
function styleImport(sourceFile, generatedFile) {
|
|
177
|
+
let relative = posix.relative(posix.dirname(sourceFile), generatedFile);
|
|
178
|
+
if (!relative.startsWith('.'))
|
|
179
|
+
relative = `./${relative}`;
|
|
180
|
+
return `import ${JSON.stringify(relative)}`;
|
|
181
|
+
}
|
|
182
|
+
function insertStyleImport(source, declaration, file) {
|
|
183
|
+
if (source.split(/\r?\n/).some(line => line.trim().replace(/;$/, '') === declaration))
|
|
184
|
+
return source;
|
|
185
|
+
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
|
|
186
|
+
let offset = 0;
|
|
187
|
+
for (const statement of sourceFile.statements) {
|
|
188
|
+
const directive = ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression);
|
|
189
|
+
if (!directive && !ts.isImportDeclaration(statement) && !ts.isImportEqualsDeclaration(statement))
|
|
190
|
+
break;
|
|
191
|
+
offset = statement.end;
|
|
192
|
+
}
|
|
193
|
+
if (offset === 0)
|
|
194
|
+
return `${declaration}\n${source}`;
|
|
195
|
+
return `${source.slice(0, offset)}\n${declaration}${source.slice(offset)}`;
|
|
196
|
+
}
|
|
197
|
+
async function readOptionalProjectFile(root, file) {
|
|
198
|
+
try {
|
|
199
|
+
return await readProjectFile(root, file);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (error.code === 'ENOENT')
|
|
203
|
+
return undefined;
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export async function readElementsStyles(root, elements) {
|
|
208
|
+
return Promise.all(elements.map(async (element) => {
|
|
209
|
+
const file = styleFile(element.element.source.file, element.owner, element.element.id);
|
|
210
|
+
const source = await readOptionalProjectFile(root, file);
|
|
211
|
+
if (source === undefined || !source.startsWith(STYLE_HEADER))
|
|
212
|
+
return { elementId: element.element.id, rules: [] };
|
|
213
|
+
const end = source.indexOf(' */', STYLE_HEADER.length);
|
|
214
|
+
if (end === -1)
|
|
215
|
+
throw new Error(`Element style metadata in ${file} is incomplete`);
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(Buffer.from(source.slice(STYLE_HEADER.length, end), 'base64url').toString('utf8'));
|
|
218
|
+
validateRules(parsed);
|
|
219
|
+
return { elementId: element.element.id, rules: parsed };
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
throw new Error(`Element style metadata in ${file} is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
223
|
+
}
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
export async function saveElementsSource(root, elements, styles) {
|
|
227
|
+
const styleByElement = new Map(styles.map(item => [item.elementId, item.rules]));
|
|
228
|
+
if (styleByElement.size !== styles.length)
|
|
229
|
+
throw new Error('Element style sources contain duplicate element ids');
|
|
230
|
+
const known = new Set(elements.map(item => item.element.id));
|
|
231
|
+
for (const id of styleByElement.keys())
|
|
232
|
+
if (!known.has(id))
|
|
233
|
+
throw new Error(`Element ${JSON.stringify(id)} is not registered by this Draft`);
|
|
234
|
+
const updates = new Map();
|
|
235
|
+
const defaultGroups = new Map();
|
|
236
|
+
for (const element of elements) {
|
|
237
|
+
for (const definition of flattenVariableTree(element.element.variables ?? [])) {
|
|
238
|
+
if (definition.defaultSource === undefined)
|
|
239
|
+
continue;
|
|
240
|
+
const list = defaultGroups.get(definition.defaultSource.file) ?? [];
|
|
241
|
+
list.push({ definition, value: element.values[definition.id] });
|
|
242
|
+
defaultGroups.set(definition.defaultSource.file, list);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const [file, variables] of defaultGroups) {
|
|
246
|
+
const original = await readProjectFile(root, file);
|
|
247
|
+
let content = original;
|
|
248
|
+
for (const replacement of [...replacementsForFile(original, variables)].reverse()) {
|
|
249
|
+
content = `${content.slice(0, replacement.start)}${replacement.value}${content.slice(replacement.end)}`;
|
|
250
|
+
}
|
|
251
|
+
updates.set(file, { original, content });
|
|
252
|
+
}
|
|
253
|
+
for (const element of elements) {
|
|
254
|
+
const rules = styleByElement.get(element.element.id);
|
|
255
|
+
if (rules === undefined)
|
|
256
|
+
continue;
|
|
257
|
+
const generatedFile = styleFile(element.element.source.file, element.owner, element.element.id);
|
|
258
|
+
const generatedOriginal = await readOptionalProjectFile(root, generatedFile);
|
|
259
|
+
updates.set(generatedFile, { ...(generatedOriginal === undefined ? {} : { original: generatedOriginal }), content: serializedStyles(element, rules) });
|
|
260
|
+
const sourceFile = element.element.source.file;
|
|
261
|
+
const existing = updates.get(sourceFile);
|
|
262
|
+
const original = existing?.original ?? await readProjectFile(root, sourceFile);
|
|
263
|
+
let content = existing?.content ?? original;
|
|
264
|
+
const declaration = styleImport(sourceFile, generatedFile);
|
|
265
|
+
content = insertStyleImport(content, declaration, sourceFile);
|
|
266
|
+
updates.set(sourceFile, { original, content });
|
|
267
|
+
}
|
|
268
|
+
if (updates.size === 0)
|
|
269
|
+
throw new Error('This Draft has no source-backed Element changes');
|
|
270
|
+
const written = [];
|
|
271
|
+
try {
|
|
272
|
+
for (const entry of updates) {
|
|
273
|
+
await writeProjectFile(root, entry[0], entry[1].content);
|
|
274
|
+
written.push(entry);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
const rollbackErrors = [];
|
|
279
|
+
for (const [file, update] of written.reverse()) {
|
|
280
|
+
try {
|
|
281
|
+
if (update.original === undefined)
|
|
282
|
+
await unlink(join(root, file));
|
|
283
|
+
else
|
|
284
|
+
await writeProjectFile(root, file, update.original);
|
|
285
|
+
}
|
|
286
|
+
catch (rollbackError) {
|
|
287
|
+
rollbackErrors.push(rollbackError);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (rollbackErrors.length > 0)
|
|
291
|
+
throw new AggregateError([error, ...rollbackErrors], 'Element source save rollback failed');
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
return { files: [...updates.keys()] };
|
|
295
|
+
}
|
package/lib/host/mcp.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
+
import * as z from 'zod/v4';
|
|
4
|
+
import { STUDIO_PATH } from '../contracts.js';
|
|
5
|
+
import { isTrustedStudioRequest } from './routes.js';
|
|
6
|
+
export const STUDIO_MCP_PATH = `${STUDIO_PATH}/mcp`;
|
|
7
|
+
const readOnlyAnnotations = {
|
|
8
|
+
readOnlyHint: true,
|
|
9
|
+
destructiveHint: false,
|
|
10
|
+
idempotentHint: true,
|
|
11
|
+
openWorldHint: false,
|
|
12
|
+
};
|
|
13
|
+
function result(value) {
|
|
14
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
15
|
+
}
|
|
16
|
+
function createServer(backend) {
|
|
17
|
+
const server = new McpServer({ name: 'dsh-webui-studio', version: '0.2.0' });
|
|
18
|
+
server.registerTool('studio_get_context', {
|
|
19
|
+
description: 'Get one bounded read-only context bundle for the current DSH WebUI instance: live selection, Harmony profile, Preview status, and related Harmony targets.',
|
|
20
|
+
annotations: readOnlyAnnotations,
|
|
21
|
+
}, async () => result(await backend.currentContext()));
|
|
22
|
+
server.registerTool('studio_get_selection', {
|
|
23
|
+
description: 'Get the DOM element currently selected in Studio, including its safe React owner summary and source candidate.',
|
|
24
|
+
annotations: readOnlyAnnotations,
|
|
25
|
+
}, async () => result({ selection: backend.currentPreviewStatus().selection ?? null }));
|
|
26
|
+
server.registerTool('studio_get_harmony_profile', {
|
|
27
|
+
description: 'Read the current WebUI Harmony profile, including installed plugins, provider and Patch order, disabled providers, compatibility findings, and revision.',
|
|
28
|
+
annotations: readOnlyAnnotations,
|
|
29
|
+
}, async () => result(await backend.currentHarmonyProfile()));
|
|
30
|
+
server.registerTool('studio_inspect_harmony_target', {
|
|
31
|
+
description: 'Inspect the current Harmony original, ordered Patch steps, and final source for an optional target package and file.',
|
|
32
|
+
inputSchema: {
|
|
33
|
+
package: z.string().min(1).optional().describe('Optional target npm package.'),
|
|
34
|
+
file: z.string().min(1).optional().describe('Optional package-relative target file.'),
|
|
35
|
+
},
|
|
36
|
+
annotations: readOnlyAnnotations,
|
|
37
|
+
}, async (input) => result(await backend.currentHarmonyInspect(input)));
|
|
38
|
+
server.registerTool('studio_read_dependency_source', {
|
|
39
|
+
description: 'Read one UTF-8 source file from a dependency installed in the current WebUI profile. Use package-relative paths obtained from Studio evidence.',
|
|
40
|
+
inputSchema: {
|
|
41
|
+
package: z.string().min(1).describe('Installed npm package name.'),
|
|
42
|
+
file: z.string().min(1).describe('Package-relative source path.'),
|
|
43
|
+
},
|
|
44
|
+
annotations: readOnlyAnnotations,
|
|
45
|
+
}, async (input) => result({
|
|
46
|
+
...input,
|
|
47
|
+
content: await backend.currentReadDependencySource(input),
|
|
48
|
+
}));
|
|
49
|
+
server.registerTool('studio_preview_status', {
|
|
50
|
+
description: 'Read the current DSH WebUI project state and latest Studio Preview connection, graph, mode, and selection status.',
|
|
51
|
+
annotations: readOnlyAnnotations,
|
|
52
|
+
}, async () => result({ project: backend.currentProjectState(), preview: backend.currentPreviewStatus() }));
|
|
53
|
+
return server;
|
|
54
|
+
}
|
|
55
|
+
function sendProtocolError(response, status, message) {
|
|
56
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
57
|
+
response.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message }, id: null }));
|
|
58
|
+
}
|
|
59
|
+
async function handleMcpRequest(backend, request, response) {
|
|
60
|
+
if (!isTrustedStudioRequest(request)) {
|
|
61
|
+
sendProtocolError(response, 403, 'Studio MCP is available from the local machine only.');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (new URL(request.url ?? '/', 'http://localhost').pathname !== STUDIO_MCP_PATH) {
|
|
65
|
+
sendProtocolError(response, 404, 'Not found.');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (request.method !== 'POST') {
|
|
69
|
+
sendProtocolError(response, 405, 'Method not allowed.');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const server = createServer(backend);
|
|
73
|
+
const transport = new StreamableHTTPServerTransport({
|
|
74
|
+
sessionIdGenerator: undefined,
|
|
75
|
+
enableJsonResponse: true,
|
|
76
|
+
});
|
|
77
|
+
const close = () => { void server.close(); };
|
|
78
|
+
response.once('close', close);
|
|
79
|
+
try {
|
|
80
|
+
await server.connect(transport);
|
|
81
|
+
await transport.handleRequest(request, response);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
response.off('close', close);
|
|
85
|
+
await server.close();
|
|
86
|
+
if (!response.headersSent) {
|
|
87
|
+
sendProtocolError(response, 500, error instanceof Error ? error.message : String(error));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export function createStudioMcpRoute(backend) {
|
|
92
|
+
return {
|
|
93
|
+
kind: 'exact',
|
|
94
|
+
path: STUDIO_MCP_PATH,
|
|
95
|
+
handler: (request, response) => handleMcpRequest(backend, request, response),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { StudioHarmonyService, StudioProjectState, StudioRuntimePluginEntry } from '../contracts.js';
|
|
3
|
+
import { type StudioCommandRunner } from './drafts.js';
|
|
4
|
+
export declare class StudioPreviewDraft {
|
|
5
|
+
private readonly ctx;
|
|
6
|
+
private readonly harmony;
|
|
7
|
+
private readonly commands;
|
|
8
|
+
private readonly name;
|
|
9
|
+
private readonly root;
|
|
10
|
+
private entryId?;
|
|
11
|
+
private createdEntry;
|
|
12
|
+
private project?;
|
|
13
|
+
constructor(ctx: Context, harmony: StudioHarmonyService, root: string, commands?: StudioCommandRunner);
|
|
14
|
+
open(): Promise<this>;
|
|
15
|
+
snapshot(): StudioProjectState;
|
|
16
|
+
runtimePlugins(): StudioRuntimePluginEntry[];
|
|
17
|
+
activate(graphRev: string): StudioProjectState;
|
|
18
|
+
applyBuild(): Promise<StudioProjectState>;
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
private waitForClientEntry;
|
|
21
|
+
private reload;
|
|
22
|
+
}
|