@norskvideo/ctl-dev-kit 0.2.107 → 0.2.108
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/create-demo/create-demo.ts +191 -0
- package/create-demo/template/core/CLAUDE.md.tmpl +158 -0
- package/create-demo/template/core/README.md.tmpl +74 -0
- package/create-demo/template/core/ctl/install.sh +27 -0
- package/create-demo/template/core/ctl/lib.sh +146 -0
- package/create-demo/template/core/ctl/mode.sh +19 -0
- package/create-demo/template/core/ctl/push.sh +16 -0
- package/create-demo/template/core/ctl/site.example.env +30 -0
- package/create-demo/template/core/ctl/studio.example.env +12 -0
- package/create-demo/template/core/gitignore +18 -0
- package/create-demo/template/core/scripts/build.sh +30 -0
- package/create-demo/template/core/scripts/test-feed.sh +44 -0
- package/create-demo/template/dashboard/dashboard/index.html +13 -0
- package/create-demo/template/dashboard/dashboard/package.json +22 -0
- package/create-demo/template/dashboard/dashboard/src/App.tsx +61 -0
- package/create-demo/template/dashboard/dashboard/src/api/types.ts +38 -0
- package/create-demo/template/dashboard/dashboard/src/components/ImagePreview.tsx +23 -0
- package/create-demo/template/dashboard/dashboard/src/components/StatusPill.tsx +65 -0
- package/create-demo/template/dashboard/dashboard/src/hooks/useAlerts.ts +98 -0
- package/create-demo/template/dashboard/dashboard/src/hooks/useDiscovery.ts +88 -0
- package/create-demo/template/dashboard/dashboard/src/hooks/useEnv.ts +53 -0
- package/create-demo/template/dashboard/dashboard/src/hooks/useLiveComponent.ts +249 -0
- package/create-demo/template/dashboard/dashboard/src/lib/workflow.ts +11 -0
- package/create-demo/template/dashboard/dashboard/src/main.tsx +12 -0
- package/create-demo/template/dashboard/dashboard/src/styles.css +84 -0
- package/create-demo/template/dashboard/dashboard/src/vite-env.d.ts +1 -0
- package/create-demo/template/dashboard/dashboard/tsconfig.json +18 -0
- package/create-demo/template/dashboard/dashboard/vite.config.ts +35 -0
- package/create-product/cli.ts +39 -1
- package/create-product/features/components.ts +10 -1
- package/package.json +1 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import { useEffect, useReducer, useRef } from "react";
|
|
3
|
+
import type { EnvResponse } from "../api/types";
|
|
4
|
+
|
|
5
|
+
// Inbound WS frame shapes — server → client. Studio's public WS
|
|
6
|
+
// (`<wsBasePath>/<id>/ws`) emits exactly these.
|
|
7
|
+
type IncomingMessage =
|
|
8
|
+
| { type: "initial-state"; state: unknown }
|
|
9
|
+
| { type: "state-update"; state: unknown }
|
|
10
|
+
| { type: "event"; event: unknown }
|
|
11
|
+
| { type: "alert"; alert: unknown }
|
|
12
|
+
| { type: "error"; message: string };
|
|
13
|
+
|
|
14
|
+
interface LiveComponentState<S, C = unknown> {
|
|
15
|
+
state: S | null;
|
|
16
|
+
config: C | null;
|
|
17
|
+
connected: boolean;
|
|
18
|
+
hasInitialState: boolean;
|
|
19
|
+
lastError: string | null;
|
|
20
|
+
disconnected: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Action<S, C> =
|
|
24
|
+
| { type: "CONNECT" }
|
|
25
|
+
| { type: "DISCONNECT" }
|
|
26
|
+
| { type: "INIT"; state: S }
|
|
27
|
+
| { type: "UPDATE"; state: S }
|
|
28
|
+
| { type: "EVENT"; event: unknown }
|
|
29
|
+
| { type: "ALERT"; alert: unknown }
|
|
30
|
+
| { type: "ERROR"; message: string }
|
|
31
|
+
| { type: "CONFIG"; config: C };
|
|
32
|
+
|
|
33
|
+
function reducer<S, C>(state: LiveComponentState<S, C>, action: Action<S, C>): LiveComponentState<S, C> {
|
|
34
|
+
switch (action.type) {
|
|
35
|
+
case "CONNECT":
|
|
36
|
+
return { ...state, connected: true, disconnected: false, lastError: null };
|
|
37
|
+
case "DISCONNECT":
|
|
38
|
+
return { ...state, connected: false, disconnected: true };
|
|
39
|
+
case "INIT":
|
|
40
|
+
return { ...state, state: action.state, hasInitialState: true };
|
|
41
|
+
case "UPDATE":
|
|
42
|
+
return { ...state, state: action.state };
|
|
43
|
+
case "EVENT":
|
|
44
|
+
// Events go to the onEvent callback, not into this state.
|
|
45
|
+
return state;
|
|
46
|
+
case "ALERT":
|
|
47
|
+
return state;
|
|
48
|
+
case "ERROR":
|
|
49
|
+
return { ...state, lastError: action.message };
|
|
50
|
+
case "CONFIG":
|
|
51
|
+
return { ...state, config: action.config };
|
|
52
|
+
default:
|
|
53
|
+
return state;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface UseLiveComponentResult<S, C = unknown> {
|
|
58
|
+
state: S | null;
|
|
59
|
+
config: C | null;
|
|
60
|
+
connected: boolean;
|
|
61
|
+
hasInitialState: boolean;
|
|
62
|
+
lastError: string | null;
|
|
63
|
+
sendCommand: (cmd: unknown) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface UseLiveComponentOptions<C> {
|
|
67
|
+
componentId: string;
|
|
68
|
+
env: EnvResponse | null;
|
|
69
|
+
// Optional initial config to seed the reducer. The discovery payload does
|
|
70
|
+
// not carry config; callers may supply a known default (e.g. one known
|
|
71
|
+
// from the workflow YAML), or leave null and render a loading state until
|
|
72
|
+
// state arrives.
|
|
73
|
+
initialConfig?: C | null;
|
|
74
|
+
// Optional callback fired whenever an `event` WS frame arrives. Studio
|
|
75
|
+
// broadcasts many state transitions as events (see e.g. videoCompose's
|
|
76
|
+
// `layout-changed`, `resolved-layout-changed`) without necessarily
|
|
77
|
+
// following up with a `state-update` snapshot. Consumers that need those
|
|
78
|
+
// deltas to drive local rendering should implement `onEvent` to reduce
|
|
79
|
+
// events into their own state — the hook itself keeps `EVENT` as a
|
|
80
|
+
// no-op in the reducer so no policy about event shapes leaks in.
|
|
81
|
+
//
|
|
82
|
+
// The callback identity is captured in a ref, so passing a fresh function
|
|
83
|
+
// per render does NOT trigger a WS reconnect.
|
|
84
|
+
onEvent?: (evt: unknown) => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const RECONNECT_INITIAL_MS = 500;
|
|
88
|
+
const RECONNECT_MAX_MS = 10_000;
|
|
89
|
+
|
|
90
|
+
// Extracted for direct unit testing without a live WebSocket. Exported under
|
|
91
|
+
// a __-prefixed alias so consumers don't reach for it as public API.
|
|
92
|
+
export function __handleIncomingFrame<S>(
|
|
93
|
+
parsed: IncomingMessage,
|
|
94
|
+
dispatch: (action: Action<S, unknown>) => void,
|
|
95
|
+
onEventRef: { current: ((evt: unknown) => void) | null | undefined },
|
|
96
|
+
): void {
|
|
97
|
+
switch (parsed.type) {
|
|
98
|
+
case "initial-state":
|
|
99
|
+
dispatch({ type: "INIT", state: parsed.state as S });
|
|
100
|
+
break;
|
|
101
|
+
case "state-update":
|
|
102
|
+
dispatch({ type: "UPDATE", state: parsed.state as S });
|
|
103
|
+
break;
|
|
104
|
+
case "event":
|
|
105
|
+
dispatch({ type: "EVENT", event: parsed.event });
|
|
106
|
+
onEventRef.current?.(parsed.event);
|
|
107
|
+
break;
|
|
108
|
+
case "alert":
|
|
109
|
+
dispatch({ type: "ALERT", alert: parsed.alert });
|
|
110
|
+
break;
|
|
111
|
+
case "error":
|
|
112
|
+
dispatch({ type: "ERROR", message: parsed.message });
|
|
113
|
+
break;
|
|
114
|
+
default:
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function useLiveComponent<S, C = unknown>(opts: UseLiveComponentOptions<C>): UseLiveComponentResult<S, C> {
|
|
120
|
+
const { componentId, env, initialConfig = null, onEvent } = opts;
|
|
121
|
+
|
|
122
|
+
// Capture the callback in a ref so the WS `useEffect`'s dep array can stay
|
|
123
|
+
// `[componentId, env]` — otherwise every parent re-render with a fresh
|
|
124
|
+
// callback identity would tear down + reconnect the socket.
|
|
125
|
+
const onEventRef = useRef<UseLiveComponentOptions<C>["onEvent"]>(onEvent);
|
|
126
|
+
onEventRef.current = onEvent;
|
|
127
|
+
|
|
128
|
+
const [state, dispatch] = useReducer(reducer as React.Reducer<LiveComponentState<S, C>, Action<S, C>>, {
|
|
129
|
+
state: null,
|
|
130
|
+
config: initialConfig,
|
|
131
|
+
connected: false,
|
|
132
|
+
hasInitialState: false,
|
|
133
|
+
lastError: null,
|
|
134
|
+
disconnected: false,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
138
|
+
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
139
|
+
const reconnectDelayRef = useRef<number>(RECONNECT_INITIAL_MS);
|
|
140
|
+
|
|
141
|
+
// Refresh seeded config if caller changes the prop.
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (initialConfig) {
|
|
144
|
+
dispatch({ type: "CONFIG", config: initialConfig });
|
|
145
|
+
}
|
|
146
|
+
}, [initialConfig]);
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (!env) return;
|
|
150
|
+
let cancelled = false;
|
|
151
|
+
|
|
152
|
+
const connect = () => {
|
|
153
|
+
if (cancelled) return;
|
|
154
|
+
|
|
155
|
+
// Compose the absolute WS URL relative to window.location. `env.wsBasePath`
|
|
156
|
+
// is path-only (e.g. `/live`). Honour the page's protocol — `wss:` when
|
|
157
|
+
// served over HTTPS.
|
|
158
|
+
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
159
|
+
const url = `${proto}//${window.location.host}${env.wsBasePath}/${componentId}/ws`;
|
|
160
|
+
|
|
161
|
+
let ws: WebSocket;
|
|
162
|
+
try {
|
|
163
|
+
ws = new WebSocket(url);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
dispatch({ type: "ERROR", message: err instanceof Error ? err.message : String(err) });
|
|
166
|
+
scheduleReconnect();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
wsRef.current = ws;
|
|
170
|
+
|
|
171
|
+
ws.onopen = () => {
|
|
172
|
+
if (cancelled) return;
|
|
173
|
+
reconnectDelayRef.current = RECONNECT_INITIAL_MS;
|
|
174
|
+
dispatch({ type: "CONNECT" });
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
ws.onmessage = (event: MessageEvent) => {
|
|
178
|
+
if (cancelled) return;
|
|
179
|
+
let parsed: IncomingMessage;
|
|
180
|
+
try {
|
|
181
|
+
parsed = JSON.parse(typeof event.data === "string" ? event.data : "") as IncomingMessage;
|
|
182
|
+
} catch (err) {
|
|
183
|
+
dispatch({ type: "ERROR", message: `bad frame: ${err instanceof Error ? err.message : String(err)}` });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
__handleIncomingFrame<S>(parsed, dispatch as (a: Action<S, unknown>) => void, onEventRef);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
ws.onerror = () => {
|
|
191
|
+
// The error event surface is opaque; rely on onclose to drive
|
|
192
|
+
// reconnect.
|
|
193
|
+
dispatch({ type: "ERROR", message: "websocket error" });
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
ws.onclose = () => {
|
|
197
|
+
if (cancelled) return;
|
|
198
|
+
wsRef.current = null;
|
|
199
|
+
dispatch({ type: "DISCONNECT" });
|
|
200
|
+
scheduleReconnect();
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const scheduleReconnect = () => {
|
|
205
|
+
if (cancelled) return;
|
|
206
|
+
const delay = reconnectDelayRef.current;
|
|
207
|
+
reconnectDelayRef.current = Math.min(delay * 2, RECONNECT_MAX_MS);
|
|
208
|
+
reconnectTimerRef.current = setTimeout(() => {
|
|
209
|
+
connect();
|
|
210
|
+
}, delay);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
connect();
|
|
214
|
+
|
|
215
|
+
return () => {
|
|
216
|
+
cancelled = true;
|
|
217
|
+
if (reconnectTimerRef.current) {
|
|
218
|
+
clearTimeout(reconnectTimerRef.current);
|
|
219
|
+
reconnectTimerRef.current = null;
|
|
220
|
+
}
|
|
221
|
+
if (wsRef.current) {
|
|
222
|
+
try {
|
|
223
|
+
wsRef.current.close();
|
|
224
|
+
} catch {
|
|
225
|
+
// ignore
|
|
226
|
+
}
|
|
227
|
+
wsRef.current = null;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}, [componentId, env]);
|
|
231
|
+
|
|
232
|
+
const sendCommand = (cmd: unknown) => {
|
|
233
|
+
const ws = wsRef.current;
|
|
234
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
235
|
+
console.warn(`useLiveComponent[${componentId}]: dropping command — socket not open`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
ws.send(JSON.stringify({ type: "command", command: cmd }));
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
state: state.state,
|
|
243
|
+
config: state.config,
|
|
244
|
+
connected: state.connected,
|
|
245
|
+
hasInitialState: state.hasInitialState,
|
|
246
|
+
lastError: state.lastError,
|
|
247
|
+
sendCommand,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The one place the dashboard learns the workflow's shape. Component ids here
|
|
2
|
+
// must match workdir/studio-save-files/__DEMO__/__DEMO__.yaml; when the
|
|
3
|
+
// workflow changes, change it here too.
|
|
4
|
+
|
|
5
|
+
export const WORKFLOW = {
|
|
6
|
+
// input.srt-listener: the camera or encoder feeding the demo.
|
|
7
|
+
input: { id: "srt_in", label: "SRT in" },
|
|
8
|
+
|
|
9
|
+
// output.preview in image mode: a still of the program, refreshed.
|
|
10
|
+
preview: { id: "preview" },
|
|
11
|
+
} as const;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { App } from "./App";
|
|
4
|
+
import "./styles.css";
|
|
5
|
+
|
|
6
|
+
const root = document.getElementById("root");
|
|
7
|
+
if (!root) throw new Error("index.html has no #root");
|
|
8
|
+
createRoot(root).render(
|
|
9
|
+
<StrictMode>
|
|
10
|
+
<App />
|
|
11
|
+
</StrictMode>,
|
|
12
|
+
);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: dark;
|
|
3
|
+
font-family: system-ui, sans-serif;
|
|
4
|
+
background: #0b0d10;
|
|
5
|
+
color: #e5e7eb;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
body {
|
|
9
|
+
margin: 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.page {
|
|
13
|
+
max-width: 1100px;
|
|
14
|
+
margin: 0 auto;
|
|
15
|
+
padding: 24px 16px;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
h1 {
|
|
19
|
+
font-size: 20px;
|
|
20
|
+
margin: 0 0 16px;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
h2 {
|
|
24
|
+
font-size: 14px;
|
|
25
|
+
text-transform: uppercase;
|
|
26
|
+
letter-spacing: 0.05em;
|
|
27
|
+
color: #9ca3af;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.preview {
|
|
31
|
+
display: block;
|
|
32
|
+
width: 100%;
|
|
33
|
+
aspect-ratio: 16 / 9;
|
|
34
|
+
object-fit: contain;
|
|
35
|
+
background: #000;
|
|
36
|
+
border-radius: 6px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.preview-empty {
|
|
40
|
+
display: grid;
|
|
41
|
+
place-items: center;
|
|
42
|
+
color: #9ca3af;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.components {
|
|
46
|
+
list-style: none;
|
|
47
|
+
padding: 0;
|
|
48
|
+
margin: 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.components li {
|
|
52
|
+
padding: 8px 0;
|
|
53
|
+
border-bottom: 1px solid #1f2937;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.component-name {
|
|
57
|
+
font-weight: 600;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.component-type {
|
|
61
|
+
margin-left: 8px;
|
|
62
|
+
color: #6b7280;
|
|
63
|
+
font-size: 12px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.component-state {
|
|
67
|
+
margin: 6px 0 0;
|
|
68
|
+
font-size: 11px;
|
|
69
|
+
color: #9ca3af;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.component-state pre {
|
|
73
|
+
white-space: pre-wrap;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.status-pill {
|
|
77
|
+
display: inline-block;
|
|
78
|
+
margin-left: 8px;
|
|
79
|
+
padding: 2px 6px;
|
|
80
|
+
border-radius: 4px;
|
|
81
|
+
font-size: 10px;
|
|
82
|
+
text-transform: uppercase;
|
|
83
|
+
letter-spacing: 0.05em;
|
|
84
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "react-jsx",
|
|
15
|
+
"noEmit": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src"]
|
|
18
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
import { defineConfig } from "vite";
|
|
4
|
+
|
|
5
|
+
// Studio serves the build from studio-save-files/__DEMO__/dashboards/ at
|
|
6
|
+
// /dashboard/__DEMO__/, so asset paths are relative. One page per HTML entry
|
|
7
|
+
// under build.rollupOptions.input; add a page by adding an entry.
|
|
8
|
+
//
|
|
9
|
+
// `bun run dev` proxies Studio's routes to STUDIO_URL, so the pages can be
|
|
10
|
+
// worked on against a running workflow. It must be a Studio reachable without
|
|
11
|
+
// the sign-in proxy in front.
|
|
12
|
+
const studio = process.env.STUDIO_URL ?? "http://localhost:8000";
|
|
13
|
+
const toStudio = { target: studio, changeOrigin: true, secure: false, ws: true };
|
|
14
|
+
|
|
15
|
+
export default defineConfig({
|
|
16
|
+
base: "./",
|
|
17
|
+
plugins: [react()],
|
|
18
|
+
resolve: { dedupe: ["react", "react-dom"] },
|
|
19
|
+
server: {
|
|
20
|
+
port: 5175,
|
|
21
|
+
proxy: {
|
|
22
|
+
"/env": { ...toStudio, rewrite: () => "/dashboard/__DEMO__/env" },
|
|
23
|
+
"/live": toStudio,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
build: {
|
|
27
|
+
outDir: "dist",
|
|
28
|
+
emptyOutDir: true,
|
|
29
|
+
rollupOptions: {
|
|
30
|
+
input: {
|
|
31
|
+
index: resolve(__dirname, "index.html"),
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
});
|
package/create-product/cli.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// so the bin runs under bun (the fleet's only runtime).
|
|
6
6
|
import { existsSync, readdirSync } from "node:fs";
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
|
+
import { createDemo, type DemoFeature } from "../create-demo/create-demo.ts";
|
|
8
9
|
import { createDashboard } from "../dashboard/create-dashboard.ts";
|
|
9
10
|
import { writeDashboardManifest } from "../dashboard/manifest.ts";
|
|
10
11
|
import { createProduct, type ProductFeature, type ProductShape, type StudioLibrary } from "./create-product.ts";
|
|
@@ -13,6 +14,8 @@ const SHAPES: ProductShape[] = ["backend-turnkey", "turnkey"];
|
|
|
13
14
|
|
|
14
15
|
const USAGE = `Usage:
|
|
15
16
|
ctl-dev-kit create-product --name <name> [options] <dir>
|
|
17
|
+
ctl-dev-kit create-demo --name <name> [--with dashboard,components] [--user <user>] <dir>
|
|
18
|
+
a customer demo: a Studio working dir installed into norsk-ctl
|
|
16
19
|
ctl-dev-kit create-dashboard --name <name> <dir> a fleet dashboard norsk-mgr hosts (a live runtime list)
|
|
17
20
|
ctl-dev-kit dashboard-manifest <src> <dist> write <dist>/dashboard.json from a fleet dashboard's calls
|
|
18
21
|
|
|
@@ -57,9 +60,13 @@ export function main(argv: string[]): void {
|
|
|
57
60
|
newDashboard(rest);
|
|
58
61
|
return;
|
|
59
62
|
}
|
|
63
|
+
if (command === "create-demo") {
|
|
64
|
+
newDemo(rest);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
60
67
|
if (command !== "create-product") {
|
|
61
68
|
fail(
|
|
62
|
-
`unknown command ${JSON.stringify(command ?? "")} — the commands are create-product, create-dashboard and dashboard-manifest.`,
|
|
69
|
+
`unknown command ${JSON.stringify(command ?? "")} — the commands are create-product, create-demo, create-dashboard and dashboard-manifest.`,
|
|
63
70
|
);
|
|
64
71
|
}
|
|
65
72
|
const { values, positionals } = parseArgs({
|
|
@@ -172,3 +179,34 @@ Next steps (also in its README):
|
|
|
172
179
|
3. norsk-mgr dashboard register ${values.name} && norsk-mgr dashboard upload ${values.name} dashboard.tar
|
|
173
180
|
`);
|
|
174
181
|
}
|
|
182
|
+
|
|
183
|
+
function newDemo(args: string[]): void {
|
|
184
|
+
const { values, positionals } = parseArgs({
|
|
185
|
+
args,
|
|
186
|
+
allowPositionals: true,
|
|
187
|
+
options: { name: { type: "string" }, with: { type: "string" }, user: { type: "string" } },
|
|
188
|
+
});
|
|
189
|
+
const dir = positionals[0];
|
|
190
|
+
if (!dir) fail("missing <dir>.");
|
|
191
|
+
if (!values.name) fail("missing --name.");
|
|
192
|
+
if (existsSync(dir) && readdirSync(dir).length > 0) fail(`${dir} exists and is not empty — refusing to overwrite.`);
|
|
193
|
+
const withFeatures = values.with
|
|
194
|
+
?.split(",")
|
|
195
|
+
.map((f) => f.trim())
|
|
196
|
+
.filter(Boolean) as DemoFeature[] | undefined;
|
|
197
|
+
let files: string[];
|
|
198
|
+
try {
|
|
199
|
+
({ files } = createDemo({ dir, name: values.name, user: values.user ?? process.env.USER ?? "me", withFeatures }));
|
|
200
|
+
} catch (err) {
|
|
201
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
202
|
+
}
|
|
203
|
+
console.log(`Generated ${files.length} files into ${dir}.
|
|
204
|
+
|
|
205
|
+
Next steps (also in the generated CLAUDE.md):
|
|
206
|
+
1. cd ${dir} && git init
|
|
207
|
+
2. cp ctl/site.example.env ctl/site.env && cp ctl/studio.example.env ctl/studio.env # then fill them in
|
|
208
|
+
3. ctl/install.sh # build, stage the working dir, launch instance ${values.name} in norsk-ctl
|
|
209
|
+
4. scripts/test-feed.sh up # a test picture into the SRT input
|
|
210
|
+
5. Replace the starter workflow in workdir/studio-save-files/${values.name}/ and ctl/push.sh after each change.
|
|
211
|
+
`);
|
|
212
|
+
}
|
|
@@ -608,7 +608,10 @@ export function exampleSidecar(args: ExampleArgs): WorkflowComponent<ExampleConf
|
|
|
608
608
|
`;
|
|
609
609
|
}
|
|
610
610
|
|
|
611
|
-
|
|
611
|
+
/** The components/ workspace alone — its build chain and the example node,
|
|
612
|
+
* without the product glue componentsFiles adds. create-demo uses this: a demo
|
|
613
|
+
* has no backend or shared/, and its build copies lib/ into the working dir. */
|
|
614
|
+
export function componentsWorkspaceFiles(ctx: ShapeContext): GeneratedFile[] {
|
|
612
615
|
return [
|
|
613
616
|
{ path: "components/package.json", content: packageJson(ctx) },
|
|
614
617
|
{ path: "components/tsconfig.json", content: TSCONFIG },
|
|
@@ -621,6 +624,12 @@ export function componentsFiles(ctx: ShapeContext): GeneratedFile[] {
|
|
|
621
624
|
{ path: "components/src/library/example/types.source.yaml", content: EXAMPLE_TYPES_YAML },
|
|
622
625
|
{ path: "components/src/library/example/info.ts", content: exampleInfoTs(ctx) },
|
|
623
626
|
{ path: "components/src/library/example/runtime.ts", content: EXAMPLE_RUNTIME_TS },
|
|
627
|
+
];
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export function componentsFiles(ctx: ShapeContext): GeneratedFile[] {
|
|
631
|
+
return [
|
|
632
|
+
...componentsWorkspaceFiles(ctx),
|
|
624
633
|
{ path: "backend/src/lib/components.ts", content: BACKEND_LIB_COMPONENTS_TS },
|
|
625
634
|
{ path: "shared/src/workflow/example-config.ts", content: exampleConfigTs(ctx) },
|
|
626
635
|
];
|