@joycostudio/studio 0.1.2 → 0.1.4
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/README.md +199 -50
- package/dist/chunk-2RGQF5X5.js +470 -0
- package/dist/chunk-2RGQF5X5.js.map +1 -0
- package/dist/{chunk-X4YLM5HA.js → chunk-DEJYGBPJ.js} +22 -1
- package/dist/chunk-DEJYGBPJ.js.map +1 -0
- package/dist/chunk-HVGJXFXT.js +314 -0
- package/dist/chunk-HVGJXFXT.js.map +1 -0
- package/dist/cli.js +878 -56
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +7 -4
- package/dist/client.js +5 -458
- package/dist/client.js.map +1 -1
- package/dist/command-menu.css +4 -1
- package/dist/controls.d.ts +1 -0
- package/dist/controls.js +3 -0
- package/dist/controls.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6 -12
- package/dist/index.js.map +1 -1
- package/dist/next-config.d.ts +4 -0
- package/dist/next-config.js +135 -0
- package/dist/next-config.js.map +1 -0
- package/dist/next.d.ts +8 -0
- package/dist/next.js +15 -0
- package/dist/next.js.map +1 -0
- package/dist/{protocol-BrHDtNkL.d.ts → protocol-DjrMADPm.d.ts} +66 -15
- package/dist/react.d.ts +40 -17
- package/dist/react.js +418 -274
- package/dist/react.js.map +1 -1
- package/dist/runtime.d.ts +33 -5
- package/dist/runtime.js +106 -87
- package/dist/runtime.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +27 -7
- package/dist/chunk-X4YLM5HA.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { spawn } from "child_process";
|
|
5
|
-
import { createRequire } from "module";
|
|
6
|
-
import
|
|
7
|
-
import { parseArgs } from "util";
|
|
5
|
+
import { createRequire as createRequire2 } from "module";
|
|
6
|
+
import path8 from "path";
|
|
7
|
+
import { parseArgs as parseArgs2 } from "util";
|
|
8
8
|
|
|
9
9
|
// src/core/tool.ts
|
|
10
|
+
var hasConfig = (tool2) => tool2.controls !== void 0;
|
|
10
11
|
var DEFAULT_PORT = 5179;
|
|
11
12
|
|
|
12
13
|
// src/runtime/runtime.ts
|
|
@@ -34,35 +35,35 @@ function kind(value) {
|
|
|
34
35
|
if (Array.isArray(value)) return "array";
|
|
35
36
|
return typeof value;
|
|
36
37
|
}
|
|
37
|
-
function shapeMismatch(reference, value,
|
|
38
|
+
function shapeMismatch(reference, value, path9) {
|
|
38
39
|
const expected = kind(reference);
|
|
39
|
-
if (kind(value) !== expected) return `${
|
|
40
|
-
if (expected === "number" && !Number.isFinite(value)) return `${
|
|
40
|
+
if (kind(value) !== expected) return `${path9}: expected ${expected}, got ${kind(value)}`;
|
|
41
|
+
if (expected === "number" && !Number.isFinite(value)) return `${path9}: expected a finite number`;
|
|
41
42
|
if (expected === "array") {
|
|
42
43
|
const [first] = reference;
|
|
43
44
|
if (first === void 0) return null;
|
|
44
45
|
for (const [index, item] of value.entries()) {
|
|
45
|
-
const nested = shapeMismatch(first, item, `${
|
|
46
|
+
const nested = shapeMismatch(first, item, `${path9}[${index}]`);
|
|
46
47
|
if (nested) return nested;
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
if (expected === "object") {
|
|
50
51
|
const ref = reference;
|
|
51
52
|
const obj = value;
|
|
52
|
-
for (const key of Object.keys(obj)) if (!(key in ref)) return `${
|
|
53
|
+
for (const key of Object.keys(obj)) if (!(key in ref)) return `${path9}.${key}: unknown key`;
|
|
53
54
|
for (const key of Object.keys(ref)) {
|
|
54
|
-
if (!(key in obj)) return `${
|
|
55
|
-
const nested = shapeMismatch(ref[key], obj[key], `${
|
|
55
|
+
if (!(key in obj)) return `${path9}.${key}: missing`;
|
|
56
|
+
const nested = shapeMismatch(ref[key], obj[key], `${path9}.${key}`);
|
|
56
57
|
if (nested) return nested;
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
return null;
|
|
60
61
|
}
|
|
61
62
|
var isRgb = (value) => typeof value === "object" && value !== null && ["r", "g", "b"].every((channel) => Number.isFinite(value[channel]));
|
|
62
|
-
function invalidControl(schema,
|
|
63
|
+
function invalidControl(schema, path9 = "") {
|
|
63
64
|
for (const [key, field] of Object.entries(schema)) {
|
|
64
65
|
if (field.kind === "button") continue;
|
|
65
|
-
const name = `${
|
|
66
|
+
const name = `${path9}${field.label ?? key}`;
|
|
66
67
|
if (field.kind === "folder") {
|
|
67
68
|
const nested = invalidControl(field.definition.schema(), `${name}/`);
|
|
68
69
|
if (nested) return nested;
|
|
@@ -101,14 +102,14 @@ var exists = (target) => stat(target).then(
|
|
|
101
102
|
() => false
|
|
102
103
|
);
|
|
103
104
|
async function resolveToolFile(root, file) {
|
|
104
|
-
const
|
|
105
|
-
if (path.isAbsolute(file)) throw
|
|
106
|
-
if (path.extname(file) !== ".json") throw
|
|
105
|
+
const fail2 = (reason) => new Error(`[studio] tool file "${file}" ${reason}`);
|
|
106
|
+
if (path.isAbsolute(file)) throw fail2("must be relative to the project root");
|
|
107
|
+
if (path.extname(file) !== ".json") throw fail2("must be a .json file");
|
|
107
108
|
const absolute = path.resolve(root, file);
|
|
108
|
-
if (!inside(root, absolute)) throw
|
|
109
|
+
if (!inside(root, absolute)) throw fail2("escapes the project root");
|
|
109
110
|
let probe = absolute;
|
|
110
111
|
while (!await exists(probe)) probe = path.dirname(probe);
|
|
111
|
-
if (probe !== root && !inside(await realpath(root), await realpath(probe))) throw
|
|
112
|
+
if (probe !== root && !inside(await realpath(root), await realpath(probe))) throw fail2("escapes the project root");
|
|
112
113
|
return absolute;
|
|
113
114
|
}
|
|
114
115
|
var hashOf = (content) => createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
@@ -150,12 +151,14 @@ async function load(tool2, file) {
|
|
|
150
151
|
throw new Error(`[studio] ${path2.basename(file)} is not a valid "${tool2.id}" config: ${parsed.message}`);
|
|
151
152
|
return { value: parsed.data, hash: disk.hash };
|
|
152
153
|
}
|
|
153
|
-
async function
|
|
154
|
-
root = path2.resolve(root);
|
|
154
|
+
async function prepare(root, tools) {
|
|
155
155
|
const entries = /* @__PURE__ */ new Map();
|
|
156
156
|
const files = /* @__PURE__ */ new Set();
|
|
157
|
+
const ids = /* @__PURE__ */ new Set();
|
|
157
158
|
for (const tool2 of tools) {
|
|
158
|
-
if (
|
|
159
|
+
if (ids.has(tool2.id)) throw new Error(`[studio] duplicate tool id "${tool2.id}"`);
|
|
160
|
+
ids.add(tool2.id);
|
|
161
|
+
if (!hasConfig(tool2)) continue;
|
|
159
162
|
const file = await resolveToolFile(root, tool2.file);
|
|
160
163
|
if (files.has(file)) throw new Error(`[studio] tool "${tool2.id}" shares its file with another tool`);
|
|
161
164
|
files.add(file);
|
|
@@ -173,6 +176,12 @@ async function createRuntime({ root, tools }) {
|
|
|
173
176
|
}
|
|
174
177
|
});
|
|
175
178
|
}
|
|
179
|
+
return { entries, ids };
|
|
180
|
+
}
|
|
181
|
+
async function createRuntime({ root, tools }) {
|
|
182
|
+
root = path2.resolve(root);
|
|
183
|
+
let { entries, ids } = await prepare(root, tools);
|
|
184
|
+
let changing = Promise.resolve();
|
|
176
185
|
const listeners = /* @__PURE__ */ new Set();
|
|
177
186
|
const commit = (entry, next, origin) => {
|
|
178
187
|
const state = { ...entry.state, ...next };
|
|
@@ -190,7 +199,7 @@ async function createRuntime({ root, tools }) {
|
|
|
190
199
|
};
|
|
191
200
|
const handlers = {
|
|
192
201
|
"tools.list": async () => success(
|
|
193
|
-
|
|
202
|
+
tools.map((tool2) => ({
|
|
194
203
|
id: tool2.id,
|
|
195
204
|
title: tool2.title ?? tool2.id,
|
|
196
205
|
file: tool2.file,
|
|
@@ -236,7 +245,8 @@ async function createRuntime({ root, tools }) {
|
|
|
236
245
|
};
|
|
237
246
|
async function withEntry(id, task) {
|
|
238
247
|
const entry = typeof id === "string" ? entries.get(id) : void 0;
|
|
239
|
-
|
|
248
|
+
if (entry) return task(entry);
|
|
249
|
+
return ids.has(id) ? error("no_config", `tool "${String(id)}" is preview-only and has no config`) : error("unknown_tool", `no tool "${String(id)}"`);
|
|
240
250
|
}
|
|
241
251
|
const reload = (entry) => serial(entry, async () => {
|
|
242
252
|
const disk = await readDisk(entry.file);
|
|
@@ -258,27 +268,76 @@ async function createRuntime({ root, tools }) {
|
|
|
258
268
|
});
|
|
259
269
|
const timers = /* @__PURE__ */ new Map();
|
|
260
270
|
const watchers = [];
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
+
const bindWatchers = () => {
|
|
272
|
+
for (const watcher of watchers.splice(0)) watcher.close();
|
|
273
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
274
|
+
timers.clear();
|
|
275
|
+
const byDirectory = Map.groupBy(entries.values(), (entry) => path2.dirname(entry.file));
|
|
276
|
+
for (const [directory, group] of byDirectory) {
|
|
277
|
+
try {
|
|
278
|
+
const watcher = watch(directory, (_, filename) => {
|
|
279
|
+
for (const entry of group) {
|
|
280
|
+
if (filename && filename !== path2.basename(entry.file)) continue;
|
|
281
|
+
clearTimeout(timers.get(entry));
|
|
282
|
+
timers.set(
|
|
283
|
+
entry,
|
|
284
|
+
setTimeout(() => {
|
|
285
|
+
void changing.then(async () => {
|
|
286
|
+
if (entries.get(entry.tool.id) === entry) await reload(entry);
|
|
287
|
+
}).catch((cause) => console.error("[studio] reload failed", cause));
|
|
288
|
+
}, 50)
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
watchers.push(watcher);
|
|
293
|
+
} catch {
|
|
294
|
+
console.warn(`[studio] not watching ${path2.relative(root, directory)} (it does not exist yet)`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
bindWatchers();
|
|
299
|
+
const replaceTools = (nextTools) => {
|
|
300
|
+
const update = changing.then(async () => {
|
|
301
|
+
await Promise.all([...entries.values()].map((entry) => entry.queue));
|
|
302
|
+
const prepared = await prepare(root, nextTools);
|
|
303
|
+
for (const [id, old] of entries) {
|
|
304
|
+
const next = prepared.entries.get(id);
|
|
305
|
+
if ((!next || next.file !== old.file) && old.state.dirty)
|
|
306
|
+
throw new Error(`Save or discard "${id}" before removing it or moving its config.`);
|
|
307
|
+
if (!next || next.file !== old.file) continue;
|
|
308
|
+
const parsed = validateConfig(next.tool, old.state.draft.value);
|
|
309
|
+
if (!parsed.ok && old.state.dirty)
|
|
310
|
+
throw new Error(
|
|
311
|
+
`Draft for "${id}" is incompatible with the new controls; save/discard it first: ${parsed.message}`
|
|
271
312
|
);
|
|
313
|
+
if (parsed.ok && old.state.dirty) {
|
|
314
|
+
next.state = {
|
|
315
|
+
...old.state,
|
|
316
|
+
saved: next.state.saved,
|
|
317
|
+
conflict: old.state.conflict || next.state.saved.hash !== old.state.saved.hash,
|
|
318
|
+
draft: { value: parsed.data, revision: old.state.draft.revision + 1 }
|
|
319
|
+
};
|
|
320
|
+
} else {
|
|
321
|
+
next.state.draft.revision = old.state.draft.revision + 1;
|
|
272
322
|
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
323
|
+
}
|
|
324
|
+
entries = prepared.entries;
|
|
325
|
+
ids = prepared.ids;
|
|
326
|
+
tools = nextTools;
|
|
327
|
+
bindWatchers();
|
|
328
|
+
for (const entry of entries.values()) commit(entry, {});
|
|
329
|
+
});
|
|
330
|
+
changing = update.catch(() => {
|
|
331
|
+
});
|
|
332
|
+
return update;
|
|
333
|
+
};
|
|
279
334
|
return {
|
|
280
335
|
root,
|
|
281
|
-
run: (op, params, origin) =>
|
|
336
|
+
run: async (op, params, origin) => {
|
|
337
|
+
await changing;
|
|
338
|
+
return handlers[op](params, origin);
|
|
339
|
+
},
|
|
340
|
+
replaceTools,
|
|
282
341
|
subscribe(listener) {
|
|
283
342
|
listeners.add(listener);
|
|
284
343
|
return () => listeners.delete(listener);
|
|
@@ -446,7 +505,7 @@ function createMcpServer(runtime, previews) {
|
|
|
446
505
|
server.registerTool(
|
|
447
506
|
"studio_list_tools",
|
|
448
507
|
{
|
|
449
|
-
description: "List registered Studio tools with their IDs, titles, config files, and preview routes.",
|
|
508
|
+
description: "List registered Studio tools with their IDs, titles, config files, and preview routes. Tools without a file are preview-only: they have no config to read, edit, or capture.",
|
|
450
509
|
inputSchema: z.object({}),
|
|
451
510
|
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }
|
|
452
511
|
},
|
|
@@ -601,19 +660,273 @@ function serveBridge(runtime, { port, host = "127.0.0.1", origins, appUrl }) {
|
|
|
601
660
|
});
|
|
602
661
|
}
|
|
603
662
|
|
|
604
|
-
// src/
|
|
663
|
+
// src/discovery/generate.ts
|
|
664
|
+
import { existsSync as existsSync2, readFileSync, readdirSync, realpathSync } from "fs";
|
|
665
|
+
import { mkdir as mkdir2, readFile as readFile2 } from "fs/promises";
|
|
666
|
+
import path4 from "path";
|
|
667
|
+
import ts from "typescript";
|
|
668
|
+
|
|
669
|
+
// src/discovery/settings.ts
|
|
605
670
|
import { existsSync } from "fs";
|
|
606
671
|
import path3 from "path";
|
|
607
672
|
import { createJiti } from "jiti";
|
|
608
673
|
var CANDIDATES = ["studio.config.ts", "studio.config.mts", "studio.config.js", "studio.config.mjs"];
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
674
|
+
function importer(root) {
|
|
675
|
+
return createJiti(import.meta.url, {
|
|
676
|
+
moduleCache: false,
|
|
677
|
+
fsCache: false,
|
|
678
|
+
tryNative: false,
|
|
679
|
+
tsconfigPaths: existsSync(path3.join(root, "tsconfig.json")) ? path3.join(root, "tsconfig.json") : false
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
async function readSettings(cwd, explicit) {
|
|
683
|
+
const file = explicit ? path3.resolve(cwd, explicit) : CANDIDATES.map((name) => path3.join(cwd, name)).find(existsSync);
|
|
684
|
+
if (explicit && !existsSync(file)) throw new Error(`[studio] no ${explicit} found in ${cwd}`);
|
|
685
|
+
const root = file ? path3.dirname(file) : path3.resolve(cwd);
|
|
686
|
+
const config = file ? await importer(root).import(file, { default: true }) : {};
|
|
687
|
+
if (!config || typeof config !== "object" || config.tools !== void 0 && !Array.isArray(config.tools))
|
|
688
|
+
throw new Error("[studio] config must default-export defineConfig({ ... })");
|
|
689
|
+
if (config.tools !== void 0 && config.toolsDir !== void 0)
|
|
690
|
+
throw new Error("[studio] Use either tools (manual registration) or toolsDir (discovery), not both.");
|
|
691
|
+
return { config, root, file };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// src/discovery/generate.ts
|
|
695
|
+
var slash = (value) => value.split(path4.sep).join("/");
|
|
696
|
+
var relativeImport = (from, to) => {
|
|
697
|
+
const relative = slash(path4.relative(path4.dirname(from), to)).replace(/\.(tsx?|jsx?|mts|mjs)$/, "");
|
|
698
|
+
return /^\.\.?\//.test(relative) ? relative : `./${relative}`;
|
|
699
|
+
};
|
|
700
|
+
var inside2 = (root, file) => {
|
|
701
|
+
const relative = path4.relative(root, file);
|
|
702
|
+
return relative === "" || relative !== ".." && !relative.startsWith(`..${path4.sep}`) && !path4.isAbsolute(relative);
|
|
703
|
+
};
|
|
704
|
+
var fail = (file, message) => {
|
|
705
|
+
throw new Error(`[studio] ${file}: ${message}`);
|
|
706
|
+
};
|
|
707
|
+
var HEADER = "// Generated by Studio. Do not edit.\n";
|
|
708
|
+
var DiscoveryInputError = class extends Error {
|
|
709
|
+
constructor(cause, inputs) {
|
|
710
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
711
|
+
this.inputs = inputs;
|
|
712
|
+
}
|
|
713
|
+
inputs;
|
|
714
|
+
};
|
|
715
|
+
function shape(value) {
|
|
716
|
+
if (value === null) return "null";
|
|
717
|
+
if (Array.isArray(value)) return `[${[...new Set(value.map(shape))].sort().join("|")}]`;
|
|
718
|
+
if (typeof value === "object")
|
|
719
|
+
return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${shape(item)}`).join(",")}}`;
|
|
720
|
+
return typeof value;
|
|
721
|
+
}
|
|
722
|
+
function declaration(file) {
|
|
723
|
+
const source = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
|
|
724
|
+
const exported = source.statements.find(ts.isExportAssignment);
|
|
725
|
+
const call = exported?.expression;
|
|
726
|
+
if (!call || !ts.isCallExpression(call) || !ts.isIdentifier(call.expression) || call.expression.text !== "defineTool")
|
|
727
|
+
return fail(file, "default-export defineTool({ id, controls }) directly so discovery can inspect it.");
|
|
728
|
+
const object = call.arguments[0];
|
|
729
|
+
if (!object || !ts.isObjectLiteralExpression(object)) return fail(file, "defineTool requires an object literal.");
|
|
730
|
+
const properties = /* @__PURE__ */ new Map();
|
|
731
|
+
for (const member of object.properties) {
|
|
732
|
+
if (ts.isSpreadAssignment(member) || !member.name || ts.isComputedPropertyName(member.name))
|
|
733
|
+
return fail(file, "spread/computed tool options are not supported; put dynamic logic inside controls.");
|
|
734
|
+
const key = member.name.getText(source).replace(/^['"]|['"]$/g, "");
|
|
735
|
+
if (properties.has(key)) return fail(file, `duplicate option ${key}`);
|
|
736
|
+
properties.set(key, member);
|
|
737
|
+
}
|
|
738
|
+
const literal = (key) => {
|
|
739
|
+
const member = properties.get(key);
|
|
740
|
+
if (!member) return void 0;
|
|
741
|
+
if (!ts.isPropertyAssignment(member) || !ts.isStringLiteral(member.initializer))
|
|
742
|
+
return fail(file, `${key} must be a static string literal.`);
|
|
743
|
+
return member.initializer.text;
|
|
744
|
+
};
|
|
745
|
+
const id = literal("id");
|
|
746
|
+
if (!id || !/^[a-z0-9][a-z0-9-]*$/.test(id)) return fail(file, "id must be an explicit kebab-case string.");
|
|
747
|
+
if (properties.has("file") || properties.has("initial"))
|
|
748
|
+
return fail(file, "discovered tools use config instead of file/initial; omit config for adjacent config.json.");
|
|
749
|
+
const configurable = properties.has("controls");
|
|
750
|
+
const config = literal("config");
|
|
751
|
+
if (config !== void 0 && !configurable) return fail(file, "config requires controls.");
|
|
752
|
+
return { id, config, configurable, preview: literal("preview"), source };
|
|
753
|
+
}
|
|
754
|
+
function pageRoute(app, file, explicit) {
|
|
755
|
+
if (explicit) return explicit;
|
|
756
|
+
const directory = path4.dirname(file);
|
|
757
|
+
if (!app || !inside2(app, directory)) return fail(file, "tools outside the app tree require an explicit preview URL.");
|
|
758
|
+
if (!["tsx", "ts", "jsx", "js"].some((ext) => existsSync2(path4.join(directory, `page.${ext}`))))
|
|
759
|
+
return fail(file, "add an adjacent page.tsx or specify preview explicitly.");
|
|
760
|
+
const segments = slash(path4.relative(app, directory)).split("/").filter(Boolean);
|
|
761
|
+
if (segments.some((part) => part.includes("[") || part.startsWith("@") || /^\(\./.test(part) || part.startsWith("_")))
|
|
762
|
+
return fail(file, "dynamic, parallel, intercepted, and private routes require an explicit preview URL.");
|
|
763
|
+
return "/" + segments.filter((part) => !/^\(.+\)$/.test(part)).join("/");
|
|
764
|
+
}
|
|
765
|
+
function walk(directory) {
|
|
766
|
+
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
767
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.isSymbolicLink()) return [];
|
|
768
|
+
const file = path4.join(directory, entry.name);
|
|
769
|
+
return entry.isDirectory() ? walk(file) : entry.name === "studio.tool.ts" ? [file] : [];
|
|
770
|
+
}).sort();
|
|
771
|
+
}
|
|
772
|
+
function compilerConfig(root, dependencies) {
|
|
773
|
+
const configFile = path4.join(root, "tsconfig.json");
|
|
774
|
+
if (!existsSync2(configFile)) return fail(root, "discovery requires tsconfig.json.");
|
|
775
|
+
const host = {
|
|
776
|
+
...ts.sys,
|
|
777
|
+
readDirectory: () => [],
|
|
778
|
+
readFile(file) {
|
|
779
|
+
dependencies.add(path4.resolve(file));
|
|
780
|
+
return ts.sys.readFile(file);
|
|
781
|
+
},
|
|
782
|
+
onUnRecoverableConfigFileDiagnostic(error2) {
|
|
783
|
+
throw new Error(ts.flattenDiagnosticMessageText(error2.messageText, "\n"));
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
const parsed = ts.getParsedCommandLineOfConfigFile(configFile, { resolveJsonModule: true }, host);
|
|
787
|
+
if (!parsed) return fail(configFile, "could not read TypeScript settings.");
|
|
788
|
+
return parsed.options;
|
|
789
|
+
}
|
|
790
|
+
async function writeChanged(file, content) {
|
|
791
|
+
if (await readFile2(file, "utf8").catch(() => "") === content) return;
|
|
792
|
+
await writeAtomic(file, content);
|
|
793
|
+
}
|
|
794
|
+
async function generate(cwd, explicit, publish = true, previous) {
|
|
795
|
+
const { root, config, file: settings } = await readSettings(cwd, explicit);
|
|
796
|
+
if (config.tools !== void 0) return void 0;
|
|
797
|
+
const app = ["app", "src/app"].map((dir) => path4.join(root, dir)).find(existsSync2);
|
|
798
|
+
const toolsDir = config.toolsDir === void 0 ? app : path4.resolve(root, config.toolsDir);
|
|
799
|
+
if (!toolsDir || !existsSync2(toolsDir))
|
|
800
|
+
return fail(root, "no tools directory found; create app/ or configure toolsDir.");
|
|
801
|
+
if (!inside2(realpathSync(root), realpathSync(toolsDir)))
|
|
802
|
+
return fail(toolsDir, "toolsDir must stay inside the project.");
|
|
803
|
+
const sources = new Set(walk(toolsDir));
|
|
804
|
+
const dependencies = /* @__PURE__ */ new Set();
|
|
805
|
+
if (settings) dependencies.add(settings);
|
|
806
|
+
const options = compilerConfig(root, dependencies);
|
|
807
|
+
const output = path4.join(root, ".studio");
|
|
808
|
+
const registry = path4.join(output, "registry.generated.ts");
|
|
809
|
+
const types = path4.join(output, "types.generated.ts");
|
|
810
|
+
const imports = [
|
|
811
|
+
HEADER,
|
|
812
|
+
'/// <reference path="./types.generated.ts" />',
|
|
813
|
+
"import type { StudioTool, DiscoveredToolConfigs } from '@joycostudio/studio'"
|
|
814
|
+
];
|
|
815
|
+
const typeImports = [HEADER, "import '@joycostudio/studio'"];
|
|
816
|
+
const declarations = [];
|
|
817
|
+
const definitions = [];
|
|
818
|
+
const mappings = [];
|
|
819
|
+
const ids = /* @__PURE__ */ new Set();
|
|
820
|
+
const destinations = /* @__PURE__ */ new Set();
|
|
821
|
+
const seeds = /* @__PURE__ */ new Map();
|
|
822
|
+
const trackImports = (file) => {
|
|
823
|
+
if (dependencies.has(file)) return;
|
|
824
|
+
dependencies.add(file);
|
|
825
|
+
if (!/\.[cm]?[jt]sx?$/.test(file)) return;
|
|
826
|
+
for (const imported of ts.preProcessFile(readFileSync(file, "utf8")).importedFiles) {
|
|
827
|
+
const resolved = ts.resolveModuleName(imported.fileName, file, options, ts.sys).resolvedModule?.resolvedFileName;
|
|
828
|
+
if (resolved && inside2(root, resolved) && !resolved.includes(`${path4.sep}node_modules${path4.sep}`) && !inside2(output, resolved))
|
|
829
|
+
trackImports(resolved);
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
for (const [index, file] of [...sources].entries()) {
|
|
833
|
+
const tool2 = declaration(file);
|
|
834
|
+
if (ids.has(tool2.id)) return fail(file, `duplicate tool id "${tool2.id}".`);
|
|
835
|
+
ids.add(tool2.id);
|
|
836
|
+
trackImports(file);
|
|
837
|
+
const preview = pageRoute(app, file, tool2.preview);
|
|
838
|
+
const title = (preview.split("/").filter(Boolean).at(-1) || tool2.id).replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
839
|
+
imports.push(`import tool${index} from ${JSON.stringify(relativeImport(registry, file))}`);
|
|
840
|
+
let values = "";
|
|
841
|
+
const resolvedTool = { source: file, title, preview };
|
|
842
|
+
if (tool2.configurable) {
|
|
843
|
+
const inputs = /* @__PURE__ */ new Set();
|
|
844
|
+
try {
|
|
845
|
+
const specifier = tool2.config ?? "./config.json";
|
|
846
|
+
const resolutionHost = {
|
|
847
|
+
...ts.sys,
|
|
848
|
+
fileExists(candidate) {
|
|
849
|
+
if (inside2(root, candidate) && !candidate.includes(`${path4.sep}node_modules${path4.sep}`))
|
|
850
|
+
inputs.add(candidate);
|
|
851
|
+
return ts.sys.fileExists(candidate);
|
|
852
|
+
},
|
|
853
|
+
directoryExists(directory) {
|
|
854
|
+
const exists2 = ts.sys.directoryExists(directory);
|
|
855
|
+
if (!exists2 && inside2(root, directory) && !directory.includes(`${path4.sep}node_modules${path4.sep}`))
|
|
856
|
+
inputs.add(directory);
|
|
857
|
+
return exists2;
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
const resolved = ts.resolveModuleName(specifier, file, options, resolutionHost).resolvedModule?.resolvedFileName;
|
|
861
|
+
if (!resolved || !resolved.endsWith(".json"))
|
|
862
|
+
return fail(file, `cannot resolve JSON config "${specifier}" through tsconfig.json.`);
|
|
863
|
+
const target = await resolveToolFile(root, slash(path4.relative(root, resolved)));
|
|
864
|
+
inputs.add(target);
|
|
865
|
+
const real = realpathSync(target);
|
|
866
|
+
if (destinations.has(real)) return fail(file, "another tool already writes this config file.");
|
|
867
|
+
destinations.add(real);
|
|
868
|
+
const value = JSON.parse(readFileSync(target, "utf8"));
|
|
869
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
870
|
+
return fail(file, "config must be a JSON object.");
|
|
871
|
+
resolvedTool.file = slash(path4.relative(root, target));
|
|
872
|
+
resolvedTool.initial = value;
|
|
873
|
+
dependencies.add(target);
|
|
874
|
+
const key = `${tool2.id}:${real}`;
|
|
875
|
+
const prior = previous?.seeds.get(key);
|
|
876
|
+
const seed = prior && shape(prior) === shape(value) ? prior : value;
|
|
877
|
+
seeds.set(key, seed);
|
|
878
|
+
typeImports.push(`import type config${index} from ${JSON.stringify(relativeImport(types, target))}`);
|
|
879
|
+
mappings.push(` ${JSON.stringify(tool2.id)}: typeof config${index}`);
|
|
880
|
+
values = `, file: ${JSON.stringify(slash(path4.relative(root, target)))}, initial: JSON.parse(${JSON.stringify(JSON.stringify(seed))}) as DiscoveredToolConfigs[${JSON.stringify(tool2.id)}]`;
|
|
881
|
+
} catch (cause) {
|
|
882
|
+
throw new DiscoveryInputError(cause, /* @__PURE__ */ new Set([...dependencies, ...inputs]));
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
declarations.push(resolvedTool);
|
|
886
|
+
definitions.push(
|
|
887
|
+
` { title: ${JSON.stringify(title)}, ...tool${index}, preview: ${JSON.stringify(preview)}${values} }`
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
const write = async () => {
|
|
891
|
+
await mkdir2(output, { recursive: true });
|
|
892
|
+
await writeChanged(
|
|
893
|
+
types,
|
|
894
|
+
`${typeImports.join("\n")}
|
|
895
|
+
|
|
896
|
+
declare module '@joycostudio/studio' {
|
|
897
|
+
interface DiscoveredToolConfigs {
|
|
898
|
+
${mappings.join("\n")}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
`
|
|
902
|
+
);
|
|
903
|
+
await writeChanged(
|
|
904
|
+
registry,
|
|
905
|
+
`${imports.join("\n")}
|
|
906
|
+
|
|
907
|
+
export const tools = [
|
|
908
|
+
${definitions.join(",\n")}
|
|
909
|
+
] satisfies readonly StudioTool[]
|
|
910
|
+
`
|
|
911
|
+
);
|
|
912
|
+
};
|
|
913
|
+
if (publish) await write();
|
|
914
|
+
return { root, toolsDir, sources, dependencies, registry, seeds, declarations, write };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// src/runtime/load.ts
|
|
918
|
+
async function loadConfig(cwd, explicit, publish = true, previous) {
|
|
919
|
+
const { config, root } = await readSettings(cwd, explicit);
|
|
920
|
+
const discovery = await generate(cwd, explicit, false, previous);
|
|
921
|
+
const jiti = importer(root);
|
|
922
|
+
const tools = config.tools ?? await Promise.all(
|
|
923
|
+
discovery.declarations.map(async ({ source, title, preview, file, initial }) => {
|
|
924
|
+
const tool2 = await jiti.import(source, { default: true });
|
|
925
|
+
return { title, ...tool2, preview, ...file ? { file, initial } : {} };
|
|
926
|
+
})
|
|
927
|
+
);
|
|
928
|
+
if (publish) await discovery?.write();
|
|
929
|
+
return { config: { ...config, tools }, root, discovery };
|
|
617
930
|
}
|
|
618
931
|
|
|
619
932
|
// src/runtime/ports.ts
|
|
@@ -647,9 +960,483 @@ async function freeAppPort(hostname, port) {
|
|
|
647
960
|
throw new Error(`[studio] ports ${port}-${port + ATTEMPTS - 1} on ${hostname} are all in use`);
|
|
648
961
|
}
|
|
649
962
|
|
|
963
|
+
// src/init.ts
|
|
964
|
+
import { spawnSync } from "child_process";
|
|
965
|
+
import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, rmdirSync, rmSync, writeFileSync } from "fs";
|
|
966
|
+
import { createRequire } from "module";
|
|
967
|
+
import path6 from "path";
|
|
968
|
+
import { parseArgs } from "util";
|
|
969
|
+
|
|
970
|
+
// package.json
|
|
971
|
+
var package_default = {
|
|
972
|
+
name: "@joycostudio/studio",
|
|
973
|
+
version: "0.1.4",
|
|
974
|
+
description: "Plug project-specific tools into a /studio route, a floating debug pane, and MCP agents.",
|
|
975
|
+
type: "module",
|
|
976
|
+
license: "ISC",
|
|
977
|
+
author: "joyco.studio",
|
|
978
|
+
repository: {
|
|
979
|
+
type: "git",
|
|
980
|
+
url: "git+https://github.com/joyco-studio/studio.git",
|
|
981
|
+
directory: "packages/studio"
|
|
982
|
+
},
|
|
983
|
+
publishConfig: {
|
|
984
|
+
registry: "https://registry.npmjs.org",
|
|
985
|
+
access: "public"
|
|
986
|
+
},
|
|
987
|
+
files: [
|
|
988
|
+
"dist"
|
|
989
|
+
],
|
|
990
|
+
bin: {
|
|
991
|
+
studio: "./dist/cli.js"
|
|
992
|
+
},
|
|
993
|
+
exports: {
|
|
994
|
+
".": {
|
|
995
|
+
types: "./dist/index.d.ts",
|
|
996
|
+
default: "./dist/index.js"
|
|
997
|
+
},
|
|
998
|
+
"./client": {
|
|
999
|
+
types: "./dist/client.d.ts",
|
|
1000
|
+
default: "./dist/client.js"
|
|
1001
|
+
},
|
|
1002
|
+
"./react": {
|
|
1003
|
+
types: "./dist/react.d.ts",
|
|
1004
|
+
default: "./dist/react.js"
|
|
1005
|
+
},
|
|
1006
|
+
"./runtime": {
|
|
1007
|
+
types: "./dist/runtime.d.ts",
|
|
1008
|
+
default: "./dist/runtime.js"
|
|
1009
|
+
},
|
|
1010
|
+
"./styles.css": "./dist/styles.css",
|
|
1011
|
+
"./command-menu.css": "./dist/command-menu.css",
|
|
1012
|
+
"./next": {
|
|
1013
|
+
types: "./dist/next.d.ts",
|
|
1014
|
+
default: "./dist/next.js"
|
|
1015
|
+
},
|
|
1016
|
+
"./controls": {
|
|
1017
|
+
types: "./dist/controls.d.ts",
|
|
1018
|
+
default: "./dist/controls.js"
|
|
1019
|
+
},
|
|
1020
|
+
"./next/config": {
|
|
1021
|
+
types: "./dist/next-config.d.ts",
|
|
1022
|
+
default: "./dist/next-config.js"
|
|
1023
|
+
}
|
|
1024
|
+
},
|
|
1025
|
+
scripts: {
|
|
1026
|
+
build: "pnpm build:shadow-css && tsup && tailwindcss -i src/react/styles.css -o dist/styles.css --minify && cp src/react/command-menu-export.css dist/command-menu.css",
|
|
1027
|
+
dev: 'pnpm build:shadow-css && pnpm run --parallel "/^dev:/"',
|
|
1028
|
+
test: "vitest run",
|
|
1029
|
+
"test:browser": "STUDIO_TEST_BROWSER=1 vitest run test/browser-sync.test.ts",
|
|
1030
|
+
"test:capture": "STUDIO_TEST_CAPTURE=1 vitest run test/capture.test.ts",
|
|
1031
|
+
typecheck: "tsc --noEmit",
|
|
1032
|
+
"dev:js": "tsup --watch",
|
|
1033
|
+
"dev:css": "tailwindcss -i src/react/styles.css -o dist/styles.css --watch=always",
|
|
1034
|
+
"build:shadow-css": "tailwindcss -i src/react/shadow.css -o src/react/generated/studio.css --minify",
|
|
1035
|
+
"dev:shadow-css": "tailwindcss -i src/react/shadow.css -o src/react/generated/studio.css --minify --watch=always"
|
|
1036
|
+
},
|
|
1037
|
+
dependencies: {
|
|
1038
|
+
"@base-ui/react": "^1.9.0",
|
|
1039
|
+
"@modelcontextprotocol/node": "^2.1.1",
|
|
1040
|
+
"@modelcontextprotocol/server": "^2.3.1",
|
|
1041
|
+
"class-variance-authority": "^0.7.1",
|
|
1042
|
+
clsx: "^2.1.1",
|
|
1043
|
+
cmdk: "^1.1.1",
|
|
1044
|
+
cn: "^0.4.0",
|
|
1045
|
+
jiti: "^2.7.0",
|
|
1046
|
+
lucide: "^1.55.0",
|
|
1047
|
+
"lucide-react": "^1.55.0",
|
|
1048
|
+
playwright: "^1.64.0",
|
|
1049
|
+
"radix-ui": "^1.8.0",
|
|
1050
|
+
"tailwind-merge": "^3.7.0",
|
|
1051
|
+
ws: "^8.22.0",
|
|
1052
|
+
zod: "^4.6.6",
|
|
1053
|
+
typescript: "^5.9.3"
|
|
1054
|
+
},
|
|
1055
|
+
peerDependencies: {
|
|
1056
|
+
"@joycostudio/xyz": "^1.4.0",
|
|
1057
|
+
react: ">=18",
|
|
1058
|
+
"react-dom": ">=18",
|
|
1059
|
+
tweakpane: "^4.0.5"
|
|
1060
|
+
},
|
|
1061
|
+
peerDependenciesMeta: {
|
|
1062
|
+
react: {
|
|
1063
|
+
optional: true
|
|
1064
|
+
},
|
|
1065
|
+
tweakpane: {
|
|
1066
|
+
optional: true
|
|
1067
|
+
},
|
|
1068
|
+
"react-dom": {
|
|
1069
|
+
optional: true
|
|
1070
|
+
}
|
|
1071
|
+
},
|
|
1072
|
+
devDependencies: {
|
|
1073
|
+
"@joycostudio/xyz": "^1.4.0",
|
|
1074
|
+
"@modelcontextprotocol/client": "^2.3.1",
|
|
1075
|
+
"@tailwindcss/cli": "^4.3.3",
|
|
1076
|
+
"@tweakpane/core": "^2.0.5",
|
|
1077
|
+
"@types/node": "^24.19.2",
|
|
1078
|
+
"@types/react": "^19.3.0",
|
|
1079
|
+
"@types/react-dom": "^19.3.0",
|
|
1080
|
+
"@types/ws": "^8.18.2",
|
|
1081
|
+
react: "^19.3.0",
|
|
1082
|
+
"react-dom": "^19.3.0",
|
|
1083
|
+
tailwindcss: "^4.3.3",
|
|
1084
|
+
tsup: "^8.5.1",
|
|
1085
|
+
"tw-animate-css": "^1.4.0",
|
|
1086
|
+
tweakpane: "^4.0.5",
|
|
1087
|
+
vitest: "^5.0.3"
|
|
1088
|
+
},
|
|
1089
|
+
sideEffects: [
|
|
1090
|
+
"*.css"
|
|
1091
|
+
]
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
// src/discovery/scaffold.ts
|
|
1095
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
1096
|
+
import path5 from "path";
|
|
1097
|
+
import ts2 from "typescript";
|
|
1098
|
+
function nextConfigEdit(root) {
|
|
1099
|
+
const name = ["next.config.ts", "next.config.mjs", "next.config.js"].find((file2) => existsSync3(path5.join(root, file2)));
|
|
1100
|
+
if (!name)
|
|
1101
|
+
return {
|
|
1102
|
+
file: path5.join(root, "next.config.ts"),
|
|
1103
|
+
content: "import { withStudio } from '@joycostudio/studio/next/config'\n\nexport default withStudio({})\n"
|
|
1104
|
+
};
|
|
1105
|
+
const file = path5.join(root, name);
|
|
1106
|
+
const text = readFileSync2(file, "utf8");
|
|
1107
|
+
const source = ts2.createSourceFile(file, text, ts2.ScriptTarget.Latest, true);
|
|
1108
|
+
const wrapped = (node) => ts2.isCallExpression(node) && ts2.isIdentifier(node.expression) && /^withStudio\w*$/.test(node.expression.text) || (ts2.forEachChild(node, wrapped) ?? false);
|
|
1109
|
+
if (text.includes("@joycostudio/studio/next/config") && wrapped(source)) return { file, content: text };
|
|
1110
|
+
const exported = source.statements.find(ts2.isExportAssignment);
|
|
1111
|
+
if (exported && !exported.isExportEquals) {
|
|
1112
|
+
const expression = exported.expression;
|
|
1113
|
+
let local = "withStudioSetup";
|
|
1114
|
+
while (new RegExp(`\\b${local}\\b`).test(text)) local += "_";
|
|
1115
|
+
return {
|
|
1116
|
+
file,
|
|
1117
|
+
content: `import { withStudio as ${local} } from '@joycostudio/studio/next/config'
|
|
1118
|
+
` + text.slice(0, expression.getStart(source)) + `${local}(${expression.getText(source)})` + text.slice(expression.end)
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
const assignment = source.statements.flatMap(
|
|
1122
|
+
(statement) => ts2.isExpressionStatement(statement) && ts2.isBinaryExpression(statement.expression) && statement.expression.left.getText(source) === "module.exports" && statement.expression.operatorToken.kind === ts2.SyntaxKind.EqualsToken ? [statement.expression.right] : []
|
|
1123
|
+
);
|
|
1124
|
+
if (assignment.length === 1) {
|
|
1125
|
+
const expression = assignment[0];
|
|
1126
|
+
return {
|
|
1127
|
+
file,
|
|
1128
|
+
content: text.slice(0, expression.getStart(source)) + `async (...args) => { const { withStudio } = await import('@joycostudio/studio/next/config'); return withStudio(${expression.getText(source)})(...args) }` + text.slice(expression.end)
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
throw new Error(
|
|
1132
|
+
`[studio] Cannot wrap ${name} automatically. Export a config object/function or wrap it with withStudio manually.`
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// src/init.ts
|
|
1137
|
+
async function init(argv) {
|
|
1138
|
+
const { positionals, values } = parseArgs({
|
|
1139
|
+
args: argv,
|
|
1140
|
+
allowPositionals: true,
|
|
1141
|
+
options: { "no-install": { type: "boolean" } }
|
|
1142
|
+
});
|
|
1143
|
+
if (positionals.length > 1) throw new Error("Usage: studio init [directory] [--no-install]");
|
|
1144
|
+
const root = path6.resolve(positionals[0] ?? ".");
|
|
1145
|
+
const manifest = path6.join(root, "package.json");
|
|
1146
|
+
const pkg = JSON.parse(readFileSync3(manifest, "utf8"));
|
|
1147
|
+
if (!pkg.dependencies?.next && !pkg.devDependencies?.next)
|
|
1148
|
+
throw new Error("[studio] init supports existing Next.js App Router projects.");
|
|
1149
|
+
if (!existsSync4(path6.join(root, "tsconfig.json")))
|
|
1150
|
+
throw new Error("[studio] init currently requires a TypeScript project (tsconfig.json).");
|
|
1151
|
+
const app = ["app", "src/app"].find((dir) => existsSync4(path6.join(root, dir)));
|
|
1152
|
+
if (!app) throw new Error("[studio] No app/ or src/app/ directory found.");
|
|
1153
|
+
const src = app.startsWith("src/") ? "src/" : "";
|
|
1154
|
+
const relative = (from, to) => {
|
|
1155
|
+
const result = path6.relative(path6.dirname(from), to).split(path6.sep).join("/");
|
|
1156
|
+
return /^\.\.?\//.test(result) ? result : `./${result}`;
|
|
1157
|
+
};
|
|
1158
|
+
const client = `${src}studio.ts`;
|
|
1159
|
+
const tool2 = `${app}/studio-preview/studio.tool.ts`;
|
|
1160
|
+
const data = `${app}/studio-preview/config.json`;
|
|
1161
|
+
const page = `${app}/studio/page.tsx`;
|
|
1162
|
+
const preview = `${app}/studio-preview/page.tsx`;
|
|
1163
|
+
const files = {
|
|
1164
|
+
[client]: `import { createStudio } from '@joycostudio/studio/next'
|
|
1165
|
+
import { tools } from '${relative(client, ".studio/registry.generated")}'
|
|
1166
|
+
|
|
1167
|
+
export const studio = createStudio({ tools })
|
|
1168
|
+
`,
|
|
1169
|
+
[data]: '{ "scale": 1 }\n',
|
|
1170
|
+
[tool2]: `import { defineTool } from '@joycostudio/studio'
|
|
1171
|
+
import { property, slider } from '@joycostudio/studio/controls'
|
|
1172
|
+
|
|
1173
|
+
export default defineTool({
|
|
1174
|
+
id: 'studio-example',
|
|
1175
|
+
title: 'Example',
|
|
1176
|
+
controls: (config) => ({ scale: slider(property(config, 'scale'), { min: 0.5, max: 2 }) }),
|
|
1177
|
+
})
|
|
1178
|
+
`,
|
|
1179
|
+
[page]: `'use client'
|
|
1180
|
+
|
|
1181
|
+
import '@joycostudio/studio/styles.css'
|
|
1182
|
+
import { Studio } from '@joycostudio/studio/react'
|
|
1183
|
+
import { studio } from '${relative(page, client.slice(0, -3))}'
|
|
1184
|
+
|
|
1185
|
+
export default function StudioPage() {
|
|
1186
|
+
return <Studio studio={studio} />
|
|
1187
|
+
}
|
|
1188
|
+
`,
|
|
1189
|
+
[preview]: `'use client'
|
|
1190
|
+
|
|
1191
|
+
import { useToolConfig } from '@joycostudio/studio/react'
|
|
1192
|
+
import { studio } from '${relative(preview, client.slice(0, -3))}'
|
|
1193
|
+
import example from './studio.tool'
|
|
1194
|
+
|
|
1195
|
+
export default function StudioPreview() {
|
|
1196
|
+
const config = useToolConfig(studio.tool(example))
|
|
1197
|
+
return (
|
|
1198
|
+
<main style={{ display: 'grid', placeItems: 'center', minHeight: '100vh' }}>
|
|
1199
|
+
<div style={{ width: 120, height: 120, background: '#ff5930', borderRadius: 24, transform: \`scale(\${config.scale})\` }} />
|
|
1200
|
+
</main>
|
|
1201
|
+
)
|
|
1202
|
+
}
|
|
1203
|
+
`
|
|
1204
|
+
};
|
|
1205
|
+
const checkRoute = (directory, segment, allowed) => {
|
|
1206
|
+
for (const entry of readdirSync2(path6.join(root, directory), { withFileTypes: true })) {
|
|
1207
|
+
const file = `${directory}/${entry.name}`;
|
|
1208
|
+
if (entry.isDirectory()) {
|
|
1209
|
+
if (/^\([^.)][^/]*\)$/.test(entry.name)) checkRoute(file, segment, allowed);
|
|
1210
|
+
else if (entry.name === segment) checkRoute(file, void 0, allowed);
|
|
1211
|
+
} else if (!segment && /^(page|route)\.[jt]sx?$/.test(entry.name) && file !== allowed) {
|
|
1212
|
+
throw new Error(`[studio] Route already exists: ${file}`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
checkRoute(app, "studio", page);
|
|
1217
|
+
checkRoute(app, "studio-preview", preview);
|
|
1218
|
+
for (const [name, content] of Object.entries(files)) {
|
|
1219
|
+
const target = path6.join(root, name);
|
|
1220
|
+
if (existsSync4(target) && readFileSync3(target, "utf8") !== content)
|
|
1221
|
+
throw new Error(`[studio] Refusing to overwrite ${name}. Use the manual setup guide for existing integrations.`);
|
|
1222
|
+
}
|
|
1223
|
+
for (const ext of ["ts", "mts", "js", "mjs"]) {
|
|
1224
|
+
if (existsSync4(path6.join(root, `studio.config.${ext}`)))
|
|
1225
|
+
throw new Error(`[studio] Found studio.config.${ext}; use the manual setup guide for existing integrations.`);
|
|
1226
|
+
}
|
|
1227
|
+
const nextEdit = nextConfigEdit(root);
|
|
1228
|
+
const dev = pkg.scripts?.dev;
|
|
1229
|
+
if (!dev || /[;&|\n]/.test(dev) || !/^studio dev(?:\s|$)/.test(dev) && !/^next dev(?:\s|$)/.test(dev))
|
|
1230
|
+
throw new Error(
|
|
1231
|
+
"[studio] Expected a next dev script. Configure studio dev -- <command> manually for custom scripts."
|
|
1232
|
+
);
|
|
1233
|
+
let manager = pkg.packageManager?.split("@")[0];
|
|
1234
|
+
if (!manager) {
|
|
1235
|
+
let dir = root;
|
|
1236
|
+
while (true) {
|
|
1237
|
+
manager = [
|
|
1238
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
1239
|
+
["yarn.lock", "yarn"],
|
|
1240
|
+
["bun.lock", "bun"],
|
|
1241
|
+
["bun.lockb", "bun"],
|
|
1242
|
+
["package-lock.json", "npm"]
|
|
1243
|
+
].find(([lock]) => existsSync4(path6.join(dir, lock)))?.[1];
|
|
1244
|
+
if (manager || path6.dirname(dir) === dir) break;
|
|
1245
|
+
dir = path6.dirname(dir);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
manager ??= "npm";
|
|
1249
|
+
if (!["npm", "pnpm", "yarn", "bun"].includes(manager))
|
|
1250
|
+
throw new Error(`[studio] Unsupported package manager: ${manager}`);
|
|
1251
|
+
if (pkg.dependencies?.["@joycostudio/studio"] || pkg.devDependencies?.["@joycostudio/studio"]) {
|
|
1252
|
+
try {
|
|
1253
|
+
const require2 = createRequire(manifest);
|
|
1254
|
+
for (const entry of ["next", "next/config", "controls", "react"]) require2.resolve(`@joycostudio/studio/${entry}`);
|
|
1255
|
+
} catch {
|
|
1256
|
+
throw new Error(
|
|
1257
|
+
"[studio] The declared Studio dependency is missing or lacks the required exports. Install the same version (or preview) as this initializer, then rerun init. No files were changed."
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const dependencies = {
|
|
1262
|
+
"@joycostudio/studio": `^${package_default.version}`,
|
|
1263
|
+
"@joycostudio/xyz": package_default.peerDependencies["@joycostudio/xyz"],
|
|
1264
|
+
tweakpane: package_default.peerDependencies.tweakpane
|
|
1265
|
+
};
|
|
1266
|
+
pkg.dependencies ??= {};
|
|
1267
|
+
for (const [name, version] of Object.entries(dependencies)) {
|
|
1268
|
+
if (!pkg.dependencies[name] && !pkg.devDependencies?.[name]) pkg.dependencies[name] = version;
|
|
1269
|
+
}
|
|
1270
|
+
if (!/^studio dev(?:\s|$)/.test(dev)) pkg.scripts.dev = `studio dev -- ${dev}`;
|
|
1271
|
+
const typecheck = pkg.scripts.typecheck ?? "tsc --noEmit";
|
|
1272
|
+
if (!typecheck.startsWith("studio generate")) pkg.scripts.typecheck = `studio generate && ${typecheck}`;
|
|
1273
|
+
const ignoreFile = path6.join(root, ".gitignore");
|
|
1274
|
+
const targets = [
|
|
1275
|
+
...Object.keys(files).map((name) => path6.join(root, name)),
|
|
1276
|
+
nextEdit.file,
|
|
1277
|
+
ignoreFile,
|
|
1278
|
+
manifest,
|
|
1279
|
+
path6.join(root, ".studio/types.generated.ts"),
|
|
1280
|
+
path6.join(root, ".studio/registry.generated.ts")
|
|
1281
|
+
];
|
|
1282
|
+
const originals = new Map(targets.map((file) => [file, existsSync4(file) ? readFileSync3(file) : void 0]));
|
|
1283
|
+
const createdDirectories = /* @__PURE__ */ new Set();
|
|
1284
|
+
for (const file of targets) {
|
|
1285
|
+
for (let dir = path6.dirname(file); !existsSync4(dir); dir = path6.dirname(dir)) createdDirectories.add(dir);
|
|
1286
|
+
}
|
|
1287
|
+
try {
|
|
1288
|
+
for (const [name, content] of Object.entries(files)) {
|
|
1289
|
+
const target = path6.join(root, name);
|
|
1290
|
+
if (existsSync4(target)) continue;
|
|
1291
|
+
mkdirSync(path6.dirname(target), { recursive: true });
|
|
1292
|
+
writeFileSync(target, content, { flag: "wx" });
|
|
1293
|
+
}
|
|
1294
|
+
writeFileSync(nextEdit.file, nextEdit.content);
|
|
1295
|
+
const ignore = originals.get(ignoreFile)?.toString() ?? "";
|
|
1296
|
+
if (!ignore.split(/\r?\n/).some((line) => [".studio", ".studio/", "/.studio/"].includes(line)))
|
|
1297
|
+
writeFileSync(ignoreFile, `${ignore}${ignore.endsWith("\n") || !ignore ? "" : "\n"}/.studio/
|
|
1298
|
+
`);
|
|
1299
|
+
await generate(root);
|
|
1300
|
+
writeFileSync(manifest, `${JSON.stringify(pkg, null, 2)}
|
|
1301
|
+
`);
|
|
1302
|
+
} catch (cause) {
|
|
1303
|
+
for (const [file, original] of originals) {
|
|
1304
|
+
if (original === void 0) rmSync(file, { force: true });
|
|
1305
|
+
else if (!existsSync4(file) || !readFileSync3(file).equals(original)) writeFileSync(file, original);
|
|
1306
|
+
}
|
|
1307
|
+
for (const dir of [...createdDirectories].sort((a, b) => b.length - a.length)) {
|
|
1308
|
+
try {
|
|
1309
|
+
rmdirSync(dir);
|
|
1310
|
+
} catch (error2) {
|
|
1311
|
+
if (!["ENOENT", "ENOTEMPTY"].includes(error2.code ?? "")) throw error2;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
throw cause;
|
|
1315
|
+
}
|
|
1316
|
+
if (!values["no-install"]) {
|
|
1317
|
+
const result = spawnSync(manager, ["install"], { cwd: root, stdio: "inherit", shell: process.platform === "win32" });
|
|
1318
|
+
if (result.error || result.status !== 0)
|
|
1319
|
+
throw new Error(`[studio] Files created, but installation failed. Run ${manager} install in ${root} to retry.`);
|
|
1320
|
+
}
|
|
1321
|
+
console.log(`[studio] Ready. ${values["no-install"] ? `Run ${manager} install, then ` : "Run "}${manager} run dev and open /studio.
|
|
1322
|
+
The example at /studio-preview reacts to edits; Save writes ${data}.
|
|
1323
|
+
To connect your app, read studio.tool(yourTool).config or useToolConfig(studio.tool(yourTool)).
|
|
1324
|
+
Optional: mount <StudioDebug studio={studio} /> or <StudioCommandMenu studio={studio} /> from @joycostudio/studio/react.
|
|
1325
|
+
For MCP, use the endpoint printed by studio dev. Preview capture additionally requires appUrl and studio install-browser.`);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// src/discovery/watch.ts
|
|
1329
|
+
import { existsSync as existsSync5, statSync, watch as watch2 } from "fs";
|
|
1330
|
+
import path7 from "path";
|
|
1331
|
+
function watchDiscovery(initial, refresh) {
|
|
1332
|
+
let current = initial;
|
|
1333
|
+
let retryInputs = /* @__PURE__ */ new Set();
|
|
1334
|
+
let watchers = [];
|
|
1335
|
+
let timer;
|
|
1336
|
+
let running = false;
|
|
1337
|
+
let pending = false;
|
|
1338
|
+
let closed = false;
|
|
1339
|
+
const schedule = () => {
|
|
1340
|
+
if (closed) return;
|
|
1341
|
+
pending = true;
|
|
1342
|
+
clearTimeout(timer);
|
|
1343
|
+
timer = setTimeout(() => void run(), 80);
|
|
1344
|
+
};
|
|
1345
|
+
const run = async () => {
|
|
1346
|
+
if (running || closed) return;
|
|
1347
|
+
pending = false;
|
|
1348
|
+
running = true;
|
|
1349
|
+
try {
|
|
1350
|
+
const next = await refresh();
|
|
1351
|
+
if (next && !closed) {
|
|
1352
|
+
current = next;
|
|
1353
|
+
retryInputs.clear();
|
|
1354
|
+
bind();
|
|
1355
|
+
}
|
|
1356
|
+
} catch (cause) {
|
|
1357
|
+
if (cause instanceof DiscoveryInputError && !closed) {
|
|
1358
|
+
retryInputs = cause.inputs;
|
|
1359
|
+
bind();
|
|
1360
|
+
}
|
|
1361
|
+
console.error("[studio] discovery update rejected:", cause instanceof Error ? cause.message : cause);
|
|
1362
|
+
} finally {
|
|
1363
|
+
running = false;
|
|
1364
|
+
if (pending) schedule();
|
|
1365
|
+
}
|
|
1366
|
+
};
|
|
1367
|
+
const bind = () => {
|
|
1368
|
+
for (const watcher of watchers) watcher.close();
|
|
1369
|
+
watchers = [];
|
|
1370
|
+
const pages = new Map(
|
|
1371
|
+
[...current.sources].flatMap(
|
|
1372
|
+
(file) => ["tsx", "ts", "jsx", "js"].map((ext) => {
|
|
1373
|
+
const page = path7.join(path7.dirname(file), `page.${ext}`);
|
|
1374
|
+
return [page, existsSync5(page)];
|
|
1375
|
+
})
|
|
1376
|
+
)
|
|
1377
|
+
);
|
|
1378
|
+
const add = (directory, recursive, listener) => {
|
|
1379
|
+
if (!existsSync5(directory)) return;
|
|
1380
|
+
const watcher = watch2(directory, { recursive }, listener);
|
|
1381
|
+
watcher.on("error", (cause) => console.error("[studio] discovery watcher:", cause.message));
|
|
1382
|
+
watcher.unref();
|
|
1383
|
+
watchers.push(watcher);
|
|
1384
|
+
};
|
|
1385
|
+
add(current.toolsDir, true, (event, filename) => {
|
|
1386
|
+
if (!filename) return schedule();
|
|
1387
|
+
const relative = filename.toString();
|
|
1388
|
+
if (relative.split(path7.sep).some((part) => part.startsWith(".") || part === "node_modules")) return;
|
|
1389
|
+
const file = path7.resolve(current.toolsDir, relative);
|
|
1390
|
+
if (path7.basename(file) === "studio.tool.ts" || current.dependencies.has(file)) return schedule();
|
|
1391
|
+
if (pages.has(file)) {
|
|
1392
|
+
if (pages.get(file) !== existsSync5(file)) schedule();
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
if (event === "rename" && (/^page\.[jt]sx?$/.test(path7.basename(file)) || !path7.extname(file) || statSync(file, { throwIfNoEntry: false })?.isDirectory()))
|
|
1396
|
+
schedule();
|
|
1397
|
+
});
|
|
1398
|
+
const targets = /* @__PURE__ */ new Set([
|
|
1399
|
+
...current.dependencies,
|
|
1400
|
+
...retryInputs,
|
|
1401
|
+
...["studio.config.ts", "studio.config.mts", "studio.config.js", "studio.config.mjs", "tsconfig.json"].map(
|
|
1402
|
+
(file) => path7.join(current.root, file)
|
|
1403
|
+
)
|
|
1404
|
+
]);
|
|
1405
|
+
const byDirectory = /* @__PURE__ */ new Map();
|
|
1406
|
+
for (const file of targets) {
|
|
1407
|
+
let directory = path7.dirname(file);
|
|
1408
|
+
while (!existsSync5(directory) && path7.dirname(directory) !== directory) directory = path7.dirname(directory);
|
|
1409
|
+
const names = byDirectory.get(directory) ?? /* @__PURE__ */ new Set();
|
|
1410
|
+
names.add(path7.relative(directory, file).split(path7.sep)[0]);
|
|
1411
|
+
byDirectory.set(directory, names);
|
|
1412
|
+
}
|
|
1413
|
+
for (const [directory, names] of byDirectory) {
|
|
1414
|
+
add(directory, false, (_, filename) => {
|
|
1415
|
+
if (!filename || names.has(filename.toString())) schedule();
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
const directoryId = statSync(current.toolsDir, { throwIfNoEntry: false })?.ino;
|
|
1419
|
+
add(path7.dirname(current.toolsDir), false, (_, filename) => {
|
|
1420
|
+
if ((!filename || filename.toString() === path7.basename(current.toolsDir)) && statSync(current.toolsDir, { throwIfNoEntry: false })?.ino !== directoryId)
|
|
1421
|
+
schedule();
|
|
1422
|
+
});
|
|
1423
|
+
};
|
|
1424
|
+
bind();
|
|
1425
|
+
return {
|
|
1426
|
+
retry: schedule,
|
|
1427
|
+
close() {
|
|
1428
|
+
closed = true;
|
|
1429
|
+
clearTimeout(timer);
|
|
1430
|
+
for (const watcher of watchers) watcher.close();
|
|
1431
|
+
}
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
|
|
650
1435
|
// src/cli.ts
|
|
651
1436
|
var HELP = `Usage: studio dev [--config studio.config.ts] [--port ${DEFAULT_PORT}] [--app-url http://localhost:3000] [-- <command...>]
|
|
1437
|
+
studio generate [--config studio.config.ts]
|
|
652
1438
|
studio install-browser
|
|
1439
|
+
studio init [directory] [--no-install]
|
|
653
1440
|
|
|
654
1441
|
Starts the Studio runtime with a WebSocket bridge and an HTTP MCP endpoint at /mcp,
|
|
655
1442
|
moving to the next free port when another project's Studio already uses it.
|
|
@@ -659,10 +1446,11 @@ Set appUrl in the config or pass --app-url to enable MCP preview capture. A conf
|
|
|
659
1446
|
appUrl port is passed to the command as PORT, moved to the next free port when taken.
|
|
660
1447
|
Run "studio install-browser" once to install Chromium for captures.`;
|
|
661
1448
|
async function main(argv) {
|
|
1449
|
+
if (argv[0] === "init") return init(argv.slice(1));
|
|
662
1450
|
if (argv[0] === "install-browser") {
|
|
663
|
-
const require2 =
|
|
664
|
-
const cli =
|
|
665
|
-
|
|
1451
|
+
const require2 = createRequire2(import.meta.url);
|
|
1452
|
+
const cli = path8.join(
|
|
1453
|
+
path8.dirname(require2.resolve("playwright/package.json")),
|
|
666
1454
|
require2("playwright/package.json").bin.playwright
|
|
667
1455
|
);
|
|
668
1456
|
const child2 = spawn(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" });
|
|
@@ -676,7 +1464,7 @@ async function main(argv) {
|
|
|
676
1464
|
const split = argv.indexOf("--");
|
|
677
1465
|
const own = split === -1 ? argv : argv.slice(0, split);
|
|
678
1466
|
const command = split === -1 ? [] : argv.slice(split + 1);
|
|
679
|
-
const { positionals, values } =
|
|
1467
|
+
const { positionals, values } = parseArgs2({
|
|
680
1468
|
args: own,
|
|
681
1469
|
allowPositionals: true,
|
|
682
1470
|
options: {
|
|
@@ -686,8 +1474,13 @@ async function main(argv) {
|
|
|
686
1474
|
help: { type: "boolean", short: "h" }
|
|
687
1475
|
}
|
|
688
1476
|
});
|
|
1477
|
+
if (positionals[0] === "generate") {
|
|
1478
|
+
await generate(process.cwd(), values.config);
|
|
1479
|
+
console.log("[studio] generated tool registry and types");
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
689
1482
|
if (values.help || positionals[0] !== "dev") return console.log(HELP);
|
|
690
|
-
const { config, root } = await loadConfig(process.cwd(), values.config);
|
|
1483
|
+
const { config, root, discovery } = await loadConfig(process.cwd(), values.config);
|
|
691
1484
|
const [bin, ...args] = command;
|
|
692
1485
|
const env = { ...process.env };
|
|
693
1486
|
let appUrl = values["app-url"] ?? config.appUrl;
|
|
@@ -703,10 +1496,30 @@ async function main(argv) {
|
|
|
703
1496
|
const bridge = await serveBridgeNear(runtime, { port, origins: config.origins, appUrl });
|
|
704
1497
|
if (bridge.port !== port) console.log(`[studio] port ${port} is in use, probably by another project's Studio`);
|
|
705
1498
|
console.log(
|
|
706
|
-
`[studio] runtime ready on ws://127.0.0.1:${bridge.port} \u2014 ${config.tools.length} tool(s) in ${
|
|
1499
|
+
`[studio] runtime ready on ws://127.0.0.1:${bridge.port} \u2014 ${config.tools.length} tool(s) in ${path8.relative(process.cwd(), root) || "."}`
|
|
707
1500
|
);
|
|
708
1501
|
console.log(`[studio] MCP ready on http://127.0.0.1:${bridge.port}/mcp`);
|
|
1502
|
+
let rejected = false;
|
|
1503
|
+
let accepted = discovery;
|
|
1504
|
+
const watcher = discovery && watchDiscovery(discovery, async () => {
|
|
1505
|
+
try {
|
|
1506
|
+
const next = await loadConfig(process.cwd(), values.config, false, accepted);
|
|
1507
|
+
await runtime.replaceTools(next.config.tools);
|
|
1508
|
+
await next.discovery?.write();
|
|
1509
|
+
accepted = next.discovery;
|
|
1510
|
+
rejected = false;
|
|
1511
|
+
return next.discovery;
|
|
1512
|
+
} catch (cause) {
|
|
1513
|
+
rejected = true;
|
|
1514
|
+
throw cause;
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
const unsubscribe = runtime.subscribe((state) => {
|
|
1518
|
+
if (rejected && !state.dirty) watcher?.retry();
|
|
1519
|
+
});
|
|
709
1520
|
const shutdown = async (code = 0) => {
|
|
1521
|
+
watcher?.close();
|
|
1522
|
+
unsubscribe();
|
|
710
1523
|
runtime.close();
|
|
711
1524
|
await bridge.close();
|
|
712
1525
|
process.exit(code);
|
|
@@ -716,7 +1529,16 @@ async function main(argv) {
|
|
|
716
1529
|
process.once("SIGTERM", () => void shutdown());
|
|
717
1530
|
return;
|
|
718
1531
|
}
|
|
719
|
-
const child = spawn(bin, args, {
|
|
1532
|
+
const child = spawn(bin, args, {
|
|
1533
|
+
stdio: "inherit",
|
|
1534
|
+
env: {
|
|
1535
|
+
...env,
|
|
1536
|
+
STUDIO_PORT: String(bridge.port),
|
|
1537
|
+
NEXT_PUBLIC_STUDIO_RUNTIME: `ws://127.0.0.1:${bridge.port}`,
|
|
1538
|
+
STUDIO_DISCOVERY_MANAGED: discovery ? "1" : "",
|
|
1539
|
+
...values.config ? { STUDIO_CONFIG: path8.resolve(values.config) } : {}
|
|
1540
|
+
}
|
|
1541
|
+
});
|
|
720
1542
|
child.once("exit", (code) => void shutdown(code ?? 0));
|
|
721
1543
|
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => child.kill(signal));
|
|
722
1544
|
}
|