@norskvideo/ctl-dev-kit 0.2.91 → 0.2.92

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.
@@ -5,12 +5,16 @@
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 { createDashboard } from "../dashboard/create-dashboard.ts";
9
+ import { writeDashboardManifest } from "../dashboard/manifest.ts";
8
10
  import { createProduct, type ProductFeature, type ProductShape, type StudioLibrary } from "./create-product.ts";
9
11
 
10
12
  const SHAPES: ProductShape[] = ["backend-turnkey", "turnkey"];
11
13
 
12
14
  const USAGE = `Usage:
13
15
  ctl-dev-kit create-product --name <name> [options] <dir>
16
+ ctl-dev-kit create-dashboard --name <name> <dir> a fleet dashboard norsk-mgr hosts (a live runtime list)
17
+ ctl-dev-kit dashboard-manifest <src> <dist> write <dist>/dashboard.json from a fleet dashboard's calls
14
18
 
15
19
  Options:
16
20
  --shape <shape> Repo shape — a preset over the features below (default: backend-turnkey)
@@ -45,8 +49,18 @@ function fail(message: string): never {
45
49
 
46
50
  export function main(argv: string[]): void {
47
51
  const [command, ...rest] = argv;
52
+ if (command === "dashboard-manifest") {
53
+ dashboardManifest(rest);
54
+ return;
55
+ }
56
+ if (command === "create-dashboard") {
57
+ newDashboard(rest);
58
+ return;
59
+ }
48
60
  if (command !== "create-product") {
49
- fail(`unknown command ${JSON.stringify(command ?? "")} — the only command is create-product.`);
61
+ fail(
62
+ `unknown command ${JSON.stringify(command ?? "")} — the commands are create-product, create-dashboard and dashboard-manifest.`,
63
+ );
50
64
  }
51
65
  const { values, positionals } = parseArgs({
52
66
  args: rest,
@@ -123,3 +137,38 @@ Next steps (also in the generated CLAUDE.md and README):
123
137
  if (import.meta.main) {
124
138
  main(process.argv.slice(2));
125
139
  }
140
+
141
+ function dashboardManifest(args: string[]): void {
142
+ const [src, dist] = args;
143
+ if (!src || !dist) fail("dashboard-manifest needs <src> and <dist>.");
144
+ const written = writeDashboardManifest(src, dist);
145
+ if (written.status === "error") {
146
+ console.error(
147
+ `dashboard.json not written: the build cannot read what this dashboard uses.\n ${written.error.join("\n ")}`,
148
+ );
149
+ process.exit(1);
150
+ }
151
+ const kinds = written.value.kinds;
152
+ console.log(`${dist}/dashboard.json: uses ${kinds.length > 0 ? kinds.join(", ") : "no kinds"}`);
153
+ }
154
+
155
+ function newDashboard(args: string[]): void {
156
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { name: { type: "string" } } });
157
+ const dir = positionals[0];
158
+ if (!dir) fail("missing <dir>.");
159
+ if (!values.name) fail("missing --name.");
160
+ if (existsSync(dir) && readdirSync(dir).length > 0) fail(`${dir} exists and is not empty — refusing to overwrite.`);
161
+ let files: string[];
162
+ try {
163
+ ({ files } = createDashboard({ dir, name: values.name }));
164
+ } catch (err) {
165
+ fail(err instanceof Error ? err.message : String(err));
166
+ }
167
+ console.log(`Generated ${files.length} files into ${dir}.
168
+
169
+ Next steps (also in its README):
170
+ 1. cd ${dir} && bun install
171
+ 2. bun run build # dist/dashboard.json and dashboard.tar
172
+ 3. norsk-mgr dashboard register ${values.name} && norsk-mgr dashboard upload ${values.name} dashboard.tar
173
+ `);
174
+ }
@@ -0,0 +1,198 @@
1
+ // `ctl-dev-kit create-dashboard --name <name> <dir>` (task 122 K): a fleet
2
+ // dashboard, a static bundle norsk-mgr hosts on a host of its own, a shared
3
+ // one or a pattern's (RFC 0006 §4.4). The starter is a live list of the
4
+ // runtimes the signed-in user may see, the smallest real consumer of the
5
+ // directory.
6
+
7
+ import { mkdirSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+
10
+ const LABEL = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
11
+
12
+ export interface CreateDashboardOptions {
13
+ dir: string;
14
+ /** The dashboard's name: its registration's, and its host's when it has
15
+ * one of its own. */
16
+ name: string;
17
+ }
18
+
19
+ function files(name: string): Record<string, string> {
20
+ return {
21
+ "package.json": `${JSON.stringify(
22
+ {
23
+ name,
24
+ private: true,
25
+ type: "module",
26
+ scripts: {
27
+ dev: "vite",
28
+ build: "vite build && ctl-dev-kit dashboard-manifest src dist && tar -C dist -cf dashboard.tar .",
29
+ typecheck: "tsc --noEmit",
30
+ },
31
+ dependencies: { "@norskvideo/ctl-sdk": "^0.1.1" },
32
+ devDependencies: { "@norskvideo/ctl-dev-kit": "^0.2.0", typescript: "^5.9.0", vite: "^8.0.0" },
33
+ },
34
+ null,
35
+ 2,
36
+ )}\n`,
37
+ "tsconfig.json": `${JSON.stringify(
38
+ {
39
+ compilerOptions: {
40
+ target: "ES2022",
41
+ module: "ESNext",
42
+ moduleResolution: "Bundler",
43
+ lib: ["ES2022", "DOM"],
44
+ strict: true,
45
+ noEmit: true,
46
+ skipLibCheck: true,
47
+ },
48
+ include: ["src", "vite.config.ts"],
49
+ },
50
+ null,
51
+ 2,
52
+ )}\n`,
53
+ ".gitignore": "node_modules/\ndist/\ndashboard.tar\n",
54
+ "index.html": `<!doctype html>
55
+ <html lang="en">
56
+ <head>
57
+ <meta charset="utf-8" />
58
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
59
+ <title>${name}</title>
60
+ </head>
61
+ <body>
62
+ <h1>Runtimes</h1>
63
+ <p id="status">Connecting…</p>
64
+ <table>
65
+ <thead>
66
+ <tr><th>Runtime</th><th>Production or product</th><th>State</th><th>Node</th></tr>
67
+ </thead>
68
+ <tbody id="runtimes"></tbody>
69
+ </table>
70
+ <script type="module" src="./src/main.ts"></script>
71
+ </body>
72
+ </html>
73
+ `,
74
+ "src/main.ts": `// The runtimes the signed-in user may see, live. The platform decides who
75
+ // sees what; this page only shows it.
76
+ //
77
+ // Name the kinds a fleet call reads with literals: \`bun run build\` derives
78
+ // dashboard.json, the registration's list of what this dashboard uses, from
79
+ // them, and norsk-mgr serves it no wider than that list.
80
+ import { type FleetItem, fleet } from "@norskvideo/ctl-sdk/fleet";
81
+
82
+ const rows = document.getElementById("runtimes") as HTMLTableSectionElement;
83
+ const status = document.getElementById("status") as HTMLParagraphElement;
84
+
85
+ function cell(text: string): HTMLTableCellElement {
86
+ const td = document.createElement("td");
87
+ td.textContent = text;
88
+ return td;
89
+ }
90
+
91
+ function render(items: FleetItem[]): void {
92
+ rows.replaceChildren(
93
+ ...items.map((item) => {
94
+ const tr = document.createElement("tr");
95
+ const { runtime } = item;
96
+ tr.append(
97
+ cell(String(runtime.name ?? runtime.id)),
98
+ cell(String(runtime.production ?? runtime.product ?? "")),
99
+ cell(item.presence?.labels["norsk.state"] ?? "unknown"),
100
+ cell(String(runtime.node ?? "")),
101
+ );
102
+ return tr;
103
+ }),
104
+ );
105
+ status.textContent = items.length === 0 ? "No runtimes you may see." : \`\${items.length} runtime(s)\`;
106
+ }
107
+
108
+ fleet.watch(
109
+ { kind: "norsk.runtime" },
110
+ {
111
+ onChange: render,
112
+ onError: () => {
113
+ status.textContent = "Reconnecting…";
114
+ },
115
+ },
116
+ );
117
+ `,
118
+ "vite.config.ts": `import { defineConfig } from "vite";
119
+
120
+ // Relative URLs throughout: a shared host serves this dashboard under
121
+ // /<name>/, its own host at the root.
122
+ //
123
+ // \`bun run dev\` reads a directory elsewhere at the page's own /_fleet, as a
124
+ // dashboard host serves it: NORSK_DIRECTORY_URL is norsk-mgr's or norsk-ctl's
125
+ // base URL, and NORSK_TOKEN an API token for it.
126
+ const target = process.env.NORSK_DIRECTORY_URL;
127
+ const token = process.env.NORSK_TOKEN;
128
+
129
+ export default defineConfig({
130
+ base: "./",
131
+ server: target
132
+ ? {
133
+ proxy: {
134
+ "/_fleet": {
135
+ target,
136
+ changeOrigin: true,
137
+ secure: false,
138
+ rewrite: (path) => path.replace(/^\\/_fleet/, "/api/v1/fleet"),
139
+ ...(token ? { headers: { authorization: \`Bearer \${token}\` } } : {}),
140
+ },
141
+ },
142
+ }
143
+ : {},
144
+ });
145
+ `,
146
+ "README.md": `# ${name}
147
+
148
+ A fleet dashboard: a static bundle norsk-mgr serves on a host of its own, reading
149
+ the directory (the runtimes the signed-in user may see, with what discovery
150
+ holds under each) through \`@norskvideo/ctl-sdk/fleet\`.
151
+
152
+ ## Develop
153
+
154
+ \`\`\`sh
155
+ bun install
156
+ NORSK_DIRECTORY_URL=https://mgr.example.com NORSK_TOKEN=<an API token> bun run dev
157
+ \`\`\`
158
+
159
+ The dev server reads that norsk-mgr's (or norsk-ctl's) directory as the token's
160
+ holder sees it, at the page's own \`/_fleet\`, as the dashboard host will.
161
+
162
+ ## Build and register
163
+
164
+ \`\`\`sh
165
+ bun run build # dist/, dist/dashboard.json and dashboard.tar
166
+ norsk-mgr dashboard register ${name} # a host of its own: ${name}.<productUiDomain>
167
+ norsk-mgr dashboard upload ${name} dashboard.tar
168
+ \`\`\`
169
+
170
+ \`--hosting shared:<group>\` serves it under \`/${name}/\` on its group's host instead;
171
+ \`--hosting pattern:<prefix>-{id} --scope production\` on a host per production,
172
+ each answering only for its own. Uploading again replaces the bundle in place.
173
+
174
+ ## What it may read
175
+
176
+ \`dashboard.json\` lists the kinds the dashboard's fleet calls name, and the build
177
+ derives it: name kinds as literals (\`fleet.watch({ kind: "norsk.runtime" })\`), or
178
+ the build fails. The dashboard host answers no wider than that list.
179
+ `,
180
+ };
181
+ }
182
+
183
+ export function createDashboard(opts: CreateDashboardOptions): { files: string[] } {
184
+ if (!LABEL.test(opts.name) || opts.name.length > 63) {
185
+ throw new Error(
186
+ `the name \${JSON.stringify(opts.name)} must be one DNS label: lowercase letters, digits and '-', at most 63`,
187
+ );
188
+ }
189
+ if (opts.name.startsWith("t-") || opts.name.startsWith("p-")) {
190
+ throw new Error(`the name \${JSON.stringify(opts.name)} starts with t- or p-, which product UI hosts use`);
191
+ }
192
+ const out = files(opts.name);
193
+ for (const [path, content] of Object.entries(out)) {
194
+ mkdirSync(dirname(join(opts.dir, path)), { recursive: true });
195
+ writeFileSync(join(opts.dir, path), content);
196
+ }
197
+ return { files: Object.keys(out) };
198
+ }
@@ -0,0 +1,30 @@
1
+ // `ctl-dev-kit dashboard-manifest <src> <dist>`: a fleet dashboard's build
2
+ // step (task 122 K). Writes `<dist>/dashboard.json`, which norsk-mgr reads at
3
+ // upload for what the dashboard uses, from the calls in `<src>`.
4
+
5
+ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
+ import { join, relative } from "node:path";
7
+ import { dashboardUses, type UsesResult } from "./uses.ts";
8
+
9
+ const SOURCE = /\.(m?[jt]sx?)$/;
10
+ const TEST = /\.(test|spec)\.[^.]+$/;
11
+
12
+ function sources(dir: string): string[] {
13
+ const out: string[] = [];
14
+ for (const name of readdirSync(dir)) {
15
+ if (name === "node_modules") continue;
16
+ const full = join(dir, name);
17
+ if (statSync(full).isDirectory()) out.push(...sources(full));
18
+ else if (SOURCE.test(name) && !TEST.test(name)) out.push(full);
19
+ }
20
+ return out;
21
+ }
22
+
23
+ export function writeDashboardManifest(srcDir: string, distDir: string): UsesResult {
24
+ const files = sources(srcDir).map((path) => ({ path: relative(srcDir, path), source: readFileSync(path, "utf8") }));
25
+ const uses = dashboardUses(files);
26
+ if (uses.status === "error") return uses;
27
+ mkdirSync(distDir, { recursive: true });
28
+ writeFileSync(join(distDir, "dashboard.json"), `${JSON.stringify({ uses: uses.value }, null, 2)}\n`);
29
+ return uses;
30
+ }
@@ -0,0 +1,115 @@
1
+ // What a fleet dashboard uses, derived from its own code when it is built
2
+ // (RFC 0006 registration-names-what-it-uses, task 122 K): every kind its
3
+ // `fleet.query` and `fleet.watch` calls name, read as literals. A call the
4
+ // build cannot read, or any other use of the client, fails the build rather
5
+ // than leave `dashboard.json` short of what the code reads.
6
+
7
+ import ts from "typescript";
8
+
9
+ export const FLEET_MODULE = "@norskvideo/ctl-sdk/fleet";
10
+ const CALLS = new Set(["query", "watch"]);
11
+
12
+ export interface DashboardUses {
13
+ kinds: string[];
14
+ }
15
+
16
+ export type UsesResult = { status: "ok"; value: DashboardUses } | { status: "error"; error: string[] };
17
+
18
+ function literalKinds(node: ts.Expression): string[] | undefined {
19
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return [node.text];
20
+ if (!ts.isArrayLiteralExpression(node)) return undefined;
21
+ const kinds: string[] = [];
22
+ for (const element of node.elements) {
23
+ if (!ts.isStringLiteral(element) && !ts.isNoSubstitutionTemplateLiteral(element)) return undefined;
24
+ kinds.push(element.text);
25
+ }
26
+ return kinds;
27
+ }
28
+
29
+ function kindsOfSelection(arg: ts.Expression | undefined): string[] | undefined {
30
+ if (arg === undefined || !ts.isObjectLiteralExpression(arg)) return undefined;
31
+ for (const property of arg.properties) {
32
+ if (ts.isPropertyAssignment(property) && property.name.getText() === "kind") {
33
+ return literalKinds(property.initializer);
34
+ }
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ export function dashboardUses(files: readonly { path: string; source: string }[]): UsesResult {
40
+ const kinds = new Set<string>();
41
+ const problems: string[] = [];
42
+ for (const { path, source } of files) {
43
+ const sf = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true);
44
+ const where = (node: ts.Node) => `${path}:${sf.getLineAndCharacterOfPosition(node.getStart()).line + 1}`;
45
+ // The names the module's client is bound to here: the `fleet` export, and
46
+ // consts holding a `fleetClient(...)`.
47
+ const clients = new Set<string>();
48
+ const factories = new Set<string>();
49
+ for (const statement of sf.statements) {
50
+ if (!ts.isImportDeclaration(statement)) continue;
51
+ if (!ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== FLEET_MODULE) continue;
52
+ const named = statement.importClause?.namedBindings;
53
+ if (named === undefined) continue;
54
+ if (ts.isNamespaceImport(named)) {
55
+ problems.push(
56
+ `${where(named)}: import the fleet client by name (\`import { fleet }\`), so its calls can be read`,
57
+ );
58
+ continue;
59
+ }
60
+ for (const element of named.elements) {
61
+ const imported = (element.propertyName ?? element.name).text;
62
+ if (imported === "fleet") clients.add(element.name.text);
63
+ if (imported === "fleetClient") factories.add(element.name.text);
64
+ }
65
+ }
66
+ if (clients.size === 0 && factories.size === 0) continue;
67
+
68
+ const visit = (node: ts.Node): void => {
69
+ if (
70
+ ts.isVariableDeclaration(node) &&
71
+ ts.isIdentifier(node.name) &&
72
+ node.initializer !== undefined &&
73
+ ts.isCallExpression(node.initializer) &&
74
+ ts.isIdentifier(node.initializer.expression) &&
75
+ factories.has(node.initializer.expression.text)
76
+ ) {
77
+ clients.add(node.name.text);
78
+ for (const arg of node.initializer.arguments) visit(arg);
79
+ return;
80
+ }
81
+ if (
82
+ ts.isCallExpression(node) &&
83
+ ts.isPropertyAccessExpression(node.expression) &&
84
+ ts.isIdentifier(node.expression.expression) &&
85
+ clients.has(node.expression.expression.text) &&
86
+ CALLS.has(node.expression.name.text)
87
+ ) {
88
+ const found = kindsOfSelection(node.arguments[0]);
89
+ if (found === undefined) {
90
+ problems.push(
91
+ `${where(node)}: ${node.expression.getText()} must name its kind as a literal, or a list of literals, in the selection it is passed, so the dashboard's uses can be derived`,
92
+ );
93
+ } else {
94
+ for (const kind of found) kinds.add(kind);
95
+ }
96
+ for (const arg of node.arguments) visit(arg);
97
+ return;
98
+ }
99
+ if (ts.isIdentifier(node) && (clients.has(node.text) || factories.has(node.text))) {
100
+ const parent = node.parent;
101
+ const declaring = ts.isImportSpecifier(parent) || (ts.isVariableDeclaration(parent) && parent.name === node);
102
+ if (!declaring) {
103
+ problems.push(
104
+ `${where(node)}: ${node.text} is used other than by calling its query or watch, so the build cannot see what it reads`,
105
+ );
106
+ }
107
+ }
108
+ ts.forEachChild(node, visit);
109
+ };
110
+ ts.forEachChild(sf, visit);
111
+ }
112
+ return problems.length > 0
113
+ ? { status: "error", error: problems }
114
+ : { status: "ok", value: { kinds: [...kinds].sort() } };
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.2.91",
3
+ "version": "0.2.92",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",