authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-11 21:53:33-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 21:53:33-07:00
log4a4c6dd2adc350a68ba18bb28b24b5a7e4660dab
tree5bebaca08ccefa0603d9de20eb20c6cd939bb07e
parente816064945144957b6eb9b268912a98b0555644b
signature Signed by PGP key B5690EEEBB952194

feat(settings): add open source license notices (#8962)

Co-authored-by: maria <maria@kuuro.net>

38 files changed, 3006 insertions(+), 58 deletions(-)

.gitignore+2
......@@ -30,6 +30,8 @@ dist-electron/
3030.electron-runtime/
3131.showcase/
3232apps/mobile/.showcase/
33apps/mobile/.generated/
34/.generated/
3335artifacts/app-store/screenshots/
3436.github/pr-assets/
3537native/**/target/
apps/mobile/metro.config.js+52-5
......@@ -1,5 +1,6 @@
11const fs = require("node:fs");
22const path = require("node:path");
3const { pathToFileURL } = require("node:url");
34const { getDefaultConfig } = require("expo/metro-config");
45const { withUniwindConfig } = require("uniwind/metro");
56const extraThemes = require("./generated-uniwind-theme-names.json");
......@@ -7,6 +8,13 @@ const extraThemes = require("./generated-uniwind-theme-names.json");
78/** @type {import("expo/metro-config").MetroConfig} */
89const config = getDefaultConfig(__dirname);
910const workspaceRoot = path.resolve(__dirname, "../..");
11const generatedLicenseModuleRoot = path.join(__dirname, ".generated", "third-party-licenses");
12const licenseGeneratorSource = path.join(
13 workspaceRoot,
14 "scripts",
15 "lib",
16 "third-party-licenses.ts",
17);
1018const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1119const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] }));
1220const resolveShikiDependencyRoot = (packageName) => {
......@@ -37,6 +45,7 @@ config.resolver = {
3745 ],
3846 extraNodeModules: {
3947 ...config.resolver?.extraNodeModules,
48 "@t3tools/mobile-third-party-licenses": generatedLicenseModuleRoot,
4049 shiki: mobileShikiRoot,
4150 "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"),
4251 "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"),
......@@ -48,8 +57,46 @@ config.resolver = {
4857 },
4958};
5059
51module.exports = withUniwindConfig(config, {
52 cssEntryFile: "./global.css",
53 extraThemes,
54 polyfills: { rem: 14 },
55});
60async function writeFileIfChanged(filePath, contents) {
61 try {
62 if ((await fs.promises.readFile(filePath, "utf8")) === contents) return;
63 } catch (error) {
64 if (error?.code !== "ENOENT") throw error;
65 }
66 await fs.promises.writeFile(filePath, contents, "utf8");
67}
68
69async function generateMobileThirdPartyLicenses() {
70 await fs.promises.mkdir(generatedLicenseModuleRoot, { recursive: true });
71 const generatorVersion = (await fs.promises.stat(licenseGeneratorSource)).mtimeMs;
72 const { generateThirdPartyLicenseManifest } = await import(
73 `${pathToFileURL(licenseGeneratorSource).href}?version=${String(generatorVersion)}`
74 );
75 const manifest = await generateThirdPartyLicenseManifest({
76 configFile: path.join(workspaceRoot, "third-party-licenses.config.json"),
77 packageManifests: [{ bundle: "mobile", path: path.join(__dirname, "package.json") }],
78 allowMissingGeneratedNotices:
79 process.env.NODE_ENV !== "production" &&
80 process.env.EAS_BUILD !== "true" &&
81 process.env.T3CODE_LICENSES_STRICT !== "1",
82 });
83
84 await Promise.all([
85 writeFileIfChanged(
86 path.join(generatedLicenseModuleRoot, "index.js"),
87 `module.exports = ${JSON.stringify(manifest)};\n`,
88 ),
89 writeFileIfChanged(
90 path.join(generatedLicenseModuleRoot, "package.json"),
91 '{"main":"index.js"}\n',
92 ),
93 ]);
94}
95
96module.exports = generateMobileThirdPartyLicenses().then(() =>
97 withUniwindConfig(config, {
98 cssEntryFile: "./global.css",
99 extraThemes,
100 polyfills: { rem: 14 },
101 }),
102);
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs-4
......@@ -79,11 +79,7 @@ const colors = {
7979
8080const customIcons = {
8181 agents: "t3-file-icon-agents",
82 claude: "t3-file-icon-claude",
83 package: "t3-file-icon-package-json",
8482 pnpm: "t3-file-icon-pnpm",
85 readme: "t3-file-icon-readme",
86 tsconfig: "t3-file-icon-tsconfig",
8783 video: "t3-file-icon-video",
8884};
8985
apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts-3
......@@ -30,13 +30,11 @@ export const MARKDOWN_FILE_ICON_SOURCES = {
3030 nextjs: require("../assets/file-icons/pierre_nextjs.png"),
3131 npm: require("../assets/file-icons/pierre_npm.png"),
3232 oxc: require("../assets/file-icons/pierre_oxc.png"),
33 package: require("../assets/file-icons/pierre_package.png"),
3433 pnpm: require("../assets/file-icons/pierre_pnpm.png"),
3534 postcss: require("../assets/file-icons/pierre_postcss.png"),
3635 prettier: require("../assets/file-icons/pierre_prettier.png"),
3736 python: require("../assets/file-icons/pierre_python.png"),
3837 react: require("../assets/file-icons/pierre_react.png"),
39 readme: require("../assets/file-icons/pierre_readme.png"),
4038 ruby: require("../assets/file-icons/pierre_ruby.png"),
4139 rust: require("../assets/file-icons/pierre_rust.png"),
4240 sass: require("../assets/file-icons/pierre_sass.png"),
......@@ -49,7 +47,6 @@ export const MARKDOWN_FILE_ICON_SOURCES = {
4947 tailwind: require("../assets/file-icons/pierre_tailwind.png"),
5048 terraform: require("../assets/file-icons/pierre_terraform.png"),
5149 text: require("../assets/file-icons/pierre_text.png"),
52 tsconfig: require("../assets/file-icons/pierre_tsconfig.png"),
5350 typescript: require("../assets/file-icons/pierre_typescript.png"),
5451 video: require("../assets/file-icons/pierre_video.png"),
5552 vite: require("../assets/file-icons/pierre_vite.png"),
apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts+4-4
......@@ -116,7 +116,7 @@ const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = {
116116 "next.config.mjs": "nextjs",
117117 "next.config.mts": "nextjs",
118118 "next.config.ts": "nextjs",
119 "package.json": "package",
119 "package.json": "npm",
120120 "pnpm-lock.yaml": "pnpm",
121121 "pnpm-workspace.yaml": "pnpm",
122122 "postcss.config.js": "postcss",
......@@ -127,7 +127,7 @@ const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = {
127127 "prettier.config.cjs": "prettier",
128128 "prettier.config.mjs": "prettier",
129129 rakefile: "ruby",
130 "readme.md": "readme",
130 "readme.md": "markdown",
131131 "stylelint.config.js": "stylelint",
132132 "stylelint.config.cjs": "stylelint",
133133 "stylelint.config.mjs": "stylelint",
......@@ -139,7 +139,7 @@ const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = {
139139 "tailwind.config.cjs": "tailwind",
140140 "tailwind.config.mjs": "tailwind",
141141 "tailwind.config.ts": "tailwind",
142 "tsconfig.json": "tsconfig",
142 "tsconfig.json": "typescript",
143143 "vite.config.js": "vite",
144144 "vite.config.mjs": "vite",
145145 "vite.config.mts": "vite",
......@@ -253,7 +253,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
253253 const exactIcon = FILE_ICON_BY_NAME[basename];
254254 if (exactIcon) return exactIcon;
255255 if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) {
256 return "tsconfig";
256 return "typescript";
257257 }
258258 const segments = basename.split(".");
259259 for (let index = 1; index < segments.length; index += 1) {
apps/mobile/src/Stack.tsx+18
......@@ -55,6 +55,10 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl
5555import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
5656import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
5757import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
58import {
59 SettingsOpenSourceLicenseRouteScreen,
60 SettingsOpenSourceLicensesRouteScreen,
61} from "./features/settings/SettingsOpenSourceLicensesRouteScreen";
5862import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
5963import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled";
6064import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
......@@ -197,6 +201,20 @@ const SettingsContentStack = createNativeStackNavigator({
197201 screen: UsageLimitAccountScreen,
198202 options: { title: "Account" },
199203 }),
204 SettingsOpenSourceLicenses: createNativeStackScreen({
205 screen: SettingsOpenSourceLicensesRouteScreen,
206 linking: "open-source-licenses",
207 options: {
208 title: "Open source licenses",
209 },
210 }),
211 SettingsOpenSourceLicense: createNativeStackScreen({
212 screen: SettingsOpenSourceLicenseRouteScreen,
213 linking: "open-source-licenses/:entryKey",
214 options: {
215 title: "License notice",
216 },
217 }),
200218 SettingsUsage: createNativeStackScreen({
201219 screen: UsageRouteScreen,
202220 linking: "usage",
apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx created+241
......@@ -0,0 +1,241 @@
1import { LegendList } from "@legendapp/list/react-native";
2import { type StaticScreenProps, useNavigation } from "@react-navigation/native";
3import {
4 filterThirdPartyLicenseEntries,
5 findThirdPartyLicenseEntry,
6 formatLicenseBundles,
7 thirdPartyLicenseEntryKey,
8 type ThirdPartyLicenseEntry,
9} from "@t3tools/shared/thirdPartyLicenses";
10import { useCallback, useMemo, useState } from "react";
11import { Linking, Platform, Pressable, ScrollView, View } from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";
13
14import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
15import { SymbolView } from "../../components/AppSymbol";
16import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
17import { NativeStackScreenOptions } from "../../native/StackHeader";
18import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses";
19
20function useMobileThirdPartyLicenses() {
21 return useMemo(() => {
22 try {
23 return getMobileThirdPartyLicenses();
24 } catch {
25 return null;
26 }
27 }, []);
28}
29
30function LicenseRow(props: {
31 readonly entry: ThirdPartyLicenseEntry;
32 readonly onPress: () => void;
33}) {
34 return (
35 <Pressable
36 accessibilityHint="Opens the complete license notice"
37 accessibilityLabel={`${props.entry.name}, ${props.entry.license}`}
38 accessibilityRole="button"
39 onPress={props.onPress}
40 className="border-b border-border bg-card px-5 py-4 active:bg-card-alt"
41 >
42 <View className="flex-row items-start gap-3">
43 <View className="min-w-0 flex-1 gap-1">
44 <Text className="text-base font-t3-medium text-foreground" numberOfLines={2}>
45 {props.entry.name}
46 </Text>
47 <Text className="text-sm text-foreground-muted" numberOfLines={2}>
48 {props.entry.version ? `${props.entry.version} · ` : ""}
49 {props.entry.license}
50 </Text>
51 </View>
52 <SymbolView
53 name="chevron.right"
54 size={16}
55 tintColorClassName={"accent-chevron"}
56 type="monochrome"
57 weight="semibold"
58 />
59 </View>
60 </Pressable>
61 );
62}
63
64export function SettingsOpenSourceLicensesRouteScreen() {
65 const navigation = useNavigation();
66 const insets = useSafeAreaInsets();
67 const [query, setQuery] = useState("");
68 const manifest = useMobileThirdPartyLicenses();
69 const entries = manifest?.entries ?? [];
70 const filteredEntries = useMemo(
71 () => filterThirdPartyLicenseEntries(entries, query),
72 [entries, query],
73 );
74 const renderItem = useCallback(
75 ({ item }: { readonly item: ThirdPartyLicenseEntry }) => (
76 <LicenseRow
77 entry={item}
78 onPress={() =>
79 navigation.navigate("SettingsSheet", {
80 screen: "SettingsContent",
81 params: {
82 screen: "SettingsOpenSourceLicense",
83 params: { entryKey: thirdPartyLicenseEntryKey(item) },
84 },
85 })
86 }
87 />
88 ),
89 [navigation],
90 );
91
92 if (!manifest) {
93 return (
94 <View collapsable={false} className="flex-1 bg-sheet">
95 {Platform.OS === "android" ? (
96 <>
97 <NativeStackScreenOptions options={{ headerShown: false }} />
98 <AndroidScreenHeader title="Open source licenses" onBack={() => navigation.goBack()} />
99 </>
100 ) : null}
101 <View className="flex-1 items-center justify-center px-6">
102 <Text className="text-center text-base text-foreground-muted">
103 License notices are unavailable in this build.
104 </Text>
105 </View>
106 </View>
107 );
108 }
109
110 return (
111 <View collapsable={false} className="flex-1 bg-sheet">
112 {Platform.OS === "android" ? (
113 <>
114 <NativeStackScreenOptions options={{ headerShown: false }} />
115 <AndroidScreenHeader title="Open source licenses" onBack={() => navigation.goBack()} />
116 </>
117 ) : null}
118 <LegendList
119 className="flex-1"
120 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
121 contentInsetAdjustmentBehavior="automatic"
122 data={filteredEntries}
123 estimatedItemSize={78}
124 keyboardDismissMode="on-drag"
125 keyboardShouldPersistTaps="handled"
126 keyExtractor={thirdPartyLicenseEntryKey}
127 ListEmptyComponent={
128 <View className="items-center px-6 py-12">
129 <Text className="text-center text-base text-foreground-muted">
130 No licenses match that search.
131 </Text>
132 </View>
133 }
134 ListHeaderComponent={
135 <View className="gap-4 px-5 pt-4 pb-5">
136 <Text className="text-base leading-normal text-foreground-muted">
137 Notices for dependencies, assets, and optional tools used by T3 Code Mobile.
138 </Text>
139 <TextInput
140 accessibilityLabel="Search open-source licenses"
141 autoCapitalize="none"
142 autoCorrect={false}
143 clearButtonMode="while-editing"
144 onChangeText={setQuery}
145 placeholder="Search packages"
146 returnKeyType="search"
147 value={query}
148 />
149 <Text className="tabular-nums text-sm text-foreground-muted">
150 {filteredEntries.length === entries.length
151 ? `${String(entries.length)} notices`
152 : `${String(filteredEntries.length)} of ${String(entries.length)} notices`}
153 </Text>
154 </View>
155 }
156 renderItem={renderItem}
157 showsVerticalScrollIndicator={false}
158 />
159 </View>
160 );
161}
162
163type LicenseDetailProps = StaticScreenProps<{ readonly entryKey: string }>;
164
165export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailProps) {
166 const navigation = useNavigation();
167 const insets = useSafeAreaInsets();
168 const manifest = useMobileThirdPartyLicenses();
169 const entry = manifest
170 ? findThirdPartyLicenseEntry(manifest.entries, route.params.entryKey)
171 : undefined;
172 const sourceUrl = entry?.sourceUrl?.match(/^https?:\/\//) ? entry.sourceUrl : null;
173
174 if (!entry) {
175 return (
176 <View collapsable={false} className="flex-1 bg-sheet">
177 {Platform.OS === "android" ? (
178 <>
179 <NativeStackScreenOptions options={{ headerShown: false }} />
180 <AndroidScreenHeader title="License notice" onBack={() => navigation.goBack()} />
181 </>
182 ) : null}
183 <View className="flex-1 items-center justify-center px-6">
184 <Text className="text-center text-base text-foreground-muted">
185 This license notice is unavailable.
186 </Text>
187 </View>
188 </View>
189 );
190 }
191
192 return (
193 <View collapsable={false} className="flex-1 bg-sheet">
194 {Platform.OS === "android" ? (
195 <>
196 <NativeStackScreenOptions options={{ headerShown: false }} />
197 <AndroidScreenHeader title="License notice" onBack={() => navigation.goBack()} />
198 </>
199 ) : null}
200 <ScrollView
201 className="flex-1"
202 contentInsetAdjustmentBehavior="automatic"
203 contentContainerClassName="gap-5 px-5 pt-4"
204 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
205 showsVerticalScrollIndicator={false}
206 >
207 <View className="gap-2 px-1">
208 <Text className="text-2xl font-t3-bold text-foreground">{entry.name}</Text>
209 <Text className="text-base leading-normal text-foreground-muted">
210 {[entry.version, entry.license, formatLicenseBundles(entry.bundles)]
211 .filter((value): value is string => Boolean(value))
212 .join(" · ")}
213 </Text>
214 {sourceUrl ? (
215 <Pressable
216 accessibilityHint="Opens the project website"
217 accessibilityRole="link"
218 onPress={() => void Linking.openURL(sourceUrl)}
219 className="min-h-12 flex-row items-center gap-2 self-start py-2 active:opacity-60"
220 >
221 <Text className="font-t3-medium text-primary">Project source</Text>
222 <SymbolView
223 name="arrow.up.right"
224 size={16}
225 tintColorClassName={"accent-primary"}
226 type="monochrome"
227 weight="semibold"
228 />
229 </Pressable>
230 ) : null}
231 </View>
232
233 <View className="overflow-hidden rounded-[24px] border-continuous bg-card p-4">
234 <Text selectable className="font-mono text-base leading-normal text-foreground">
235 {entry.noticeText}
236 </Text>
237 </View>
238 </ScrollView>
239 </View>
240 );
241}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+5
......@@ -821,6 +821,11 @@ function AppSettingsSection() {
821821 return (
822822 <SettingsSection title="App">
823823 <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" />
824 <SettingsRow
825 icon="doc.on.doc"
826 label="Open source licenses"
827 target="SettingsOpenSourceLicenses"
828 />
824829 <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" />
825830 {updateCheckAvailable ? (
826831 <Pressable
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1
......@@ -4,6 +4,7 @@ export type SettingsSheetTarget =
44 | "SettingsAppearance"
55 | "SettingsProjectGrouping"
66 | "SettingsClientStorage"
7 | "SettingsOpenSourceLicenses"
78 | "SettingsUsage";
89
910export type SettingsLegalDocumentTarget = "SettingsLegal";
apps/mobile/src/features/settings/mobileThirdPartyLicenses.ts created+13
......@@ -0,0 +1,13 @@
1import {
2 decodeThirdPartyLicenseManifest,
3 type ThirdPartyLicenseManifest,
4} from "@t3tools/shared/thirdPartyLicenses";
5
6let cachedManifest: ThirdPartyLicenseManifest | undefined;
7
8export function getMobileThirdPartyLicenses(): ThirdPartyLicenseManifest {
9 if (cachedManifest) return cachedManifest;
10 const generatedManifest: unknown = require("@t3tools/mobile-third-party-licenses");
11 cachedManifest = decodeThirdPartyLicenseManifest(generatedManifest);
12 return cachedManifest;
13}
apps/mobile/src/lib/markdownLinks.test.ts+1-1
......@@ -75,7 +75,7 @@ describe("resolveMarkdownLinkPresentation", () => {
7575 expect(resolveMarkdownLinkPresentation("package.json")).toEqual({
7676 kind: "file",
7777 href: "package.json",
78 icon: "package",
78 icon: "npm",
7979 label: "package.json",
8080 path: "package.json",
8181 });
apps/mobile/src/lib/nativeMarkdownText.test.ts+1-1
......@@ -95,7 +95,7 @@ describe("nativeMarkdownTextRuns", () => {
9595 {
9696 text: "README.md:12",
9797 href: "file:///repo/README.md#L12",
98 fileIcon: "readme",
98 fileIcon: "markdown",
9999 },
100100 ]);
101101 });
apps/mobile/src/types/mobile-third-party-licenses.d.ts created+4
......@@ -0,0 +1,4 @@
1declare module "@t3tools/mobile-third-party-licenses" {
2 const manifest: unknown;
3 export default manifest;
4}
apps/web/THIRD_PARTY_NOTICES.md deleted-11
......@@ -1,11 +0,0 @@
1# Third-Party Notices
2
3## vscode-icons
4
5The custom file icon symbols in `src/pierre-icons.ts` are adapted from the
6[`vscode-icons`](https://github.com/vscode-icons/vscode-icons) project.
7
8Copyright (c) 2016 Roberto Huertas
9
10Licensed under the MIT License. The full license text is available in the
11upstream repository: <https://github.com/vscode-icons/vscode-icons/blob/master/LICENSE>.
apps/web/src/components/settings/OpenSourceLicenses.tsx created+270
......@@ -0,0 +1,270 @@
1import { ChevronRightIcon, ExternalLinkIcon, SearchIcon } from "lucide-react";
2import { useCallback, useEffect, useMemo, useState } from "react";
3
4import {
5 decodeThirdPartyLicenseManifest,
6 filterThirdPartyLicenseEntries,
7 formatLicenseBundles,
8 thirdPartyLicenseEntryKey,
9 type ThirdPartyLicenseEntry,
10 type ThirdPartyLicenseManifest,
11} from "@t3tools/shared/thirdPartyLicenses";
12
13import { Button } from "../ui/button";
14import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
15import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
17import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
18
19type LicenseManifestState =
20 | { readonly status: "loading" }
21 | { readonly status: "error"; readonly message: string }
22 | { readonly status: "ready"; readonly manifest: ThirdPartyLicenseManifest };
23
24async function loadLicenseManifest(signal: AbortSignal): Promise<ThirdPartyLicenseManifest> {
25 const response = await fetch(
26 `${import.meta.env.BASE_URL.replace(/\/$/, "")}/third-party-licenses.json`,
27 { signal },
28 );
29 if (!response.ok) {
30 throw new Error(`The license manifest request failed with status ${String(response.status)}.`);
31 }
32 return decodeThirdPartyLicenseManifest((await response.json()) as unknown);
33}
34
35function LicenseNoticeRow({
36 entry,
37 open,
38 onOpenChange,
39}: {
40 readonly entry: ThirdPartyLicenseEntry;
41 readonly open: boolean;
42 readonly onOpenChange: (open: boolean) => void;
43}) {
44 return (
45 <Collapsible open={open} onOpenChange={onOpenChange}>
46 <article>
47 <div className="flex min-h-10 items-center hover:bg-muted/35 sm:min-h-9">
48 <CollapsibleTrigger className="group flex min-h-10 min-w-0 flex-1 items-center gap-2.5 px-3 text-left sm:min-h-9 sm:px-4">
49 <ChevronRightIcon
50 aria-hidden
51 className="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 group-data-panel-open:rotate-90"
52 />
53 <span className="flex min-w-0 flex-1 items-baseline gap-2">
54 <span className="truncate font-medium text-foreground">{entry.name}</span>
55 {entry.version ? (
56 <code className="shrink-0 text-xs text-muted-foreground">{entry.version}</code>
57 ) : null}
58 </span>
59 <span className="max-w-[42%] shrink-0 truncate text-xs text-muted-foreground">
60 {entry.license} · {formatLicenseBundles(entry.bundles)}
61 </span>
62 </CollapsibleTrigger>
63 {entry.sourceUrl ? (
64 <Button
65 aria-label={`View project source for ${entry.name}`}
66 className="me-3 shrink-0 sm:me-4"
67 render={<a href={entry.sourceUrl} rel="noreferrer noopener" target="_blank" />}
68 size="icon-micro"
69 title="Project source"
70 variant="ghost-muted"
71 >
72 <ExternalLinkIcon aria-hidden className="size-3" />
73 </Button>
74 ) : null}
75 </div>
76 <CollapsiblePanel>
77 {open ? (
78 <div className="px-9 pt-1 pb-4 sm:px-10">
79 <pre className="max-w-[76ch] whitespace-pre-wrap break-words font-mono text-xs/5 text-foreground/80">
80 {entry.noticeText}
81 </pre>
82 </div>
83 ) : null}
84 </CollapsiblePanel>
85 </article>
86 </Collapsible>
87 );
88}
89
90function LicenseCount({
91 filteredCount,
92 totalCount,
93}: {
94 filteredCount: number;
95 totalCount: number;
96}) {
97 return (
98 <p className="whitespace-nowrap text-xs font-normal text-muted-foreground tabular-nums">
99 {filteredCount === totalCount
100 ? `${String(totalCount)} notices`
101 : `${String(filteredCount)} of ${String(totalCount)}`}
102 </p>
103 );
104}
105
106function LicenseHeaderAction({
107 query,
108 onQueryChange,
109 searchOpen,
110 onSearchOpenChange,
111 filteredCount,
112 totalCount,
113}: {
114 query: string;
115 onQueryChange: (value: string) => void;
116 searchOpen: boolean;
117 onSearchOpenChange: (open: boolean) => void;
118 filteredCount: number;
119 totalCount: number;
120}) {
121 if (!searchOpen) {
122 return (
123 <div className="flex items-center gap-1.5">
124 <LicenseCount filteredCount={filteredCount} totalCount={totalCount} />
125 <Tooltip>
126 <TooltipTrigger
127 render={
128 <Button
129 aria-label="Search open-source licenses"
130 onClick={() => onSearchOpenChange(true)}
131 size="icon-micro"
132 type="button"
133 variant="ghost-muted"
134 >
135 <SearchIcon className="size-3" />
136 </Button>
137 }
138 />
139 <TooltipPopup side="top">Search licenses</TooltipPopup>
140 </Tooltip>
141 </div>
142 );
143 }
144
145 return (
146 <div className="flex items-center gap-2">
147 <div className="hidden sm:block">
148 <LicenseCount filteredCount={filteredCount} totalCount={totalCount} />
149 </div>
150 <InputGroup className="w-36 sm:w-44">
151 <InputGroupAddon>
152 <SearchIcon aria-hidden className="size-3" />
153 </InputGroupAddon>
154 <InputGroupInput
155 aria-label="Search open-source licenses"
156 autoFocus
157 onBlur={() => {
158 if (query.length === 0) onSearchOpenChange(false);
159 }}
160 onChange={(event) => onQueryChange(event.currentTarget.value)}
161 onKeyDown={(event) => {
162 if (event.key !== "Escape") return;
163 event.preventDefault();
164 onQueryChange("");
165 onSearchOpenChange(false);
166 }}
167 placeholder="Search licenses"
168 size="sm"
169 type="search"
170 value={query}
171 />
172 </InputGroup>
173 </div>
174 );
175}
176
177function LicenseManifestError({ message, onRetry }: { message: string; onRetry: () => void }) {
178 return (
179 <div className="flex flex-col items-start gap-3 px-3 py-5 sm:px-4">
180 <div className="flex flex-col gap-1">
181 <h3 className="text-sm font-medium text-foreground">Open-source notices are unavailable</h3>
182 <p className="max-w-[70ch] text-pretty text-[13px] leading-[1.45] text-muted-foreground/80">
183 {message}
184 </p>
185 </div>
186 <Button type="button" size="xs" variant="outline" onClick={onRetry}>
187 Try again
188 </Button>
189 </div>
190 );
191}
192
193export function OpenSourceLicensesPanel() {
194 const [state, setState] = useState<LicenseManifestState>({ status: "loading" });
195 const [query, setQuery] = useState("");
196 const [searchOpen, setSearchOpen] = useState(false);
197 const [openEntryKey, setOpenEntryKey] = useState<string | null>(null);
198 const [requestVersion, setRequestVersion] = useState(0);
199
200 useEffect(() => {
201 const controller = new AbortController();
202 setState({ status: "loading" });
203 void loadLicenseManifest(controller.signal).then(
204 (manifest) => setState({ status: "ready", manifest }),
205 (error: unknown) => {
206 if (controller.signal.aborted) return;
207 setState({
208 status: "error",
209 message: error instanceof Error ? error.message : "The license manifest could not load.",
210 });
211 },
212 );
213 return () => controller.abort();
214 }, [requestVersion]);
215
216 const entries = state.status === "ready" ? state.manifest.entries : [];
217 const filteredEntries = useMemo(
218 () => filterThirdPartyLicenseEntries(entries, query),
219 [entries, query],
220 );
221 const retry = useCallback(() => setRequestVersion((value) => value + 1), []);
222
223 return (
224 <SettingsPageContainer>
225 <SettingsSection
226 title="Third-party notices"
227 headerAction={
228 state.status === "ready" ? (
229 <LicenseHeaderAction
230 query={query}
231 onQueryChange={setQuery}
232 searchOpen={searchOpen}
233 onSearchOpenChange={setSearchOpen}
234 filteredCount={filteredEntries.length}
235 totalCount={entries.length}
236 />
237 ) : null
238 }
239 >
240 {state.status === "ready" ? (
241 <div className="text-base sm:text-sm">
242 {filteredEntries.length > 0 ? (
243 filteredEntries.map((entry) => {
244 const entryKey = thirdPartyLicenseEntryKey(entry);
245 return (
246 <LicenseNoticeRow
247 key={entryKey}
248 entry={entry}
249 open={openEntryKey === entryKey}
250 onOpenChange={(open) => setOpenEntryKey(open ? entryKey : null)}
251 />
252 );
253 })
254 ) : (
255 <p className="px-3 py-8 text-center text-sm/6 text-muted-foreground sm:px-4">
256 No licenses match that search.
257 </p>
258 )}
259 </div>
260 ) : state.status === "error" ? (
261 <LicenseManifestError message={state.message} onRetry={retry} />
262 ) : (
263 <p className="px-3 py-5 text-sm/6 text-muted-foreground sm:px-4">
264 Loading open-source notices…
265 </p>
266 )}
267 </SettingsSection>
268 </SettingsPageContainer>
269 );
270}
apps/web/src/components/settings/SettingsBreadcrumb.tsx+1
......@@ -36,6 +36,7 @@ import {
3636const SETTINGS_BREADCRUMB_LABELS: Readonly<Record<string, string>> = {
3737 ...SETTINGS_SECTION_LABELS,
3838 "/settings/diagnostics": "Diagnostics",
39 "/settings/open-source-licenses": "Open source licenses",
3940};
4041
4142function settingsBreadcrumbLabel(pathname: string): string | null {
apps/web/src/components/settings/SettingsPanels.tsx+13
......@@ -2907,6 +2907,19 @@ export function GeneralSettingsPanel() {
29072907 </Button>
29082908 }
29092909 />
2910 <SettingsRow
2911 {...searchableSetting("open-source-licenses")}
2912 description="Notices for dependencies, assets, and optional tools used by T3 Code."
2913 control={
2914 <Button
2915 render={<Link to="/settings/open-source-licenses" />}
2916 size="xs"
2917 variant="outline"
2918 >
2919 View licenses
2920 </Button>
2921 }
2922 />
29102923 </SettingsSection>
29112924
29122925 <LegacyFeaturesSection />
apps/web/src/components/settings/SettingsSidebarNav.tsx+4-1
......@@ -322,7 +322,10 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
322322 <SidebarMenu className="ps-px">
323323 {navItems.map((item) => {
324324 const Icon = item.icon;
325 const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`);
325 const isGeneralDetailPage =
326 item.to === "/settings/general" && pathname === "/settings/open-source-licenses";
327 const isActive =
328 isGeneralDetailPage || pathname === item.to || pathname.startsWith(`${item.to}/`);
326329 return (
327330 <SidebarMenuItem key={item.to}>
328331 <SidebarMenuButton
apps/web/src/components/settings/settingsSearch.ts+5
......@@ -355,6 +355,11 @@ export const SETTINGS_SEARCH_ITEMS = [
355355 to: "/settings/general",
356356 searchTerms: ["logs traces processes resource history failures spans cpu memory"],
357357 },
358 {
359 id: "open-source-licenses",
360 title: "Open source licenses",
361 to: "/settings/general",
362 },
358363 {
359364 id: "legacy-plan-mode",
360365 title: "Plan mode (legacy)",
apps/web/src/pierre-icons.test.ts+13-8
......@@ -14,18 +14,21 @@ describe("Pierre file icons", () => {
1414 assert.equal(resolvePierreIconForEntry("vite.config.ts", "file")?.token, "vite");
1515 });
1616
17 it("extends Pierre with T3-specific exact filename icons", () => {
17 it("uses built-in Pierre icons where available", () => {
18 assert.equal(resolvePierreIconForEntry("package.json", "file")?.name, "file-tree-builtin-npm");
1819 assert.equal(
19 resolvePierreIconForEntry("package.json", "file")?.name,
20 "t3-file-icon-package-json",
20 resolvePierreIconForEntry("config/tsconfig.json", "file")?.name,
21 "file-tree-builtin-typescript",
2122 );
23 assert.equal(resolvePierreIconForEntry("CLAUDE.md", "file")?.name, "file-tree-builtin-claude");
2224 assert.equal(
23 resolvePierreIconForEntry("config/tsconfig.json", "file")?.name,
24 "t3-file-icon-tsconfig",
25 resolvePierreIconForEntry("README.md", "file")?.name,
26 "file-tree-builtin-markdown",
2527 );
28 });
29
30 it("extends Pierre with T3-specific exact filename icons", () => {
2631 assert.equal(resolvePierreIconForEntry("AGENTS.md", "file")?.name, "t3-file-icon-agents");
27 assert.equal(resolvePierreIconForEntry("CLAUDE.md", "file")?.name, "t3-file-icon-claude");
28 assert.equal(resolvePierreIconForEntry("README.md", "file")?.name, "t3-file-icon-readme");
2932 assert.equal(resolvePierreIconForEntry("pnpm-lock.yaml", "file")?.name, "t3-file-icon-pnpm");
3033 assert.equal(
3134 resolvePierreIconForEntry("pnpm-workspace.yaml", "file")?.name,
......@@ -34,7 +37,9 @@ describe("Pierre file icons", () => {
3437 });
3538
3639 it("ships every custom icon referenced by the extended resolver", () => {
37 const customIconNames = new Set(Object.values(T3_PIERRE_ICONS.byFileName));
40 const customIconNames = new Set(
41 Object.values(T3_PIERRE_ICONS.byFileName).filter((name) => name.startsWith("t3-")),
42 );
3843 for (const iconName of customIconNames) {
3944 assert.include(T3_PIERRE_ICONS.spriteSheet, `id="${iconName}"`);
4045 }
apps/web/src/pierre-icons.ts+2-19
......@@ -21,24 +21,9 @@ const T3_FILE_ICON_SPRITE = `
2121 <path d="M7 3v18M3 7.5h4M3 12h18M3 16.5h4M17 3v18M17 7.5h4M17 16.5h4" />
2222 </g>
2323 </symbol>
24 <symbol id="t3-file-icon-package-json" viewBox="0 0 32 32">
25 <path d="M2 2H30V30H2" fill="#c12127" />
26 <path d="M7.25 7.25h17.5v17.5h-3.5v-14H16v14H7.25" fill="#fff" />
27 </symbol>
28 <symbol id="t3-file-icon-tsconfig" viewBox="0 0 32 32">
29 <path d="M23.827 8.243a4.424 4.424 0 0 1 2.223 1.281 5.853 5.853 0 0 1 .852 1.143c.011.045-1.534 1.083-2.471 1.662-.034.023-.169-.124-.322-.35a2.014 2.014 0 0 0-1.67-1c-1.077-.074-1.771.49-1.766 1.433a1.3 1.3 0 0 0 .153.666c.237.49.677.784 2.059 1.383 2.544 1.1 3.636 1.817 4.31 2.843a5.158 5.158 0 0 1 .416 4.333 4.764 4.764 0 0 1-3.932 2.815 10.9 10.9 0 0 1-2.708-.028 6.531 6.531 0 0 1-3.616-1.884 6.278 6.278 0 0 1-.926-1.371 2.655 2.655 0 0 1 .327-.208c.158-.09.756-.434 1.32-.761l1.024-.6.214.312a4.771 4.771 0 0 0 1.35 1.292 3.3 3.3 0 0 0 3.458-.175 1.545 1.545 0 0 0 .2-1.974c-.276-.4-.84-.727-2.443-1.422a8.8 8.8 0 0 1-3.349-2.055 4.687 4.687 0 0 1-.976-1.777 7.116 7.116 0 0 1-.062-2.268 4.332 4.332 0 0 1 3.644-3.374 9 9 0 0 1 2.71.01ZM15.484 9.726l.011 1.454h-4.63v13.148H7.6V11.183H2.97V9.755a13.986 13.986 0 0 1 .04-1.466c.017-.023 2.832-.034 6.245-.028l6.211.017Z" fill="#007acc" />
30 <path d="m27.075 25.107.363-.361c1.68.055 1.706 0 1.78-.177l.462-1.124.034-.107-.038-.093c-.02-.049-.081-.2-1.13-1.2v-.526c1.211-1.166 1.185-1.226 1.116-1.4l-.46-1.136c-.069-.17-.1-.237-1.763-.191l-.364-.367a8.138 8.138 0 0 0-.057-1.657l-.047-.106-1.2-.525c-.177-.081-.239-.11-1.372 1.124l-.509-.008c-1.167-1.245-1.222-1.223-1.4-1.152l-1.115.452c-.175.071-.236.1-.169 1.79l-.36.359c-1.68-.055-1.7 0-1.778.177L18.606 20l-.036.108.038.094c.02.048.078.194 1.13 1.2v.525c-1.211 1.166-1.184 1.226-1.115 1.4l.459 1.137c.07.174.1.236 1.763.192l.363.377a8.169 8.169 0 0 0 .055 1.654l.047.107 1.208.528c.176.073.236.1 1.366-1.13l.509.006c1.168 1.247 1.228 1.223 1.4 1.154l1.113-.45c.176-.075.237-.102.169-1.795Zm-4.788-2.632a2 2 0 1 1 2.618 1.14 2.023 2.023 0 0 1-2.618-1.14Z" fill="#99b8c4" />
31 </symbol>
3224 <symbol id="t3-file-icon-agents" viewBox="0 0 32 32">
3325 <path fill="currentColor" d="M27.2 16c0-6.19-5.01-11.2-11.2-11.2C9.81 4.8 4.8 9.81 4.8 16S9.81 27.2 16 27.2c6.19 0 11.2-5.01 11.2-11.2Zm-5.6 2.1a1.4 1.4 0 1 1 0 2.8h-4.2a1.4 1.4 0 1 1 0-2.8Zm-11.2-6.8c.622-.373 1.42-.208 1.84.361l.079.119 2.1 3.5.088.171c.15.351.15.748 0 1.1l-.088.171-2.1 3.5a1.4 1.4 0 0 1-2.4-1.44L11.59 16l-1.67-2.78-.067-.127c-.302-.642-.075-1.42.547-1.79ZM30 16c0 7.73-6.27 14-14 14S2 23.73 2 16 8.27 2 16 2s14 6.27 14 14Z" />
3426 </symbol>
35 <symbol id="t3-file-icon-claude" viewBox="0 0 32 32">
36 <path fill="#d97757" d="m7.5 20.61 5.5-3.08.1-.27-.1-.15h-.27l-.92-.06a234.2 234.2 0 0 1-8.51-.34l-.67-.14-.62-.82.06-.41.56-.38.8.07c3.08.2 6.15.4 9.21.72h.46l.06-.19-.16-.1-.12-.12-2.74-1.86c-2.04-1.3-3.55-2.43-5.38-3.68l-.43-.54-.18-1.18.76-.84 1.03.07.26.07c2.03 1.6 4.1 3.14 6.17 4.66l.43.36.17-.12.02-.09-.2-.32c-1.43-2.6-2.55-4.62-4-6.96l-.2-.72c-.08-.3-.13-.55-.13-.85l.87-1.18.49-.16 1.16.16.49.42c1.4 3.31 2.76 5.93 4.23 8.83l.29.97.1.3h.19v-.17c.65-3.42 0-6.57 1.22-9.53l.87-.57.68.33.56.8-.08.51c-.37 2.62-.92 5.22-1.4 7.82h.24l.29-.29a68.66 68.66 0 0 1 4.91-5.94l.64-.5h1.2l.89 1.32-.4 1.36a53.45 53.45 0 0 0-4.66 6.47l.09.13.22-.02c2.4-.57 4.84-.99 7.27-1.4l.97.45.1.46-.37.94c-3 .72-6 1.34-9 2.05l-.05.04.06.07c2.7.26 5.62.3 7.99.47l.92.61.55.75-.1.56-1.4.73c-2.93-.7-5.19-1.22-7.91-1.9h-.22v.13c2.18 2.12 4.6 4.27 6.54 6.07l.15.68-.38.53-.4-.06c-2.04-1.43-3.9-3.09-5.8-4.7h-.15v.2l.52.76c1.14 1.79 2.64 3.25 2.87 5.37l-.2.41-.7.25-.78-.14a73.16 73.16 0 0 1-4.58-7.04l-.17.09-.78 8.46-.37.43-.85.33-.71-.54-.38-.87a114 114 0 0 0 1.53-7.97l.2-.73-.01-.05-.16.02a76.67 76.67 0 0 1-6.23 7.88l-.48.2-.84-.44.08-.77.47-.69c2.07-2.57 3.54-4.66 5.54-7v-.19h-.07l-7.4 4.8-1.31.17-.57-.53.07-.87.27-.28 2.23-1.53Z" />
37 </symbol>
38 <symbol id="t3-file-icon-readme" viewBox="0 0 32 32">
39 <rect x="2.5" y="7.955" width="27" height="16.091" fill="none" stroke="#b48a5a" />
40 <path fill="#b48a5a" d="M5.909 20.636v-9.272h2.727l2.728 3.409 2.727-3.409h2.727v9.272h-2.727v-5.318l-2.727 3.409-2.728-3.409v5.318H5.91Zm17.046 0-4.091-4.5h2.727v-4.772h2.727v4.772h2.727l-4.09 4.5Z" />
41 </symbol>
4227 <symbol id="t3-file-icon-pnpm" viewBox="0 0 32 32">
4328 <path fill="#f9ad00" d="M30 10.75h-8.749V2H30Zm-9.626 0h-8.75V2h8.75Zm-9.625 0H2V2h8.749ZM30 20.375h-8.749v-8.75H30Z" />
4429 <path fill="currentColor" d="M20.374 20.375h-8.75v-8.75h8.75Zm0 9.625h-8.75v-8.75h8.75ZM30 30h-8.749v-8.75H30Zm-19.251 0H2v-8.75h8.749Z" />
......@@ -50,11 +35,9 @@ export const T3_PIERRE_ICONS = {
5035 colored: true,
5136 spriteSheet: T3_FILE_ICON_SPRITE,
5237 byFileName: {
53 "package.json": "t3-file-icon-package-json",
54 "tsconfig.json": "t3-file-icon-tsconfig",
38 "package.json": "file-tree-builtin-npm",
39 "tsconfig.json": "file-tree-builtin-typescript",
5540 "agents.md": "t3-file-icon-agents",
56 "claude.md": "t3-file-icon-claude",
57 "readme.md": "t3-file-icon-readme",
5841 "pnpm-lock.yaml": "t3-file-icon-pnpm",
5942 "pnpm-workspace.yaml": "t3-file-icon-pnpm",
6043 },
apps/web/src/routeTree.gen.ts+22
......@@ -20,6 +20,7 @@ import { Route as SettingsSourceControlRouteImport } from './routes/settings.sou
2020import { Route as SettingsSnapShotRouteImport } from './routes/settings.snap-shot'
2121import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'
2222import { Route as SettingsProjectsRouteImport } from './routes/settings.projects'
23import { Route as SettingsOpenSourceLicensesRouteImport } from './routes/settings.open-source-licenses'
2324import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings'
2425import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations'
2526import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
......@@ -87,6 +88,12 @@ const SettingsProjectsRoute = SettingsProjectsRouteImport.update({
8788 path: '/projects',
8889 getParentRoute: () => SettingsRoute,
8990} as any)
91const SettingsOpenSourceLicensesRoute =
92 SettingsOpenSourceLicensesRouteImport.update({
93 id: '/open-source-licenses',
94 path: '/open-source-licenses',
95 getParentRoute: () => SettingsRoute,
96 } as any)
9097const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({
9198 id: '/keybindings',
9299 path: '/keybindings',
......@@ -166,6 +173,7 @@ export interface FileRoutesByFullPath {
166173 '/settings/general': typeof SettingsGeneralRoute
167174 '/settings/integrations': typeof SettingsIntegrationsRoute
168175 '/settings/keybindings': typeof SettingsKeybindingsRoute
176 '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute
169177 '/settings/projects': typeof SettingsProjectsRoute
170178 '/settings/providers': typeof SettingsProvidersRoute
171179 '/settings/snap-shot': typeof SettingsSnapShotRoute
......@@ -189,6 +197,7 @@ export interface FileRoutesByTo {
189197 '/settings/general': typeof SettingsGeneralRoute
190198 '/settings/integrations': typeof SettingsIntegrationsRoute
191199 '/settings/keybindings': typeof SettingsKeybindingsRoute
200 '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute
192201 '/settings/projects': typeof SettingsProjectsRoute
193202 '/settings/providers': typeof SettingsProvidersRoute
194203 '/settings/snap-shot': typeof SettingsSnapShotRoute
......@@ -215,6 +224,7 @@ export interface FileRoutesById {
215224 '/settings/general': typeof SettingsGeneralRoute
216225 '/settings/integrations': typeof SettingsIntegrationsRoute
217226 '/settings/keybindings': typeof SettingsKeybindingsRoute
227 '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute
218228 '/settings/projects': typeof SettingsProjectsRoute
219229 '/settings/providers': typeof SettingsProvidersRoute
220230 '/settings/snap-shot': typeof SettingsSnapShotRoute
......@@ -242,6 +252,7 @@ export interface FileRouteTypes {
242252 | '/settings/general'
243253 | '/settings/integrations'
244254 | '/settings/keybindings'
255 | '/settings/open-source-licenses'
245256 | '/settings/projects'
246257 | '/settings/providers'
247258 | '/settings/snap-shot'
......@@ -265,6 +276,7 @@ export interface FileRouteTypes {
265276 | '/settings/general'
266277 | '/settings/integrations'
267278 | '/settings/keybindings'
279 | '/settings/open-source-licenses'
268280 | '/settings/projects'
269281 | '/settings/providers'
270282 | '/settings/snap-shot'
......@@ -290,6 +302,7 @@ export interface FileRouteTypes {
290302 | '/settings/general'
291303 | '/settings/integrations'
292304 | '/settings/keybindings'
305 | '/settings/open-source-licenses'
293306 | '/settings/projects'
294307 | '/settings/providers'
295308 | '/settings/snap-shot'
......@@ -389,6 +402,13 @@ declare module '@tanstack/react-router' {
389402 preLoaderRoute: typeof SettingsProjectsRouteImport
390403 parentRoute: typeof SettingsRoute
391404 }
405 '/settings/open-source-licenses': {
406 id: '/settings/open-source-licenses'
407 path: '/open-source-licenses'
408 fullPath: '/settings/open-source-licenses'
409 preLoaderRoute: typeof SettingsOpenSourceLicensesRouteImport
410 parentRoute: typeof SettingsRoute
411 }
392412 '/settings/keybindings': {
393413 id: '/settings/keybindings'
394414 path: '/keybindings'
......@@ -500,6 +520,7 @@ interface SettingsRouteChildren {
500520 SettingsGeneralRoute: typeof SettingsGeneralRoute
501521 SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute
502522 SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute
523 SettingsOpenSourceLicensesRoute: typeof SettingsOpenSourceLicensesRoute
503524 SettingsProjectsRoute: typeof SettingsProjectsRoute
504525 SettingsProvidersRoute: typeof SettingsProvidersRoute
505526 SettingsSnapShotRoute: typeof SettingsSnapShotRoute
......@@ -514,6 +535,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {
514535 SettingsGeneralRoute: SettingsGeneralRoute,
515536 SettingsIntegrationsRoute: SettingsIntegrationsRoute,
516537 SettingsKeybindingsRoute: SettingsKeybindingsRoute,
538 SettingsOpenSourceLicensesRoute: SettingsOpenSourceLicensesRoute,
517539 SettingsProjectsRoute: SettingsProjectsRoute,
518540 SettingsProvidersRoute: SettingsProvidersRoute,
519541 SettingsSnapShotRoute: SettingsSnapShotRoute,
apps/web/src/routes/settings.open-source-licenses.tsx created+7
......@@ -0,0 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { OpenSourceLicensesPanel } from "../components/settings/OpenSourceLicenses";
4
5export const Route = createFileRoute("/settings/open-source-licenses")({
6 component: OpenSourceLicensesPanel,
7});
apps/web/tsconfig.json+2-1
......@@ -32,6 +32,7 @@
3232 "vercel.ts",
3333 "test",
3434 "scripts/warm-dep-cache.ts",
35 "../../scripts/lib/public-config.ts"
35 "../../scripts/lib/public-config.ts",
36 "../../scripts/lib/third-party-licenses.ts"
3637 ]
3738}
apps/web/vite.config.ts+10
......@@ -12,6 +12,7 @@ import pkg from "./package.json" with { type: "json" };
1212import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy";
1313
1414import { loadRepoEnv } from "../../scripts/lib/public-config";
15import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses";
1516import { tailwindPlugins } from "./vite/tailwind";
1617
1718const repoEnv = loadRepoEnv();
......@@ -157,6 +158,15 @@ export default defineConfig(() => {
157158 assetsInclude: ["**/*.wasm"],
158159 plugins: [
159160 devCompressionPlugin(),
161 thirdPartyLicensesPlugin({
162 bundleName: "web",
163 configFile: new URL("../../third-party-licenses.config.json", import.meta.url),
164 packageManifests: [
165 { bundle: "web", path: new URL("./package.json", import.meta.url) },
166 { bundle: "server", path: new URL("../server/package.json", import.meta.url) },
167 { bundle: "desktop", path: new URL("../desktop/package.json", import.meta.url) },
168 ],
169 }),
160170 // Route components load as split chunks so settings, pull-request, and
161171 // usage code stay out of the cold-start payload; the router prefetches
162172 // them on navigation intent (see getRouter's defaultPreload).
docs/internals/open-source-licenses.md created+102
......@@ -0,0 +1,102 @@
1# Open source license notices
2
3License notices are generated independently for the client that ships them:
4
5- The web build emits `third-party-licenses.json` beside `index.html`. The Settings page loads that
6 static file, so the same artifact works in hosted web, the client bundled with `npx t3`, and
7 desktop.
8- The mobile Metro config generates an ignored virtual module before each development, native, or
9 over-the-air JavaScript bundle. Mobile loads and decodes that module only when a license screen
10 opens, so the notice text does not occupy memory during ordinary app startup. It does not need a
11 network request.
12
13Neither path depends on the connected environment or an RPC.
14
15## What the build collects
16
17The generator follows installed production and optional dependencies, including dependencies of
18workspace packages, and omits first-party `@t3tools/*` packages. The web manifest starts from the
19web, server, and desktop package manifests. The mobile manifest starts from the mobile package
20manifest. During the web bundle, the generator also checks emitted module ids to catch a bundled
21npm import missing from a package manifest.
22
23The mobile manifest deliberately follows the complete production dependency closure declared by
24Expo and React Native. That is conservative and can include build tooling that is not present in
25the final JavaScript bundle, but it avoids dropping a notice when platform bundling changes.
26
27The build fails when a collected package has no distributable license identifier or contains no
28license or notice text. Generated notices use license templates from the pinned SPDX License List.
29Strict web and EAS builds download a missing template into the gitignored `.generated/` cache;
30`pnpm licenses:sync` can warm that cache explicitly. Local web and Metro development do not make a
31network request and omit generated rows until the cache exists. This keeps dev startup optional
32while preventing incomplete release artifacts.
33
34## Custom notices and package overrides
35
36The repository-level `third-party-licenses.config.json` holds manually maintained exceptions for
37all clients. Add an entry to `customNotices` for adapted icons, fonts, media, native modules, or
38another asset that did not come from an npm package:
39
40```json
41{
42 "name": "asset-name",
43 "license": "CC-BY-4.0",
44 "generatedNotices": [
45 {
46 "licenseId": "CC-BY-4.0",
47 "preamble": ["Asset by Example Author. Changes: converted to MP3."]
48 }
49 ],
50 "sourceUrl": "https://example.com/source",
51 "bundles": ["assets", "web"]
52}
53```
54
55Each `generatedNotices` item names an SPDX license template and can add `copyrights` or a short
56`preamble` for attribution and provenance. Multiple items are joined into one row for software
57that vendors separately licensed code. Keep `noticeFile` or `noticeFiles` only when a vendored
58source tree already carries an intrinsic license file that should remain beside it. Paths are
59relative to the config file. `bundles` controls which generated manifests include the entry and
60supplies the label shown to users. Use `includeInBundles` when those differ, such as an optional
61server tool that should appear in both client manifests but is not bundled into either client.
62
63Use `packageOverrides` only when an installed npm archive omits its notice or has incorrect
64metadata:
65
66```json
67{
68 "name": "package-name",
69 "version": "1.2.3",
70 "generatedNotice": {
71 "licenseId": "MIT",
72 "copyrights": ["Copyright (c) 2026 Example Author"]
73 },
74 "license": "MIT",
75 "sourceUrl": "https://example.com/package-name"
76}
77```
78
79`version`, `license`, and `sourceUrl` are optional. Omitting `version` applies the override to every
80installed version of that package. An override can use `repositoryUrl` instead of `name` when
81several packages from one monorepo share the same notice:
82
83```json
84{
85 "repositoryUrl": "https://github.com/example/project",
86 "generatedNotice": {
87 "licenseId": "Apache-2.0"
88 }
89}
90```
91
92The generator also reuses an installed sibling package's notice when both packages declare the
93same normalized repository and license. A name-and-version override always wins over these
94repository fallbacks.
95
96The `@react-grab/cli` override uses the root React Grab repository's MIT license because the CLI's
97npm archive omits both its license field and license file. Keep the override until the published
98CLI package carries that metadata itself.
99
100Generated mobile files live under `apps/mobile/.generated/`, while fetched SPDX templates live
101under the repository `.generated/` directory. Both are ignored. Do not commit or edit them;
102updating dependencies or configuration is enough for the next strict build to refresh the output.
docs/user/open-source-licenses.md created+13
......@@ -0,0 +1,13 @@
1# Open source licenses
2
3T3 Code includes third-party software and adapted assets. To read their license and attribution
4notices:
5
6- On web and desktop, open **Settings → General**, find **Open source licenses** under **About**,
7 and select **View licenses**.
8- On mobile, open **Settings → App → Open source licenses**.
9
10The page lists the version when available, license identifier, and the parts of T3 Code that include
11or use the item. This also covers optional device tools that T3 Code installs on demand instead of
12bundling. Select a row to read the complete notice text. Use the search field to find a package,
13version, license, or app component.
package.json+1
......@@ -18,6 +18,7 @@
1818 "screenshots:mobile": "node scripts/mobile-showcase.ts",
1919 "icons:export": "node scripts/export-brand-icons.ts",
2020 "icons:check": "node scripts/export-brand-icons.ts --check",
21 "licenses:sync": "node scripts/sync-third-party-license-notices.ts",
2122 "icons:export:android": "node scripts/export-android-icons.ts",
2223 "build": "vp run --filter './apps/*' build",
2324 "build:marketing": "vp run --filter @t3tools/marketing build",
packages/shared/package.json+4
......@@ -275,6 +275,10 @@
275275 "types": "./src/desktopAppControl.ts",
276276 "import": "./src/desktopAppControl.ts"
277277 },
278 "./thirdPartyLicenses": {
279 "types": "./src/thirdPartyLicenses.ts",
280 "import": "./src/thirdPartyLicenses.ts"
281 },
278282 "./claudeCompaction": {
279283 "types": "./src/claudeCompaction.ts",
280284 "import": "./src/claudeCompaction.ts"
packages/shared/src/thirdPartyLicenses.test.ts created+91
......@@ -0,0 +1,91 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 decodeThirdPartyLicenseManifest,
5 filterThirdPartyLicenseEntries,
6 findThirdPartyLicenseEntry,
7 formatLicenseBundles,
8 thirdPartyLicenseEntryKey,
9 type ThirdPartyLicenseEntry,
10} from "./thirdPartyLicenses.js";
11
12const ENTRIES: ReadonlyArray<ThirdPartyLicenseEntry> = [
13 {
14 bundles: ["web"],
15 kind: "package",
16 license: "MIT",
17 name: "react",
18 noticeText: "React license",
19 sourceUrl: "https://react.dev",
20 version: "19.2.6",
21 },
22 {
23 bundles: ["assets", "mobile"],
24 kind: "custom",
25 license: "CC-BY-4.0",
26 name: "sample-icons",
27 noticeText: "Icon notice",
28 sourceUrl: null,
29 version: null,
30 },
31];
32
33describe("third-party license manifests", () => {
34 it("decodes the generated manifest shape", () => {
35 expect(decodeThirdPartyLicenseManifest({ schemaVersion: 1, entries: ENTRIES })).toEqual({
36 schemaVersion: 1,
37 entries: ENTRIES,
38 });
39 });
40
41 it("rejects unsupported manifest versions", () => {
42 expect(() => decodeThirdPartyLicenseManifest({ schemaVersion: 2, entries: [] })).toThrow(
43 "unsupported format",
44 );
45 });
46
47 it("rejects unsafe source links", () => {
48 expect(() =>
49 decodeThirdPartyLicenseManifest({
50 schemaVersion: 1,
51 entries: [{ ...ENTRIES[0]!, sourceUrl: "javascript:alert(1)" }],
52 }),
53 ).toThrow("invalid shape");
54 });
55
56 it("rejects duplicate navigation keys", () => {
57 expect(() =>
58 decodeThirdPartyLicenseManifest({ schemaVersion: 1, entries: [ENTRIES[0]!, ENTRIES[0]!] }),
59 ).toThrow("duplicate entry");
60 });
61
62 it("filters by package, license, version, and bundle", () => {
63 expect(filterThirdPartyLicenseEntries(ENTRIES, "react 19.2")).toEqual([ENTRIES[0]]);
64 expect(filterThirdPartyLicenseEntries(ENTRIES, "cc-by mobile")).toEqual([ENTRIES[1]]);
65 expect(filterThirdPartyLicenseEntries(ENTRIES, "apache")).toEqual([]);
66 });
67
68 it("formats platform bundle names for display", () => {
69 expect(
70 formatLicenseBundles(["android", "assets", "ios", "mobile", "plugin", "constructor"]),
71 ).toBe("Android, Assets, iOS, Mobile, plugin, constructor");
72 });
73
74 it("finds an entry by its stable navigation key", () => {
75 const entry = ENTRIES[0]!;
76 expect(findThirdPartyLicenseEntry(ENTRIES, thirdPartyLicenseEntryKey(entry))).toBe(entry);
77 });
78
79 it("uses path-safe navigation keys for scoped packages", () => {
80 expect(thirdPartyLicenseEntryKey({ ...ENTRIES[0]!, name: "@scope/package" })).not.toContain(
81 "/",
82 );
83 });
84
85 it("uses distinct navigation keys when names and versions contain delimiters", () => {
86 const first = thirdPartyLicenseEntryKey({ ...ENTRIES[0]!, name: "a:1", version: null });
87 const second = thirdPartyLicenseEntryKey({ ...ENTRIES[0]!, name: "a", version: "1:custom" });
88
89 expect(first).not.toBe(second);
90 });
91});
packages/shared/src/thirdPartyLicenses.ts created+127
......@@ -0,0 +1,127 @@
1export interface ThirdPartyLicenseEntry {
2 readonly bundles: ReadonlyArray<string>;
3 readonly kind: "custom" | "package";
4 readonly license: string;
5 readonly name: string;
6 readonly noticeText: string;
7 readonly sourceUrl: string | null;
8 readonly version: string | null;
9}
10
11export interface ThirdPartyLicenseManifest {
12 readonly schemaVersion: 1;
13 readonly entries: ReadonlyArray<ThirdPartyLicenseEntry>;
14}
15
16function isRecord(value: unknown): value is Record<string, unknown> {
17 return typeof value === "object" && value !== null && !Array.isArray(value);
18}
19
20function isStringArray(value: unknown): value is ReadonlyArray<string> {
21 return Array.isArray(value) && value.every((entry) => typeof entry === "string");
22}
23
24function isHttpUrl(value: unknown): value is string {
25 if (typeof value !== "string") return false;
26 try {
27 const protocol = new URL(value).protocol;
28 return protocol === "http:" || protocol === "https:";
29 } catch {
30 return false;
31 }
32}
33
34function decodeEntry(value: unknown, index: number): ThirdPartyLicenseEntry {
35 if (!isRecord(value)) {
36 throw new Error(`License entry ${String(index + 1)} is not an object.`);
37 }
38 if (
39 !isStringArray(value.bundles) ||
40 value.bundles.length === 0 ||
41 (value.kind !== "custom" && value.kind !== "package") ||
42 typeof value.license !== "string" ||
43 typeof value.name !== "string" ||
44 typeof value.noticeText !== "string" ||
45 (value.sourceUrl !== null && !isHttpUrl(value.sourceUrl)) ||
46 (value.version !== null && typeof value.version !== "string")
47 ) {
48 throw new Error(`License entry ${String(index + 1)} has an invalid shape.`);
49 }
50 return {
51 bundles: value.bundles,
52 kind: value.kind,
53 license: value.license,
54 name: value.name,
55 noticeText: value.noticeText,
56 sourceUrl: value.sourceUrl,
57 version: value.version,
58 };
59}
60
61export function decodeThirdPartyLicenseManifest(value: unknown): ThirdPartyLicenseManifest {
62 if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.entries)) {
63 throw new Error("The open-source license manifest has an unsupported format.");
64 }
65 const entries = value.entries.map(decodeEntry);
66 const entryKeys = new Set<string>();
67 for (const entry of entries) {
68 const key = thirdPartyLicenseEntryKey(entry);
69 if (entryKeys.has(key)) {
70 throw new Error(`The open-source license manifest contains a duplicate entry: ${key}`);
71 }
72 entryKeys.add(key);
73 }
74 return {
75 schemaVersion: 1,
76 entries,
77 };
78}
79
80export function filterThirdPartyLicenseEntries(
81 entries: ReadonlyArray<ThirdPartyLicenseEntry>,
82 query: string,
83): ReadonlyArray<ThirdPartyLicenseEntry> {
84 const terms = query
85 .trim()
86 .toLowerCase()
87 .split(/\s+/)
88 .filter((term) => term.length > 0);
89 if (terms.length === 0) return entries;
90 return entries.filter((entry) => {
91 const searchable = [entry.name, entry.version, entry.license, ...entry.bundles]
92 .filter((value): value is string => value !== null)
93 .join(" ")
94 .toLowerCase();
95 return terms.every((term) => searchable.includes(term));
96 });
97}
98
99const BUNDLE_LABELS: Readonly<Record<string, string>> = {
100 android: "Android",
101 assets: "Assets",
102 desktop: "Desktop",
103 "device-tools": "Device tools",
104 ios: "iOS",
105 mobile: "Mobile",
106 server: "Server",
107 web: "Web",
108};
109
110export function formatLicenseBundles(bundles: ReadonlyArray<string>): string {
111 return bundles
112 .map((bundle) =>
113 Object.prototype.hasOwnProperty.call(BUNDLE_LABELS, bundle) ? BUNDLE_LABELS[bundle] : bundle,
114 )
115 .join(", ");
116}
117
118export function thirdPartyLicenseEntryKey(entry: ThirdPartyLicenseEntry): string {
119 return encodeURIComponent(JSON.stringify([entry.kind, entry.name, entry.version]));
120}
121
122export function findThirdPartyLicenseEntry(
123 entries: ReadonlyArray<ThirdPartyLicenseEntry>,
124 key: string,
125): ThirdPartyLicenseEntry | undefined {
126 return entries.find((entry) => thirdPartyLicenseEntryKey(entry) === key);
127}
scripts/lib/third-party-licenses.test.ts created+504
......@@ -0,0 +1,504 @@
1// @effect-diagnostics nodeBuiltinImport:off - Tests exercise the Node filesystem build boundary.
2
3import * as NodeFSP from "node:fs/promises";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6import * as NodeURL from "node:url";
7
8import { afterEach, describe, expect, it } from "vite-plus/test";
9
10import {
11 generateThirdPartyLicenseManifest,
12 THIRD_PARTY_LICENSES_FILE_NAME,
13 thirdPartyLicensesPlugin,
14} from "./third-party-licenses.js";
15
16const tempDirectories: string[] = [];
17const REPOSITORY_ROOT = NodePath.resolve(
18 NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)),
19 "../..",
20);
21
22async function writeJson(path: string, value: unknown): Promise<void> {
23 await NodeFSP.mkdir(NodePath.dirname(path), { recursive: true });
24 await NodeFSP.writeFile(path, `${JSON.stringify(value)}\n`, "utf8");
25}
26
27async function createFixture(): Promise<{
28 readonly appManifest: string;
29 readonly configFile: string;
30 readonly dependencyRoot: string;
31 readonly root: string;
32}> {
33 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-licenses-"));
34 tempDirectories.push(root);
35 const appManifest = NodePath.join(root, "package.json");
36 const dependencyRoot = NodePath.join(root, "node_modules", "demo-dependency");
37 const configFile = NodePath.join(root, "third-party-licenses.config.json");
38
39 await writeJson(appManifest, {
40 name: "fixture-app",
41 dependencies: { "demo-dependency": "1.2.3" },
42 });
43 await writeJson(NodePath.join(dependencyRoot, "package.json"), {
44 name: "demo-dependency",
45 version: "1.2.3",
46 license: "MIT",
47 main: "index.js",
48 repository: "example/demo-dependency",
49 });
50 await NodeFSP.writeFile(NodePath.join(dependencyRoot, "index.js"), "export {};\n", "utf8");
51 await NodeFSP.writeFile(
52 NodePath.join(dependencyRoot, "LICENSE"),
53 "Demo MIT license text\n",
54 "utf8",
55 );
56 await NodeFSP.writeFile(NodePath.join(root, "asset-notice.txt"), "Asset notice text\n", "utf8");
57 await writeJson(configFile, {
58 customNotices: [
59 {
60 name: "demo-asset",
61 license: "CC-BY-4.0",
62 noticeFile: "asset-notice.txt",
63 bundles: ["assets", "web"],
64 },
65 ],
66 packageOverrides: [],
67 });
68 return { appManifest, configFile, dependencyRoot, root };
69}
70
71afterEach(async () => {
72 await Promise.all(
73 tempDirectories
74 .splice(0)
75 .map((directory) => NodeFSP.rm(directory, { force: true, recursive: true })),
76 );
77});
78
79describe("third-party license generation", () => {
80 it("keeps the GhosttyKit notice pinned to the vendored framework revision", async () => {
81 const [config, revision] = await Promise.all([
82 NodeFSP.readFile(NodePath.join(REPOSITORY_ROOT, "third-party-licenses.config.json"), "utf8"),
83 NodeFSP.readFile(
84 NodePath.join(REPOSITORY_ROOT, "apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION"),
85 "utf8",
86 ),
87 ]);
88
89 expect(config).toContain(revision.trim());
90 expect(config).toContain(
91 "https://github.com/Yash-Singh1/ghostty/tree/t3code/custom-io-ordered-feed",
92 );
93 });
94
95 it("collects production packages and custom asset notices", async () => {
96 const fixture = await createFixture();
97 const manifest = await generateThirdPartyLicenseManifest({
98 configFile: fixture.configFile,
99 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
100 });
101
102 expect(manifest).toEqual({
103 schemaVersion: 1,
104 entries: [
105 {
106 bundles: ["assets", "web"],
107 kind: "custom",
108 license: "CC-BY-4.0",
109 name: "demo-asset",
110 noticeText: "Asset notice text",
111 sourceUrl: null,
112 version: null,
113 },
114 {
115 bundles: ["web"],
116 kind: "package",
117 license: "MIT",
118 name: "demo-dependency",
119 noticeText: "Demo MIT license text",
120 sourceUrl: "https://github.com/example/demo-dependency",
121 version: "1.2.3",
122 },
123 ],
124 });
125 });
126
127 it("renders generated notices from the ignored SPDX cache", async () => {
128 const fixture = await createFixture();
129 await writeJson(
130 NodePath.join(fixture.root, ".generated/third-party-licenses/spdx/v3.28.0/MIT.json"),
131 {
132 licenseId: "MIT",
133 licenseText: "MIT License\n\nCopyright (c) <year> <copyright holders>\n\nPermission text",
134 },
135 );
136 await writeJson(fixture.configFile, {
137 customNotices: [
138 {
139 name: "generated-asset",
140 license: "MIT",
141 generatedNotices: [
142 {
143 licenseId: "MIT",
144 copyrights: ["Copyright (c) 2026 Example Author"],
145 preamble: ["Adapted for T3 Code."],
146 },
147 ],
148 bundles: ["assets", "web"],
149 },
150 ],
151 packageOverrides: [],
152 });
153
154 const manifest = await generateThirdPartyLicenseManifest({
155 configFile: fixture.configFile,
156 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
157 });
158
159 expect(manifest.entries.find((entry) => entry.name === "generated-asset")?.noticeText).toBe(
160 "Adapted for T3 Code.\n\nMIT License\n\nCopyright (c) 2026 Example Author\n\nPermission text",
161 );
162 });
163
164 it("omits generated rows without a cache during optional development", async () => {
165 const fixture = await createFixture();
166 await writeJson(fixture.configFile, {
167 customNotices: [
168 {
169 name: "generated-asset",
170 license: "MIT",
171 generatedNotices: [{ licenseId: "MIT" }],
172 bundles: ["assets", "web"],
173 },
174 ],
175 packageOverrides: [],
176 });
177
178 const manifest = await generateThirdPartyLicenseManifest({
179 configFile: fixture.configFile,
180 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
181 allowMissingGeneratedNotices: true,
182 });
183
184 expect(manifest.entries.some((entry) => entry.name === "generated-asset")).toBe(false);
185 });
186
187 it("finds packages whose exports hide both their manifest and entry point", async () => {
188 const fixture = await createFixture();
189 await writeJson(NodePath.join(fixture.dependencyRoot, "package.json"), {
190 name: "demo-dependency",
191 version: "1.2.3",
192 license: "MIT",
193 exports: {},
194 repository: "example/demo-dependency",
195 });
196
197 const manifest = await generateThirdPartyLicenseManifest({
198 configFile: fixture.configFile,
199 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
200 });
201
202 expect(manifest.entries.some((entry) => entry.name === "demo-dependency")).toBe(true);
203 });
204
205 it("includes custom notices selected by the dev server bundle", async () => {
206 const fixture = await createFixture();
207 await writeJson(fixture.configFile, {
208 customNotices: [
209 {
210 name: "desktop-only-asset",
211 license: "CC-BY-4.0",
212 noticeFile: "asset-notice.txt",
213 bundles: ["assets"],
214 includeInBundles: ["desktop"],
215 },
216 ],
217 packageOverrides: [],
218 });
219
220 const plugin = thirdPartyLicensesPlugin({
221 bundleName: "desktop",
222 configFile: fixture.configFile,
223 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
224 });
225 let middleware:
226 | ((
227 request: { readonly url: string },
228 response: {
229 statusCode: number;
230 setHeader(name: string, value: string): void;
231 end(body: string): void;
232 },
233 next: (error?: Error) => void,
234 ) => void)
235 | undefined;
236 if (typeof plugin.configureServer !== "function") {
237 throw new Error("Expected the license plugin to define a configureServer hook.");
238 }
239 plugin.configureServer.call(
240 {} as never,
241 {
242 middlewares: {
243 use(handler: typeof middleware) {
244 middleware = handler;
245 },
246 },
247 } as never,
248 );
249 if (!middleware) throw new Error("Expected the license plugin to register middleware.");
250
251 const responseBody = await new Promise<string>((resolve, reject) => {
252 middleware!(
253 { url: `/${THIRD_PARTY_LICENSES_FILE_NAME}` },
254 {
255 statusCode: 0,
256 setHeader() {},
257 end: resolve,
258 },
259 (error) => reject(error ?? new Error("License middleware skipped the request.")),
260 );
261 });
262 const manifest = JSON.parse(responseBody) as { entries: ReadonlyArray<{ name: string }> };
263
264 expect(manifest.entries.some((entry) => entry.name === "desktop-only-asset")).toBe(true);
265 });
266
267 it("fails when a production package has no distributable notice text", async () => {
268 const fixture = await createFixture();
269 await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE"));
270
271 await expect(
272 generateThirdPartyLicenseManifest({
273 configFile: fixture.configFile,
274 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
275 }),
276 ).rejects.toThrow("does not include a license or notice file");
277 });
278
279 it("collects nested notices even when a package also has a root license", async () => {
280 const fixture = await createFixture();
281 await NodeFSP.mkdir(NodePath.join(fixture.dependencyRoot, "dist", "third-party"), {
282 recursive: true,
283 });
284 await NodeFSP.mkdir(NodePath.join(fixture.dependencyRoot, "lib"), { recursive: true });
285 await NodeFSP.writeFile(
286 NodePath.join(fixture.dependencyRoot, "dist", "third-party", "NOTICE.txt"),
287 "Nested notice\n",
288 "utf8",
289 );
290 await NodeFSP.writeFile(
291 NodePath.join(fixture.dependencyRoot, "lib", "license_header.js"),
292 "require('not-a-license');\n",
293 "utf8",
294 );
295
296 const manifest = await generateThirdPartyLicenseManifest({
297 configFile: fixture.configFile,
298 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
299 });
300
301 expect(manifest.entries.find((entry) => entry.name === "demo-dependency")?.noticeText).toBe(
302 "dist/third-party/NOTICE.txt\n\nNested notice\n\n---\n\nLICENSE\n\nDemo MIT license text",
303 );
304 });
305
306 it("uses package overrides for notices published outside the npm archive", async () => {
307 const fixture = await createFixture();
308 await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE"));
309 await NodeFSP.writeFile(NodePath.join(fixture.root, "override.txt"), "Override text\n", "utf8");
310 await writeJson(fixture.configFile, {
311 customNotices: [],
312 packageOverrides: [
313 {
314 name: "demo-dependency",
315 noticeFile: "override.txt",
316 },
317 ],
318 });
319
320 const manifest = await generateThirdPartyLicenseManifest({
321 configFile: fixture.configFile,
322 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
323 });
324
325 expect(manifest.entries[0]?.noticeText).toBe("Override text");
326 });
327
328 it("applies repository overrides across monorepo packages", async () => {
329 const fixture = await createFixture();
330 await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE"));
331 await NodeFSP.writeFile(
332 NodePath.join(fixture.root, "override.txt"),
333 "Repository text\n",
334 "utf8",
335 );
336 await writeJson(fixture.configFile, {
337 customNotices: [],
338 packageOverrides: [
339 {
340 repositoryUrl: "https://github.com/example/demo-dependency",
341 noticeFile: "override.txt",
342 },
343 ],
344 });
345
346 const manifest = await generateThirdPartyLicenseManifest({
347 configFile: fixture.configFile,
348 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
349 });
350
351 expect(manifest.entries[0]?.noticeText).toBe("Repository text");
352 });
353
354 it("reuses a repository license for packages from the same monorepo", async () => {
355 const fixture = await createFixture();
356 const siblingRoot = NodePath.join(fixture.root, "node_modules", "demo-sibling");
357 await writeJson(fixture.appManifest, {
358 name: "fixture-app",
359 dependencies: { "demo-dependency": "1.2.3", "demo-sibling": "2.0.0" },
360 });
361 await writeJson(NodePath.join(siblingRoot, "package.json"), {
362 name: "demo-sibling",
363 version: "2.0.0",
364 license: "MIT",
365 main: "index.js",
366 repository: "https://github.com/example/demo-dependency.git#main",
367 });
368 await NodeFSP.writeFile(NodePath.join(siblingRoot, "index.js"), "export {};\n", "utf8");
369
370 const manifest = await generateThirdPartyLicenseManifest({
371 configFile: fixture.configFile,
372 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
373 });
374
375 expect(manifest.entries.find((entry) => entry.name === "demo-sibling")?.noticeText).toBe(
376 "Demo MIT license text",
377 );
378 });
379
380 it("prefers version-specific repository overrides", async () => {
381 const fixture = await createFixture();
382 await NodeFSP.writeFile(NodePath.join(fixture.root, "generic.txt"), "Generic text\n", "utf8");
383 await NodeFSP.writeFile(NodePath.join(fixture.root, "exact.txt"), "Exact text\n", "utf8");
384 await writeJson(fixture.configFile, {
385 customNotices: [],
386 packageOverrides: [
387 {
388 repositoryUrl: "https://github.com/example/demo-dependency",
389 noticeFile: "generic.txt",
390 },
391 {
392 repositoryUrl: "https://github.com/example/demo-dependency",
393 version: "1.2.3",
394 noticeFile: "exact.txt",
395 },
396 ],
397 });
398
399 const manifest = await generateThirdPartyLicenseManifest({
400 configFile: fixture.configFile,
401 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
402 });
403
404 expect(manifest.entries[0]?.noticeText).toBe("Exact text");
405 });
406
407 it("omits custom notices for other bundles", async () => {
408 const fixture = await createFixture();
409 await writeJson(fixture.configFile, {
410 customNotices: [
411 {
412 name: "web-only-asset",
413 license: "MIT",
414 noticeFile: "asset-notice.txt",
415 bundles: ["assets", "web"],
416 },
417 ],
418 packageOverrides: [],
419 });
420
421 const manifest = await generateThirdPartyLicenseManifest({
422 configFile: fixture.configFile,
423 packageManifests: [{ bundle: "mobile", path: fixture.appManifest }],
424 });
425
426 expect(manifest.entries.some((entry) => entry.name === "web-only-asset")).toBe(false);
427 });
428
429 it("can show a multi-file notice under a label that differs from its client manifests", async () => {
430 const fixture = await createFixture();
431 await NodeFSP.writeFile(
432 NodePath.join(fixture.root, "tool-license.txt"),
433 "Tool license\n",
434 "utf8",
435 );
436 await NodeFSP.writeFile(
437 NodePath.join(fixture.root, "vendor-notice.txt"),
438 "Vendor notice\n",
439 "utf8",
440 );
441 await writeJson(fixture.configFile, {
442 customNotices: [
443 {
444 name: "optional-tool",
445 license: "MIT AND Apache-2.0",
446 noticeFiles: ["tool-license.txt", "vendor-notice.txt"],
447 bundles: ["device-tools"],
448 includeInBundles: ["mobile", "web"],
449 },
450 ],
451 packageOverrides: [],
452 });
453
454 const manifest = await generateThirdPartyLicenseManifest({
455 configFile: fixture.configFile,
456 packageManifests: [{ bundle: "mobile", path: fixture.appManifest }],
457 });
458
459 expect(manifest.entries.find((entry) => entry.name === "optional-tool")).toMatchObject({
460 bundles: ["device-tools"],
461 noticeText: "Tool license\n\n---\n\nVendor notice",
462 });
463 });
464
465 it("fails when a custom notice file is empty", async () => {
466 const fixture = await createFixture();
467 await NodeFSP.writeFile(NodePath.join(fixture.root, "asset-notice.txt"), "\n", "utf8");
468
469 await expect(
470 generateThirdPartyLicenseManifest({
471 configFile: fixture.configFile,
472 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
473 }),
474 ).rejects.toThrow('Custom third-party notice "demo-asset" is empty');
475 });
476
477 it("fails generation when custom notices produce duplicate navigation keys", async () => {
478 const fixture = await createFixture();
479 await writeJson(fixture.configFile, {
480 customNotices: [
481 {
482 name: "duplicate-asset",
483 license: "MIT",
484 noticeFile: "asset-notice.txt",
485 bundles: ["assets", "web"],
486 },
487 {
488 name: "duplicate-asset",
489 license: "CC0-1.0",
490 noticeFile: "asset-notice.txt",
491 bundles: ["assets", "web"],
492 },
493 ],
494 packageOverrides: [],
495 });
496
497 await expect(
498 generateThirdPartyLicenseManifest({
499 configFile: fixture.configFile,
500 packageManifests: [{ bundle: "web", path: fixture.appManifest }],
501 }),
502 ).rejects.toThrow("duplicate custom notice for duplicate-asset");
503 });
504});
scripts/lib/third-party-licenses.ts created+1014
......@@ -0,0 +1,1014 @@
1// @effect-diagnostics nodeBuiltinImport:off globalFetch:off - Vite's build plugin runs before an Effect runtime exists.
2
3import * as NodeFSP from "node:fs/promises";
4import * as NodePath from "node:path";
5import * as NodeURL from "node:url";
6import * as NodeModule from "node:module";
7
8import type { Plugin } from "vite-plus";
9
10export const THIRD_PARTY_LICENSES_FILE_NAME = "third-party-licenses.json";
11const SPDX_LICENSE_LIST_VERSION = "v3.28.0";
12const SPDX_LICENSE_LIST_REVISION = "c4a7237ec8f4654e867546f9f409749300f1bf4c";
13const GENERATED_NOTICE_CACHE_DIRECTORY = ".generated/third-party-licenses/spdx";
14
15export interface ThirdPartyLicenseEntry {
16 readonly bundles: ReadonlyArray<string>;
17 readonly kind: "custom" | "package";
18 readonly license: string;
19 readonly name: string;
20 readonly noticeText: string;
21 readonly sourceUrl: string | null;
22 readonly version: string | null;
23}
24
25export interface ThirdPartyLicenseManifest {
26 readonly schemaVersion: 1;
27 readonly entries: ReadonlyArray<ThirdPartyLicenseEntry>;
28}
29
30export interface ThirdPartyLicensePackageManifest {
31 readonly bundle: string;
32 readonly path: string | URL;
33}
34
35export interface ThirdPartyLicensesPluginOptions {
36 readonly configFile?: string | URL;
37 readonly packageManifests: ReadonlyArray<ThirdPartyLicensePackageManifest>;
38 readonly bundleName: string;
39}
40
41interface GeneratedNoticeConfigEntry {
42 readonly copyrights?: ReadonlyArray<string>;
43 readonly licenseId: string;
44 readonly preamble?: ReadonlyArray<string>;
45}
46
47interface PackageJson {
48 readonly dependencies?: Readonly<Record<string, string>>;
49 readonly homepage?: unknown;
50 readonly license?: unknown;
51 readonly licenses?: unknown;
52 readonly name?: unknown;
53 readonly optionalDependencies?: Readonly<Record<string, string>>;
54 readonly repository?: unknown;
55 readonly version?: unknown;
56}
57
58interface CustomNoticeConfigEntry {
59 readonly bundles?: ReadonlyArray<string>;
60 readonly includeInBundles?: ReadonlyArray<string>;
61 readonly license: string;
62 readonly name: string;
63 readonly generatedNotices?: ReadonlyArray<GeneratedNoticeConfigEntry>;
64 readonly noticeFiles?: ReadonlyArray<string>;
65 readonly sourceUrl?: string;
66 readonly version?: string;
67}
68
69interface PackageNoticeOverrideConfigEntry {
70 readonly generatedNotice?: GeneratedNoticeConfigEntry;
71 readonly license?: string;
72 readonly name?: string;
73 readonly noticeFile?: string;
74 readonly repositoryUrl?: string;
75 readonly sourceUrl?: string;
76 readonly version?: string;
77}
78
79interface ThirdPartyLicensesConfig {
80 readonly customNotices: ReadonlyArray<CustomNoticeConfigEntry>;
81 readonly packageOverrides: ReadonlyArray<PackageNoticeOverrideConfigEntry>;
82}
83
84interface SpdxLicenseDetails {
85 readonly licenseId: string;
86 readonly licenseText: string;
87}
88
89interface CollectedPackage {
90 readonly bundles: Set<string>;
91 readonly packageJson: PackageJson;
92 readonly packageRoot: string;
93}
94
95interface PackageCollection {
96 readonly byIdentity: Map<string, CollectedPackage>;
97}
98
99const EMPTY_CONFIG: ThirdPartyLicensesConfig = {
100 customNotices: [],
101 packageOverrides: [],
102};
103
104const NOTICE_FILE_PATTERN = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/i;
105const NOTICE_TEXT_EXTENSIONS = new Set([
106 "",
107 ".0bsd",
108 ".agpl",
109 ".apache2",
110 ".bsd",
111 ".gpl",
112 ".isc",
113 ".lgpl",
114 ".markdown",
115 ".md",
116 ".mit",
117 ".mpl",
118 ".mpl2",
119 ".rst",
120 ".txt",
121 ".unlicense",
122]);
123const FIRST_PARTY_PACKAGE_PREFIX = "@t3tools/";
124
125function isNoticeTextFile(fileName: string): boolean {
126 return (
127 NOTICE_FILE_PATTERN.test(fileName) &&
128 NOTICE_TEXT_EXTENSIONS.has(NodePath.extname(fileName).toLowerCase())
129 );
130}
131
132function asPath(value: string | URL): string {
133 return value instanceof URL ? NodeURL.fileURLToPath(value) : NodePath.resolve(value);
134}
135
136function isRecord(value: unknown): value is Record<string, unknown> {
137 return typeof value === "object" && value !== null && !Array.isArray(value);
138}
139
140function readRequiredString(value: Record<string, unknown>, key: string, context: string): string {
141 const field = value[key];
142 if (typeof field !== "string" || field.trim().length === 0) {
143 throw new Error(`${context} must define a non-empty "${key}" string.`);
144 }
145 return field.trim();
146}
147
148function readOptionalString(
149 value: Record<string, unknown>,
150 key: string,
151 context: string,
152): string | undefined {
153 const field = value[key];
154 if (field === undefined) return undefined;
155 if (typeof field !== "string" || field.trim().length === 0) {
156 throw new Error(`${context} must define "${key}" as a non-empty string when present.`);
157 }
158 return field.trim();
159}
160
161function readOptionalStringArray(
162 value: Record<string, unknown>,
163 key: string,
164 context: string,
165): ReadonlyArray<string> | undefined {
166 const field = value[key];
167 if (field === undefined) return undefined;
168 if (
169 !Array.isArray(field) ||
170 field.length === 0 ||
171 field.some((entry) => typeof entry !== "string" || entry.trim().length === 0)
172 ) {
173 throw new Error(`${context} must define "${key}" as a non-empty string array.`);
174 }
175 return field.map((entry) => (entry as string).trim());
176}
177
178function decodeGeneratedNotice(value: unknown, context: string): GeneratedNoticeConfigEntry {
179 if (!isRecord(value)) throw new Error(`${context} must be an object.`);
180 const copyrights = readOptionalStringArray(value, "copyrights", context);
181 const preamble = readOptionalStringArray(value, "preamble", context);
182 return {
183 licenseId: readRequiredString(value, "licenseId", context),
184 ...(copyrights !== undefined ? { copyrights } : {}),
185 ...(preamble !== undefined ? { preamble } : {}),
186 };
187}
188
189function decodeGeneratedNotices(
190 value: unknown,
191 context: string,
192): ReadonlyArray<GeneratedNoticeConfigEntry> | undefined {
193 if (value === undefined) return undefined;
194 if (!Array.isArray(value) || value.length === 0) {
195 throw new Error(`${context} must define "generatedNotices" as a non-empty array.`);
196 }
197 return value.map((entry, index) =>
198 decodeGeneratedNotice(entry, `${context} generated notice at index ${String(index)}`),
199 );
200}
201
202function decodeCustomNotices(value: unknown): ReadonlyArray<CustomNoticeConfigEntry> {
203 if (value === undefined) return [];
204 if (!Array.isArray(value)) {
205 throw new Error('Third-party license config field "customNotices" must be an array.');
206 }
207 return value.map((entry, index) => {
208 const context = `Third-party custom notice at index ${String(index)}`;
209 if (!isRecord(entry)) throw new Error(`${context} must be an object.`);
210 const version = readOptionalString(entry, "version", context);
211 const sourceUrl = readOptionalString(entry, "sourceUrl", context);
212 const bundles = readOptionalStringArray(entry, "bundles", context);
213 const includeInBundles = readOptionalStringArray(entry, "includeInBundles", context);
214 const noticeFile = readOptionalString(entry, "noticeFile", context);
215 const noticeFiles = readOptionalStringArray(entry, "noticeFiles", context);
216 const generatedNotices = decodeGeneratedNotices(entry.generatedNotices, context);
217 const noticeSourceCount =
218 Number(noticeFile !== undefined) +
219 Number(noticeFiles !== undefined) +
220 Number(generatedNotices !== undefined);
221 if (noticeSourceCount !== 1) {
222 throw new Error(
223 `${context} must define exactly one of "noticeFile", "noticeFiles", or "generatedNotices".`,
224 );
225 }
226 return {
227 name: readRequiredString(entry, "name", context),
228 license: readRequiredString(entry, "license", context),
229 ...(noticeFile !== undefined ? { noticeFiles: [noticeFile] } : {}),
230 ...(noticeFiles !== undefined ? { noticeFiles } : {}),
231 ...(generatedNotices !== undefined ? { generatedNotices } : {}),
232 ...(version !== undefined ? { version } : {}),
233 ...(sourceUrl !== undefined ? { sourceUrl } : {}),
234 ...(bundles !== undefined ? { bundles } : {}),
235 ...(includeInBundles !== undefined ? { includeInBundles } : {}),
236 };
237 });
238}
239
240function decodePackageOverrides(value: unknown): ReadonlyArray<PackageNoticeOverrideConfigEntry> {
241 if (value === undefined) return [];
242 if (!Array.isArray(value)) {
243 throw new Error('Third-party license config field "packageOverrides" must be an array.');
244 }
245 return value.map((entry, index) => {
246 const context = `Third-party package override at index ${String(index)}`;
247 if (!isRecord(entry)) throw new Error(`${context} must be an object.`);
248 const name = readOptionalString(entry, "name", context);
249 const repositoryUrl = readOptionalString(entry, "repositoryUrl", context);
250 if ((name === undefined) === (repositoryUrl === undefined)) {
251 throw new Error(`${context} must define exactly one of "name" or "repositoryUrl".`);
252 }
253 const version = readOptionalString(entry, "version", context);
254 const license = readOptionalString(entry, "license", context);
255 const noticeFile = readOptionalString(entry, "noticeFile", context);
256 const generatedNotice =
257 entry.generatedNotice === undefined
258 ? undefined
259 : decodeGeneratedNotice(entry.generatedNotice, `${context} generated notice`);
260 if (noticeFile !== undefined && generatedNotice !== undefined) {
261 throw new Error(`${context} cannot define both "noticeFile" and "generatedNotice".`);
262 }
263 const sourceUrl = readOptionalString(entry, "sourceUrl", context);
264 return {
265 ...(name !== undefined ? { name } : {}),
266 ...(repositoryUrl !== undefined ? { repositoryUrl } : {}),
267 ...(version !== undefined ? { version } : {}),
268 ...(license !== undefined ? { license } : {}),
269 ...(noticeFile !== undefined ? { noticeFile } : {}),
270 ...(generatedNotice !== undefined ? { generatedNotice } : {}),
271 ...(sourceUrl !== undefined ? { sourceUrl } : {}),
272 };
273 });
274}
275
276async function readConfig(configFile: string | URL | undefined): Promise<{
277 readonly config: ThirdPartyLicensesConfig;
278 readonly directory: string;
279}> {
280 if (configFile === undefined) {
281 return { config: EMPTY_CONFIG, directory: NodePath.resolve(".") };
282 }
283 const configPath = asPath(configFile);
284 const source = await NodeFSP.readFile(configPath, "utf8");
285 const decoded = JSON.parse(source) as unknown;
286 if (!isRecord(decoded)) throw new Error("Third-party license config must contain an object.");
287 return {
288 config: {
289 customNotices: decodeCustomNotices(decoded.customNotices),
290 packageOverrides: decodePackageOverrides(decoded.packageOverrides),
291 },
292 directory: NodePath.dirname(configPath),
293 };
294}
295
296function spdxLicenseCachePath(configDirectory: string, licenseId: string): string {
297 return NodePath.join(
298 configDirectory,
299 GENERATED_NOTICE_CACHE_DIRECTORY,
300 SPDX_LICENSE_LIST_VERSION,
301 `${licenseId}.json`,
302 );
303}
304
305function decodeSpdxLicenseDetails(value: unknown, expectedLicenseId: string): SpdxLicenseDetails {
306 if (
307 !isRecord(value) ||
308 value.licenseId !== expectedLicenseId ||
309 typeof value.licenseText !== "string" ||
310 value.licenseText.trim().length === 0
311 ) {
312 throw new Error(`SPDX returned invalid license details for ${expectedLicenseId}.`);
313 }
314 return { licenseId: expectedLicenseId, licenseText: value.licenseText.trim() };
315}
316
317async function readCachedSpdxLicense(
318 configDirectory: string,
319 licenseId: string,
320): Promise<SpdxLicenseDetails | null> {
321 try {
322 const source = await NodeFSP.readFile(spdxLicenseCachePath(configDirectory, licenseId), "utf8");
323 return decodeSpdxLicenseDetails(JSON.parse(source) as unknown, licenseId);
324 } catch (error) {
325 const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
326 if (code === "ENOENT") return null;
327 throw error;
328 }
329}
330
331async function downloadSpdxLicense(
332 configDirectory: string,
333 licenseId: string,
334): Promise<SpdxLicenseDetails> {
335 const url = `https://raw.githubusercontent.com/spdx/license-list-data/${SPDX_LICENSE_LIST_REVISION}/json/details/${encodeURIComponent(licenseId)}.json`;
336 const response = await fetch(url);
337 if (!response.ok) {
338 throw new Error(
339 `Could not download SPDX license ${licenseId}: HTTP ${String(response.status)}.`,
340 );
341 }
342 const details = decodeSpdxLicenseDetails((await response.json()) as unknown, licenseId);
343 const cachePath = spdxLicenseCachePath(configDirectory, licenseId);
344 await NodeFSP.mkdir(NodePath.dirname(cachePath), { recursive: true });
345 await NodeFSP.writeFile(cachePath, `${JSON.stringify(details)}\n`, "utf8");
346 return details;
347}
348
349async function resolveSpdxLicense(
350 configDirectory: string,
351 licenseId: string,
352 allowMissing: boolean,
353): Promise<SpdxLicenseDetails | null> {
354 const cached = await readCachedSpdxLicense(configDirectory, licenseId);
355 if (cached || allowMissing) return cached;
356 return downloadSpdxLicense(configDirectory, licenseId);
357}
358
359function renderGeneratedNotice(config: GeneratedNoticeConfigEntry, licenseText: string): string {
360 const copyrights = config.copyrights ?? [];
361 let renderedLicense = licenseText;
362 if (copyrights.length > 0) {
363 const placeholderPattern = /^Copyright[^\n]*(?:<year>|<copyright holders>|<owner>)[^\n]*$/m;
364 if (placeholderPattern.test(renderedLicense)) {
365 renderedLicense = renderedLicense.replace(placeholderPattern, copyrights.join("\n"));
366 } else if (config.licenseId === "ISC") {
367 renderedLicense = renderedLicense.replace(
368 /^(?:Copyright[^\n]*\n)+/m,
369 `${copyrights.join("\n")}\n`,
370 );
371 } else {
372 renderedLicense = `${copyrights.join("\n")}\n\n${renderedLicense}`;
373 }
374 }
375 return [...(config.preamble ?? []), renderedLicense].join("\n\n").trim();
376}
377
378async function generatedNoticeText(
379 configs: ReadonlyArray<GeneratedNoticeConfigEntry>,
380 configDirectory: string,
381 allowMissing: boolean,
382): Promise<string | null> {
383 const sections = await Promise.all(
384 configs.map(async (config) => {
385 const details = await resolveSpdxLicense(configDirectory, config.licenseId, allowMissing);
386 return details ? renderGeneratedNotice(config, details.licenseText) : null;
387 }),
388 );
389 return sections.some((section) => section === null)
390 ? null
391 : (sections as ReadonlyArray<string>).join("\n\n---\n\n");
392}
393
394function configuredGeneratedNotices(
395 config: ThirdPartyLicensesConfig,
396): ReadonlyArray<GeneratedNoticeConfigEntry> {
397 return [
398 ...config.customNotices.flatMap((notice) => notice.generatedNotices ?? []),
399 ...config.packageOverrides.flatMap((override) =>
400 override.generatedNotice ? [override.generatedNotice] : [],
401 ),
402 ];
403}
404
405async function syncConfiguredGeneratedNotices(
406 config: ThirdPartyLicensesConfig,
407 directory: string,
408): Promise<void> {
409 const licenseIds = [
410 ...new Set(configuredGeneratedNotices(config).map((notice) => notice.licenseId)),
411 ].sort((left, right) => left.localeCompare(right));
412 await Promise.all(licenseIds.map((licenseId) => resolveSpdxLicense(directory, licenseId, false)));
413}
414
415export async function syncThirdPartyLicenseNotices(configFile: string | URL): Promise<void> {
416 const { config, directory } = await readConfig(configFile);
417 await syncConfiguredGeneratedNotices(config, directory);
418}
419
420async function readPackageJson(packageJsonPath: string): Promise<PackageJson> {
421 const source = await NodeFSP.readFile(packageJsonPath, "utf8");
422 const value = JSON.parse(source) as unknown;
423 if (!isRecord(value)) throw new Error(`Package manifest is not an object: ${packageJsonPath}`);
424 return value as PackageJson;
425}
426
427function packageIdentity(packageJson: PackageJson, packageRoot: string): string {
428 const name =
429 typeof packageJson.name === "string" ? packageJson.name : NodePath.basename(packageRoot);
430 const version = typeof packageJson.version === "string" ? packageJson.version : "unknown";
431 return `${name}@${version}`;
432}
433
434async function findPackageRoot(
435 resolvedPath: string,
436 expectedName?: string,
437): Promise<{ readonly packageJson: PackageJson; readonly packageRoot: string } | null> {
438 let current = NodePath.dirname(resolvedPath);
439 const root = NodePath.parse(current).root;
440
441 while (current !== root) {
442 const packageJsonPath = NodePath.join(current, "package.json");
443 try {
444 const packageJson = await readPackageJson(packageJsonPath);
445 const matchesExpectedPackage =
446 expectedName !== undefined
447 ? packageJson.name === expectedName
448 : typeof packageJson.name === "string" && typeof packageJson.version === "string";
449 if (matchesExpectedPackage) {
450 return { packageJson, packageRoot: await NodeFSP.realpath(current) };
451 }
452 } catch (error) {
453 const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
454 if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
455 }
456 current = NodePath.dirname(current);
457 }
458 return null;
459}
460
461async function resolveDependencyPackage(
462 dependencyName: string,
463 fromPackageJsonPath: string,
464): Promise<{ readonly packageJson: PackageJson; readonly packageRoot: string } | null> {
465 const requireFromPackage = NodeModule.createRequire(fromPackageJsonPath);
466 const candidates = [`${dependencyName}/package.json`, dependencyName];
467 for (const candidate of candidates) {
468 try {
469 const resolved = requireFromPackage.resolve(candidate);
470 const found = await findPackageRoot(resolved, dependencyName);
471 if (found) return found;
472 } catch (error) {
473 const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
474 if (code !== "MODULE_NOT_FOUND" && code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") throw error;
475 }
476 }
477
478 let current = NodePath.dirname(fromPackageJsonPath);
479 const root = NodePath.parse(current).root;
480 while (true) {
481 const packageRoot = NodePath.join(current, "node_modules", dependencyName);
482 try {
483 const packageJson = await readPackageJson(NodePath.join(packageRoot, "package.json"));
484 if (packageJson.name === dependencyName) {
485 return { packageJson, packageRoot: await NodeFSP.realpath(packageRoot) };
486 }
487 } catch (error) {
488 const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
489 if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
490 }
491 if (current === root) break;
492 current = NodePath.dirname(current);
493 }
494 return null;
495}
496
497function dependencyNames(packageJson: PackageJson): ReadonlyArray<string> {
498 return [
499 ...new Set([
500 ...Object.keys(packageJson.dependencies ?? {}),
501 ...Object.keys(packageJson.optionalDependencies ?? {}),
502 ]),
503 ].sort((left, right) => left.localeCompare(right));
504}
505
506async function collectProductionDependencyPackages(
507 packageManifests: ReadonlyArray<ThirdPartyLicensePackageManifest>,
508): Promise<PackageCollection> {
509 const collection: PackageCollection = {
510 byIdentity: new Map(),
511 };
512 const visited = new Set<string>();
513
514 const visitManifest = async (packageJsonPath: string, bundle: string): Promise<void> => {
515 const packageJson = await readPackageJson(packageJsonPath);
516 for (const dependencyName of dependencyNames(packageJson)) {
517 const resolved = await resolveDependencyPackage(dependencyName, packageJsonPath);
518 if (!resolved) continue;
519 const visitKey = `${bundle}:${resolved.packageRoot}`;
520 if (visited.has(visitKey)) continue;
521 visited.add(visitKey);
522
523 const dependencyPackageJsonPath = NodePath.join(resolved.packageRoot, "package.json");
524 const name =
525 typeof resolved.packageJson.name === "string" ? resolved.packageJson.name : dependencyName;
526 if (!name.startsWith(FIRST_PARTY_PACKAGE_PREFIX)) {
527 const identity = packageIdentity(resolved.packageJson, resolved.packageRoot);
528 const existing = collection.byIdentity.get(identity);
529 if (existing) {
530 existing.bundles.add(bundle);
531 } else {
532 collection.byIdentity.set(identity, {
533 bundles: new Set([bundle]),
534 packageJson: resolved.packageJson,
535 packageRoot: resolved.packageRoot,
536 });
537 }
538 }
539
540 await visitManifest(dependencyPackageJsonPath, bundle);
541 }
542 };
543
544 for (const manifest of packageManifests) {
545 await visitManifest(asPath(manifest.path), manifest.bundle);
546 }
547 return collection;
548}
549
550function moduleFilePath(moduleId: string): string | null {
551 if (moduleId.startsWith("\0") || moduleId.includes("\0")) return null;
552 const withoutQuery = moduleId.split(/[?#]/, 1)[0] ?? moduleId;
553 const viteFilePath = withoutQuery.startsWith("/@fs/")
554 ? withoutQuery.slice("/@fs/".length)
555 : withoutQuery;
556 const filePath = withoutQuery.startsWith("file:")
557 ? NodeURL.fileURLToPath(withoutQuery)
558 : /^[A-Za-z]:[\\/]/.test(viteFilePath)
559 ? viteFilePath
560 : NodePath.resolve("/", viteFilePath);
561 const normalized = filePath.replaceAll("\\", "/");
562 return normalized.includes("/node_modules/") ? filePath : null;
563}
564
565async function addBundledModulePackages(
566 collection: PackageCollection,
567 moduleIds: ReadonlyArray<string>,
568 bundle: string,
569): Promise<void> {
570 for (const moduleId of moduleIds) {
571 const filePath = moduleFilePath(moduleId);
572 if (!filePath) continue;
573 let found: Awaited<ReturnType<typeof findPackageRoot>>;
574 try {
575 found = await findPackageRoot(await NodeFSP.realpath(filePath));
576 } catch (error) {
577 const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
578 if (code === "ENOENT" || code === "ENOTDIR") continue;
579 throw error;
580 }
581 if (!found || typeof found.packageJson.name !== "string") continue;
582 if (found.packageJson.name.startsWith(FIRST_PARTY_PACKAGE_PREFIX)) continue;
583 const identity = packageIdentity(found.packageJson, found.packageRoot);
584 const existing = collection.byIdentity.get(identity);
585 if (existing) {
586 existing.bundles.add(bundle);
587 } else {
588 collection.byIdentity.set(identity, {
589 bundles: new Set([bundle]),
590 packageJson: found.packageJson,
591 packageRoot: found.packageRoot,
592 });
593 }
594 }
595}
596
597function normalizeLicense(packageJson: PackageJson): string | null {
598 if (typeof packageJson.license === "string" && packageJson.license.trim().length > 0) {
599 return packageJson.license.trim();
600 }
601 if (isRecord(packageJson.license) && typeof packageJson.license.type === "string") {
602 return packageJson.license.type.trim() || null;
603 }
604 const declaredLicenses = Array.isArray(packageJson.license)
605 ? packageJson.license
606 : packageJson.licenses;
607 if (Array.isArray(declaredLicenses)) {
608 const licenses = declaredLicenses
609 .map((entry) => {
610 if (typeof entry === "string") return entry.trim();
611 if (isRecord(entry) && typeof entry.type === "string") return entry.type.trim();
612 return "";
613 })
614 .filter((entry) => entry.length > 0);
615 if (licenses.length > 0) return licenses.join(" OR ");
616 }
617 return null;
618}
619
620function normalizeRepositoryUrl(value: unknown): string | null {
621 const raw =
622 typeof value === "string"
623 ? value
624 : isRecord(value) && typeof value.url === "string"
625 ? value.url
626 : null;
627 if (!raw) return null;
628 const trimmed = raw.trim();
629 if (trimmed.startsWith("github:")) return `https://github.com/${trimmed.slice(7)}`;
630 const normalized = trimmed
631 .replace(/^git\+ssh:\/\/git@github\.com\//, "https://github.com/")
632 .replace(/^git\+/, "")
633 .replace(/^git@github\.com:/, "https://github.com/")
634 .replace(/^ssh:\/\/(?:git@)?github\.com\//, "https://github.com/")
635 .replace(/^git:\/\/github\.com\//, "https://github.com/")
636 .replace(/#.*$/, "")
637 .replace(/\.git$/, "");
638 if (/^[\w.-]+\/[\w.-]+$/.test(normalized)) return `https://github.com/${normalized}`;
639 return normalized;
640}
641
642function packageSourceUrl(packageJson: PackageJson): string | null {
643 if (typeof packageJson.homepage === "string" && packageJson.homepage.trim().length > 0) {
644 return packageJson.homepage.trim();
645 }
646 return normalizeRepositoryUrl(packageJson.repository);
647}
648
649async function readPackageNoticeText(packageRoot: string): Promise<string | null> {
650 const noticeFiles: string[] = [];
651 const rootEntries = await NodeFSP.readdir(packageRoot, { withFileTypes: true });
652 noticeFiles.push(
653 ...rootEntries
654 .filter((entry) => entry.isFile() && isNoticeTextFile(entry.name))
655 .map((entry) => entry.name),
656 );
657
658 const collectNestedNoticeFiles = async (directory: string, depth: number): Promise<void> => {
659 const directoryEntries = await NodeFSP.readdir(NodePath.join(packageRoot, directory), {
660 withFileTypes: true,
661 });
662 await Promise.all(
663 directoryEntries.map(async (entry) => {
664 const relativePath = NodePath.join(directory, entry.name);
665 if (entry.isFile() && isNoticeTextFile(entry.name)) {
666 noticeFiles.push(relativePath);
667 return;
668 }
669 if (
670 depth > 0 &&
671 entry.isDirectory() &&
672 entry.name !== "node_modules" &&
673 entry.name !== ".git"
674 ) {
675 await collectNestedNoticeFiles(relativePath, depth - 1);
676 }
677 }),
678 );
679 };
680 await Promise.all(
681 rootEntries
682 .filter(
683 (entry) => entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git",
684 )
685 .map((entry) => collectNestedNoticeFiles(entry.name, 2)),
686 );
687 noticeFiles.sort((left, right) => left.localeCompare(right));
688 if (noticeFiles.length === 0) return null;
689
690 const sections: string[] = [];
691 for (const fileName of noticeFiles) {
692 const contents = (await NodeFSP.readFile(NodePath.join(packageRoot, fileName), "utf8")).trim();
693 if (contents.length === 0) continue;
694 sections.push(noticeFiles.length === 1 ? contents : `${fileName}\n\n${contents}`);
695 }
696 return sections.length > 0 ? sections.join("\n\n---\n\n") : null;
697}
698
699function repositoryNoticeKey(packageJson: PackageJson, license: string): string | null {
700 const repositoryUrl = normalizeRepositoryUrl(packageJson.repository);
701 return repositoryUrl ? `${repositoryUrl.toLowerCase()}\n${license.toLowerCase()}` : null;
702}
703
704async function collectRepositoryNotices(
705 collection: PackageCollection,
706 packageNotices: Map<string, Promise<string | null>>,
707): Promise<ReadonlyMap<string, string>> {
708 const notices = new Map<string, string>();
709 const candidates = await Promise.all(
710 [...collection.byIdentity.values()].map(async (collected) => {
711 const license = normalizeLicense(collected.packageJson);
712 if (!license) return null;
713 const key = repositoryNoticeKey(collected.packageJson, license);
714 if (!key) return null;
715 const noticeText = await packageNoticeText(collected.packageRoot, packageNotices);
716 return noticeText ? { key, noticeText } : null;
717 }),
718 );
719 for (const candidate of candidates) {
720 if (candidate && !notices.has(candidate.key)) notices.set(candidate.key, candidate.noticeText);
721 }
722 return notices;
723}
724
725function packageNoticeText(
726 packageRoot: string,
727 cache: Map<string, Promise<string | null>>,
728): Promise<string | null> {
729 const existing = cache.get(packageRoot);
730 if (existing) return existing;
731 const notice = readPackageNoticeText(packageRoot);
732 cache.set(packageRoot, notice);
733 return notice;
734}
735
736function findPackageOverride(
737 overrides: ReadonlyArray<PackageNoticeOverrideConfigEntry>,
738 name: string,
739 version: string,
740 packageJson: PackageJson,
741): PackageNoticeOverrideConfigEntry | undefined {
742 const repositoryUrl = normalizeRepositoryUrl(packageJson.repository)?.toLowerCase();
743 const matchesRepository = (override: PackageNoticeOverrideConfigEntry) =>
744 repositoryUrl !== undefined &&
745 override.repositoryUrl !== undefined &&
746 normalizeRepositoryUrl(override.repositoryUrl)?.toLowerCase() === repositoryUrl;
747 return (
748 overrides.find((override) => override.name === name && override.version === version) ??
749 overrides.find((override) => override.name === name && override.version === undefined) ??
750 overrides.find((override) => matchesRepository(override) && override.version === version) ??
751 overrides.find((override) => matchesRepository(override) && override.version === undefined)
752 );
753}
754
755async function packageEntry(
756 collected: CollectedPackage,
757 config: ThirdPartyLicensesConfig,
758 configDirectory: string,
759 packageNotices: Map<string, Promise<string | null>>,
760 repositoryNotices: ReadonlyMap<string, string>,
761 allowMissingGeneratedNotices: boolean,
762): Promise<ThirdPartyLicenseEntry | null> {
763 const name =
764 typeof collected.packageJson.name === "string"
765 ? collected.packageJson.name
766 : NodePath.basename(collected.packageRoot);
767 const version =
768 typeof collected.packageJson.version === "string" ? collected.packageJson.version : "unknown";
769 const override = findPackageOverride(
770 config.packageOverrides,
771 name,
772 version,
773 collected.packageJson,
774 );
775 const license = override?.license ?? normalizeLicense(collected.packageJson);
776 if (!license || /^(?:unlicensed|proprietary)$/i.test(license)) {
777 throw new Error(
778 `${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.`,
779 );
780 }
781
782 const repositoryKey = repositoryNoticeKey(collected.packageJson, license);
783 const noticeText = override?.generatedNotice
784 ? await generatedNoticeText(
785 [override.generatedNotice],
786 configDirectory,
787 allowMissingGeneratedNotices,
788 )
789 : override?.noticeFile
790 ? (
791 await NodeFSP.readFile(NodePath.resolve(configDirectory, override.noticeFile), "utf8")
792 ).trim()
793 : ((await packageNoticeText(collected.packageRoot, packageNotices)) ??
794 (repositoryKey ? repositoryNotices.get(repositoryKey) : undefined));
795 if (!noticeText) {
796 if (override?.generatedNotice && allowMissingGeneratedNotices) return null;
797 throw new Error(
798 `${name}@${version} does not include a license or notice file. Add a package override with "noticeFile" or "generatedNotice" in the third-party license config.`,
799 );
800 }
801
802 return {
803 bundles: [...collected.bundles].sort((left, right) => left.localeCompare(right)),
804 kind: "package",
805 license,
806 name,
807 noticeText,
808 sourceUrl: override?.sourceUrl ?? packageSourceUrl(collected.packageJson),
809 version,
810 };
811}
812
813async function customEntries(
814 config: ThirdPartyLicensesConfig,
815 configDirectory: string,
816 includedBundles: ReadonlySet<string>,
817 allowMissingGeneratedNotices: boolean,
818): Promise<ReadonlyArray<ThirdPartyLicenseEntry>> {
819 const entries = await Promise.all(
820 config.customNotices
821 .filter(
822 (notice) =>
823 (notice.includeInBundles ?? notice.bundles) === undefined ||
824 (notice.includeInBundles ?? notice.bundles)?.some((bundle) =>
825 includedBundles.has(bundle),
826 ),
827 )
828 .map(async (notice) => {
829 const noticeText = notice.generatedNotices
830 ? await generatedNoticeText(
831 notice.generatedNotices,
832 configDirectory,
833 allowMissingGeneratedNotices,
834 )
835 : (
836 await Promise.all(
837 notice.noticeFiles!.map(async (noticeFile) => {
838 const contents = (
839 await NodeFSP.readFile(NodePath.resolve(configDirectory, noticeFile), "utf8")
840 ).trim();
841 if (contents.length === 0) {
842 throw new Error(`Custom third-party notice "${notice.name}" is empty.`);
843 }
844 return contents;
845 }),
846 )
847 ).join("\n\n---\n\n");
848 if (noticeText === null) {
849 if (allowMissingGeneratedNotices) return null;
850 throw new Error(`Could not generate custom third-party notice "${notice.name}".`);
851 }
852 if (noticeText.length === 0) {
853 throw new Error(`Custom third-party notice "${notice.name}" is empty.`);
854 }
855 return {
856 bundles: [...(notice.bundles ?? ["assets"])].sort((left, right) =>
857 left.localeCompare(right),
858 ),
859 kind: "custom" as const,
860 license: notice.license,
861 name: notice.name,
862 noticeText,
863 sourceUrl: notice.sourceUrl ?? null,
864 version: notice.version ?? null,
865 };
866 }),
867 );
868 return entries.flatMap((entry) => (entry ? [entry] : []));
869}
870
871function entrySort(left: ThirdPartyLicenseEntry, right: ThirdPartyLicenseEntry): number {
872 return (
873 left.name.localeCompare(right.name) ||
874 (left.version ?? "").localeCompare(right.version ?? "") ||
875 left.kind.localeCompare(right.kind)
876 );
877}
878
879function assertUniqueEntries(entries: ReadonlyArray<ThirdPartyLicenseEntry>): void {
880 const identities = new Set<string>();
881 for (const entry of entries) {
882 const identity = JSON.stringify([entry.kind, entry.name, entry.version]);
883 if (identities.has(identity)) {
884 throw new Error(
885 `Third-party license generation found a duplicate ${entry.kind} notice for ${entry.name}${entry.version ? `@${entry.version}` : ""}.`,
886 );
887 }
888 identities.add(identity);
889 }
890}
891
892export async function generateThirdPartyLicenseManifest(input: {
893 readonly configFile?: string | URL;
894 readonly packageManifests: ReadonlyArray<ThirdPartyLicensePackageManifest>;
895 readonly bundledModuleIds?: ReadonlyArray<string>;
896 readonly bundleName?: string;
897 readonly allowMissingGeneratedNotices?: boolean;
898}): Promise<ThirdPartyLicenseManifest> {
899 const [{ config, directory }, collection] = await Promise.all([
900 readConfig(input.configFile),
901 collectProductionDependencyPackages(input.packageManifests),
902 ]);
903 if (!(input.allowMissingGeneratedNotices ?? false)) {
904 await syncConfiguredGeneratedNotices(config, directory);
905 }
906 if (input.bundledModuleIds && input.bundleName) {
907 await addBundledModulePackages(collection, input.bundledModuleIds, input.bundleName);
908 }
909
910 const packageNotices = new Map<string, Promise<string | null>>();
911 const repositoryNotices = await collectRepositoryNotices(collection, packageNotices);
912
913 const packageEntryResults = await Promise.allSettled(
914 [...collection.byIdentity.values()].map((collected) =>
915 packageEntry(
916 collected,
917 config,
918 directory,
919 packageNotices,
920 repositoryNotices,
921 input.allowMissingGeneratedNotices ?? false,
922 ),
923 ),
924 );
925 const failures = packageEntryResults.flatMap((result) =>
926 result.status === "rejected"
927 ? [result.reason instanceof Error ? result.reason.message : String(result.reason)]
928 : [],
929 );
930 if (failures.length > 0) {
931 throw new Error(
932 `Third-party license generation found ${String(failures.length)} invalid package notice${failures.length === 1 ? "" : "s"}:\n${failures.map((failure) => `- ${failure}`).join("\n")}`,
933 );
934 }
935 const packageEntries = packageEntryResults.flatMap((result) =>
936 result.status === "fulfilled" && result.value ? [result.value] : [],
937 );
938 const includedBundles = new Set(input.packageManifests.map((manifest) => manifest.bundle));
939 if (input.bundleName) includedBundles.add(input.bundleName);
940 const manualEntries = await customEntries(
941 config,
942 directory,
943 includedBundles,
944 input.allowMissingGeneratedNotices ?? false,
945 );
946 const entries = [...packageEntries, ...manualEntries].sort(entrySort);
947 assertUniqueEntries(entries);
948 return {
949 schemaVersion: 1,
950 entries,
951 };
952}
953
954function moduleIdsFromBundle(bundle: unknown): ReadonlyArray<string> {
955 const ids = new Set<string>();
956 if (!isRecord(bundle)) return [];
957 for (const output of Object.values(bundle)) {
958 if (!isRecord(output) || output.type !== "chunk" || !isRecord(output.modules)) continue;
959 for (const id of Object.keys(output.modules)) ids.add(id);
960 }
961 return [...ids];
962}
963
964function serializeManifest(manifest: ThirdPartyLicenseManifest): string {
965 return `${JSON.stringify(manifest)}\n`;
966}
967
968export function thirdPartyLicensesPlugin(options: ThirdPartyLicensesPluginOptions): Plugin {
969 return {
970 name: "t3code:third-party-licenses",
971 configureServer(server) {
972 let manifestPromise: Promise<ThirdPartyLicenseManifest> | null = null;
973 server.middlewares.use((request, response, next) => {
974 if (request.url?.split("?", 1)[0] !== `/${THIRD_PARTY_LICENSES_FILE_NAME}`) {
975 next();
976 return;
977 }
978 manifestPromise ??= generateThirdPartyLicenseManifest({
979 packageManifests: options.packageManifests,
980 bundleName: options.bundleName,
981 allowMissingGeneratedNotices: true,
982 ...(options.configFile !== undefined ? { configFile: options.configFile } : {}),
983 }).catch((error: unknown) => {
984 manifestPromise = null;
985 throw error;
986 });
987 void manifestPromise.then(
988 (manifest) => {
989 response.statusCode = 200;
990 response.setHeader("Content-Type", "application/json; charset=utf-8");
991 response.setHeader("Cache-Control", "no-store");
992 response.end(serializeManifest(manifest));
993 },
994 (error: unknown) => {
995 next(error instanceof Error ? error : new Error(String(error)));
996 },
997 );
998 });
999 },
1000 async generateBundle(_outputOptions, bundle) {
1001 const manifest = await generateThirdPartyLicenseManifest({
1002 packageManifests: options.packageManifests,
1003 bundledModuleIds: moduleIdsFromBundle(bundle),
1004 bundleName: options.bundleName,
1005 ...(options.configFile !== undefined ? { configFile: options.configFile } : {}),
1006 });
1007 this.emitFile({
1008 type: "asset",
1009 fileName: THIRD_PARTY_LICENSES_FILE_NAME,
1010 source: serializeManifest(manifest),
1011 });
1012 },
1013 };
1014}
scripts/sync-third-party-license-notices.ts created+9
......@@ -0,0 +1,9 @@
1// @effect-diagnostics nodeBuiltinImport:off - This is a build-time filesystem script.
2
3import * as NodePath from "node:path";
4
5import { syncThirdPartyLicenseNotices } from "./lib/third-party-licenses.ts";
6
7const configFile = NodePath.resolve("third-party-licenses.config.json");
8
9await syncThirdPartyLicenseNotices(configFile);
third-party-licenses.config.json created+450
......@@ -0,0 +1,450 @@
1{
2 "customNotices": [
3 {
4 "bundles": ["assets", "desktop", "web"],
5 "license": "CC0-1.0",
6 "name": "Folder_Whoosh.wav by BaggoNotes",
7 "sourceUrl": "https://freesound.org/people/BaggoNotes/sounds/704258/",
8 "generatedNotices": [
9 {
10 "licenseId": "CC0-1.0",
11 "preamble": [
12 "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."
13 ]
14 }
15 ]
16 },
17 {
18 "bundles": ["assets", "desktop", "web"],
19 "license": "CC0-1.0",
20 "name": "Contarex camera shutter.wav by Tonik1105",
21 "sourceUrl": "https://freesound.org/people/Tonik1105/sounds/520684/",
22 "generatedNotices": [
23 {
24 "licenseId": "CC0-1.0",
25 "preamble": [
26 "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."
27 ]
28 }
29 ]
30 },
31 {
32 "bundles": ["device-tools"],
33 "includeInBundles": ["mobile", "web"],
34 "license": "MIT",
35 "name": "agent-device",
36 "sourceUrl": "https://github.com/callstack/agent-device",
37 "version": "0.20.10",
38 "generatedNotices": [
39 {
40 "licenseId": "MIT",
41 "copyrights": ["Copyright (c) 2026 Callstack"]
42 }
43 ]
44 },
45 {
46 "bundles": ["device-tools"],
47 "includeInBundles": ["mobile", "web"],
48 "license": "MIT AND Apache-2.0 AND BSD-3-Clause",
49 "name": "expo-device-hub",
50 "sourceUrl": "https://github.com/expo/expo-device-hub",
51 "version": "0.9.0",
52 "generatedNotices": [
53 {
54 "licenseId": "MIT",
55 "copyrights": ["Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)"]
56 },
57 {
58 "licenseId": "Apache-2.0",
59 "preamble": [
60 "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."
61 ]
62 },
63 {
64 "licenseId": "BSD-3-Clause",
65 "copyrights": ["Copyright (c) 2011, The WebRTC project authors. All rights reserved."]
66 }
67 ]
68 },
69 {
70 "bundles": ["android", "assets", "mobile", "web"],
71 "license": "MIT",
72 "name": "libghostty-vt",
73 "noticeFile": "native/libghostty-vt/LICENSE",
74 "sourceUrl": "https://github.com/ghostty-org/ghostty"
75 },
76 {
77 "bundles": ["assets", "web"],
78 "license": "MIT",
79 "name": "Symbols Nerd Font Mono",
80 "noticeFile": "apps/web/src/terminal/ghostty/fonts/LICENSE",
81 "sourceUrl": "https://github.com/ryanoasis/nerd-fonts"
82 },
83 {
84 "bundles": ["ios", "mobile"],
85 "license": "MIT",
86 "name": "GhosttyKit",
87 "sourceUrl": "https://github.com/Yash-Singh1/ghostty/tree/t3code/custom-io-ordered-feed",
88 "generatedNotices": [
89 {
90 "licenseId": "MIT",
91 "copyrights": ["Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors"],
92 "preamble": [
93 "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"
94 ]
95 }
96 ]
97 },
98 {
99 "bundles": ["android", "assets", "mobile"],
100 "license": "Apache-2.0",
101 "name": "MesloLGS NF",
102 "sourceUrl": "https://github.com/romkatv/powerlevel10k-media",
103 "generatedNotices": [
104 {
105 "licenseId": "Apache-2.0",
106 "copyrights": ["Copyright 2009, 2010, 2013 André Berg"]
107 }
108 ]
109 },
110 {
111 "bundles": ["mobile"],
112 "license": "MIT",
113 "name": "react-native-uitextview",
114 "noticeFile": "apps/mobile/modules/t3-markdown-text/LICENSE",
115 "sourceUrl": "https://github.com/bluesky-social/react-native-uitextview",
116 "version": "2.2.0"
117 },
118 {
119 "bundles": ["mobile"],
120 "license": "MIT",
121 "name": "Expo text editor implementation",
122 "noticeFile": "apps/mobile/modules/t3-composer-editor/LICENSE",
123 "sourceUrl": "https://github.com/expo/expo"
124 }
125 ],
126 "packageOverrides": [
127 {
128 "license": "MIT",
129 "name": "@react-grab/cli",
130 "sourceUrl": "https://github.com/aidenybai/react-grab/tree/main/packages/cli",
131 "generatedNotice": {
132 "licenseId": "MIT",
133 "copyrights": ["Copyright (c) 2025 Aiden Bai"]
134 }
135 },
136 {
137 "name": "glob-to-regexp",
138 "generatedNotice": {
139 "licenseId": "BSD-2-Clause",
140 "copyrights": ["Copyright (c) 2013, Nick Fitzgerald"]
141 }
142 },
143 {
144 "name": "kubernetes-types",
145 "generatedNotice": {
146 "licenseId": "Apache-2.0"
147 }
148 },
149 {
150 "name": "@msgpackr-extract/msgpackr-extract-linux-x64",
151 "generatedNotice": {
152 "licenseId": "MIT",
153 "copyrights": ["Copyright (c) 2020 Kris Zyp"]
154 }
155 },
156 {
157 "name": "isarray",
158 "generatedNotice": {
159 "licenseId": "MIT",
160 "copyrights": ["Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>"]
161 }
162 },
163 {
164 "name": "keyv",
165 "generatedNotice": {
166 "licenseId": "MIT",
167 "copyrights": ["Copyright (c) 2017-2021 Luke Childs", "Copyright (c) 2021-2022 Jared Wray"]
168 }
169 },
170 {
171 "license": "MIT",
172 "name": "type-fest",
173 "generatedNotice": {
174 "licenseId": "MIT",
175 "copyrights": [
176 "Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)"
177 ]
178 }
179 },
180 {
181 "name": "lazy-val",
182 "generatedNotice": {
183 "licenseId": "MIT",
184 "copyrights": ["Copyright (c) Vladimir Krivosheev"]
185 }
186 },
187 {
188 "name": "@pierre/theming",
189 "generatedNotice": {
190 "licenseId": "Apache-2.0",
191 "copyrights": ["Copyright 2025 Pierre Computer Company"]
192 }
193 },
194 {
195 "name": "lru_map",
196 "generatedNotice": {
197 "licenseId": "MIT",
198 "copyrights": ["Copyright (c) 2010-2016 Rasmus Andersson <https://rsms.me/>"]
199 }
200 },
201 {
202 "name": "@electron-internal/extract-zip",
203 "generatedNotice": {
204 "licenseId": "BSD-2-Clause",
205 "copyrights": ["Copyright (c) 2026 Samuel Attard and Electron contributors"]
206 }
207 },
208 {
209 "repositoryUrl": "https://github.com/xa11y/xa11y",
210 "generatedNotice": {
211 "licenseId": "MIT",
212 "copyrights": ["Copyright (c) 2025 Stephen Crowe"]
213 }
214 },
215 {
216 "license": "MIT",
217 "name": "map-stream",
218 "generatedNotice": {
219 "licenseId": "MIT",
220 "copyrights": ["Copyright (c) 2011 Dominic Tarr"]
221 }
222 },
223 {
224 "license": "Apache-2.0",
225 "name": "jsbi"
226 },
227 {
228 "name": "@yuuang/ffi-rs-android-arm64",
229 "generatedNotice": {
230 "licenseId": "MIT",
231 "copyrights": ["Copyright (c) 2019 zhangyuang"]
232 }
233 },
234 {
235 "name": "@yuuang/ffi-rs-darwin-arm64",
236 "generatedNotice": {
237 "licenseId": "MIT",
238 "copyrights": ["Copyright (c) 2019 zhangyuang"]
239 }
240 },
241 {
242 "name": "@yuuang/ffi-rs-darwin-x64",
243 "generatedNotice": {
244 "licenseId": "MIT",
245 "copyrights": ["Copyright (c) 2019 zhangyuang"]
246 }
247 },
248 {
249 "name": "@yuuang/ffi-rs-linux-arm-gnueabihf",
250 "generatedNotice": {
251 "licenseId": "MIT",
252 "copyrights": ["Copyright (c) 2019 zhangyuang"]
253 }
254 },
255 {
256 "name": "@yuuang/ffi-rs-linux-arm64-gnu",
257 "generatedNotice": {
258 "licenseId": "MIT",
259 "copyrights": ["Copyright (c) 2019 zhangyuang"]
260 }
261 },
262 {
263 "name": "@yuuang/ffi-rs-linux-arm64-musl",
264 "generatedNotice": {
265 "licenseId": "MIT",
266 "copyrights": ["Copyright (c) 2019 zhangyuang"]
267 }
268 },
269 {
270 "name": "@yuuang/ffi-rs-linux-x64-gnu",
271 "generatedNotice": {
272 "licenseId": "MIT",
273 "copyrights": ["Copyright (c) 2019 zhangyuang"]
274 }
275 },
276 {
277 "name": "@yuuang/ffi-rs-linux-x64-musl",
278 "generatedNotice": {
279 "licenseId": "MIT",
280 "copyrights": ["Copyright (c) 2019 zhangyuang"]
281 }
282 },
283 {
284 "name": "@yuuang/ffi-rs-win32-arm64-msvc",
285 "generatedNotice": {
286 "licenseId": "MIT",
287 "copyrights": ["Copyright (c) 2019 zhangyuang"]
288 }
289 },
290 {
291 "name": "@yuuang/ffi-rs-win32-ia32-msvc",
292 "generatedNotice": {
293 "licenseId": "MIT",
294 "copyrights": ["Copyright (c) 2019 zhangyuang"]
295 }
296 },
297 {
298 "name": "@yuuang/ffi-rs-win32-x64-msvc",
299 "generatedNotice": {
300 "licenseId": "MIT",
301 "copyrights": ["Copyright (c) 2019 zhangyuang"]
302 }
303 },
304 {
305 "repositoryUrl": "https://github.com/facebook/metro",
306 "generatedNotice": {
307 "licenseId": "MIT",
308 "copyrights": ["Copyright (c) Meta Platforms, Inc. and affiliates."]
309 }
310 },
311 {
312 "repositoryUrl": "https://github.com/react/metro",
313 "generatedNotice": {
314 "licenseId": "MIT",
315 "copyrights": ["Copyright (c) Meta Platforms, Inc. and affiliates."]
316 }
317 },
318 {
319 "name": "@react-native-ai/apple",
320 "sourceUrl": "https://github.com/callstackincubator/ai",
321 "generatedNotice": {
322 "licenseId": "MIT",
323 "copyrights": [
324 "Copyright (c) 2024-2025 Szymon Rybczak",
325 "Copyright (c) 2025-present Callstack"
326 ]
327 }
328 },
329 {
330 "name": "react-remove-scroll-bar",
331 "generatedNotice": {
332 "licenseId": "MIT",
333 "copyrights": ["Copyright (c) Anton Korzunov <thekashey@gmail.com>"]
334 }
335 },
336 {
337 "name": "standard-navigation",
338 "generatedNotice": {
339 "licenseId": "MIT",
340 "copyrights": ["Copyright (c) 2026 React Navigation Contributors"]
341 }
342 },
343 {
344 "name": "@expo/sdk-runtime-versions",
345 "noticeFile": "apps/mobile/modules/t3-composer-editor/LICENSE"
346 },
347 {
348 "name": "@expo/ws-tunnel",
349 "noticeFile": "apps/mobile/modules/t3-composer-editor/LICENSE"
350 },
351 {
352 "name": "stream-buffers",
353 "generatedNotice": {
354 "licenseId": "Unlicense"
355 }
356 },
357 {
358 "name": "bplist-parser",
359 "generatedNotice": {
360 "licenseId": "MIT",
361 "copyrights": ["Copyright (c) 2012 Near Infinity Corporation"]
362 }
363 },
364 {
365 "name": "@expo/devcert",
366 "generatedNotice": {
367 "licenseId": "MIT",
368 "copyrights": ["Copyright (c) Dave Wasmer"]
369 }
370 },
371 {
372 "name": "jimp-compact",
373 "generatedNotice": {
374 "licenseId": "MIT",
375 "copyrights": ["Copyright (c) 2018 Oliver Moran"]
376 }
377 },
378 {
379 "name": "fb-watchman",
380 "generatedNotice": {
381 "licenseId": "Apache-2.0"
382 }
383 },
384 {
385 "name": "bser",
386 "generatedNotice": {
387 "licenseId": "Apache-2.0"
388 }
389 },
390 {
391 "name": "@expo/xcpretty",
392 "generatedNotice": {
393 "licenseId": "BSD-3-Clause",
394 "copyrights": ["Copyright (c) 2016-present, 650 Industries, Inc. (Expo)"]
395 }
396 },
397 {
398 "license": "MIT",
399 "name": "fb-dotslash",
400 "generatedNotice": {
401 "licenseId": "MIT",
402 "copyrights": ["Copyright (c) Meta Platforms, Inc. and affiliates."]
403 }
404 },
405 {
406 "name": "structured-headers",
407 "generatedNotice": {
408 "licenseId": "MIT",
409 "copyrights": ["Copyright (c) 2018 Evert Pot"]
410 }
411 },
412 {
413 "name": "badgin",
414 "generatedNotice": {
415 "licenseId": "MIT",
416 "copyrights": ["Copyright (c) Julian Hundeloh and Tinycon contributors"],
417 "preamble": ["badgin is a refactored fork of Tinycon."]
418 }
419 },
420 {
421 "name": "react-native-nitro-modules",
422 "generatedNotice": {
423 "licenseId": "MIT",
424 "copyrights": ["Copyright (c) 2024 Marc Rousavy"]
425 }
426 },
427 {
428 "name": "boolbase",
429 "generatedNotice": {
430 "licenseId": "ISC",
431 "copyrights": ["Copyright (c) 2014-2015, Felix Boehm <me@feedic.com>"]
432 }
433 },
434 {
435 "repositoryUrl": "https://github.com/dmtrKovalenko/fff",
436 "generatedNotice": {
437 "licenseId": "MIT",
438 "copyrights": ["Copyright (c) 2025 Dmitriy Kovalenko"]
439 }
440 },
441 {
442 "name": "@opencode-ai/sdk",
443 "sourceUrl": "https://github.com/anomalyco/opencode/tree/dev/packages/sdk/js",
444 "generatedNotice": {
445 "licenseId": "MIT",
446 "copyrights": ["Copyright (c) 2025 opencode"]
447 }
448 }
449 ]
450}