diff --git a/.gitignore b/.gitignore index 57262578a786..b65fb1ed9914 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 f8eda69b6fb7..02a6e2c8666c 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 5150250a1b6d..000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png and /dev/null differ 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 bfbd4298b015..000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png and /dev/null differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png deleted file mode 100644 index e8bf751f434b..000000000000 Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png and /dev/null differ 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 8510ce97ba68..fdbb03382eef 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 463e00207d97..fb0209b6a990 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 19f71f631663..4143b2fc8a28 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 dd0b48700ef7..914b7c4f73a4 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 000000000000..48be8d4408dc --- /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 T3 Code 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 d57904b7e7a2..b4614d1c071c 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -821,6 +821,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 bf7e90e41973..794c59f98339 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 000000000000..7dfee0688bcb --- /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 c9a675ef41c9..000000000000 --- 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 000000000000..dc4703cad51a --- /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 5c1cf73f70da..a21b58d03e0b 100644 --- a/apps/web/src/components/settings/SettingsBreadcrumb.tsx +++ b/apps/web/src/components/settings/SettingsBreadcrumb.tsx @@ -36,6 +36,7 @@ import { 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 17af7cc9a115..8a78d4138c6b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2907,6 +2907,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 1893a59acb42..f215d6c25e11 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -322,7 +322,10 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { {navItems.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 b4b83e4df976..49aec9cc4cef 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 a274d16b3d86..4ce1c3557ee8 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 000000000000..1737b0b27f40 --- /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 65b04800e837..a49328545a94 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 ec4f15fc7eeb..1d710f273cd0 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(); @@ -157,6 +158,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 000000000000..5e882e5c87fa --- /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 000000000000..2c99bc46fb10 --- /dev/null +++ b/docs/user/open-source-licenses.md @@ -0,0 +1,13 @@ +# Open source licenses + +T3 Code 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 T3 Code that include +or use the item. This also covers optional device tools that T3 Code 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 dd1a106a0409..ab88fbeb4d6e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "screenshots:mobile": "node scripts/mobile-showcase.ts", "icons:export": "node scripts/export-brand-icons.ts", "icons:check": "node scripts/export-brand-icons.ts --check", + "licenses:sync": "node scripts/sync-third-party-license-notices.ts", "icons:export:android": "node scripts/export-android-icons.ts", "build": "vp run --filter './apps/*' build", "build:marketing": "vp run --filter @t3tools/marketing build", diff --git a/packages/shared/package.json b/packages/shared/package.json index bda80d937b92..4d4fa4bf015b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -275,6 +275,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 000000000000..c133cfdfa342 --- /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 000000000000..ab570cb2e1e7 --- /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 000000000000..997b2bd58d9e --- /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 000000000000..487853fab904 --- /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 000000000000..55a3fc0eeae8 --- /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 000000000000..8670eaf64ad5 --- /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"] + } + } + ] +}