Skip to content
Source
Sign inSign up

ManagedProjectFolders.ts

514 lines18.0 KB

1import {
2 CommandId,
3 GitCommandError,
4 type GitManagerServiceError,
5 ProjectId,
6 type ThreadId,
7 VcsProcessSpawnError,
8} from "@t3tools/contracts";
9import { newProjectFolderName } from "@t3tools/shared/path";
10import * as Cause from "effect/Cause";
11import * as Context from "effect/Context";
12import * as SecureRandom from "effect/crypto/SecureRandom";
13import * as DateTime from "effect/DateTime";
14import * as Duration from "effect/Duration";
15import * as Effect from "effect/Effect";
16import * as Exit from "effect/Exit";
17import * as FileSystem from "effect/FileSystem";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as Path from "effect/Path";
21import * as PlatformError from "effect/PlatformError";
22import * as Result from "effect/Result";
23import * as Schema from "effect/Schema";
24​
25import * as ServerConfig from "../config.ts";
26import * as GitWorkflow from "../git/GitWorkflowService.ts";
27import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
28import * as ProjectService from "./ProjectService.ts";
29​
30export class ScratchUnavailableError extends Schema.TaggedError<ScratchUnavailableError>()(
31 "ScratchUnavailableError",
32 {
33 baseDir: Schema.String,
34 repositoryRoot: Schema.optional(Schema.String),
35 },
36) {
37 override get message(): string {
38 if (this.repositoryRoot === undefined) {
39 return "Threads without a project are not available on this environment.";
40 }
41 return `Threads without a project are off because the T3 home ${this.baseDir} is inside the Git repository ${this.repositoryRoot}. If that repository was created by mistake, move ${this.repositoryRoot}/.git out of the way; otherwise point T3CODE_HOME (or --base-dir) at a folder outside it. Then restart T3 Code.`;
42 }
44​
45export class ScratchFolderError extends Schema.TaggedError<ScratchFolderError>()(
46 "ScratchFolderError",
47 {
48 folder: Schema.String,
49 cause: Schema.Defect(),
50 },
51) {
52 override get message(): string {
53 return "Failed to create the folder for threads without a project.";
54 }
56​
57export class ScratchProjectError extends Schema.TaggedError<ScratchProjectError>()(
58 "ScratchProjectError",
59 {
60 workspaceRoot: Schema.String,
61 cause: Schema.Defect(),
62 },
63) {
64 override get message(): string {
65 return "Failed to create the project for threads without a project.";
66 }
68​
69export type ScratchError = ScratchUnavailableError | ScratchFolderError | ScratchProjectError;
70​
71// The spawner checks cwd before spawning, so NotFound from spawn itself means a missing executable.
72const isMissingExecutable = (cause: unknown) =>
73 PlatformError.isPlatformError(cause) &&
74 cause.reason._tag === "NotFound" &&
75 cause.reason.method === "spawn";
76​
77const isGitCommandError = Schema.is(GitCommandError);
78​
79export class NamedProjectFolderError extends Schema.TaggedError<NamedProjectFolderError>()(
80 "NamedProjectFolderError",
81 {
82 folder: Schema.String,
83 cause: Schema.Defect(),
84 },
85) {
86 override get message(): string {
87 return isGitCommandError(this.cause) && isMissingExecutable(this.cause.cause)
88 ? "Git isn't installed on this machine. Install Git to create a project."
89 : "Failed to create the project folder.";
90 }
92​
93export class NamedProjectCreateError extends Schema.TaggedError<NamedProjectCreateError>()(
94 "NamedProjectCreateError",
95 {
96 workspaceRoot: Schema.String,
97 cause: Schema.Defect(),
98 },
99) {
100 override get message(): string {
101 return "Failed to create the project.";
102 }
104​
105export type NamedProjectError = NamedProjectFolderError | NamedProjectCreateError;
106​
107export class ManagedProjectFolders extends Context.Service<
108 ManagedProjectFolders,
109 {
110 readonly scratchRoot: Effect.Effect<Option.Option<string>>;
111 readonly ensureScratchProject: Effect.Effect<{ readonly projectId: ProjectId }, ScratchError>;
112 readonly isScratchProject: (projectId: ProjectId) => Effect.Effect<boolean>;
113 readonly folderForThread: (input: {
114 readonly projectId: ProjectId;
115 readonly threadId: ThreadId;
116 readonly text: string;
117 }) => Effect.Effect<Option.Option<string>, ScratchFolderError>;
118 readonly namedProjectsRoot: string;
119 /** Removes failed/cancelled creations' folders only while no project owns them. */
120 readonly createNamedProject: (input: { readonly name: string }) => Effect.Effect<
121 {
122 readonly projectId: ProjectId;
123 readonly workspaceRoot: string;
124 readonly commitError?: string;
125 },
126 NamedProjectError
127 >;
128 }
129>()("t3/project/ManagedProjectFolders") {}
130​
131const folderWords = (text: string) =>
132 text
133 .toLowerCase()
134 .split(/[^a-z0-9]+/)
135 .filter(Boolean)
136 .slice(0, 5)
137 .join("-")
138 .slice(0, 48)
139 .replace(/-+$/, "");
140​
141const ICON_BACKGROUNDS = [
142 "#dc2626",
143 "#ea580c",
144 "#d97706",
145 "#16a34a",
146 "#059669",
147 "#0d9488",
148 "#0891b2",
149 "#0284c7",
150 "#2563eb",
151 "#4f46e5",
152 "#7c3aed",
153 "#9333ea",
154 "#c026d3",
155 "#db2777",
156 "#e11d48",
157] as const;
158​
159const MAX_NAMED_FOLDER_ATTEMPTS = 100;
160​
161function escapeXml(value: string): string {
162 return value
163 .replaceAll("&", "&amp;")
164 .replaceAll("<", "&lt;")
165 .replaceAll(">", "&gt;")
166 .replaceAll('"', "&quot;");
168​
169// ProjectFaviconResolver finds assets/icon.svg on every clone.
170function namedProjectIconSvg(name: string): string {
171 const words = name.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
172 const initials =
173 words
174 .slice(0, 2)
175 .map((word) => Array.from(word)[0] ?? "")
176 .join("")
177 .toUpperCase() ||
178 Array.from(name.trim())[0] ||
179 "?";
180 let hash = 0;
181 for (const char of name) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0;
182 const background = ICON_BACKGROUNDS[hash % ICON_BACKGROUNDS.length];
183 return [
184 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">`,
185 ` <rect width="64" height="64" rx="14" fill="${background}"/>`,
186 ` <text x="32" y="32" dy="0.35em" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, sans-serif" font-size="${initials.length > 1 ? 26 : 32}" font-weight="600" fill="#ffffff">${escapeXml(initials)}</text>`,
187 `</svg>`,
188 "",
189 ].join("\n");
191​
192function namedProjectReadme(name: string): string {
193 return [
194 `<img src="assets/icon.svg" width="64" height="64" alt="">`,
195 "",
196 `# ${name}`,
197 "",
198 "Created in [T3 Code](https://t3.codes).",
199 "",
200 ].join("\n");
202​
203function describeCommitFailure(stderr: string): string {
204 if (/identity unknown|tell me who you are|no (name|email) was given/i.test(stderr)) {
205 return "Git has no name or email on this machine. Set user.name and user.email, then commit.";
206 }
207 const lines = stderr
208 .split("\n")
209 .map((line) => line.trim())
210 .filter(Boolean);
211 return lines.at(-1) ?? "Git could not make the first commit.";
213​
214const isVcsProcessSpawnError = Schema.is(VcsProcessSpawnError);
215​
216const isGitMissing = (error: GitManagerServiceError) =>
217 error._tag === "GitManagerError" &&
218 isVcsProcessSpawnError(error.cause) &&
219 PlatformError.isPlatformError(error.cause.cause) &&
220 error.cause.cause.reason._tag === "NotFound";
221​
222const make = Effect.gen(function* () {
223 const config = yield* ServerConfig.ServerConfig;
224 const fileSystem = yield* FileSystem.FileSystem;
225 const path = yield* Path.Path;
226 const gitWorkflow = yield* GitWorkflow.GitWorkflowService;
227 const git = yield* GitVcsDriver.GitVcsDriver;
228 const projects = yield* ProjectService.ProjectService;
229 const random = yield* SecureRandom.SecureRandom;
230​
231 // Non-recursive mkdir atomically claims a name; racers move on after AlreadyExists.
232 const claimFreeFolder = Effect.fnUntraced(function* <E>(
233 folderFor: (attempt: number) => Effect.Effect<Option.Option<string>, E>,
234 onError: (folder: string, cause: PlatformError.PlatformError) => E,
235 ) {
236 for (let attempt = 1; ; attempt++) {
237 const folder = yield* folderFor(attempt);
238 if (Option.isNone(folder)) return folder;
239 const claimed = yield* fileSystem.makeDirectory(folder.value).pipe(
240 Effect.as(true),
241 Effect.catchIf(
242 (error) => error.reason._tag === "AlreadyExists",
243 () => Effect.succeed(false),
244 ),
245 Effect.mapError((cause) => onError(folder.value, cause)),
246 );
247 if (claimed) return folder;
248 }
249 });
250​
251 // Scratch inside a checkout would inherit its git status and checkpoints; require a data dir outside work trees.
252 const [probe, invalidate] = yield* Effect.cachedInvalidateWithTTL(
253 gitWorkflow.repositoryRoot(config.baseDir).pipe(
254 Effect.catchIf(isGitMissing, () => Effect.succeed(null)),
255 Effect.flatMap(
256 (repositoryRoot): Effect.Effect<Result.Result<string, ScratchUnavailableError>> => {
257 if (repositoryRoot === null) {
258 return Effect.succeed(Result.succeed(path.resolve(config.baseDir, "scratch")));
259 }
260 const unavailable = new ScratchUnavailableError({
261 baseDir: config.baseDir,
262 repositoryRoot,
263 });
264 return Effect.logWarning(unavailable.message).pipe(Effect.as(Result.fail(unavailable)));
265 },
266 ),
267 Effect.catchCause((cause) =>
268 Cause.hasInterrupts(cause)
269 ? Effect.interrupt
270 : Effect.succeed(Result.fail(new ScratchUnavailableError({ baseDir: config.baseDir }))),
271 ),
272 ),
273 Duration.infinity,
274 );
275 const scratchProbe = probe.pipe(Effect.onInterrupt(() => invalidate));
276 // A symlinked scratch folder lets the No project live at either the link or its target.
277 const scratchProjectRoot = (root: string) =>
278 fileSystem.realPath(root).pipe(
279 Effect.flatMap((target) =>
280 target === root
281 ? Effect.succeed(root)
282 : projects
283 .getByWorkspaceRoot(target)
284 .pipe(Effect.map((project) => (Option.isSome(project) ? target : root))),
285 ),
286 Effect.orElseSucceed(() => root),
287 );
288 const scratchRoot = scratchProbe.pipe(
289 Effect.flatMap((probed) =>
290 Result.isSuccess(probed)
291 ? scratchProjectRoot(probed.success).pipe(Effect.asSome)
292 : Effect.succeedNone,
293 ),
294 );
295​
296 const makeScratchFolder = (folder: string) =>
297 fileSystem
298 .makeDirectory(folder, { recursive: true })
299 .pipe(Effect.mapError((cause) => new ScratchFolderError({ folder, cause })));
300​
301 const ensureScratchProject: ManagedProjectFolders["Service"]["ensureScratchProject"] = Effect.gen(
302 function* () {
303 const probedRoot = yield* scratchProbe.pipe(Effect.flatMap(Effect.fromResult));
304 yield* makeScratchFolder(probedRoot);
305 const workspaceRoot = yield* scratchProjectRoot(probedRoot);
306 const id = yield* random
307 .uuidV4()
308 .pipe(Effect.mapError((cause) => new ScratchProjectError({ workspaceRoot, cause })));
309 const bootstrapped = yield* projects
310 .bootstrap({
311 commandId: CommandId.make(`scratch-project:${id}`),
312 projectId: ProjectId.make(id),
313 title: "No project",
314 workspaceRoot,
315 })
316 .pipe(
317 // bootstrap reads before locking; racing creates return the winner in a conflict.
318 Effect.catchTags({
319 ProjectConflictError: (conflict) =>
320 Effect.succeed({
321 project: { id: conflict.conflictingProjectId },
322 created: false,
323 }),
324 }),
325 Effect.mapError((cause) => new ScratchProjectError({ workspaceRoot, cause })),
326 );
327 if (bootstrapped.created) {
328 // Set once at create, so a user's own icon choice is never overwritten.
329 yield* projects
330 .update({
331 commandId: CommandId.make(`scratch-project-icon:${id}`),
332 projectId: bootstrapped.project.id,
333 projectIcon: { kind: "lucide", name: "message-square-dashed", color: "gray" },
334 })
335 .pipe(Effect.mapError((cause) => new ScratchProjectError({ workspaceRoot, cause })));
336 }
337 return { projectId: bootstrapped.project.id };
338 },
339 );
340​
341 const isScratchRoot = (projectId: ProjectId, root: string) =>
342 projects.getById(projectId).pipe(
343 Effect.map(
344 Option.exists((project) => path.resolve(project.workspaceRoot) === path.resolve(root)),
345 ),
346 Effect.orElseSucceed(() => false),
347 );
348​
349 const isScratchProject: ManagedProjectFolders["Service"]["isScratchProject"] = (projectId) =>
350 scratchRoot.pipe(
351 Effect.flatMap(
352 Option.match({
353 onNone: () => Effect.succeed(false),
354 onSome: (root) => isScratchRoot(projectId, root),
355 }),
356 ),
357 );
358​
359 const folderForThread: ManagedProjectFolders["Service"]["folderForThread"] = Effect.fn(
360 "ManagedProjectFolders.folderForThread",
361 )(function* (input) {
362 const root = yield* scratchRoot;
363 if (Option.isNone(root)) return Option.none();
364 if (!(yield* isScratchRoot(input.projectId, root.value))) return Option.none();
365 const date = DateTime.formatIso(yield* DateTime.now).slice(0, 10);
366 const words = folderWords(input.text);
367 const id = input.threadId.toLowerCase().replace(/[^a-z0-9]/g, "");
368 const folderFor = (idPart: string) =>
369 path.join(root.value, [date, words, idPart].filter(Boolean).join("-"));
370 yield* makeScratchFolder(root.value);
371 const fullFolder = folderFor(id);
372 return yield* claimFreeFolder(
373 (attempt) =>
374 attempt === 1
375 ? Effect.succeedSome(folderFor(id.slice(-8)))
376 : attempt === 2
377 ? Effect.succeedSome(fullFolder)
378 : random.uuidV4().pipe(
379 Effect.map((uuid) => Option.some(`${fullFolder}-${uuid.slice(0, 8)}`)),
380 Effect.mapError((cause) => new ScratchFolderError({ folder: fullFolder, cause })),
381 ),
382 (folder, cause) => new ScratchFolderError({ folder, cause }),
383 );
384 });
385​
386 const namedProjectsRoot = path.resolve(config.baseDir, "projects");
387​
388 const claimNamedFolder = Effect.fn("ManagedProjectFolders.claimNamedFolder")(function* (
389 name: string,
390 ) {
391 yield* fileSystem
392 .makeDirectory(namedProjectsRoot, { recursive: true })
393 .pipe(
394 Effect.mapError(
395 (cause) => new NamedProjectFolderError({ folder: namedProjectsRoot, cause }),
396 ),
397 );
398 const folderName = newProjectFolderName(name);
399 const claimed = yield* claimFreeFolder(
400 (attempt) =>
401 Effect.succeed(
402 attempt > MAX_NAMED_FOLDER_ATTEMPTS
403 ? Option.none()
404 : Option.some(
405 path.join(
406 namedProjectsRoot,
407 attempt === 1 ? folderName : `${folderName}-${attempt}`,
408 ),
409 ),
410 ),
411 (folder, cause) => new NamedProjectFolderError({ folder, cause }),
412 );
413 if (Option.isSome(claimed)) return claimed.value;
414 return yield* new NamedProjectFolderError({
415 folder: path.join(namedProjectsRoot, folderName),
416 cause: `Every folder name for "${folderName}" is taken.`,
417 });
418 });
419​
420 const scaffoldRepository = Effect.fn("ManagedProjectFolders.scaffoldRepository")(function* (
421 cwd: string,
422 name: string,
423 ) {
424 const branch =
425 (yield* git
426 .readConfigValue(cwd, "init.defaultBranch")
427 .pipe(Effect.orElseSucceed(() => null))) ?? "main";
428 yield* git.execute({
429 operation: "ManagedProjectFolders.init",
430 cwd,
431 args: ["init", `--initial-branch=${branch}`],
432 timeoutMs: 10_000,
433 });
434 yield* fileSystem.writeFileString(path.join(cwd, "README.md"), namedProjectReadme(name));
435 yield* fileSystem.makeDirectory(path.join(cwd, "assets"));
436 yield* fileSystem.writeFileString(
437 path.join(cwd, "assets", "icon.svg"),
438 namedProjectIconSvg(name),
439 );
440 // Force explicit filenames so global ignore rules cannot drop them, and keep
441 // core.safecrlf=true from refusing the LF files under CRLF conversion.
442 yield* git.execute({
443 operation: "ManagedProjectFolders.add",
444 cwd,
445 args: ["-c", "core.safecrlf=false", "add", "--force", "--", "README.md", "assets/icon.svg"],
446 timeoutMs: 10_000,
447 });
448 return yield* git
449 .execute({
450 operation: "ManagedProjectFolders.commit",
451 cwd,
452 args: ["commit", "--message", "Initial commit"],
453 allowNonZeroExit: true,
454 timeoutMs: 30_000,
455 })
456 .pipe(
457 Effect.map((result) =>
458 result.exitCode === 0 ? undefined : describeCommitFailure(result.stderr),
459 ),
460 Effect.catch((error) => Effect.succeed(error.message)),
461 );
462 });
463​
464 const createNamedProject: ManagedProjectFolders["Service"]["createNamedProject"] = Effect.fn(
465 "ManagedProjectFolders.createNamedProject",
466 )(function* (input) {
467 const workspaceRoot = yield* claimNamedFolder(input.name);
468 const removeFolder = fileSystem.remove(workspaceRoot, { recursive: true }).pipe(Effect.ignore);
469 const prepared = yield* Effect.all([
470 scaffoldRepository(workspaceRoot, input.name).pipe(
471 Effect.mapError((cause) => new NamedProjectFolderError({ folder: workspaceRoot, cause })),
472 ),
473 random
474 .uuidV4()
475 .pipe(Effect.mapError((cause) => new NamedProjectCreateError({ workspaceRoot, cause }))),
476 ]).pipe(Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : removeFolder)));
477 const [commitError, id] = prepared;
478 const project = yield* projects
479 .create({
480 commandId: CommandId.make(`named-project:${id}`),
481 projectId: ProjectId.make(id),
482 title: input.name,
483 workspaceRoot,
484 })
485 .pipe(
486 // Re-check ownership before deleting rejected creations; interruption after commit or conflicts can leave an owner.
487 Effect.tapError((error) =>
488 error._tag === "ProjectConflictError"
489 ? Effect.void
490 : projects.getByWorkspaceRoot(workspaceRoot).pipe(
491 Effect.flatMap((owner) => (Option.isNone(owner) ? removeFolder : Effect.void)),
492 Effect.ignore,
493 ),
494 ),
495 Effect.mapError((cause) => new NamedProjectCreateError({ workspaceRoot, cause })),
496 );
497 return {
498 projectId: project.id,
499 workspaceRoot,
500 ...(commitError === undefined ? {} : { commitError }),
501 };
502 });
503​
504 return ManagedProjectFolders.of({
505 scratchRoot,
506 ensureScratchProject,
507 isScratchProject,
508 folderForThread,
509 namedProjectsRoot,
510 createNamedProject,
511 });
512});
513​
514export const layer = Layer.effect(ManagedProjectFolders, make);