@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,613 @@
1
+ import path from "node:path";
2
+ import {
3
+ MANIFEST_FILENAME,
4
+ MANIFEST_VERSION,
5
+ findUp,
6
+ readJsonFile,
7
+ slugifyId,
8
+ writeJsonFileAtomic,
9
+ } from "./core.js";
10
+
11
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
12
+ const SEMVER_PATTERN =
13
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
14
+ const LICENSEE_TYPES = new Set(["individual", "organization", "agency", "client", "other"]);
15
+ const SOURCE_TYPES = new Set(["oss", "byo"]);
16
+ const OFFERING_TYPES = new Set(["commercial", "trial"]);
17
+ const INSTANCE_STATUS = new Set(["active", "expired", "superseded", "revoked"]);
18
+ const ACQUISITION_SOURCES = new Set(["direct_foundry", "reseller", "marketplace", "legacy"]);
19
+
20
+ function isObject(value) {
21
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
22
+ }
23
+
24
+ function pushError(errors, pathName, message) {
25
+ errors.push(`${pathName} ${message}`);
26
+ }
27
+
28
+ function validateString(errors, pathName, value, options = {}) {
29
+ if (typeof value !== "string") {
30
+ pushError(errors, pathName, "must be a string");
31
+ return;
32
+ }
33
+
34
+ if (options.minLength && value.length < options.minLength) {
35
+ pushError(errors, pathName, `must have length >= ${options.minLength}`);
36
+ }
37
+
38
+ if (options.maxLength && value.length > options.maxLength) {
39
+ pushError(errors, pathName, `must have length <= ${options.maxLength}`);
40
+ }
41
+
42
+ if (options.pattern && !options.pattern.test(value)) {
43
+ pushError(errors, pathName, "has invalid format");
44
+ }
45
+
46
+ if (options.enum && !options.enum.has(value)) {
47
+ pushError(errors, pathName, `must be one of: ${Array.from(options.enum).join(", ")}`);
48
+ }
49
+ }
50
+
51
+ function validateArray(errors, pathName, value, options = {}) {
52
+ if (!Array.isArray(value)) {
53
+ pushError(errors, pathName, "must be an array");
54
+ return false;
55
+ }
56
+
57
+ if (typeof options.minItems === "number" && value.length < options.minItems) {
58
+ pushError(errors, pathName, `must contain at least ${options.minItems} item(s)`);
59
+ }
60
+
61
+ return true;
62
+ }
63
+
64
+ function validateLicensee(errors, pathName, value) {
65
+ if (!isObject(value)) {
66
+ pushError(errors, pathName, "must be an object");
67
+ return;
68
+ }
69
+
70
+ validateString(errors, `${pathName}.licensee_id`, value.licensee_id, {
71
+ minLength: 1,
72
+ maxLength: 128,
73
+ pattern: ID_PATTERN,
74
+ });
75
+ validateString(errors, `${pathName}.type`, value.type, { enum: LICENSEE_TYPES });
76
+ validateString(errors, `${pathName}.legal_name`, value.legal_name, { minLength: 1 });
77
+
78
+ if (value.country !== undefined) {
79
+ validateString(errors, `${pathName}.country`, value.country, { minLength: 2, maxLength: 2 });
80
+ }
81
+
82
+ if (value.contact_email !== undefined) {
83
+ validateString(errors, `${pathName}.contact_email`, value.contact_email, { minLength: 3 });
84
+ }
85
+ }
86
+
87
+ function validateFont(errors, pathName, value) {
88
+ if (!isObject(value)) {
89
+ pushError(errors, pathName, "must be an object");
90
+ return;
91
+ }
92
+
93
+ validateString(errors, `${pathName}.font_id`, value.font_id, {
94
+ minLength: 1,
95
+ maxLength: 128,
96
+ pattern: ID_PATTERN,
97
+ });
98
+ validateString(errors, `${pathName}.family_name`, value.family_name, { minLength: 1 });
99
+
100
+ if (!isObject(value.source)) {
101
+ pushError(errors, `${pathName}.source`, "must be an object");
102
+ } else {
103
+ validateString(errors, `${pathName}.source.type`, value.source.type, { enum: SOURCE_TYPES });
104
+
105
+ if (value.source.uri !== undefined) {
106
+ try {
107
+ new URL(String(value.source.uri));
108
+ } catch {
109
+ pushError(errors, `${pathName}.source.uri`, "must be a valid URI");
110
+ }
111
+ }
112
+ }
113
+
114
+ const hasLicenseIds = validateArray(errors, `${pathName}.license_instance_ids`, value.license_instance_ids);
115
+ if (hasLicenseIds) {
116
+ for (let index = 0; index < value.license_instance_ids.length; index += 1) {
117
+ validateString(
118
+ errors,
119
+ `${pathName}.license_instance_ids[${index}]`,
120
+ value.license_instance_ids[index],
121
+ {
122
+ minLength: 1,
123
+ maxLength: 128,
124
+ pattern: ID_PATTERN,
125
+ },
126
+ );
127
+ }
128
+ }
129
+
130
+ if (value.active_license_instance_id !== undefined) {
131
+ validateString(errors, `${pathName}.active_license_instance_id`, value.active_license_instance_id, {
132
+ minLength: 1,
133
+ maxLength: 128,
134
+ pattern: ID_PATTERN,
135
+ });
136
+ }
137
+ }
138
+
139
+ function validateMetricLimit(errors, pathName, value) {
140
+ if (!isObject(value)) {
141
+ pushError(errors, pathName, "must be an object");
142
+ return;
143
+ }
144
+
145
+ validateString(errors, `${pathName}.metric_type`, value.metric_type, { minLength: 1 });
146
+
147
+ if (typeof value.limit !== "number" || Number.isNaN(value.limit)) {
148
+ pushError(errors, `${pathName}.limit`, "must be a number");
149
+ }
150
+
151
+ validateString(errors, `${pathName}.period`, value.period, { minLength: 1 });
152
+ }
153
+
154
+ function validateEvidence(errors, pathName, value) {
155
+ if (!isObject(value)) {
156
+ pushError(errors, pathName, "must be an object");
157
+ return;
158
+ }
159
+
160
+ validateString(errors, `${pathName}.evidence_id`, value.evidence_id, {
161
+ minLength: 1,
162
+ maxLength: 128,
163
+ pattern: ID_PATTERN,
164
+ });
165
+ validateString(errors, `${pathName}.type`, value.type, { minLength: 1 });
166
+ validateString(errors, `${pathName}.document_hash`, value.document_hash, {
167
+ pattern: /^[A-Fa-f0-9]{64}$/,
168
+ });
169
+ }
170
+
171
+ function validateLicenseOffering(errors, pathName, value) {
172
+ if (!isObject(value)) {
173
+ pushError(errors, pathName, "must be an object");
174
+ return;
175
+ }
176
+
177
+ if (value.kind !== "offering") {
178
+ pushError(errors, `${pathName}.kind`, "must equal 'offering'");
179
+ }
180
+
181
+ validateString(errors, `${pathName}.offering_id`, value.offering_id, {
182
+ minLength: 1,
183
+ maxLength: 128,
184
+ pattern: ID_PATTERN,
185
+ });
186
+ validateString(errors, `${pathName}.offering_version`, value.offering_version, {
187
+ pattern: SEMVER_PATTERN,
188
+ });
189
+ validateString(errors, `${pathName}.offering_type`, value.offering_type, {
190
+ enum: OFFERING_TYPES,
191
+ });
192
+ validateString(errors, `${pathName}.name`, value.name, { minLength: 1 });
193
+
194
+ const hasRights = validateArray(errors, `${pathName}.rights`, value.rights, { minItems: 1 });
195
+ if (hasRights) {
196
+ for (let index = 0; index < value.rights.length; index += 1) {
197
+ const right = value.rights[index];
198
+ if (!isObject(right)) {
199
+ pushError(errors, `${pathName}.rights[${index}]`, "must be an object");
200
+ continue;
201
+ }
202
+
203
+ validateString(errors, `${pathName}.rights[${index}].right_id`, right.right_id, {
204
+ minLength: 1,
205
+ maxLength: 128,
206
+ pattern: ID_PATTERN,
207
+ });
208
+ validateString(errors, `${pathName}.rights[${index}].right_type`, right.right_type, { minLength: 1 });
209
+ }
210
+ }
211
+
212
+ validateArray(errors, `${pathName}.metric_models`, value.metric_models);
213
+
214
+ if (!isObject(value.price_formula)) {
215
+ pushError(errors, `${pathName}.price_formula`, "must be an object");
216
+ } else {
217
+ validateString(errors, `${pathName}.price_formula.currency`, value.price_formula.currency, {
218
+ minLength: 3,
219
+ maxLength: 3,
220
+ });
221
+
222
+ if (
223
+ typeof value.price_formula.base_price !== "number" ||
224
+ Number.isNaN(value.price_formula.base_price) ||
225
+ value.price_formula.base_price < 0
226
+ ) {
227
+ pushError(errors, `${pathName}.price_formula.base_price`, "must be a non-negative number");
228
+ }
229
+ }
230
+ }
231
+
232
+ function validateLicenseInstance(errors, pathName, value) {
233
+ if (!isObject(value)) {
234
+ pushError(errors, pathName, "must be an object");
235
+ return;
236
+ }
237
+
238
+ if (value.kind !== "instance") {
239
+ pushError(errors, `${pathName}.kind`, "must equal 'instance'");
240
+ }
241
+
242
+ validateString(errors, `${pathName}.license_id`, value.license_id, {
243
+ minLength: 1,
244
+ maxLength: 128,
245
+ pattern: ID_PATTERN,
246
+ });
247
+ validateString(errors, `${pathName}.licensee_id`, value.licensee_id, {
248
+ minLength: 1,
249
+ maxLength: 128,
250
+ pattern: ID_PATTERN,
251
+ });
252
+
253
+ if (!isObject(value.offering_ref)) {
254
+ pushError(errors, `${pathName}.offering_ref`, "must be an object");
255
+ } else {
256
+ validateString(errors, `${pathName}.offering_ref.offering_id`, value.offering_ref.offering_id, {
257
+ minLength: 1,
258
+ maxLength: 128,
259
+ pattern: ID_PATTERN,
260
+ });
261
+ validateString(
262
+ errors,
263
+ `${pathName}.offering_ref.offering_version`,
264
+ value.offering_ref.offering_version,
265
+ {
266
+ pattern: SEMVER_PATTERN,
267
+ },
268
+ );
269
+ }
270
+
271
+ if (!isObject(value.scope)) {
272
+ pushError(errors, `${pathName}.scope`, "must be an object");
273
+ } else {
274
+ validateString(errors, `${pathName}.scope.scope_type`, value.scope.scope_type, { minLength: 1 });
275
+ validateString(errors, `${pathName}.scope.scope_id`, value.scope.scope_id, {
276
+ minLength: 1,
277
+ maxLength: 128,
278
+ pattern: ID_PATTERN,
279
+ });
280
+ }
281
+
282
+ const hasFontRefs = validateArray(errors, `${pathName}.font_refs`, value.font_refs, { minItems: 1 });
283
+ if (hasFontRefs) {
284
+ for (let index = 0; index < value.font_refs.length; index += 1) {
285
+ const fontRef = value.font_refs[index];
286
+ if (!isObject(fontRef)) {
287
+ pushError(errors, `${pathName}.font_refs[${index}]`, "must be an object");
288
+ continue;
289
+ }
290
+
291
+ validateString(errors, `${pathName}.font_refs[${index}].font_id`, fontRef.font_id, {
292
+ minLength: 1,
293
+ maxLength: 128,
294
+ pattern: ID_PATTERN,
295
+ });
296
+ validateString(errors, `${pathName}.font_refs[${index}].family_name`, fontRef.family_name, {
297
+ minLength: 1,
298
+ });
299
+ }
300
+ }
301
+
302
+ const hasActivatedRights = validateArray(
303
+ errors,
304
+ `${pathName}.activated_right_ids`,
305
+ value.activated_right_ids,
306
+ { minItems: 1 },
307
+ );
308
+ if (hasActivatedRights) {
309
+ for (let index = 0; index < value.activated_right_ids.length; index += 1) {
310
+ validateString(
311
+ errors,
312
+ `${pathName}.activated_right_ids[${index}]`,
313
+ value.activated_right_ids[index],
314
+ {
315
+ minLength: 1,
316
+ maxLength: 128,
317
+ pattern: ID_PATTERN,
318
+ },
319
+ );
320
+ }
321
+ }
322
+
323
+ validateString(errors, `${pathName}.status`, value.status, { enum: INSTANCE_STATUS });
324
+
325
+ const hasEvidence = validateArray(errors, `${pathName}.evidence`, value.evidence, { minItems: 1 });
326
+ if (hasEvidence) {
327
+ for (let index = 0; index < value.evidence.length; index += 1) {
328
+ validateEvidence(errors, `${pathName}.evidence[${index}]`, value.evidence[index]);
329
+ }
330
+ }
331
+
332
+ validateString(errors, `${pathName}.acquisition_source`, value.acquisition_source, {
333
+ enum: ACQUISITION_SOURCES,
334
+ });
335
+
336
+ if (value.metric_limits !== undefined) {
337
+ const hasMetricLimits = validateArray(errors, `${pathName}.metric_limits`, value.metric_limits);
338
+ if (hasMetricLimits) {
339
+ for (let index = 0; index < value.metric_limits.length; index += 1) {
340
+ validateMetricLimit(errors, `${pathName}.metric_limits[${index}]`, value.metric_limits[index]);
341
+ }
342
+ }
343
+ }
344
+ }
345
+
346
+ export async function validateManifestDocument(document) {
347
+ const errors = [];
348
+
349
+ if (!isObject(document)) {
350
+ pushError(errors, "/", "must be an object");
351
+ return { valid: false, errors };
352
+ }
353
+
354
+ if (document.manifest_version !== MANIFEST_VERSION) {
355
+ pushError(errors, "/manifest_version", `must be '${MANIFEST_VERSION}'`);
356
+ }
357
+
358
+ if (!isObject(document.project)) {
359
+ pushError(errors, "/project", "must be an object");
360
+ } else {
361
+ validateString(errors, "/project/project_id", document.project.project_id, {
362
+ minLength: 1,
363
+ maxLength: 128,
364
+ pattern: ID_PATTERN,
365
+ });
366
+ validateString(errors, "/project/name", document.project.name, { minLength: 1 });
367
+
368
+ if (document.project.domains !== undefined) {
369
+ const hasDomains = validateArray(errors, "/project/domains", document.project.domains);
370
+ if (hasDomains) {
371
+ for (let index = 0; index < document.project.domains.length; index += 1) {
372
+ validateString(errors, `/project/domains[${index}]`, document.project.domains[index], {
373
+ minLength: 1,
374
+ });
375
+ }
376
+ }
377
+ }
378
+ }
379
+
380
+ const hasLicensees = validateArray(errors, "/licensees", document.licensees, { minItems: 1 });
381
+ if (hasLicensees) {
382
+ for (let index = 0; index < document.licensees.length; index += 1) {
383
+ validateLicensee(errors, `/licensees[${index}]`, document.licensees[index]);
384
+ }
385
+ }
386
+
387
+ const hasFonts = validateArray(errors, "/fonts", document.fonts);
388
+ if (hasFonts) {
389
+ for (let index = 0; index < document.fonts.length; index += 1) {
390
+ validateFont(errors, `/fonts[${index}]`, document.fonts[index]);
391
+ }
392
+ }
393
+
394
+ const hasInstances = validateArray(errors, "/license_instances", document.license_instances);
395
+ if (hasInstances) {
396
+ for (let index = 0; index < document.license_instances.length; index += 1) {
397
+ validateLicenseInstance(errors, `/license_instances[${index}]`, document.license_instances[index]);
398
+ }
399
+ }
400
+
401
+ if (document.license_offerings !== undefined) {
402
+ const hasOfferings = validateArray(errors, "/license_offerings", document.license_offerings);
403
+ if (hasOfferings) {
404
+ for (let index = 0; index < document.license_offerings.length; index += 1) {
405
+ validateLicenseOffering(errors, `/license_offerings[${index}]`, document.license_offerings[index]);
406
+ }
407
+ }
408
+ }
409
+
410
+ return {
411
+ valid: errors.length === 0,
412
+ errors,
413
+ };
414
+ }
415
+
416
+ export async function validateLicenseDocument(document) {
417
+ const errors = [];
418
+
419
+ if (!isObject(document)) {
420
+ pushError(errors, "/", "must be an object");
421
+ return { valid: false, errors };
422
+ }
423
+
424
+ const kind = document.kind;
425
+
426
+ if (kind === "offering") {
427
+ validateLicenseOffering(errors, "/", document);
428
+ } else if (kind === "instance") {
429
+ validateLicenseInstance(errors, "/", document);
430
+ } else {
431
+ pushError(errors, "/kind", "must be either 'offering' or 'instance'");
432
+ }
433
+
434
+ return {
435
+ valid: errors.length === 0,
436
+ errors,
437
+ };
438
+ }
439
+
440
+ export async function assertValidManifest(document) {
441
+ const result = await validateManifestDocument(document);
442
+ if (!result.valid) {
443
+ throw new Error(`Manifest validation failed: ${result.errors.join("; ")}`);
444
+ }
445
+ }
446
+
447
+ export async function assertValidLicense(document) {
448
+ const result = await validateLicenseDocument(document);
449
+ if (!result.valid) {
450
+ throw new Error(`License validation failed: ${result.errors.join("; ")}`);
451
+ }
452
+ }
453
+
454
+ export function resolveManifestPath(options = {}) {
455
+ const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
456
+
457
+ if (options.manifestPath) {
458
+ return path.resolve(cwd, options.manifestPath);
459
+ }
460
+
461
+ const discoveredPath = findUp(MANIFEST_FILENAME, cwd);
462
+
463
+ if (discoveredPath) {
464
+ return discoveredPath;
465
+ }
466
+
467
+ if (options.required ?? true) {
468
+ throw new Error(
469
+ `Could not find ${MANIFEST_FILENAME} in ${cwd} or its parent directories. Run 'setzkasten init' first.`,
470
+ );
471
+ }
472
+
473
+ return path.join(cwd, MANIFEST_FILENAME);
474
+ }
475
+
476
+ export async function loadManifest(options = {}) {
477
+ const manifestPath = resolveManifestPath({
478
+ cwd: options.cwd,
479
+ manifestPath: options.manifestPath,
480
+ required: true,
481
+ });
482
+
483
+ const manifest = await readJsonFile(manifestPath);
484
+ await assertValidManifest(manifest);
485
+
486
+ return {
487
+ manifest,
488
+ manifestPath,
489
+ projectRoot: path.dirname(manifestPath),
490
+ };
491
+ }
492
+
493
+ export async function saveManifest(manifestPath, manifest) {
494
+ await assertValidManifest(manifest);
495
+ await writeJsonFileAtomic(manifestPath, manifest);
496
+ }
497
+
498
+ export function createManifest(input) {
499
+ const projectName = String(input.projectName ?? "").trim();
500
+ if (projectName.length === 0) {
501
+ throw new Error("projectName must not be empty.");
502
+ }
503
+
504
+ const projectId = input.projectId ?? slugifyId(projectName);
505
+ const licenseeId = input.licenseeId ?? `${projectId}.owner`;
506
+
507
+ const manifest = {
508
+ manifest_version: MANIFEST_VERSION,
509
+ project: {
510
+ project_id: projectId,
511
+ name: projectName,
512
+ },
513
+ licensees: [
514
+ {
515
+ licensee_id: licenseeId,
516
+ type: input.licenseeType ?? "organization",
517
+ legal_name: input.licenseeLegalName ?? projectName,
518
+ },
519
+ ],
520
+ fonts: [],
521
+ license_instances: [],
522
+ };
523
+
524
+ if (input.projectRepo) {
525
+ manifest.project.repo = input.projectRepo;
526
+ }
527
+
528
+ if (Array.isArray(input.projectDomains) && input.projectDomains.length > 0) {
529
+ manifest.project.domains = input.projectDomains;
530
+ }
531
+
532
+ if (input.licenseeCountry) {
533
+ manifest.licensees[0].country = input.licenseeCountry;
534
+ }
535
+
536
+ if (input.licenseeVatId) {
537
+ manifest.licensees[0].vat_id = input.licenseeVatId;
538
+ }
539
+
540
+ if (input.licenseeContactEmail) {
541
+ manifest.licensees[0].contact_email = input.licenseeContactEmail;
542
+ }
543
+
544
+ return manifest;
545
+ }
546
+
547
+ function deepClone(value) {
548
+ return JSON.parse(JSON.stringify(value));
549
+ }
550
+
551
+ function normalizeStringArray(value) {
552
+ if (!Array.isArray(value)) {
553
+ return [];
554
+ }
555
+
556
+ return value.filter((entry) => typeof entry === "string");
557
+ }
558
+
559
+ export function addFontToManifest(manifest, font) {
560
+ const draft = deepClone(manifest);
561
+ const fonts = Array.isArray(draft.fonts) ? draft.fonts : [];
562
+
563
+ if (fonts.some((entry) => isObject(entry) && entry.font_id === font.font_id)) {
564
+ throw new Error(`Font with font_id '${font.font_id}' already exists in manifest.`);
565
+ }
566
+
567
+ fonts.push({
568
+ font_id: font.font_id,
569
+ family_name: font.family_name,
570
+ source: font.source,
571
+ usage: font.usage,
572
+ active_license_instance_id: font.active_license_instance_id,
573
+ license_instance_ids: normalizeStringArray(font.license_instance_ids),
574
+ });
575
+
576
+ draft.fonts = fonts;
577
+ return draft;
578
+ }
579
+
580
+ export function removeFontFromManifest(manifest, fontId) {
581
+ const draft = deepClone(manifest);
582
+ const fonts = Array.isArray(draft.fonts) ? draft.fonts : [];
583
+
584
+ const filteredFonts = fonts.filter((entry) => !isObject(entry) || entry.font_id !== fontId);
585
+
586
+ draft.fonts = filteredFonts;
587
+
588
+ return {
589
+ manifest: draft,
590
+ removed: filteredFonts.length !== fonts.length,
591
+ };
592
+ }
593
+
594
+ export function getManifestProjectId(manifest) {
595
+ if (!isObject(manifest.project)) {
596
+ throw new Error("manifest.project must be an object.");
597
+ }
598
+
599
+ const projectId = manifest.project.project_id;
600
+ if (typeof projectId !== "string" || projectId.length === 0) {
601
+ throw new Error("manifest.project.project_id is required.");
602
+ }
603
+
604
+ return projectId;
605
+ }
606
+
607
+ export function getManifestDomains(manifest) {
608
+ if (!isObject(manifest.project)) {
609
+ return [];
610
+ }
611
+
612
+ return normalizeStringArray(manifest.project.domains);
613
+ }