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 @@...@@ -10,6 +10,7 @@
10 "typecheck": "astro check"10 "typecheck": "astro check"
11 },11 },
12 "dependencies": {12 "dependencies": {
13 "@t3tools/shared": "workspace:*",
13 "astro": "^7.0.3"14 "astro": "^7.0.3"
14 },15 },
15 "devDependencies": {16 "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 @@...@@ -1,7 +1,7 @@
1import type { VercelConfig } from "@vercel/config/v1";1import type { VercelConfig } from "@vercel/config/v1";
22
3export const config: VercelConfig = {3export 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...'",
5 buildCommand: "vp run --filter @t3tools/marketing build",5 buildCommand: "vp run --filter @t3tools/marketing build",
6 outputDirectory: "dist",6 outputDirectory: "dist",
7};7};
apps/server/src/assets/AssetAccess.test.ts+5-1
...@@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError";...@@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError";
11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
12import * as ServerConfig from "../config.ts";12import * as ServerConfig from "../config.ts";
13import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";13import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
14import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
14import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";15import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
15import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";16import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
1617
...@@ -20,7 +21,10 @@ const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {...@@ -20,7 +21,10 @@ const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
20const testLayer = Layer.mergeAll(21const testLayer = Layer.mergeAll(
21 configLayer,22 configLayer,
22 WorkspacePaths.layer,23 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 ),
24 ServerSecretStore.layer.pipe(Layer.provide(configLayer)),28 ServerSecretStore.layer.pipe(Layer.provide(configLayer)),
25).pipe(Layer.provideMerge(NodeServices.layer));29).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";...@@ -8,9 +8,15 @@ import * as PlatformError from "effect/PlatformError";
88
9import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";9import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
10import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";10import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
11import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
1112
12const TestLayer = Layer.empty.pipe(13const 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 ),
14 Layer.provideMerge(NodeServices.layer),20 Layer.provideMerge(NodeServices.layer),
15);21);
1622
...@@ -37,7 +43,7 @@ const writeTextFile = Effect.fn("writeTextFile")(function* (...@@ -37,7 +43,7 @@ const writeTextFile = Effect.fn("writeTextFile")(function* (
3743
38const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>44const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>
39 ProjectFaviconResolver.make.pipe(45 ProjectFaviconResolver.make.pipe(
40 Effect.provide(WorkspacePaths.layer),46 Effect.provide([WorkspacePaths.layer, T3ProjectFileLoader.layer]),
41 Effect.provideService(FileSystem.FileSystem, fileSystem),47 Effect.provideService(FileSystem.FileSystem, fileSystem),
42 );48 );
4349
...@@ -56,6 +62,63 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {...@@ -56,6 +62,63 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
56 }),62 }),
57 );63 );
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
59 it.effect("resolves icon hrefs from project source files", () =>122 it.effect("resolves icon hrefs from project source files", () =>
60 Effect.gen(function* () {123 Effect.gen(function* () {
61 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;124 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
apps/server/src/project/ProjectFaviconResolver.ts+11
...@@ -16,6 +16,7 @@ import * as PlatformError from "effect/PlatformError";...@@ -16,6 +16,7 @@ import * as PlatformError from "effect/PlatformError";
16import * as Schema from "effect/Schema";16import * as Schema from "effect/Schema";
1717
18import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";18import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
19import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
1920
20// Well-known favicon paths checked in order.21// Well-known favicon paths checked in order.
21const FAVICON_CANDIDATES = [22const FAVICON_CANDIDATES = [
...@@ -117,6 +118,7 @@ export const make = Effect.gen(function* () {...@@ -117,6 +118,7 @@ export const make = Effect.gen(function* () {
117 const fileSystem = yield* FileSystem.FileSystem;118 const fileSystem = yield* FileSystem.FileSystem;
118 const path = yield* Path.Path;119 const path = yield* Path.Path;
119 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;120 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
121 const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
120122
121 const resolveIconHref = (href: string): ReadonlyArray<string> => {123 const resolveIconHref = (href: string): ReadonlyArray<string> => {
122 const clean = href.replace(/^\//, "");124 const clean = href.replace(/^\//, "");
...@@ -177,6 +179,15 @@ export const make = Effect.gen(function* () {...@@ -177,6 +179,15 @@ export const make = Effect.gen(function* () {
177 }),179 }),
178 ),180 ),
179 );181 );
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
180 for (const candidate of FAVICON_CANDIDATES) {191 for (const candidate of FAVICON_CANDIDATES) {
181 const existing = yield* findExistingFile(projectCwd, [candidate]);192 const existing = yield* findExistingFile(projectCwd, [candidate]);
182 if (existing) {193 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";...@@ -92,6 +92,7 @@ import * as PreviewManager from "./preview/Manager.ts";
92import * as PortScanner from "./preview/PortScanner.ts";92import * as PortScanner from "./preview/PortScanner.ts";
93import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";93import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
94import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";94import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
95import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
95import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";96import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
96import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";97import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
97import * as ServerEnvironment from "./environment/ServerEnvironment.ts";98import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
...@@ -501,7 +502,10 @@ const buildAppUnderTest = (options?: {...@@ -501,7 +502,10 @@ const buildAppUnderTest = (options?: {
501 Layer.provide(WorkspacePaths.layer),502 Layer.provide(WorkspacePaths.layer),
502 Layer.provide(workspaceEntriesLayer),503 Layer.provide(workspaceEntriesLayer),
503 ),504 ),
504 ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)),505 ProjectFaviconResolver.layer.pipe(
506 Layer.provide(WorkspacePaths.layer),
507 Layer.provide(T3ProjectFileLoader.layer),
508 ),
505 );509 );
506 const gitWorkflowLayer = GitWorkflowService.layer.pipe(510 const gitWorkflowLayer = GitWorkflowService.layer.pipe(
507 Layer.provideMerge(vcsDriverRegistryLayer),511 Layer.provideMerge(vcsDriverRegistryLayer),
apps/server/src/server.ts+2
...@@ -54,6 +54,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";...@@ -54,6 +54,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
54import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";54import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
55import * as ServerSettings from "./serverSettings.ts";55import * as ServerSettings from "./serverSettings.ts";
56import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";56import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
57import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
57import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";58import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
58import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";59import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
59import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";60import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
...@@ -265,6 +266,7 @@ const WorkspaceLayerLive = Layer.mergeAll(...@@ -265,6 +266,7 @@ const WorkspaceLayerLive = Layer.mergeAll(
265266
266const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe(267const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe(
267 Layer.provide(WorkspacePaths.layer),268 Layer.provide(WorkspacePaths.layer),
269 Layer.provide(T3ProjectFileLoader.layer),
268);270);
269271
270const AuthLayerLive = EnvironmentAuth.layer.pipe(272const AuthLayerLive = EnvironmentAuth.layer.pipe(
apps/web/src/components/ProjectScriptsControl.tsx+98-1
...@@ -2,6 +2,7 @@ import type {...@@ -2,6 +2,7 @@ import type {
2 ProjectScript,2 ProjectScript,
3 ProjectScriptIcon,3 ProjectScriptIcon,
4 ResolvedKeybindingsConfig,4 ResolvedKeybindingsConfig,
5 T3ProjectFileScript,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
6import {7import {
7 isAtomCommandInterrupted,8 isAtomCommandInterrupted,
...@@ -11,6 +12,7 @@ import {...@@ -11,6 +12,7 @@ import {
11import {12import {
12 BugIcon,13 BugIcon,
13 ChevronDownIcon,14 ChevronDownIcon,
15 DownloadIcon,
14 FlaskConicalIcon,16 FlaskConicalIcon,
15 HammerIcon,17 HammerIcon,
16 ListChecksIcon,18 ListChecksIcon,
...@@ -54,7 +56,16 @@ import {...@@ -54,7 +56,16 @@ import {
54import { Group, GroupSeparator } from "./ui/group";56import { Group, GroupSeparator } from "./ui/group";
55import { Input } from "./ui/input";57import { Input } from "./ui/input";
56import { Label } from "./ui/label";58import { 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";
58import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";69import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
59import { Switch } from "./ui/switch";70import { Switch } from "./ui/switch";
60import { Textarea } from "./ui/textarea";71import { Textarea } from "./ui/textarea";
...@@ -98,8 +109,12 @@ export interface NewProjectScriptInput {...@@ -98,8 +109,12 @@ export interface NewProjectScriptInput {
98109
99export type ProjectScriptActionResult = AtomCommandResult<void, unknown>;110export type ProjectScriptActionResult = AtomCommandResult<void, unknown>;
100111
112const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];
113
101interface ProjectScriptsControlProps {114interface ProjectScriptsControlProps {
102 scripts: ReadonlyArray<ProjectScript>;115 scripts: ReadonlyArray<ProjectScript>;
116 /** Scripts declared in the project's checked-in t3.json, offered for import. */
117 fileScripts?: ReadonlyArray<T3ProjectFileScript>;
103 keybindings: ResolvedKeybindingsConfig;118 keybindings: ResolvedKeybindingsConfig;
104 preferredScriptId?: string | null;119 preferredScriptId?: string | null;
105 onRunScript: (script: ProjectScript) => void;120 onRunScript: (script: ProjectScript) => void;
...@@ -113,6 +128,7 @@ interface ProjectScriptsControlProps {...@@ -113,6 +128,7 @@ interface ProjectScriptsControlProps {
113128
114export default function ProjectScriptsControl({129export default function ProjectScriptsControl({
115 scripts,130 scripts,
131 fileScripts = NO_FILE_SCRIPTS,
116 keybindings,132 keybindings,
117 preferredScriptId = null,133 preferredScriptId = null,
118 onRunScript,134 onRunScript,
...@@ -141,6 +157,18 @@ export default function ProjectScriptsControl({...@@ -141,6 +157,18 @@ export default function ProjectScriptsControl({
141 }157 }
142 return primaryProjectScript(scripts);158 return primaryProjectScript(scripts);
143 }, [preferredScriptId, scripts]);159 }, [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 );
144 const isEditing = editingScriptId !== null;172 const isEditing = editingScriptId !== null;
145 const dropdownItemClassName =173 const dropdownItemClassName =
146 "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";174 "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({...@@ -247,6 +275,57 @@ export default function ProjectScriptsControl({
247 void onDeleteScript(editingScriptId);275 void onDeleteScript(editingScriptId);
248 }, [editingScriptId, onDeleteScript]);276 }, [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
250 return (329 return (
251 <>330 <>
252 {primaryScript ? (331 {primaryScript ? (
...@@ -320,6 +399,7 @@ export default function ProjectScriptsControl({...@@ -320,6 +399,7 @@ export default function ProjectScriptsControl({
320 </MenuItem>399 </MenuItem>
321 );400 );
322 })}401 })}
402 {importMenuItems}
323 <MenuItem className={dropdownItemClassName} onClick={openAddDialog}>403 <MenuItem className={dropdownItemClassName} onClick={openAddDialog}>
324 <PlusIcon className="size-4" />404 <PlusIcon className="size-4" />
325 Add action405 Add action
...@@ -327,6 +407,23 @@ export default function ProjectScriptsControl({...@@ -327,6 +407,23 @@ export default function ProjectScriptsControl({
327 </MenuPopup>407 </MenuPopup>
328 </Menu>408 </Menu>
329 </Group>409 </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>
330 ) : (427 ) : (
331 <Tooltip>428 <Tooltip>
332 <TooltipTrigger429 <TooltipTrigger
apps/web/src/components/chat/ChatHeader.tsx+6
...@@ -16,6 +16,7 @@ import ProjectScriptsControl, {...@@ -16,6 +16,7 @@ import ProjectScriptsControl, {
16} from "../ProjectScriptsControl";16} from "../ProjectScriptsControl";
17import { OpenInPicker } from "./OpenInPicker";17import { OpenInPicker } from "./OpenInPicker";
18import { usePrimaryEnvironmentId } from "../../state/environments";18import { usePrimaryEnvironmentId } from "../../state/environments";
19import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts";
19import { ProjectFavicon } from "../ProjectFavicon";20import { ProjectFavicon } from "../ProjectFavicon";
20import { cn } from "~/lib/utils";21import { cn } from "~/lib/utils";
2122
...@@ -74,6 +75,10 @@ export const ChatHeader = memo(function ChatHeader({...@@ -74,6 +75,10 @@ export const ChatHeader = memo(function ChatHeader({
74 onDeleteProjectScript,75 onDeleteProjectScript,
75}: ChatHeaderProps) {76}: ChatHeaderProps) {
76 const primaryEnvironmentId = usePrimaryEnvironmentId();77 const primaryEnvironmentId = usePrimaryEnvironmentId();
78 const fileScripts = useT3ProjectFileScripts(
79 activeThreadEnvironmentId,
80 activeProjectScripts ? activeProjectCwd : null,
81 );
77 const showOpenInPicker = shouldShowOpenInPicker({82 const showOpenInPicker = shouldShowOpenInPicker({
78 activeProjectName,83 activeProjectName,
79 activeThreadEnvironmentId,84 activeThreadEnvironmentId,
...@@ -126,6 +131,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -126,6 +131,7 @@ export const ChatHeader = memo(function ChatHeader({
126 {activeProjectScripts && (131 {activeProjectScripts && (
127 <ProjectScriptsControl132 <ProjectScriptsControl
128 scripts={activeProjectScripts}133 scripts={activeProjectScripts}
134 fileScripts={fileScripts}
129 keybindings={keybindings}135 keybindings={keybindings}
130 preferredScriptId={preferredScriptId}136 preferredScriptId={preferredScriptId}
131 onRunScript={onRunProjectScript}137 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";...@@ -18,6 +18,7 @@ export * from "./git.ts";
18export * from "./vcs.ts";18export * from "./vcs.ts";
19export * from "./sourceControl.ts";19export * from "./sourceControl.ts";
20export * from "./orchestration.ts";20export * from "./orchestration.ts";
21export * from "./t3ProjectFile.ts";
21export * from "./editor.ts";22export * from "./editor.ts";
22export * from "./project.ts";23export * from "./project.ts";
23export * from "./filesystem.ts";24export * 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 @@...@@ -87,6 +87,10 @@
87 "types": "./src/projectScripts.ts",87 "types": "./src/projectScripts.ts",
88 "import": "./src/projectScripts.ts"88 "import": "./src/projectScripts.ts"
89 },89 },
90 "./t3ProjectFile": {
91 "types": "./src/t3ProjectFile.ts",
92 "import": "./src/t3ProjectFile.ts"
93 },
90 "./orchestrationTiming": {94 "./orchestrationTiming": {
91 "types": "./src/orchestrationTiming.ts",95 "types": "./src/orchestrationTiming.ts",
92 "import": "./src/orchestrationTiming.ts"96 "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:...@@ -172,6 +172,9 @@ importers:
172172
173 apps/marketing:173 apps/marketing:
174 dependencies:174 dependencies:
175 '@t3tools/shared':
176 specifier: workspace:*
177 version: link:../../packages/shared
175 astro:178 astro:
176 specifier: ^7.0.3179 specifier: ^7.0.3
177 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)180 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}