@setzkasten/cli 0.1.0-rc.4 → 0.1.0-rc.5
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/README.md +12 -0
- package/package.json +1 -1
- package/src/index.js +90 -5
- package/src/lib/manifest-lib.js +143 -2
- package/src/lib/scanner.js +142 -2
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ CLI-first tool for font license governance, audit logging, and deterministic pol
|
|
|
7
7
|
- Writes an append-only event log (`.setzkasten/events.log`)
|
|
8
8
|
- Adds/removes font entries
|
|
9
9
|
- Scans controlled local assets for usage signals
|
|
10
|
+
- Discovers likely license files and computes deterministic `document_hash` values
|
|
11
|
+
- Links license evidence files to existing license instances (`evidence add`)
|
|
10
12
|
- Evaluates policy decisions (`allow`, `warn`, `escalate`)
|
|
11
13
|
- Generates deterministic quote output
|
|
12
14
|
- Provides a migration stub command
|
|
@@ -21,11 +23,21 @@ npm install -g @setzkasten/cli
|
|
|
21
23
|
setzkasten init --name "My Project"
|
|
22
24
|
setzkasten add --font-id inter --family "Inter" --source oss
|
|
23
25
|
setzkasten scan --path . --discover
|
|
26
|
+
setzkasten evidence add --license-id lic_inter_001 --file ./licenses/OFL.txt
|
|
24
27
|
setzkasten policy
|
|
25
28
|
setzkasten quote
|
|
26
29
|
setzkasten migrate
|
|
27
30
|
```
|
|
28
31
|
|
|
32
|
+
## License Evidence Workflow
|
|
33
|
+
1. Run `setzkasten scan --path . --discover` to list discovered fonts and likely license files.
|
|
34
|
+
2. Review `result.discovered_license_files` in JSON output (`path`, `document_hash`, `detected_license`, `matched_font_ids`).
|
|
35
|
+
3. Link the local license file to a license instance:
|
|
36
|
+
```bash
|
|
37
|
+
setzkasten evidence add --license-id <license_id> --file <path-to-license-file>
|
|
38
|
+
```
|
|
39
|
+
4. Run `setzkasten policy` to verify BYO evidence state.
|
|
40
|
+
|
|
29
41
|
## Data written locally
|
|
30
42
|
- `LICENSE_MANIFEST.json`
|
|
31
43
|
- `.setzkasten/events.log`
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
|
-
import { access } from "node:fs/promises";
|
|
3
|
+
import { access, readFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import process from "node:process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { MANIFEST_FILENAME, parseListFlag, slugifyId } from "./lib/core.js";
|
|
7
|
+
import { MANIFEST_FILENAME, parseListFlag, sha256Hex, slugifyId } from "./lib/core.js";
|
|
8
8
|
import { appendProjectEvent } from "./lib/events.js";
|
|
9
9
|
import {
|
|
10
10
|
addFontToManifest,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
loadManifest,
|
|
14
14
|
removeFontFromManifest,
|
|
15
15
|
saveManifest,
|
|
16
|
+
upsertLicenseEvidence,
|
|
16
17
|
} from "./lib/manifest-lib.js";
|
|
17
18
|
import { evaluatePolicy } from "./lib/policy.js";
|
|
18
19
|
import { generateQuote } from "./lib/quote.js";
|
|
@@ -28,7 +29,8 @@ Commands:
|
|
|
28
29
|
init Create ${MANIFEST_FILENAME} and .setzkasten/events.log
|
|
29
30
|
add Add font entry to manifest
|
|
30
31
|
remove Remove font entry from manifest
|
|
31
|
-
scan Scan local repository usage and optionally discover font files
|
|
32
|
+
scan Scan local repository usage and optionally discover font/license files
|
|
33
|
+
evidence Attach/update license evidence from local files
|
|
32
34
|
policy Evaluate policy decision (allow|warn|escalate)
|
|
33
35
|
quote Generate deterministic quote from license schema data
|
|
34
36
|
migrate Generate migration stub plan
|
|
@@ -37,13 +39,20 @@ Common options:
|
|
|
37
39
|
--manifest <path> Explicit path to ${MANIFEST_FILENAME}
|
|
38
40
|
Scan options:
|
|
39
41
|
--path <dir> Directory to scan (default: project root)
|
|
40
|
-
--discover
|
|
41
|
-
--max-discovered-files <n>
|
|
42
|
+
--discover Discover existing font and license files
|
|
43
|
+
--max-discovered-files <n> Max discovered font files in output (default: 200)
|
|
44
|
+
--max-discovered-license-files <n> Max discovered license files in output (default: 200)
|
|
45
|
+
Evidence options:
|
|
46
|
+
setzkasten evidence add --license-id <id> --file <path>
|
|
47
|
+
[--type <type>] [--evidence-id <id>] [--document-name <name>]
|
|
48
|
+
[--document-url <uri>] [--reference <id>] [--issuer <name>]
|
|
49
|
+
[--purchased-at <iso-date-time>] [--notes <text>]
|
|
42
50
|
|
|
43
51
|
Examples:
|
|
44
52
|
setzkasten init --name "Acme Project"
|
|
45
53
|
setzkasten add --font-id inter --family "Inter" --source oss
|
|
46
54
|
setzkasten scan --path . --discover
|
|
55
|
+
setzkasten evidence add --license-id lic_web_001 --file ./licenses/OFL.txt
|
|
47
56
|
setzkasten policy --fail-on escalate
|
|
48
57
|
`;
|
|
49
58
|
|
|
@@ -339,6 +348,7 @@ async function handleScan(cwd, flags) {
|
|
|
339
348
|
const scanRoot = path.resolve(cwd, getStringFlag(flags, "path") ?? projectRoot);
|
|
340
349
|
const maxMatchedPaths = Number(getStringFlag(flags, "max-matched-paths") ?? "30");
|
|
341
350
|
const maxDiscoveredFiles = Number(getStringFlag(flags, "max-discovered-files") ?? "200");
|
|
351
|
+
const maxDiscoveredLicenseFiles = Number(getStringFlag(flags, "max-discovered-license-files") ?? "200");
|
|
342
352
|
const discover = getBooleanFlag(flags, "discover");
|
|
343
353
|
|
|
344
354
|
const scanResult = await scanProject({
|
|
@@ -346,6 +356,7 @@ async function handleScan(cwd, flags) {
|
|
|
346
356
|
manifest,
|
|
347
357
|
maxMatchedPathsPerFont: Number.isFinite(maxMatchedPaths) ? maxMatchedPaths : 30,
|
|
348
358
|
maxDiscoveredFiles: Number.isFinite(maxDiscoveredFiles) ? maxDiscoveredFiles : 200,
|
|
359
|
+
maxDiscoveredLicenseFiles: Number.isFinite(maxDiscoveredLicenseFiles) ? maxDiscoveredLicenseFiles : 200,
|
|
349
360
|
discover,
|
|
350
361
|
});
|
|
351
362
|
|
|
@@ -362,6 +373,9 @@ async function handleScan(cwd, flags) {
|
|
|
362
373
|
discovered_font_files_count: Array.isArray(scanResult.discovered_font_files)
|
|
363
374
|
? scanResult.discovered_font_files.length
|
|
364
375
|
: 0,
|
|
376
|
+
discovered_license_files_count: Array.isArray(scanResult.discovered_license_files)
|
|
377
|
+
? scanResult.discovered_license_files.length
|
|
378
|
+
: 0,
|
|
365
379
|
discover_enabled: discover,
|
|
366
380
|
root_path: scanResult.root_path,
|
|
367
381
|
},
|
|
@@ -376,6 +390,75 @@ async function handleScan(cwd, flags) {
|
|
|
376
390
|
return 0;
|
|
377
391
|
}
|
|
378
392
|
|
|
393
|
+
async function handleEvidenceAdd(cwd, flags) {
|
|
394
|
+
const manifestPath = resolveManifestPathFromFlag(cwd, flags);
|
|
395
|
+
const { manifest, manifestPath: resolvedManifestPath, projectRoot } = await loadManifest({
|
|
396
|
+
cwd,
|
|
397
|
+
manifestPath,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const licenseId = requireStringFlag(flags, "license-id");
|
|
401
|
+
const documentPath = path.resolve(cwd, requireStringFlag(flags, "file"));
|
|
402
|
+
|
|
403
|
+
let documentBuffer;
|
|
404
|
+
try {
|
|
405
|
+
documentBuffer = await readFile(documentPath);
|
|
406
|
+
} catch {
|
|
407
|
+
throw new Error(`Could not read evidence file at ${documentPath}.`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const upsertResult = upsertLicenseEvidence(manifest, {
|
|
411
|
+
licenseId,
|
|
412
|
+
evidenceId: getStringFlag(flags, "evidence-id"),
|
|
413
|
+
type: getStringFlag(flags, "type") ?? "other",
|
|
414
|
+
documentHash: sha256Hex(documentBuffer),
|
|
415
|
+
documentName: getStringFlag(flags, "document-name") ?? path.basename(documentPath),
|
|
416
|
+
documentPath: path.relative(projectRoot, documentPath),
|
|
417
|
+
documentUrl: getStringFlag(flags, "document-url"),
|
|
418
|
+
reference: getStringFlag(flags, "reference"),
|
|
419
|
+
issuer: getStringFlag(flags, "issuer"),
|
|
420
|
+
purchasedAt: getStringFlag(flags, "purchased-at"),
|
|
421
|
+
notes: getStringFlag(flags, "notes"),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
await saveManifest(resolvedManifestPath, upsertResult.manifest);
|
|
425
|
+
|
|
426
|
+
await appendProjectEvent({
|
|
427
|
+
projectRoot,
|
|
428
|
+
projectId: getManifestProjectId(upsertResult.manifest),
|
|
429
|
+
eventType: "manifest.license_ref_added",
|
|
430
|
+
payload: {
|
|
431
|
+
action: upsertResult.action,
|
|
432
|
+
license_id: upsertResult.license_id,
|
|
433
|
+
evidence_id: upsertResult.evidence.evidence_id,
|
|
434
|
+
evidence_type: upsertResult.evidence.type,
|
|
435
|
+
document_hash: upsertResult.evidence.document_hash,
|
|
436
|
+
document_name: upsertResult.evidence.document_name ?? path.basename(documentPath),
|
|
437
|
+
file_path: path.relative(projectRoot, documentPath),
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
printJson({
|
|
442
|
+
ok: true,
|
|
443
|
+
command: "evidence",
|
|
444
|
+
action: "add",
|
|
445
|
+
manifest_path: resolvedManifestPath,
|
|
446
|
+
result: upsertResult,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
return 0;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function handleEvidence(cwd, flags, positionals) {
|
|
453
|
+
const action = positionals[0] ?? "add";
|
|
454
|
+
|
|
455
|
+
if (action !== "add") {
|
|
456
|
+
throw new Error(`Unknown evidence action '${action}'. Supported: add`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return handleEvidenceAdd(cwd, flags);
|
|
460
|
+
}
|
|
461
|
+
|
|
379
462
|
async function handlePolicy(cwd, flags) {
|
|
380
463
|
const manifestPath = resolveManifestPathFromFlag(cwd, flags);
|
|
381
464
|
const { manifest, projectRoot } = await loadManifest({
|
|
@@ -490,6 +573,8 @@ export async function runCli(argv = process.argv.slice(2), cwd = process.cwd())
|
|
|
490
573
|
return handleRemove(cwd, parsed.flags);
|
|
491
574
|
case "scan":
|
|
492
575
|
return handleScan(cwd, parsed.flags);
|
|
576
|
+
case "evidence":
|
|
577
|
+
return handleEvidence(cwd, parsed.flags, parsed.positionals);
|
|
493
578
|
case "policy":
|
|
494
579
|
return handlePolicy(cwd, parsed.flags);
|
|
495
580
|
case "quote":
|
package/src/lib/manifest-lib.js
CHANGED
|
@@ -16,6 +16,7 @@ const SOURCE_TYPES = new Set(["oss", "byo"]);
|
|
|
16
16
|
const OFFERING_TYPES = new Set(["commercial", "trial"]);
|
|
17
17
|
const INSTANCE_STATUS = new Set(["active", "expired", "superseded", "revoked"]);
|
|
18
18
|
const ACQUISITION_SOURCES = new Set(["direct_foundry", "reseller", "marketplace", "legacy"]);
|
|
19
|
+
const SHA256_PATTERN = /^[A-Fa-f0-9]{64}$/;
|
|
19
20
|
|
|
20
21
|
function isObject(value) {
|
|
21
22
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
@@ -164,7 +165,7 @@ function validateEvidence(errors, pathName, value) {
|
|
|
164
165
|
});
|
|
165
166
|
validateString(errors, `${pathName}.type`, value.type, { minLength: 1 });
|
|
166
167
|
validateString(errors, `${pathName}.document_hash`, value.document_hash, {
|
|
167
|
-
pattern:
|
|
168
|
+
pattern: SHA256_PATTERN,
|
|
168
169
|
});
|
|
169
170
|
}
|
|
170
171
|
|
|
@@ -322,7 +323,7 @@ function validateLicenseInstance(errors, pathName, value) {
|
|
|
322
323
|
|
|
323
324
|
validateString(errors, `${pathName}.status`, value.status, { enum: INSTANCE_STATUS });
|
|
324
325
|
|
|
325
|
-
const hasEvidence = validateArray(errors, `${pathName}.evidence`, value.evidence, { minItems:
|
|
326
|
+
const hasEvidence = validateArray(errors, `${pathName}.evidence`, value.evidence, { minItems: 0 });
|
|
326
327
|
if (hasEvidence) {
|
|
327
328
|
for (let index = 0; index < value.evidence.length; index += 1) {
|
|
328
329
|
validateEvidence(errors, `${pathName}.evidence[${index}]`, value.evidence[index]);
|
|
@@ -556,6 +557,15 @@ function normalizeStringArray(value) {
|
|
|
556
557
|
return value.filter((entry) => typeof entry === "string");
|
|
557
558
|
}
|
|
558
559
|
|
|
560
|
+
function normalizeOptionalString(value) {
|
|
561
|
+
if (typeof value !== "string") {
|
|
562
|
+
return undefined;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const normalized = value.trim();
|
|
566
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
567
|
+
}
|
|
568
|
+
|
|
559
569
|
export function addFontToManifest(manifest, font) {
|
|
560
570
|
const draft = deepClone(manifest);
|
|
561
571
|
const fonts = Array.isArray(draft.fonts) ? draft.fonts : [];
|
|
@@ -591,6 +601,137 @@ export function removeFontFromManifest(manifest, fontId) {
|
|
|
591
601
|
};
|
|
592
602
|
}
|
|
593
603
|
|
|
604
|
+
export function upsertLicenseEvidence(manifest, input) {
|
|
605
|
+
const licenseId = normalizeOptionalString(input?.licenseId);
|
|
606
|
+
if (!licenseId) {
|
|
607
|
+
throw new Error("licenseId is required.");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const documentHash = normalizeOptionalString(input?.documentHash);
|
|
611
|
+
if (!documentHash || !SHA256_PATTERN.test(documentHash)) {
|
|
612
|
+
throw new Error("documentHash must be a 64-char sha256 hex string.");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const draft = deepClone(manifest);
|
|
616
|
+
const instances = Array.isArray(draft.license_instances) ? draft.license_instances : [];
|
|
617
|
+
const instanceIndex = instances.findIndex(
|
|
618
|
+
(entry) => isObject(entry) && typeof entry.license_id === "string" && entry.license_id === licenseId,
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
if (instanceIndex < 0) {
|
|
622
|
+
throw new Error(`License instance '${licenseId}' not found in manifest.`);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const instance = instances[instanceIndex];
|
|
626
|
+
const currentEvidence = Array.isArray(instance.evidence) ? instance.evidence : [];
|
|
627
|
+
|
|
628
|
+
const evidenceType = normalizeOptionalString(input?.type) ?? "other";
|
|
629
|
+
const documentName =
|
|
630
|
+
normalizeOptionalString(input?.documentName) ??
|
|
631
|
+
normalizeOptionalString(input?.documentPath) ??
|
|
632
|
+
"license-document";
|
|
633
|
+
const requestedEvidenceId = normalizeOptionalString(input?.evidenceId);
|
|
634
|
+
|
|
635
|
+
if (requestedEvidenceId && !ID_PATTERN.test(requestedEvidenceId)) {
|
|
636
|
+
throw new Error("evidenceId has invalid format.");
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const generatedEvidenceId = slugifyId(`${path.parse(documentName).name}-${documentHash.slice(0, 8)}`, "evidence");
|
|
640
|
+
const evidenceId = requestedEvidenceId ?? generatedEvidenceId;
|
|
641
|
+
|
|
642
|
+
const nextEvidence = {
|
|
643
|
+
evidence_id: evidenceId,
|
|
644
|
+
type: evidenceType,
|
|
645
|
+
document_hash: documentHash,
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const reference = normalizeOptionalString(input?.reference);
|
|
649
|
+
if (reference) {
|
|
650
|
+
nextEvidence.reference = reference;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const issuer = normalizeOptionalString(input?.issuer);
|
|
654
|
+
if (issuer) {
|
|
655
|
+
nextEvidence.issuer = issuer;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const documentUrl = normalizeOptionalString(input?.documentUrl);
|
|
659
|
+
if (documentUrl) {
|
|
660
|
+
try {
|
|
661
|
+
new URL(documentUrl);
|
|
662
|
+
} catch {
|
|
663
|
+
throw new Error("documentUrl must be a valid URI.");
|
|
664
|
+
}
|
|
665
|
+
nextEvidence.document_url = documentUrl;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const purchasedAt = normalizeOptionalString(input?.purchasedAt);
|
|
669
|
+
if (purchasedAt) {
|
|
670
|
+
nextEvidence.purchased_at = purchasedAt;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const notes = normalizeOptionalString(input?.notes);
|
|
674
|
+
if (notes) {
|
|
675
|
+
nextEvidence.notes = notes;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const normalizedDocumentName = normalizeOptionalString(input?.documentName);
|
|
679
|
+
if (normalizedDocumentName) {
|
|
680
|
+
nextEvidence.document_name = normalizedDocumentName;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
let action = "added";
|
|
684
|
+
let appliedEvidence = nextEvidence;
|
|
685
|
+
|
|
686
|
+
const byIdIndex = currentEvidence.findIndex(
|
|
687
|
+
(entry) => isObject(entry) && typeof entry.evidence_id === "string" && entry.evidence_id === evidenceId,
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
if (byIdIndex >= 0) {
|
|
691
|
+
appliedEvidence = {
|
|
692
|
+
...currentEvidence[byIdIndex],
|
|
693
|
+
...nextEvidence,
|
|
694
|
+
};
|
|
695
|
+
currentEvidence[byIdIndex] = appliedEvidence;
|
|
696
|
+
action = "updated";
|
|
697
|
+
} else {
|
|
698
|
+
const byHashIndex = currentEvidence.findIndex(
|
|
699
|
+
(entry) =>
|
|
700
|
+
isObject(entry) &&
|
|
701
|
+
typeof entry.document_hash === "string" &&
|
|
702
|
+
entry.document_hash.toLowerCase() === documentHash.toLowerCase(),
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
if (byHashIndex >= 0) {
|
|
706
|
+
const existing = currentEvidence[byHashIndex];
|
|
707
|
+
appliedEvidence = {
|
|
708
|
+
...existing,
|
|
709
|
+
...nextEvidence,
|
|
710
|
+
evidence_id:
|
|
711
|
+
typeof existing.evidence_id === "string" && existing.evidence_id.length > 0
|
|
712
|
+
? existing.evidence_id
|
|
713
|
+
: evidenceId,
|
|
714
|
+
};
|
|
715
|
+
currentEvidence[byHashIndex] = appliedEvidence;
|
|
716
|
+
action = "updated";
|
|
717
|
+
} else {
|
|
718
|
+
currentEvidence.push(nextEvidence);
|
|
719
|
+
action = "added";
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
instance.evidence = currentEvidence;
|
|
724
|
+
instances[instanceIndex] = instance;
|
|
725
|
+
draft.license_instances = instances;
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
manifest: draft,
|
|
729
|
+
action,
|
|
730
|
+
license_id: licenseId,
|
|
731
|
+
evidence: appliedEvidence,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
594
735
|
export function getManifestProjectId(manifest) {
|
|
595
736
|
if (!isObject(manifest.project)) {
|
|
596
737
|
throw new Error("manifest.project must be an object.");
|
package/src/lib/scanner.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { nowIso, slugifyId } from "./core.js";
|
|
3
|
+
import { nowIso, sha256Hex, slugifyId } from "./core.js";
|
|
4
4
|
|
|
5
5
|
const DEFAULT_IGNORED_DIRS = new Set([
|
|
6
6
|
".git",
|
|
@@ -31,6 +31,8 @@ const TEXT_FILE_EXTENSIONS = new Set([
|
|
|
31
31
|
]);
|
|
32
32
|
|
|
33
33
|
const FONT_FILE_EXTENSIONS = new Set([".woff2", ".woff", ".ttf", ".otf", ".otc"]);
|
|
34
|
+
const LICENSE_FILE_EXTENSIONS = new Set(["", ".txt", ".md", ".pdf", ".rtf", ".html", ".htm"]);
|
|
35
|
+
const LICENSE_FILE_NAME_PATTERN = /(license|licence|eula|ofl|fontlog|copying|copyright)/i;
|
|
34
36
|
|
|
35
37
|
const STYLE_TOKENS = new Set([
|
|
36
38
|
"regular",
|
|
@@ -71,9 +73,26 @@ function shouldDiscoverFontFile(filePath) {
|
|
|
71
73
|
return FONT_FILE_EXTENSIONS.has(extension);
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
function shouldDiscoverLicenseFile(filePath) {
|
|
77
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
78
|
+
const fileName = path.basename(filePath).toLowerCase();
|
|
79
|
+
|
|
80
|
+
if (!LICENSE_FILE_EXTENSIONS.has(extension)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return LICENSE_FILE_NAME_PATTERN.test(fileName);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isLikelyTextLicenseFile(filePath) {
|
|
88
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
89
|
+
return extension === "" || extension === ".txt" || extension === ".md" || extension === ".html" || extension === ".htm";
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
async function collectProjectFiles(rootPath) {
|
|
75
93
|
const textFiles = [];
|
|
76
94
|
const fontFiles = [];
|
|
95
|
+
const licenseFiles = [];
|
|
77
96
|
|
|
78
97
|
async function walk(dirPath) {
|
|
79
98
|
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
@@ -109,6 +128,10 @@ async function collectProjectFiles(rootPath) {
|
|
|
109
128
|
if (shouldDiscoverFontFile(fullPath)) {
|
|
110
129
|
fontFiles.push(fullPath);
|
|
111
130
|
}
|
|
131
|
+
|
|
132
|
+
if (shouldDiscoverLicenseFile(fullPath)) {
|
|
133
|
+
licenseFiles.push(fullPath);
|
|
134
|
+
}
|
|
112
135
|
}
|
|
113
136
|
}
|
|
114
137
|
|
|
@@ -117,6 +140,7 @@ async function collectProjectFiles(rootPath) {
|
|
|
117
140
|
return {
|
|
118
141
|
textFiles,
|
|
119
142
|
fontFiles,
|
|
143
|
+
licenseFiles,
|
|
120
144
|
};
|
|
121
145
|
}
|
|
122
146
|
|
|
@@ -174,11 +198,103 @@ function discoverFontFiles(rootPath, fontFiles, maxDiscoveredFiles) {
|
|
|
174
198
|
.slice(0, maxDiscoveredFiles);
|
|
175
199
|
}
|
|
176
200
|
|
|
201
|
+
function detectLicenseKind(fileName, contentLower) {
|
|
202
|
+
if (contentLower) {
|
|
203
|
+
if (
|
|
204
|
+
contentLower.includes("sil open font license") ||
|
|
205
|
+
contentLower.includes("ofl version 1.1") ||
|
|
206
|
+
contentLower.includes("scripts.sil.org/ofl")
|
|
207
|
+
) {
|
|
208
|
+
return "sil_ofl_1_1";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (contentLower.includes("apache license") && contentLower.includes("version 2.0")) {
|
|
212
|
+
return "apache_2_0";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (contentLower.includes("mit license")) {
|
|
216
|
+
return "mit";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (contentLower.includes("gnu general public license")) {
|
|
220
|
+
return "gpl";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (contentLower.includes("mozilla public license")) {
|
|
224
|
+
return "mpl";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (contentLower.includes("eula") || contentLower.includes("end user license agreement")) {
|
|
228
|
+
return "eula";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (fileName.toLowerCase().includes("ofl")) {
|
|
233
|
+
return "sil_ofl_1_1";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (fileName.toLowerCase().includes("eula")) {
|
|
237
|
+
return "eula";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function matchFontIdsFromLicenseContent(fonts, contentLower) {
|
|
244
|
+
if (!contentLower) {
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return fonts
|
|
249
|
+
.filter((font) => contentLower.includes(font.family_name.toLowerCase()))
|
|
250
|
+
.map((font) => font.font_id)
|
|
251
|
+
.sort((a, b) => a.localeCompare(b));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function discoverLicenseFiles(rootPath, licenseFiles, fonts, maxDiscoveredLicenseFiles) {
|
|
255
|
+
const discovered = [];
|
|
256
|
+
|
|
257
|
+
for (const filePath of licenseFiles) {
|
|
258
|
+
let fileBuffer;
|
|
259
|
+
let fileStat;
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
fileBuffer = await readFile(filePath);
|
|
263
|
+
fileStat = await stat(filePath);
|
|
264
|
+
} catch {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
269
|
+
const fileName = path.basename(filePath);
|
|
270
|
+
const relativePath = relativeTo(rootPath, filePath);
|
|
271
|
+
const contentLower =
|
|
272
|
+
isLikelyTextLicenseFile(filePath) && fileBuffer.length <= 512 * 1024
|
|
273
|
+
? fileBuffer.toString("utf8").toLowerCase()
|
|
274
|
+
: null;
|
|
275
|
+
|
|
276
|
+
discovered.push({
|
|
277
|
+
path: relativePath,
|
|
278
|
+
extension,
|
|
279
|
+
file_name: fileName,
|
|
280
|
+
size_bytes: fileStat.size,
|
|
281
|
+
document_hash: sha256Hex(fileBuffer),
|
|
282
|
+
detected_license: detectLicenseKind(fileName, contentLower),
|
|
283
|
+
matched_font_ids: matchFontIdsFromLicenseContent(fonts, contentLower),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return discovered
|
|
288
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
289
|
+
.slice(0, maxDiscoveredLicenseFiles);
|
|
290
|
+
}
|
|
291
|
+
|
|
177
292
|
export async function scanProject(input) {
|
|
178
293
|
const rootPath = path.resolve(input.rootPath);
|
|
179
294
|
const maxMatchedPathsPerFont = input.maxMatchedPathsPerFont ?? 30;
|
|
180
295
|
const discover = input.discover === true;
|
|
181
296
|
const maxDiscoveredFiles = input.maxDiscoveredFiles ?? 200;
|
|
297
|
+
const maxDiscoveredLicenseFiles = input.maxDiscoveredLicenseFiles ?? 200;
|
|
182
298
|
|
|
183
299
|
const fonts = normalizeFonts(input.manifest)
|
|
184
300
|
.map((font) => ({
|
|
@@ -187,7 +303,7 @@ export async function scanProject(input) {
|
|
|
187
303
|
}))
|
|
188
304
|
.filter((font) => font.font_id.length > 0 && font.family_name.length > 0);
|
|
189
305
|
|
|
190
|
-
const { textFiles, fontFiles } = await collectProjectFiles(rootPath);
|
|
306
|
+
const { textFiles, fontFiles, licenseFiles } = await collectProjectFiles(rootPath);
|
|
191
307
|
const matches = new Map();
|
|
192
308
|
|
|
193
309
|
for (const font of fonts) {
|
|
@@ -227,18 +343,38 @@ export async function scanProject(input) {
|
|
|
227
343
|
}
|
|
228
344
|
}
|
|
229
345
|
|
|
346
|
+
const discoveredLicenseFiles = discover
|
|
347
|
+
? await discoverLicenseFiles(rootPath, licenseFiles, fonts, maxDiscoveredLicenseFiles)
|
|
348
|
+
: [];
|
|
349
|
+
|
|
230
350
|
return {
|
|
231
351
|
scanned_at: nowIso(),
|
|
232
352
|
root_path: rootPath,
|
|
233
353
|
scanned_files_count: textFiles.length,
|
|
234
354
|
font_matches: Object.fromEntries(Array.from(matches.entries())),
|
|
235
355
|
discovered_font_files: discover ? discoverFontFiles(rootPath, fontFiles, maxDiscoveredFiles) : [],
|
|
356
|
+
discovered_license_files: discoveredLicenseFiles,
|
|
236
357
|
};
|
|
237
358
|
}
|
|
238
359
|
|
|
239
360
|
export function applyScanResultToManifest(manifest, scanResult) {
|
|
240
361
|
const draft = deepClone(manifest);
|
|
241
362
|
const fonts = Array.isArray(draft.fonts) ? draft.fonts : [];
|
|
363
|
+
const licenseMatchesByFont = new Map();
|
|
364
|
+
|
|
365
|
+
const discoveredLicenseFiles = Array.isArray(scanResult.discovered_license_files)
|
|
366
|
+
? scanResult.discovered_license_files
|
|
367
|
+
: [];
|
|
368
|
+
|
|
369
|
+
for (const licenseFile of discoveredLicenseFiles) {
|
|
370
|
+
const matchedFontIds = Array.isArray(licenseFile.matched_font_ids) ? licenseFile.matched_font_ids : [];
|
|
371
|
+
for (const fontId of matchedFontIds) {
|
|
372
|
+
if (!licenseMatchesByFont.has(fontId)) {
|
|
373
|
+
licenseMatchesByFont.set(fontId, []);
|
|
374
|
+
}
|
|
375
|
+
licenseMatchesByFont.get(fontId).push(licenseFile.path);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
242
378
|
|
|
243
379
|
for (const font of fonts) {
|
|
244
380
|
const fontId = typeof font.font_id === "string" ? font.font_id : "";
|
|
@@ -250,12 +386,16 @@ export function applyScanResultToManifest(manifest, scanResult) {
|
|
|
250
386
|
const currentUsage =
|
|
251
387
|
font.usage && typeof font.usage === "object" && !Array.isArray(font.usage) ? font.usage : {};
|
|
252
388
|
|
|
389
|
+
const matchedLicensePaths = licenseMatchesByFont.get(fontId) ?? [];
|
|
390
|
+
|
|
253
391
|
font.usage = {
|
|
254
392
|
...currentUsage,
|
|
255
393
|
scan: {
|
|
256
394
|
scanned_at: scanResult.scanned_at,
|
|
257
395
|
match_count: match?.match_count ?? 0,
|
|
258
396
|
matched_paths: match?.matched_paths ?? [],
|
|
397
|
+
license_match_count: matchedLicensePaths.length,
|
|
398
|
+
license_matched_paths: matchedLicensePaths.slice(0, 30),
|
|
259
399
|
},
|
|
260
400
|
};
|
|
261
401
|
}
|