@norskvideo/ctl-sdk 0.1.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.
Files changed (69) hide show
  1. package/base.css +53 -0
  2. package/browser.d.ts +8 -0
  3. package/browser.js +11 -0
  4. package/capabilities-router.d.ts +9 -0
  5. package/capabilities-router.js +15 -0
  6. package/cjs-interop.d.ts +33 -0
  7. package/cjs-interop.js +61 -0
  8. package/components/ProductIframe.d.ts +37 -0
  9. package/components/ProductIframe.js +119 -0
  10. package/components/ProductTemplateBuildForm.d.ts +40 -0
  11. package/components/ProductTemplateBuildForm.js +81 -0
  12. package/components/index.d.ts +3 -0
  13. package/components/index.js +3 -0
  14. package/components/ui-primitives.d.ts +22 -0
  15. package/components/ui-primitives.js +13 -0
  16. package/dev-url.d.ts +1 -0
  17. package/dev-url.js +14 -0
  18. package/docker-runner.d.ts +20 -0
  19. package/docker-runner.js +54 -0
  20. package/fonts/Geist-LICENSE.txt +92 -0
  21. package/fonts/Geist.woff2 +0 -0
  22. package/fonts/GeistMono.woff2 +0 -0
  23. package/fonts/README.md +21 -0
  24. package/fonts/STUDIO-FONT-SYNC.md +86 -0
  25. package/index.d.ts +15 -0
  26. package/index.js +17 -0
  27. package/license-registration.d.ts +52 -0
  28. package/license-registration.js +38 -0
  29. package/license-stager.d.ts +30 -0
  30. package/license-stager.js +118 -0
  31. package/license-v2.d.ts +107 -0
  32. package/license-v2.js +205 -0
  33. package/manifest-fetch.d.ts +29 -0
  34. package/manifest-fetch.js +100 -0
  35. package/manifest-router.d.ts +11 -0
  36. package/manifest-router.js +16 -0
  37. package/manifest-schema.d.ts +113 -0
  38. package/manifest-schema.js +135 -0
  39. package/manifest-seed.d.ts +28 -0
  40. package/manifest-seed.js +40 -0
  41. package/openapi-router.d.ts +12 -0
  42. package/openapi-router.js +21 -0
  43. package/package.json +46 -0
  44. package/parsing.d.ts +9 -0
  45. package/parsing.js +83 -0
  46. package/product-error.d.ts +4 -0
  47. package/product-error.js +8 -0
  48. package/product-health-monitor.d.ts +72 -0
  49. package/product-health-monitor.js +136 -0
  50. package/product-service.d.ts +118 -0
  51. package/product-service.js +340 -0
  52. package/product-template-error.d.ts +10 -0
  53. package/product-template-error.js +14 -0
  54. package/product-template-materials.d.ts +17 -0
  55. package/product-template-materials.js +51 -0
  56. package/product-template-parsing.d.ts +14 -0
  57. package/product-template-parsing.js +66 -0
  58. package/product-template-record.d.ts +45 -0
  59. package/product-template-record.js +1 -0
  60. package/product-types.d.ts +31 -0
  61. package/product-types.js +1 -0
  62. package/proxy-middleware.d.ts +7 -0
  63. package/proxy-middleware.js +112 -0
  64. package/runtime.d.ts +2 -0
  65. package/runtime.js +21 -0
  66. package/validate.d.ts +3 -0
  67. package/validate.js +22 -0
  68. package/workflow.d.ts +60 -0
  69. package/workflow.js +57 -0
package/base.css ADDED
@@ -0,0 +1,53 @@
1
+ /*
2
+ * Base stylesheet shared by every product frontend. Products import this and
3
+ * state no font/theme/reset opinion of their own — so norsk-ctl, the product
4
+ * config UIs, and Studio all render identically.
5
+ *
6
+ * @import "@norskvideo/ctl-sdk/base.css";
7
+ *
8
+ * Geist Sans + Geist Mono (Vercel, SIL OFL 1.1 — see fonts/Geist-LICENSE.txt),
9
+ * vendored once here as single variable woff2 files so nothing pulls a font
10
+ * from a CDN and every surface renders the same glyphs. Norsk Studio must be
11
+ * moved onto the same Geist files to stay in sync (see fonts/README.md).
12
+ */
13
+ @import "tailwindcss";
14
+
15
+ @font-face {
16
+ font-family: "Geist";
17
+ font-style: normal;
18
+ font-weight: 100 900; /* variable range → no faux bold */
19
+ font-display: swap;
20
+ src: url("./fonts/Geist.woff2") format("woff2-variations"),
21
+ url("./fonts/Geist.woff2") format("woff2");
22
+ }
23
+
24
+ @font-face {
25
+ font-family: "Geist Mono";
26
+ font-style: normal;
27
+ font-weight: 100 900;
28
+ font-display: swap;
29
+ src: url("./fonts/GeistMono.woff2") format("woff2-variations"),
30
+ url("./fonts/GeistMono.woff2") format("woff2");
31
+ }
32
+
33
+ @theme {
34
+ --color-zinc-850: #1f1f23;
35
+ --font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
36
+ --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
37
+ }
38
+
39
+ * {
40
+ box-sizing: border-box;
41
+ }
42
+
43
+ ::-webkit-scrollbar {
44
+ width: 6px;
45
+ height: 6px;
46
+ }
47
+ ::-webkit-scrollbar-track {
48
+ background: #18181b;
49
+ }
50
+ ::-webkit-scrollbar-thumb {
51
+ background: #3f3f46;
52
+ border-radius: 3px;
53
+ }
package/browser.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from "./manifest-schema.js";
2
+ export * from "./parsing.js";
3
+ export * from "./product-error.js";
4
+ export * from "./product-template-error.js";
5
+ export * from "./product-template-parsing.js";
6
+ export * from "./product-template-record.js";
7
+ export * from "./product-types.js";
8
+ export * from "./validate.js";
package/browser.js ADDED
@@ -0,0 +1,11 @@
1
+ // Browser-safe surface: types, schemas, parsers, validators.
2
+ // Anything with Node-only deps (fs, child_process, express) lives in the
3
+ // default entry only.
4
+ export * from "./manifest-schema.js";
5
+ export * from "./parsing.js";
6
+ export * from "./product-error.js";
7
+ export * from "./product-template-error.js";
8
+ export * from "./product-template-parsing.js";
9
+ export * from "./product-template-record.js";
10
+ export * from "./product-types.js";
11
+ export * from "./validate.js";
@@ -0,0 +1,9 @@
1
+ import { Router } from "express";
2
+ /**
3
+ * Serves a product's self-describing capabilities at `GET /capabilities`,
4
+ * relative to wherever the router is mounted (products mount it under `/api`).
5
+ *
6
+ * Generic over `T` so each product's distinct capabilities type flows through
7
+ * unchanged — the factory carries no product knowledge and no flags.
8
+ */
9
+ export declare function createCapabilitiesRouter<T>(buildCapabilities: () => T): Router;
@@ -0,0 +1,15 @@
1
+ import { Router } from "express";
2
+ /**
3
+ * Serves a product's self-describing capabilities at `GET /capabilities`,
4
+ * relative to wherever the router is mounted (products mount it under `/api`).
5
+ *
6
+ * Generic over `T` so each product's distinct capabilities type flows through
7
+ * unchanged — the factory carries no product knowledge and no flags.
8
+ */
9
+ export function createCapabilitiesRouter(buildCapabilities) {
10
+ const router = Router();
11
+ router.get("/capabilities", (_req, res) => {
12
+ res.json(buildCapabilities());
13
+ });
14
+ return router;
15
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Interop for the TypeScript-compiled CommonJS modules the Norsk Studio
3
+ * libraries ship (`exports.default = fn` plus an `__esModule` marker).
4
+ *
5
+ * How such a module reaches an ESM importer depends on WHO loaded it, and the
6
+ * shapes are not interchangeable:
7
+ *
8
+ * - Left external and required at runtime by bun → `.default` is the function.
9
+ * - Bundled by `bun build` → the emitted helper is
10
+ * `__toESM(require_mod(), /* isNodeMode *\/ 1)`, and that helper reads
11
+ * `isNodeMode || !mod || !mod.__esModule` — short-circuiting on isNodeMode
12
+ * before it ever tests `__esModule`. So `default` is set to the entire
13
+ * `module.exports`, and the real function sits at `.default.default`.
14
+ *
15
+ * A plain `import fn from "..."` therefore works or breaks purely on whether
16
+ * the importing package's build marked the library external — an invisible
17
+ * coupling between a bundler flag and a runtime call. Probe's product-template
18
+ * generation hit exactly this: `shared`'s build externalises the studio libs,
19
+ * `backend`'s does not, so the same source worked in one bundle and threw
20
+ * "import_info.default is not a function" in the other.
21
+ *
22
+ * Route every studio-library default import through here and the call site
23
+ * stops caring how it was built.
24
+ */
25
+ /**
26
+ * Extract the callable default export of a CommonJS-compiled module, whatever
27
+ * interop shape it arrived in.
28
+ *
29
+ * @param mod The imported module namespace, or the module itself.
30
+ * @param specifier What was imported — used in the error, so a failure names the
31
+ * module instead of a mangled bundler local like `import_info`.
32
+ */
33
+ export declare function callableDefault<T extends (...args: never[]) => unknown>(mod: unknown, specifier: string): T;
package/cjs-interop.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Interop for the TypeScript-compiled CommonJS modules the Norsk Studio
3
+ * libraries ship (`exports.default = fn` plus an `__esModule` marker).
4
+ *
5
+ * How such a module reaches an ESM importer depends on WHO loaded it, and the
6
+ * shapes are not interchangeable:
7
+ *
8
+ * - Left external and required at runtime by bun → `.default` is the function.
9
+ * - Bundled by `bun build` → the emitted helper is
10
+ * `__toESM(require_mod(), /* isNodeMode *\/ 1)`, and that helper reads
11
+ * `isNodeMode || !mod || !mod.__esModule` — short-circuiting on isNodeMode
12
+ * before it ever tests `__esModule`. So `default` is set to the entire
13
+ * `module.exports`, and the real function sits at `.default.default`.
14
+ *
15
+ * A plain `import fn from "..."` therefore works or breaks purely on whether
16
+ * the importing package's build marked the library external — an invisible
17
+ * coupling between a bundler flag and a runtime call. Probe's product-template
18
+ * generation hit exactly this: `shared`'s build externalises the studio libs,
19
+ * `backend`'s does not, so the same source worked in one bundle and threw
20
+ * "import_info.default is not a function" in the other.
21
+ *
22
+ * Route every studio-library default import through here and the call site
23
+ * stops caring how it was built.
24
+ */
25
+ /** How many `.default` levels to unwrap before giving up (guards cycles). */
26
+ const MAX_UNWRAP = 4;
27
+ /**
28
+ * Extract the callable default export of a CommonJS-compiled module, whatever
29
+ * interop shape it arrived in.
30
+ *
31
+ * @param mod The imported module namespace, or the module itself.
32
+ * @param specifier What was imported — used in the error, so a failure names the
33
+ * module instead of a mangled bundler local like `import_info`.
34
+ */
35
+ export function callableDefault(mod, specifier) {
36
+ let candidate = mod;
37
+ for (let depth = 0; depth <= MAX_UNWRAP; depth++) {
38
+ if (typeof candidate === "function")
39
+ return candidate;
40
+ if (candidate === null || typeof candidate !== "object")
41
+ break;
42
+ const next = candidate.default;
43
+ if (next === undefined || next === candidate)
44
+ break;
45
+ candidate = next;
46
+ }
47
+ throw new Error(`'${specifier}' has no callable default export (found ${describe(mod)}). ` +
48
+ "A CommonJS module bundled by bun exposes module.exports as `default`, so the function " +
49
+ "can sit one level deeper — use callableDefault() rather than a plain default import.");
50
+ }
51
+ /** Describe what actually arrived, shallowly — enough to debug, no dumping. */
52
+ function describe(value) {
53
+ if (value === null)
54
+ return "null";
55
+ if (value === undefined)
56
+ return "undefined";
57
+ if (typeof value !== "object")
58
+ return typeof value;
59
+ const keys = Object.keys(value).slice(0, 8);
60
+ return `an object with keys [${keys.join(", ")}]`;
61
+ }
@@ -0,0 +1,37 @@
1
+ export type SubmitResult = {
2
+ ok: true;
3
+ formValues: Record<string, unknown>;
4
+ } | {
5
+ ok: false;
6
+ error?: string;
7
+ };
8
+ export type ProductIframeHandle = {
9
+ submit: () => Promise<SubmitResult>;
10
+ };
11
+ export declare const ProductIframe: import("react").ForwardRefExoticComponent<{
12
+ productName: string;
13
+ configScreenUrl: string;
14
+ /**
15
+ * Fires when the iframe emits a `validity` postMessage. Parent forms
16
+ * use this to disable Save / Save & Launch while the form is invalid.
17
+ * Default before any validity message arrives is treat-as-valid —
18
+ * products that don't emit validity shouldn't be blocked.
19
+ */
20
+ onValidityChange?: (valid: boolean, errors: string[]) => void;
21
+ /**
22
+ * Fires when the iframe emits a `suggest-name` postMessage — a product
23
+ * template name derived from the product's current choices (e.g. template +
24
+ * release). The runner uses it as placeholder/prefill for its own
25
+ * product-template-name field while that field is untouched. Products that
26
+ * don't emit it leave the field's default behaviour unchanged.
27
+ */
28
+ onSuggestName?: (name: string) => void;
29
+ /**
30
+ * Optional product-template/launch context forwarded verbatim in the `init`
31
+ * payload (as `launchContext`). Used by the instance-launch config screen
32
+ * (not the template-configure screen) so the product can prefill against
33
+ * the product template being launched — e.g. its parameters and current
34
+ * defaults.
35
+ */
36
+ launchContext?: Record<string, unknown>;
37
+ } & import("react").RefAttributes<ProductIframeHandle>>;
@@ -0,0 +1,119 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
3
+ const PROTOCOL_VERSION = 1;
4
+ const SUBMIT_TIMEOUT_MS = 30_000;
5
+ function resolveSrc(productName, configScreenUrl) {
6
+ const path = configScreenUrl.startsWith("/") ? configScreenUrl : `/${configScreenUrl}`;
7
+ return `/products/${encodeURIComponent(productName)}${path}`;
8
+ }
9
+ export const ProductIframe = forwardRef(function ProductIframe({ productName, configScreenUrl, onValidityChange, onSuggestName, launchContext }, ref) {
10
+ const iframeRef = useRef(null);
11
+ const [height, setHeight] = useState(420);
12
+ const [valid, setValid] = useState(true);
13
+ const [errors, setErrors] = useState(null);
14
+ const submitResolverRef = useRef(null);
15
+ // Ref-stored callback so the message listener (mount-only useEffect) can
16
+ // always reach the latest one without re-binding.
17
+ const onValidityChangeRef = useRef(onValidityChange);
18
+ useEffect(() => {
19
+ onValidityChangeRef.current = onValidityChange;
20
+ }, [onValidityChange]);
21
+ const onSuggestNameRef = useRef(onSuggestName);
22
+ useEffect(() => {
23
+ onSuggestNameRef.current = onSuggestName;
24
+ }, [onSuggestName]);
25
+ const src = resolveSrc(productName, configScreenUrl);
26
+ useEffect(() => {
27
+ const onMessage = (event) => {
28
+ if (event.source !== iframeRef.current?.contentWindow)
29
+ return;
30
+ const data = event.data;
31
+ if (!data || typeof data !== "object")
32
+ return;
33
+ if (data.v !== PROTOCOL_VERSION)
34
+ return;
35
+ const payload = data.payload ?? {};
36
+ switch (data.type) {
37
+ case "ready":
38
+ case "resize":
39
+ if (typeof payload.height === "number" && payload.height > 0)
40
+ setHeight(payload.height);
41
+ break;
42
+ case "validity": {
43
+ const v = typeof payload.valid === "boolean" ? payload.valid : true;
44
+ const errs = Array.isArray(payload.errors) ? payload.errors : [];
45
+ setValid(v);
46
+ setErrors(errs.length ? errs : null);
47
+ onValidityChangeRef.current?.(v, errs);
48
+ break;
49
+ }
50
+ case "suggest-name": {
51
+ if (typeof payload.name === "string" && payload.name)
52
+ onSuggestNameRef.current?.(payload.name);
53
+ break;
54
+ }
55
+ case "submit-result": {
56
+ const resolve = submitResolverRef.current;
57
+ submitResolverRef.current = null;
58
+ if (!resolve)
59
+ break;
60
+ // New shape: { ok: true, formValues } / { ok: false, error }.
61
+ // Old shape (option-a): { ok: true, productTemplate, ... }. If we see
62
+ // the old shape, fall through with an empty formValues so the
63
+ // runner-side error message is the one the user sees.
64
+ if (payload?.ok === true && payload.formValues && typeof payload.formValues === "object") {
65
+ resolve({ ok: true, formValues: payload.formValues });
66
+ }
67
+ else if (payload?.ok === true) {
68
+ console.warn("[ProductIframe] product emitted legacy submit-result without `formValues`; " +
69
+ "update the product to send `{ ok: true, formValues: {...} }`");
70
+ resolve({ ok: false, error: "product returned legacy submit-result shape (no formValues)" });
71
+ }
72
+ else {
73
+ resolve({ ok: false, error: typeof payload?.error === "string" ? payload.error : undefined });
74
+ }
75
+ break;
76
+ }
77
+ // dirty / request-cancel / navigate intentionally ignored in v1
78
+ }
79
+ };
80
+ window.addEventListener("message", onMessage);
81
+ return () => window.removeEventListener("message", onMessage);
82
+ }, []);
83
+ // Path prefix where the product is mounted on the runner's origin. The
84
+ // product's router (e.g. React Router's `basename`) needs this to resolve
85
+ // routes correctly when iframed; standalone mode can ignore it.
86
+ const basename = `/products/${encodeURIComponent(productName)}`;
87
+ // Ref so the mount-time onLoad closure always sends the latest context.
88
+ const launchContextRef = useRef(launchContext);
89
+ launchContextRef.current = launchContext;
90
+ const sendInit = useCallback(() => {
91
+ iframeRef.current?.contentWindow?.postMessage({
92
+ v: PROTOCOL_VERSION,
93
+ type: "init",
94
+ payload: {
95
+ instanceContext: { basename },
96
+ theme: "dark",
97
+ locale: "en",
98
+ ...(launchContextRef.current ? { launchContext: launchContextRef.current } : {}),
99
+ },
100
+ }, "*");
101
+ }, [basename]);
102
+ useImperativeHandle(ref, () => ({
103
+ submit: () => new Promise((resolve) => {
104
+ if (!iframeRef.current?.contentWindow) {
105
+ resolve({ ok: false, error: "Iframe not loaded" });
106
+ return;
107
+ }
108
+ submitResolverRef.current = resolve;
109
+ iframeRef.current.contentWindow.postMessage({ v: PROTOCOL_VERSION, type: "submit", payload: {} }, "*");
110
+ setTimeout(() => {
111
+ if (submitResolverRef.current === resolve) {
112
+ submitResolverRef.current = null;
113
+ resolve({ ok: false, error: `Iframe did not respond within ${SUBMIT_TIMEOUT_MS / 1000}s` });
114
+ }
115
+ }, SUBMIT_TIMEOUT_MS);
116
+ }),
117
+ }));
118
+ return (_jsxs("div", { className: "rounded border border-zinc-700 bg-zinc-950 overflow-hidden", children: [_jsx("iframe", { ref: iframeRef, src: src, title: `${productName} configuration`, onLoad: sendInit, className: "w-full block border-0", style: { height } }), !valid && (_jsx("p", { className: "text-xs text-amber-400 px-3 py-1.5 border-t border-zinc-800", children: errors?.length ? errors.join("; ") : "Form has validation errors." }))] }));
119
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Configure-iframe form. Produces a *product template*, NOT a running instance.
3
+ * Two submit paths:
4
+ * - "Save" — caller persists the product template, then we close.
5
+ * - "Save & Launch…" — caller persists the product template and is expected to
6
+ * hand off to its launch flow.
7
+ *
8
+ * No runner-side mode toggle here: the iframe is pure product UX. Caller
9
+ * supplies the actual "save" implementation via `onSubmit`, so this works
10
+ * for both the runner (POST /api/product-templates/build → store on disk) and the
11
+ * manager (POST /api/instances → submit to the manager-daemon as
12
+ * ProductTemplateRef.inline_tar).
13
+ */
14
+ export type ProductTemplateBuildRequest = {
15
+ productName: string;
16
+ productTemplateName: string;
17
+ formValues: Record<string, unknown>;
18
+ };
19
+ export type ProductTemplateBuildOutcome<TProductTemplate> = {
20
+ ok: true;
21
+ productTemplate: TProductTemplate;
22
+ } | {
23
+ ok: false;
24
+ error: string;
25
+ };
26
+ export declare function ProductTemplateBuildForm<TProductTemplate>({ productName, configScreenUrl, onClose, onSubmit, onSaved, onSavedAndLaunch, }: {
27
+ productName: string;
28
+ configScreenUrl: string | null;
29
+ onClose: () => void;
30
+ /**
31
+ * Persists the product template. Implementation owns the wire protocol —
32
+ * runner stores on disk; manager submits to manager-daemon. Returns the
33
+ * resulting product-template record (consumer-defined shape) on success.
34
+ */
35
+ onSubmit: (req: ProductTemplateBuildRequest) => Promise<ProductTemplateBuildOutcome<TProductTemplate>>;
36
+ /** Save-only: product template persisted, form closes. */
37
+ onSaved?: (productTemplate: TProductTemplate) => void;
38
+ /** Save & continue: caller transitions to its launch flow. */
39
+ onSavedAndLaunch?: (productTemplate: TProductTemplate) => void;
40
+ }): import("react").JSX.Element;
@@ -0,0 +1,81 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { X } from "lucide-react";
3
+ import { useRef, useState } from "react";
4
+ import toast from "react-hot-toast";
5
+ import { validateInstanceId } from "../validate.js";
6
+ import { ProductIframe } from "./ProductIframe.js";
7
+ import { useUIPrimitives } from "./ui-primitives.js";
8
+ export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose, onSubmit, onSaved, onSavedAndLaunch, }) {
9
+ const { Button, Input, Label } = useUIPrimitives();
10
+ const [productTemplateName, setProductTemplateName] = useState("");
11
+ // The iframe can suggest a name derived from its current choices (#317).
12
+ // We prefill the field with it while the operator hasn't typed anything,
13
+ // so changing template/release keeps the suggestion live; once they edit
14
+ // the field, their value wins and we stop syncing.
15
+ const [suggestedName, setSuggestedName] = useState("");
16
+ const [nameTouched, setNameTouched] = useState(false);
17
+ const [submitting, setSubmitting] = useState(false);
18
+ // Treat-as-valid until the iframe says otherwise (per the v1 contract:
19
+ // products that don't care about validation aren't required to emit).
20
+ const [iframeValid, setIframeValid] = useState(true);
21
+ const iframeRef = useRef(null);
22
+ const handleSuggestName = (name) => {
23
+ setSuggestedName(name);
24
+ if (!nameTouched)
25
+ setProductTemplateName(name);
26
+ };
27
+ const nameValidation = productTemplateName ? validateInstanceId(productTemplateName) : null;
28
+ const productTemplateNameError = nameValidation?.status === "error" ? nameValidation.message : null;
29
+ const canSubmit = !!productTemplateName && !productTemplateNameError && !submitting && iframeValid;
30
+ // Internal helper. Returns the saved product template on success (so the
31
+ // caller can hand it off to its launch flow without waiting for a refetch),
32
+ // null on failure.
33
+ const saveProductTemplate = async () => {
34
+ if (!iframeRef.current) {
35
+ toast.error("Configuration not ready");
36
+ return null;
37
+ }
38
+ const result = await iframeRef.current.submit();
39
+ if (!result.ok) {
40
+ toast.error(result.error ?? "Configuration submission failed");
41
+ return null;
42
+ }
43
+ const outcome = await onSubmit({ productName, productTemplateName, formValues: result.formValues });
44
+ if (!outcome.ok) {
45
+ toast.error(`Save product template failed: ${outcome.error}`);
46
+ return null;
47
+ }
48
+ return outcome.productTemplate;
49
+ };
50
+ const handleSave = async (e) => {
51
+ e.preventDefault();
52
+ setSubmitting(true);
53
+ try {
54
+ const pt = await saveProductTemplate();
55
+ if (!pt)
56
+ return;
57
+ toast.success(`Product template '${productTemplateName}' saved`);
58
+ onSaved?.(pt);
59
+ onClose();
60
+ }
61
+ finally {
62
+ setSubmitting(false);
63
+ }
64
+ };
65
+ const handleSaveAndLaunch = async () => {
66
+ setSubmitting(true);
67
+ try {
68
+ const pt = await saveProductTemplate();
69
+ if (!pt)
70
+ return;
71
+ onSavedAndLaunch?.(pt);
72
+ }
73
+ finally {
74
+ setSubmitting(false);
75
+ }
76
+ };
77
+ return (_jsxs("div", { className: "mb-6 rounded-lg border border-zinc-700 bg-zinc-900 p-4", children: [_jsxs("div", { className: "flex items-center justify-between mb-4", children: [_jsxs("div", { children: [_jsxs("h4", { className: "text-sm font-semibold text-zinc-100", children: ["Create ", productName, " product template"] }), _jsx("p", { className: "text-xs text-zinc-500 mt-0.5", children: "Saves a reusable product template. Launch instances from it later." })] }), _jsx("button", { type: "button", onClick: onClose, className: "text-zinc-500 hover:text-zinc-300", children: _jsx(X, { size: 14 }) })] }), _jsxs("form", { onSubmit: handleSave, className: "space-y-4", children: [_jsxs("div", { children: [_jsx(Label, { children: "Product template name *" }), _jsx(Input, { value: productTemplateName, onChange: (e) => {
78
+ setNameTouched(true);
79
+ setProductTemplateName(e.target.value);
80
+ }, placeholder: suggestedName || `e.g. ${productName}-prod`, required: true }), productTemplateNameError ? (_jsx("p", { className: "text-xs text-red-400 mt-1", children: productTemplateNameError })) : (_jsx("p", { className: "text-xs text-zinc-500 mt-1", children: "Lowercase letters, numbers, and hyphens only. Used to launch instances later." }))] }), configScreenUrl ? (_jsx(ProductIframe, { ref: iframeRef, productName: productName, configScreenUrl: configScreenUrl, onValidityChange: (v) => setIframeValid(v), onSuggestName: handleSuggestName })) : (_jsxs("p", { className: "text-xs text-zinc-500", children: ["Product manifest does not declare ", _jsx("code", { children: "ui.configScreenUrl" }), " \u2014 nothing to configure."] })), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "primary", disabled: !canSubmit, onClick: handleSaveAndLaunch, children: submitting ? "Working…" : "Save & Launch…" }), _jsx(Button, { type: "submit", variant: "default", disabled: !canSubmit, children: "Save" }), _jsx(Button, { type: "button", variant: "ghost", onClick: onClose, children: "Cancel" })] })] })] }));
81
+ }
@@ -0,0 +1,3 @@
1
+ export { ProductIframe, type ProductIframeHandle, type SubmitResult } from "./ProductIframe.js";
2
+ export { ProductTemplateBuildForm, type ProductTemplateBuildOutcome, type ProductTemplateBuildRequest, } from "./ProductTemplateBuildForm.js";
3
+ export { type ButtonProps, type ButtonSize, type ButtonVariant, type InputProps, type LabelProps, type UIPrimitives, UIPrimitivesProvider, useUIPrimitives, } from "./ui-primitives.js";
@@ -0,0 +1,3 @@
1
+ export { ProductIframe } from "./ProductIframe.js";
2
+ export { ProductTemplateBuildForm, } from "./ProductTemplateBuildForm.js";
3
+ export { UIPrimitivesProvider, useUIPrimitives, } from "./ui-primitives.js";
@@ -0,0 +1,22 @@
1
+ import { type ButtonHTMLAttributes, type ComponentType, type InputHTMLAttributes, type ReactNode } from "react";
2
+ export type ButtonVariant = "default" | "ghost" | "danger" | "primary";
3
+ export type ButtonSize = "xs" | "sm" | "md";
4
+ export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
5
+ variant?: ButtonVariant;
6
+ size?: ButtonSize;
7
+ };
8
+ export type InputProps = InputHTMLAttributes<HTMLInputElement>;
9
+ export type LabelProps = {
10
+ children: ReactNode;
11
+ className?: string;
12
+ };
13
+ export interface UIPrimitives {
14
+ Button: ComponentType<ButtonProps>;
15
+ Input: ComponentType<InputProps>;
16
+ Label: ComponentType<LabelProps>;
17
+ }
18
+ export declare function UIPrimitivesProvider({ value, children }: {
19
+ value: UIPrimitives;
20
+ children: ReactNode;
21
+ }): import("react").JSX.Element;
22
+ export declare function useUIPrimitives(): UIPrimitives;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, } from "react";
3
+ const UIPrimitivesContext = createContext(null);
4
+ export function UIPrimitivesProvider({ value, children }) {
5
+ return _jsx(UIPrimitivesContext.Provider, { value: value, children: children });
6
+ }
7
+ export function useUIPrimitives() {
8
+ const ctx = useContext(UIPrimitivesContext);
9
+ if (!ctx) {
10
+ throw new Error("useUIPrimitives: no UIPrimitivesProvider in tree. Wrap your app root with <UIPrimitivesProvider value={...}>.");
11
+ }
12
+ return ctx;
13
+ }
package/dev-url.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function validateDevUrl(url: string): URL;
package/dev-url.js ADDED
@@ -0,0 +1,14 @@
1
+ import { ProductError } from "./product-error.js";
2
+ export function validateDevUrl(url) {
3
+ let parsed;
4
+ try {
5
+ parsed = new URL(url);
6
+ }
7
+ catch {
8
+ throw new ProductError("DEV_URL_INVALID", `dev URL is not a valid URL: ${url}`);
9
+ }
10
+ if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
11
+ throw new ProductError("DEV_URL_NOT_LOCALHOST", `dev URL must be localhost or 127.0.0.1 (v1), got ${parsed.hostname}`);
12
+ }
13
+ return parsed;
14
+ }
@@ -0,0 +1,20 @@
1
+ /** Role label stamped on every product control-plane container. The daemon's
2
+ * infrastructure view discovers managed singletons by the presence of a
3
+ * `norsk-ctl.role` label (proxy, proxy-oauth2, cpu-monitor, product); the
4
+ * value doubles as the card's component name. */
5
+ export declare const PRODUCT_ROLE_LABEL = "norsk-ctl.role=product";
6
+ /** argv (sans leading "docker") that launches a product container: detached,
7
+ * auto-removed, loopback-published to its internal 4321, and role-labelled so
8
+ * it shows up in the Infrastructure tab. */
9
+ export declare function productRunArgs(image: string, hostPort: number): string[];
10
+ /** Stable container name for a product, derived from its manifest name, so
11
+ * `docker ps` shows `norsk-product-studio` instead of a random docker alias.
12
+ * Mirrors the singleton naming of norsk-proxy / norsk-ctl-cpu-monitor. */
13
+ export declare function productContainerName(productName: string): string;
14
+ export declare function dockerRun(image: string, hostPort: number): Promise<string>;
15
+ export declare function dockerRm(containerId: string): Promise<void>;
16
+ /** Best-effort rename of a running container. The product is tracked by
17
+ * container id, never by name, so a rename failure (e.g. a stale container
18
+ * already holding the name) is non-fatal — the product still works, it just
19
+ * keeps its random docker alias. */
20
+ export declare function dockerRename(containerId: string, name: string): Promise<void>;
@@ -0,0 +1,54 @@
1
+ import { ProductError } from "./product-error.js";
2
+ const CONTAINER_INTERNAL_PORT = 4321;
3
+ /** Role label stamped on every product control-plane container. The daemon's
4
+ * infrastructure view discovers managed singletons by the presence of a
5
+ * `norsk-ctl.role` label (proxy, proxy-oauth2, cpu-monitor, product); the
6
+ * value doubles as the card's component name. */
7
+ export const PRODUCT_ROLE_LABEL = "norsk-ctl.role=product";
8
+ /** argv (sans leading "docker") that launches a product container: detached,
9
+ * auto-removed, loopback-published to its internal 4321, and role-labelled so
10
+ * it shows up in the Infrastructure tab. */
11
+ export function productRunArgs(image, hostPort) {
12
+ return [
13
+ "run",
14
+ "-d",
15
+ "--rm",
16
+ "--label",
17
+ PRODUCT_ROLE_LABEL,
18
+ "-p",
19
+ `127.0.0.1:${hostPort}:${CONTAINER_INTERNAL_PORT}`,
20
+ image,
21
+ ];
22
+ }
23
+ /** Stable container name for a product, derived from its manifest name, so
24
+ * `docker ps` shows `norsk-product-studio` instead of a random docker alias.
25
+ * Mirrors the singleton naming of norsk-proxy / norsk-ctl-cpu-monitor. */
26
+ export function productContainerName(productName) {
27
+ const slug = productName
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]+/g, "-")
30
+ .replace(/^-+|-+$/g, "");
31
+ return `norsk-product-${slug || "unnamed"}`;
32
+ }
33
+ export async function dockerRun(image, hostPort) {
34
+ const proc = Bun.spawn(["docker", ...productRunArgs(image, hostPort)], { stdout: "pipe", stderr: "pipe" });
35
+ const exitCode = await proc.exited;
36
+ const stdout = (await new Response(proc.stdout).text()).trim();
37
+ const stderr = (await new Response(proc.stderr).text()).trim();
38
+ if (exitCode !== 0 || !stdout) {
39
+ throw new ProductError("DOCKER_RUN_FAILED", `docker run failed (exit ${exitCode}): ${stderr || "no stderr"}`);
40
+ }
41
+ return stdout;
42
+ }
43
+ export async function dockerRm(containerId) {
44
+ const proc = Bun.spawn(["docker", "rm", "-f", containerId], { stdout: "pipe", stderr: "pipe" });
45
+ await proc.exited;
46
+ }
47
+ /** Best-effort rename of a running container. The product is tracked by
48
+ * container id, never by name, so a rename failure (e.g. a stale container
49
+ * already holding the name) is non-fatal — the product still works, it just
50
+ * keeps its random docker alias. */
51
+ export async function dockerRename(containerId, name) {
52
+ const proc = Bun.spawn(["docker", "rename", containerId, name], { stdout: "pipe", stderr: "pipe" });
53
+ await proc.exited;
54
+ }