@kaupang/studio 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  startStudio
3
- } from "./chunk-RTEE4IVR.js";
3
+ } from "./chunk-PSLFVQY4.js";
4
4
  export {
5
5
  startStudio
6
6
  };
package/dist/serve.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  startStudio
4
- } from "./chunk-RTEE4IVR.js";
4
+ } from "./chunk-PSLFVQY4.js";
5
5
 
6
6
  // src/serve.ts
7
7
  var { url } = await startStudio({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaupang/studio",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "kaupang studio — a small web UI to browse a catalog and assemble environments + solutions, then export a kaupang config. Launch with `kaupang studio` or run the Docker image.",
5
5
  "license": "MIT",
6
6
  "author": "Andreas Quist Batista",
@@ -30,12 +30,27 @@
30
30
  "dist"
31
31
  ],
32
32
  "scripts": {
33
- "build": "tsup",
34
- "typecheck": "tsc --noEmit",
33
+ "build": "npm run build:web && tsup",
34
+ "build:web": "vite build web",
35
+ "dev": "vite web",
36
+ "typecheck": "tsc --noEmit && tsc -p web/tsconfig.json",
35
37
  "prepublishOnly": "npm run build"
36
38
  },
37
39
  "dependencies": {
38
- "@kaupang/core": "^0.2.0"
40
+ "@kaupang/core": "^0.3.0"
41
+ },
42
+ "devDependencies": {
43
+ "@tailwindcss/vite": "^4.3.1",
44
+ "@tanstack/react-virtual": "^3.14.3",
45
+ "@types/react": "^19.2.17",
46
+ "@types/react-dom": "^19.2.3",
47
+ "@vitejs/plugin-react": "^6.0.2",
48
+ "@xyflow/react": "^12.11.0",
49
+ "react": "^19.2.7",
50
+ "react-dom": "^19.2.7",
51
+ "tailwindcss": "^4.3.1",
52
+ "vite": "^8.0.16",
53
+ "vite-plugin-singlefile": "^2.3.3"
39
54
  },
40
55
  "engines": {
41
56
  "node": ">=18"
@@ -1,80 +0,0 @@
1
- // src/server.ts
2
- import { createServer } from "node:http";
3
- import { spawn } from "node:child_process";
4
- import { createCatalogResolver } from "@kaupang/core/internal";
5
-
6
- // src/index.html
7
- var src_default = '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>kaupang studio</title>\n <style>\n :root {\n --bg: #0f1419; --panel: #171d26; --panel2: #1f2733; --line: #2a3340;\n --fg: #e6edf3; --dim: #93a1b0; --accent: #4cc2ff; --good: #3fb950; --bad: #f85149;\n }\n * { box-sizing: border-box; }\n body { margin: 0; background: var(--bg); color: var(--fg);\n font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }\n header { padding: 18px 24px; border-bottom: 1px solid var(--line); display: flex; align-items: baseline; gap: 12px; }\n header h1 { font-size: 18px; margin: 0; }\n header .sub { color: var(--dim); font-size: 13px; }\n main { max-width: 1100px; margin: 0 auto; padding: 24px; display: grid; gap: 20px; }\n .step { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px 20px; }\n .step > h2 { margin: 0 0 14px; font-size: 14px; letter-spacing: .02em; }\n .step > h2 .n { color: var(--bg); background: var(--accent); border-radius: 50%; width: 22px; height: 22px;\n display: inline-flex; align-items: center; justify-content: center; font-size: 12px; margin-right: 8px; font-weight: 700; }\n label { display: block; color: var(--dim); font-size: 12px; margin: 8px 0 3px; }\n input, select, textarea { width: 100%; background: var(--panel2); color: var(--fg); border: 1px solid var(--line);\n border-radius: 7px; padding: 7px 9px; font: inherit; }\n textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }\n .row { display: flex; gap: 10px; flex-wrap: wrap; }\n .row > * { flex: 1; min-width: 120px; }\n button { background: var(--accent); color: #04121c; border: 0; border-radius: 7px; padding: 8px 13px;\n font: inherit; font-weight: 600; cursor: pointer; }\n button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }\n button.danger { background: transparent; color: var(--bad); border: 1px solid var(--line); padding: 4px 9px; }\n button.sm { padding: 4px 9px; font-size: 12px; }\n .presets { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; margin-top: 12px; }\n .preset { background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; }\n .preset b { color: var(--accent); }\n .preset .meta { color: var(--dim); font-size: 12px; margin-top: 4px; word-break: break-all; }\n .env, .svc { border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin-top: 12px; background: var(--panel2); }\n .svc { background: var(--bg); margin-top: 10px; }\n .env > .head, .svc > .head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }\n .env > .head b, .svc > .head b { color: var(--accent); }\n .grow { flex: 1; }\n .muted { color: var(--dim); font-size: 12px; }\n .checks { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; }\n .checks label { display: inline-flex; align-items: center; gap: 6px; color: var(--fg); margin: 0; }\n .checks input { width: auto; }\n pre { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 12px;\n overflow: auto; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; margin: 6px 0 0; }\n .file { margin-top: 14px; }\n .file > .head { display: flex; align-items: center; gap: 10px; }\n .file > .head .name { font-family: ui-monospace, monospace; font-size: 12px; color: var(--accent); }\n .err { color: var(--bad); font-size: 12px; margin-top: 8px; }\n .hint { color: var(--dim); font-size: 12px; margin-top: 6px; }\n code { background: var(--panel2); padding: 1px 5px; border-radius: 4px; }\n </style>\n </head>\n <body>\n <header>\n <h1>\u2693 kaupang studio</h1>\n <span class="sub">browse a catalog \u2192 assemble environments \u2192 compose a solution \u2192 export a config</span>\n </header>\n <main id="app"></main>\n\n <script>\n const S = {\n source: "",\n presets: {}, // name -> spec\n loaded: false,\n error: "",\n project: "my-app",\n repo: "",\n envs: [], // { name, dependsOn, services: [{ name, preset, ports, env, dependsOn }] }\n solution: { name: "release", envs: [], target: "local" },\n };\n\n const $ = (sel, el = document) => el.querySelector(sel);\n const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", \'"\': "&quot;" }[c]));\n\n function sourceFor(s) {\n if (/^https?:\\/\\//.test(s)) return { type: "http", url: s };\n if (s.startsWith("oci://")) return { type: "oci", ref: s.slice(6) };\n return { type: "file", path: s };\n }\n function splitList(s) { return String(s || "").split(",").map((x) => x.trim()).filter(Boolean); }\n function parseEnv(s) {\n const out = {};\n for (const line of String(s || "").split("\\n")) {\n const t = line.trim(); if (!t) continue;\n const i = t.indexOf("="); if (i === -1) continue;\n out[t.slice(0, i).trim()] = t.slice(i + 1).trim();\n }\n return out;\n }\n\n async function loadCatalog() {\n S.error = "";\n try {\n const r = await fetch("/api/catalog?source=" + encodeURIComponent(S.source));\n const data = await r.json();\n if (!r.ok) throw new Error(data.error || "failed to load catalog");\n S.presets = data.services || {};\n S.loaded = true;\n } catch (e) { S.error = e.message; S.loaded = false; }\n render();\n }\n\n function buildFiles() {\n const files = {};\n for (const env of S.envs) {\n if (!env.name) continue;\n const services = {};\n for (const sv of env.services) {\n if (!sv.name || !sv.preset) continue;\n const overrides = {};\n const ports = splitList(sv.ports); if (ports.length) overrides.ports = ports;\n const e = parseEnv(sv.env); if (Object.keys(e).length) overrides.env = e;\n const dep = splitList(sv.dependsOn); if (dep.length) overrides.dependsOn = dep;\n services[sv.name] = Object.keys(overrides).length\n ? { $catalog: sv.preset, overrides }\n : { $catalog: sv.preset };\n }\n const body = {};\n const ed = splitList(env.dependsOn); if (ed.length) body.dependsOn = ed.length === 1 ? ed[0] : ed;\n body.services = services;\n files["environments/" + env.name + ".json"] = body;\n }\n const config = { environments: "./environments", project: S.project || "app" };\n if (S.repo) config.dockerRepository = S.repo;\n if (S.source) config.catalog = { sources: [sourceFor(S.source)] };\n const sol = S.solution;\n if (sol.name && sol.envs.length) {\n const recipe = { environments: sol.envs };\n if (sol.target && sol.target !== "local") recipe.target = sol.target;\n config.solutions = { [sol.name]: recipe };\n }\n files["kaupang.config.json"] = config;\n return files;\n }\n\n function download(name, text) {\n const a = document.createElement("a");\n a.href = URL.createObjectURL(new Blob([text], { type: "application/json" }));\n a.download = name.replace(/\\//g, "_");\n a.click();\n URL.revokeObjectURL(a.href);\n }\n\n function render() {\n const presetNames = Object.keys(S.presets);\n const app = $("#app");\n app.innerHTML = `\n <section class="step">\n <h2><span class="n">1</span>Catalog</h2>\n <label>Catalog source \u2014 a file path, http(s) URL, or <code>oci://ref</code></label>\n <div class="row">\n <input id="src" class="grow" placeholder="./catalog.json" value="${esc(S.source)}" />\n <button data-act="load" style="flex:0 0 auto">Load</button>\n </div>\n ${S.error ? `<div class="err">${esc(S.error)}</div>` : ""}\n ${S.loaded ? `<div class="presets">${presetNames.map((n) => {\n const p = S.presets[n] || {};\n return `<div class="preset"><b>${esc(n)}</b><div class="meta">${esc(p.image || p.build || "\u2014")}${\n p.ports ? "<br>ports: " + esc((p.ports || []).join(", ")) : ""}</div></div>`;\n }).join("") || `<span class="muted">no presets in this catalog</span>`}</div>` : ""}\n </section>\n\n <section class="step">\n <h2><span class="n">2</span>Project &amp; environments</h2>\n <div class="row">\n <div><label>Project</label><input data-bind="project" value="${esc(S.project)}" /></div>\n <div><label>Docker repository (optional, prefixes bare image names)</label><input data-bind="repo" placeholder="ghcr.io/acme" value="${esc(S.repo)}" /></div>\n </div>\n ${S.envs.map((env, ei) => `\n <div class="env">\n <div class="head">\n <b>environment</b>\n <input class="grow" data-env="${ei}" data-field="name" placeholder="env name (e.g. market)" value="${esc(env.name)}" />\n <button class="danger sm" data-act="rmEnv" data-env="${ei}">remove</button>\n </div>\n <label>depends on (other env names, comma-separated)</label>\n <input data-env="${ei}" data-field="dependsOn" placeholder="saga, runes" value="${esc(env.dependsOn || "")}" />\n ${env.services.map((sv, si) => `\n <div class="svc">\n <div class="head">\n <b>service</b>\n <input class="grow" data-env="${ei}" data-svc="${si}" data-field="name" placeholder="service name" value="${esc(sv.name)}" />\n <span class="muted">from ${esc(sv.preset)}</span>\n <button class="danger sm" data-act="rmSvc" data-env="${ei}" data-svc="${si}">\xD7</button>\n </div>\n <div class="row">\n <div><label>ports override (comma)</label><input data-env="${ei}" data-svc="${si}" data-field="ports" placeholder="8080:3000" value="${esc(sv.ports || "")}" /></div>\n <div><label>depends on (services, comma)</label><input data-env="${ei}" data-svc="${si}" data-field="dependsOn" placeholder="migrate" value="${esc(sv.dependsOn || "")}" /></div>\n </div>\n <label>env override (one KEY=VALUE per line)</label>\n <textarea rows="2" data-env="${ei}" data-svc="${si}" data-field="env" placeholder="LOG_LEVEL=info">${esc(sv.env || "")}</textarea>\n </div>`).join("")}\n <div class="row" style="margin-top:10px">\n <select data-add-svc="${ei}">\n <option value="">+ add service from preset\u2026</option>\n ${presetNames.map((n) => `<option value="${esc(n)}">${esc(n)}</option>`).join("")}\n </select>\n </div>\n </div>`).join("")}\n <div style="margin-top:14px"><button class="ghost" data-act="addEnv">+ add environment</button></div>\n ${!S.loaded ? `<p class="hint">Load a catalog above to add services from presets.</p>` : ""}\n </section>\n\n <section class="step">\n <h2><span class="n">3</span>Solution</h2>\n <div class="row">\n <div><label>Solution name</label><input data-bind="solName" value="${esc(S.solution.name)}" /></div>\n <div><label>Default target (optional)</label><input data-bind="solTarget" placeholder="local" value="${esc(S.solution.target)}" /></div>\n </div>\n <label>Environments in this solution</label>\n <div class="checks">\n ${S.envs.filter((e) => e.name).map((e) => `\n <label><input type="checkbox" data-sol-env="${esc(e.name)}" ${S.solution.envs.includes(e.name) ? "checked" : ""} /> ${esc(e.name)}</label>`).join("")\n || `<span class="muted">add an environment first</span>`}\n </div>\n </section>\n\n <section class="step">\n <h2><span class="n">4</span>Export</h2>\n <p class="hint">Drop these files in a folder and run <code>kaupang up ${esc(S.solution.name || "<env>")}</code>. JSON configs need no Node.</p>\n <div id="export"></div>\n </section>`;\n renderExport();\n }\n\n function renderExport() {\n const files = buildFiles();\n const wrap = $("#export");\n if (!wrap) return;\n wrap.innerHTML = Object.entries(files).map(([name, obj]) => {\n const json = JSON.stringify(obj, null, 2);\n return `<div class="file"><div class="head"><span class="name">${esc(name)}</span>\n <button class="ghost sm" data-dl="${esc(name)}">download</button></div>\n <pre>${esc(json)}</pre></div>`;\n }).join("");\n wrap.dataset.files = JSON.stringify(files);\n }\n\n // ---- event wiring (delegation) ----\n document.addEventListener("click", (ev) => {\n const t = ev.target.closest("[data-act],[data-dl]");\n if (!t) return;\n const act = t.dataset.act;\n if (act === "load") { S.source = $("#src").value.trim(); loadCatalog(); }\n else if (act === "addEnv") { S.envs.push({ name: "", dependsOn: "", services: [] }); render(); }\n else if (act === "rmEnv") { S.envs.splice(+t.dataset.env, 1); render(); }\n else if (act === "rmSvc") { S.envs[+t.dataset.env].services.splice(+t.dataset.svc, 1); render(); }\n else if (t.dataset.dl) {\n const files = JSON.parse($("#export").dataset.files || "{}");\n download(t.dataset.dl, JSON.stringify(files[t.dataset.dl], null, 2));\n }\n });\n\n document.addEventListener("change", (ev) => {\n const t = ev.target;\n if (t.dataset.addSvc !== undefined && t.value) {\n S.envs[+t.dataset.addSvc].services.push({ name: t.value, preset: t.value, ports: "", env: "", dependsOn: "" });\n render();\n } else if (t.dataset.solEnv !== undefined) {\n const name = t.dataset.solEnv;\n const set = new Set(S.solution.envs);\n t.checked ? set.add(name) : set.delete(name);\n S.solution.envs = [...set];\n renderExport();\n }\n });\n\n document.addEventListener("input", (ev) => {\n const t = ev.target;\n if (t.dataset.bind) {\n const m = { project: "project", repo: "repo", solName: ["solution", "name"], solTarget: ["solution", "target"] }[t.dataset.bind];\n if (Array.isArray(m)) S[m[0]][m[1]] = t.value; else S[m] = t.value;\n renderExport();\n } else if (t.dataset.env !== undefined) {\n const env = S.envs[+t.dataset.env];\n if (t.dataset.svc !== undefined) env.services[+t.dataset.svc][t.dataset.field] = t.value;\n else env[t.dataset.field] = t.value;\n renderExport();\n }\n });\n\n // load any default catalog the server was started with\n fetch("/api/defaults").then((r) => r.json()).then((d) => {\n if (d.catalog) { S.source = d.catalog; render(); loadCatalog(); } else { render(); }\n }).catch(render);\n </script>\n </body>\n</html>\n';
8
-
9
- // src/ui.ts
10
- var INDEX_HTML = src_default;
11
-
12
- // src/server.ts
13
- function sourceFor(catalog) {
14
- if (/^https?:\/\//.test(catalog)) return { type: "http", url: catalog };
15
- if (catalog.startsWith("oci://")) return { type: "oci", ref: catalog.slice("oci://".length) };
16
- return { type: "file", path: catalog };
17
- }
18
- async function readCatalog(catalog, rootDir) {
19
- const resolver = createCatalogResolver({ sources: [sourceFor(catalog)] }, rootDir);
20
- const services = {};
21
- for (const name of await resolver.list()) services[name] = await resolver.resolve(name);
22
- const solutions = await resolver.listSolutions().catch(() => []);
23
- return { services, solutions };
24
- }
25
- async function startStudio(opts = {}) {
26
- const port = opts.port ?? 8080;
27
- const host = opts.host ?? "127.0.0.1";
28
- const rootDir = opts.rootDir ?? process.cwd();
29
- const server = createServer(async (req, res) => {
30
- try {
31
- const url2 = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
32
- if (url2.pathname === "/" || url2.pathname === "/index.html") {
33
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
34
- res.end(INDEX_HTML);
35
- return;
36
- }
37
- if (url2.pathname === "/api/catalog") {
38
- const catalog = url2.searchParams.get("source") || opts.catalog || "";
39
- if (!catalog) {
40
- res.writeHead(400, { "content-type": "application/json" });
41
- res.end(JSON.stringify({ error: "No catalog source. Enter a file path, http(s) URL, or oci://ref." }));
42
- return;
43
- }
44
- const data = await readCatalog(catalog, rootDir);
45
- res.writeHead(200, { "content-type": "application/json" });
46
- res.end(JSON.stringify(data));
47
- return;
48
- }
49
- if (url2.pathname === "/api/defaults") {
50
- res.writeHead(200, { "content-type": "application/json" });
51
- res.end(JSON.stringify({ catalog: opts.catalog ?? "" }));
52
- return;
53
- }
54
- res.writeHead(404, { "content-type": "text/plain" });
55
- res.end("not found");
56
- } catch (err) {
57
- res.writeHead(500, { "content-type": "application/json" });
58
- res.end(JSON.stringify({ error: err.message }));
59
- }
60
- });
61
- await new Promise((resolve, reject) => {
62
- server.once("error", reject);
63
- server.listen(port, host, resolve);
64
- });
65
- const url = `http://${host === "0.0.0.0" ? "localhost" : host}:${port}`;
66
- if (opts.open) openBrowser(url);
67
- return { url, close: () => server.close() };
68
- }
69
- function openBrowser(url) {
70
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
71
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
72
- try {
73
- spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
74
- } catch {
75
- }
76
- }
77
-
78
- export {
79
- startStudio
80
- };