@norskvideo/ctl-dev-kit 0.1.67 → 0.1.68

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.
@@ -0,0 +1,117 @@
1
+ // Lint a repo's licenceTemplate.json against what the repo actually is.
2
+ //
3
+ // The template is this repo's half of a licence: the `products[]` entries a
4
+ // grant needs in order to entitle it. Minting composes {a grant's header} +
5
+ // {the union of one or more repos' entries}, so a template that names the wrong
6
+ // product — or an imageRef the build never produces — mints a licence that
7
+ // entitles nothing, and the first sign is a 60-second readiness timeout.
8
+ //
9
+ // That is not hypothetical. licensing/partner-all-products-template.json is
10
+ // hand-maintained, names four products, and has silently omitted both turnkeys
11
+ // for as long as they have existed. This lint is what makes the derived
12
+ // version impossible to get wrong the same way.
13
+ //
14
+ // It asserts recorded INTENT matches reality, not security: a user can retag
15
+ // any image, so imageRef buys no defence. What it buys is a statement of what
16
+ // was meant, which is only worth having if something keeps it true.
17
+ import { readFileSync } from "node:fs";
18
+
19
+ export interface RepoFacts {
20
+ /** The repo's own PRODUCT_NAME. */
21
+ product: string;
22
+ /** The image name CI builds under — see builtImageName. */
23
+ image: string;
24
+ }
25
+
26
+ /** The image name CI builds under, by CI's own rule: PUBLISH_IMAGE when set,
27
+ * else the build script's IMAGE_TAG default, tag stripped. The licence matches
28
+ * on the repo part alone (`imageRefMatches`: `parsed.repo !== entry.imageRef`),
29
+ * so the tag never belongs in a template. */
30
+ export function builtImageName(opts: { publishImage: string; buildDefault: string }): string | null {
31
+ const ref = opts.publishImage.trim() || opts.buildDefault.trim();
32
+ if (!ref) return null;
33
+ // Strip a tag, but not a registry port: a colon is a tag only after the last slash.
34
+ const lastSlash = ref.lastIndexOf("/");
35
+ const colon = ref.indexOf(":", lastSlash + 1);
36
+ return colon === -1 ? ref : ref.slice(0, colon);
37
+ }
38
+
39
+ const REQUIRED_ENTRY_FIELDS = ["product", "imageRef", "versionConstraint", "mode"] as const;
40
+
41
+ /** Fields that describe WHO a licence was cut for. A repo does not know them,
42
+ * and a merged staff licence that inherited one repo's expiry would be a
43
+ * quiet, dated surprise. */
44
+ const GRANT_ONLY_FIELDS = ["issuedTo", "email", "expiry", "timeout", "nonProduction"] as const;
45
+
46
+ export function templateProblems(templateJson: string, repo: RepoFacts): string[] {
47
+ let parsed: { products?: unknown } & Record<string, unknown>;
48
+ try {
49
+ parsed = JSON.parse(templateJson);
50
+ } catch (e) {
51
+ return [`licenseTemplate.json is not valid JSON: ${String(e)}`];
52
+ }
53
+ if (typeof parsed !== "object" || parsed === null) return ["licenseTemplate.json is not an object"];
54
+
55
+ const problems: string[] = [];
56
+ for (const f of GRANT_ONLY_FIELDS) {
57
+ if (f in parsed) {
58
+ problems.push(`licenseTemplate.json carries "${f}", which belongs to the grant, not the repo — remove it`);
59
+ }
60
+ }
61
+ if (!Array.isArray(parsed.products)) {
62
+ problems.push('licenseTemplate.json has no "products" array');
63
+ return problems;
64
+ }
65
+
66
+ const entries = parsed.products as Record<string, unknown>[];
67
+ for (const [i, entry] of entries.entries()) {
68
+ for (const f of REQUIRED_ENTRY_FIELDS) {
69
+ if (typeof entry?.[f] !== "string") problems.push(`products[${i}] is missing "${f}"`);
70
+ }
71
+ const ref = entry?.imageRef;
72
+ if (typeof ref === "string") {
73
+ const lastSlash = ref.lastIndexOf("/");
74
+ if (ref.indexOf(":", lastSlash + 1) !== -1) {
75
+ problems.push(`products[${i}].imageRef "${ref}" carries a tag; a licence matches the repo part only`);
76
+ }
77
+ }
78
+ }
79
+
80
+ const named = entries.map((e) => e?.product).filter((p): p is string => typeof p === "string");
81
+ if (!named.includes(repo.product)) {
82
+ problems.push(
83
+ `licenseTemplate.json does not name this repo's product ${repo.product} (names: ${named.join(", ") || "nothing"})`,
84
+ );
85
+ return problems;
86
+ }
87
+
88
+ const mine = entries.find((e) => e?.product === repo.product);
89
+ if (typeof mine?.imageRef === "string" && mine.imageRef !== repo.image) {
90
+ problems.push(`products[].imageRef for ${repo.product} is "${mine.imageRef}" but this repo builds "${repo.image}"`);
91
+ }
92
+ return problems;
93
+ }
94
+
95
+ if (import.meta.main) {
96
+ const [file, product, image] = process.argv.slice(2);
97
+ if (!file || !product || !image) {
98
+ console.error("usage: check-template.ts <licenseTemplate.json> <product-name> <built-image-name>");
99
+ process.exit(2);
100
+ }
101
+ let contents: string;
102
+ try {
103
+ contents = readFileSync(file, "utf8");
104
+ } catch {
105
+ // Absence is not yet an error: the turnkeys' templates wait on their
106
+ // customer-facing image names being settled. Flipping this to a failure is
107
+ // a one-line change once every repo carries one.
108
+ console.log(`licence-template: no ${file} — not yet required in this repo`);
109
+ process.exit(0);
110
+ }
111
+ const problems = templateProblems(contents, { product, image });
112
+ if (problems.length) {
113
+ for (const p of problems) console.error(`licence-template: ${p}`);
114
+ process.exit(1);
115
+ }
116
+ console.log(`licence-template: ${file} names ${product} at ${image}`);
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.67",
3
+ "version": "0.1.68",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",