@setzkasten/cli 0.1.0-rc.1

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,271 @@
1
+ function asObject(value) {
2
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
3
+ return null;
4
+ }
5
+
6
+ return value;
7
+ }
8
+
9
+ function asString(value) {
10
+ return typeof value === "string" && value.length > 0 ? value : null;
11
+ }
12
+
13
+ function asStringArray(value) {
14
+ if (!Array.isArray(value)) {
15
+ return [];
16
+ }
17
+
18
+ return value.filter((entry) => typeof entry === "string" && entry.length > 0);
19
+ }
20
+
21
+ function readProjectDomains(manifest) {
22
+ const project = asObject(manifest.project);
23
+ if (!project) {
24
+ return [];
25
+ }
26
+
27
+ return asStringArray(project.domains);
28
+ }
29
+
30
+ function makeOfferingKey(offeringId, offeringVersion) {
31
+ return `${offeringId}@${offeringVersion}`;
32
+ }
33
+
34
+ function readRequiredModifications(fontUsage) {
35
+ const usage = asObject(fontUsage);
36
+ if (!usage) {
37
+ return [];
38
+ }
39
+
40
+ const candidates = [
41
+ usage.required_modifications,
42
+ usage.modifications_required,
43
+ usage.requiredModificationKinds,
44
+ ];
45
+
46
+ for (const candidate of candidates) {
47
+ const values = asStringArray(candidate);
48
+ if (values.length > 0) {
49
+ return values;
50
+ }
51
+ }
52
+
53
+ return [];
54
+ }
55
+
56
+ function isSelfHostingUsage(fontUsage) {
57
+ const usage = asObject(fontUsage);
58
+ if (!usage) {
59
+ return false;
60
+ }
61
+
62
+ if (usage.self_hosting === true) {
63
+ return true;
64
+ }
65
+
66
+ const hosting = usage.hosting;
67
+ if (typeof hosting === "string") {
68
+ return hosting === "self_hosting";
69
+ }
70
+
71
+ if (Array.isArray(hosting)) {
72
+ return hosting.includes("self_hosting");
73
+ }
74
+
75
+ return false;
76
+ }
77
+
78
+ function findRight(rights, rightType) {
79
+ return rights.find((right) => typeof right.right_type === "string" && right.right_type === rightType) ?? null;
80
+ }
81
+
82
+ function makeDecision(reasons) {
83
+ if (reasons.some((reason) => reason.severity === "escalate")) {
84
+ return "escalate";
85
+ }
86
+
87
+ if (reasons.some((reason) => reason.severity === "warn")) {
88
+ return "warn";
89
+ }
90
+
91
+ return "allow";
92
+ }
93
+
94
+ function normalizeRights(value) {
95
+ if (!Array.isArray(value)) {
96
+ return [];
97
+ }
98
+
99
+ return value.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry));
100
+ }
101
+
102
+ export function evaluatePolicy(manifest) {
103
+ const reasons = [];
104
+ const evidenceRequired = new Set();
105
+
106
+ const projectDomains = readProjectDomains(manifest);
107
+ const fonts = Array.isArray(manifest.fonts) ? manifest.fonts : [];
108
+ const instances = Array.isArray(manifest.license_instances) ? manifest.license_instances : [];
109
+ const offerings = Array.isArray(manifest.license_offerings) ? manifest.license_offerings : [];
110
+
111
+ const instancesById = new Map();
112
+ for (const instance of instances) {
113
+ const instanceId = asString(instance.license_id);
114
+ if (instanceId) {
115
+ instancesById.set(instanceId, instance);
116
+ }
117
+ }
118
+
119
+ const offeringsByKey = new Map();
120
+ for (const offering of offerings) {
121
+ const offeringId = asString(offering.offering_id);
122
+ const offeringVersion = asString(offering.offering_version);
123
+
124
+ if (offeringId && offeringVersion) {
125
+ offeringsByKey.set(makeOfferingKey(offeringId, offeringVersion), offering);
126
+ }
127
+ }
128
+
129
+ for (const font of fonts) {
130
+ const fontId = asString(font.font_id) ?? "unknown_font";
131
+ const source = asObject(font.source);
132
+ const sourceType = asString(source?.type);
133
+
134
+ const instanceIds = asStringArray(font.license_instance_ids);
135
+ const activeInstanceId = asString(font.active_license_instance_id) ?? instanceIds[0] ?? null;
136
+ const instance = activeInstanceId ? instancesById.get(activeInstanceId) ?? null : null;
137
+
138
+ if (sourceType === "byo") {
139
+ if (!instance) {
140
+ reasons.push({
141
+ code: "BYO_NO_LICENSE_INSTANCE",
142
+ severity: "warn",
143
+ message: `BYO font '${fontId}' has no linked license instance.`,
144
+ context: { font_id: fontId },
145
+ });
146
+ evidenceRequired.add("license_instances[].evidence[]");
147
+ } else {
148
+ const evidence = Array.isArray(instance.evidence) ? instance.evidence : [];
149
+ if (evidence.length === 0) {
150
+ reasons.push({
151
+ code: "BYO_NO_EVIDENCE",
152
+ severity: "warn",
153
+ message: `BYO font '${fontId}' has no evidence attached.`,
154
+ context: { font_id: fontId, license_id: activeInstanceId },
155
+ });
156
+ evidenceRequired.add("license_instances[].evidence[]");
157
+ }
158
+ }
159
+ }
160
+
161
+ if (!instance) {
162
+ continue;
163
+ }
164
+
165
+ const status = asString(instance.status);
166
+ if (status && status !== "active") {
167
+ reasons.push({
168
+ code: "LICENSE_STATUS_NOT_ACTIVE",
169
+ severity: "escalate",
170
+ message: `License instance '${activeInstanceId}' is '${status}', not active.`,
171
+ context: { license_id: activeInstanceId, status },
172
+ });
173
+ }
174
+
175
+ const scope = asObject(instance.scope);
176
+ const scopeDomains = asStringArray(scope?.domains);
177
+
178
+ if (projectDomains.length > 0 && scopeDomains.length > 0) {
179
+ for (const domain of projectDomains) {
180
+ if (!scopeDomains.includes(domain)) {
181
+ reasons.push({
182
+ code: "DOMAIN_OUT_OF_SCOPE",
183
+ severity: "warn",
184
+ message: `Project domain '${domain}' is not covered by license scope for '${activeInstanceId}'.`,
185
+ context: { license_id: activeInstanceId, domain },
186
+ });
187
+ }
188
+ }
189
+ }
190
+
191
+ const offeringRef = asObject(instance.offering_ref);
192
+ const offeringId = asString(offeringRef?.offering_id);
193
+ const offeringVersion = asString(offeringRef?.offering_version);
194
+
195
+ let offering = null;
196
+ if (offeringId && offeringVersion) {
197
+ offering = offeringsByKey.get(makeOfferingKey(offeringId, offeringVersion)) ?? null;
198
+ if (!offering) {
199
+ reasons.push({
200
+ code: "OFFERING_REFERENCE_MISSING",
201
+ severity: "warn",
202
+ message: `Offering '${offeringId}@${offeringVersion}' referenced by '${activeInstanceId}' is missing.`,
203
+ context: { license_id: activeInstanceId, offering_id: offeringId, offering_version: offeringVersion },
204
+ });
205
+ }
206
+ }
207
+
208
+ if (!offering) {
209
+ continue;
210
+ }
211
+
212
+ const rights = normalizeRights(offering.rights);
213
+
214
+ if (isSelfHostingUsage(font.usage)) {
215
+ const selfHostingRight = findRight(rights, "distribution_self_hosting");
216
+ const cdnHostingRight = findRight(rights, "distribution_cdn_hosting");
217
+
218
+ const selfHostingAllowed = selfHostingRight?.allowed === true;
219
+ const cdnOnly = cdnHostingRight?.allowed === true && !selfHostingAllowed;
220
+
221
+ if (cdnOnly) {
222
+ reasons.push({
223
+ code: "SELF_HOSTING_NOT_ALLOWED",
224
+ severity: "warn",
225
+ message: `Font '${fontId}' appears self-hosted but offering allows CDN-only distribution.`,
226
+ context: { font_id: fontId, license_id: activeInstanceId },
227
+ });
228
+ }
229
+ }
230
+
231
+ const requiredModifications = readRequiredModifications(font.usage);
232
+ if (requiredModifications.length > 0) {
233
+ const modificationRight = findRight(rights, "modification");
234
+
235
+ if (!modificationRight || modificationRight.allowed !== true) {
236
+ reasons.push({
237
+ code: "MODIFICATION_NOT_ALLOWED",
238
+ severity: "escalate",
239
+ message: `Font '${fontId}' requires modification but offering does not allow it.`,
240
+ context: { font_id: fontId, license_id: activeInstanceId, required: requiredModifications },
241
+ });
242
+ continue;
243
+ }
244
+
245
+ const allowedKinds = asStringArray(modificationRight.modification_kinds);
246
+ if (allowedKinds.length > 0) {
247
+ const disallowedKinds = requiredModifications.filter((kind) => !allowedKinds.includes(kind));
248
+
249
+ if (disallowedKinds.length > 0) {
250
+ reasons.push({
251
+ code: "MODIFICATION_KIND_NOT_ALLOWED",
252
+ severity: "escalate",
253
+ message: `Font '${fontId}' requires unsupported modification kinds.`,
254
+ context: {
255
+ font_id: fontId,
256
+ license_id: activeInstanceId,
257
+ required: requiredModifications,
258
+ disallowed: disallowedKinds,
259
+ },
260
+ });
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ return {
267
+ decision: makeDecision(reasons),
268
+ reasons,
269
+ evidence_required: Array.from(evidenceRequired).sort((a, b) => a.localeCompare(b)),
270
+ };
271
+ }
@@ -0,0 +1,219 @@
1
+ import { canonicalStringify, nowIso, roundMoney, sha256Hex } from "./core.js";
2
+
3
+ function asObject(value) {
4
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
5
+ return null;
6
+ }
7
+
8
+ return value;
9
+ }
10
+
11
+ function asString(value) {
12
+ return typeof value === "string" && value.length > 0 ? value : null;
13
+ }
14
+
15
+ function asNumber(value) {
16
+ if (typeof value === "number" && Number.isFinite(value)) {
17
+ return value;
18
+ }
19
+
20
+ return null;
21
+ }
22
+
23
+ function asMetricLimits(value) {
24
+ if (!Array.isArray(value)) {
25
+ return [];
26
+ }
27
+
28
+ return value.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry));
29
+ }
30
+
31
+ function matchesWhen(when, instance) {
32
+ const metricType = asString(when.metric_type);
33
+ const period = asString(when.period);
34
+
35
+ const limits = asMetricLimits(instance.metric_limits).filter((limit) => {
36
+ const limitMetricType = asString(limit.metric_type);
37
+ const limitPeriod = asString(limit.period);
38
+
39
+ if (metricType && limitMetricType !== metricType) {
40
+ return false;
41
+ }
42
+
43
+ if (period && limitPeriod !== period) {
44
+ return false;
45
+ }
46
+
47
+ return true;
48
+ });
49
+
50
+ if ((metricType || period) && limits.length === 0) {
51
+ return false;
52
+ }
53
+
54
+ const gte = asNumber(when.gte);
55
+ const gt = asNumber(when.gt);
56
+ const lte = asNumber(when.lte);
57
+ const lt = asNumber(when.lt);
58
+ const eq = asNumber(when.eq);
59
+
60
+ if (gte === null && gt === null && lte === null && lt === null && eq === null) {
61
+ return true;
62
+ }
63
+
64
+ const candidates = limits.length > 0 ? limits : asMetricLimits(instance.metric_limits);
65
+
66
+ return candidates.some((candidate) => {
67
+ const value = asNumber(candidate.limit);
68
+ if (value === null) {
69
+ return false;
70
+ }
71
+
72
+ if (gte !== null && value < gte) {
73
+ return false;
74
+ }
75
+
76
+ if (gt !== null && value <= gt) {
77
+ return false;
78
+ }
79
+
80
+ if (lte !== null && value > lte) {
81
+ return false;
82
+ }
83
+
84
+ if (lt !== null && value >= lt) {
85
+ return false;
86
+ }
87
+
88
+ if (eq !== null && value !== eq) {
89
+ return false;
90
+ }
91
+
92
+ return true;
93
+ });
94
+ }
95
+
96
+ function makeOfferingKey(offeringId, offeringVersion) {
97
+ return `${offeringId}@${offeringVersion}`;
98
+ }
99
+
100
+ function evaluateAmountForInstance(instance, offering) {
101
+ const priceFormula = asObject(offering.price_formula);
102
+ if (!priceFormula) {
103
+ throw new Error("price_formula missing for offering.");
104
+ }
105
+
106
+ const currency = asString(priceFormula.currency);
107
+ const basePrice = asNumber(priceFormula.base_price);
108
+
109
+ if (!currency || basePrice === null) {
110
+ throw new Error("price_formula must include currency and base_price.");
111
+ }
112
+
113
+ let amount = basePrice;
114
+
115
+ const rules = Array.isArray(priceFormula.rules)
116
+ ? priceFormula.rules.filter((rule) => {
117
+ return Boolean(rule) && typeof rule === "object" && !Array.isArray(rule);
118
+ })
119
+ : [];
120
+
121
+ for (const rule of rules) {
122
+ const when = asObject(rule.when);
123
+
124
+ if (when && !matchesWhen(when, instance)) {
125
+ continue;
126
+ }
127
+
128
+ const multiplier = asNumber(rule.multiplier) ?? 1;
129
+ const add = asNumber(rule.add) ?? 0;
130
+
131
+ amount = roundMoney(amount * multiplier + add);
132
+ }
133
+
134
+ return {
135
+ currency,
136
+ amount: roundMoney(amount),
137
+ };
138
+ }
139
+
140
+ export function generateQuote(manifest) {
141
+ const offerings = Array.isArray(manifest.license_offerings) ? manifest.license_offerings : [];
142
+ const instances = Array.isArray(manifest.license_instances) ? manifest.license_instances : [];
143
+
144
+ const offeringsByKey = new Map();
145
+
146
+ for (const offering of offerings) {
147
+ const offeringId = asString(offering.offering_id);
148
+ const offeringVersion = asString(offering.offering_version);
149
+
150
+ if (offeringId && offeringVersion) {
151
+ offeringsByKey.set(makeOfferingKey(offeringId, offeringVersion), offering);
152
+ }
153
+ }
154
+
155
+ const lineItems = [];
156
+ const skipped = [];
157
+
158
+ for (const instance of instances) {
159
+ const licenseId = asString(instance.license_id);
160
+ if (!licenseId) {
161
+ continue;
162
+ }
163
+
164
+ const status = asString(instance.status);
165
+ if (status && status !== "active") {
166
+ skipped.push(`${licenseId}:status=${status}`);
167
+ continue;
168
+ }
169
+
170
+ const offeringRef = asObject(instance.offering_ref);
171
+ const offeringId = asString(offeringRef?.offering_id);
172
+ const offeringVersion = asString(offeringRef?.offering_version);
173
+
174
+ if (!offeringId || !offeringVersion) {
175
+ skipped.push(`${licenseId}:offering_ref_missing`);
176
+ continue;
177
+ }
178
+
179
+ const offering = offeringsByKey.get(makeOfferingKey(offeringId, offeringVersion));
180
+ if (!offering) {
181
+ skipped.push(`${licenseId}:offering_not_found`);
182
+ continue;
183
+ }
184
+
185
+ const evaluated = evaluateAmountForInstance(instance, offering);
186
+
187
+ lineItems.push({
188
+ license_id: licenseId,
189
+ offering_id: offeringId,
190
+ offering_version: offeringVersion,
191
+ currency: evaluated.currency,
192
+ amount: evaluated.amount,
193
+ });
194
+ }
195
+
196
+ lineItems.sort((a, b) => a.license_id.localeCompare(b.license_id));
197
+
198
+ const totals = {};
199
+ for (const lineItem of lineItems) {
200
+ const previous = totals[lineItem.currency] ?? 0;
201
+ totals[lineItem.currency] = roundMoney(previous + lineItem.amount);
202
+ }
203
+
204
+ skipped.sort((a, b) => a.localeCompare(b));
205
+
206
+ const fingerprintTarget = {
207
+ totals,
208
+ line_items: lineItems,
209
+ skipped,
210
+ };
211
+
212
+ return {
213
+ generated_at: nowIso(),
214
+ totals,
215
+ line_items: lineItems,
216
+ skipped,
217
+ deterministic_hash: sha256Hex(canonicalStringify(fingerprintTarget)),
218
+ };
219
+ }
@@ -0,0 +1,176 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { nowIso } from "./core.js";
4
+
5
+ const DEFAULT_IGNORED_DIRS = new Set([
6
+ ".git",
7
+ "node_modules",
8
+ ".setzkasten",
9
+ "dist",
10
+ "coverage",
11
+ ".next",
12
+ ".turbo",
13
+ ]);
14
+
15
+ const TEXT_FILE_EXTENSIONS = new Set([
16
+ ".css",
17
+ ".scss",
18
+ ".sass",
19
+ ".less",
20
+ ".js",
21
+ ".jsx",
22
+ ".ts",
23
+ ".tsx",
24
+ ".html",
25
+ ".htm",
26
+ ".md",
27
+ ".json",
28
+ ".yaml",
29
+ ".yml",
30
+ ".txt",
31
+ ]);
32
+
33
+ function deepClone(value) {
34
+ return JSON.parse(JSON.stringify(value));
35
+ }
36
+
37
+ function normalizeFonts(manifest) {
38
+ return Array.isArray(manifest.fonts) ? manifest.fonts : [];
39
+ }
40
+
41
+ function shouldScanFile(filePath) {
42
+ const extension = path.extname(filePath).toLowerCase();
43
+ return TEXT_FILE_EXTENSIONS.has(extension);
44
+ }
45
+
46
+ async function collectScanFiles(rootPath) {
47
+ const files = [];
48
+
49
+ async function walk(dirPath) {
50
+ const entries = await readdir(dirPath, { withFileTypes: true });
51
+
52
+ for (const entry of entries) {
53
+ if (entry.name.startsWith(".")) {
54
+ if (entry.isDirectory() && !DEFAULT_IGNORED_DIRS.has(entry.name)) {
55
+ await walk(path.join(dirPath, entry.name));
56
+ }
57
+ continue;
58
+ }
59
+
60
+ const fullPath = path.join(dirPath, entry.name);
61
+
62
+ if (entry.isDirectory()) {
63
+ if (!DEFAULT_IGNORED_DIRS.has(entry.name)) {
64
+ await walk(fullPath);
65
+ }
66
+ continue;
67
+ }
68
+
69
+ if (!entry.isFile() || !shouldScanFile(fullPath)) {
70
+ continue;
71
+ }
72
+
73
+ const fileStat = await stat(fullPath);
74
+ if (fileStat.size > 2 * 1024 * 1024) {
75
+ continue;
76
+ }
77
+
78
+ files.push(fullPath);
79
+ }
80
+ }
81
+
82
+ await walk(rootPath);
83
+ return files;
84
+ }
85
+
86
+ function relativeTo(rootPath, targetPath) {
87
+ const relativePath = path.relative(rootPath, targetPath);
88
+ return relativePath.length > 0 ? relativePath : ".";
89
+ }
90
+
91
+ export async function scanProject(input) {
92
+ const rootPath = path.resolve(input.rootPath);
93
+ const maxMatchedPathsPerFont = input.maxMatchedPathsPerFont ?? 30;
94
+
95
+ const fonts = normalizeFonts(input.manifest)
96
+ .map((font) => ({
97
+ font_id: typeof font.font_id === "string" ? font.font_id : "",
98
+ family_name: typeof font.family_name === "string" ? font.family_name : "",
99
+ }))
100
+ .filter((font) => font.font_id.length > 0 && font.family_name.length > 0);
101
+
102
+ const files = await collectScanFiles(rootPath);
103
+ const matches = new Map();
104
+
105
+ for (const font of fonts) {
106
+ matches.set(font.font_id, {
107
+ font_id: font.font_id,
108
+ family_name: font.family_name,
109
+ match_count: 0,
110
+ matched_paths: [],
111
+ });
112
+ }
113
+
114
+ for (const filePath of files) {
115
+ let content;
116
+
117
+ try {
118
+ content = await readFile(filePath, "utf8");
119
+ } catch {
120
+ continue;
121
+ }
122
+
123
+ const lowerContent = content.toLowerCase();
124
+
125
+ for (const font of fonts) {
126
+ if (!lowerContent.includes(font.family_name.toLowerCase())) {
127
+ continue;
128
+ }
129
+
130
+ const entry = matches.get(font.font_id);
131
+ if (!entry) {
132
+ continue;
133
+ }
134
+
135
+ entry.match_count += 1;
136
+ if (entry.matched_paths.length < maxMatchedPathsPerFont) {
137
+ entry.matched_paths.push(relativeTo(rootPath, filePath));
138
+ }
139
+ }
140
+ }
141
+
142
+ return {
143
+ scanned_at: nowIso(),
144
+ root_path: rootPath,
145
+ scanned_files_count: files.length,
146
+ font_matches: Object.fromEntries(Array.from(matches.entries())),
147
+ };
148
+ }
149
+
150
+ export function applyScanResultToManifest(manifest, scanResult) {
151
+ const draft = deepClone(manifest);
152
+ const fonts = Array.isArray(draft.fonts) ? draft.fonts : [];
153
+
154
+ for (const font of fonts) {
155
+ const fontId = typeof font.font_id === "string" ? font.font_id : "";
156
+ if (!fontId) {
157
+ continue;
158
+ }
159
+
160
+ const match = scanResult.font_matches[fontId];
161
+ const currentUsage =
162
+ font.usage && typeof font.usage === "object" && !Array.isArray(font.usage) ? font.usage : {};
163
+
164
+ font.usage = {
165
+ ...currentUsage,
166
+ scan: {
167
+ scanned_at: scanResult.scanned_at,
168
+ match_count: match?.match_count ?? 0,
169
+ matched_paths: match?.matched_paths ?? [],
170
+ },
171
+ };
172
+ }
173
+
174
+ draft.fonts = fonts;
175
+ return draft;
176
+ }