authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-23 11:42:35+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 09:42:35+00:00
log1c9a6de26eb41f77dbf2f6039427f50f2e9f8b39
tree1721e9037ccd3b589af0710cd32e1c4281b6fc76
parentfbd77420f27c1322d49726dd927861b8b5903494
signature Signed by PGP key B5690EEEBB952194

Add shared t3.json project configuration support (#4317)


22 files changed, 697 insertions(+), 10 deletions(-)

apps/marketing/package.json+1
......@@ -10,6 +10,7 @@
1010 "typecheck": "astro check"
1111 },
1212 "dependencies": {
13 "@t3tools/shared": "workspace:*",
1314 "astro": "^7.0.3"
1415 },
1516 "devDependencies": {
apps/marketing/src/pages/schema/t3.json.ts created+10
......@@ -0,0 +1,10 @@
1import type { APIRoute } from "astro";
2
3import { buildT3ProjectFileJsonSchema } from "@t3tools/shared/t3ProjectFile";
4
5// Rendered at build time; published at https://t3.codes/schema/t3.json so
6// t3.json files can reference it via "$schema" for editor/LSP support.
7export const GET: APIRoute = () =>
8 new Response(`${JSON.stringify(buildT3ProjectFileJsonSchema(), null, 2)}\n`, {
9 headers: { "Content-Type": "application/json" },
10 });
apps/marketing/vercel.ts+1-1
......@@ -1,7 +1,7 @@
11import type { VercelConfig } from "@vercel/config/v1";
22
33export const config: VercelConfig = {
4 installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing'",
4 installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'",
55 buildCommand: "vp run --filter @t3tools/marketing build",
66 outputDirectory: "dist",
77};
apps/server/src/assets/AssetAccess.test.ts+5-1
......@@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError";
1111import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
1212import * as ServerConfig from "../config.ts";
1313import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
14import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
1415import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
1516import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
1617
......@@ -20,7 +21,10 @@ const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
2021const testLayer = Layer.mergeAll(
2122 configLayer,
2223 WorkspacePaths.layer,
23 ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)),
24 ProjectFaviconResolver.layer.pipe(
25 Layer.provide(WorkspacePaths.layer),
26 Layer.provide(T3ProjectFileLoader.layer),
27 ),
2428 ServerSecretStore.layer.pipe(Layer.provide(configLayer)),
2529).pipe(Layer.provideMerge(NodeServices.layer));
2630
apps/server/src/project/ProjectFaviconResolver.test.ts+65-2
......@@ -8,9 +8,15 @@ import * as PlatformError from "effect/PlatformError";
88
99import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
1010import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
11import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
1112
1213const TestLayer = Layer.empty.pipe(
13 Layer.provideMerge(ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer))),
14 Layer.provideMerge(
15 ProjectFaviconResolver.layer.pipe(
16 Layer.provide(WorkspacePaths.layer),
17 Layer.provide(T3ProjectFileLoader.layer),
18 ),
19 ),
1420 Layer.provideMerge(NodeServices.layer),
1521);
1622
......@@ -37,7 +43,7 @@ const writeTextFile = Effect.fn("writeTextFile")(function* (
3743
3844const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>
3945 ProjectFaviconResolver.make.pipe(
40 Effect.provide(WorkspacePaths.layer),
46 Effect.provide([WorkspacePaths.layer, T3ProjectFileLoader.layer]),
4147 Effect.provideService(FileSystem.FileSystem, fileSystem),
4248 );
4349
......@@ -56,6 +62,63 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
5662 }),
5763 );
5864
65 it.effect("prefers a t3.json iconPath over well-known files", () =>
66 Effect.gen(function* () {
67 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
68 const cwd = yield* makeTempDir;
69 yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/mark.svg" }');
70 yield* writeTextFile(cwd, "brand/mark.svg", "<svg>mark</svg>");
71 yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
72
73 const resolved = yield* resolver.resolvePath(cwd);
74
75 expect(resolved).not.toBeNull();
76 expect(resolved).toContain("brand/mark.svg");
77 }),
78 );
79
80 it.effect("falls back to well-known files when the t3.json iconPath does not exist", () =>
81 Effect.gen(function* () {
82 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
83 const cwd = yield* makeTempDir;
84 yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/missing.svg" }');
85 yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
86
87 const resolved = yield* resolver.resolvePath(cwd);
88
89 expect(resolved).not.toBeNull();
90 expect(resolved).toContain("favicon.svg");
91 }),
92 );
93
94 it.effect("ignores invalid t3.json files", () =>
95 Effect.gen(function* () {
96 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
97 const cwd = yield* makeTempDir;
98 yield* writeTextFile(cwd, "t3.json", "{ not json");
99 yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
100
101 const resolved = yield* resolver.resolvePath(cwd);
102
103 expect(resolved).not.toBeNull();
104 expect(resolved).toContain("favicon.svg");
105 }),
106 );
107
108 it.effect("does not resolve a t3.json iconPath outside the workspace root", () =>
109 Effect.gen(function* () {
110 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
111 const parent = yield* makeTempDir;
112 const cwd = `${parent}/app`;
113 yield* writeTextFile(parent, "secret.svg", "<svg>secret</svg>");
114 yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "../secret.svg" }');
115
116 const resolved = yield* resolver.resolvePath(cwd);
117
118 expect(resolved).toBeNull();
119 }),
120 );
121
59122 it.effect("resolves icon hrefs from project source files", () =>
60123 Effect.gen(function* () {
61124 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
apps/server/src/project/ProjectFaviconResolver.ts+11
......@@ -16,6 +16,7 @@ import * as PlatformError from "effect/PlatformError";
1616import * as Schema from "effect/Schema";
1717
1818import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
19import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
1920
2021// Well-known favicon paths checked in order.
2122const FAVICON_CANDIDATES = [
......@@ -117,6 +118,7 @@ export const make = Effect.gen(function* () {
117118 const fileSystem = yield* FileSystem.FileSystem;
118119 const path = yield* Path.Path;
119120 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
121 const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
120122
121123 const resolveIconHref = (href: string): ReadonlyArray<string> => {
122124 const clean = href.replace(/^\//, "");
......@@ -177,6 +179,15 @@ export const make = Effect.gen(function* () {
177179 }),
178180 ),
179181 );
182 // A t3.json iconPath takes precedence over the well-known locations.
183 const projectFile = yield* projectFileLoader.load(projectCwd);
184 if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) {
185 const existing = yield* findExistingFile(projectCwd, [projectFile.value.iconPath]);
186 if (existing) {
187 return existing;
188 }
189 }
190
180191 for (const candidate of FAVICON_CANDIDATES) {
181192 const existing = yield* findExistingFile(projectCwd, [candidate]);
182193 if (existing) {
apps/server/src/project/T3ProjectFileLoader.test.ts created+89
......@@ -0,0 +1,89 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, describe, expect } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8
9import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
10
11const TestLayer = Layer.empty.pipe(
12 Layer.provideMerge(T3ProjectFileLoader.layer),
13 Layer.provideMerge(NodeServices.layer),
14);
15
16const makeTempDir = Effect.gen(function* () {
17 const fileSystem = yield* FileSystem.FileSystem;
18 return yield* fileSystem.makeTempDirectoryScoped({
19 prefix: "t3code-project-file-",
20 });
21});
22
23const writeProjectFile = Effect.fn("writeProjectFile")(function* (cwd: string, contents: string) {
24 const fileSystem = yield* FileSystem.FileSystem;
25 const path = yield* Path.Path;
26 yield* fileSystem.writeFileString(path.join(cwd, "t3.json"), contents).pipe(Effect.orDie);
27});
28
29it.layer(TestLayer)("T3ProjectFileLoader", (it) => {
30 describe("load", () => {
31 it.effect("loads and decodes a valid t3.json", () =>
32 Effect.gen(function* () {
33 const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
34 const cwd = yield* makeTempDir;
35 yield* writeProjectFile(
36 cwd,
37 `{
38 // JSONC is tolerated
39 "iconPath": "assets/logo.svg",
40 "scripts": [{ "name": "Dev", "command": "pnpm dev" }],
41 }`,
42 );
43
44 const loaded = yield* loader.load(cwd);
45
46 expect(Option.isSome(loaded)).toBe(true);
47 if (Option.isSome(loaded)) {
48 expect(loaded.value.iconPath).toBe("assets/logo.svg");
49 expect(loaded.value.scripts).toEqual([{ name: "Dev", command: "pnpm dev" }]);
50 }
51 }),
52 );
53
54 it.effect("returns none when t3.json is missing", () =>
55 Effect.gen(function* () {
56 const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
57 const cwd = yield* makeTempDir;
58
59 const loaded = yield* loader.load(cwd);
60
61 expect(Option.isNone(loaded)).toBe(true);
62 }),
63 );
64
65 it.effect("returns none for malformed JSON without failing", () =>
66 Effect.gen(function* () {
67 const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
68 const cwd = yield* makeTempDir;
69 yield* writeProjectFile(cwd, "{ not json");
70
71 const loaded = yield* loader.load(cwd);
72
73 expect(Option.isNone(loaded)).toBe(true);
74 }),
75 );
76
77 it.effect("returns none for schema-invalid files without failing", () =>
78 Effect.gen(function* () {
79 const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
80 const cwd = yield* makeTempDir;
81 yield* writeProjectFile(cwd, '{ "scripts": [{ "name": "Dev" }] }');
82
83 const loaded = yield* loader.load(cwd);
84
85 expect(Option.isNone(loaded)).toBe(true);
86 }),
87 );
88 });
89});
apps/server/src/project/T3ProjectFileLoader.ts created+108
......@@ -0,0 +1,108 @@
1/**
2 * T3ProjectFileLoader - Effect service that loads the checked-in `t3.json`
3 * project file from a workspace root.
4 *
5 * Loading is best-effort: a missing file resolves to `Option.none`, and
6 * unreadable or invalid files are logged and treated as absent so callers
7 * can fall back to their defaults.
8 *
9 * @module T3ProjectFileLoader
10 */
11import * as Context from "effect/Context";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Path from "effect/Path";
17import * as Schema from "effect/Schema";
18
19import { T3_PROJECT_FILE_NAME, type T3ProjectFile } from "@t3tools/contracts";
20import { T3ProjectFileFromJson } from "@t3tools/shared/t3ProjectFile";
21
22const decodeT3ProjectFileJson = Schema.decodeEffect(T3ProjectFileFromJson);
23
24export class T3ProjectFileLoadError extends Schema.TaggedErrorClass<T3ProjectFileLoadError>()(
25 "T3ProjectFileLoadError",
26 {
27 operation: Schema.Literals(["read", "decode"]),
28 workspaceRoot: Schema.String,
29 filePath: Schema.String,
30 cause: Schema.Defect(),
31 },
32) {
33 override get message(): string {
34 return `Failed to ${this.operation} ${T3_PROJECT_FILE_NAME} at ${this.filePath}.`;
35 }
36}
37
38/** Service tag for t3.json project file loading. */
39export class T3ProjectFileLoader extends Context.Service<
40 T3ProjectFileLoader,
41 {
42 /**
43 * Load and decode `t3.json` at the workspace root.
44 *
45 * Never fails: missing, unreadable, or invalid files resolve to
46 * `Option.none` (invalid files are logged as warnings).
47 */
48 readonly load: (workspaceRoot: string) => Effect.Effect<Option.Option<T3ProjectFile>>;
49 }
50>()("t3/project/T3ProjectFileLoader") {}
51
52const logT3ProjectFileLoadError = (error: T3ProjectFileLoadError) =>
53 Effect.logWarning(error).pipe(
54 Effect.annotateLogs({
55 operation: error.operation,
56 workspaceRoot: error.workspaceRoot,
57 filePath: error.filePath,
58 errorTag: error._tag,
59 }),
60 );
61
62export const make = Effect.gen(function* () {
63 const fileSystem = yield* FileSystem.FileSystem;
64 const path = yield* Path.Path;
65
66 const load: T3ProjectFileLoader["Service"]["load"] = Effect.fn("T3ProjectFileLoader.load")(
67 function* (workspaceRoot) {
68 const filePath = path.join(workspaceRoot, T3_PROJECT_FILE_NAME);
69 const raw = yield* fileSystem.readFileString(filePath).pipe(
70 Effect.map(Option.some),
71 Effect.catchTags({
72 PlatformError: (error) =>
73 error.reason._tag === "NotFound"
74 ? Effect.succeed(Option.none<string>())
75 : logT3ProjectFileLoadError(
76 new T3ProjectFileLoadError({
77 operation: "read",
78 workspaceRoot,
79 filePath,
80 cause: error,
81 }),
82 ).pipe(Effect.as(Option.none<string>())),
83 }),
84 );
85 if (Option.isNone(raw)) {
86 return Option.none<T3ProjectFile>();
87 }
88 return yield* decodeT3ProjectFileJson(raw.value).pipe(
89 Effect.map(Option.some),
90 Effect.catchTags({
91 SchemaError: (error) =>
92 logT3ProjectFileLoadError(
93 new T3ProjectFileLoadError({
94 operation: "decode",
95 workspaceRoot,
96 filePath,
97 cause: error,
98 }),
99 ).pipe(Effect.as(Option.none<T3ProjectFile>())),
100 }),
101 );
102 },
103 );
104
105 return T3ProjectFileLoader.of({ load });
106});
107
108export const layer = Layer.effect(T3ProjectFileLoader, make);
apps/server/src/server.test.ts+5-1
......@@ -92,6 +92,7 @@ import * as PreviewManager from "./preview/Manager.ts";
9292import * as PortScanner from "./preview/PortScanner.ts";
9393import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
9494import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
95import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
9596import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
9697import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
9798import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
......@@ -501,7 +502,10 @@ const buildAppUnderTest = (options?: {
501502 Layer.provide(WorkspacePaths.layer),
502503 Layer.provide(workspaceEntriesLayer),
503504 ),
504 ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)),
505 ProjectFaviconResolver.layer.pipe(
506 Layer.provide(WorkspacePaths.layer),
507 Layer.provide(T3ProjectFileLoader.layer),
508 ),
505509 );
506510 const gitWorkflowLayer = GitWorkflowService.layer.pipe(
507511 Layer.provideMerge(vcsDriverRegistryLayer),
apps/server/src/server.ts+2
......@@ -54,6 +54,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
5454import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
5555import * as ServerSettings from "./serverSettings.ts";
5656import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
57import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
5758import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
5859import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
5960import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
......@@ -265,6 +266,7 @@ const WorkspaceLayerLive = Layer.mergeAll(
265266
266267const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe(
267268 Layer.provide(WorkspacePaths.layer),
269 Layer.provide(T3ProjectFileLoader.layer),
268270);
269271
270272const AuthLayerLive = EnvironmentAuth.layer.pipe(
apps/web/src/components/ProjectScriptsControl.tsx+98-1
......@@ -2,6 +2,7 @@ import type {
22 ProjectScript,
33 ProjectScriptIcon,
44 ResolvedKeybindingsConfig,
5 T3ProjectFileScript,
56} from "@t3tools/contracts";
67import {
78 isAtomCommandInterrupted,
......@@ -11,6 +12,7 @@ import {
1112import {
1213 BugIcon,
1314 ChevronDownIcon,
15 DownloadIcon,
1416 FlaskConicalIcon,
1517 HammerIcon,
1618 ListChecksIcon,
......@@ -54,7 +56,16 @@ import {
5456import { Group, GroupSeparator } from "./ui/group";
5557import { Input } from "./ui/input";
5658import { Label } from "./ui/label";
57import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
59import {
60 Menu,
61 MenuGroup,
62 MenuGroupLabel,
63 MenuItem,
64 MenuPopup,
65 MenuSeparator,
66 MenuShortcut,
67 MenuTrigger,
68} from "./ui/menu";
5869import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
5970import { Switch } from "./ui/switch";
6071import { Textarea } from "./ui/textarea";
......@@ -98,8 +109,12 @@ export interface NewProjectScriptInput {
98109
99110export type ProjectScriptActionResult = AtomCommandResult<void, unknown>;
100111
112const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];
113
101114interface ProjectScriptsControlProps {
102115 scripts: ReadonlyArray<ProjectScript>;
116 /** Scripts declared in the project's checked-in t3.json, offered for import. */
117 fileScripts?: ReadonlyArray<T3ProjectFileScript>;
103118 keybindings: ResolvedKeybindingsConfig;
104119 preferredScriptId?: string | null;
105120 onRunScript: (script: ProjectScript) => void;
......@@ -113,6 +128,7 @@ interface ProjectScriptsControlProps {
113128
114129export default function ProjectScriptsControl({
115130 scripts,
131 fileScripts = NO_FILE_SCRIPTS,
116132 keybindings,
117133 preferredScriptId = null,
118134 onRunScript,
......@@ -141,6 +157,18 @@ export default function ProjectScriptsControl({
141157 }
142158 return primaryProjectScript(scripts);
143159 }, [preferredScriptId, scripts]);
160 const importableScripts = useMemo(
161 () =>
162 fileScripts.filter(
163 (fileScript) =>
164 !scripts.some(
165 (script) =>
166 script.command === fileScript.command ||
167 script.name.toLowerCase() === fileScript.name.toLowerCase(),
168 ),
169 ),
170 [fileScripts, scripts],
171 );
144172 const isEditing = editingScriptId !== null;
145173 const dropdownItemClassName =
146174 "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground";
......@@ -247,6 +275,57 @@ export default function ProjectScriptsControl({
247275 void onDeleteScript(editingScriptId);
248276 }, [editingScriptId, onDeleteScript]);
249277
278 const importFileScript = async (fileScript: T3ProjectFileScript) => {
279 const payload: NewProjectScriptInput = {
280 name: fileScript.name,
281 command: fileScript.command,
282 icon: fileScript.icon ?? "play",
283 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
284 keybinding: null,
285 previewUrl: fileScript.previewUrl ?? null,
286 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
287 };
288 const result = await onAddScript(payload);
289 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
290 // Surface the failure through the regular add dialog, prefilled so the
291 // user can adjust and retry.
292 const error = squashAtomCommandFailure(result);
293 setEditingScriptId(null);
294 setName(payload.name);
295 setCommand(payload.command);
296 setIcon(payload.icon);
297 setIconPickerOpen(false);
298 setRunOnWorktreeCreate(payload.runOnWorktreeCreate);
299 setKeybinding("");
300 setPreviewUrl(payload.previewUrl ?? "");
301 setAutoOpenPreview(payload.autoOpenPreview);
302 setValidationError(error instanceof Error ? error.message : "Failed to import action.");
303 setDialogOpen(true);
304 }
305 };
306
307 const importMenuItems = importableScripts.length > 0 && (
308 <>
309 {primaryScript && <MenuSeparator />}
310 <MenuGroup>
311 <MenuGroupLabel>From t3.json</MenuGroupLabel>
312 {importableScripts.map((fileScript) => (
313 <MenuItem
314 key={`${fileScript.name} ${fileScript.command}`}
315 className={dropdownItemClassName}
316 onClick={() => void importFileScript(fileScript)}
317 >
318 <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4" />
319 <span className="truncate">{fileScript.name}</span>
320 <MenuShortcut className="ms-auto">
321 <DownloadIcon className="size-3.5" aria-label="Import" />
322 </MenuShortcut>
323 </MenuItem>
324 ))}
325 </MenuGroup>
326 </>
327 );
328
250329 return (
251330 <>
252331 {primaryScript ? (
......@@ -320,6 +399,7 @@ export default function ProjectScriptsControl({
320399 </MenuItem>
321400 );
322401 })}
402 {importMenuItems}
323403 <MenuItem className={dropdownItemClassName} onClick={openAddDialog}>
324404 <PlusIcon className="size-4" />
325405 Add action
......@@ -327,6 +407,23 @@ export default function ProjectScriptsControl({
327407 </MenuPopup>
328408 </Menu>
329409 </Group>
410 ) : importableScripts.length > 0 ? (
411 <Menu highlightItemOnHover={false}>
412 <MenuTrigger render={<Button size="xs" variant="outline" aria-label="Project actions" />}>
413 <PlusIcon className="size-3.5" />
414 <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5">
415 Add action
416 </span>
417 <ChevronDownIcon className="size-3.5" />
418 </MenuTrigger>
419 <MenuPopup align="end">
420 {importMenuItems}
421 <MenuItem className={dropdownItemClassName} onClick={openAddDialog}>
422 <PlusIcon className="size-4" />
423 Add action
424 </MenuItem>
425 </MenuPopup>
426 </Menu>
330427 ) : (
331428 <Tooltip>
332429 <TooltipTrigger
apps/web/src/components/chat/ChatHeader.tsx+6
......@@ -16,6 +16,7 @@ import ProjectScriptsControl, {
1616} from "../ProjectScriptsControl";
1717import { OpenInPicker } from "./OpenInPicker";
1818import { usePrimaryEnvironmentId } from "../../state/environments";
19import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts";
1920import { ProjectFavicon } from "../ProjectFavicon";
2021import { cn } from "~/lib/utils";
2122
......@@ -74,6 +75,10 @@ export const ChatHeader = memo(function ChatHeader({
7475 onDeleteProjectScript,
7576}: ChatHeaderProps) {
7677 const primaryEnvironmentId = usePrimaryEnvironmentId();
78 const fileScripts = useT3ProjectFileScripts(
79 activeThreadEnvironmentId,
80 activeProjectScripts ? activeProjectCwd : null,
81 );
7782 const showOpenInPicker = shouldShowOpenInPicker({
7883 activeProjectName,
7984 activeThreadEnvironmentId,
......@@ -126,6 +131,7 @@ export const ChatHeader = memo(function ChatHeader({
126131 {activeProjectScripts && (
127132 <ProjectScriptsControl
128133 scripts={activeProjectScripts}
134 fileScripts={fileScripts}
129135 keybindings={keybindings}
130136 preferredScriptId={preferredScriptId}
131137 onRunScript={onRunProjectScript}
apps/web/src/hooks/useT3ProjectFileScripts.ts created+34
......@@ -0,0 +1,34 @@
1import {
2 T3_PROJECT_FILE_NAME,
3 type EnvironmentId,
4 type T3ProjectFileScript,
5} from "@t3tools/contracts";
6import { T3ProjectFileFromJson } from "@t3tools/shared/t3ProjectFile";
7import * as Exit from "effect/Exit";
8import * as Schema from "effect/Schema";
9import { useMemo } from "react";
10
11import { useProjectFileQuery } from "~/components/files/projectFilesQueryState";
12
13const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson);
14
15const NO_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];
16
17/**
18 * Scripts declared in the project's checked-in `t3.json`, offered in the
19 * scripts menu for import. Missing, truncated, or invalid files resolve to
20 * an empty list.
21 */
22export function useT3ProjectFileScripts(
23 environmentId: EnvironmentId,
24 cwd: string | null,
25): ReadonlyArray<T3ProjectFileScript> {
26 const query = useProjectFileQuery(environmentId, cwd ?? "", T3_PROJECT_FILE_NAME, cwd !== null);
27 const contents = query.data && !query.data.truncated ? query.data.contents : null;
28 return useMemo(() => {
29 if (contents === null) return NO_SCRIPTS;
30 const decoded = decodeT3ProjectFile(contents);
31 if (Exit.isFailure(decoded)) return NO_SCRIPTS;
32 return decoded.value.scripts ?? NO_SCRIPTS;
33 }, [contents]);
34}
favicon.svg deleted-4
......@@ -1,4 +0,0 @@
1<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
2<path d="M0 10C0 4.47715 4.47715 0 10 0H118C123.523 0 128 4.47715 128 10V118C128 123.523 123.523 128 118 128H10C4.47715 128 0 123.523 0 118V10Z" fill="black"/>
3<path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/>
4</svg>
packages/contracts/src/index.ts+1
......@@ -18,6 +18,7 @@ export * from "./git.ts";
1818export * from "./vcs.ts";
1919export * from "./sourceControl.ts";
2020export * from "./orchestration.ts";
21export * from "./t3ProjectFile.ts";
2122export * from "./editor.ts";
2223export * from "./project.ts";
2324export * from "./filesystem.ts";
packages/contracts/src/t3ProjectFile.test.ts created+55
......@@ -0,0 +1,55 @@
1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";
3
4import { T3ProjectFile } from "./t3ProjectFile.ts";
5
6const decode = Schema.decodeUnknownSync(T3ProjectFile);
7
8describe("T3ProjectFile", () => {
9 it("decodes a full project file", () => {
10 const decoded = decode({
11 $schema: "https://t3.codes/schema/t3.json",
12 iconPath: "assets/logo.svg",
13 scripts: [
14 {
15 name: "Dev",
16 command: "pnpm dev",
17 icon: "play",
18 runOnWorktreeCreate: false,
19 previewUrl: "http://localhost:3000",
20 autoOpenPreview: true,
21 },
22 { name: "Test", command: "pnpm test" },
23 ],
24 });
25
26 expect(decoded.iconPath).toBe("assets/logo.svg");
27 expect(decoded.scripts).toHaveLength(2);
28 expect(decoded.scripts?.[1]).toEqual({ name: "Test", command: "pnpm test" });
29 });
30
31 it("decodes an empty object and ignores unknown fields", () => {
32 expect(decode({})).toEqual({});
33 expect(decode({ futureField: true })).toEqual({});
34 });
35
36 it("trims icon paths and script fields", () => {
37 const decoded = decode({
38 iconPath: " assets/logo.svg ",
39 scripts: [{ name: " Dev ", command: " pnpm dev " }],
40 });
41
42 expect(decoded.iconPath).toBe("assets/logo.svg");
43 expect(decoded.scripts?.[0]).toEqual({ name: "Dev", command: "pnpm dev" });
44 });
45
46 it("rejects scripts without a command", () => {
47 expect(() => decode({ scripts: [{ name: "Dev" }] })).toThrow();
48 });
49
50 it("rejects unknown script icons", () => {
51 expect(() =>
52 decode({ scripts: [{ name: "Dev", command: "pnpm dev", icon: "rocket" }] }),
53 ).toThrow();
54 });
55});
packages/contracts/src/t3ProjectFile.ts created+88
......@@ -0,0 +1,88 @@
1import * as Schema from "effect/Schema";
2import * as SchemaTransformation from "effect/SchemaTransformation";
3
4import { ProjectScriptIcon } from "./orchestration.ts";
5
6/** File name of the checked-in T3 project file, resolved at the workspace root. */
7export const T3_PROJECT_FILE_NAME = "t3.json";
8
9/** Public URL of the published JSON Schema for {@link T3ProjectFile}. */
10export const T3_PROJECT_FILE_SCHEMA_URL = "https://t3.codes/schema/t3.json";
11
12const T3_PROJECT_FILE_PATH_MAX_LENGTH = 512;
13const T3_PROJECT_FILE_MAX_SCRIPTS = 50;
14
15// Annotations go on the encoded (string) side so they survive into the
16// published JSON Schema; decoding still trims and re-validates non-emptiness.
17const trimmedNonEmpty = (annotations: { readonly description: string }, maxLength?: number) => {
18 const annotated = Schema.String.annotate(annotations);
19 const encoded =
20 maxLength === undefined
21 ? annotated.check(Schema.isNonEmpty())
22 : annotated.check(Schema.isNonEmpty(), Schema.isMaxLength(maxLength));
23 return encoded.pipe(Schema.decodeTo(encoded, SchemaTransformation.trim()));
24};
25
26export const T3ProjectFileScript = Schema.Struct({
27 name: trimmedNonEmpty({
28 description: "Display name for the script, shown in the T3 Code scripts menu.",
29 }),
30 command: trimmedNonEmpty({
31 description: "Shell command executed in a T3 Code terminal at the project root.",
32 }),
33 icon: Schema.optionalKey(
34 ProjectScriptIcon.annotate({
35 description: 'Icon shown next to the script in the scripts menu. Defaults to "play".',
36 }),
37 ),
38 runOnWorktreeCreate: Schema.optionalKey(
39 Schema.Boolean.annotate({
40 description:
41 "When true, the script runs automatically after a worktree is created for a new thread.",
42 }),
43 ),
44 previewUrl: Schema.optionalKey(
45 trimmedNonEmpty({
46 description:
47 "URL opened in the in-app browser preview when this script runs. Only honored on the desktop build.",
48 }),
49 ),
50 autoOpenPreview: Schema.optionalKey(
51 Schema.Boolean.annotate({
52 description:
53 "When true, automatically open the preview panel at `previewUrl` the moment the script starts.",
54 }),
55 ),
56}).annotate({
57 description: "A project script that team members can import into T3 Code.",
58});
59export type T3ProjectFileScript = typeof T3ProjectFileScript.Type;
60
61export const T3ProjectFile = Schema.Struct({
62 $schema: Schema.optionalKey(
63 Schema.String.annotate({
64 description: `URL of the JSON Schema for this file, typically "${T3_PROJECT_FILE_SCHEMA_URL}".`,
65 }),
66 ),
67 iconPath: Schema.optionalKey(
68 trimmedNonEmpty(
69 {
70 description:
71 'Workspace-relative path to the project icon (e.g. "assets/logo.svg"). Checked before T3 Code\'s built-in icon locations.',
72 },
73 T3_PROJECT_FILE_PATH_MAX_LENGTH,
74 ),
75 ),
76 scripts: Schema.optionalKey(
77 Schema.Array(T3ProjectFileScript)
78 .annotate({
79 description: "Project scripts shared with everyone who opens this repository in T3 Code.",
80 })
81 .check(Schema.isMaxLength(T3_PROJECT_FILE_MAX_SCRIPTS)),
82 ),
83}).annotate({
84 title: "T3 project file",
85 description:
86 "Checked-in project configuration for T3 Code (t3.json at the repository root). See https://t3.codes for documentation.",
87});
88export type T3ProjectFile = typeof T3ProjectFile.Type;
packages/shared/package.json+4
......@@ -87,6 +87,10 @@
8787 "types": "./src/projectScripts.ts",
8888 "import": "./src/projectScripts.ts"
8989 },
90 "./t3ProjectFile": {
91 "types": "./src/t3ProjectFile.ts",
92 "import": "./src/t3ProjectFile.ts"
93 },
9094 "./orchestrationTiming": {
9195 "types": "./src/orchestrationTiming.ts",
9296 "import": "./src/orchestrationTiming.ts"
packages/shared/src/t3ProjectFile.test.ts created+69
......@@ -0,0 +1,69 @@
1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";
3
4import { buildT3ProjectFileJsonSchema, T3ProjectFileFromJson } from "./t3ProjectFile.ts";
5
6const decodeJson = Schema.decodeUnknownSync(T3ProjectFileFromJson);
7
8describe("buildT3ProjectFileJsonSchema", () => {
9 it("emits a draft 2020-12 schema with the published $id", () => {
10 const schema = buildT3ProjectFileJsonSchema();
11
12 expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
13 expect(schema.$id).toBe("https://t3.codes/schema/t3.json");
14 expect(schema.type).toBe("object");
15 expect(schema.additionalProperties).toBe(false);
16 });
17
18 it("documents every supported field", () => {
19 const schema = buildT3ProjectFileJsonSchema() as {
20 properties: Record<
21 string,
22 {
23 description?: string;
24 items?: { properties: Record<string, unknown>; required: ReadonlyArray<string> };
25 }
26 >;
27 required?: ReadonlyArray<string>;
28 };
29
30 expect(Object.keys(schema.properties).sort()).toEqual(["$schema", "iconPath", "scripts"]);
31 expect(schema.required).toBeUndefined();
32 expect(schema.properties.iconPath?.description).toContain("Workspace-relative path");
33
34 const script = schema.properties.scripts?.items;
35 expect(script?.required).toEqual(["name", "command"]);
36 expect(Object.keys(script?.properties ?? {}).sort()).toEqual([
37 "autoOpenPreview",
38 "command",
39 "icon",
40 "name",
41 "previewUrl",
42 "runOnWorktreeCreate",
43 ]);
44 });
45
46 it("stays JSON-serializable", () => {
47 const schema = buildT3ProjectFileJsonSchema();
48 expect(JSON.parse(JSON.stringify(schema))).toEqual(schema);
49 });
50});
51
52describe("T3ProjectFileFromJson", () => {
53 it("decodes lenient JSONC with comments and trailing commas", () => {
54 const decoded = decodeJson(`{
55 // team scripts
56 "iconPath": "assets/logo.svg",
57 "scripts": [
58 { "name": "Dev", "command": "pnpm dev", },
59 ],
60 }`);
61
62 expect(decoded.iconPath).toBe("assets/logo.svg");
63 expect(decoded.scripts?.[0]).toEqual({ name: "Dev", command: "pnpm dev" });
64 });
65
66 it("fails on malformed JSON", () => {
67 expect(() => decodeJson("{ not json")).toThrow();
68 });
69});
packages/shared/src/t3ProjectFile.ts created+30
......@@ -0,0 +1,30 @@
1import * as Schema from "effect/Schema";
2
3import { T3ProjectFile, T3_PROJECT_FILE_SCHEMA_URL } from "@t3tools/contracts";
4
5import { fromLenientJson } from "./schemaJson.ts";
6
7/**
8 * Codec between the raw `t3.json` file contents (lenient JSONC string) and the
9 * decoded {@link T3ProjectFile}.
10 */
11export const T3ProjectFileFromJson = fromLenientJson(T3ProjectFile);
12
13/**
14 * Build the publishable JSON Schema document for `t3.json` (draft 2020-12).
15 *
16 * Served from the marketing site at {@link T3_PROJECT_FILE_SCHEMA_URL} so
17 * editors get LSP support via a `$schema` reference.
18 */
19export function buildT3ProjectFileJsonSchema(): Record<string, unknown> {
20 const document = Schema.toJsonSchemaDocument(T3ProjectFile);
21 const jsonSchema: Record<string, unknown> = {
22 $schema: "https://json-schema.org/draft/2020-12/schema",
23 $id: T3_PROJECT_FILE_SCHEMA_URL,
24 ...document.schema,
25 };
26 if (document.definitions && Object.keys(document.definitions).length > 0) {
27 jsonSchema.$defs = document.definitions;
28 }
29 return jsonSchema;
30}
pnpm-lock.yaml+3
......@@ -172,6 +172,9 @@ importers:
172172
173173 apps/marketing:
174174 dependencies:
175 '@t3tools/shared':
176 specifier: workspace:*
177 version: link:../../packages/shared
175178 astro:
176179 specifier: ^7.0.3
177180 version: 7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)
t3.json created+12
......@@ -0,0 +1,12 @@
1{
2 "$schema": "https://t3.codes/schema/t3.json",
3 "iconPath": "assets/dev/blueprint-web-apple-touch-180.png",
4 "scripts": [
5 {
6 "name": "Setup Worktree",
7 "command": "vp i && ln -sf $T3CODE_PROJECT_ROOT/.env .env && ln -sf $T3CODE_PROJECT_ROOT/infra/relay/.env infra/relay/.env",
8 "icon": "configure",
9 "runOnWorktreeCreate": true
10 }
11 ]
12}