1import {
2 AuthAdministrativeScopes,
3 CommandId,
4 EnvironmentHttpApi,
5 EnvironmentHttpCommonError,
6 type ProjectMutation,
7 type ProjectSnapshot,
8 ProjectId,
9} from "@t3tools/contracts";
10import * as Console from "effect/Console";
11import * as Crypto from "effect/Crypto";
12import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";
14import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Path from "effect/Path";
18import * as References from "effect/References";
19import * as Schema from "effect/Schema";
20import { Argument, Command, Flag, GlobalFlag } from "effect/cli";
21import { FetchHttpClient, HttpClient, HttpClientError } from "effect/http";
22import * as HttpApiClient from "effect/http-api/HttpApiClient";
23
24import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
25import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
26
27import * as ServerConfig from "../config.ts";
28import * as SqlitePersistence from "../persistence/Sqlite.ts";
29import * as ProcessRunner from "../processRunner.ts";
30import * as ServerOwnership from "../serverOwnership.ts";
31import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
32import * as ArchiveRuntime from "../archive/runtimeLayer.ts";
33import * as ArchiveConnection from "../archive/ArchiveConnection.ts";
34import * as SharedServer from "../environment/SharedServer.ts";
35import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
36import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
37import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
38import * as ProjectService from "../project/ProjectService.ts";
39import { projectMutationOperation } from "../project/ProjectMutation.ts";
40import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
41import * as ServerSettings from "../serverSettings.ts";
42import {
43 clearPersistedServerRuntimeState,
44 readPersistedServerRuntimeState,
45} from "../serverRuntimeState.ts";
46import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
47import { type CliAuthLocationFlags, projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
48
49type ProjectMutationTarget = {
50 readonly id: ProjectId;
51 readonly title: string;
52 readonly workspaceRoot: string;
53};
54
55type ProjectCommandExecutionMode = "live" | "offline";
56type ProjectCliDispatchCommand = ProjectMutation;
57
58const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
59
60export class ProjectCommandIdGenerationError extends Schema.TaggedError<ProjectCommandIdGenerationError>()(
61 "ProjectCommandIdGenerationError",
62 {
63 operation: Schema.Literal("generateProjectCommandId"),
64 cause: Schema.Defect(),
65 },
66) {
67 override get message(): string {
68 return "Failed to generate a project command identifier.";
69 }
70}
71
72export class ProjectLiveServerDeclaredResponseError extends Schema.TaggedError<ProjectLiveServerDeclaredResponseError>()(
73 "ProjectLiveServerDeclaredResponseError",
74 {
75 operation: Schema.Literal("callLiveServer"),
76 code: Schema.String,
77 traceId: Schema.String,
78 cause: Schema.Defect(),
79 },
80) {
81 override get message(): string {
82 return `Server request failed (${this.code}, trace ${this.traceId}).`;
83 }
84}
85
86export class ProjectLiveServerUndeclaredStatusError extends Schema.TaggedError<ProjectLiveServerUndeclaredStatusError>()(
87 "ProjectLiveServerUndeclaredStatusError",
88 {
89 operation: Schema.Literal("callLiveServer"),
90 status: Schema.Int,
91 cause: Schema.Defect(),
92 },
93) {
94 override get message(): string {
95 return `Server request failed with undeclared status ${this.status}.`;
96 }
97}
98
99export class ProjectLiveServerRequestError extends Schema.TaggedError<ProjectLiveServerRequestError>()(
100 "ProjectLiveServerRequestError",
101 {
102 operation: Schema.Literal("callLiveServer"),
103 cause: Schema.Defect(),
104 },
105) {
106 override get message(): string {
107 return "Failed to call the running server.";
108 }
109}
110
111export class ProjectTitleEmptyError extends Schema.TaggedError<ProjectTitleEmptyError>()(
112 "ProjectTitleEmptyError",
113 {
114 operation: Schema.Literal("validateProjectTitle"),
115 title: Schema.String,
116 },
117) {
118 override get message(): string {
119 return "Project title cannot be empty.";
120 }
121}
122
123export class ProjectIdentifierEmptyError extends Schema.TaggedError<ProjectIdentifierEmptyError>()(
124 "ProjectIdentifierEmptyError",
125 {
126 operation: Schema.Literal("resolveProjectTarget"),
127 identifier: Schema.String,
128 },
129) {
130 override get message(): string {
131 return "Project identifier cannot be empty.";
132 }
133}
134
135export class ProjectNotFoundError extends Schema.TaggedError<ProjectNotFoundError>()(
136 "ProjectNotFoundError",
137 {
138 operation: Schema.Literal("resolveProjectTarget"),
139 identifier: Schema.String,
140 normalizedWorkspaceRoot: Schema.optional(Schema.String),
141 activeProjectCount: Schema.Number,
142 cause: Schema.optional(Schema.Defect()),
143 },
144) {
145 override get message(): string {
146 return `No active project found for '${this.identifier}'.`;
147 }
148}
149
150export class ProjectAlreadyExistsError extends Schema.TaggedError<ProjectAlreadyExistsError>()(
151 "ProjectAlreadyExistsError",
152 {
153 operation: Schema.Literal("addProject"),
154 projectId: ProjectId,
155 workspaceRoot: Schema.String,
156 },
157) {
158 override get message(): string {
159 return `An active project already exists for '${this.workspaceRoot}'.`;
160 }
161}
162
163export const ProjectCommandError = Schema.Union([
164 ProjectCommandIdGenerationError,
165 ProjectLiveServerDeclaredResponseError,
166 ProjectLiveServerUndeclaredStatusError,
167 ProjectLiveServerRequestError,
168 ProjectTitleEmptyError,
169 ProjectIdentifierEmptyError,
170 ProjectNotFoundError,
171 ProjectAlreadyExistsError,
172]);
173export type ProjectCommandError = typeof ProjectCommandError.Type;
174
175export function projectCommandErrorFromLiveServerRequest(cause: unknown): ProjectCommandError {
176 if (isEnvironmentHttpCommonError(cause)) {
177 return new ProjectLiveServerDeclaredResponseError({
178 operation: "callLiveServer",
179 code: cause.code,
180 traceId: cause.traceId,
181 cause,
182 });
183 }
184 if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) {
185 return new ProjectLiveServerUndeclaredStatusError({
186 operation: "callLiveServer",
187 status: cause.response.status,
188 cause,
189 });
190 }
191
192 return new ProjectLiveServerRequestError({ operation: "callLiveServer", cause });
193}
194
195const projectCommandUuid = Crypto.Crypto.pipe(
196 Effect.flatMap((crypto) => crypto.randomUUIDv4()),
197 Effect.mapError(
198 (cause) =>
199 new ProjectCommandIdGenerationError({
200 operation: "generateProjectCommandId",
201 cause,
202 }),
203 ),
204);
205
206const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe(
207 Layer.provideMerge(
208 ArchiveRuntime.layer.pipe(
209 Layer.provide(ArchiveConnection.layerLegacy.pipe(Layer.provide(SharedServer.layer))),
210 Layer.provide(ServerSecretStore.layer),
211 ),
212 ),
213 Layer.provideMerge(ProjectEnrichmentService.layer),
214 Layer.provideMerge(RepositoryIdentityResolver.layer),
215 Layer.provideMerge(
216 ProjectFaviconResolver.layer.pipe(
217 Layer.provide(WorkspacePaths.layer),
218 Layer.provide(T3ProjectFileLoader.layer),
219 ),
220 ),
221 Layer.provideMerge(WorkspacePaths.layer),
222 Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))),
223 Layer.provideMerge(SqlitePersistence.layerConfig),
224);
225
226const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1);
227const withProjectCliSessionToken = <A, E, R>(
228 environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"],
229 run: (token: string) => Effect.Effect<A, E, R>,
230) =>
231 Effect.acquireUseRelease(
232 environmentAuth.issueSession({
233 scopes: AuthAdministrativeScopes,
234 label: "t3 project cli",
235 }),
236 (issued) => run(issued.token),
237 (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
238 );
239
240const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
241 effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT));
242
243const makeLiveServerClient = (origin: string) =>
244 HttpApiClient.make(EnvironmentHttpApi, {
245 baseUrl: origin,
246 });
247
248const normalizeWorkspaceRootForProjectCommand = Effect.fn(
249 "normalizeWorkspaceRootForProjectCommand",
250)(function* (workspaceRoot: string) {
251 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
252 return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot);
253});
254
255const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* (
256 workspaceRoot: string,
257 explicitTitle?: string,
258) {
259 if (explicitTitle !== undefined) {
260 const trimmed = explicitTitle.trim();
261 if (trimmed.length > 0) {
262 return trimmed;
263 }
264 return yield* new ProjectTitleEmptyError({
265 operation: "validateProjectTitle",
266 title: explicitTitle,
267 });
268 }
269
270 const path = yield* Path.Path;
271 const basename = path.basename(workspaceRoot).trim();
272 return basename.length > 0 ? basename : "project";
273});
274
275const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: {
276 readonly snapshot: ProjectSnapshot;
277 readonly identifier: string;
278}) {
279 const trimmedIdentifier = input.identifier.trim();
280 if (trimmedIdentifier.length === 0) {
281 return yield* new ProjectIdentifierEmptyError({
282 operation: "resolveProjectTarget",
283 identifier: input.identifier,
284 });
285 }
286
287 const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null);
288 const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier);
289 if (exactIdMatch) {
290 return {
291 id: exactIdMatch.id,
292 title: exactIdMatch.title,
293 workspaceRoot: exactIdMatch.workspaceRoot,
294 } satisfies ProjectMutationTarget;
295 }
296
297 const normalizedWorkspaceRootResult = yield* Effect.result(
298 normalizeWorkspaceRootForProjectCommand(trimmedIdentifier),
299 );
300 const normalizedWorkspaceRoot =
301 normalizedWorkspaceRootResult._tag === "Success" ? normalizedWorkspaceRootResult.success : null;
302
303 // A stored workspace path still identifies its project after the directory is gone.
304 const exactWorkspaceMatch = activeProjects.find(
305 (project) => project.workspaceRoot === (normalizedWorkspaceRoot ?? trimmedIdentifier),
306 );
307
308 const resolved = exactWorkspaceMatch;
309 if (!resolved) {
310 return yield* new ProjectNotFoundError({
311 operation: "resolveProjectTarget",
312 identifier: trimmedIdentifier,
313 activeProjectCount: activeProjects.length,
314 ...(normalizedWorkspaceRoot === null ? {} : { normalizedWorkspaceRoot }),
315 ...(normalizedWorkspaceRootResult._tag === "Failure"
316 ? { cause: normalizedWorkspaceRootResult.failure }
317 : {}),
318 });
319 }
320
321 return {
322 id: resolved.id,
323 title: resolved.title,
324 workspaceRoot: resolved.workspaceRoot,
325 } satisfies ProjectMutationTarget;
326});
327
328const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) =>
329 Effect.gen(function* () {
330 const client = yield* makeLiveServerClient(origin);
331 return yield* client.projects.snapshot({
332 headers: { authorization: `Bearer ${bearerToken}` },
333 });
334 }).pipe(
335 withProjectCliLiveServerTimeout,
336 Effect.mapError(projectCommandErrorFromLiveServerRequest),
337 );
338
339const dispatchLiveOrchestrationCommand = (
340 origin: string,
341 bearerToken: string,
342 command: ProjectCliDispatchCommand,
343) =>
344 Effect.gen(function* () {
345 const client = yield* makeLiveServerClient(origin);
346 yield* client.projects.mutate({
347 headers: { authorization: `Bearer ${bearerToken}` },
348 payload: command,
349 } as Parameters<typeof client.projects.mutate>[0]);
350 }).pipe(
351 withProjectCliLiveServerTimeout,
352 Effect.mapError(projectCommandErrorFromLiveServerRequest),
353 );
354
355const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
356 const projects = yield* ProjectService.ProjectService;
357 return yield* projects.snapshot;
358});
359
360const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")(
361 function* (
362 environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"],
363 config: ServerConfig.ServerConfig["Service"],
364 ) {
365 const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
366 if (Option.isNone(runtimeState)) {
367 return Option.none<{ readonly origin: string }>();
368 }
369
370 const attempt = withProjectCliSessionToken(environmentAuth, (token) =>
371 fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe(
372 Effect.as({
373 origin: runtimeState.value.origin,
374 }),
375 ),
376 );
377
378 const attempted = yield* Effect.result(attempt);
379 if (attempted._tag === "Success") {
380 return Option.some(attempted.success);
381 }
382
383 yield* Effect.logDebug("Failed to connect to the persisted project CLI server.", {
384 origin: runtimeState.value.origin,
385 cause: attempted.failure,
386 });
387 // The offline path decides whether that server still owns the home.
388 return Option.none<{ readonly origin: string }>();
389 },
390);
391
392const runProjectMutation = Effect.fn("runProjectMutation")(function* (
393 flags: CliAuthLocationFlags,
394 run: (input: {
395 readonly snapshot: ProjectSnapshot;
396 readonly dispatch: (
397 command: ProjectCliDispatchCommand,
398 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
399 readonly mode: ProjectCommandExecutionMode;
400 }) => Effect.Effect<
401 string,
402 Error,
403 | Crypto.Crypto
404 | FileSystem.FileSystem
405 | HttpClient.HttpClient
406 | Path.Path
407 | WorkspacePaths.WorkspacePaths
408 >,
409) {
410 const logLevel = yield* GlobalFlag.LogLevel;
411 const config = yield* resolveCliAuthConfig(flags, logLevel);
412 const minimumLogLevel = config.logLevel;
413
414 return yield* Effect.gen(function* () {
415 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
416 const liveMode = yield* tryResolveLiveProjectExecutionMode(environmentAuth, config);
417
418 if (Option.isSome(liveMode)) {
419 return yield* withProjectCliSessionToken(environmentAuth, (token) =>
420 Effect.gen(function* () {
421 const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token);
422 const output = yield* run({
423 snapshot,
424 dispatch: (command) =>
425 dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command),
426 mode: "live",
427 });
428 yield* Console.log(output);
429 }),
430 );
431 }
432
433 const layerOfflineRuntime = layerProjectCliRuntime.pipe(
434 Layer.provide(ServerConfig.layer(config)),
435 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
436 );
437
438 // An unreachable server may still be running; never write behind it.
439 yield* ServerOwnership.acquireServerOwnership(config.serverRuntimeStatePath).pipe(
440 Effect.provide(ProcessRunner.layer),
441 );
442 yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath);
443 return yield* Effect.gen(function* () {
444 const snapshot = yield* getOfflineSnapshot();
445 const projects = yield* ProjectService.ProjectService;
446 const output = yield* run({
447 snapshot,
448 dispatch: (command) => projectMutationOperation(projects, command).pipe(Effect.asVoid),
449 mode: "offline",
450 });
451 yield* Console.log(output);
452 }).pipe(Effect.provide(layerOfflineRuntime));
453 }).pipe(
454 Effect.scoped,
455 Effect.provide(
456 Layer.mergeAll(EnvironmentAuth.layerRuntime, WorkspacePaths.layer).pipe(
457 Layer.provideMerge(FetchHttpClient.layer),
458 Layer.provide(ServerConfig.layer(config)),
459 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
460 ),
461 ),
462 );
463});
464
465const projectAddCommand = Command.make("add", {
466 ...projectLocationFlags,
467 workspaceRoot: Argument.String("path").pipe(
468 Argument.withDescription("Workspace root to add as a project."),
469 ),
470 title: Flag.String("title").pipe(Flag.withDescription("Optional project title."), Flag.optional),
471}).pipe(
472 Command.withDescription("Add a project."),
473 Command.withHandler((flags) =>
474 runProjectMutation(
475 flags,
476 Effect.fn("projectAddMutation")(function* ({
477 snapshot,
478 dispatch,
479 }: {
480 readonly snapshot: ProjectSnapshot;
481 readonly dispatch: (
482 command: ProjectCliDispatchCommand,
483 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
484 }) {
485 const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot);
486 const existingProject = snapshot.projects.find(
487 (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot,
488 );
489 if (existingProject) {
490 return yield* new ProjectAlreadyExistsError({
491 operation: "addProject",
492 projectId: existingProject.id,
493 workspaceRoot,
494 });
495 }
496
497 const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title));
498 const projectId = ProjectId.make(yield* projectCommandUuid);
499 yield* dispatch({
500 type: "project.create",
501 commandId: CommandId.make(yield* projectCommandUuid),
502 projectId,
503 title,
504 workspaceRoot,
505 });
506 return `Added project ${projectId} (${title}) at ${workspaceRoot}.`;
507 }),
508 ),
509 ),
510);
511
512const projectRemoveCommand = Command.make("remove", {
513 ...projectLocationFlags,
514 project: Argument.String("project").pipe(
515 Argument.withDescription("Project id or workspace root to remove."),
516 ),
517 force: Flag.Boolean("force").pipe(
518 Flag.withDescription("Delete the project and all of its threads."),
519 Flag.withDefault(false),
520 ),
521}).pipe(
522 Command.withDescription("Remove a project."),
523 Command.withHandler((flags) =>
524 runProjectMutation(
525 flags,
526 Effect.fn("projectRemoveMutation")(function* ({
527 snapshot,
528 dispatch,
529 }: {
530 readonly snapshot: ProjectSnapshot;
531 readonly dispatch: (
532 command: ProjectCliDispatchCommand,
533 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
534 }) {
535 const project = yield* findActiveProjectTarget({
536 snapshot,
537 identifier: flags.project,
538 });
539 yield* dispatch({
540 type: "project.delete",
541 commandId: CommandId.make(yield* projectCommandUuid),
542 projectId: project.id,
543 force: flags.force,
544 });
545 return `Removed project ${project.id} (${project.title}).`;
546 }),
547 ),
548 ),
549);
550
551const projectRenameCommand = Command.make("rename", {
552 ...projectLocationFlags,
553 project: Argument.String("project").pipe(
554 Argument.withDescription("Project id or workspace root to rename."),
555 ),
556 title: Argument.String("title").pipe(Argument.withDescription("New project title.")),
557}).pipe(
558 Command.withDescription("Rename a project."),
559 Command.withHandler((flags) =>
560 runProjectMutation(
561 flags,
562 Effect.fn("projectRenameMutation")(function* ({
563 snapshot,
564 dispatch,
565 }: {
566 readonly snapshot: ProjectSnapshot;
567 readonly dispatch: (
568 command: ProjectCliDispatchCommand,
569 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
570 }) {
571 const project = yield* findActiveProjectTarget({
572 snapshot,
573 identifier: flags.project,
574 });
575 const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title);
576 if (nextTitle === project.title) {
577 return `Project ${project.id} is already named ${nextTitle}.`;
578 }
579
580 yield* dispatch({
581 type: "project.update",
582 commandId: CommandId.make(yield* projectCommandUuid),
583 projectId: project.id,
584 title: nextTitle,
585 });
586 return `Renamed project ${project.id} to ${nextTitle}.`;
587 }),
588 ),
589 ),
590);
591
592export const projectCommand = Command.make("project").pipe(
593 Command.withDescription("Manage projects."),
594 Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]),
595);