1/**
2 * ManagedProjectFolders - the project folders T3 Code makes for the user under
3 * its data dir, rather than ones the user picks:
4 *
5 * - `<baseDir>/scratch`: the Scratch project ("No project"), with a folder of
6 * its own for each thread;
7 * - `<baseDir>/projects/<slug>`: projects started from just a name, each a new
8 * Git repository with a README, an icon, and a first commit.
9 *
10 * @module ManagedProjectFolders
11 */
12import {
13 CommandId,
14 type GitManagerServiceError,
15 ProjectId,
16 type ThreadId,
17 VcsProcessSpawnError,
18} from "@t3tools/contracts";
19import { newProjectFolderName } from "@t3tools/shared/path";
20import * as Cause from "effect/Cause";
21import * as Context from "effect/Context";
22import * as Crypto from "effect/Crypto";
23import * as DateTime from "effect/DateTime";
24import * as Duration from "effect/Duration";
25import * as Effect from "effect/Effect";
26import * as Exit from "effect/Exit";
27import * as FileSystem from "effect/FileSystem";
28import * as Layer from "effect/Layer";
29import * as Option from "effect/Option";
30import * as Path from "effect/Path";
31import * as PlatformError from "effect/PlatformError";
32import * as Schema from "effect/Schema";
33
34import * as ServerConfig from "../config.ts";
35import * as GitWorkflow from "../git/GitWorkflowService.ts";
36import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
37import * as ProjectService from "./ProjectService.ts";
38
39export class ScratchUnavailableError extends Schema.TaggedError<ScratchUnavailableError>()(
40 "ScratchUnavailableError",
41 {},
42) {
43 override get message(): string {
44 return "Threads without a project are not available on this environment.";
45 }
46}
47
48export class ScratchFolderError extends Schema.TaggedError<ScratchFolderError>()(
49 "ScratchFolderError",
50 {
51 folder: Schema.String,
52 cause: Schema.Defect(),
53 },
54) {
55 override get message(): string {
56 return "Failed to create the folder for threads without a project.";
57 }
58}
59
60export class ScratchProjectError extends Schema.TaggedError<ScratchProjectError>()(
61 "ScratchProjectError",
62 {
63 workspaceRoot: Schema.String,
64 cause: Schema.Defect(),
65 },
66) {
67 override get message(): string {
68 return "Failed to create the project for threads without a project.";
69 }
70}
71
72export type ScratchError = ScratchUnavailableError | ScratchFolderError | ScratchProjectError;
73
74export class NamedProjectFolderError extends Schema.TaggedError<NamedProjectFolderError>()(
75 "NamedProjectFolderError",
76 {
77 folder: Schema.String,
78 cause: Schema.Defect(),
79 },
80) {
81 override get message(): string {
82 return "Failed to create the project folder.";
83 }
84}
85
86export class NamedProjectCreateError extends Schema.TaggedError<NamedProjectCreateError>()(
87 "NamedProjectCreateError",
88 {
89 workspaceRoot: Schema.String,
90 cause: Schema.Defect(),
91 },
92) {
93 override get message(): string {
94 return "Failed to create the project.";
95 }
96}
97
98export type NamedProjectError = NamedProjectFolderError | NamedProjectCreateError;
99
100export class ManagedProjectFolders extends Context.Service<
101 ManagedProjectFolders,
102 {
103 /** The Scratch folder, or None when this environment does not offer one. */
104 readonly scratchRoot: Effect.Effect<Option.Option<string>>;
105 /** Finds or creates the Scratch project and (re)creates its folder. */
106 readonly ensureScratchProject: Effect.Effect<{ readonly projectId: ProjectId }, ScratchError>;
107 /**
108 * Claims a fresh folder for a new thread in the Scratch project, named from
109 * the date, its first message, and its id. None for every other project.
110 */
111 readonly folderForThread: (input: {
112 readonly projectId: ProjectId;
113 readonly threadId: ThreadId;
114 readonly text: string;
115 }) => Effect.Effect<Option.Option<string>, ScratchFolderError>;
116 /** The folder that holds projects started from just a name. */
117 readonly namedProjectsRoot: string;
118 /**
119 * Starts a project from just a name: claims `<namedProjectsRoot>/<slug>`
120 * (adding `-2`, `-3`, ... when taken), makes it a Git repository with a
121 * README, an icon, and a first commit, then creates the project. A failed
122 * commit (no Git identity, a signing prompt) keeps the project and returns
123 * why in `commitError`. The folder is removed when the create fails or is
124 * cancelled before the project exists, never once another project owns it.
125 */
126 readonly createNamedProject: (input: { readonly name: string }) => Effect.Effect<
127 {
128 readonly projectId: ProjectId;
129 readonly workspaceRoot: string;
130 readonly commitError?: string;
131 },
132 NamedProjectError
133 >;
134 }
135>()("t3/project/ManagedProjectFolders") {}
136
137// Only [a-z0-9] reaches a folder name, so it stays one path segment, and the
138// words are capped so a pasted blob cannot outgrow a file name.
139const folderWords = (text: string) =>
140 text
141 .toLowerCase()
142 .split(/[^a-z0-9]+/)
143 .filter(Boolean)
144 .slice(0, 5)
145 .join("-")
146 .slice(0, 48)
147 .replace(/-+$/, "");
148
149// Tailwind 600 shades: dark enough for white initials on every hue.
150const ICON_BACKGROUNDS = [
151 "#dc2626",
152 "#ea580c",
153 "#d97706",
154 "#16a34a",
155 "#059669",
156 "#0d9488",
157 "#0891b2",
158 "#0284c7",
159 "#2563eb",
160 "#4f46e5",
161 "#7c3aed",
162 "#9333ea",
163 "#c026d3",
164 "#db2777",
165 "#e11d48",
166] as const;
167
168const MAX_NAMED_FOLDER_ATTEMPTS = 100;
169
170function escapeXml(value: string): string {
171 return value
172 .replaceAll("&", "&amp;")
173 .replaceAll("<", "&lt;")
174 .replaceAll(">", "&gt;")
175 .replaceAll('"', "&quot;");
176}
177
178/**
179 * A rounded square with the name's initials, colored by a hash of the name.
180 * It lives at `assets/icon.svg`, a path ProjectFaviconResolver already checks,
181 * so every machine that clones the project shows the same icon.
182 */
183function namedProjectIconSvg(name: string): string {
184 const words = name.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
185 const initials =
186 words
187 .slice(0, 2)
188 .map((word) => Array.from(word)[0] ?? "")
189 .join("")
190 .toUpperCase() ||
191 Array.from(name.trim())[0] ||
192 "?";
193 let hash = 0;
194 for (const char of name) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0;
195 const background = ICON_BACKGROUNDS[hash % ICON_BACKGROUNDS.length];
196 return [
197 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">`,
198 ` <rect width="64" height="64" rx="14" fill="${background}"/>`,
199 ` <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>`,
200 `</svg>`,
201 "",
202 ].join("\n");
203}
204
205function namedProjectReadme(name: string): string {
206 return [
207 `<img src="assets/icon.svg" width="64" height="64" alt="">`,
208 "",
209 `# ${name}`,
210 "",
211 "Created in [T3 Code](https://t3.codes).",
212 "",
213 ].join("\n");
214}
215
216// Git's own identity message runs several lines; say what to do instead.
217// Otherwise its last line ("error: gpg failed to sign the data") says enough.
218function describeCommitFailure(stderr: string): string {
219 if (/identity unknown|tell me who you are|no (name|email) was given/i.test(stderr)) {
220 return "Git has no name or email on this machine. Set user.name and user.email, then commit.";
221 }
222 const lines = stderr
223 .split("\n")
224 .map((line) => line.trim())
225 .filter(Boolean);
226 return lines.at(-1) ?? "Git could not make the first commit.";
227}
228
229const isVcsProcessSpawnError = Schema.is(VcsProcessSpawnError);
230
231// Spawning a `git` that is not on PATH fails with NotFound, on Windows too.
232const isGitMissing = (error: GitManagerServiceError) =>
233 error._tag === "GitManagerError" &&
234 isVcsProcessSpawnError(error.cause) &&
235 PlatformError.isPlatformError(error.cause.cause) &&
236 error.cause.cause.reason._tag === "NotFound";
237
238const make = Effect.gen(function* () {
239 const config = yield* ServerConfig.ServerConfig;
240 const fileSystem = yield* FileSystem.FileSystem;
241 const path = yield* Path.Path;
242 const gitWorkflow = yield* GitWorkflow.GitWorkflowService;
243 const git = yield* GitVcsDriver.GitVcsDriver;
244 const projects = yield* ProjectService.ProjectService;
245 const crypto = yield* Crypto.Crypto;
246
247 /**
248 * Claims the first free folder among `folderFor(1)`, `folderFor(2)`, ...,
249 * stopping with None when `folderFor` runs out of names. Each folder is
250 * created without `recursive`, so creating it is the claim: of two racers
251 * for one name, one gets AlreadyExists and moves on to the next. Scratch
252 * thread folders and named projects both claim through this, so no two
253 * threads or projects ever share a folder.
254 */
255 const claimFreeFolder = Effect.fnUntraced(function* <E>(
256 folderFor: (attempt: number) => Effect.Effect<Option.Option<string>, E>,
257 onError: (folder: string, cause: PlatformError.PlatformError) => E,
258 ) {
259 for (let attempt = 1; ; attempt++) {
260 const folder = yield* folderFor(attempt);
261 if (Option.isNone(folder)) return folder;
262 const claimed = yield* fileSystem.makeDirectory(folder.value).pipe(
263 Effect.as(true),
264 Effect.catchIf(
265 (error) => error.reason._tag === "AlreadyExists",
266 () => Effect.succeed(false),
267 ),
268 Effect.mapError((cause) => onError(folder.value, cause)),
269 );
270 if (claimed) return folder;
271 }
272 });
273
274 // Inside a checkout (a dev worktree's .t3, a dotfiles home) the folder would
275 // inherit the repo's git status and checkpoints, so Scratch is offered only
276 // when the data dir is outside any work tree. With no Git installed nothing
277 // can see a checkout, so Scratch is offered. Probed once; other detection
278 // failures hide Scratch rather than failing callers. An interrupted probe
279 // invalidates the cache so the next caller probes again.
280 const [probe, invalidate] = yield* Effect.cachedInvalidateWithTTL(
281 gitWorkflow.isRepository(config.baseDir).pipe(
282 Effect.catchIf(isGitMissing, () => Effect.succeed(false)),
283 Effect.map((isRepository) =>
284 isRepository ? Option.none<string>() : Option.some(path.resolve(config.baseDir, "scratch")),
285 ),
286 Effect.catchCause((cause) =>
287 Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(Option.none<string>()),
288 ),
289 ),
290 Duration.infinity,
291 );
292 const scratchRoot = probe.pipe(Effect.onInterrupt(() => invalidate));
293
294 const makeScratchFolder = (folder: string) =>
295 fileSystem
296 .makeDirectory(folder, { recursive: true })
297 .pipe(Effect.mapError((cause) => new ScratchFolderError({ folder, cause })));
298
299 const ensureScratchProject: ManagedProjectFolders["Service"]["ensureScratchProject"] = Effect.gen(
300 function* () {
301 const workspaceRoot = yield* scratchRoot.pipe(
302 Effect.flatMap(
303 Option.match({
304 onNone: () => Effect.fail(new ScratchUnavailableError()),
305 onSome: Effect.succeed,
306 }),
307 ),
308 );
309 // Re-made on every call, so a deleted Scratch folder still runs threads.
310 yield* makeScratchFolder(workspaceRoot);
311 const id = yield* crypto
312 .randomUUIDv4()
313 .pipe(Effect.mapError((cause) => new ScratchProjectError({ workspaceRoot, cause })));
314 const bootstrapped = yield* projects
315 .bootstrap({
316 commandId: CommandId.make(`scratch-project:${id}`),
317 projectId: ProjectId.make(id),
318 title: "No project",
319 workspaceRoot,
320 })
321 .pipe(
322 // bootstrap looks the root up before taking the workspace lock, so a
323 // racing create loses with a conflict that names the winner.
324 Effect.catchTags({
325 ProjectConflictError: (conflict) =>
326 Effect.succeed({
327 project: { id: conflict.conflictingProjectId },
328 created: false,
329 }),
330 }),
331 Effect.mapError((cause) => new ScratchProjectError({ workspaceRoot, cause })),
332 );
333 if (bootstrapped.created) {
334 // A dashed chat bubble in neutral gray marks Scratch. Set once at
335 // create, so a user's own icon choice is never overwritten.
336 yield* projects
337 .update({
338 commandId: CommandId.make(`scratch-project-icon:${id}`),
339 projectId: bootstrapped.project.id,
340 projectIcon: { kind: "lucide", name: "message-square-dashed", color: "gray" },
341 })
342 .pipe(Effect.mapError((cause) => new ScratchProjectError({ workspaceRoot, cause })));
343 }
344 return { projectId: bootstrapped.project.id };
345 },
346 );
347
348 const isScratchProject = (projectId: ProjectId, root: string) =>
349 projects.getById(projectId).pipe(
350 Effect.map(
351 Option.exists((project) => path.resolve(project.workspaceRoot) === path.resolve(root)),
352 ),
353 // An unreadable project is not Scratch; the launch reports its own
354 // project lookup failure.
355 Effect.orElseSucceed(() => false),
356 );
357
358 const folderForThread: ManagedProjectFolders["Service"]["folderForThread"] = Effect.fn(
359 "ManagedProjectFolders.folderForThread",
360 )(function* (input) {
361 const root = yield* scratchRoot;
362 if (Option.isNone(root)) return Option.none();
363 if (!(yield* isScratchProject(input.projectId, root.value))) return Option.none();
364 const date = DateTime.formatIso(yield* DateTime.now).slice(0, 10);
365 const words = folderWords(input.text);
366 const id = input.threadId.toLowerCase().replace(/[^a-z0-9]/g, "");
367 const folderFor = (idPart: string) =>
368 path.join(root.value, [date, words, idPart].filter(Boolean).join("-"));
369 yield* makeScratchFolder(root.value);
370 const fullFolder = folderFor(id);
371 // Thread ids often share a prefix ("thread:..."), so the first name uses
372 // the id's tail. A taken short name falls back to the full id, which only
373 // this thread holds. Ids that normalize alike, or a launch retried without
374 // its receipt, can find the full name taken too, so later attempts add a
375 // fresh random suffix; the names never run out.
376 return yield* claimFreeFolder(
377 (attempt) =>
378 attempt === 1
379 ? Effect.succeed(Option.some(folderFor(id.slice(-8))))
380 : attempt === 2
381 ? Effect.succeed(Option.some(fullFolder))
382 : crypto.randomUUIDv4().pipe(
383 Effect.map((uuid) => Option.some(`${fullFolder}-${uuid.slice(0, 8)}`)),
384 Effect.mapError((cause) => new ScratchFolderError({ folder: fullFolder, cause })),
385 ),
386 (folder, cause) => new ScratchFolderError({ folder, cause }),
387 );
388 });
389
390 // Projects started from just a name live beside Scratch and worktrees, away
391 // from folders the user organizes by hand. A nested repository is fine here
392 // (unlike Scratch) because each project gets its own `git init`.
393 const namedProjectsRoot = path.resolve(config.baseDir, "projects");
394
395 const claimNamedFolder = Effect.fn("ManagedProjectFolders.claimNamedFolder")(function* (
396 name: string,
397 ) {
398 yield* fileSystem
399 .makeDirectory(namedProjectsRoot, { recursive: true })
400 .pipe(
401 Effect.mapError(
402 (cause) => new NamedProjectFolderError({ folder: namedProjectsRoot, cause }),
403 ),
404 );
405 const folderName = newProjectFolderName(name);
406 const claimed = yield* claimFreeFolder(
407 (attempt) =>
408 Effect.succeed(
409 attempt > MAX_NAMED_FOLDER_ATTEMPTS
410 ? Option.none()
411 : Option.some(
412 path.join(
413 namedProjectsRoot,
414 attempt === 1 ? folderName : `${folderName}-${attempt}`,
415 ),
416 ),
417 ),
418 (folder, cause) => new NamedProjectFolderError({ folder, cause }),
419 );
420 if (Option.isSome(claimed)) return claimed.value;
421 return yield* new NamedProjectFolderError({
422 folder: path.join(namedProjectsRoot, folderName),
423 cause: `Every folder name for "${folderName}" is taken.`,
424 });
425 });
426
427 // git init, the starter files, and the first commit. Only the commit may
428 // fail softly; everything before it fails the create.
429 const scaffoldRepository = Effect.fn("ManagedProjectFolders.scaffoldRepository")(function* (
430 cwd: string,
431 name: string,
432 ) {
433 const branch =
434 (yield* git
435 .readConfigValue(cwd, "init.defaultBranch")
436 .pipe(Effect.orElseSucceed(() => null))) ?? "main";
437 yield* git.execute({
438 operation: "ManagedProjectFolders.init",
439 cwd,
440 args: ["init", `--initial-branch=${branch}`],
441 timeoutMs: 10_000,
442 });
443 yield* fileSystem.writeFileString(path.join(cwd, "README.md"), namedProjectReadme(name));
444 yield* fileSystem.makeDirectory(path.join(cwd, "assets"));
445 yield* fileSystem.writeFileString(
446 path.join(cwd, "assets", "icon.svg"),
447 namedProjectIconSvg(name),
448 );
449 // Named and forced so a global ignore rule (say `*.svg`) cannot drop one.
450 yield* git.execute({
451 operation: "ManagedProjectFolders.add",
452 cwd,
453 args: ["add", "--force", "--", "README.md", "assets/icon.svg"],
454 timeoutMs: 10_000,
455 });
456 return yield* git
457 .execute({
458 operation: "ManagedProjectFolders.commit",
459 cwd,
460 args: ["commit", "--message", "Initial commit"],
461 allowNonZeroExit: true,
462 timeoutMs: 30_000,
463 })
464 .pipe(
465 Effect.map((result) =>
466 result.exitCode === 0 ? undefined : describeCommitFailure(result.stderr),
467 ),
468 Effect.catch((error) => Effect.succeed(error.message)),
469 );
470 });
471
472 const createNamedProject: ManagedProjectFolders["Service"]["createNamedProject"] = Effect.fn(
473 "ManagedProjectFolders.createNamedProject",
474 )(function* (input) {
475 const workspaceRoot = yield* claimNamedFolder(input.name);
476 const removeFolder = fileSystem.remove(workspaceRoot, { recursive: true }).pipe(Effect.ignore);
477 // Until the create is dispatched nothing else can use the folder, so any
478 // exit but success removes it, an interrupt included.
479 const prepared = yield* Effect.all([
480 scaffoldRepository(workspaceRoot, input.name).pipe(
481 Effect.mapError((cause) => new NamedProjectFolderError({ folder: workspaceRoot, cause })),
482 ),
483 crypto
484 .randomUUIDv4()
485 .pipe(Effect.mapError((cause) => new NamedProjectCreateError({ workspaceRoot, cause }))),
486 ]).pipe(Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : removeFolder)));
487 const [commitError, id] = prepared;
488 const project = yield* projects
489 .create({
490 commandId: CommandId.make(`named-project:${id}`),
491 projectId: ProjectId.make(id),
492 title: input.name,
493 workspaceRoot,
494 })
495 .pipe(
496 // A rejected create leaves the folder unused, so remove it. An
497 // interrupt can land after the create is committed, and a conflict
498 // means another project owns the folder, so it stays then; the owner
499 // is checked again first, since deleting another project's files is
500 // never acceptable.
501 Effect.tapError((error) =>
502 error._tag === "ProjectConflictError"
503 ? Effect.void
504 : projects.getByWorkspaceRoot(workspaceRoot).pipe(
505 Effect.flatMap((owner) => (Option.isNone(owner) ? removeFolder : Effect.void)),
506 Effect.ignore,
507 ),
508 ),
509 Effect.mapError((cause) => new NamedProjectCreateError({ workspaceRoot, cause })),
510 );
511 return {
512 projectId: project.id,
513 workspaceRoot,
514 ...(commitError === undefined ? {} : { commitError }),
515 };
516 });
517
518 return ManagedProjectFolders.of({
519 scratchRoot,
520 ensureScratchProject,
521 folderForThread,
522 namedProjectsRoot,
523 createNamedProject,
524 });
525});
526
527export const layer = Layer.effect(ManagedProjectFolders, make);