Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 205 additions & 2 deletions apps/server/src/project/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, expect, it, vi } from "@effect/vitest";
import { type OrchestrationProject, ProjectId } from "@t3tools/contracts";
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 * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
import * as TerminalManager from "../terminal/Manager.ts";
import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts";
import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";

const isProjectSetupScriptOperationError = Schema.is(
ProjectSetupScriptRunner.ProjectSetupScriptOperationError,
Expand All @@ -17,10 +21,13 @@ const isProjectSetupScriptCommandError = Schema.is(
ProjectSetupScriptRunner.ProjectSetupScriptCommandError,
);

const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationProject => ({
const makeProject = (
scripts: OrchestrationProject["scripts"],
workspaceRoot = "/repo/project",
): OrchestrationProject => ({
id: ProjectId.make("project-1"),
title: "Project",
workspaceRoot: "/repo/project",
workspaceRoot,
defaultModelSelection: null,
threadCreationDefaults: {
environmentMode: null,
Expand Down Expand Up @@ -89,8 +96,40 @@ const testLayer = (
ProjectSetupScriptRunner.layer.pipe(
Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),
Layer.provideMerge(makeTerminalManagerLayer(runCommand)),
// T3-CUSTOM(expbkt3): the t3.json setup fallback reads the real workspace root.
Layer.provideMerge(T3ProjectFileLoader.layer),
Layer.provideMerge(NodeServices.layer),
);

// T3-CUSTOM(expbkt3): BEGIN — checked-in t3.json setup script coverage.
const makeWorkspaceRoot = Effect.fn("makeWorkspaceRoot")(function* (t3ProjectFile?: string) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspaceRoot = yield* fileSystem
.makeTempDirectoryScoped({ prefix: "t3code-setup-script-" })
.pipe(Effect.orDie);
if (t3ProjectFile !== undefined) {
yield* fileSystem
.writeFileString(path.join(workspaceRoot, "t3.json"), t3ProjectFile)
.pipe(Effect.orDie);
}
return workspaceRoot;
});

const succeedingRunCommand = (terminalId: string) =>
vi.fn((input: Parameters<TerminalManager.TerminalManager["Service"]["runCommand"]>[0]) =>
(input.onStarted?.() ?? Effect.void).pipe(
Effect.as({
threadId: "thread-1",
terminalId,
exitCode: 0,
exitSignal: null,
error: null,
}),
),
);
// T3-CUSTOM(expbkt3): END

describe("ProjectSetupScriptRunner", () => {
it.effect("returns no-script when no setup script exists", () => {
const runCommand = vi.fn(() => Effect.die("unexpected runCommand"));
Expand Down Expand Up @@ -251,4 +290,168 @@ describe("ProjectSetupScriptRunner", () => {
}
}).pipe(Effect.provide(testLayer(project, () => Effect.fail(terminalError))));
});

// T3-CUSTOM(expbkt3): BEGIN — a repository can ship its setup action in t3.json.
it.effect("prefers a persisted setup script over the one declared in t3.json", () => {
const runCommand = succeedingRunCommand("setup-setup");

return Effect.gen(function* () {
const workspaceRoot = yield* makeWorkspaceRoot(
`{ "scripts": [
{ "name": "From t3.json", "command": "./tools/from-t3-json.sh", "runOnWorktreeCreate": true }
] }`,
);
const project = makeProject(
[
{
id: "setup",
name: "Setup",
command: "bun install",
icon: "configure",
runOnWorktreeCreate: true,
},
],
workspaceRoot,
);

const result = yield* Effect.gen(function* () {
const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
return yield* runner.runForThread({
threadId: "thread-1",
projectId: "project-1",
worktreePath: "/repo/worktrees/a",
});
}).pipe(Effect.provide(testLayer(project, runCommand)));

expect(result).toMatchObject({
status: "completed",
scriptId: "setup",
scriptName: "Setup",
});
expect(runCommand).toHaveBeenCalledWith(
expect.objectContaining({ command: "bun install", terminalId: "setup-setup" }),
);
}).pipe(Effect.provide(NodeServices.layer));
});

it.effect("runs the t3.json setup script when the project has no persisted script", () => {
const runCommand = succeedingRunCommand("setup-t3-json-setup");
const onStarted = vi.fn(() => Effect.void);

return Effect.gen(function* () {
const workspaceRoot = yield* makeWorkspaceRoot(
`{ "scripts": [
{ "name": "Install", "command": "bun install" },
{ "name": "Bootstrap", "command": "./tools/setup.sh", "runOnWorktreeCreate": true }
] }`,
);
const project = makeProject([], workspaceRoot);

const result = yield* Effect.gen(function* () {
const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
return yield* runner.runForThread({
threadId: "thread-1",
projectId: "project-1",
worktreePath: "/repo/worktrees/a",
onStarted,
});
}).pipe(Effect.provide(testLayer(project, runCommand)));

expect(result).toEqual({
status: "completed",
scriptId: "t3-json-setup",
scriptName: "Bootstrap",
terminalId: "setup-t3-json-setup",
cwd: "/repo/worktrees/a",
exitCode: 0,
});
expect(runCommand).toHaveBeenCalledWith({
threadId: "thread-1",
terminalId: "setup-t3-json-setup",
cwd: "/repo/worktrees/a",
worktreePath: "/repo/worktrees/a",
env: {
T3CODE_PROJECT_ROOT: workspaceRoot,
T3CODE_WORKTREE_PATH: "/repo/worktrees/a",
},
command: "./tools/setup.sh",
onStarted: expect.any(Function),
});
expect(onStarted).toHaveBeenCalledWith({
scriptId: "t3-json-setup",
scriptName: "Bootstrap",
terminalId: "setup-t3-json-setup",
cwd: "/repo/worktrees/a",
});
}).pipe(Effect.provide(NodeServices.layer));
});

it.effect("returns no-script when the workspace has no t3.json", () => {
const runCommand = vi.fn(() => Effect.die("unexpected runCommand"));

return Effect.gen(function* () {
const workspaceRoot = yield* makeWorkspaceRoot();
const project = makeProject([], workspaceRoot);

const result = yield* Effect.gen(function* () {
const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
return yield* runner.runForThread({
threadId: "thread-1",
projectId: "project-1",
worktreePath: "/repo/worktrees/a",
});
}).pipe(Effect.provide(testLayer(project, runCommand)));

expect(result).toEqual({ status: "no-script" });
expect(runCommand).not.toHaveBeenCalled();
}).pipe(Effect.provide(NodeServices.layer));
});

it.effect("returns no-script when no t3.json script opts into worktree creation", () => {
const runCommand = vi.fn(() => Effect.die("unexpected runCommand"));

return Effect.gen(function* () {
const workspaceRoot = yield* makeWorkspaceRoot(
`{ "scripts": [
{ "name": "Dev", "command": "bun dev" },
{ "name": "Test", "command": "bun test", "runOnWorktreeCreate": false }
] }`,
);
const project = makeProject([], workspaceRoot);

const result = yield* Effect.gen(function* () {
const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
return yield* runner.runForThread({
threadId: "thread-1",
projectId: "project-1",
worktreePath: "/repo/worktrees/a",
});
}).pipe(Effect.provide(testLayer(project, runCommand)));

expect(result).toEqual({ status: "no-script" });
expect(runCommand).not.toHaveBeenCalled();
}).pipe(Effect.provide(NodeServices.layer));
});

it.effect("returns no-script for a malformed t3.json instead of failing", () => {
const runCommand = vi.fn(() => Effect.die("unexpected runCommand"));

return Effect.gen(function* () {
const workspaceRoot = yield* makeWorkspaceRoot("{ not json");
const project = makeProject([], workspaceRoot);

const result = yield* Effect.gen(function* () {
const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
return yield* runner.runForThread({
threadId: "thread-1",
projectId: "project-1",
worktreePath: "/repo/worktrees/a",
});
}).pipe(Effect.provide(testLayer(project, runCommand)));

expect(result).toEqual({ status: "no-script" });
expect(runCommand).not.toHaveBeenCalled();
}).pipe(Effect.provide(NodeServices.layer));
});
// T3-CUSTOM(expbkt3): END
});
42 changes: 40 additions & 2 deletions apps/server/src/project/ProjectSetupScriptRunner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { ProjectId } from "@t3tools/contracts";
import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts";
// T3-CUSTOM(expbkt3): BEGIN — checked-in t3.json setup scripts run automatically.
import {
projectScriptFromFileScript,
projectScriptRuntimeEnv,
setupProjectScript,
setupT3ProjectFileScript,
} from "@t3tools/shared/projectScripts";
// T3-CUSTOM(expbkt3): END
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand All @@ -8,6 +15,16 @@ import * as Schema from "effect/Schema";

import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
import * as TerminalManager from "../terminal/Manager.ts";
// T3-CUSTOM(expbkt3): BEGIN — checked-in t3.json setup scripts run automatically.
import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";

/**
* Synthetic id for the setup script a repository declares in `t3.json`. It has
* no persisted record, so the id only has to be stable: it names the default
* setup terminal and appears in bootstrap receipts.
*/
const T3_PROJECT_FILE_SETUP_SCRIPT_ID = "t3-json-setup";
// T3-CUSTOM(expbkt3): END

export interface ProjectSetupScriptRunnerResultNoScript {
readonly status: "no-script";
Expand Down Expand Up @@ -121,6 +138,22 @@ export class ProjectSetupScriptRunner extends Context.Service<
export const make = Effect.gen(function* () {
const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
const terminalManager = yield* TerminalManager.TerminalManager;
const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader;

/**
* The setup script a repository declares in its checked-in `t3.json`, if any.
* Loading is infallible: a missing, unreadable, or invalid file resolves to
* `Option.none`, and so to no script.
*/
const t3ProjectFileSetupScript = Effect.fn("ProjectSetupScriptRunner.t3ProjectFileSetupScript")(
function* (workspaceRoot: string) {
const projectFile = yield* projectFileLoader.load(workspaceRoot);
const fileScript = setupT3ProjectFileScript(Option.getOrUndefined(projectFile)?.scripts);
return fileScript === null
? null
: projectScriptFromFileScript(T3_PROJECT_FILE_SETUP_SCRIPT_ID, fileScript);
},
);

const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn(
"ProjectSetupScriptRunner.runForThread",
Expand Down Expand Up @@ -164,7 +197,12 @@ export const make = Effect.gen(function* () {
return yield* new ProjectSetupScriptProjectNotFoundError(errorContext);
}

const script = setupProjectScript(project.scripts);
// A project script the user configured always wins; the repository's
// checked-in t3.json is the fallback, so a repo that ships its setup action
// works in a fresh worktree without a per-environment manual import.
const script =
setupProjectScript(project.scripts) ??
(yield* t3ProjectFileSetupScript(project.workspaceRoot));
if (!script) {
return {
status: "no-script",
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,9 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay
);

const GitManagerLayerLive = GitManager.layer.pipe(
Layer.provideMerge(ProjectSetupScriptRunner.layer),
// T3-CUSTOM(expbkt3): BEGIN — setup falls back to the repository's t3.json.
Layer.provideMerge(ProjectSetupScriptRunner.layer.pipe(Layer.provide(T3ProjectFileLoader.layer))),
// T3-CUSTOM(expbkt3): END
Layer.provideMerge(GitVcsDriver.layer),
Layer.provideMerge(SourceControlProviderRegistryLayerLive),
Layer.provideMerge(TextGeneration.layer),
Expand Down
17 changes: 17 additions & 0 deletions docs/user/worktree-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,23 @@ Retry is safe after a partial run.
Only one action is used automatically. If older project data flags more than one action, T3 uses
the first and shows a warning until you save a single selection.

## Ship the setup action in the repository

A repository can declare its setup action in a `t3.json` file at the repository root, so everyone
who opens it gets a prepared worktree without configuring anything:

```json
{
"$schema": "https://t3.codes/schema/t3.json",
"scripts": [{ "name": "Bootstrap", "command": "./tools/setup.sh", "runOnWorktreeCreate": true }]
}
```

The checked-in action runs on every new worktree, with no import step, on every machine and server
where the repository is opened. A worktree setup action configured under **Settings → Projects**
always wins; the checked-in one is the fallback. T3 uses the first `t3.json` script flagged
`runOnWorktreeCreate`, and a missing or invalid `t3.json` simply means no setup action runs.

## Choose app and project defaults

The **New threads** app settings define the environment-local defaults for:
Expand Down
42 changes: 41 additions & 1 deletion packages/shared/src/projectScripts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { ProjectScript } from "@t3tools/contracts";
// T3-CUSTOM(expbkt3): BEGIN — checked-in t3.json setup scripts run automatically.
import type { ProjectScript, T3ProjectFileScript } from "@t3tools/contracts";
// T3-CUSTOM(expbkt3): END

interface ProjectScriptRuntimeEnvInput {
project: {
Expand Down Expand Up @@ -35,3 +37,41 @@ export function projectScriptRuntimeEnv(
export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null {
return scripts.find((script) => script.runOnWorktreeCreate) ?? null;
}
// T3-CUSTOM(expbkt3): BEGIN — a repository can ship its setup action in t3.json
// instead of every teammate importing it once per project, per environment.

/**
* The `t3.json` script a new worktree should run, if the file declares one.
* Mirrors {@link setupProjectScript} over the checked-in file's entries.
*/
export function setupT3ProjectFileScript(
scripts: readonly T3ProjectFileScript[] | undefined,
): T3ProjectFileScript | null {
return scripts?.find((script) => script.runOnWorktreeCreate === true) ?? null;
}

/**
* Turn a `t3.json` script entry into a runnable {@link ProjectScript}, filling
* the fields the file leaves optional. `id` is supplied by the caller because
* the file has no identity of its own; use a stable value so terminals and
* receipts stay recognizable across runs.
*/
export function projectScriptFromFileScript(
id: string,
fileScript: T3ProjectFileScript,
): ProjectScript {
return {
id,
name: fileScript.name,
command: fileScript.command,
icon: fileScript.icon ?? "play",
runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
...(fileScript.previewUrl === undefined
? {}
: {
previewUrl: fileScript.previewUrl,
autoOpenPreview: fileScript.autoOpenPreview ?? false,
}),
};
}
// T3-CUSTOM(expbkt3): END
Loading