diff --git a/apps/marketing/package.json b/apps/marketing/package.json
index d122dac3c56..912faf88164 100644
--- a/apps/marketing/package.json
+++ b/apps/marketing/package.json
@@ -10,6 +10,7 @@
"typecheck": "astro check"
},
"dependencies": {
+ "@t3tools/shared": "workspace:*",
"astro": "^7.0.3"
},
"devDependencies": {
diff --git a/apps/marketing/src/pages/schema/t3.json.ts b/apps/marketing/src/pages/schema/t3.json.ts
new file mode 100644
index 00000000000..707523eafc5
--- /dev/null
+++ b/apps/marketing/src/pages/schema/t3.json.ts
@@ -0,0 +1,10 @@
+import type { APIRoute } from "astro";
+
+import { buildT3ProjectFileJsonSchema } from "@t3tools/shared/t3ProjectFile";
+
+// Rendered at build time; published at https://t3.codes/schema/t3.json so
+// t3.json files can reference it via "$schema" for editor/LSP support.
+export const GET: APIRoute = () =>
+ new Response(`${JSON.stringify(buildT3ProjectFileJsonSchema(), null, 2)}\n`, {
+ headers: { "Content-Type": "application/json" },
+ });
diff --git a/apps/marketing/vercel.ts b/apps/marketing/vercel.ts
index c7344e9ff78..d2a3c774b8a 100644
--- a/apps/marketing/vercel.ts
+++ b/apps/marketing/vercel.ts
@@ -1,7 +1,7 @@
import type { VercelConfig } from "@vercel/config/v1";
export const config: VercelConfig = {
- installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing'",
+ installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'",
buildCommand: "vp run --filter @t3tools/marketing build",
outputDirectory: "dist",
};
diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts
index 06a22754e55..42fd3f900e5 100644
--- a/apps/server/src/assets/AssetAccess.test.ts
+++ b/apps/server/src/assets/AssetAccess.test.ts
@@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerConfig from "../config.ts";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
+import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
@@ -20,7 +21,10 @@ const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
const testLayer = Layer.mergeAll(
configLayer,
WorkspacePaths.layer,
- ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)),
+ ProjectFaviconResolver.layer.pipe(
+ Layer.provide(WorkspacePaths.layer),
+ Layer.provide(T3ProjectFileLoader.layer),
+ ),
ServerSecretStore.layer.pipe(Layer.provide(configLayer)),
).pipe(Layer.provideMerge(NodeServices.layer));
diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts
index 0b017b22e4e..75db78844a5 100644
--- a/apps/server/src/project/ProjectFaviconResolver.test.ts
+++ b/apps/server/src/project/ProjectFaviconResolver.test.ts
@@ -8,9 +8,15 @@ import * as PlatformError from "effect/PlatformError";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
+import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
const TestLayer = Layer.empty.pipe(
- Layer.provideMerge(ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer))),
+ Layer.provideMerge(
+ ProjectFaviconResolver.layer.pipe(
+ Layer.provide(WorkspacePaths.layer),
+ Layer.provide(T3ProjectFileLoader.layer),
+ ),
+ ),
Layer.provideMerge(NodeServices.layer),
);
@@ -37,7 +43,7 @@ const writeTextFile = Effect.fn("writeTextFile")(function* (
const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>
ProjectFaviconResolver.make.pipe(
- Effect.provide(WorkspacePaths.layer),
+ Effect.provide([WorkspacePaths.layer, T3ProjectFileLoader.layer]),
Effect.provideService(FileSystem.FileSystem, fileSystem),
);
@@ -56,6 +62,63 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
}),
);
+ it.effect("prefers a t3.json iconPath over well-known files", () =>
+ Effect.gen(function* () {
+ const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
+ const cwd = yield* makeTempDir;
+ yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/mark.svg" }');
+ yield* writeTextFile(cwd, "brand/mark.svg", "");
+ yield* writeTextFile(cwd, "favicon.svg", "");
+
+ const resolved = yield* resolver.resolvePath(cwd);
+
+ expect(resolved).not.toBeNull();
+ expect(resolved).toContain("brand/mark.svg");
+ }),
+ );
+
+ it.effect("falls back to well-known files when the t3.json iconPath does not exist", () =>
+ Effect.gen(function* () {
+ const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
+ const cwd = yield* makeTempDir;
+ yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/missing.svg" }');
+ yield* writeTextFile(cwd, "favicon.svg", "");
+
+ const resolved = yield* resolver.resolvePath(cwd);
+
+ expect(resolved).not.toBeNull();
+ expect(resolved).toContain("favicon.svg");
+ }),
+ );
+
+ it.effect("ignores invalid t3.json files", () =>
+ Effect.gen(function* () {
+ const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
+ const cwd = yield* makeTempDir;
+ yield* writeTextFile(cwd, "t3.json", "{ not json");
+ yield* writeTextFile(cwd, "favicon.svg", "");
+
+ const resolved = yield* resolver.resolvePath(cwd);
+
+ expect(resolved).not.toBeNull();
+ expect(resolved).toContain("favicon.svg");
+ }),
+ );
+
+ it.effect("does not resolve a t3.json iconPath outside the workspace root", () =>
+ Effect.gen(function* () {
+ const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
+ const parent = yield* makeTempDir;
+ const cwd = `${parent}/app`;
+ yield* writeTextFile(parent, "secret.svg", "");
+ yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "../secret.svg" }');
+
+ const resolved = yield* resolver.resolvePath(cwd);
+
+ expect(resolved).toBeNull();
+ }),
+ );
+
it.effect("resolves icon hrefs from project source files", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts
index e644df06ae6..2c7195de630 100644
--- a/apps/server/src/project/ProjectFaviconResolver.ts
+++ b/apps/server/src/project/ProjectFaviconResolver.ts
@@ -16,6 +16,7 @@ import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
+import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
// Well-known favicon paths checked in order.
const FAVICON_CANDIDATES = [
@@ -117,6 +118,7 @@ export const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
+ const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
const resolveIconHref = (href: string): ReadonlyArray => {
const clean = href.replace(/^\//, "");
@@ -177,6 +179,15 @@ export const make = Effect.gen(function* () {
}),
),
);
+ // A t3.json iconPath takes precedence over the well-known locations.
+ const projectFile = yield* projectFileLoader.load(projectCwd);
+ if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) {
+ const existing = yield* findExistingFile(projectCwd, [projectFile.value.iconPath]);
+ if (existing) {
+ return existing;
+ }
+ }
+
for (const candidate of FAVICON_CANDIDATES) {
const existing = yield* findExistingFile(projectCwd, [candidate]);
if (existing) {
diff --git a/apps/server/src/project/T3ProjectFileLoader.test.ts b/apps/server/src/project/T3ProjectFileLoader.test.ts
new file mode 100644
index 00000000000..c4761825387
--- /dev/null
+++ b/apps/server/src/project/T3ProjectFileLoader.test.ts
@@ -0,0 +1,89 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { it, describe, expect } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+
+import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
+
+const TestLayer = Layer.empty.pipe(
+ Layer.provideMerge(T3ProjectFileLoader.layer),
+ Layer.provideMerge(NodeServices.layer),
+);
+
+const makeTempDir = Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ return yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3code-project-file-",
+ });
+});
+
+const writeProjectFile = Effect.fn("writeProjectFile")(function* (cwd: string, contents: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ yield* fileSystem.writeFileString(path.join(cwd, "t3.json"), contents).pipe(Effect.orDie);
+});
+
+it.layer(TestLayer)("T3ProjectFileLoader", (it) => {
+ describe("load", () => {
+ it.effect("loads and decodes a valid t3.json", () =>
+ Effect.gen(function* () {
+ const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
+ const cwd = yield* makeTempDir;
+ yield* writeProjectFile(
+ cwd,
+ `{
+ // JSONC is tolerated
+ "iconPath": "assets/logo.svg",
+ "scripts": [{ "name": "Dev", "command": "pnpm dev" }],
+ }`,
+ );
+
+ const loaded = yield* loader.load(cwd);
+
+ expect(Option.isSome(loaded)).toBe(true);
+ if (Option.isSome(loaded)) {
+ expect(loaded.value.iconPath).toBe("assets/logo.svg");
+ expect(loaded.value.scripts).toEqual([{ name: "Dev", command: "pnpm dev" }]);
+ }
+ }),
+ );
+
+ it.effect("returns none when t3.json is missing", () =>
+ Effect.gen(function* () {
+ const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
+ const cwd = yield* makeTempDir;
+
+ const loaded = yield* loader.load(cwd);
+
+ expect(Option.isNone(loaded)).toBe(true);
+ }),
+ );
+
+ it.effect("returns none for malformed JSON without failing", () =>
+ Effect.gen(function* () {
+ const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
+ const cwd = yield* makeTempDir;
+ yield* writeProjectFile(cwd, "{ not json");
+
+ const loaded = yield* loader.load(cwd);
+
+ expect(Option.isNone(loaded)).toBe(true);
+ }),
+ );
+
+ it.effect("returns none for schema-invalid files without failing", () =>
+ Effect.gen(function* () {
+ const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
+ const cwd = yield* makeTempDir;
+ yield* writeProjectFile(cwd, '{ "scripts": [{ "name": "Dev" }] }');
+
+ const loaded = yield* loader.load(cwd);
+
+ expect(Option.isNone(loaded)).toBe(true);
+ }),
+ );
+ });
+});
diff --git a/apps/server/src/project/T3ProjectFileLoader.ts b/apps/server/src/project/T3ProjectFileLoader.ts
new file mode 100644
index 00000000000..00ad9d3835a
--- /dev/null
+++ b/apps/server/src/project/T3ProjectFileLoader.ts
@@ -0,0 +1,108 @@
+/**
+ * T3ProjectFileLoader - Effect service that loads the checked-in `t3.json`
+ * project file from a workspace root.
+ *
+ * Loading is best-effort: a missing file resolves to `Option.none`, and
+ * unreadable or invalid files are logged and treated as absent so callers
+ * can fall back to their defaults.
+ *
+ * @module T3ProjectFileLoader
+ */
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+import { T3_PROJECT_FILE_NAME, type T3ProjectFile } from "@t3tools/contracts";
+import { T3ProjectFileFromJson } from "@t3tools/shared/t3ProjectFile";
+
+const decodeT3ProjectFileJson = Schema.decodeEffect(T3ProjectFileFromJson);
+
+export class T3ProjectFileLoadError extends Schema.TaggedErrorClass()(
+ "T3ProjectFileLoadError",
+ {
+ operation: Schema.Literals(["read", "decode"]),
+ workspaceRoot: Schema.String,
+ filePath: Schema.String,
+ cause: Schema.Defect(),
+ },
+) {
+ override get message(): string {
+ return `Failed to ${this.operation} ${T3_PROJECT_FILE_NAME} at ${this.filePath}.`;
+ }
+}
+
+/** Service tag for t3.json project file loading. */
+export class T3ProjectFileLoader extends Context.Service<
+ T3ProjectFileLoader,
+ {
+ /**
+ * Load and decode `t3.json` at the workspace root.
+ *
+ * Never fails: missing, unreadable, or invalid files resolve to
+ * `Option.none` (invalid files are logged as warnings).
+ */
+ readonly load: (workspaceRoot: string) => Effect.Effect>;
+ }
+>()("t3/project/T3ProjectFileLoader") {}
+
+const logT3ProjectFileLoadError = (error: T3ProjectFileLoadError) =>
+ Effect.logWarning(error).pipe(
+ Effect.annotateLogs({
+ operation: error.operation,
+ workspaceRoot: error.workspaceRoot,
+ filePath: error.filePath,
+ errorTag: error._tag,
+ }),
+ );
+
+export const make = Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+
+ const load: T3ProjectFileLoader["Service"]["load"] = Effect.fn("T3ProjectFileLoader.load")(
+ function* (workspaceRoot) {
+ const filePath = path.join(workspaceRoot, T3_PROJECT_FILE_NAME);
+ const raw = yield* fileSystem.readFileString(filePath).pipe(
+ Effect.map(Option.some),
+ Effect.catchTags({
+ PlatformError: (error) =>
+ error.reason._tag === "NotFound"
+ ? Effect.succeed(Option.none())
+ : logT3ProjectFileLoadError(
+ new T3ProjectFileLoadError({
+ operation: "read",
+ workspaceRoot,
+ filePath,
+ cause: error,
+ }),
+ ).pipe(Effect.as(Option.none())),
+ }),
+ );
+ if (Option.isNone(raw)) {
+ return Option.none();
+ }
+ return yield* decodeT3ProjectFileJson(raw.value).pipe(
+ Effect.map(Option.some),
+ Effect.catchTags({
+ SchemaError: (error) =>
+ logT3ProjectFileLoadError(
+ new T3ProjectFileLoadError({
+ operation: "decode",
+ workspaceRoot,
+ filePath,
+ cause: error,
+ }),
+ ).pipe(Effect.as(Option.none())),
+ }),
+ );
+ },
+ );
+
+ return T3ProjectFileLoader.of({ load });
+});
+
+export const layer = Layer.effect(T3ProjectFileLoader, make);
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index c8c4ff377e8..871b79eca90 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -92,6 +92,7 @@ import * as PreviewManager from "./preview/Manager.ts";
import * as PortScanner from "./preview/PortScanner.ts";
import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
+import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
@@ -501,7 +502,10 @@ const buildAppUnderTest = (options?: {
Layer.provide(WorkspacePaths.layer),
Layer.provide(workspaceEntriesLayer),
),
- ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)),
+ ProjectFaviconResolver.layer.pipe(
+ Layer.provide(WorkspacePaths.layer),
+ Layer.provide(T3ProjectFileLoader.layer),
+ ),
);
const gitWorkflowLayer = GitWorkflowService.layer.pipe(
Layer.provideMerge(vcsDriverRegistryLayer),
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 21d0ebe5fff..66b9823afb3 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -54,6 +54,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
import * as ServerSettings from "./serverSettings.ts";
import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
+import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
@@ -265,6 +266,7 @@ const WorkspaceLayerLive = Layer.mergeAll(
const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe(
Layer.provide(WorkspacePaths.layer),
+ Layer.provide(T3ProjectFileLoader.layer),
);
const AuthLayerLive = EnvironmentAuth.layer.pipe(
diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx
index 4438a671f5d..3f465f7caf9 100644
--- a/apps/web/src/components/ProjectScriptsControl.tsx
+++ b/apps/web/src/components/ProjectScriptsControl.tsx
@@ -2,6 +2,7 @@ import type {
ProjectScript,
ProjectScriptIcon,
ResolvedKeybindingsConfig,
+ T3ProjectFileScript,
} from "@t3tools/contracts";
import {
isAtomCommandInterrupted,
@@ -11,6 +12,7 @@ import {
import {
BugIcon,
ChevronDownIcon,
+ DownloadIcon,
FlaskConicalIcon,
HammerIcon,
ListChecksIcon,
@@ -54,7 +56,16 @@ import {
import { Group, GroupSeparator } from "./ui/group";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
-import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
+import {
+ Menu,
+ MenuGroup,
+ MenuGroupLabel,
+ MenuItem,
+ MenuPopup,
+ MenuSeparator,
+ MenuShortcut,
+ MenuTrigger,
+} from "./ui/menu";
import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
import { Switch } from "./ui/switch";
import { Textarea } from "./ui/textarea";
@@ -98,8 +109,12 @@ export interface NewProjectScriptInput {
export type ProjectScriptActionResult = AtomCommandResult;
+const NO_FILE_SCRIPTS: ReadonlyArray = [];
+
interface ProjectScriptsControlProps {
scripts: ReadonlyArray;
+ /** Scripts declared in the project's checked-in t3.json, offered for import. */
+ fileScripts?: ReadonlyArray;
keybindings: ResolvedKeybindingsConfig;
preferredScriptId?: string | null;
onRunScript: (script: ProjectScript) => void;
@@ -113,6 +128,7 @@ interface ProjectScriptsControlProps {
export default function ProjectScriptsControl({
scripts,
+ fileScripts = NO_FILE_SCRIPTS,
keybindings,
preferredScriptId = null,
onRunScript,
@@ -141,6 +157,18 @@ export default function ProjectScriptsControl({
}
return primaryProjectScript(scripts);
}, [preferredScriptId, scripts]);
+ const importableScripts = useMemo(
+ () =>
+ fileScripts.filter(
+ (fileScript) =>
+ !scripts.some(
+ (script) =>
+ script.command === fileScript.command ||
+ script.name.toLowerCase() === fileScript.name.toLowerCase(),
+ ),
+ ),
+ [fileScripts, scripts],
+ );
const isEditing = editingScriptId !== null;
const dropdownItemClassName =
"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({
void onDeleteScript(editingScriptId);
}, [editingScriptId, onDeleteScript]);
+ const importFileScript = async (fileScript: T3ProjectFileScript) => {
+ const payload: NewProjectScriptInput = {
+ name: fileScript.name,
+ command: fileScript.command,
+ icon: fileScript.icon ?? "play",
+ runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
+ keybinding: null,
+ previewUrl: fileScript.previewUrl ?? null,
+ autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
+ };
+ const result = await onAddScript(payload);
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ // Surface the failure through the regular add dialog, prefilled so the
+ // user can adjust and retry.
+ const error = squashAtomCommandFailure(result);
+ setEditingScriptId(null);
+ setName(payload.name);
+ setCommand(payload.command);
+ setIcon(payload.icon);
+ setIconPickerOpen(false);
+ setRunOnWorktreeCreate(payload.runOnWorktreeCreate);
+ setKeybinding("");
+ setPreviewUrl(payload.previewUrl ?? "");
+ setAutoOpenPreview(payload.autoOpenPreview);
+ setValidationError(error instanceof Error ? error.message : "Failed to import action.");
+ setDialogOpen(true);
+ }
+ };
+
+ const importMenuItems = importableScripts.length > 0 && (
+ <>
+ {primaryScript && }
+
+ From t3.json
+ {importableScripts.map((fileScript) => (
+
+ ))}
+
+ >
+ );
+
return (
<>
{primaryScript ? (
@@ -320,6 +399,7 @@ export default function ProjectScriptsControl({
);
})}
+ {importMenuItems}