From 9be7c6ac91c7d9550fe624713a6a3af7758a955f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 11 Sep 2026 21:53:33 -0700 Subject: [PATCH 1/2] feat(settings): add open source license notices (#8962) Co-authored-by: maria (cherry picked from commit 4a4c6dd2adc350a68ba18bb28b24b5a7e4660dab) --- .gitignore | 2 + apps/mobile/metro.config.js | 57 +- .../assets/file-icons/pierre_package.png | Bin 472 -> 0 bytes .../assets/file-icons/pierre_readme.png | Bin 1220 -> 0 bytes .../assets/file-icons/pierre_tsconfig.png | Bin 2023 -> 0 bytes .../scripts/sync-pierre-file-icons.mjs | 4 - .../src/markdownFileIcons.generated.ts | 3 - .../t3-markdown-text/src/markdownLinks.ts | 8 +- apps/mobile/src/Stack.tsx | 18 + .../SettingsOpenSourceLicensesRouteScreen.tsx | 241 ++++ .../features/settings/SettingsRouteScreen.tsx | 5 + .../components/settings-sheet-targets.ts | 1 + .../settings/mobileThirdPartyLicenses.ts | 13 + apps/mobile/src/lib/markdownLinks.test.ts | 2 +- .../mobile/src/lib/nativeMarkdownText.test.ts | 2 +- .../types/mobile-third-party-licenses.d.ts | 4 + apps/web/THIRD_PARTY_NOTICES.md | 11 - .../settings/OpenSourceLicenses.tsx | 270 +++++ .../settings/SettingsBreadcrumb.tsx | 1 + .../components/settings/SettingsPanels.tsx | 13 + .../settings/SettingsSidebarNav.tsx | 5 +- .../src/components/settings/settingsSearch.ts | 5 + apps/web/src/pierre-icons.test.ts | 21 +- apps/web/src/pierre-icons.ts | 21 +- apps/web/src/routeTree.gen.ts | 22 + .../routes/settings.open-source-licenses.tsx | 7 + apps/web/tsconfig.json | 3 +- apps/web/vite.config.ts | 10 + docs/internals/open-source-licenses.md | 102 ++ docs/user/open-source-licenses.md | 13 + package.json | 1 + packages/shared/package.json | 4 + .../shared/src/thirdPartyLicenses.test.ts | 91 ++ packages/shared/src/thirdPartyLicenses.ts | 127 +++ scripts/lib/third-party-licenses.test.ts | 504 ++++++++ scripts/lib/third-party-licenses.ts | 1014 +++++++++++++++++ scripts/sync-third-party-license-notices.ts | 9 + third-party-licenses.config.json | 450 ++++++++ 38 files changed, 3006 insertions(+), 58 deletions(-) delete mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png delete mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png delete mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png create mode 100644 apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx create mode 100644 apps/mobile/src/features/settings/mobileThirdPartyLicenses.ts create mode 100644 apps/mobile/src/types/mobile-third-party-licenses.d.ts delete mode 100644 apps/web/THIRD_PARTY_NOTICES.md create mode 100644 apps/web/src/components/settings/OpenSourceLicenses.tsx create mode 100644 apps/web/src/routes/settings.open-source-licenses.tsx create mode 100644 docs/internals/open-source-licenses.md create mode 100644 docs/user/open-source-licenses.md create mode 100644 packages/shared/src/thirdPartyLicenses.test.ts create mode 100644 packages/shared/src/thirdPartyLicenses.ts create mode 100644 scripts/lib/third-party-licenses.test.ts create mode 100644 scripts/lib/third-party-licenses.ts create mode 100644 scripts/sync-third-party-license-notices.ts create mode 100644 third-party-licenses.config.json diff --git a/.gitignore b/.gitignore index faf7e09df..35291bda8 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ dist-electron/ .electron-runtime/ .showcase/ apps/mobile/.showcase/ +apps/mobile/.generated/ +/.generated/ artifacts/app-store/screenshots/ .github/pr-assets/ native/**/target/ diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index f8eda69b6..02a6e2c86 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -1,5 +1,6 @@ const fs = require("node:fs"); const path = require("node:path"); +const { pathToFileURL } = require("node:url"); const { getDefaultConfig } = require("expo/metro-config"); const { withUniwindConfig } = require("uniwind/metro"); const extraThemes = require("./generated-uniwind-theme-names.json"); @@ -7,6 +8,13 @@ const extraThemes = require("./generated-uniwind-theme-names.json"); /** @type {import("expo/metro-config").MetroConfig} */ const config = getDefaultConfig(__dirname); const workspaceRoot = path.resolve(__dirname, "../.."); +const generatedLicenseModuleRoot = path.join(__dirname, ".generated", "third-party-licenses"); +const licenseGeneratorSource = path.join( + workspaceRoot, + "scripts", + "lib", + "third-party-licenses.ts", +); const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] })); const resolveShikiDependencyRoot = (packageName) => { @@ -37,6 +45,7 @@ config.resolver = { ], extraNodeModules: { ...config.resolver?.extraNodeModules, + "@t3tools/mobile-third-party-licenses": generatedLicenseModuleRoot, shiki: mobileShikiRoot, "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"), "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"), @@ -48,8 +57,46 @@ config.resolver = { }, }; -module.exports = withUniwindConfig(config, { - cssEntryFile: "./global.css", - extraThemes, - polyfills: { rem: 14 }, -}); +async function writeFileIfChanged(filePath, contents) { + try { + if ((await fs.promises.readFile(filePath, "utf8")) === contents) return; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + await fs.promises.writeFile(filePath, contents, "utf8"); +} + +async function generateMobileThirdPartyLicenses() { + await fs.promises.mkdir(generatedLicenseModuleRoot, { recursive: true }); + const generatorVersion = (await fs.promises.stat(licenseGeneratorSource)).mtimeMs; + const { generateThirdPartyLicenseManifest } = await import( + `${pathToFileURL(licenseGeneratorSource).href}?version=${String(generatorVersion)}` + ); + const manifest = await generateThirdPartyLicenseManifest({ + configFile: path.join(workspaceRoot, "third-party-licenses.config.json"), + packageManifests: [{ bundle: "mobile", path: path.join(__dirname, "package.json") }], + allowMissingGeneratedNotices: + process.env.NODE_ENV !== "production" && + process.env.EAS_BUILD !== "true" && + process.env.T3CODE_LICENSES_STRICT !== "1", + }); + + await Promise.all([ + writeFileIfChanged( + path.join(generatedLicenseModuleRoot, "index.js"), + `module.exports = ${JSON.stringify(manifest)};\n`, + ), + writeFileIfChanged( + path.join(generatedLicenseModuleRoot, "package.json"), + '{"main":"index.js"}\n', + ), + ]); +} + +module.exports = generateMobileThirdPartyLicenses().then(() => + withUniwindConfig(config, { + cssEntryFile: "./global.css", + extraThemes, + polyfills: { rem: 14 }, + }), +); diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png deleted file mode 100644 index 5150250a1b6d014ec0b25260adfcb67feeb96114..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 472 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=jKx9jP7LeL$-D$|Tv8)E(|mmy zw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(<^uz(rC1}St4blLz^=H%((7*fIb zcGgDTLk=R&_8oRN83H#tE=XSJai(cX>k9?x07bI~P6hQU($*RQbE8EvjuRX~S;r?5k;ix?m+kv`eSH8?)FggF<-1)AEbJDfEv+waFPd&R7I!V8{B?2cD&@D1@j&lynMb9cS)0ku`jp0lj5 zJoaw;50(S-k6qe0rK9rxSBZ6hquy8ZN2it)S$Y20V!;-)ZPkzR2F4R<)359hc3{%f se#~+r$~|k(50(iQQO8((9Hty#KeA2nsN!U+Q=m}wboFyt=akR{0IAon^Z)<= diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png deleted file mode 100644 index bfbd4298b015a0935f59f6deb047070bfaecd976..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1220 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=jKx9jP7LeL$-D$|Tv8)E(|mmy zw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(<^uz(rC1}St4blSkcz@q2r;uuoF z_;!}BM@XPZ!~5ENwasj|zU{d8sC_2kQcjbP$?04##OwPOD=DBUah^PDm40qwS0f`dV|QaOZic;jaP%k zV%Z&d#TbG-R{pBd)m+*(GnebYWB#{it|#<9lYN`XqP#a{iH_P_h5(<{xgyyY(;HqY zX1HW41iaSLES>T?_rtnV^7F2}{yOW#+cqxOMSk~`j!%&+kNzJWm#*TztLAB!Pt2aP zS<(A=_0w4v9C@iRSyT40wZ{pEWp2Cnwy*xPcz=nRpWw^PJx%-%<7Qqq6qNZCtaC2= z%CA+;Hd8ol4@m25e=1$L$u_#-(qEyQe^`F|?o26oEU@j9$OFCgr&({xG3XR|tzLY$ z_pO_w>$@xO77EEaEblQow?cn+={80WpPoZ&FV3qnw?BEjv8%VmYJT#E+ZDCzPJClJ zprJZp9&hQ@_rc6}qIT`>4=M^+`#dVM-YaNVYO@aSX6L{~>%1#8=A7QT;OEa9{0(2v z@wpcCK3rJNky^KB&g9&iObjmHe;nS?pYTEIzzfe4HF|*?SQ!=;Ol3H;jF;zyufhk- zwfE~wF02VCQ`UCa=fQO)u(RiNc79aZ%gfC`doW!GUJWK&)q7RH1-}V|L4y(Bj|+9?w3pF z3S>X#bP*F_+WR7=&!K;HeZ_T;oowpAmZwb5@}JW3Cbm!a?q;iLxeP+W`F!_|hbCRW z^@RPw^P-rQe&xP?W)V|A+)WBnT*79Y9+_>t_OaO!*A+H*%}kPCEjGNt)$sLia^Wu{ zr;5E+d<{-hH=C~gD{t~RYE{@z=f@&1SbgSfnc2MH*&`OeidoI_x~2cFpDbe#XBOVi zxn_=+OjpH=%Gtjc&-C$l{W_1cO|&)U;;Wxlr-i2J>SR~!6}?%;kQ3m4`ayWd>umQfx%!rW>tio_udFcD zX|g)rTdG!nEoDw9Wt#az*YDV@3-vC!i#5E$m8?Tt#Zu!KS4hkBoY(U)Pf**FaPvVf z=k`>=Vz0?PdQRTj8_%oqxu<&ZM?^;T?7j5L=e6M_k?D$0E6?q_X7u&C#0|x6{V4V6 zYTjZd%hs%tWL&|`bMB!=q^#MiHy?5;R-U>!ZRS1AYf|d#w;hOTOvx5z*rXb+@wUs3 z*@yq2$M*FX>eVNBBn301{rA_OawYun1;&o`4vv5I9C$;fV9B7s?CLP-fc%<#Bb`y+pz006kmOi^~niu^-%w&Ph% z4ES&?pddRFL*Ut{_{#Bu@^m)4Ze;~19(#5Gltctr|5T1CdQ1QSlR*IZSV4boWS0M= zNHX}p{!h^uW7q%yCoyIygUg|ybu5SXWwgleuifjrG0%~*AT$=NQA{O0=OGZ)`}|u9 z^PMp~Hq(Sl3H{HNWe_l30#C9jwoG0$-cZ2sMM8q8d25R-tF2@LyMk1xq)#Tk^Kf9p zXXe%(u5%sUFrZnqUUZZn{rljO%{-jK2!%m2rbCTl(;W_^cdMsOtT^YABIR5s$H z;9D4x?=Z3Ac%|S8@kll-{6mNfO;tKDyp7;kp(-GDx*=aY_jOsAdFpt@Gc=;Ggb^);&VQPV}}0yt(^k-R0xLb9aJyok-=C*Dr9qJm*&9X^zt^Y`k5$ zJiQc7knK5wDxBXb__9iIq1#y=mL@*c^3x>I42n`FI77M;gzCfncGy}mELX>9V>OL! zW>RrDrdc}+LTWmJ<+T_iJ9!Z!k0fkD4B8Uk<$=W z*e>kn9hFD!8tqlSemanERn;&peN_31P369EnxH<)zNOX5{_a-2+s37wVSJvU#{n-> zWUbY8i0Wt;hD|m9C#F;v%qcprifQ4g@GFv-B;{D@-rdoYXcAH^3Usy~FdI?AQoSnmy)I$VhrHaWZB**T4cq+_-{DT>=&(vYL&_1oDQH03IM!Xz+ej`W1d&=|V2Ltf9zJ zgWx!+549EJD&J&5-fB>eQX}*oic-9qMXdUHnPUA(*ubwRX1&7aH^Io=7MboC8Q)jl zn`RLv5Yo*%P4PnM>t&|nn#{B_GFCJRm4`!BwKei^I3)B6NNM$K=-@u1px2I<-;1u- zWTH(B@e=lg+#aWw@|`aMczhSS)5O-wWckHOS&|$xDb}v@^fS={ehaWM;>AeN0~1`~ zEy%(OJ?oKW+ULMWWA?_>AS|LWHINV55?UND)n$B9GVa`32WdMpqsf3;QxH4VEtGTP zaye&!xNV23sHDfaj-l#+X8-qnQ^s#XY&kz(@KDa6hrZ)X-lUczz6VcU#Z)JL;*%GGw>5F+ z-1i!bzGhn@eZx6|QV?QF=`6!OY<}_W)}j;|6ITQ(#k1Vqneac`QTk#cGci`-%8t~f zbq2joMY%|~GlxBb5;aQWrv9jWH+UOUJo+UmMF8s4YI#?%#7z{YMkf2R+RdtD&Q1~P zDtvS$4Dx(et(m7qB7OHhsunv0`KJ#gUg=U8%MD@+$g*(V`r`4E6Q8Kwen!%j{kcwj z0+mpeRPYIA4}}|lvvDD!&4t~M_8Q*Xz`tjx?ptRW@KdSmJi0fMnn$E?e+Rv75J8J6 z=lnegshR5WzR-T=;}TXs1^NPK8u3oSwGEsQG~%^KX?MdV&jj+4aRaeK_uC{szu&Eg z>nLvxeMS}9Erb`>qsP8@blS#A4J6XaVh zGI=pH(k-1}e;~A$^;iEK@#vheza#Bw7C{z!PhHdeTCAS-@LjDc*pqqqC4AYZdn+ni zClX%20c&i$3Gw9M2R=^y94uUUsiLOJuNSWP`t@V=x2`i`VNpGZf$<=oADqjvAii$x zr!*rkotf2p-5P2+EL>?hifqA0fdWzSfjW{{!mkcTilE|{y(N|C9~^n^VHfpPvlbqi0%c})6%S{J2XgC2#*qi5-&gc;E2_`7 zv)(|e?Dc?wrRwPLMRFJaLS&?$ylO)-0eS7TsTq-pAA`f8-(m8r4z(-8&0)hR!h?#fvotY!ME)lT^t0B2+Y)dTyJGfozpt?1;b}z{pC|#jcj!0 sDT6igQ?*WSgc!oKIiLN1u`IQzl0FBs7&YfA{_|wb(AKDDhHi2H2C?d%2LJ#7 diff --git a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs index 8510ce97b..fdbb03382 100644 --- a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs +++ b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs @@ -79,11 +79,7 @@ const colors = { const customIcons = { agents: "t3-file-icon-agents", - claude: "t3-file-icon-claude", - package: "t3-file-icon-package-json", pnpm: "t3-file-icon-pnpm", - readme: "t3-file-icon-readme", - tsconfig: "t3-file-icon-tsconfig", video: "t3-file-icon-video", }; diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts index 463e00207..fb0209b6a 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts @@ -30,13 +30,11 @@ export const MARKDOWN_FILE_ICON_SOURCES = { nextjs: require("../assets/file-icons/pierre_nextjs.png"), npm: require("../assets/file-icons/pierre_npm.png"), oxc: require("../assets/file-icons/pierre_oxc.png"), - package: require("../assets/file-icons/pierre_package.png"), pnpm: require("../assets/file-icons/pierre_pnpm.png"), postcss: require("../assets/file-icons/pierre_postcss.png"), prettier: require("../assets/file-icons/pierre_prettier.png"), python: require("../assets/file-icons/pierre_python.png"), react: require("../assets/file-icons/pierre_react.png"), - readme: require("../assets/file-icons/pierre_readme.png"), ruby: require("../assets/file-icons/pierre_ruby.png"), rust: require("../assets/file-icons/pierre_rust.png"), sass: require("../assets/file-icons/pierre_sass.png"), @@ -49,7 +47,6 @@ export const MARKDOWN_FILE_ICON_SOURCES = { tailwind: require("../assets/file-icons/pierre_tailwind.png"), terraform: require("../assets/file-icons/pierre_terraform.png"), text: require("../assets/file-icons/pierre_text.png"), - tsconfig: require("../assets/file-icons/pierre_tsconfig.png"), typescript: require("../assets/file-icons/pierre_typescript.png"), video: require("../assets/file-icons/pierre_video.png"), vite: require("../assets/file-icons/pierre_vite.png"), diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index 19f71f631..4143b2fc8 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -116,7 +116,7 @@ const FILE_ICON_BY_NAME: Readonly> = { "next.config.mjs": "nextjs", "next.config.mts": "nextjs", "next.config.ts": "nextjs", - "package.json": "package", + "package.json": "npm", "pnpm-lock.yaml": "pnpm", "pnpm-workspace.yaml": "pnpm", "postcss.config.js": "postcss", @@ -127,7 +127,7 @@ const FILE_ICON_BY_NAME: Readonly> = { "prettier.config.cjs": "prettier", "prettier.config.mjs": "prettier", rakefile: "ruby", - "readme.md": "readme", + "readme.md": "markdown", "stylelint.config.js": "stylelint", "stylelint.config.cjs": "stylelint", "stylelint.config.mjs": "stylelint", @@ -139,7 +139,7 @@ const FILE_ICON_BY_NAME: Readonly> = { "tailwind.config.cjs": "tailwind", "tailwind.config.mjs": "tailwind", "tailwind.config.ts": "tailwind", - "tsconfig.json": "tsconfig", + "tsconfig.json": "typescript", "vite.config.js": "vite", "vite.config.mjs": "vite", "vite.config.mts": "vite", @@ -253,7 +253,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { const exactIcon = FILE_ICON_BY_NAME[basename]; if (exactIcon) return exactIcon; if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { - return "tsconfig"; + return "typescript"; } const segments = basename.split("."); for (let index = 1; index < segments.length; index += 1) { diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 8b2fb3919..2f8021261 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -55,6 +55,10 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; +import { + SettingsOpenSourceLicenseRouteScreen, + SettingsOpenSourceLicensesRouteScreen, +} from "./features/settings/SettingsOpenSourceLicensesRouteScreen"; import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; @@ -197,6 +201,20 @@ const SettingsContentStack = createNativeStackNavigator({ screen: UsageLimitAccountScreen, options: { title: "Account" }, }), + SettingsOpenSourceLicenses: createNativeStackScreen({ + screen: SettingsOpenSourceLicensesRouteScreen, + linking: "open-source-licenses", + options: { + title: "Open source licenses", + }, + }), + SettingsOpenSourceLicense: createNativeStackScreen({ + screen: SettingsOpenSourceLicenseRouteScreen, + linking: "open-source-licenses/:entryKey", + options: { + title: "License notice", + }, + }), SettingsUsage: createNativeStackScreen({ screen: UsageRouteScreen, linking: "usage", diff --git a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx new file mode 100644 index 000000000..1300f484e --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx @@ -0,0 +1,241 @@ +import { LegendList } from "@legendapp/list/react-native"; +import { type StaticScreenProps, useNavigation } from "@react-navigation/native"; +import { + filterThirdPartyLicenseEntries, + findThirdPartyLicenseEntry, + formatLicenseBundles, + thirdPartyLicenseEntryKey, + type ThirdPartyLicenseEntry, +} from "@t3tools/shared/thirdPartyLicenses"; +import { useCallback, useMemo, useState } from "react"; +import { Linking, Platform, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses"; + +function useMobileThirdPartyLicenses() { + return useMemo(() => { + try { + return getMobileThirdPartyLicenses(); + } catch { + return null; + } + }, []); +} + +function LicenseRow(props: { + readonly entry: ThirdPartyLicenseEntry; + readonly onPress: () => void; +}) { + return ( + + + + + {props.entry.name} + + + {props.entry.version ? `${props.entry.version} · ` : ""} + {props.entry.license} + + + + + + ); +} + +export function SettingsOpenSourceLicensesRouteScreen() { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const [query, setQuery] = useState(""); + const manifest = useMobileThirdPartyLicenses(); + const entries = manifest?.entries ?? []; + const filteredEntries = useMemo( + () => filterThirdPartyLicenseEntries(entries, query), + [entries, query], + ); + const renderItem = useCallback( + ({ item }: { readonly item: ThirdPartyLicenseEntry }) => ( + + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { + screen: "SettingsOpenSourceLicense", + params: { entryKey: thirdPartyLicenseEntryKey(item) }, + }, + }) + } + /> + ), + [navigation], + ); + + if (!manifest) { + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + + + License notices are unavailable in this build. + + + + ); + } + + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + + + No licenses match that search. + + + } + ListHeaderComponent={ + + + Notices for dependencies, assets, and optional tools used by Pylon Mobile. + + + + {filteredEntries.length === entries.length + ? `${String(entries.length)} notices` + : `${String(filteredEntries.length)} of ${String(entries.length)} notices`} + + + } + renderItem={renderItem} + showsVerticalScrollIndicator={false} + /> + + ); +} + +type LicenseDetailProps = StaticScreenProps<{ readonly entryKey: string }>; + +export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailProps) { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const manifest = useMobileThirdPartyLicenses(); + const entry = manifest + ? findThirdPartyLicenseEntry(manifest.entries, route.params.entryKey) + : undefined; + const sourceUrl = entry?.sourceUrl?.match(/^https?:\/\//) ? entry.sourceUrl : null; + + if (!entry) { + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + + + This license notice is unavailable. + + + + ); + } + + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + + + {entry.name} + + {[entry.version, entry.license, formatLicenseBundles(entry.bundles)] + .filter((value): value is string => Boolean(value)) + .join(" · ")} + + {sourceUrl ? ( + void Linking.openURL(sourceUrl)} + className="min-h-12 flex-row items-center gap-2 self-start py-2 active:opacity-60" + > + Project source + + + ) : null} + + + + + {entry.noticeText} + + + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 134d73296..3ea92f50e 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -897,6 +897,11 @@ function AppSettingsSection() { return ( + {updateCheckAvailable ? ( { expect(resolveMarkdownLinkPresentation("package.json")).toEqual({ kind: "file", href: "package.json", - icon: "package", + icon: "npm", label: "package.json", path: "package.json", }); diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index bf7e90e41..794c59f98 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -95,7 +95,7 @@ describe("nativeMarkdownTextRuns", () => { { text: "README.md:12", href: "file:///repo/README.md#L12", - fileIcon: "readme", + fileIcon: "markdown", }, ]); }); diff --git a/apps/mobile/src/types/mobile-third-party-licenses.d.ts b/apps/mobile/src/types/mobile-third-party-licenses.d.ts new file mode 100644 index 000000000..7dfee0688 --- /dev/null +++ b/apps/mobile/src/types/mobile-third-party-licenses.d.ts @@ -0,0 +1,4 @@ +declare module "@t3tools/mobile-third-party-licenses" { + const manifest: unknown; + export default manifest; +} diff --git a/apps/web/THIRD_PARTY_NOTICES.md b/apps/web/THIRD_PARTY_NOTICES.md deleted file mode 100644 index c9a675ef4..000000000 --- a/apps/web/THIRD_PARTY_NOTICES.md +++ /dev/null @@ -1,11 +0,0 @@ -# Third-Party Notices - -## vscode-icons - -The custom file icon symbols in `src/pierre-icons.ts` are adapted from the -[`vscode-icons`](https://github.com/vscode-icons/vscode-icons) project. - -Copyright (c) 2016 Roberto Huertas - -Licensed under the MIT License. The full license text is available in the -upstream repository: . diff --git a/apps/web/src/components/settings/OpenSourceLicenses.tsx b/apps/web/src/components/settings/OpenSourceLicenses.tsx new file mode 100644 index 000000000..dc4703cad --- /dev/null +++ b/apps/web/src/components/settings/OpenSourceLicenses.tsx @@ -0,0 +1,270 @@ +import { ChevronRightIcon, ExternalLinkIcon, SearchIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { + decodeThirdPartyLicenseManifest, + filterThirdPartyLicenseEntries, + formatLicenseBundles, + thirdPartyLicenseEntryKey, + type ThirdPartyLicenseEntry, + type ThirdPartyLicenseManifest, +} from "@t3tools/shared/thirdPartyLicenses"; + +import { Button } from "../ui/button"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; + +type LicenseManifestState = + | { readonly status: "loading" } + | { readonly status: "error"; readonly message: string } + | { readonly status: "ready"; readonly manifest: ThirdPartyLicenseManifest }; + +async function loadLicenseManifest(signal: AbortSignal): Promise { + const response = await fetch( + `${import.meta.env.BASE_URL.replace(/\/$/, "")}/third-party-licenses.json`, + { signal }, + ); + if (!response.ok) { + throw new Error(`The license manifest request failed with status ${String(response.status)}.`); + } + return decodeThirdPartyLicenseManifest((await response.json()) as unknown); +} + +function LicenseNoticeRow({ + entry, + open, + onOpenChange, +}: { + readonly entry: ThirdPartyLicenseEntry; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +}) { + return ( + +
+
+ + + + {entry.name} + {entry.version ? ( + {entry.version} + ) : null} + + + {entry.license} · {formatLicenseBundles(entry.bundles)} + + + {entry.sourceUrl ? ( + + ) : null} +
+ + {open ? ( +
+
+                {entry.noticeText}
+              
+
+ ) : null} +
+
+
+ ); +} + +function LicenseCount({ + filteredCount, + totalCount, +}: { + filteredCount: number; + totalCount: number; +}) { + return ( +

+ {filteredCount === totalCount + ? `${String(totalCount)} notices` + : `${String(filteredCount)} of ${String(totalCount)}`} +

+ ); +} + +function LicenseHeaderAction({ + query, + onQueryChange, + searchOpen, + onSearchOpenChange, + filteredCount, + totalCount, +}: { + query: string; + onQueryChange: (value: string) => void; + searchOpen: boolean; + onSearchOpenChange: (open: boolean) => void; + filteredCount: number; + totalCount: number; +}) { + if (!searchOpen) { + return ( +
+ + + onSearchOpenChange(true)} + size="icon-micro" + type="button" + variant="ghost-muted" + > + + + } + /> + Search licenses + +
+ ); + } + + return ( +
+
+ +
+ + + + + { + if (query.length === 0) onSearchOpenChange(false); + }} + onChange={(event) => onQueryChange(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + onQueryChange(""); + onSearchOpenChange(false); + }} + placeholder="Search licenses" + size="sm" + type="search" + value={query} + /> + +
+ ); +} + +function LicenseManifestError({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+
+

Open-source notices are unavailable

+

+ {message} +

+
+ +
+ ); +} + +export function OpenSourceLicensesPanel() { + const [state, setState] = useState({ status: "loading" }); + const [query, setQuery] = useState(""); + const [searchOpen, setSearchOpen] = useState(false); + const [openEntryKey, setOpenEntryKey] = useState(null); + const [requestVersion, setRequestVersion] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setState({ status: "loading" }); + void loadLicenseManifest(controller.signal).then( + (manifest) => setState({ status: "ready", manifest }), + (error: unknown) => { + if (controller.signal.aborted) return; + setState({ + status: "error", + message: error instanceof Error ? error.message : "The license manifest could not load.", + }); + }, + ); + return () => controller.abort(); + }, [requestVersion]); + + const entries = state.status === "ready" ? state.manifest.entries : []; + const filteredEntries = useMemo( + () => filterThirdPartyLicenseEntries(entries, query), + [entries, query], + ); + const retry = useCallback(() => setRequestVersion((value) => value + 1), []); + + return ( + + + ) : null + } + > + {state.status === "ready" ? ( +
+ {filteredEntries.length > 0 ? ( + filteredEntries.map((entry) => { + const entryKey = thirdPartyLicenseEntryKey(entry); + return ( + setOpenEntryKey(open ? entryKey : null)} + /> + ); + }) + ) : ( +

+ No licenses match that search. +

+ )} +
+ ) : state.status === "error" ? ( + + ) : ( +

+ Loading open-source notices… +

+ )} +
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsBreadcrumb.tsx b/apps/web/src/components/settings/SettingsBreadcrumb.tsx index bb631187c..b23e9d71f 100644 --- a/apps/web/src/components/settings/SettingsBreadcrumb.tsx +++ b/apps/web/src/components/settings/SettingsBreadcrumb.tsx @@ -8,6 +8,7 @@ import { SETTINGS_SECTION_LABELS } from "./settingsSearch"; const SETTINGS_BREADCRUMB_LABELS: Readonly> = { ...SETTINGS_SECTION_LABELS, "/settings/diagnostics": "Diagnostics", + "/settings/open-source-licenses": "Open source licenses", }; function settingsBreadcrumbLabel(pathname: string): string | null { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 52fc3d28e..aa408c27a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2919,6 +2919,19 @@ export function GeneralSettingsPanel() { } /> + } + size="xs" + variant="outline" + > + View licenses + + } + />
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 75624792c..cf2d0b83c 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -321,7 +321,10 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { {SETTINGS_NAV_ITEMS.map((item) => { const Icon = item.icon; - const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`); + const isGeneralDetailPage = + item.to === "/settings/general" && pathname === "/settings/open-source-licenses"; + const isActive = + isGeneralDetailPage || pathname === item.to || pathname.startsWith(`${item.to}/`); return ( { assert.equal(resolvePierreIconForEntry("vite.config.ts", "file")?.token, "vite"); }); - it("extends Pierre with T3-specific exact filename icons", () => { + it("uses built-in Pierre icons where available", () => { + assert.equal(resolvePierreIconForEntry("package.json", "file")?.name, "file-tree-builtin-npm"); assert.equal( - resolvePierreIconForEntry("package.json", "file")?.name, - "t3-file-icon-package-json", + resolvePierreIconForEntry("config/tsconfig.json", "file")?.name, + "file-tree-builtin-typescript", ); + assert.equal(resolvePierreIconForEntry("CLAUDE.md", "file")?.name, "file-tree-builtin-claude"); assert.equal( - resolvePierreIconForEntry("config/tsconfig.json", "file")?.name, - "t3-file-icon-tsconfig", + resolvePierreIconForEntry("README.md", "file")?.name, + "file-tree-builtin-markdown", ); + }); + + it("extends Pierre with T3-specific exact filename icons", () => { assert.equal(resolvePierreIconForEntry("AGENTS.md", "file")?.name, "t3-file-icon-agents"); - assert.equal(resolvePierreIconForEntry("CLAUDE.md", "file")?.name, "t3-file-icon-claude"); - assert.equal(resolvePierreIconForEntry("README.md", "file")?.name, "t3-file-icon-readme"); assert.equal(resolvePierreIconForEntry("pnpm-lock.yaml", "file")?.name, "t3-file-icon-pnpm"); assert.equal( resolvePierreIconForEntry("pnpm-workspace.yaml", "file")?.name, @@ -34,7 +37,9 @@ describe("Pierre file icons", () => { }); it("ships every custom icon referenced by the extended resolver", () => { - const customIconNames = new Set(Object.values(T3_PIERRE_ICONS.byFileName)); + const customIconNames = new Set( + Object.values(T3_PIERRE_ICONS.byFileName).filter((name) => name.startsWith("t3-")), + ); for (const iconName of customIconNames) { assert.include(T3_PIERRE_ICONS.spriteSheet, `id="${iconName}"`); } diff --git a/apps/web/src/pierre-icons.ts b/apps/web/src/pierre-icons.ts index b4b83e4df..49aec9cc4 100644 --- a/apps/web/src/pierre-icons.ts +++ b/apps/web/src/pierre-icons.ts @@ -21,24 +21,9 @@ const T3_FILE_ICON_SPRITE = ` - - - - - - - - - - - - - - - @@ -50,11 +35,9 @@ export const T3_PIERRE_ICONS = { colored: true, spriteSheet: T3_FILE_ICON_SPRITE, byFileName: { - "package.json": "t3-file-icon-package-json", - "tsconfig.json": "t3-file-icon-tsconfig", + "package.json": "file-tree-builtin-npm", + "tsconfig.json": "file-tree-builtin-typescript", "agents.md": "t3-file-icon-agents", - "claude.md": "t3-file-icon-claude", - "readme.md": "t3-file-icon-readme", "pnpm-lock.yaml": "t3-file-icon-pnpm", "pnpm-workspace.yaml": "t3-file-icon-pnpm", }, diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index a274d16b3..4ce1c3557 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as SettingsSourceControlRouteImport } from './routes/settings.sou import { Route as SettingsSnapShotRouteImport } from './routes/settings.snap-shot' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' import { Route as SettingsProjectsRouteImport } from './routes/settings.projects' +import { Route as SettingsOpenSourceLicensesRouteImport } from './routes/settings.open-source-licenses' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' @@ -87,6 +88,12 @@ const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ path: '/projects', getParentRoute: () => SettingsRoute, } as any) +const SettingsOpenSourceLicensesRoute = + SettingsOpenSourceLicensesRouteImport.update({ + id: '/open-source-licenses', + path: '/open-source-licenses', + getParentRoute: () => SettingsRoute, + } as any) const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ id: '/keybindings', path: '/keybindings', @@ -166,6 +173,7 @@ export interface FileRoutesByFullPath { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/snap-shot': typeof SettingsSnapShotRoute @@ -189,6 +197,7 @@ export interface FileRoutesByTo { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/snap-shot': typeof SettingsSnapShotRoute @@ -215,6 +224,7 @@ export interface FileRoutesById { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/snap-shot': typeof SettingsSnapShotRoute @@ -242,6 +252,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/open-source-licenses' | '/settings/projects' | '/settings/providers' | '/settings/snap-shot' @@ -265,6 +276,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/open-source-licenses' | '/settings/projects' | '/settings/providers' | '/settings/snap-shot' @@ -290,6 +302,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/open-source-licenses' | '/settings/projects' | '/settings/providers' | '/settings/snap-shot' @@ -389,6 +402,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProjectsRouteImport parentRoute: typeof SettingsRoute } + '/settings/open-source-licenses': { + id: '/settings/open-source-licenses' + path: '/open-source-licenses' + fullPath: '/settings/open-source-licenses' + preLoaderRoute: typeof SettingsOpenSourceLicensesRouteImport + parentRoute: typeof SettingsRoute + } '/settings/keybindings': { id: '/settings/keybindings' path: '/keybindings' @@ -500,6 +520,7 @@ interface SettingsRouteChildren { SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute + SettingsOpenSourceLicensesRoute: typeof SettingsOpenSourceLicensesRoute SettingsProjectsRoute: typeof SettingsProjectsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSnapShotRoute: typeof SettingsSnapShotRoute @@ -514,6 +535,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsGeneralRoute: SettingsGeneralRoute, SettingsIntegrationsRoute: SettingsIntegrationsRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, + SettingsOpenSourceLicensesRoute: SettingsOpenSourceLicensesRoute, SettingsProjectsRoute: SettingsProjectsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSnapShotRoute: SettingsSnapShotRoute, diff --git a/apps/web/src/routes/settings.open-source-licenses.tsx b/apps/web/src/routes/settings.open-source-licenses.tsx new file mode 100644 index 000000000..1737b0b27 --- /dev/null +++ b/apps/web/src/routes/settings.open-source-licenses.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { OpenSourceLicensesPanel } from "../components/settings/OpenSourceLicenses"; + +export const Route = createFileRoute("/settings/open-source-licenses")({ + component: OpenSourceLicensesPanel, +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 65b04800e..a49328545 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -32,6 +32,7 @@ "vercel.ts", "test", "scripts/warm-dep-cache.ts", - "../../scripts/lib/public-config.ts" + "../../scripts/lib/public-config.ts", + "../../scripts/lib/third-party-licenses.ts" ] } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 973ff1d46..c58deeb6b 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -12,6 +12,7 @@ import pkg from "./package.json" with { type: "json" }; import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; import { loadRepoEnv } from "../../scripts/lib/public-config"; +import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; import { tailwindPlugins } from "./vite/tailwind"; const repoEnv = loadRepoEnv(); @@ -169,6 +170,15 @@ export default defineConfig(() => { assetsInclude: ["**/*.wasm"], plugins: [ devCompressionPlugin(), + thirdPartyLicensesPlugin({ + bundleName: "web", + configFile: new URL("../../third-party-licenses.config.json", import.meta.url), + packageManifests: [ + { bundle: "web", path: new URL("./package.json", import.meta.url) }, + { bundle: "server", path: new URL("../server/package.json", import.meta.url) }, + { bundle: "desktop", path: new URL("../desktop/package.json", import.meta.url) }, + ], + }), // Route components load as split chunks so settings, pull-request, and // usage code stay out of the cold-start payload; the router prefetches // them on navigation intent (see getRouter's defaultPreload). diff --git a/docs/internals/open-source-licenses.md b/docs/internals/open-source-licenses.md new file mode 100644 index 000000000..5e882e5c8 --- /dev/null +++ b/docs/internals/open-source-licenses.md @@ -0,0 +1,102 @@ +# Open source license notices + +License notices are generated independently for the client that ships them: + +- The web build emits `third-party-licenses.json` beside `index.html`. The Settings page loads that + static file, so the same artifact works in hosted web, the client bundled with `npx t3`, and + desktop. +- The mobile Metro config generates an ignored virtual module before each development, native, or + over-the-air JavaScript bundle. Mobile loads and decodes that module only when a license screen + opens, so the notice text does not occupy memory during ordinary app startup. It does not need a + network request. + +Neither path depends on the connected environment or an RPC. + +## What the build collects + +The generator follows installed production and optional dependencies, including dependencies of +workspace packages, and omits first-party `@t3tools/*` packages. The web manifest starts from the +web, server, and desktop package manifests. The mobile manifest starts from the mobile package +manifest. During the web bundle, the generator also checks emitted module ids to catch a bundled +npm import missing from a package manifest. + +The mobile manifest deliberately follows the complete production dependency closure declared by +Expo and React Native. That is conservative and can include build tooling that is not present in +the final JavaScript bundle, but it avoids dropping a notice when platform bundling changes. + +The build fails when a collected package has no distributable license identifier or contains no +license or notice text. Generated notices use license templates from the pinned SPDX License List. +Strict web and EAS builds download a missing template into the gitignored `.generated/` cache; +`pnpm licenses:sync` can warm that cache explicitly. Local web and Metro development do not make a +network request and omit generated rows until the cache exists. This keeps dev startup optional +while preventing incomplete release artifacts. + +## Custom notices and package overrides + +The repository-level `third-party-licenses.config.json` holds manually maintained exceptions for +all clients. Add an entry to `customNotices` for adapted icons, fonts, media, native modules, or +another asset that did not come from an npm package: + +```json +{ + "name": "asset-name", + "license": "CC-BY-4.0", + "generatedNotices": [ + { + "licenseId": "CC-BY-4.0", + "preamble": ["Asset by Example Author. Changes: converted to MP3."] + } + ], + "sourceUrl": "https://example.com/source", + "bundles": ["assets", "web"] +} +``` + +Each `generatedNotices` item names an SPDX license template and can add `copyrights` or a short +`preamble` for attribution and provenance. Multiple items are joined into one row for software +that vendors separately licensed code. Keep `noticeFile` or `noticeFiles` only when a vendored +source tree already carries an intrinsic license file that should remain beside it. Paths are +relative to the config file. `bundles` controls which generated manifests include the entry and +supplies the label shown to users. Use `includeInBundles` when those differ, such as an optional +server tool that should appear in both client manifests but is not bundled into either client. + +Use `packageOverrides` only when an installed npm archive omits its notice or has incorrect +metadata: + +```json +{ + "name": "package-name", + "version": "1.2.3", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2026 Example Author"] + }, + "license": "MIT", + "sourceUrl": "https://example.com/package-name" +} +``` + +`version`, `license`, and `sourceUrl` are optional. Omitting `version` applies the override to every +installed version of that package. An override can use `repositoryUrl` instead of `name` when +several packages from one monorepo share the same notice: + +```json +{ + "repositoryUrl": "https://github.com/example/project", + "generatedNotice": { + "licenseId": "Apache-2.0" + } +} +``` + +The generator also reuses an installed sibling package's notice when both packages declare the +same normalized repository and license. A name-and-version override always wins over these +repository fallbacks. + +The `@react-grab/cli` override uses the root React Grab repository's MIT license because the CLI's +npm archive omits both its license field and license file. Keep the override until the published +CLI package carries that metadata itself. + +Generated mobile files live under `apps/mobile/.generated/`, while fetched SPDX templates live +under the repository `.generated/` directory. Both are ignored. Do not commit or edit them; +updating dependencies or configuration is enough for the next strict build to refresh the output. diff --git a/docs/user/open-source-licenses.md b/docs/user/open-source-licenses.md new file mode 100644 index 000000000..cf0bc976b --- /dev/null +++ b/docs/user/open-source-licenses.md @@ -0,0 +1,13 @@ +# Open source licenses + +Pylon includes third-party software and adapted assets. To read their license and attribution +notices: + +- On web and desktop, open **Settings → General**, find **Open source licenses** under **About**, + and select **View licenses**. +- On mobile, open **Settings → App → Open source licenses**. + +The page lists the version when available, license identifier, and the parts of Pylon that include +or use the item. This also covers optional device tools that Pylon installs on demand instead of +bundling. Select a row to read the complete notice text. Use the search field to find a package, +version, license, or app component. diff --git a/package.json b/package.json index d1b1c90ea..1ab6b87e6 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "icons:export": "node scripts/export-pylon-brand-icons.mjs", "icons:check": "node scripts/export-pylon-brand-icons.mjs --check", "icons:export:t3": "node scripts/export-brand-icons.ts", + "licenses:sync": "node scripts/sync-third-party-license-notices.ts", "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build", "build:marketing": "vp run --filter @t3tools/marketing build", "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build", diff --git a/packages/shared/package.json b/packages/shared/package.json index 3b1ab83bf..bb56d050c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -267,6 +267,10 @@ "types": "./src/desktopAppControl.ts", "import": "./src/desktopAppControl.ts" }, + "./thirdPartyLicenses": { + "types": "./src/thirdPartyLicenses.ts", + "import": "./src/thirdPartyLicenses.ts" + }, "./claudeCompaction": { "types": "./src/claudeCompaction.ts", "import": "./src/claudeCompaction.ts" diff --git a/packages/shared/src/thirdPartyLicenses.test.ts b/packages/shared/src/thirdPartyLicenses.test.ts new file mode 100644 index 000000000..c133cfdfa --- /dev/null +++ b/packages/shared/src/thirdPartyLicenses.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + decodeThirdPartyLicenseManifest, + filterThirdPartyLicenseEntries, + findThirdPartyLicenseEntry, + formatLicenseBundles, + thirdPartyLicenseEntryKey, + type ThirdPartyLicenseEntry, +} from "./thirdPartyLicenses.js"; + +const ENTRIES: ReadonlyArray = [ + { + bundles: ["web"], + kind: "package", + license: "MIT", + name: "react", + noticeText: "React license", + sourceUrl: "https://react.dev", + version: "19.2.6", + }, + { + bundles: ["assets", "mobile"], + kind: "custom", + license: "CC-BY-4.0", + name: "sample-icons", + noticeText: "Icon notice", + sourceUrl: null, + version: null, + }, +]; + +describe("third-party license manifests", () => { + it("decodes the generated manifest shape", () => { + expect(decodeThirdPartyLicenseManifest({ schemaVersion: 1, entries: ENTRIES })).toEqual({ + schemaVersion: 1, + entries: ENTRIES, + }); + }); + + it("rejects unsupported manifest versions", () => { + expect(() => decodeThirdPartyLicenseManifest({ schemaVersion: 2, entries: [] })).toThrow( + "unsupported format", + ); + }); + + it("rejects unsafe source links", () => { + expect(() => + decodeThirdPartyLicenseManifest({ + schemaVersion: 1, + entries: [{ ...ENTRIES[0]!, sourceUrl: "javascript:alert(1)" }], + }), + ).toThrow("invalid shape"); + }); + + it("rejects duplicate navigation keys", () => { + expect(() => + decodeThirdPartyLicenseManifest({ schemaVersion: 1, entries: [ENTRIES[0]!, ENTRIES[0]!] }), + ).toThrow("duplicate entry"); + }); + + it("filters by package, license, version, and bundle", () => { + expect(filterThirdPartyLicenseEntries(ENTRIES, "react 19.2")).toEqual([ENTRIES[0]]); + expect(filterThirdPartyLicenseEntries(ENTRIES, "cc-by mobile")).toEqual([ENTRIES[1]]); + expect(filterThirdPartyLicenseEntries(ENTRIES, "apache")).toEqual([]); + }); + + it("formats platform bundle names for display", () => { + expect( + formatLicenseBundles(["android", "assets", "ios", "mobile", "plugin", "constructor"]), + ).toBe("Android, Assets, iOS, Mobile, plugin, constructor"); + }); + + it("finds an entry by its stable navigation key", () => { + const entry = ENTRIES[0]!; + expect(findThirdPartyLicenseEntry(ENTRIES, thirdPartyLicenseEntryKey(entry))).toBe(entry); + }); + + it("uses path-safe navigation keys for scoped packages", () => { + expect(thirdPartyLicenseEntryKey({ ...ENTRIES[0]!, name: "@scope/package" })).not.toContain( + "/", + ); + }); + + it("uses distinct navigation keys when names and versions contain delimiters", () => { + const first = thirdPartyLicenseEntryKey({ ...ENTRIES[0]!, name: "a:1", version: null }); + const second = thirdPartyLicenseEntryKey({ ...ENTRIES[0]!, name: "a", version: "1:custom" }); + + expect(first).not.toBe(second); + }); +}); diff --git a/packages/shared/src/thirdPartyLicenses.ts b/packages/shared/src/thirdPartyLicenses.ts new file mode 100644 index 000000000..ab570cb2e --- /dev/null +++ b/packages/shared/src/thirdPartyLicenses.ts @@ -0,0 +1,127 @@ +export interface ThirdPartyLicenseEntry { + readonly bundles: ReadonlyArray; + readonly kind: "custom" | "package"; + readonly license: string; + readonly name: string; + readonly noticeText: string; + readonly sourceUrl: string | null; + readonly version: string | null; +} + +export interface ThirdPartyLicenseManifest { + readonly schemaVersion: 1; + readonly entries: ReadonlyArray; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStringArray(value: unknown): value is ReadonlyArray { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} + +function isHttpUrl(value: unknown): value is string { + if (typeof value !== "string") return false; + try { + const protocol = new URL(value).protocol; + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } +} + +function decodeEntry(value: unknown, index: number): ThirdPartyLicenseEntry { + if (!isRecord(value)) { + throw new Error(`License entry ${String(index + 1)} is not an object.`); + } + if ( + !isStringArray(value.bundles) || + value.bundles.length === 0 || + (value.kind !== "custom" && value.kind !== "package") || + typeof value.license !== "string" || + typeof value.name !== "string" || + typeof value.noticeText !== "string" || + (value.sourceUrl !== null && !isHttpUrl(value.sourceUrl)) || + (value.version !== null && typeof value.version !== "string") + ) { + throw new Error(`License entry ${String(index + 1)} has an invalid shape.`); + } + return { + bundles: value.bundles, + kind: value.kind, + license: value.license, + name: value.name, + noticeText: value.noticeText, + sourceUrl: value.sourceUrl, + version: value.version, + }; +} + +export function decodeThirdPartyLicenseManifest(value: unknown): ThirdPartyLicenseManifest { + if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.entries)) { + throw new Error("The open-source license manifest has an unsupported format."); + } + const entries = value.entries.map(decodeEntry); + const entryKeys = new Set(); + for (const entry of entries) { + const key = thirdPartyLicenseEntryKey(entry); + if (entryKeys.has(key)) { + throw new Error(`The open-source license manifest contains a duplicate entry: ${key}`); + } + entryKeys.add(key); + } + return { + schemaVersion: 1, + entries, + }; +} + +export function filterThirdPartyLicenseEntries( + entries: ReadonlyArray, + query: string, +): ReadonlyArray { + const terms = query + .trim() + .toLowerCase() + .split(/\s+/) + .filter((term) => term.length > 0); + if (terms.length === 0) return entries; + return entries.filter((entry) => { + const searchable = [entry.name, entry.version, entry.license, ...entry.bundles] + .filter((value): value is string => value !== null) + .join(" ") + .toLowerCase(); + return terms.every((term) => searchable.includes(term)); + }); +} + +const BUNDLE_LABELS: Readonly> = { + android: "Android", + assets: "Assets", + desktop: "Desktop", + "device-tools": "Device tools", + ios: "iOS", + mobile: "Mobile", + server: "Server", + web: "Web", +}; + +export function formatLicenseBundles(bundles: ReadonlyArray): string { + return bundles + .map((bundle) => + Object.prototype.hasOwnProperty.call(BUNDLE_LABELS, bundle) ? BUNDLE_LABELS[bundle] : bundle, + ) + .join(", "); +} + +export function thirdPartyLicenseEntryKey(entry: ThirdPartyLicenseEntry): string { + return encodeURIComponent(JSON.stringify([entry.kind, entry.name, entry.version])); +} + +export function findThirdPartyLicenseEntry( + entries: ReadonlyArray, + key: string, +): ThirdPartyLicenseEntry | undefined { + return entries.find((entry) => thirdPartyLicenseEntryKey(entry) === key); +} diff --git a/scripts/lib/third-party-licenses.test.ts b/scripts/lib/third-party-licenses.test.ts new file mode 100644 index 000000000..997b2bd58 --- /dev/null +++ b/scripts/lib/third-party-licenses.test.ts @@ -0,0 +1,504 @@ +// @effect-diagnostics nodeBuiltinImport:off - Tests exercise the Node filesystem build boundary. + +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + generateThirdPartyLicenseManifest, + THIRD_PARTY_LICENSES_FILE_NAME, + thirdPartyLicensesPlugin, +} from "./third-party-licenses.js"; + +const tempDirectories: string[] = []; +const REPOSITORY_ROOT = NodePath.resolve( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "../..", +); + +async function writeJson(path: string, value: unknown): Promise { + await NodeFSP.mkdir(NodePath.dirname(path), { recursive: true }); + await NodeFSP.writeFile(path, `${JSON.stringify(value)}\n`, "utf8"); +} + +async function createFixture(): Promise<{ + readonly appManifest: string; + readonly configFile: string; + readonly dependencyRoot: string; + readonly root: string; +}> { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-licenses-")); + tempDirectories.push(root); + const appManifest = NodePath.join(root, "package.json"); + const dependencyRoot = NodePath.join(root, "node_modules", "demo-dependency"); + const configFile = NodePath.join(root, "third-party-licenses.config.json"); + + await writeJson(appManifest, { + name: "fixture-app", + dependencies: { "demo-dependency": "1.2.3" }, + }); + await writeJson(NodePath.join(dependencyRoot, "package.json"), { + name: "demo-dependency", + version: "1.2.3", + license: "MIT", + main: "index.js", + repository: "example/demo-dependency", + }); + await NodeFSP.writeFile(NodePath.join(dependencyRoot, "index.js"), "export {};\n", "utf8"); + await NodeFSP.writeFile( + NodePath.join(dependencyRoot, "LICENSE"), + "Demo MIT license text\n", + "utf8", + ); + await NodeFSP.writeFile(NodePath.join(root, "asset-notice.txt"), "Asset notice text\n", "utf8"); + await writeJson(configFile, { + customNotices: [ + { + name: "demo-asset", + license: "CC-BY-4.0", + noticeFile: "asset-notice.txt", + bundles: ["assets", "web"], + }, + ], + packageOverrides: [], + }); + return { appManifest, configFile, dependencyRoot, root }; +} + +afterEach(async () => { + await Promise.all( + tempDirectories + .splice(0) + .map((directory) => NodeFSP.rm(directory, { force: true, recursive: true })), + ); +}); + +describe("third-party license generation", () => { + it("keeps the GhosttyKit notice pinned to the vendored framework revision", async () => { + const [config, revision] = await Promise.all([ + NodeFSP.readFile(NodePath.join(REPOSITORY_ROOT, "third-party-licenses.config.json"), "utf8"), + NodeFSP.readFile( + NodePath.join(REPOSITORY_ROOT, "apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION"), + "utf8", + ), + ]); + + expect(config).toContain(revision.trim()); + expect(config).toContain( + "https://github.com/Yash-Singh1/ghostty/tree/t3code/custom-io-ordered-feed", + ); + }); + + it("collects production packages and custom asset notices", async () => { + const fixture = await createFixture(); + const manifest = await generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "web", path: fixture.appManifest }], + }); + + expect(manifest).toEqual({ + schemaVersion: 1, + entries: [ + { + bundles: ["assets", "web"], + kind: "custom", + license: "CC-BY-4.0", + name: "demo-asset", + noticeText: "Asset notice text", + sourceUrl: null, + version: null, + }, + { + bundles: ["web"], + kind: "package", + license: "MIT", + name: "demo-dependency", + noticeText: "Demo MIT license text", + sourceUrl: "https://github.com/example/demo-dependency", + version: "1.2.3", + }, + ], + }); + }); + + it("renders generated notices from the ignored SPDX cache", async () => { + const fixture = await createFixture(); + await writeJson( + NodePath.join(fixture.root, ".generated/third-party-licenses/spdx/v3.28.0/MIT.json"), + { + licenseId: "MIT", + licenseText: "MIT License\n\nCopyright (c) \n\nPermission text", + }, + ); + await writeJson(fixture.configFile, { + customNotices: [ + { + name: "generated-asset", + license: "MIT", + generatedNotices: [ + { + licenseId: "MIT", + copyrights: ["Copyright (c) 2026 Example Author"], + preamble: ["Adapted for T3 Code."], + }, + ], + bundles: ["assets", "web"], + }, + ], + packageOverrides: [], + }); + + const manifest = await generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "web", path: fixture.appManifest }], + }); + + expect(manifest.entries.find((entry) => entry.name === "generated-asset")?.noticeText).toBe( + "Adapted for T3 Code.\n\nMIT License\n\nCopyright (c) 2026 Example Author\n\nPermission text", + ); + }); + + it("omits generated rows without a cache during optional development", async () => { + const fixture = await createFixture(); + await writeJson(fixture.configFile, { + customNotices: [ + { + name: "generated-asset", + license: "MIT", + generatedNotices: [{ licenseId: "MIT" }], + bundles: ["assets", "web"], + }, + ], + packageOverrides: [], + }); + + const manifest = await generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "web", path: fixture.appManifest }], + allowMissingGeneratedNotices: true, + }); + + expect(manifest.entries.some((entry) => entry.name === "generated-asset")).toBe(false); + }); + + it("finds packages whose exports hide both their manifest and entry point", async () => { + const fixture = await createFixture(); + await writeJson(NodePath.join(fixture.dependencyRoot, "package.json"), { + name: "demo-dependency", + version: "1.2.3", + license: "MIT", + exports: {}, + repository: "example/demo-dependency", + }); + + const manifest = await generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "web", path: fixture.appManifest }], + }); + + expect(manifest.entries.some((entry) => entry.name === "demo-dependency")).toBe(true); + }); + + it("includes custom notices selected by the dev server bundle", async () => { + const fixture = await createFixture(); + await writeJson(fixture.configFile, { + customNotices: [ + { + name: "desktop-only-asset", + license: "CC-BY-4.0", + noticeFile: "asset-notice.txt", + bundles: ["assets"], + includeInBundles: ["desktop"], + }, + ], + packageOverrides: [], + }); + + const plugin = thirdPartyLicensesPlugin({ + bundleName: "desktop", + configFile: fixture.configFile, + packageManifests: [{ bundle: "web", path: fixture.appManifest }], + }); + let middleware: + | (( + request: { readonly url: string }, + response: { + statusCode: number; + setHeader(name: string, value: string): void; + end(body: string): void; + }, + next: (error?: Error) => void, + ) => void) + | undefined; + if (typeof plugin.configureServer !== "function") { + throw new Error("Expected the license plugin to define a configureServer hook."); + } + plugin.configureServer.call( + {} as never, + { + middlewares: { + use(handler: typeof middleware) { + middleware = handler; + }, + }, + } as never, + ); + if (!middleware) throw new Error("Expected the license plugin to register middleware."); + + const responseBody = await new Promise((resolve, reject) => { + middleware!( + { url: `/${THIRD_PARTY_LICENSES_FILE_NAME}` }, + { + statusCode: 0, + setHeader() {}, + end: resolve, + }, + (error) => reject(error ?? new Error("License middleware skipped the request.")), + ); + }); + const manifest = JSON.parse(responseBody) as { entries: ReadonlyArray<{ name: string }> }; + + expect(manifest.entries.some((entry) => entry.name === "desktop-only-asset")).toBe(true); + }); + + it("fails when a production package has no distributable notice text", async () => { + const fixture = await createFixture(); + await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE")); + + await expect( + generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "web", path: fixture.appManifest }], + }), + ).rejects.toThrow("does not include a license or notice file"); + }); + + it("collects nested notices even when a package also has a root license", async () => { + const fixture = await createFixture(); + await NodeFSP.mkdir(NodePath.join(fixture.dependencyRoot, "dist", "third-party"), { + recursive: true, + }); + await NodeFSP.mkdir(NodePath.join(fixture.dependencyRoot, "lib"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(fixture.dependencyRoot, "dist", "third-party", "NOTICE.txt"), + "Nested notice\n", + "utf8", + ); + await NodeFSP.writeFile( + NodePath.join(fixture.dependencyRoot, "lib", "license_header.js"), + "require('not-a-license');\n", + "utf8", + ); + + const manifest = await generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "web", path: fixture.appManifest }], + }); + + expect(manifest.entries.find((entry) => entry.name === "demo-dependency")?.noticeText).toBe( + "dist/third-party/NOTICE.txt\n\nNested notice\n\n---\n\nLICENSE\n\nDemo MIT license text", + ); + }); + + it("uses package overrides for notices published outside the npm archive", async () => { + const fixture = await createFixture(); + await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE")); + await NodeFSP.writeFile(NodePath.join(fixture.root, "override.txt"), "Override text\n", "utf8"); + await writeJson(fixture.configFile, { + customNotices: [], + packageOverrides: [ + { + name: "demo-dependency", + noticeFile: "override.txt", + }, + ], + }); + + const manifest = await generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "web", path: fixture.appManifest }], + }); + + expect(manifest.entries[0]?.noticeText).toBe("Override text"); + }); + + it("applies repository overrides across monorepo packages", async () => { + const fixture = await createFixture(); + await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE")); + await NodeFSP.writeFile( + NodePath.join(fixture.root, "override.txt"), + "Repository text\n", + "utf8", + ); + await writeJson(fixture.configFile, { + customNotices: [], + packageOverrides: [ + { + repositoryUrl: "https://github.com/example/demo-dependency", + noticeFile: "override.txt", + }, + ], + }); + + const manifest = await generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "web", path: fixture.appManifest }], + }); + + expect(manifest.entries[0]?.noticeText).toBe("Repository text"); + }); + + it("reuses a repository license for packages from the same monorepo", async () => { + const fixture = await createFixture(); + const siblingRoot = NodePath.join(fixture.root, "node_modules", "demo-sibling"); + await writeJson(fixture.appManifest, { + name: "fixture-app", + dependencies: { "demo-dependency": "1.2.3", "demo-sibling": "2.0.0" }, + }); + await writeJson(NodePath.join(siblingRoot, "package.json"), { + name: "demo-sibling", + version: "2.0.0", + license: "MIT", + main: "index.js", + repository: "https://github.com/example/demo-dependency.git#main", + }); + await NodeFSP.writeFile(NodePath.join(siblingRoot, "index.js"), "export {};\n", "utf8"); + + const manifest = await generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "web", path: fixture.appManifest }], + }); + + expect(manifest.entries.find((entry) => entry.name === "demo-sibling")?.noticeText).toBe( + "Demo MIT license text", + ); + }); + + it("prefers version-specific repository overrides", async () => { + const fixture = await createFixture(); + await NodeFSP.writeFile(NodePath.join(fixture.root, "generic.txt"), "Generic text\n", "utf8"); + await NodeFSP.writeFile(NodePath.join(fixture.root, "exact.txt"), "Exact text\n", "utf8"); + await writeJson(fixture.configFile, { + customNotices: [], + packageOverrides: [ + { + repositoryUrl: "https://github.com/example/demo-dependency", + noticeFile: "generic.txt", + }, + { + repositoryUrl: "https://github.com/example/demo-dependency", + version: "1.2.3", + noticeFile: "exact.txt", + }, + ], + }); + + const manifest = await generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "web", path: fixture.appManifest }], + }); + + expect(manifest.entries[0]?.noticeText).toBe("Exact text"); + }); + + it("omits custom notices for other bundles", async () => { + const fixture = await createFixture(); + await writeJson(fixture.configFile, { + customNotices: [ + { + name: "web-only-asset", + license: "MIT", + noticeFile: "asset-notice.txt", + bundles: ["assets", "web"], + }, + ], + packageOverrides: [], + }); + + const manifest = await generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "mobile", path: fixture.appManifest }], + }); + + expect(manifest.entries.some((entry) => entry.name === "web-only-asset")).toBe(false); + }); + + it("can show a multi-file notice under a label that differs from its client manifests", async () => { + const fixture = await createFixture(); + await NodeFSP.writeFile( + NodePath.join(fixture.root, "tool-license.txt"), + "Tool license\n", + "utf8", + ); + await NodeFSP.writeFile( + NodePath.join(fixture.root, "vendor-notice.txt"), + "Vendor notice\n", + "utf8", + ); + await writeJson(fixture.configFile, { + customNotices: [ + { + name: "optional-tool", + license: "MIT AND Apache-2.0", + noticeFiles: ["tool-license.txt", "vendor-notice.txt"], + bundles: ["device-tools"], + includeInBundles: ["mobile", "web"], + }, + ], + packageOverrides: [], + }); + + const manifest = await generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "mobile", path: fixture.appManifest }], + }); + + expect(manifest.entries.find((entry) => entry.name === "optional-tool")).toMatchObject({ + bundles: ["device-tools"], + noticeText: "Tool license\n\n---\n\nVendor notice", + }); + }); + + it("fails when a custom notice file is empty", async () => { + const fixture = await createFixture(); + await NodeFSP.writeFile(NodePath.join(fixture.root, "asset-notice.txt"), "\n", "utf8"); + + await expect( + generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "web", path: fixture.appManifest }], + }), + ).rejects.toThrow('Custom third-party notice "demo-asset" is empty'); + }); + + it("fails generation when custom notices produce duplicate navigation keys", async () => { + const fixture = await createFixture(); + await writeJson(fixture.configFile, { + customNotices: [ + { + name: "duplicate-asset", + license: "MIT", + noticeFile: "asset-notice.txt", + bundles: ["assets", "web"], + }, + { + name: "duplicate-asset", + license: "CC0-1.0", + noticeFile: "asset-notice.txt", + bundles: ["assets", "web"], + }, + ], + packageOverrides: [], + }); + + await expect( + generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "web", path: fixture.appManifest }], + }), + ).rejects.toThrow("duplicate custom notice for duplicate-asset"); + }); +}); diff --git a/scripts/lib/third-party-licenses.ts b/scripts/lib/third-party-licenses.ts new file mode 100644 index 000000000..487853fab --- /dev/null +++ b/scripts/lib/third-party-licenses.ts @@ -0,0 +1,1014 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetch:off - Vite's build plugin runs before an Effect runtime exists. + +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeModule from "node:module"; + +import type { Plugin } from "vite-plus"; + +export const THIRD_PARTY_LICENSES_FILE_NAME = "third-party-licenses.json"; +const SPDX_LICENSE_LIST_VERSION = "v3.28.0"; +const SPDX_LICENSE_LIST_REVISION = "c4a7237ec8f4654e867546f9f409749300f1bf4c"; +const GENERATED_NOTICE_CACHE_DIRECTORY = ".generated/third-party-licenses/spdx"; + +export interface ThirdPartyLicenseEntry { + readonly bundles: ReadonlyArray; + readonly kind: "custom" | "package"; + readonly license: string; + readonly name: string; + readonly noticeText: string; + readonly sourceUrl: string | null; + readonly version: string | null; +} + +export interface ThirdPartyLicenseManifest { + readonly schemaVersion: 1; + readonly entries: ReadonlyArray; +} + +export interface ThirdPartyLicensePackageManifest { + readonly bundle: string; + readonly path: string | URL; +} + +export interface ThirdPartyLicensesPluginOptions { + readonly configFile?: string | URL; + readonly packageManifests: ReadonlyArray; + readonly bundleName: string; +} + +interface GeneratedNoticeConfigEntry { + readonly copyrights?: ReadonlyArray; + readonly licenseId: string; + readonly preamble?: ReadonlyArray; +} + +interface PackageJson { + readonly dependencies?: Readonly>; + readonly homepage?: unknown; + readonly license?: unknown; + readonly licenses?: unknown; + readonly name?: unknown; + readonly optionalDependencies?: Readonly>; + readonly repository?: unknown; + readonly version?: unknown; +} + +interface CustomNoticeConfigEntry { + readonly bundles?: ReadonlyArray; + readonly includeInBundles?: ReadonlyArray; + readonly license: string; + readonly name: string; + readonly generatedNotices?: ReadonlyArray; + readonly noticeFiles?: ReadonlyArray; + readonly sourceUrl?: string; + readonly version?: string; +} + +interface PackageNoticeOverrideConfigEntry { + readonly generatedNotice?: GeneratedNoticeConfigEntry; + readonly license?: string; + readonly name?: string; + readonly noticeFile?: string; + readonly repositoryUrl?: string; + readonly sourceUrl?: string; + readonly version?: string; +} + +interface ThirdPartyLicensesConfig { + readonly customNotices: ReadonlyArray; + readonly packageOverrides: ReadonlyArray; +} + +interface SpdxLicenseDetails { + readonly licenseId: string; + readonly licenseText: string; +} + +interface CollectedPackage { + readonly bundles: Set; + readonly packageJson: PackageJson; + readonly packageRoot: string; +} + +interface PackageCollection { + readonly byIdentity: Map; +} + +const EMPTY_CONFIG: ThirdPartyLicensesConfig = { + customNotices: [], + packageOverrides: [], +}; + +const NOTICE_FILE_PATTERN = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/i; +const NOTICE_TEXT_EXTENSIONS = new Set([ + "", + ".0bsd", + ".agpl", + ".apache2", + ".bsd", + ".gpl", + ".isc", + ".lgpl", + ".markdown", + ".md", + ".mit", + ".mpl", + ".mpl2", + ".rst", + ".txt", + ".unlicense", +]); +const FIRST_PARTY_PACKAGE_PREFIX = "@t3tools/"; + +function isNoticeTextFile(fileName: string): boolean { + return ( + NOTICE_FILE_PATTERN.test(fileName) && + NOTICE_TEXT_EXTENSIONS.has(NodePath.extname(fileName).toLowerCase()) + ); +} + +function asPath(value: string | URL): string { + return value instanceof URL ? NodeURL.fileURLToPath(value) : NodePath.resolve(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readRequiredString(value: Record, key: string, context: string): string { + const field = value[key]; + if (typeof field !== "string" || field.trim().length === 0) { + throw new Error(`${context} must define a non-empty "${key}" string.`); + } + return field.trim(); +} + +function readOptionalString( + value: Record, + key: string, + context: string, +): string | undefined { + const field = value[key]; + if (field === undefined) return undefined; + if (typeof field !== "string" || field.trim().length === 0) { + throw new Error(`${context} must define "${key}" as a non-empty string when present.`); + } + return field.trim(); +} + +function readOptionalStringArray( + value: Record, + key: string, + context: string, +): ReadonlyArray | undefined { + const field = value[key]; + if (field === undefined) return undefined; + if ( + !Array.isArray(field) || + field.length === 0 || + field.some((entry) => typeof entry !== "string" || entry.trim().length === 0) + ) { + throw new Error(`${context} must define "${key}" as a non-empty string array.`); + } + return field.map((entry) => (entry as string).trim()); +} + +function decodeGeneratedNotice(value: unknown, context: string): GeneratedNoticeConfigEntry { + if (!isRecord(value)) throw new Error(`${context} must be an object.`); + const copyrights = readOptionalStringArray(value, "copyrights", context); + const preamble = readOptionalStringArray(value, "preamble", context); + return { + licenseId: readRequiredString(value, "licenseId", context), + ...(copyrights !== undefined ? { copyrights } : {}), + ...(preamble !== undefined ? { preamble } : {}), + }; +} + +function decodeGeneratedNotices( + value: unknown, + context: string, +): ReadonlyArray | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`${context} must define "generatedNotices" as a non-empty array.`); + } + return value.map((entry, index) => + decodeGeneratedNotice(entry, `${context} generated notice at index ${String(index)}`), + ); +} + +function decodeCustomNotices(value: unknown): ReadonlyArray { + if (value === undefined) return []; + if (!Array.isArray(value)) { + throw new Error('Third-party license config field "customNotices" must be an array.'); + } + return value.map((entry, index) => { + const context = `Third-party custom notice at index ${String(index)}`; + if (!isRecord(entry)) throw new Error(`${context} must be an object.`); + const version = readOptionalString(entry, "version", context); + const sourceUrl = readOptionalString(entry, "sourceUrl", context); + const bundles = readOptionalStringArray(entry, "bundles", context); + const includeInBundles = readOptionalStringArray(entry, "includeInBundles", context); + const noticeFile = readOptionalString(entry, "noticeFile", context); + const noticeFiles = readOptionalStringArray(entry, "noticeFiles", context); + const generatedNotices = decodeGeneratedNotices(entry.generatedNotices, context); + const noticeSourceCount = + Number(noticeFile !== undefined) + + Number(noticeFiles !== undefined) + + Number(generatedNotices !== undefined); + if (noticeSourceCount !== 1) { + throw new Error( + `${context} must define exactly one of "noticeFile", "noticeFiles", or "generatedNotices".`, + ); + } + return { + name: readRequiredString(entry, "name", context), + license: readRequiredString(entry, "license", context), + ...(noticeFile !== undefined ? { noticeFiles: [noticeFile] } : {}), + ...(noticeFiles !== undefined ? { noticeFiles } : {}), + ...(generatedNotices !== undefined ? { generatedNotices } : {}), + ...(version !== undefined ? { version } : {}), + ...(sourceUrl !== undefined ? { sourceUrl } : {}), + ...(bundles !== undefined ? { bundles } : {}), + ...(includeInBundles !== undefined ? { includeInBundles } : {}), + }; + }); +} + +function decodePackageOverrides(value: unknown): ReadonlyArray { + if (value === undefined) return []; + if (!Array.isArray(value)) { + throw new Error('Third-party license config field "packageOverrides" must be an array.'); + } + return value.map((entry, index) => { + const context = `Third-party package override at index ${String(index)}`; + if (!isRecord(entry)) throw new Error(`${context} must be an object.`); + const name = readOptionalString(entry, "name", context); + const repositoryUrl = readOptionalString(entry, "repositoryUrl", context); + if ((name === undefined) === (repositoryUrl === undefined)) { + throw new Error(`${context} must define exactly one of "name" or "repositoryUrl".`); + } + const version = readOptionalString(entry, "version", context); + const license = readOptionalString(entry, "license", context); + const noticeFile = readOptionalString(entry, "noticeFile", context); + const generatedNotice = + entry.generatedNotice === undefined + ? undefined + : decodeGeneratedNotice(entry.generatedNotice, `${context} generated notice`); + if (noticeFile !== undefined && generatedNotice !== undefined) { + throw new Error(`${context} cannot define both "noticeFile" and "generatedNotice".`); + } + const sourceUrl = readOptionalString(entry, "sourceUrl", context); + return { + ...(name !== undefined ? { name } : {}), + ...(repositoryUrl !== undefined ? { repositoryUrl } : {}), + ...(version !== undefined ? { version } : {}), + ...(license !== undefined ? { license } : {}), + ...(noticeFile !== undefined ? { noticeFile } : {}), + ...(generatedNotice !== undefined ? { generatedNotice } : {}), + ...(sourceUrl !== undefined ? { sourceUrl } : {}), + }; + }); +} + +async function readConfig(configFile: string | URL | undefined): Promise<{ + readonly config: ThirdPartyLicensesConfig; + readonly directory: string; +}> { + if (configFile === undefined) { + return { config: EMPTY_CONFIG, directory: NodePath.resolve(".") }; + } + const configPath = asPath(configFile); + const source = await NodeFSP.readFile(configPath, "utf8"); + const decoded = JSON.parse(source) as unknown; + if (!isRecord(decoded)) throw new Error("Third-party license config must contain an object."); + return { + config: { + customNotices: decodeCustomNotices(decoded.customNotices), + packageOverrides: decodePackageOverrides(decoded.packageOverrides), + }, + directory: NodePath.dirname(configPath), + }; +} + +function spdxLicenseCachePath(configDirectory: string, licenseId: string): string { + return NodePath.join( + configDirectory, + GENERATED_NOTICE_CACHE_DIRECTORY, + SPDX_LICENSE_LIST_VERSION, + `${licenseId}.json`, + ); +} + +function decodeSpdxLicenseDetails(value: unknown, expectedLicenseId: string): SpdxLicenseDetails { + if ( + !isRecord(value) || + value.licenseId !== expectedLicenseId || + typeof value.licenseText !== "string" || + value.licenseText.trim().length === 0 + ) { + throw new Error(`SPDX returned invalid license details for ${expectedLicenseId}.`); + } + return { licenseId: expectedLicenseId, licenseText: value.licenseText.trim() }; +} + +async function readCachedSpdxLicense( + configDirectory: string, + licenseId: string, +): Promise { + try { + const source = await NodeFSP.readFile(spdxLicenseCachePath(configDirectory, licenseId), "utf8"); + return decodeSpdxLicenseDetails(JSON.parse(source) as unknown, licenseId); + } catch (error) { + const code = isRecord(error) && typeof error.code === "string" ? error.code : null; + if (code === "ENOENT") return null; + throw error; + } +} + +async function downloadSpdxLicense( + configDirectory: string, + licenseId: string, +): Promise { + const url = `https://raw.githubusercontent.com/spdx/license-list-data/${SPDX_LICENSE_LIST_REVISION}/json/details/${encodeURIComponent(licenseId)}.json`; + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Could not download SPDX license ${licenseId}: HTTP ${String(response.status)}.`, + ); + } + const details = decodeSpdxLicenseDetails((await response.json()) as unknown, licenseId); + const cachePath = spdxLicenseCachePath(configDirectory, licenseId); + await NodeFSP.mkdir(NodePath.dirname(cachePath), { recursive: true }); + await NodeFSP.writeFile(cachePath, `${JSON.stringify(details)}\n`, "utf8"); + return details; +} + +async function resolveSpdxLicense( + configDirectory: string, + licenseId: string, + allowMissing: boolean, +): Promise { + const cached = await readCachedSpdxLicense(configDirectory, licenseId); + if (cached || allowMissing) return cached; + return downloadSpdxLicense(configDirectory, licenseId); +} + +function renderGeneratedNotice(config: GeneratedNoticeConfigEntry, licenseText: string): string { + const copyrights = config.copyrights ?? []; + let renderedLicense = licenseText; + if (copyrights.length > 0) { + const placeholderPattern = /^Copyright[^\n]*(?:||)[^\n]*$/m; + if (placeholderPattern.test(renderedLicense)) { + renderedLicense = renderedLicense.replace(placeholderPattern, copyrights.join("\n")); + } else if (config.licenseId === "ISC") { + renderedLicense = renderedLicense.replace( + /^(?:Copyright[^\n]*\n)+/m, + `${copyrights.join("\n")}\n`, + ); + } else { + renderedLicense = `${copyrights.join("\n")}\n\n${renderedLicense}`; + } + } + return [...(config.preamble ?? []), renderedLicense].join("\n\n").trim(); +} + +async function generatedNoticeText( + configs: ReadonlyArray, + configDirectory: string, + allowMissing: boolean, +): Promise { + const sections = await Promise.all( + configs.map(async (config) => { + const details = await resolveSpdxLicense(configDirectory, config.licenseId, allowMissing); + return details ? renderGeneratedNotice(config, details.licenseText) : null; + }), + ); + return sections.some((section) => section === null) + ? null + : (sections as ReadonlyArray).join("\n\n---\n\n"); +} + +function configuredGeneratedNotices( + config: ThirdPartyLicensesConfig, +): ReadonlyArray { + return [ + ...config.customNotices.flatMap((notice) => notice.generatedNotices ?? []), + ...config.packageOverrides.flatMap((override) => + override.generatedNotice ? [override.generatedNotice] : [], + ), + ]; +} + +async function syncConfiguredGeneratedNotices( + config: ThirdPartyLicensesConfig, + directory: string, +): Promise { + const licenseIds = [ + ...new Set(configuredGeneratedNotices(config).map((notice) => notice.licenseId)), + ].sort((left, right) => left.localeCompare(right)); + await Promise.all(licenseIds.map((licenseId) => resolveSpdxLicense(directory, licenseId, false))); +} + +export async function syncThirdPartyLicenseNotices(configFile: string | URL): Promise { + const { config, directory } = await readConfig(configFile); + await syncConfiguredGeneratedNotices(config, directory); +} + +async function readPackageJson(packageJsonPath: string): Promise { + const source = await NodeFSP.readFile(packageJsonPath, "utf8"); + const value = JSON.parse(source) as unknown; + if (!isRecord(value)) throw new Error(`Package manifest is not an object: ${packageJsonPath}`); + return value as PackageJson; +} + +function packageIdentity(packageJson: PackageJson, packageRoot: string): string { + const name = + typeof packageJson.name === "string" ? packageJson.name : NodePath.basename(packageRoot); + const version = typeof packageJson.version === "string" ? packageJson.version : "unknown"; + return `${name}@${version}`; +} + +async function findPackageRoot( + resolvedPath: string, + expectedName?: string, +): Promise<{ readonly packageJson: PackageJson; readonly packageRoot: string } | null> { + let current = NodePath.dirname(resolvedPath); + const root = NodePath.parse(current).root; + + while (current !== root) { + const packageJsonPath = NodePath.join(current, "package.json"); + try { + const packageJson = await readPackageJson(packageJsonPath); + const matchesExpectedPackage = + expectedName !== undefined + ? packageJson.name === expectedName + : typeof packageJson.name === "string" && typeof packageJson.version === "string"; + if (matchesExpectedPackage) { + return { packageJson, packageRoot: await NodeFSP.realpath(current) }; + } + } catch (error) { + const code = isRecord(error) && typeof error.code === "string" ? error.code : null; + if (code !== "ENOENT" && code !== "ENOTDIR") throw error; + } + current = NodePath.dirname(current); + } + return null; +} + +async function resolveDependencyPackage( + dependencyName: string, + fromPackageJsonPath: string, +): Promise<{ readonly packageJson: PackageJson; readonly packageRoot: string } | null> { + const requireFromPackage = NodeModule.createRequire(fromPackageJsonPath); + const candidates = [`${dependencyName}/package.json`, dependencyName]; + for (const candidate of candidates) { + try { + const resolved = requireFromPackage.resolve(candidate); + const found = await findPackageRoot(resolved, dependencyName); + if (found) return found; + } catch (error) { + const code = isRecord(error) && typeof error.code === "string" ? error.code : null; + if (code !== "MODULE_NOT_FOUND" && code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") throw error; + } + } + + let current = NodePath.dirname(fromPackageJsonPath); + const root = NodePath.parse(current).root; + while (true) { + const packageRoot = NodePath.join(current, "node_modules", dependencyName); + try { + const packageJson = await readPackageJson(NodePath.join(packageRoot, "package.json")); + if (packageJson.name === dependencyName) { + return { packageJson, packageRoot: await NodeFSP.realpath(packageRoot) }; + } + } catch (error) { + const code = isRecord(error) && typeof error.code === "string" ? error.code : null; + if (code !== "ENOENT" && code !== "ENOTDIR") throw error; + } + if (current === root) break; + current = NodePath.dirname(current); + } + return null; +} + +function dependencyNames(packageJson: PackageJson): ReadonlyArray { + return [ + ...new Set([ + ...Object.keys(packageJson.dependencies ?? {}), + ...Object.keys(packageJson.optionalDependencies ?? {}), + ]), + ].sort((left, right) => left.localeCompare(right)); +} + +async function collectProductionDependencyPackages( + packageManifests: ReadonlyArray, +): Promise { + const collection: PackageCollection = { + byIdentity: new Map(), + }; + const visited = new Set(); + + const visitManifest = async (packageJsonPath: string, bundle: string): Promise => { + const packageJson = await readPackageJson(packageJsonPath); + for (const dependencyName of dependencyNames(packageJson)) { + const resolved = await resolveDependencyPackage(dependencyName, packageJsonPath); + if (!resolved) continue; + const visitKey = `${bundle}:${resolved.packageRoot}`; + if (visited.has(visitKey)) continue; + visited.add(visitKey); + + const dependencyPackageJsonPath = NodePath.join(resolved.packageRoot, "package.json"); + const name = + typeof resolved.packageJson.name === "string" ? resolved.packageJson.name : dependencyName; + if (!name.startsWith(FIRST_PARTY_PACKAGE_PREFIX)) { + const identity = packageIdentity(resolved.packageJson, resolved.packageRoot); + const existing = collection.byIdentity.get(identity); + if (existing) { + existing.bundles.add(bundle); + } else { + collection.byIdentity.set(identity, { + bundles: new Set([bundle]), + packageJson: resolved.packageJson, + packageRoot: resolved.packageRoot, + }); + } + } + + await visitManifest(dependencyPackageJsonPath, bundle); + } + }; + + for (const manifest of packageManifests) { + await visitManifest(asPath(manifest.path), manifest.bundle); + } + return collection; +} + +function moduleFilePath(moduleId: string): string | null { + if (moduleId.startsWith("\0") || moduleId.includes("\0")) return null; + const withoutQuery = moduleId.split(/[?#]/, 1)[0] ?? moduleId; + const viteFilePath = withoutQuery.startsWith("/@fs/") + ? withoutQuery.slice("/@fs/".length) + : withoutQuery; + const filePath = withoutQuery.startsWith("file:") + ? NodeURL.fileURLToPath(withoutQuery) + : /^[A-Za-z]:[\\/]/.test(viteFilePath) + ? viteFilePath + : NodePath.resolve("/", viteFilePath); + const normalized = filePath.replaceAll("\\", "/"); + return normalized.includes("/node_modules/") ? filePath : null; +} + +async function addBundledModulePackages( + collection: PackageCollection, + moduleIds: ReadonlyArray, + bundle: string, +): Promise { + for (const moduleId of moduleIds) { + const filePath = moduleFilePath(moduleId); + if (!filePath) continue; + let found: Awaited>; + try { + found = await findPackageRoot(await NodeFSP.realpath(filePath)); + } catch (error) { + const code = isRecord(error) && typeof error.code === "string" ? error.code : null; + if (code === "ENOENT" || code === "ENOTDIR") continue; + throw error; + } + if (!found || typeof found.packageJson.name !== "string") continue; + if (found.packageJson.name.startsWith(FIRST_PARTY_PACKAGE_PREFIX)) continue; + const identity = packageIdentity(found.packageJson, found.packageRoot); + const existing = collection.byIdentity.get(identity); + if (existing) { + existing.bundles.add(bundle); + } else { + collection.byIdentity.set(identity, { + bundles: new Set([bundle]), + packageJson: found.packageJson, + packageRoot: found.packageRoot, + }); + } + } +} + +function normalizeLicense(packageJson: PackageJson): string | null { + if (typeof packageJson.license === "string" && packageJson.license.trim().length > 0) { + return packageJson.license.trim(); + } + if (isRecord(packageJson.license) && typeof packageJson.license.type === "string") { + return packageJson.license.type.trim() || null; + } + const declaredLicenses = Array.isArray(packageJson.license) + ? packageJson.license + : packageJson.licenses; + if (Array.isArray(declaredLicenses)) { + const licenses = declaredLicenses + .map((entry) => { + if (typeof entry === "string") return entry.trim(); + if (isRecord(entry) && typeof entry.type === "string") return entry.type.trim(); + return ""; + }) + .filter((entry) => entry.length > 0); + if (licenses.length > 0) return licenses.join(" OR "); + } + return null; +} + +function normalizeRepositoryUrl(value: unknown): string | null { + const raw = + typeof value === "string" + ? value + : isRecord(value) && typeof value.url === "string" + ? value.url + : null; + if (!raw) return null; + const trimmed = raw.trim(); + if (trimmed.startsWith("github:")) return `https://github.com/${trimmed.slice(7)}`; + const normalized = trimmed + .replace(/^git\+ssh:\/\/git@github\.com\//, "https://github.com/") + .replace(/^git\+/, "") + .replace(/^git@github\.com:/, "https://github.com/") + .replace(/^ssh:\/\/(?:git@)?github\.com\//, "https://github.com/") + .replace(/^git:\/\/github\.com\//, "https://github.com/") + .replace(/#.*$/, "") + .replace(/\.git$/, ""); + if (/^[\w.-]+\/[\w.-]+$/.test(normalized)) return `https://github.com/${normalized}`; + return normalized; +} + +function packageSourceUrl(packageJson: PackageJson): string | null { + if (typeof packageJson.homepage === "string" && packageJson.homepage.trim().length > 0) { + return packageJson.homepage.trim(); + } + return normalizeRepositoryUrl(packageJson.repository); +} + +async function readPackageNoticeText(packageRoot: string): Promise { + const noticeFiles: string[] = []; + const rootEntries = await NodeFSP.readdir(packageRoot, { withFileTypes: true }); + noticeFiles.push( + ...rootEntries + .filter((entry) => entry.isFile() && isNoticeTextFile(entry.name)) + .map((entry) => entry.name), + ); + + const collectNestedNoticeFiles = async (directory: string, depth: number): Promise => { + const directoryEntries = await NodeFSP.readdir(NodePath.join(packageRoot, directory), { + withFileTypes: true, + }); + await Promise.all( + directoryEntries.map(async (entry) => { + const relativePath = NodePath.join(directory, entry.name); + if (entry.isFile() && isNoticeTextFile(entry.name)) { + noticeFiles.push(relativePath); + return; + } + if ( + depth > 0 && + entry.isDirectory() && + entry.name !== "node_modules" && + entry.name !== ".git" + ) { + await collectNestedNoticeFiles(relativePath, depth - 1); + } + }), + ); + }; + await Promise.all( + rootEntries + .filter( + (entry) => entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git", + ) + .map((entry) => collectNestedNoticeFiles(entry.name, 2)), + ); + noticeFiles.sort((left, right) => left.localeCompare(right)); + if (noticeFiles.length === 0) return null; + + const sections: string[] = []; + for (const fileName of noticeFiles) { + const contents = (await NodeFSP.readFile(NodePath.join(packageRoot, fileName), "utf8")).trim(); + if (contents.length === 0) continue; + sections.push(noticeFiles.length === 1 ? contents : `${fileName}\n\n${contents}`); + } + return sections.length > 0 ? sections.join("\n\n---\n\n") : null; +} + +function repositoryNoticeKey(packageJson: PackageJson, license: string): string | null { + const repositoryUrl = normalizeRepositoryUrl(packageJson.repository); + return repositoryUrl ? `${repositoryUrl.toLowerCase()}\n${license.toLowerCase()}` : null; +} + +async function collectRepositoryNotices( + collection: PackageCollection, + packageNotices: Map>, +): Promise> { + const notices = new Map(); + const candidates = await Promise.all( + [...collection.byIdentity.values()].map(async (collected) => { + const license = normalizeLicense(collected.packageJson); + if (!license) return null; + const key = repositoryNoticeKey(collected.packageJson, license); + if (!key) return null; + const noticeText = await packageNoticeText(collected.packageRoot, packageNotices); + return noticeText ? { key, noticeText } : null; + }), + ); + for (const candidate of candidates) { + if (candidate && !notices.has(candidate.key)) notices.set(candidate.key, candidate.noticeText); + } + return notices; +} + +function packageNoticeText( + packageRoot: string, + cache: Map>, +): Promise { + const existing = cache.get(packageRoot); + if (existing) return existing; + const notice = readPackageNoticeText(packageRoot); + cache.set(packageRoot, notice); + return notice; +} + +function findPackageOverride( + overrides: ReadonlyArray, + name: string, + version: string, + packageJson: PackageJson, +): PackageNoticeOverrideConfigEntry | undefined { + const repositoryUrl = normalizeRepositoryUrl(packageJson.repository)?.toLowerCase(); + const matchesRepository = (override: PackageNoticeOverrideConfigEntry) => + repositoryUrl !== undefined && + override.repositoryUrl !== undefined && + normalizeRepositoryUrl(override.repositoryUrl)?.toLowerCase() === repositoryUrl; + return ( + overrides.find((override) => override.name === name && override.version === version) ?? + overrides.find((override) => override.name === name && override.version === undefined) ?? + overrides.find((override) => matchesRepository(override) && override.version === version) ?? + overrides.find((override) => matchesRepository(override) && override.version === undefined) + ); +} + +async function packageEntry( + collected: CollectedPackage, + config: ThirdPartyLicensesConfig, + configDirectory: string, + packageNotices: Map>, + repositoryNotices: ReadonlyMap, + allowMissingGeneratedNotices: boolean, +): Promise { + const name = + typeof collected.packageJson.name === "string" + ? collected.packageJson.name + : NodePath.basename(collected.packageRoot); + const version = + typeof collected.packageJson.version === "string" ? collected.packageJson.version : "unknown"; + const override = findPackageOverride( + config.packageOverrides, + name, + version, + collected.packageJson, + ); + const license = override?.license ?? normalizeLicense(collected.packageJson); + if (!license || /^(?:unlicensed|proprietary)$/i.test(license)) { + throw new Error( + `${name}@${version} does not declare a distributable license. Add a package override in the third-party license config if the package publishes its notice elsewhere.`, + ); + } + + const repositoryKey = repositoryNoticeKey(collected.packageJson, license); + const noticeText = override?.generatedNotice + ? await generatedNoticeText( + [override.generatedNotice], + configDirectory, + allowMissingGeneratedNotices, + ) + : override?.noticeFile + ? ( + await NodeFSP.readFile(NodePath.resolve(configDirectory, override.noticeFile), "utf8") + ).trim() + : ((await packageNoticeText(collected.packageRoot, packageNotices)) ?? + (repositoryKey ? repositoryNotices.get(repositoryKey) : undefined)); + if (!noticeText) { + if (override?.generatedNotice && allowMissingGeneratedNotices) return null; + throw new Error( + `${name}@${version} does not include a license or notice file. Add a package override with "noticeFile" or "generatedNotice" in the third-party license config.`, + ); + } + + return { + bundles: [...collected.bundles].sort((left, right) => left.localeCompare(right)), + kind: "package", + license, + name, + noticeText, + sourceUrl: override?.sourceUrl ?? packageSourceUrl(collected.packageJson), + version, + }; +} + +async function customEntries( + config: ThirdPartyLicensesConfig, + configDirectory: string, + includedBundles: ReadonlySet, + allowMissingGeneratedNotices: boolean, +): Promise> { + const entries = await Promise.all( + config.customNotices + .filter( + (notice) => + (notice.includeInBundles ?? notice.bundles) === undefined || + (notice.includeInBundles ?? notice.bundles)?.some((bundle) => + includedBundles.has(bundle), + ), + ) + .map(async (notice) => { + const noticeText = notice.generatedNotices + ? await generatedNoticeText( + notice.generatedNotices, + configDirectory, + allowMissingGeneratedNotices, + ) + : ( + await Promise.all( + notice.noticeFiles!.map(async (noticeFile) => { + const contents = ( + await NodeFSP.readFile(NodePath.resolve(configDirectory, noticeFile), "utf8") + ).trim(); + if (contents.length === 0) { + throw new Error(`Custom third-party notice "${notice.name}" is empty.`); + } + return contents; + }), + ) + ).join("\n\n---\n\n"); + if (noticeText === null) { + if (allowMissingGeneratedNotices) return null; + throw new Error(`Could not generate custom third-party notice "${notice.name}".`); + } + if (noticeText.length === 0) { + throw new Error(`Custom third-party notice "${notice.name}" is empty.`); + } + return { + bundles: [...(notice.bundles ?? ["assets"])].sort((left, right) => + left.localeCompare(right), + ), + kind: "custom" as const, + license: notice.license, + name: notice.name, + noticeText, + sourceUrl: notice.sourceUrl ?? null, + version: notice.version ?? null, + }; + }), + ); + return entries.flatMap((entry) => (entry ? [entry] : [])); +} + +function entrySort(left: ThirdPartyLicenseEntry, right: ThirdPartyLicenseEntry): number { + return ( + left.name.localeCompare(right.name) || + (left.version ?? "").localeCompare(right.version ?? "") || + left.kind.localeCompare(right.kind) + ); +} + +function assertUniqueEntries(entries: ReadonlyArray): void { + const identities = new Set(); + for (const entry of entries) { + const identity = JSON.stringify([entry.kind, entry.name, entry.version]); + if (identities.has(identity)) { + throw new Error( + `Third-party license generation found a duplicate ${entry.kind} notice for ${entry.name}${entry.version ? `@${entry.version}` : ""}.`, + ); + } + identities.add(identity); + } +} + +export async function generateThirdPartyLicenseManifest(input: { + readonly configFile?: string | URL; + readonly packageManifests: ReadonlyArray; + readonly bundledModuleIds?: ReadonlyArray; + readonly bundleName?: string; + readonly allowMissingGeneratedNotices?: boolean; +}): Promise { + const [{ config, directory }, collection] = await Promise.all([ + readConfig(input.configFile), + collectProductionDependencyPackages(input.packageManifests), + ]); + if (!(input.allowMissingGeneratedNotices ?? false)) { + await syncConfiguredGeneratedNotices(config, directory); + } + if (input.bundledModuleIds && input.bundleName) { + await addBundledModulePackages(collection, input.bundledModuleIds, input.bundleName); + } + + const packageNotices = new Map>(); + const repositoryNotices = await collectRepositoryNotices(collection, packageNotices); + + const packageEntryResults = await Promise.allSettled( + [...collection.byIdentity.values()].map((collected) => + packageEntry( + collected, + config, + directory, + packageNotices, + repositoryNotices, + input.allowMissingGeneratedNotices ?? false, + ), + ), + ); + const failures = packageEntryResults.flatMap((result) => + result.status === "rejected" + ? [result.reason instanceof Error ? result.reason.message : String(result.reason)] + : [], + ); + if (failures.length > 0) { + throw new Error( + `Third-party license generation found ${String(failures.length)} invalid package notice${failures.length === 1 ? "" : "s"}:\n${failures.map((failure) => `- ${failure}`).join("\n")}`, + ); + } + const packageEntries = packageEntryResults.flatMap((result) => + result.status === "fulfilled" && result.value ? [result.value] : [], + ); + const includedBundles = new Set(input.packageManifests.map((manifest) => manifest.bundle)); + if (input.bundleName) includedBundles.add(input.bundleName); + const manualEntries = await customEntries( + config, + directory, + includedBundles, + input.allowMissingGeneratedNotices ?? false, + ); + const entries = [...packageEntries, ...manualEntries].sort(entrySort); + assertUniqueEntries(entries); + return { + schemaVersion: 1, + entries, + }; +} + +function moduleIdsFromBundle(bundle: unknown): ReadonlyArray { + const ids = new Set(); + if (!isRecord(bundle)) return []; + for (const output of Object.values(bundle)) { + if (!isRecord(output) || output.type !== "chunk" || !isRecord(output.modules)) continue; + for (const id of Object.keys(output.modules)) ids.add(id); + } + return [...ids]; +} + +function serializeManifest(manifest: ThirdPartyLicenseManifest): string { + return `${JSON.stringify(manifest)}\n`; +} + +export function thirdPartyLicensesPlugin(options: ThirdPartyLicensesPluginOptions): Plugin { + return { + name: "t3code:third-party-licenses", + configureServer(server) { + let manifestPromise: Promise | null = null; + server.middlewares.use((request, response, next) => { + if (request.url?.split("?", 1)[0] !== `/${THIRD_PARTY_LICENSES_FILE_NAME}`) { + next(); + return; + } + manifestPromise ??= generateThirdPartyLicenseManifest({ + packageManifests: options.packageManifests, + bundleName: options.bundleName, + allowMissingGeneratedNotices: true, + ...(options.configFile !== undefined ? { configFile: options.configFile } : {}), + }).catch((error: unknown) => { + manifestPromise = null; + throw error; + }); + void manifestPromise.then( + (manifest) => { + response.statusCode = 200; + response.setHeader("Content-Type", "application/json; charset=utf-8"); + response.setHeader("Cache-Control", "no-store"); + response.end(serializeManifest(manifest)); + }, + (error: unknown) => { + next(error instanceof Error ? error : new Error(String(error))); + }, + ); + }); + }, + async generateBundle(_outputOptions, bundle) { + const manifest = await generateThirdPartyLicenseManifest({ + packageManifests: options.packageManifests, + bundledModuleIds: moduleIdsFromBundle(bundle), + bundleName: options.bundleName, + ...(options.configFile !== undefined ? { configFile: options.configFile } : {}), + }); + this.emitFile({ + type: "asset", + fileName: THIRD_PARTY_LICENSES_FILE_NAME, + source: serializeManifest(manifest), + }); + }, + }; +} diff --git a/scripts/sync-third-party-license-notices.ts b/scripts/sync-third-party-license-notices.ts new file mode 100644 index 000000000..55a3fc0ee --- /dev/null +++ b/scripts/sync-third-party-license-notices.ts @@ -0,0 +1,9 @@ +// @effect-diagnostics nodeBuiltinImport:off - This is a build-time filesystem script. + +import * as NodePath from "node:path"; + +import { syncThirdPartyLicenseNotices } from "./lib/third-party-licenses.ts"; + +const configFile = NodePath.resolve("third-party-licenses.config.json"); + +await syncThirdPartyLicenseNotices(configFile); diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json new file mode 100644 index 000000000..8670eaf64 --- /dev/null +++ b/third-party-licenses.config.json @@ -0,0 +1,450 @@ +{ + "customNotices": [ + { + "bundles": ["assets", "desktop", "web"], + "license": "CC0-1.0", + "name": "Folder_Whoosh.wav by BaggoNotes", + "sourceUrl": "https://freesound.org/people/BaggoNotes/sounds/704258/", + "generatedNotices": [ + { + "licenseId": "CC0-1.0", + "preamble": [ + "The bundled snap-shot-whoosh.mp3 is derived from Folder_Whoosh.wav by BaggoNotes:\nhttps://freesound.org/people/BaggoNotes/sounds/704258/\n\nThe original sound is dedicated to the public domain under CC0 1.0 Universal." + ] + } + ] + }, + { + "bundles": ["assets", "desktop", "web"], + "license": "CC0-1.0", + "name": "Contarex camera shutter.wav by Tonik1105", + "sourceUrl": "https://freesound.org/people/Tonik1105/sounds/520684/", + "generatedNotices": [ + { + "licenseId": "CC0-1.0", + "preamble": [ + "The bundled snap-shot-click.mp3 is derived from Contarex camera shutter.wav by Tonik1105:\nhttps://freesound.org/people/Tonik1105/sounds/520684/\n\nThe original sound is dedicated to the public domain under CC0 1.0 Universal." + ] + } + ] + }, + { + "bundles": ["device-tools"], + "includeInBundles": ["mobile", "web"], + "license": "MIT", + "name": "agent-device", + "sourceUrl": "https://github.com/callstack/agent-device", + "version": "0.20.10", + "generatedNotices": [ + { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2026 Callstack"] + } + ] + }, + { + "bundles": ["device-tools"], + "includeInBundles": ["mobile", "web"], + "license": "MIT AND Apache-2.0 AND BSD-3-Clause", + "name": "expo-device-hub", + "sourceUrl": "https://github.com/expo/expo-device-hub", + "version": "0.9.0", + "generatedNotices": [ + { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)"] + }, + { + "licenseId": "Apache-2.0", + "preamble": [ + "This distribution includes software from the original serve-sim project, created and open-sourced by Evan Bacon:\nhttps://github.com/EvanBacon/serve-sim\n\nOriginal work copyright 2026 Evan Bacon. This fork contains modifications by Expo and other contributors." + ] + }, + { + "licenseId": "BSD-3-Clause", + "copyrights": ["Copyright (c) 2011, The WebRTC project authors. All rights reserved."] + } + ] + }, + { + "bundles": ["android", "assets", "mobile", "web"], + "license": "MIT", + "name": "libghostty-vt", + "noticeFile": "native/libghostty-vt/LICENSE", + "sourceUrl": "https://github.com/ghostty-org/ghostty" + }, + { + "bundles": ["assets", "web"], + "license": "MIT", + "name": "Symbols Nerd Font Mono", + "noticeFile": "apps/web/src/terminal/ghostty/fonts/LICENSE", + "sourceUrl": "https://github.com/ryanoasis/nerd-fonts" + }, + { + "bundles": ["ios", "mobile"], + "license": "MIT", + "name": "GhosttyKit", + "sourceUrl": "https://github.com/Yash-Singh1/ghostty/tree/t3code/custom-io-ordered-feed", + "generatedNotices": [ + { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors"], + "preamble": [ + "GhosttyKit\n\nThe iOS terminal renderer vendors GhosttyKit.xcframework, a libghostty build produced from VVTerm's custom-I/O and live-padding Ghostty branch.\n\nUpstream project: https://github.com/ghostty-org/ghostty\nBased on: https://github.com/wiedymi/ghostty/tree/vvterm/custom-io-padding\nVendored source branch: https://github.com/Yash-Singh1/ghostty/tree/t3code/custom-io-ordered-feed\nVendored revision: cf8edc23f3a6a87a96e41a90013e89e987d34980\nReference integration: https://github.com/vivy-company/vvterm" + ] + } + ] + }, + { + "bundles": ["android", "assets", "mobile"], + "license": "Apache-2.0", + "name": "MesloLGS NF", + "sourceUrl": "https://github.com/romkatv/powerlevel10k-media", + "generatedNotices": [ + { + "licenseId": "Apache-2.0", + "copyrights": ["Copyright 2009, 2010, 2013 André Berg"] + } + ] + }, + { + "bundles": ["mobile"], + "license": "MIT", + "name": "react-native-uitextview", + "noticeFile": "apps/mobile/modules/t3-markdown-text/LICENSE", + "sourceUrl": "https://github.com/bluesky-social/react-native-uitextview", + "version": "2.2.0" + }, + { + "bundles": ["mobile"], + "license": "MIT", + "name": "Expo text editor implementation", + "noticeFile": "apps/mobile/modules/t3-composer-editor/LICENSE", + "sourceUrl": "https://github.com/expo/expo" + } + ], + "packageOverrides": [ + { + "license": "MIT", + "name": "@react-grab/cli", + "sourceUrl": "https://github.com/aidenybai/react-grab/tree/main/packages/cli", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2025 Aiden Bai"] + } + }, + { + "name": "glob-to-regexp", + "generatedNotice": { + "licenseId": "BSD-2-Clause", + "copyrights": ["Copyright (c) 2013, Nick Fitzgerald"] + } + }, + { + "name": "kubernetes-types", + "generatedNotice": { + "licenseId": "Apache-2.0" + } + }, + { + "name": "@msgpackr-extract/msgpackr-extract-linux-x64", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2020 Kris Zyp"] + } + }, + { + "name": "isarray", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2013 Julian Gruber "] + } + }, + { + "name": "keyv", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2017-2021 Luke Childs", "Copyright (c) 2021-2022 Jared Wray"] + } + }, + { + "license": "MIT", + "name": "type-fest", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": [ + "Copyright (c) Sindre Sorhus (https://sindresorhus.com)" + ] + } + }, + { + "name": "lazy-val", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) Vladimir Krivosheev"] + } + }, + { + "name": "@pierre/theming", + "generatedNotice": { + "licenseId": "Apache-2.0", + "copyrights": ["Copyright 2025 Pierre Computer Company"] + } + }, + { + "name": "lru_map", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2010-2016 Rasmus Andersson "] + } + }, + { + "name": "@electron-internal/extract-zip", + "generatedNotice": { + "licenseId": "BSD-2-Clause", + "copyrights": ["Copyright (c) 2026 Samuel Attard and Electron contributors"] + } + }, + { + "repositoryUrl": "https://github.com/xa11y/xa11y", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2025 Stephen Crowe"] + } + }, + { + "license": "MIT", + "name": "map-stream", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2011 Dominic Tarr"] + } + }, + { + "license": "Apache-2.0", + "name": "jsbi" + }, + { + "name": "@yuuang/ffi-rs-android-arm64", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2019 zhangyuang"] + } + }, + { + "name": "@yuuang/ffi-rs-darwin-arm64", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2019 zhangyuang"] + } + }, + { + "name": "@yuuang/ffi-rs-darwin-x64", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2019 zhangyuang"] + } + }, + { + "name": "@yuuang/ffi-rs-linux-arm-gnueabihf", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2019 zhangyuang"] + } + }, + { + "name": "@yuuang/ffi-rs-linux-arm64-gnu", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2019 zhangyuang"] + } + }, + { + "name": "@yuuang/ffi-rs-linux-arm64-musl", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2019 zhangyuang"] + } + }, + { + "name": "@yuuang/ffi-rs-linux-x64-gnu", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2019 zhangyuang"] + } + }, + { + "name": "@yuuang/ffi-rs-linux-x64-musl", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2019 zhangyuang"] + } + }, + { + "name": "@yuuang/ffi-rs-win32-arm64-msvc", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2019 zhangyuang"] + } + }, + { + "name": "@yuuang/ffi-rs-win32-ia32-msvc", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2019 zhangyuang"] + } + }, + { + "name": "@yuuang/ffi-rs-win32-x64-msvc", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2019 zhangyuang"] + } + }, + { + "repositoryUrl": "https://github.com/facebook/metro", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) Meta Platforms, Inc. and affiliates."] + } + }, + { + "repositoryUrl": "https://github.com/react/metro", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) Meta Platforms, Inc. and affiliates."] + } + }, + { + "name": "@react-native-ai/apple", + "sourceUrl": "https://github.com/callstackincubator/ai", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": [ + "Copyright (c) 2024-2025 Szymon Rybczak", + "Copyright (c) 2025-present Callstack" + ] + } + }, + { + "name": "react-remove-scroll-bar", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) Anton Korzunov "] + } + }, + { + "name": "standard-navigation", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2026 React Navigation Contributors"] + } + }, + { + "name": "@expo/sdk-runtime-versions", + "noticeFile": "apps/mobile/modules/t3-composer-editor/LICENSE" + }, + { + "name": "@expo/ws-tunnel", + "noticeFile": "apps/mobile/modules/t3-composer-editor/LICENSE" + }, + { + "name": "stream-buffers", + "generatedNotice": { + "licenseId": "Unlicense" + } + }, + { + "name": "bplist-parser", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2012 Near Infinity Corporation"] + } + }, + { + "name": "@expo/devcert", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) Dave Wasmer"] + } + }, + { + "name": "jimp-compact", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2018 Oliver Moran"] + } + }, + { + "name": "fb-watchman", + "generatedNotice": { + "licenseId": "Apache-2.0" + } + }, + { + "name": "bser", + "generatedNotice": { + "licenseId": "Apache-2.0" + } + }, + { + "name": "@expo/xcpretty", + "generatedNotice": { + "licenseId": "BSD-3-Clause", + "copyrights": ["Copyright (c) 2016-present, 650 Industries, Inc. (Expo)"] + } + }, + { + "license": "MIT", + "name": "fb-dotslash", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) Meta Platforms, Inc. and affiliates."] + } + }, + { + "name": "structured-headers", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2018 Evert Pot"] + } + }, + { + "name": "badgin", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) Julian Hundeloh and Tinycon contributors"], + "preamble": ["badgin is a refactored fork of Tinycon."] + } + }, + { + "name": "react-native-nitro-modules", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2024 Marc Rousavy"] + } + }, + { + "name": "boolbase", + "generatedNotice": { + "licenseId": "ISC", + "copyrights": ["Copyright (c) 2014-2015, Felix Boehm "] + } + }, + { + "repositoryUrl": "https://github.com/dmtrKovalenko/fff", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2025 Dmitriy Kovalenko"] + } + }, + { + "name": "@opencode-ai/sdk", + "sourceUrl": "https://github.com/anomalyco/opencode/tree/dev/packages/sdk/js", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2025 opencode"] + } + } + ] +} From 38efa3c35430f1a7df8c742c9eb9b65be48a1021 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Sat, 12 Sep 2026 00:28:55 -0600 Subject: [PATCH 2/2] fix(licenses): generate notices for Pylon dependencies and identity --- .../web/src/components/settings/SettingsPanels.tsx | 2 +- docs/internals/open-source-licenses.md | 4 ++++ third-party-licenses.config.json | 14 +++++++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index aa408c27a..9c89e7d14 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2921,7 +2921,7 @@ export function GeneralSettingsPanel() { /> } diff --git a/docs/internals/open-source-licenses.md b/docs/internals/open-source-licenses.md index 5e882e5c8..20ee4aa86 100644 --- a/docs/internals/open-source-licenses.md +++ b/docs/internals/open-source-licenses.md @@ -97,6 +97,10 @@ The `@react-grab/cli` override uses the root React Grab repository's MIT license npm archive omits both its license field and license file. Keep the override until the published CLI package carries that metadata itself. +The version-specific `@npmcli/agent` 4.0.2 override uses the ISC identifier and GitHub Inc. +author attribution declared in its tagged `package.json`; neither that tag nor the npm archive +includes a license file. Recheck this override when upgrading the package. + Generated mobile files live under `apps/mobile/.generated/`, while fetched SPDX templates live under the repository `.generated/` directory. Both are ignored. Do not commit or edit them; updating dependencies or configuration is enough for the next strict build to refresh the output. diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 8670eaf64..cbd5f6644 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -103,7 +103,7 @@ "generatedNotices": [ { "licenseId": "Apache-2.0", - "copyrights": ["Copyright 2009, 2010, 2013 André Berg"] + "copyrights": ["Copyright 2009, 2010, 2013 Andr\u00e9 Berg"] } ] }, @@ -445,6 +445,18 @@ "licenseId": "MIT", "copyrights": ["Copyright (c) 2025 opencode"] } + }, + { + "name": "@npmcli/agent", + "version": "4.0.2", + "sourceUrl": "https://github.com/npm/agent/tree/v4.0.2", + "generatedNotice": { + "licenseId": "ISC", + "copyrights": ["Copyright (c) GitHub Inc."], + "preamble": [ + "@npmcli/agent 4.0.2 declares ISC licensing and GitHub Inc. as its author in package.json. The published archive and tagged source do not include a license file." + ] + } } ] }