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.
Files changed (51) hide show
  1. package/PRODUCT.md +11 -7
  2. package/README.md +69 -21
  3. package/README.zh-CN.md +64 -20
  4. package/dist/bridge.js +10 -10
  5. package/dist/studio.css +1 -1
  6. package/dist/studio.js +16691 -10202
  7. package/docs/bidirectional-connection-handoff.md +729 -0
  8. package/docs/harmony-api-requirements.md +17 -13
  9. package/docs/remote-development.md +80 -0
  10. package/lib/bridge/element-style-selector.d.ts +1 -0
  11. package/lib/bridge/element-style-selector.js +53 -0
  12. package/lib/contracts.d.ts +152 -83
  13. package/lib/contracts.js +0 -2
  14. package/lib/host/agent.d.ts +15 -13
  15. package/lib/host/agent.js +213 -56
  16. package/lib/host/automatic-patch.d.ts +9 -0
  17. package/lib/host/automatic-patch.js +433 -0
  18. package/lib/host/backend.d.ts +134 -4
  19. package/lib/host/backend.js +465 -114
  20. package/lib/host/drafts.d.ts +1 -1
  21. package/lib/host/drafts.js +65 -15
  22. package/lib/host/element-source.d.ts +9 -0
  23. package/lib/host/element-source.js +295 -0
  24. package/lib/host/mcp.d.ts +4 -0
  25. package/lib/host/mcp.js +97 -0
  26. package/lib/host/preview-draft.d.ts +22 -0
  27. package/lib/host/preview-draft.js +162 -0
  28. package/lib/host/preview-port.d.ts +8 -0
  29. package/lib/host/preview-port.js +32 -0
  30. package/lib/host/preview-worker.d.ts +65 -2
  31. package/lib/host/preview-worker.js +203 -76
  32. package/lib/host/preview.d.ts +26 -5
  33. package/lib/host/preview.js +130 -49
  34. package/lib/host/readiness.d.ts +2 -2
  35. package/lib/host/readiness.js +14 -17
  36. package/lib/host/routes.d.ts +1 -7
  37. package/lib/host/routes.js +41 -50
  38. package/lib/host/runtime-profile.d.ts +2 -1
  39. package/lib/host/runtime-profile.js +30 -8
  40. package/lib/host/source-resolution.d.ts +11 -1
  41. package/lib/host/source-resolution.js +69 -24
  42. package/lib/host/studio-service.d.ts +129 -0
  43. package/lib/host/studio-service.js +53 -0
  44. package/lib/index.d.ts +5 -0
  45. package/lib/index.js +64 -30
  46. package/lib/studio-remote.d.ts +126 -0
  47. package/lib/studio-remote.js +188 -0
  48. package/lib/variable-tree.d.ts +2 -0
  49. package/lib/variable-tree.js +13 -0
  50. package/package.json +62 -25
  51. package/studio.patch.yml +12 -0
@@ -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 {
@@ -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: 'npm@10.0.0',
35
+ packageManager: 'pnpm@10.34.5',
36
36
  exports: { '.': './lib/index.js', './client': './lib/client.js', './package.json': './package.json' },
37
- scripts: { build: 'node --check lib/index.js && node --check lib/client.js' },
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(name) {
42
- return `window.__ModuleLoader__.load({
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
- apply() {},
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, 'lib'), { recursive: true });
246
+ await mkdir(join(root, 'src'), { recursive: true });
201
247
  await writeFile(join(root, 'package.json'), templateManifest(name));
202
- await writeFile(join(root, 'lib/index.js'), 'export const name = "draft-host"\nexport function apply() {}\n');
203
- await writeFile(join(root, 'lib/client.js'), templateClient(name));
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
- if (input.profileMode === 'custom')
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
+ }
@@ -0,0 +1,4 @@
1
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
2
+ import type { StudioBackend } from './backend.js';
3
+ export declare const STUDIO_MCP_PATH = "/studio/mcp";
4
+ export declare function createStudioMcpRoute(backend: StudioBackend): WebRoute;
@@ -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
+ }