@joycostudio/studio 0.1.3 → 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/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 path4 from "path";
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, path5) {
38
+ function shapeMismatch(reference, value, path9) {
39
39
  const expected = kind(reference);
40
- if (kind(value) !== expected) return `${path5}: expected ${expected}, got ${kind(value)}`;
41
- if (expected === "number" && !Number.isFinite(value)) return `${path5}: expected a finite number`;
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, `${path5}[${index}]`);
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 `${path5}.${key}: unknown key`;
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 `${path5}.${key}: missing`;
56
- const nested = shapeMismatch(ref[key], obj[key], `${path5}.${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, path5 = "") {
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 = `${path5}${field.label ?? key}`;
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 fail = (reason) => new Error(`[studio] tool file "${file}" ${reason}`);
106
- if (path.isAbsolute(file)) throw fail("must be relative to the project root");
107
- if (path.extname(file) !== ".json") throw fail("must be a .json file");
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 fail("escapes the project root");
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 fail("escapes the project root");
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 createRuntime({ root, tools }) {
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 byDirectory = Map.groupBy(entries.values(), (entry) => path2.dirname(entry.file));
267
- for (const [directory, group] of byDirectory) {
268
- try {
269
- const watcher = watch(directory, (_, filename) => {
270
- for (const entry of group) {
271
- if (filename && filename !== path2.basename(entry.file)) continue;
272
- clearTimeout(timers.get(entry));
273
- timers.set(
274
- entry,
275
- setTimeout(() => void reload(entry).catch((cause) => console.error("[studio] reload failed", cause)), 50)
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
- watchers.push(watcher);
280
- } catch {
281
- console.warn(`[studio] not watching ${path2.relative(root, directory)} (it does not exist yet)`);
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) => handlers[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/runtime/load.ts
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
- async function loadConfig(cwd, explicit) {
615
- const file = explicit ? path3.resolve(cwd, explicit) : CANDIDATES.map((name) => path3.join(cwd, name)).find((candidate) => existsSync(candidate));
616
- if (!file || !existsSync(file)) throw new Error(`[studio] no ${explicit ?? "studio.config.ts"} found in ${cwd}`);
617
- const jiti = createJiti(import.meta.url, { moduleCache: false });
618
- const config = await jiti.import(file, { default: true });
619
- if (!Array.isArray(config?.tools))
620
- throw new Error(`[studio] ${path3.basename(file)} must default-export defineConfig({ tools })`);
621
- return { config, root: path3.dirname(file) };
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.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
+
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 = createRequire(import.meta.url);
669
- const cli = path4.join(
670
- path4.dirname(require2.resolve("playwright/package.json")),
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 } = parseArgs({
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 ${path4.relative(process.cwd(), root) || "."}`
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, { stdio: "inherit", env: { ...env, STUDIO_PORT: String(bridge.port) } });
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
  }