@norskvideo/ctl-dev-kit 0.2.91 → 0.2.93
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/conventions/docs-site.md +12 -9
- package/conventions/publish-docs-site.yml +29 -8
- package/conventions/publish-docs.yml +12 -0
- package/create-product/cli.ts +50 -1
- package/dashboard/create-dashboard.ts +198 -0
- package/dashboard/manifest.ts +30 -0
- package/dashboard/uses.ts +115 -0
- package/package.json +1 -1
package/conventions/docs-site.md
CHANGED
|
@@ -176,12 +176,14 @@ ln -s ../../../../../docs/generated/<slug> website/src/content/docs/guides/<slug
|
|
|
176
176
|
```
|
|
177
177
|
|
|
178
178
|
`docs:site` runs the dev-kit's `docs/guide-stubs.ts stub` first, so the site
|
|
179
|
-
builds on a checkout that has never captured
|
|
180
|
-
`
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
179
|
+
builds on a checkout that has never captured. The captures come from the
|
|
180
|
+
product's nightly `publish-docs.yml`, which saves `docs/generated` to the
|
|
181
|
+
Actions cache after a passing engine tier; `publish-docs-site.yml` restores
|
|
182
|
+
the newest set before its build (and runs when `publish-docs` completes, so a
|
|
183
|
+
fresh capture publishes without waiting for a push). It then runs
|
|
184
|
+
`guide-stubs.ts check` and refuses to deploy while any guide is still a
|
|
185
|
+
placeholder: until the guide has been captured once, the publish stops rather
|
|
186
|
+
than shipping an empty page.
|
|
185
187
|
|
|
186
188
|
## The two builds
|
|
187
189
|
|
|
@@ -211,9 +213,10 @@ holds the copy byte-identical, and `bun run sync:drift` writes it — creating i
|
|
|
211
213
|
where the repo has a site and is not a turnkey. On every push to `main` (and on
|
|
212
214
|
demand) it installs `website/`, builds the public variant and deploys
|
|
213
215
|
`website/dist-public` to Cloudflare Pages with `wrangler`. It never cancels a
|
|
214
|
-
run in flight, because a cancelled deploy is a half-published site.
|
|
215
|
-
|
|
216
|
-
|
|
216
|
+
run in flight, because a cancelled deploy is a half-published site. Before the
|
|
217
|
+
build it restores the newest recorded-guide captures from the Actions cache
|
|
218
|
+
(`docs-captures-*`, saved by `publish-docs.yml`); a site with no recorded
|
|
219
|
+
guides restores nothing it reads, and builds from its committed prose.
|
|
217
220
|
|
|
218
221
|
Where it may exist is the gate's business (ADR-0012, and the documentation
|
|
219
222
|
plan): a repo with `website/astro.config.mjs` may carry it; a repo without a
|
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
#
|
|
5
5
|
# This is the SITE channel, not the bundle: publish-docs.yml / build-docs.yml
|
|
6
6
|
# regenerate the illustrated manual the product image serves at /docs, nightly,
|
|
7
|
-
# from a licensed Docker run. The site
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# refuses to deploy
|
|
7
|
+
# from a licensed Docker run. The site needs no licence and no Docker, and so
|
|
8
|
+
# rides every push (conventions/docs-site.md, "Two documentation channels").
|
|
9
|
+
# A site that also symlinks RECORDED guides (website/src/content/docs/guides/
|
|
10
|
+
# <slug> -> docs/generated/<slug>) publishes them from the captures that nightly
|
|
11
|
+
# run saves to the Actions cache: they are restored before the build, which
|
|
12
|
+
# stubs only a guide never captured, and the gate below refuses to deploy a
|
|
13
|
+
# stub. So the site also runs when publish-docs completes, to publish a fresh
|
|
14
|
+
# capture without waiting for the next push.
|
|
13
15
|
#
|
|
14
16
|
# This workflow is SINGLE-SOURCED in @norskvideo/ctl-dev-kit
|
|
15
17
|
# (conventions/publish-docs-site.yml) and copied verbatim: there is no per-repo
|
|
@@ -40,6 +42,11 @@ on:
|
|
|
40
42
|
push:
|
|
41
43
|
branches: [main]
|
|
42
44
|
workflow_dispatch:
|
|
45
|
+
# a fresh capture of the recorded guides (publish-docs.yml saves it)
|
|
46
|
+
workflow_run:
|
|
47
|
+
workflows: [publish-docs]
|
|
48
|
+
types: [completed]
|
|
49
|
+
branches: [main]
|
|
43
50
|
|
|
44
51
|
permissions:
|
|
45
52
|
contents: read
|
|
@@ -114,6 +121,19 @@ jobs:
|
|
|
114
121
|
with:
|
|
115
122
|
clean: true
|
|
116
123
|
|
|
124
|
+
# The recorded guides' captures, from the product's nightly capture run
|
|
125
|
+
# (publish-docs.yml saves docs/generated after a passing engine tier):
|
|
126
|
+
# the newest set, restored where docs:site:public expects it. None yet --
|
|
127
|
+
# a site that records no guides, or before the first capture -- restores
|
|
128
|
+
# nothing; the build stubs what is missing and the gate below refuses to
|
|
129
|
+
# deploy a stub.
|
|
130
|
+
- name: Restore the recorded guides' captures
|
|
131
|
+
uses: actions/cache/restore@v4
|
|
132
|
+
with:
|
|
133
|
+
path: docs/generated
|
|
134
|
+
key: docs-captures-${{ github.sha }}
|
|
135
|
+
restore-keys: docs-captures-
|
|
136
|
+
|
|
117
137
|
# website/ is a STANDALONE package -- its own bun.lock, outside the root
|
|
118
138
|
# workspaces -- so the repo-root install never covers it, and the build
|
|
119
139
|
# needs both: the root for @norskvideo/ctl-dev-kit (docs:site:public stubs
|
|
@@ -145,8 +165,9 @@ jobs:
|
|
|
145
165
|
# be replaced with "run the doc-guide locally", and nothing else would
|
|
146
166
|
# notice -- the build is green, the links resolve. --content reads the
|
|
147
167
|
# guides the site declares by symlink, so a site that declares none has
|
|
148
|
-
# nothing to refuse; one that does needs its captures
|
|
149
|
-
# until the
|
|
168
|
+
# nothing to refuse; one that does needs its captures, restored above,
|
|
169
|
+
# and until the nightly run has captured them this step says which pages
|
|
170
|
+
# are missing.
|
|
150
171
|
- name: Refuse to publish placeholder guides
|
|
151
172
|
run: |
|
|
152
173
|
nix develop .#build --command bash -c '
|
|
@@ -318,6 +318,18 @@ jobs:
|
|
|
318
318
|
if: ${{ steps.engine.outcome == 'success' }}
|
|
319
319
|
run: nix develop .#build --command bun run docs:manual
|
|
320
320
|
|
|
321
|
+
# The captures, for the product's own docs site: publish-docs-site.yml
|
|
322
|
+
# restores the newest set, so its recorded guides publish from this run,
|
|
323
|
+
# the only one that can make them. Only after a passing engine tier, so a
|
|
324
|
+
# site never takes a half-captured set over the last good one. Each run
|
|
325
|
+
# its own key: a cache entry is never overwritten.
|
|
326
|
+
- name: Cache the guide captures for the docs site
|
|
327
|
+
if: ${{ steps.engine.outcome == 'success' }}
|
|
328
|
+
uses: actions/cache/save@v4
|
|
329
|
+
with:
|
|
330
|
+
path: docs/generated
|
|
331
|
+
key: docs-captures-${{ github.sha }}-${{ github.run_id }}
|
|
332
|
+
|
|
321
333
|
# Said three ways, so a failure is never silent: a warning on the run, a
|
|
322
334
|
# line in its summary, and the cell's detail (the notify job).
|
|
323
335
|
- name: Report the engine tier (not gating)
|
package/create-product/cli.ts
CHANGED
|
@@ -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(
|
|
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
|
+
}
|