@typecad/cuttlefish 1.0.0-alpha.10 → 1.0.0-alpha.12

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,385 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Shared SPDX license-detection core
3
+ //
4
+ // Framework-agnostic license resolution for the `cuttlefish licenses` command.
5
+ // The pure detection engine (SPDX table, marker matching, copyleft
6
+ // classification) and the file-based license resolver (LICENSE file + source
7
+ // header comments + an injected manifest reader) live here so every framework
8
+ // package can reuse them. Each framework supplies only its own dependency
9
+ // enumeration (arduino-cli lib list, west list, …) and a manifest reader, then
10
+ // calls resolveLibraryLicense.
11
+ //
12
+ // Nothing here imports a framework package or shells out to a toolchain: all
13
+ // filesystem access is through injected readFile/readdir seams, so the core is
14
+ // deterministic and unit-testable without spawning.
15
+ // ---------------------------------------------------------------------------
16
+ import path from "node:path";
17
+ export const SPDX_TABLE = [
18
+ {
19
+ id: "MIT",
20
+ risk: "permissive",
21
+ aliases: ["MIT", "MIT-0", "Expat"],
22
+ markers: ["permission is hereby granted, free of charge"],
23
+ shortMarkers: ["mit licence", "mit license"],
24
+ },
25
+ {
26
+ id: "BSD-3-Clause",
27
+ risk: "permissive",
28
+ aliases: ["BSD-3", "BSD-3-Clause", "BSD", "New BSD"],
29
+ markers: [
30
+ "redistribution and use in source and binary forms",
31
+ "neither the name",
32
+ ],
33
+ // Adafruit's header convention: "BSD license, all text here/above must be
34
+ // included in any redistribution." Adafruit declares these as BSD-3.
35
+ shortMarkers: ["bsd license, all text", "bsd license. all text"],
36
+ },
37
+ {
38
+ id: "BSD-2-Clause",
39
+ risk: "permissive",
40
+ aliases: ["BSD-2", "BSD-2-Clause", "FreeBSD"],
41
+ markers: [
42
+ "redistribution and use in source and binary forms",
43
+ "redistributions of source code must retain the above copyright notice",
44
+ ],
45
+ shortMarkers: [],
46
+ },
47
+ {
48
+ id: "Apache-2.0",
49
+ risk: "permissive",
50
+ aliases: ["Apache-2.0", "Apache 2.0", "Apache-2", "ASL-2.0"],
51
+ markers: ["apache license", "version 2.0"],
52
+ // ArduinoHttpClient header: "Released under Apache License, version 2.0"
53
+ shortMarkers: ["apache license, version 2.0", "under apache license"],
54
+ },
55
+ {
56
+ id: "LGPL-2.1",
57
+ risk: "weak-copyleft",
58
+ aliases: ["LGPL-2.1", "LGPL-2.1-only", "LGPL-2.1-or-later", "Lesser GPL 2.1"],
59
+ markers: ["gnu lesser general public license", "version 2.1"],
60
+ // ESP32Servo header: "GNU Lesser General Public ... version 2.1"
61
+ shortMarkers: ["gnu lesser general public", "version 2.1"],
62
+ },
63
+ {
64
+ id: "LGPL-3.0",
65
+ risk: "weak-copyleft",
66
+ aliases: ["LGPL-3.0", "LGPL-3", "LGPL-3.0-only", "LGPL-3.0-or-later"],
67
+ markers: ["gnu lesser general public license", "version 3"],
68
+ shortMarkers: [],
69
+ },
70
+ {
71
+ id: "GPL-2.0",
72
+ risk: "strong-copyleft",
73
+ aliases: ["GPL-2.0", "GPL-2", "GPLv2", "GPL-2.0-only", "GPL-2.0-or-later"],
74
+ markers: ["gnu general public license", "version 2"],
75
+ shortMarkers: [],
76
+ },
77
+ {
78
+ id: "GPL-3.0",
79
+ risk: "strong-copyleft",
80
+ aliases: ["GPL-3.0", "GPL-3", "GPLv3", "GPL-3.0-only", "GPL-3.0-or-later"],
81
+ markers: ["gnu general public license", "version 3"],
82
+ shortMarkers: [],
83
+ },
84
+ {
85
+ id: "AGPL-3.0",
86
+ risk: "strong-copyleft",
87
+ aliases: ["AGPL-3.0", "AGPL-3", "Affero GPL 3", "AGPL-3.0-only", "AGPL-3.0-or-later"],
88
+ markers: ["gnu affero general public license"],
89
+ shortMarkers: [],
90
+ },
91
+ {
92
+ id: "Unlicense",
93
+ risk: "permissive",
94
+ aliases: ["Unlicense", "The Unlicense"],
95
+ markers: [
96
+ "this is free and unencumbered software released into the public domain",
97
+ ],
98
+ shortMarkers: [],
99
+ },
100
+ {
101
+ id: "CC-BY-4.0",
102
+ risk: "permissive",
103
+ aliases: ["CC-BY-4.0", "Creative Commons Attribution 4.0", "cc by 4.0"],
104
+ markers: ["creative commons attribution 4.0"],
105
+ shortMarkers: [],
106
+ },
107
+ {
108
+ id: "CC-BY-SA-4.0",
109
+ risk: "strong-copyleft",
110
+ aliases: [
111
+ "CC-BY-SA-4.0",
112
+ "Creative Commons Attribution-ShareAlike 4.0",
113
+ "cc by-sa 4.0",
114
+ ],
115
+ markers: ["creative commons attribution-sharealike 4.0"],
116
+ shortMarkers: [],
117
+ },
118
+ {
119
+ id: "CC-BY-NC-4.0",
120
+ risk: "strong-copyleft",
121
+ aliases: [
122
+ "CC-BY-NC-4.0",
123
+ "Creative Commons Attribution-NonCommercial 4.0",
124
+ "cc by-nc 4.0",
125
+ ],
126
+ markers: ["creative commons attribution-noncommercial 4.0"],
127
+ shortMarkers: [],
128
+ },
129
+ ];
130
+ export const RISK_RANK = {
131
+ "strong-copyleft": 0,
132
+ "weak-copyleft": 1,
133
+ permissive: 2,
134
+ unknown: 3,
135
+ };
136
+ /**
137
+ * Candidate LICENSE filenames checked case-insensitively. Includes the British
138
+ * "LICENCE" spelling (e.g. lvgl ships LICENCE.txt) and the `.rst`
139
+ * (reStructuredText) form common in Zephyr/Linux modules (e.g.
140
+ * trusted-firmware-m ships `license.rst`).
141
+ *
142
+ * Deliberately does NOT match per-component `COPYING.<spec>` files (e.g.
143
+ * picolibc's COPYING.GPL2 / COPYING.NEWLIB / COPYING.picolibc): those name a
144
+ * specific license rather than the project license, and matching COPYING.GPL2
145
+ * would falsely flag a BSD project as strong-copyleft.
146
+ */
147
+ export const LICENSE_FILENAMES = [
148
+ "LICENSE",
149
+ "LICENSE.md",
150
+ "LICENSE.txt",
151
+ "LICENSE.markdown",
152
+ "LICENSE.rst",
153
+ "LICENCE",
154
+ "LICENCE.md",
155
+ "LICENCE.txt",
156
+ "LICENCE.rst",
157
+ "COPYING",
158
+ "COPYING.txt",
159
+ "COPYING.rst",
160
+ ];
161
+ /**
162
+ * Extensions whose header comments may carry a license notice (Adafruit and
163
+ * many libraries embed the license at the top of the primary source file
164
+ * rather than in a standalone LICENSE file).
165
+ */
166
+ const HEADER_EXTENSIONS = [".h", ".hpp", ".cpp", ".c"];
167
+ // ---------------------------------------------------------------------------
168
+ // Pure SPDX detection
169
+ // ---------------------------------------------------------------------------
170
+ /**
171
+ * Normalize license input text for matching: trim, lowercase, strip surrounding
172
+ * quotes/whitespace.
173
+ */
174
+ export function normalize(input) {
175
+ return input.trim().toLowerCase().replace(/^["']|["']$/g, "");
176
+ }
177
+ /**
178
+ * Resolve a raw license input (either a short manifest value, the full text of
179
+ * a LICENSE file, or a source-file header comment) to a canonical SPDX ID.
180
+ *
181
+ * Matching priority:
182
+ * 1. SPDX-License-Identifier: <id> marker (authoritative when present)
183
+ * 2. exact alias match (suits the short manifest value)
184
+ * 3. substring markers match, ALL markers required (suits full LICENSE text)
185
+ * 4. shortMarkers match, ANY one sufficient (suits sparse header comments
186
+ * like Adafruit's "BSD license, all text here must be included")
187
+ *
188
+ * Returns the SPDX ID string, or undefined if nothing matched.
189
+ */
190
+ export function identifySpdx(input) {
191
+ const norm = normalize(input);
192
+ // 1. SPDX-License-Identifier marker — extract the id and alias-match it.
193
+ // Aliases are stored in their canonical case; compare lowercased.
194
+ const marker = norm.match(/spdx-license-identifier:\s*([^\s\n]+)/);
195
+ if (marker) {
196
+ const id = marker[1].toLowerCase();
197
+ for (const entry of SPDX_TABLE) {
198
+ if (entry.aliases.some((a) => a.toLowerCase() === id)) {
199
+ return entry.id;
200
+ }
201
+ }
202
+ }
203
+ // 2. exact alias match (case-insensitive; `norm` is already lowercased).
204
+ for (const entry of SPDX_TABLE) {
205
+ if (entry.aliases.some((a) => a.toLowerCase() === norm)) {
206
+ return entry.id;
207
+ }
208
+ }
209
+ // 3. substring markers — every marker phrase must appear.
210
+ // BSD-3 is listed before BSD-2 so its superset clauses (which contain
211
+ // "neither the name") win over BSD-2's subset.
212
+ for (const entry of SPDX_TABLE) {
213
+ if (entry.markers.every((m) => norm.includes(m))) {
214
+ return entry.id;
215
+ }
216
+ }
217
+ // 4. shortMarkers — ANY one match is sufficient. Used for sparse header
218
+ // comments where the full license text is absent.
219
+ for (const entry of SPDX_TABLE) {
220
+ if (entry.shortMarkers.some((m) => norm.includes(m))) {
221
+ return entry.id;
222
+ }
223
+ }
224
+ return undefined;
225
+ }
226
+ /**
227
+ * Classify the copyleft risk of a known SPDX ID. Returns "unknown" for
228
+ * unrecognized ids.
229
+ */
230
+ export function classifyRisk(spdx) {
231
+ const entry = SPDX_TABLE.find((e) => e.id === spdx || e.aliases.includes(spdx));
232
+ return entry ? entry.risk : "unknown";
233
+ }
234
+ // ---------------------------------------------------------------------------
235
+ // License-file / source-header readers (pure I/O via injected seams)
236
+ // ---------------------------------------------------------------------------
237
+ /**
238
+ * Read the first LICENSE/COPYING file found in a directory and return its text.
239
+ * Checks the directory itself only (no recursion). The match is
240
+ * case-insensitive, but the file is read using its REAL on-disk name (not the
241
+ * canonical candidate) so it works on case-sensitive filesystems where a module
242
+ * ships e.g. `license.rst` rather than `LICENSE.rst`.
243
+ */
244
+ export function readLicenseFileIn(dir, readFile, readdir) {
245
+ // Map each entry's lowercased name back to its real (on-disk) casing.
246
+ const lowered = new Map();
247
+ for (const e of readdir(dir))
248
+ lowered.set(e.toLowerCase(), e);
249
+ for (const candidate of LICENSE_FILENAMES) {
250
+ const actual = lowered.get(candidate.toLowerCase());
251
+ if (actual !== undefined) {
252
+ return readFile(path.join(dir, actual));
253
+ }
254
+ }
255
+ return undefined;
256
+ }
257
+ /**
258
+ * Read the first LICENSE/COPYING file found in installDir or one of its common
259
+ * subdirectories (e.g. `src/`, where Arduino's own libraries keep it).
260
+ */
261
+ export function readLicenseFile(installDir, readFile, readdir, subdirs = ["src"]) {
262
+ const rootHit = readLicenseFileIn(installDir, readFile, readdir);
263
+ if (rootHit)
264
+ return rootHit;
265
+ for (const sub of subdirs) {
266
+ const text = readLicenseFileIn(path.join(installDir, sub), readFile, readdir);
267
+ if (text)
268
+ return text;
269
+ }
270
+ return undefined;
271
+ }
272
+ /**
273
+ * Read the leading header comment of each candidate source file in installDir
274
+ * (and its subdirs) and concatenate them, so the SPDX matcher can look for
275
+ * license phrases. The license notice usually lives in the file named after the
276
+ * dependency itself, so such files are scanned first; then a cap of further
277
+ * headers/sources is scanned to keep this cheap.
278
+ */
279
+ export function readSourceHeaders(depName, installDir, readFile, readdir, subdirs = ["src"]) {
280
+ const dirs = [installDir, ...subdirs.map((s) => path.join(installDir, s))];
281
+ // Normalize the dependency name into the stem its source files likely use:
282
+ // "Adafruit seesaw Library" -> "adafruit_seesaw".
283
+ const stem = depName.toLowerCase().replace(/\s+library$/, "").replace(/\s+/g, "_");
284
+ const chunks = [];
285
+ for (const dir of dirs) {
286
+ let entries;
287
+ try {
288
+ entries = readdir(dir);
289
+ }
290
+ catch {
291
+ continue;
292
+ }
293
+ const sources = entries
294
+ .filter((e) => HEADER_EXTENSIONS.some((ext) => e.toLowerCase().endsWith(ext)))
295
+ // Files whose basename starts with the dependency stem go first — that is
296
+ // where the license header conventionally lives.
297
+ .sort((a, b) => {
298
+ const aMatch = Number(a.toLowerCase().startsWith(stem));
299
+ const bMatch = Number(b.toLowerCase().startsWith(stem));
300
+ return bMatch - aMatch;
301
+ })
302
+ .slice(0, 6);
303
+ for (const src of sources) {
304
+ const text = readFile(path.join(dir, src));
305
+ if (text) {
306
+ // Take a generous leading window. Most licenses sit in the first few
307
+ // lines, but some .cpp files place the notice after a long copyright
308
+ // preamble (e.g. OneWire.cpp at ~line 99), so 120 lines covers it
309
+ // without reading whole large files.
310
+ chunks.push(text.split(/\r?\n/).slice(0, 120).join("\n"));
311
+ }
312
+ }
313
+ }
314
+ return chunks.length > 0 ? chunks.join("\n") : undefined;
315
+ }
316
+ /**
317
+ * Resolve a single dependency's license. Priority: manifest → LICENSE file →
318
+ * source-file header comments → none.
319
+ *
320
+ * Never throws: an unreadable manifest/LICENSE/source simply falls through to
321
+ * the next step, ending at a `"none"` / unknown entry.
322
+ */
323
+ export function resolveLibraryLicense(dep, readFile, readdir, options = {}) {
324
+ const installDir = dep.installDir ?? "";
325
+ const subdirs = options.subdirs ?? ["src"];
326
+ const manifestLabel = options.manifestSourceLabel ?? "manifest";
327
+ const base = {
328
+ name: dep.name,
329
+ version: dep.version,
330
+ path: installDir,
331
+ };
332
+ // 1. manifest (e.g. Arduino library.properties `license=`).
333
+ if (options.readManifestLicense) {
334
+ const manifestLicense = options.readManifestLicense(installDir, readFile);
335
+ if (manifestLicense) {
336
+ const spdx = identifySpdx(manifestLicense);
337
+ if (spdx) {
338
+ return { ...base, spdx, risk: classifyRisk(spdx), source: manifestLabel };
339
+ }
340
+ }
341
+ }
342
+ // 2. LICENSE file (root or a subdir).
343
+ const fileText = readLicenseFile(installDir, readFile, readdir, subdirs);
344
+ if (fileText) {
345
+ const spdx = identifySpdx(fileText);
346
+ if (spdx) {
347
+ return { ...base, spdx, risk: classifyRisk(spdx), source: "license-file" };
348
+ }
349
+ }
350
+ // 3. source-file header comments (Adafruit/Arduino pattern: license notice
351
+ // embedded at the top of the primary .h/.cpp, no standalone LICENSE file).
352
+ const headerText = readSourceHeaders(dep.name, installDir, readFile, readdir, subdirs);
353
+ if (headerText) {
354
+ const spdx = identifySpdx(headerText);
355
+ if (spdx) {
356
+ return { ...base, spdx, risk: classifyRisk(spdx), source: "source-header" };
357
+ }
358
+ }
359
+ // 4. unknown
360
+ return { ...base, spdx: undefined, risk: "unknown", source: "none" };
361
+ }
362
+ // ---------------------------------------------------------------------------
363
+ // Presentation helpers (shared by every framework's presenter)
364
+ // ---------------------------------------------------------------------------
365
+ export function riskBracket(risk) {
366
+ if (risk === "strong-copyleft")
367
+ return " [COPYLEFT]";
368
+ if (risk === "weak-copyleft")
369
+ return " [weak copyleft]";
370
+ return "";
371
+ }
372
+ export function statusMark(risk) {
373
+ return risk === "permissive" ? " ✓" : "";
374
+ }
375
+ export function countByRisk(libs) {
376
+ const counts = {
377
+ permissive: 0,
378
+ "weak-copyleft": 0,
379
+ "strong-copyleft": 0,
380
+ unknown: 0,
381
+ };
382
+ for (const l of libs)
383
+ counts[l.risk] += 1;
384
+ return counts;
385
+ }
package/dist/cli.js CHANGED
@@ -442,6 +442,12 @@ async function main() {
442
442
  let effectiveFrameworkPackage = options.frameworkPackage;
443
443
  let effectiveMcuPackage;
444
444
  let effectivePort = options.port;
445
+ // CUTTLEFISH_PORT env var sits between the CLI flag and the config file
446
+ // (flag > env > config), so cross-platform uploads don't need a
447
+ // Windows-specific COM port baked into package.json scripts.
448
+ if (!effectivePort && process.env.CUTTLEFISH_PORT) {
449
+ effectivePort = process.env.CUTTLEFISH_PORT;
450
+ }
445
451
  if (config) {
446
452
  if (config.mcu) {
447
453
  effectiveMcuPackage = config.mcu;
@@ -52,6 +52,14 @@ export interface ResolvedCuttlefishConfig {
52
52
  * Returns the absolute path on success, `undefined` if none is found.
53
53
  */
54
54
  export declare function findConfigFile(startDir: string): string | undefined;
55
+ /**
56
+ * Warn about config values the AST-only parser cannot evaluate. The loader
57
+ * deliberately never runs user code (no ts-node / dynamic import), so only
58
+ * inline literals survive extraction; without these warnings a value like
59
+ * `libraries: sdlLibraries` (an identifier) used to vanish silently and
60
+ * surface later as an opaque link error.
61
+ */
62
+ export type ConfigDropWarning = (message: string) => void;
55
63
  /**
56
64
  *
57
65
  * The file must have a default export whose initializer is an object literal.