@joycostudio/studio 0.1.3 → 0.1.5
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 +190 -57
- package/dist/{chunk-OIMLF2CL.js → chunk-DEJYGBPJ.js} +6 -5
- 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/chunk-ZH3NEN4V.js +476 -0
- package/dist/chunk-ZH3NEN4V.js.map +1 -0
- package/dist/cli.js +869 -52
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +11 -6
- package/dist/client.js +5 -460
- 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 +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-Dgj_7ibg.d.ts → protocol-DjrMADPm.d.ts} +40 -5
- package/dist/react.d.ts +22 -10
- package/dist/react.js +368 -252
- package/dist/react.js.map +1 -1
- package/dist/runtime.d.ts +32 -4
- package/dist/runtime.js +97 -83
- package/dist/runtime.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +27 -7
- package/dist/chunk-OIMLF2CL.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
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
10
|
var hasConfig = (tool2) => tool2.controls !== void 0;
|
|
@@ -35,35 +35,35 @@ function kind(value) {
|
|
|
35
35
|
if (Array.isArray(value)) return "array";
|
|
36
36
|
return typeof value;
|
|
37
37
|
}
|
|
38
|
-
function shapeMismatch(reference, value,
|
|
38
|
+
function shapeMismatch(reference, value, path9) {
|
|
39
39
|
const expected = kind(reference);
|
|
40
|
-
if (kind(value) !== expected) return `${
|
|
41
|
-
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`;
|
|
42
42
|
if (expected === "array") {
|
|
43
43
|
const [first] = reference;
|
|
44
44
|
if (first === void 0) return null;
|
|
45
45
|
for (const [index, item] of value.entries()) {
|
|
46
|
-
const nested = shapeMismatch(first, item, `${
|
|
46
|
+
const nested = shapeMismatch(first, item, `${path9}[${index}]`);
|
|
47
47
|
if (nested) return nested;
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
if (expected === "object") {
|
|
51
51
|
const ref = reference;
|
|
52
52
|
const obj = value;
|
|
53
|
-
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`;
|
|
54
54
|
for (const key of Object.keys(ref)) {
|
|
55
|
-
if (!(key in obj)) return `${
|
|
56
|
-
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}`);
|
|
57
57
|
if (nested) return nested;
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
return null;
|
|
61
61
|
}
|
|
62
62
|
var isRgb = (value) => typeof value === "object" && value !== null && ["r", "g", "b"].every((channel) => Number.isFinite(value[channel]));
|
|
63
|
-
function invalidControl(schema,
|
|
63
|
+
function invalidControl(schema, path9 = "") {
|
|
64
64
|
for (const [key, field] of Object.entries(schema)) {
|
|
65
65
|
if (field.kind === "button") continue;
|
|
66
|
-
const name = `${
|
|
66
|
+
const name = `${path9}${field.label ?? key}`;
|
|
67
67
|
if (field.kind === "folder") {
|
|
68
68
|
const nested = invalidControl(field.definition.schema(), `${name}/`);
|
|
69
69
|
if (nested) return nested;
|
|
@@ -102,14 +102,14 @@ var exists = (target) => stat(target).then(
|
|
|
102
102
|
() => false
|
|
103
103
|
);
|
|
104
104
|
async function resolveToolFile(root, file) {
|
|
105
|
-
const
|
|
106
|
-
if (path.isAbsolute(file)) throw
|
|
107
|
-
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");
|
|
108
108
|
const absolute = path.resolve(root, file);
|
|
109
|
-
if (!inside(root, absolute)) throw
|
|
109
|
+
if (!inside(root, absolute)) throw fail2("escapes the project root");
|
|
110
110
|
let probe = absolute;
|
|
111
111
|
while (!await exists(probe)) probe = path.dirname(probe);
|
|
112
|
-
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");
|
|
113
113
|
return absolute;
|
|
114
114
|
}
|
|
115
115
|
var hashOf = (content) => createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
@@ -151,8 +151,7 @@ async function load(tool2, file) {
|
|
|
151
151
|
throw new Error(`[studio] ${path2.basename(file)} is not a valid "${tool2.id}" config: ${parsed.message}`);
|
|
152
152
|
return { value: parsed.data, hash: disk.hash };
|
|
153
153
|
}
|
|
154
|
-
async function
|
|
155
|
-
root = path2.resolve(root);
|
|
154
|
+
async function prepare(root, tools) {
|
|
156
155
|
const entries = /* @__PURE__ */ new Map();
|
|
157
156
|
const files = /* @__PURE__ */ new Set();
|
|
158
157
|
const ids = /* @__PURE__ */ new Set();
|
|
@@ -177,6 +176,12 @@ async function createRuntime({ root, tools }) {
|
|
|
177
176
|
}
|
|
178
177
|
});
|
|
179
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();
|
|
180
185
|
const listeners = /* @__PURE__ */ new Set();
|
|
181
186
|
const commit = (entry, next, origin) => {
|
|
182
187
|
const state = { ...entry.state, ...next };
|
|
@@ -263,27 +268,76 @@ async function createRuntime({ root, tools }) {
|
|
|
263
268
|
});
|
|
264
269
|
const timers = /* @__PURE__ */ new Map();
|
|
265
270
|
const watchers = [];
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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}`
|
|
276
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;
|
|
277
322
|
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
+
};
|
|
284
334
|
return {
|
|
285
335
|
root,
|
|
286
|
-
run: (op, params, origin) =>
|
|
336
|
+
run: async (op, params, origin) => {
|
|
337
|
+
await changing;
|
|
338
|
+
return handlers[op](params, origin);
|
|
339
|
+
},
|
|
340
|
+
replaceTools,
|
|
287
341
|
subscribe(listener) {
|
|
288
342
|
listeners.add(listener);
|
|
289
343
|
return () => listeners.delete(listener);
|
|
@@ -606,19 +660,273 @@ function serveBridge(runtime, { port, host = "127.0.0.1", origins, appUrl }) {
|
|
|
606
660
|
});
|
|
607
661
|
}
|
|
608
662
|
|
|
609
|
-
// 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
|
|
610
670
|
import { existsSync } from "fs";
|
|
611
671
|
import path3 from "path";
|
|
612
672
|
import { createJiti } from "jiti";
|
|
613
673
|
var CANDIDATES = ["studio.config.ts", "studio.config.mts", "studio.config.js", "studio.config.mjs"];
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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 };
|
|
622
930
|
}
|
|
623
931
|
|
|
624
932
|
// src/runtime/ports.ts
|
|
@@ -652,9 +960,483 @@ async function freeAppPort(hostname, port) {
|
|
|
652
960
|
throw new Error(`[studio] ports ${port}-${port + ATTEMPTS - 1} on ${hostname} are all in use`);
|
|
653
961
|
}
|
|
654
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.5",
|
|
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
|
+
|
|
655
1435
|
// src/cli.ts
|
|
656
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]
|
|
657
1438
|
studio install-browser
|
|
1439
|
+
studio init [directory] [--no-install]
|
|
658
1440
|
|
|
659
1441
|
Starts the Studio runtime with a WebSocket bridge and an HTTP MCP endpoint at /mcp,
|
|
660
1442
|
moving to the next free port when another project's Studio already uses it.
|
|
@@ -664,10 +1446,11 @@ Set appUrl in the config or pass --app-url to enable MCP preview capture. A conf
|
|
|
664
1446
|
appUrl port is passed to the command as PORT, moved to the next free port when taken.
|
|
665
1447
|
Run "studio install-browser" once to install Chromium for captures.`;
|
|
666
1448
|
async function main(argv) {
|
|
1449
|
+
if (argv[0] === "init") return init(argv.slice(1));
|
|
667
1450
|
if (argv[0] === "install-browser") {
|
|
668
|
-
const require2 =
|
|
669
|
-
const cli =
|
|
670
|
-
|
|
1451
|
+
const require2 = createRequire2(import.meta.url);
|
|
1452
|
+
const cli = path8.join(
|
|
1453
|
+
path8.dirname(require2.resolve("playwright/package.json")),
|
|
671
1454
|
require2("playwright/package.json").bin.playwright
|
|
672
1455
|
);
|
|
673
1456
|
const child2 = spawn(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" });
|
|
@@ -681,7 +1464,7 @@ async function main(argv) {
|
|
|
681
1464
|
const split = argv.indexOf("--");
|
|
682
1465
|
const own = split === -1 ? argv : argv.slice(0, split);
|
|
683
1466
|
const command = split === -1 ? [] : argv.slice(split + 1);
|
|
684
|
-
const { positionals, values } =
|
|
1467
|
+
const { positionals, values } = parseArgs2({
|
|
685
1468
|
args: own,
|
|
686
1469
|
allowPositionals: true,
|
|
687
1470
|
options: {
|
|
@@ -691,8 +1474,13 @@ async function main(argv) {
|
|
|
691
1474
|
help: { type: "boolean", short: "h" }
|
|
692
1475
|
}
|
|
693
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
|
+
}
|
|
694
1482
|
if (values.help || positionals[0] !== "dev") return console.log(HELP);
|
|
695
|
-
const { config, root } = await loadConfig(process.cwd(), values.config);
|
|
1483
|
+
const { config, root, discovery } = await loadConfig(process.cwd(), values.config);
|
|
696
1484
|
const [bin, ...args] = command;
|
|
697
1485
|
const env = { ...process.env };
|
|
698
1486
|
let appUrl = values["app-url"] ?? config.appUrl;
|
|
@@ -708,10 +1496,30 @@ async function main(argv) {
|
|
|
708
1496
|
const bridge = await serveBridgeNear(runtime, { port, origins: config.origins, appUrl });
|
|
709
1497
|
if (bridge.port !== port) console.log(`[studio] port ${port} is in use, probably by another project's Studio`);
|
|
710
1498
|
console.log(
|
|
711
|
-
`[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) || "."}`
|
|
712
1500
|
);
|
|
713
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
|
+
});
|
|
714
1520
|
const shutdown = async (code = 0) => {
|
|
1521
|
+
watcher?.close();
|
|
1522
|
+
unsubscribe();
|
|
715
1523
|
runtime.close();
|
|
716
1524
|
await bridge.close();
|
|
717
1525
|
process.exit(code);
|
|
@@ -721,7 +1529,16 @@ async function main(argv) {
|
|
|
721
1529
|
process.once("SIGTERM", () => void shutdown());
|
|
722
1530
|
return;
|
|
723
1531
|
}
|
|
724
|
-
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
|
+
});
|
|
725
1542
|
child.once("exit", (code) => void shutdown(code ?? 0));
|
|
726
1543
|
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => child.kill(signal));
|
|
727
1544
|
}
|