Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/studio.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ jobs:
working-directory: studio
run: npx vitest run

- name: Build
- name: Build + hermetic server-tier suite
working-directory: studio
run: npm run build
run: npm run test:server

- name: Audit
working-directory: studio
Expand Down
55 changes: 55 additions & 0 deletions studio/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Mecatl Studio — environment variables
# Copy this file to .env.local and fill in the values.
#
# Studio has two pure deployment modes:
#
# MANAGED (default): `npm run dev` (or `task studio:dev`) starts the local
# controller, which supervises a `mecated` it spawns from ../bin/mecated on a
# random loopback port with a generated bearer token. Requires `task build`
# at the repo root first. No variables are needed.
#
# EXTERNAL: set MECATL_BASE_URL and Studio proxies to that daemon instead.
# No controller runs; every local control surface (provider, model router,
# MCP gateway) answers 409 as owned by the deployment.

# ── External mode ────────────────────────────────────────────────────────────
# Base URL of an already-running mecated HTTP listener. Its presence selects
# external mode.
# MECATL_BASE_URL=https://mecated.internal.example.com:8081

# Bearer token for the external daemon (`mecated serve --auth-token ...`).
# Injected server-side only; the browser never sees or supplies it.
# MECATL_AUTH_TOKEN=

# Absolute path every file and shell tool is rooted at, required on session
# create. Injected server-side so it never reaches the client bundle.
# MECATL_WORKSPACE=/absolute/path/to/workspace

# ── Origin pinning (both modes) ──────────────────────────────────────────────
# Comma-separated list of origins Studio itself is served from. Requests whose
# Host or Origin fall outside this list are refused (CSRF/DNS-rebinding gate).
# MECATL_STUDIO_PUBLIC_ORIGIN=http://localhost:3000,http://127.0.0.1:3000

# Origin allowlist enforced by the local controller (managed mode).
# MECATL_STUDIO_ORIGINS=http://localhost:3000,http://127.0.0.1:3000

# ── Managed mode options ─────────────────────────────────────────────────────
# Provider for the supervised daemon: mock, openrouter, or toolhive. When
# unset the controller probes for a local ToolHive LLM gateway and otherwise
# falls back to the offline mock provider. Credentials are never entered in
# Studio: mecated reads them from ~/.config/mecatl/auth.yaml.
# MECATL_STUDIO_PROVIDER=

# Set to 1 to allow an MCP gateway URL on loopback HTTP (HTTPS is otherwise
# required).
# MECATL_ALLOW_INSECURE_LOOPBACK_MCP=

# ── Branding (optional) ──────────────────────────────────────────────────────
# Display name used as the logo's alt text.
# BRAND_NAME=

# URL of a custom logo image, proxied server-side by /brand/logo.
# BRAND_LOGO_URL=

# URL of a custom favicon (ICO, PNG, SVG, JPEG).
# FAVICON_URL=
7 changes: 7 additions & 0 deletions studio/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@
"node": ">=22.13.0"
},
"scripts": {
"dev": "node scripts/dev-local.mjs",
"dev:web": "next dev",
"mecatl": "node scripts/local-controller.mjs",
"build": "next build",
"start": "node scripts/dev-local.mjs --production",
"lint": "biome check",
"format": "biome format --write",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"knip": "knip",
"dev": "next dev"
"test:server": "npm run build && node --test tests/rendered-html.test.mjs"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
Expand Down Expand Up @@ -44,6 +47,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.72.1",
"server-only": "^0.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "3.5.0"
},
Expand Down
82 changes: 82 additions & 0 deletions studio/scripts/dev-local.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { spawn } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const controllerScript = resolve(root, "scripts/local-controller.mjs");
const next = resolve(root, "node_modules/.bin/next");
const production = process.argv.includes("--production");
const externalMode = Boolean(process.env.MECATL_BASE_URL?.trim());

let stopping = false;
let controller = null;
let web = null;
let controllerRestart = null;
let webRestart = null;

const delayRestart = (callback) => setTimeout(callback, 750);

async function controllerIsHealthy() {
try {
const response = await fetch("http://127.0.0.1:8788/status", {
signal: AbortSignal.timeout(1_500),
});
return response.ok;
} catch {
return false;
}
}

async function ensureController() {
if (externalMode || stopping || controller || (await controllerIsHealthy()))
return;
controller = spawn(process.execPath, [controllerScript], {
cwd: root,
env: process.env,
stdio: "inherit",
});
controller.once("exit", () => {
controller = null;
if (!stopping)
controllerRestart = delayRestart(() => {
controllerRestart = null;
void ensureController();
});
});
}

function startWeb() {
if (stopping || web) return;
web = spawn(next, production ? ["start"] : ["dev"], {
cwd: root,
env: process.env,
stdio: "inherit",
});
web.once("exit", () => {
web = null;
if (!stopping)
webRestart = delayRestart(() => {
webRestart = null;
startWeb();
});
});
}

async function shutdown() {
if (stopping) return;
stopping = true;
if (controllerRestart) clearTimeout(controllerRestart);
if (webRestart) clearTimeout(webRestart);
clearInterval(healthCheck);
controller?.kill("SIGTERM");
web?.kill("SIGTERM");
setTimeout(() => process.exit(0), 2_000).unref();
}

for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, shutdown);

await ensureController();
startWeb();
const healthCheck = setInterval(() => {
void ensureController();
}, 3_000);
Loading
Loading