diff --git a/.changeset/wise-projects-update.md b/.changeset/wise-projects-update.md new file mode 100644 index 0000000..164d3d4 --- /dev/null +++ b/.changeset/wise-projects-update.md @@ -0,0 +1,5 @@ +--- +"t3code-cli": minor +--- + +Add `project update` for editing project metadata. diff --git a/README.md b/README.md index d96dfca..ee2a430 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ pnpm sync-upstream --target stable ```sh t3cli project list # List known projects t3cli project add [--path ] [--title ] +t3cli project update [--project <ref>] [--title <title>] [--workspace-root <path>] [--provider <id>] [--model <slug>] [--option <key=value>] [--reasoning-effort <value>] [--effort <value>] [--fast-mode | --no-fast-mode] [--thinking | --no-thinking] [--clear-default-model] [--thread-env local|worktree | --clear-thread-env] [--favicon <path> | --clear-favicon] [--format auto|human|json] t3cli project delete [--project <ref>] [--force] [--yes] ``` diff --git a/src/application/project-commands.ts b/src/application/project-commands.ts index 7069590..12dfe06 100644 --- a/src/application/project-commands.ts +++ b/src/application/project-commands.ts @@ -45,19 +45,19 @@ export const makeProjectDeleteCommand = Effect.fn("makeProjectDeleteCommand")(fu export const makeProjectMetaUpdateCommand = Effect.fn("makeProjectMetaUpdateCommand")( function* (input: { readonly projectId: string; - readonly scripts: Extract< - ClientOrchestrationCommand, - { readonly type: "project.meta.update" } - >["scripts"]; - }) { + } & Omit< + Extract<ClientOrchestrationCommand, { readonly type: "project.meta.update" }>, + "type" | "commandId" | "projectId" + >) { const crypto = yield* Crypto.Crypto; + const { projectId, ...patch } = input; return { type: "project.meta.update", commandId: CommandId.make( `t3cli:project-meta-update:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`, ), - projectId: ProjectId.make(input.projectId), - scripts: input.scripts, + projectId: ProjectId.make(projectId), + ...patch, } satisfies Extract<ClientOrchestrationCommand, { readonly type: "project.meta.update" }>; }, ); diff --git a/src/application/projects.ts b/src/application/projects.ts index caf5f9b..a3f02b3 100644 --- a/src/application/projects.ts +++ b/src/application/projects.ts @@ -4,9 +4,18 @@ import * as Path from "effect/Path"; import { CliRuntime } from "../cli/runtime/service.ts"; import { T3Orchestration } from "../orchestration/service.ts"; -import { ProjectCreateVisibilityError, ProjectLookupError } from "../domain/error.ts"; +import { + ProjectCreateVisibilityError, + ProjectLookupError, + ProjectUpdateValidationError, +} from "../domain/error.ts"; import { findProjectById, resolveProjectScope } from "../domain/helpers.ts"; -import { makeProjectCreateCommand, makeProjectDeleteCommand } from "./project-commands.ts"; +import { + makeProjectCreateCommand, + makeProjectDeleteCommand, + makeProjectMetaUpdateCommand, +} from "./project-commands.ts"; +import { resolveModelSelection } from "./model-selection.ts"; import { waitForShellSequence } from "./shell-sequence.ts"; import type { T3ProjectApplicationService } from "./service.ts"; @@ -69,11 +78,83 @@ export const makeProjectApplication = Effect.fn("makeProjectApplication")(functi const dispatch = yield* orchestration.dispatch(command); return { projectId: input.projectId, dispatch }; }); + const updateProject: T3ProjectApplicationService["updateProject"] = Effect.fn( + "T3ApplicationLive.updateProject", + )(function* (input) { + const project = yield* resolveProject(input.projectRef); + let workspaceRoot = input.workspaceRoot; + if (workspaceRoot !== undefined) { + if (!input.local && !path.isAbsolute(workspaceRoot)) { + return yield* Effect.fail( + new ProjectUpdateValidationError({ + message: "--workspace-root must be absolute for a remote environment", + projectId: project.id, + }), + ); + } + workspaceRoot = input.local + ? path.resolve(cliRuntime.cwd, workspaceRoot) + : path.normalize(workspaceRoot); + } + if (input.favicon !== undefined && input.favicon !== null) { + const supported = /\.(?:avif|gif|jpe?g|png|svg|webp)$/i.test(input.favicon); + if ( + path.isAbsolute(input.favicon) || + input.favicon.split(/[\\/]/).includes("..") || + !supported + ) { + return yield* Effect.fail( + new ProjectUpdateValidationError({ + message: "--favicon must be a workspace-relative supported image path", + projectId: project.id, + }), + ); + } + } + let defaultModelSelection = input.defaultModelSelection; + if (input.provider !== undefined || input.model !== undefined || input.options !== undefined) { + defaultModelSelection = yield* resolveModelSelection({ + start: { + message: "", + ...(input.provider !== undefined ? { provider: input.provider } : {}), + ...(input.model !== undefined ? { model: input.model } : {}), + ...(input.options !== undefined ? { options: input.options } : {}), + }, + project, + serverConfig: yield* orchestration.getServerConfig(), + }); + } + const command = yield* makeProjectMetaUpdateCommand({ + projectId: project.id, + ...(input.title !== undefined ? { title: input.title } : {}), + ...(workspaceRoot !== undefined ? { workspaceRoot } : {}), + ...(defaultModelSelection !== undefined ? { defaultModelSelection } : {}), + ...(input.defaultThreadEnvironment !== undefined + ? { defaultThreadEnvironment: input.defaultThreadEnvironment } + : {}), + ...(input.favicon !== undefined ? { favicon: input.favicon } : {}), + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + const dispatch = yield* orchestration.dispatch(command); + const snapshot = yield* waitForShellSequence({ sequence: dispatch.sequence }).pipe( + Effect.provideService(T3Orchestration, orchestration), + ); + const updated = findProjectById(snapshot, project.id); + if (updated === null) { + return yield* Effect.fail( + new ProjectLookupError({ + message: `project not found after update: ${project.id}`, + ref: project.id, + }), + ); + } + return { dispatch, project: updated }; + }); return { loadShell, addProject, resolveProject, + updateProject, deleteProject, } satisfies T3ProjectApplicationService; }); diff --git a/src/application/service.ts b/src/application/service.ts index c686f77..3f221b4 100644 --- a/src/application/service.ts +++ b/src/application/service.ts @@ -187,6 +187,21 @@ export type T3ProjectApplicationService = { readonly resolveProject: ( projectRef: string, ) => Effect.Effect<OrchestrationProjectShell, ApplicationError>; + readonly updateProject: (input: { + readonly projectRef: string; + readonly title?: string; + readonly workspaceRoot?: string; + readonly local: boolean; + readonly provider?: string; + readonly model?: string; + readonly options?: NonNullable<ModelSelection["options"]>; + readonly defaultModelSelection?: null; + readonly defaultThreadEnvironment?: "local" | "worktree" | null; + readonly favicon?: string | null; + }) => Effect.Effect< + { readonly dispatch: DispatchResult; readonly project: OrchestrationProjectShell }, + ApplicationError + >; readonly deleteProject: (input: { readonly projectId: string; readonly force?: boolean; diff --git a/src/cli/format/project.ts b/src/cli/format/project.ts index 722e6db..199044e 100644 --- a/src/cli/format/project.ts +++ b/src/cli/format/project.ts @@ -24,6 +24,14 @@ export function formatProjectAddedHuman(project: OrchestrationProjectShell) { ])}`; } +export function formatProjectUpdatedHuman(project: OrchestrationProjectShell) { + return `project updated\n${formatRecord([ + { field: "title", value: project.title }, + { field: "id", value: project.id }, + { field: "path", value: project.workspaceRoot }, + ])}`; +} + export function formatProjectDeletedHuman(input: { readonly projectId: string; readonly dispatch: { readonly sequence: number }; diff --git a/src/cli/project.ts b/src/cli/project.ts index 33b1860..312d1be 100644 --- a/src/cli/project.ts +++ b/src/cli/project.ts @@ -6,6 +6,7 @@ import { extraArgsConfig } from "./extra-args.ts"; import { formatFlag, projectPathFlag } from "./flags.ts"; import { formatProjectAddedHuman, formatProjectsHuman } from "./format/project.ts"; import { deleteProjectCommand } from "./projects/delete.ts"; +import { updateProjectCommand } from "./projects/update.ts"; import { T3Application } from "../application/service.ts"; import { CliRuntime } from "../cli/runtime/service.ts"; import { loadT3CliEnv } from "../config/env/env.ts"; @@ -15,7 +16,7 @@ import { T3Output } from "./output/service.ts"; export function createProjectCommand() { return Command.make("project").pipe( Command.withDescription("project commands"), - Command.withSubcommands([listCommand, addCommand, deleteProjectCommand]), + Command.withSubcommands([listCommand, addCommand, updateProjectCommand, deleteProjectCommand]), ); } diff --git a/src/cli/projects/update.test.ts b/src/cli/projects/update.test.ts new file mode 100644 index 0000000..a40c1ad --- /dev/null +++ b/src/cli/projects/update.test.ts @@ -0,0 +1,90 @@ +import "vite-plus/test/config"; + +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Option from "effect/Option"; +import { assert, describe, it } from "@effect/vitest"; +import { Command } from "effect/unstable/cli"; +import { fromPartial } from "@total-typescript/shoehorn"; + +import { T3Application } from "../../application/service.ts"; +import { T3Config } from "../../config/config.ts"; +import { t3CliEnvConfigLayer } from "../../config/env/env.test-utils.ts"; +import * as CliRuntime from "../runtime/service.ts"; +import { T3Output } from "../output/service.ts"; +import { updateProjectCommand } from "./update.ts"; + +const testLayer = Layer.mergeAll( + Layer.succeed( + T3Application, + fromPartial({ updateProject: () => Effect.die("updateProject should not be called") }), + ), + Layer.succeed( + T3Config, + fromPartial({ + resolve: () => + Effect.succeed({ url: "ws://localhost", token: "token", source: "config", local: true }), + }), + ), + Layer.succeed(T3Output, { + writeStdout: () => Effect.void, + writeStderr: () => Effect.void, + printJson: () => Effect.void, + printNdjson: () => Effect.void, + printInfo: () => Effect.void, + }), + NodeServices.layer, + CliRuntime.layer, + t3CliEnvConfigLayer("/tmp/t3cli-test"), +); + +describe("updateProjectCommand", () => { + it.layer(testLayer)("validation", (t) => { + const run = Command.runWith(updateProjectCommand, { version: "0.0.0-test" }); + + t.effect("requires at least one update", () => + expectError(run(["--project", "proj-1"]), "MissingUpdateFieldsError"), + ); + + t.effect("rejects a default model value with its clear flag", () => + expectError( + run(["--project", "proj-1", "--model", "gpt-5", "--clear-default-model"]), + "ConflictingUpdateFlagsError", + ), + ); + + t.effect("rejects a favicon value with its clear flag", () => + expectError( + run(["--project", "proj-1", "--favicon", "icon.png", "--clear-favicon"]), + "ConflictingUpdateFlagsError", + ), + ); + + t.effect("rejects a thread environment value with its clear flag", () => + expectError( + run(["--project", "proj-1", "--thread-env", "local", "--clear-thread-env"]), + "ConflictingUpdateFlagsError", + ), + ); + }); +}); + +function expectError<R>( + effect: Effect.Effect<unknown, unknown, R>, + expectedTag: string, +) { + return Effect.gen(function* () { + const exit = yield* effect.pipe(Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + assert.isTrue(Option.isSome(error)); + if (Option.isSome(error)) { + assert.equal((error.value as { readonly _tag?: string })._tag, expectedTag); + } + } + }); +} diff --git a/src/cli/projects/update.ts b/src/cli/projects/update.ts new file mode 100644 index 0000000..2b83418 --- /dev/null +++ b/src/cli/projects/update.ts @@ -0,0 +1,105 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { Command, Flag } from "effect/unstable/cli"; + +import { T3Application } from "../../application/service.ts"; +import { CliRuntime } from "../runtime/service.ts"; +import { T3Config } from "../../config/config.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; +import { extraArgsConfig } from "../extra-args.ts"; +import { ConflictingUpdateFlagsError, MissingUpdateFieldsError } from "../error.ts"; +import { formatFlag, modelFlags, projectFlag } from "../flags.ts"; +import { formatProjectUpdatedHuman } from "../format/project.ts"; +import { resolveOutputFormat } from "../format/output.ts"; +import { buildModelOptions } from "../model-options.ts"; +import { T3Output } from "../output/service.ts"; +import { requireCommandProjectRef } from "../require.ts"; + +export const updateProjectCommand = Command.make( + "update", + { + project: projectFlag, + title: Flag.string("title").pipe(Flag.optional), + workspaceRoot: Flag.string("workspace-root").pipe(Flag.optional), + provider: Flag.string("provider").pipe(Flag.optional), + model: Flag.string("model").pipe(Flag.optional), + ...modelFlags, + clearDefaultModel: Flag.boolean("clear-default-model").pipe(Flag.optional), + threadEnv: Flag.choice("thread-env", ["local", "worktree"] as const).pipe(Flag.optional), + clearThreadEnv: Flag.boolean("clear-thread-env").pipe(Flag.optional), + favicon: Flag.string("favicon").pipe(Flag.optional), + clearFavicon: Flag.boolean("clear-favicon").pipe(Flag.optional), + format: formatFlag, + ...extraArgsConfig, + }, + (flags) => + Effect.gen(function* () { + const title = Option.getOrUndefined(flags.title); + const workspaceRoot = Option.getOrUndefined(flags.workspaceRoot); + const provider = Option.getOrUndefined(flags.provider); + const model = Option.getOrUndefined(flags.model); + const threadEnv = Option.getOrUndefined(flags.threadEnv); + const favicon = Option.getOrUndefined(flags.favicon); + const clearDefaultModel = Option.getOrUndefined(flags.clearDefaultModel) === true; + const clearThreadEnv = Option.getOrUndefined(flags.clearThreadEnv) === true; + const clearFavicon = Option.getOrUndefined(flags.clearFavicon) === true; + const options = buildModelOptions(flags); + const hasModel = provider !== undefined || model !== undefined || options.length > 0; + + if (hasModel && clearDefaultModel) { + return yield* conflict("model flags and --clear-default-model are mutually exclusive"); + } + if (threadEnv !== undefined && clearThreadEnv) { + return yield* conflict("--thread-env and --clear-thread-env are mutually exclusive"); + } + if (favicon !== undefined && clearFavicon) { + return yield* conflict("--favicon and --clear-favicon are mutually exclusive"); + } + if ( + title === undefined && + workspaceRoot === undefined && + !hasModel && + !clearDefaultModel && + threadEnv === undefined && + !clearThreadEnv && + favicon === undefined && + !clearFavicon + ) { + return yield* Effect.fail( + new MissingUpdateFieldsError({ + message: "at least one project metadata update field is required", + }), + ); + } + + const application = yield* T3Application; + const config = yield* T3Config; + const runtime = yield* CliRuntime; + const env = yield* loadT3CliEnv; + const output = yield* T3Output; + const result = yield* application.updateProject({ + projectRef: yield* requireCommandProjectRef({ project: flags.project }), + local: (yield* config.resolve()).local, + ...(title !== undefined ? { title } : {}), + ...(workspaceRoot !== undefined ? { workspaceRoot } : {}), + ...(provider !== undefined ? { provider } : {}), + ...(model !== undefined ? { model } : {}), + ...(options.length > 0 ? { options } : {}), + ...(clearDefaultModel ? { defaultModelSelection: null } : {}), + ...(clearThreadEnv + ? { defaultThreadEnvironment: null } + : threadEnv !== undefined + ? { defaultThreadEnvironment: threadEnv } + : {}), + ...(clearFavicon ? { favicon: null } : favicon !== undefined ? { favicon } : {}), + }); + if (resolveOutputFormat(flags.format, runtime, env, "json") === "json") { + return yield* output.printJson(result.project); + } + return yield* output.printInfo(formatProjectUpdatedHuman(result.project)); + }), +).pipe(Command.withDescription("update project metadata")); + +function conflict(message: string) { + return Effect.fail(new ConflictingUpdateFlagsError({ message })); +} diff --git a/src/domain/error.ts b/src/domain/error.ts index d901836..8a60152 100644 --- a/src/domain/error.ts +++ b/src/domain/error.ts @@ -72,6 +72,11 @@ export class ProjectActionValidationError extends Schema.TaggedErrorClass<Projec }, ) {} +export class ProjectUpdateValidationError extends Schema.TaggedErrorClass<ProjectUpdateValidationError>()( + "ProjectUpdateValidationError", + { message: Schema.String, projectId: Schema.String }, +) {} + export type DomainError = | ProjectLookupError | ModelSelectionError @@ -81,4 +86,5 @@ export type DomainError = | ProjectCreateVisibilityError | TerminalLookupError | ProjectActionLookupError - | ProjectActionValidationError; + | ProjectActionValidationError + | ProjectUpdateValidationError;