Compare commits
1 commit
dev
...
jlongster/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78b7781ab9 |
18 changed files with 478 additions and 128 deletions
|
|
@ -18,6 +18,7 @@ import { ConfigFormatter } from "./config/formatter"
|
||||||
import { ConfigLSP } from "./config/lsp"
|
import { ConfigLSP } from "./config/lsp"
|
||||||
import { ConfigMCP } from "./config/mcp"
|
import { ConfigMCP } from "./config/mcp"
|
||||||
import { ConfigPlugin } from "./config/plugin"
|
import { ConfigPlugin } from "./config/plugin"
|
||||||
|
import { ConfigProjectCopy } from "./config/project-copy"
|
||||||
import { ConfigProvider } from "./config/provider"
|
import { ConfigProvider } from "./config/provider"
|
||||||
import { ConfigReference } from "./config/reference"
|
import { ConfigReference } from "./config/reference"
|
||||||
import { ConfigToolOutput } from "./config/tool-output"
|
import { ConfigToolOutput } from "./config/tool-output"
|
||||||
|
|
@ -101,6 +102,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||||
description: "Ordered external plugin packages to load",
|
description: "Ordered external plugin packages to load",
|
||||||
}),
|
}),
|
||||||
|
projectCopy: ConfigProjectCopy.Info.pipe(Schema.optional).annotate({
|
||||||
|
description: "Project copy strategy and destination template",
|
||||||
|
}),
|
||||||
experimental: ConfigExperimental.Experimental.pipe(Schema.optional),
|
experimental: ConfigExperimental.Experimental.pipe(Schema.optional),
|
||||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
|
||||||
8
packages/core/src/config/project-copy.ts
Normal file
8
packages/core/src/config/project-copy.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export * as ConfigProjectCopy from "./project-copy"
|
||||||
|
|
||||||
|
import { Schema } from "effect"
|
||||||
|
|
||||||
|
export class Info extends Schema.Class<Info>("ConfigProjectCopy.Info")({
|
||||||
|
strategy: Schema.Literal("git_worktree").pipe(Schema.optional),
|
||||||
|
directory: Schema.String.pipe(Schema.optional),
|
||||||
|
}) {}
|
||||||
|
|
@ -41,6 +41,8 @@ import { LLMClient } from "@opencode-ai/llm"
|
||||||
import { RequestExecutor } from "@opencode-ai/llm/route"
|
import { RequestExecutor } from "@opencode-ai/llm/route"
|
||||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||||
import { SessionRunnerModel } from "./session/runner/model"
|
import { SessionRunnerModel } from "./session/runner/model"
|
||||||
|
import { ProjectCopy } from "./project/copy"
|
||||||
|
import { Git } from "./git"
|
||||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
|
|
||||||
|
|
@ -65,6 +67,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||||
Watcher.locationLayer,
|
Watcher.locationLayer,
|
||||||
Pty.locationLayer,
|
Pty.locationLayer,
|
||||||
SkillV2.locationLayer,
|
SkillV2.locationLayer,
|
||||||
|
ProjectCopy.locationLayer,
|
||||||
systemContext,
|
systemContext,
|
||||||
LocationMutation.locationLayer.pipe(Layer.orDie),
|
LocationMutation.locationLayer.pipe(Layer.orDie),
|
||||||
).pipe(Layer.provideMerge(location))
|
).pipe(Layer.provideMerge(location))
|
||||||
|
|
@ -119,6 +122,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||||
ModelsDev.defaultLayer,
|
ModelsDev.defaultLayer,
|
||||||
FSUtil.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
AppProcess.defaultLayer,
|
AppProcess.defaultLayer,
|
||||||
|
Git.defaultLayer,
|
||||||
Global.defaultLayer,
|
Global.defaultLayer,
|
||||||
Ripgrep.defaultLayer,
|
Ripgrep.defaultLayer,
|
||||||
Database.defaultLayer,
|
Database.defaultLayer,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { ModelV2 } from "./model"
|
||||||
import type { Catalog } from "./catalog"
|
import type { Catalog } from "./catalog"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
|
import { LayerNode } from "./effect/layer-node"
|
||||||
|
|
||||||
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
|
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
|
||||||
export type ID = typeof ID.Type
|
export type ID = typeof ID.Type
|
||||||
|
|
@ -33,6 +34,19 @@ type HookSpec = {
|
||||||
}
|
}
|
||||||
output: {}
|
output: {}
|
||||||
}
|
}
|
||||||
|
"projectCopy.create.before": {
|
||||||
|
input: {
|
||||||
|
projectID: import("./project").Project.ID
|
||||||
|
sourceDirectory: import("./schema").AbsolutePath
|
||||||
|
context?: string
|
||||||
|
}
|
||||||
|
output: {
|
||||||
|
strategy: import("./project/copy").ProjectCopy.StrategyID
|
||||||
|
directory?: import("./schema").AbsolutePath
|
||||||
|
name?: string
|
||||||
|
error?: import("./project/copy").ProjectCopy.DestinationExistsError
|
||||||
|
}
|
||||||
|
}
|
||||||
"aisdk.language": {
|
"aisdk.language": {
|
||||||
input: {
|
input: {
|
||||||
model: ModelV2.Info
|
model: ModelV2.Info
|
||||||
|
|
@ -189,6 +203,7 @@ export const layer = Layer.effect(
|
||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
export const node = LayerNode.make(layer, [EventV2.node])
|
||||||
|
|
||||||
// opencode
|
// opencode
|
||||||
// sdcok
|
// sdcok
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { AccountPlugin } from "./account"
|
||||||
import { AgentPlugin } from "./agent"
|
import { AgentPlugin } from "./agent"
|
||||||
import { CommandPlugin } from "./command"
|
import { CommandPlugin } from "./command"
|
||||||
import { SkillPlugin } from "./skill"
|
import { SkillPlugin } from "./skill"
|
||||||
|
import { ProjectCopyDestinationPlugin } from "./project-copy-destination"
|
||||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||||
import { EnvPlugin } from "./env"
|
import { EnvPlugin } from "./env"
|
||||||
import { ModelsDevPlugin } from "./models-dev"
|
import { ModelsDevPlugin } from "./models-dev"
|
||||||
|
|
@ -101,6 +102,7 @@ export const layer = Layer.effect(
|
||||||
yield* add(AgentPlugin.Plugin)
|
yield* add(AgentPlugin.Plugin)
|
||||||
yield* add(CommandPlugin.Plugin)
|
yield* add(CommandPlugin.Plugin)
|
||||||
yield* add(SkillPlugin.Plugin)
|
yield* add(SkillPlugin.Plugin)
|
||||||
|
yield* add(ProjectCopyDestinationPlugin.Plugin)
|
||||||
for (const item of ProviderPlugins) {
|
for (const item of ProviderPlugins) {
|
||||||
yield* add(item)
|
yield* add(item)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
72
packages/core/src/plugin/project-copy-destination.ts
Normal file
72
packages/core/src/plugin/project-copy-destination.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
export * as ProjectCopyDestinationPlugin from "./project-copy-destination"
|
||||||
|
|
||||||
|
import path from "path"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Config } from "../config"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Location } from "../location"
|
||||||
|
import { PluginV2 } from "../plugin"
|
||||||
|
import { ProjectCopy } from "../project/copy"
|
||||||
|
import { AbsolutePath } from "../schema"
|
||||||
|
import { FSUtil } from "../fs-util"
|
||||||
|
import { Slug } from "../util/slug"
|
||||||
|
|
||||||
|
export const Plugin = PluginV2.define({
|
||||||
|
id: PluginV2.ID.make("project-copy-destination"),
|
||||||
|
effect: Effect.gen(function* () {
|
||||||
|
const config = Config.latest(yield* (yield* Config.Service).entries(), "projectCopy")
|
||||||
|
const global = yield* Global.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
|
||||||
|
return {
|
||||||
|
"projectCopy.create.before": Effect.fn(function* (event) {
|
||||||
|
event.strategy = event.strategy ?? config?.strategy
|
||||||
|
event.name ??= Slug.create()
|
||||||
|
event.name = sanitize(event.name) || Slug.create()
|
||||||
|
|
||||||
|
if (!event.directory) {
|
||||||
|
let dest = path.join(location.project.directory, ".worktrees", event.name)
|
||||||
|
if (config?.directory) {
|
||||||
|
dest = config.directory
|
||||||
|
.replaceAll("{project.name}", path.basename(location.project.directory))
|
||||||
|
.replaceAll("{project.directory}", location.project.directory)
|
||||||
|
.replaceAll("{project.id}", location.project.id)
|
||||||
|
.replaceAll("{name}", event.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
dest =
|
||||||
|
dest === "~"
|
||||||
|
? global.home
|
||||||
|
: dest.startsWith("~/")
|
||||||
|
? path.join(global.home, dest.slice(2))
|
||||||
|
: path.resolve(location.project.directory, dest)
|
||||||
|
event.directory = AbsolutePath.make(path.dirname(dest))
|
||||||
|
event.name = path.basename(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = event.name
|
||||||
|
let suffix = 1
|
||||||
|
let destination = AbsolutePath.make(path.join(event.directory, name))
|
||||||
|
while (yield* fs.existsSafe(destination)) {
|
||||||
|
suffix++
|
||||||
|
if (suffix > 10) {
|
||||||
|
event.error = new ProjectCopy.DestinationExistsError({ directory: destination })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.name = `${name}-${suffix}`
|
||||||
|
destination = AbsolutePath.make(path.join(event.directory, event.name))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
function sanitize(input: string) {
|
||||||
|
return input
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+/, "")
|
||||||
|
.replace(/-+$/, "")
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ import { LayerNode } from "../effect/layer-node"
|
||||||
import { Project } from "../project"
|
import { Project } from "../project"
|
||||||
import { ProjectDirectoryTable } from "./sql"
|
import { ProjectDirectoryTable } from "./sql"
|
||||||
import { makeStrategies } from "./copy-strategies"
|
import { makeStrategies } from "./copy-strategies"
|
||||||
import { Slug } from "../util/slug"
|
import { PluginV2 } from "../plugin"
|
||||||
|
|
||||||
export const StrategyID = Schema.Literal("git_worktree")
|
export const StrategyID = Schema.Literal("git_worktree")
|
||||||
export type StrategyID = typeof StrategyID.Type
|
export type StrategyID = typeof StrategyID.Type
|
||||||
|
|
@ -26,7 +26,7 @@ export const CreateInput = Schema.Struct({
|
||||||
projectID: Project.ID,
|
projectID: Project.ID,
|
||||||
strategy: StrategyID,
|
strategy: StrategyID,
|
||||||
sourceDirectory: AbsolutePath,
|
sourceDirectory: AbsolutePath,
|
||||||
directory: AbsolutePath,
|
directory: Schema.optional(AbsolutePath),
|
||||||
name: Schema.optional(Schema.String),
|
name: Schema.optional(Schema.String),
|
||||||
context: Schema.optional(Schema.String),
|
context: Schema.optional(Schema.String),
|
||||||
}).annotate({ identifier: "ProjectCopy.CreateInput" })
|
}).annotate({ identifier: "ProjectCopy.CreateInput" })
|
||||||
|
|
@ -71,11 +71,20 @@ export class StrategyNotFoundError extends Schema.TaggedErrorClass<StrategyNotFo
|
||||||
{ directory: AbsolutePath },
|
{ directory: AbsolutePath },
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
export class CreateUnavailableError extends Schema.TaggedErrorClass<CreateUnavailableError>()(
|
||||||
|
"ProjectCopy.CreateUnavailableError",
|
||||||
|
{
|
||||||
|
directory: Schema.optional(AbsolutePath),
|
||||||
|
name: Schema.optional(Schema.String),
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
export type Error =
|
export type Error =
|
||||||
| SourceDirectoryNotFoundError
|
| SourceDirectoryNotFoundError
|
||||||
| DestinationExistsError
|
| DestinationExistsError
|
||||||
| DirectoryUnavailableError
|
| DirectoryUnavailableError
|
||||||
| StrategyNotFoundError
|
| StrategyNotFoundError
|
||||||
|
| CreateUnavailableError
|
||||||
| Git.WorktreeError
|
| Git.WorktreeError
|
||||||
|
|
||||||
export interface Strategy {
|
export interface Strategy {
|
||||||
|
|
@ -115,6 +124,7 @@ export const layer = Layer.effect(
|
||||||
const git = yield* Git.Service
|
const git = yield* Git.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
|
||||||
const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
|
const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
|
||||||
const resolved = AbsolutePath.make(FSUtil.resolve(input))
|
const resolved = AbsolutePath.make(FSUtil.resolve(input))
|
||||||
|
|
@ -192,21 +202,31 @@ export const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
|
|
||||||
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
|
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
|
||||||
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
|
const resolved = yield* plugin.trigger(
|
||||||
const name = input.name ?? Slug.create()
|
"projectCopy.create.before",
|
||||||
let suffix = 1
|
{
|
||||||
let copyDirectory = AbsolutePath.make(path.join(input.directory, name))
|
projectID: input.projectID,
|
||||||
while (yield* fs.existsSafe(copyDirectory)) {
|
sourceDirectory: input.sourceDirectory,
|
||||||
suffix++
|
context: input.context,
|
||||||
if (suffix > 10) return yield* new DestinationExistsError({ directory: copyDirectory })
|
},
|
||||||
copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
|
{
|
||||||
}
|
strategy: input.strategy,
|
||||||
|
directory: input.directory,
|
||||||
|
name: input.name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (resolved.error) return yield* resolved.error
|
||||||
|
if (!resolved.directory || !resolved.name)
|
||||||
|
return yield* new CreateUnavailableError({ directory: resolved.directory, name: resolved.name })
|
||||||
|
|
||||||
const result = yield* strategy(input.strategy).create({
|
yield* fs.makeDirectory(resolved.directory, { recursive: true }).pipe(Effect.orDie)
|
||||||
|
const copyDirectory = AbsolutePath.make(path.join(resolved.directory, resolved.name))
|
||||||
|
|
||||||
|
const result = yield* strategy(resolved.strategy).create({
|
||||||
directory: copyDirectory,
|
directory: copyDirectory,
|
||||||
sourceDirectory: yield* source(input.sourceDirectory, input.projectID),
|
sourceDirectory: yield* source(input.sourceDirectory, input.projectID),
|
||||||
})
|
})
|
||||||
yield* changed(input.projectID, yield* insert(input.projectID, result.directory, input.strategy))
|
yield* changed(input.projectID, yield* insert(input.projectID, result.directory, resolved.strategy))
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -270,10 +290,6 @@ export const layer = Layer.effect(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const locationLayer = layer.pipe(Layer.provide(PluginV2.locationLayer))
|
||||||
Layer.provide(Database.defaultLayer),
|
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, EventV2.node, Database.node, PluginV2.node])
|
||||||
Layer.provide(Git.defaultLayer),
|
|
||||||
Layer.provide(EventV2.defaultLayer),
|
|
||||||
)
|
|
||||||
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, EventV2.node, Database.node])
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,17 @@ export const Info = Schema.Struct({
|
||||||
description: "@deprecated Use 'references' field instead. Named git or local directory references",
|
description: "@deprecated Use 'references' field instead. Named git or local directory references",
|
||||||
}),
|
}),
|
||||||
watcher: Schema.optional(Schema.Struct({ ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))) })),
|
watcher: Schema.optional(Schema.Struct({ ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))) })),
|
||||||
|
projectCopy: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
strategy: Schema.optional(Schema.Literal("git_worktree")).annotate({
|
||||||
|
description: "Strategy used to create project copies",
|
||||||
|
}),
|
||||||
|
directory: Schema.optional(Schema.String).annotate({
|
||||||
|
description:
|
||||||
|
"Project copy path template supporting {project.name}, {project.directory}, {project.id}, and {name} placeholders",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).annotate({ description: "Project copy configuration" }),
|
||||||
snapshot: Schema.optional(Schema.Boolean).annotate({
|
snapshot: Schema.optional(Schema.Boolean).annotate({
|
||||||
description:
|
description:
|
||||||
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||||
plugins: info.plugin?.map((plugin) =>
|
plugins: info.plugin?.map((plugin) =>
|
||||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||||
),
|
),
|
||||||
|
projectCopy: info.projectCopy,
|
||||||
experimental: info.experimental?.policies && { policies: info.experimental.policies },
|
experimental: info.experimental?.policies && { policies: info.experimental.policies },
|
||||||
providers: providers(info.provider),
|
providers: providers(info.provider),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,22 @@ describe("Config", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("migrates project copy configuration", () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
expect(
|
||||||
|
ConfigMigrateV1.migrate({
|
||||||
|
projectCopy: {
|
||||||
|
strategy: "git_worktree",
|
||||||
|
directory: "{project.directory}/.worktrees/{project.name}-{project.id}-{name}",
|
||||||
|
},
|
||||||
|
}).projectCopy,
|
||||||
|
).toEqual({
|
||||||
|
strategy: "git_worktree",
|
||||||
|
directory: "{project.directory}/.worktrees/{project.name}-{project.id}-{name}",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("migrates v1 provider setup options into AISDK settings", () =>
|
it.effect("migrates v1 provider setup options into AISDK settings", () =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const migrated = ConfigMigrateV1.migrate({
|
const migrated = ConfigMigrateV1.migrate({
|
||||||
|
|
|
||||||
165
packages/core/test/plugin/project-copy-destination.test.ts
Normal file
165
packages/core/test/plugin/project-copy-destination.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { Config } from "@opencode-ai/core/config"
|
||||||
|
import { ConfigProjectCopy } from "@opencode-ai/core/config/project-copy"
|
||||||
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
import { ProjectCopyDestinationPlugin } from "@opencode-ai/core/plugin/project-copy-destination"
|
||||||
|
import { Project } from "@opencode-ai/core/project"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { tmpdir } from "../fixture/tmpdir"
|
||||||
|
import { it } from "../lib/effect"
|
||||||
|
|
||||||
|
const projectID = Project.ID.make("project-id")
|
||||||
|
const root = "/projects/example"
|
||||||
|
const databaseNode = LayerNode.make(Database.layerFromPath(":memory:"), [])
|
||||||
|
const configNode = makeConfig(new Config.Info({}))
|
||||||
|
const globalNode = LayerNode.make(
|
||||||
|
Layer.succeed(Global.Service, Global.Service.of(Global.make({ data: "/data", home: "/home/test" }))),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const locationNode = LayerNode.make(
|
||||||
|
Layer.succeed(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of({
|
||||||
|
directory: AbsolutePath.make(root),
|
||||||
|
project: { id: projectID, directory: AbsolutePath.make(root) },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const installNode = LayerNode.make(
|
||||||
|
Layer.effectDiscard(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugin = yield* PluginV2.Service
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const global = yield* Global.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
|
yield* plugin.add({
|
||||||
|
id: ProjectCopyDestinationPlugin.Plugin.id,
|
||||||
|
effect: ProjectCopyDestinationPlugin.Plugin.effect.pipe(
|
||||||
|
Effect.provideService(Config.Service, config),
|
||||||
|
Effect.provideService(FSUtil.Service, fs),
|
||||||
|
Effect.provideService(Global.Service, global),
|
||||||
|
Effect.provideService(Location.Service, location),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[PluginV2.node, configNode, globalNode, locationNode, FSUtil.node],
|
||||||
|
)
|
||||||
|
const graph = LayerNode.group([PluginV2.node, installNode])
|
||||||
|
|
||||||
|
function makeConfig(info: Config.Info) {
|
||||||
|
return LayerNode.make(
|
||||||
|
Layer.succeed(
|
||||||
|
Config.Service,
|
||||||
|
Config.Service.of({
|
||||||
|
entries: () =>
|
||||||
|
Effect.succeed([
|
||||||
|
new Config.Document({
|
||||||
|
type: "document",
|
||||||
|
info,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function providePlugin(info = new Config.Info({})) {
|
||||||
|
return Effect.provide(
|
||||||
|
LayerNode.buildLayer(graph, {
|
||||||
|
replacements: [
|
||||||
|
LayerNode.replace(Database.node, databaseNode),
|
||||||
|
LayerNode.replace(configNode, makeConfig(info)),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function trigger(input: { name?: string; directory?: AbsolutePath }) {
|
||||||
|
return PluginV2.Service.use((plugin) =>
|
||||||
|
plugin.trigger(
|
||||||
|
"projectCopy.create.before",
|
||||||
|
{ projectID, sourceDirectory: AbsolutePath.make("/projects/example") },
|
||||||
|
{ strategy: "git_worktree", ...input },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProjectCopyDestinationPlugin", () => {
|
||||||
|
it.effect(
|
||||||
|
"expands project variables and sanitizes the copy name",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const result = yield* trigger({ name: "Fix / Auth: Please" })
|
||||||
|
|
||||||
|
expect(result.directory).toBe(AbsolutePath.make("/projects/example/copies"))
|
||||||
|
expect(result.name).toBe("example-project-id-fix-auth-please")
|
||||||
|
}).pipe(
|
||||||
|
providePlugin(
|
||||||
|
new Config.Info({
|
||||||
|
projectCopy: new ConfigProjectCopy.Info({
|
||||||
|
strategy: "git_worktree",
|
||||||
|
directory: "{project.directory}/copies/{project.name}-{project.id}-{name}",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves an explicit directory and sanitizes the name", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const result = yield* trigger({ directory: AbsolutePath.make("/tmp/copies"), name: "A/B" })
|
||||||
|
|
||||||
|
expect(result.directory).toBe(AbsolutePath.make("/tmp/copies"))
|
||||||
|
expect(result.name).toBe("a-b")
|
||||||
|
}).pipe(providePlugin()),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("adds a suffix when destinations already exist", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const directory = yield* Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(item) => Effect.promise(() => item[Symbol.asyncDispose]()),
|
||||||
|
)
|
||||||
|
yield* Effect.promise(() => fs.mkdir(path.join(directory.path, "copy")))
|
||||||
|
yield* Effect.promise(() => fs.mkdir(path.join(directory.path, "copy-2")))
|
||||||
|
|
||||||
|
const result = yield* trigger({ directory: AbsolutePath.make(directory.path), name: "copy" })
|
||||||
|
|
||||||
|
expect(result.name).toBe("copy-3")
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
}).pipe(providePlugin()),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("returns an error after ten destination conflicts", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const directory = yield* Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(item) => Effect.promise(() => item[Symbol.asyncDispose]()),
|
||||||
|
)
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Promise.all(
|
||||||
|
Array.from({ length: 10 }, (_, index) =>
|
||||||
|
fs.mkdir(path.join(directory.path, index === 0 ? "copy" : `copy-${index + 1}`)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = yield* trigger({ directory: AbsolutePath.make(directory.path), name: "copy" })
|
||||||
|
|
||||||
|
expect(result.error).toBeInstanceOf(Error)
|
||||||
|
expect(result.error?.directory).toBe(AbsolutePath.make(path.join(directory.path, "copy-10")))
|
||||||
|
}).pipe(providePlugin()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -12,18 +12,21 @@ import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { Project } from "@opencode-ai/core/project"
|
import { Project } from "@opencode-ai/core/project"
|
||||||
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||||
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const databaseLayer = Database.layerFromPath(":memory:")
|
const databaseLayer = Database.layerFromPath(":memory:")
|
||||||
const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
|
const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
|
||||||
|
const pluginLayer = PluginV2.layer.pipe(Layer.provide(eventLayer))
|
||||||
const copyLayer = ProjectCopy.layer.pipe(
|
const copyLayer = ProjectCopy.layer.pipe(
|
||||||
Layer.provide(databaseLayer),
|
Layer.provide(databaseLayer),
|
||||||
Layer.provide(eventLayer),
|
Layer.provide(eventLayer),
|
||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
|
Layer.provide(pluginLayer),
|
||||||
)
|
)
|
||||||
const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer))
|
const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer, pluginLayer))
|
||||||
|
|
||||||
function abs(input: string) {
|
function abs(input: string) {
|
||||||
return AbsolutePath.make(input)
|
return AbsolutePath.make(input)
|
||||||
|
|
@ -131,6 +134,58 @@ describe("ProjectCopy", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("runs the before create hook before resolving the destination", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const input = yield* setup()
|
||||||
|
const copy = yield* ProjectCopy.Service
|
||||||
|
const plugins = yield* PluginV2.Service
|
||||||
|
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||||
|
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-hook"))
|
||||||
|
const target = abs(path.join(parent, "configured"))
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||||
|
)
|
||||||
|
yield* plugins.add({
|
||||||
|
id: PluginV2.ID.make("project-copy-test"),
|
||||||
|
effect: Effect.succeed({
|
||||||
|
"projectCopy.create.before": Effect.fn(function* (event) {
|
||||||
|
event.directory = parent
|
||||||
|
event.name = "configured"
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const created = yield* copy.create({
|
||||||
|
projectID: input.projectID,
|
||||||
|
strategy: "git_worktree",
|
||||||
|
sourceDirectory: input.sourceDirectory,
|
||||||
|
directory: abs(path.join(temp, "unused")),
|
||||||
|
name: "unused",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(created.directory).toBe(target)
|
||||||
|
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("fails when the before create hook leaves the destination incomplete", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const input = yield* setup()
|
||||||
|
const copy = yield* ProjectCopy.Service
|
||||||
|
|
||||||
|
const error = yield* copy
|
||||||
|
.create({
|
||||||
|
projectID: input.projectID,
|
||||||
|
strategy: "git_worktree",
|
||||||
|
sourceDirectory: input.sourceDirectory,
|
||||||
|
})
|
||||||
|
.pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(ProjectCopy.CreateUnavailableError)
|
||||||
|
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("requires force to remove a dirty git worktree", () =>
|
it.live("requires force to remove a dirty git worktree", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const input = yield* setup()
|
const input = yield* setup()
|
||||||
|
|
@ -166,71 +221,6 @@ describe("ProjectCopy", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("adds a numeric suffix when a copy directory already exists", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const input = yield* setup()
|
|
||||||
const copy = yield* ProjectCopy.Service
|
|
||||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
|
||||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-suffix"))
|
|
||||||
const target = abs(path.join(parent, "copy-3"))
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
|
||||||
)
|
|
||||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy"), { recursive: true }))
|
|
||||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy-2")))
|
|
||||||
|
|
||||||
const created = yield* copy.create({
|
|
||||||
projectID: input.projectID,
|
|
||||||
strategy: "git_worktree",
|
|
||||||
sourceDirectory: input.sourceDirectory,
|
|
||||||
directory: parent,
|
|
||||||
name: "copy",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(created.directory).toBe(target)
|
|
||||||
expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy")).then((item) => item.isDirectory()))).toBe(
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy-2")).then((item) => item.isDirectory()))).toBe(
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("fails after ten copy directory conflicts", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const input = yield* setup()
|
|
||||||
const copy = yield* ProjectCopy.Service
|
|
||||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
|
||||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-conflicts"))
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
|
||||||
)
|
|
||||||
yield* Effect.promise(() =>
|
|
||||||
Promise.all(
|
|
||||||
Array.from({ length: 10 }, (_, index) =>
|
|
||||||
fs.mkdir(path.join(parent, index === 0 ? "copy" : `copy-${index + 1}`), { recursive: true }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const error = yield* copy
|
|
||||||
.create({
|
|
||||||
projectID: input.projectID,
|
|
||||||
strategy: "git_worktree",
|
|
||||||
sourceDirectory: input.sourceDirectory,
|
|
||||||
directory: parent,
|
|
||||||
name: "copy",
|
|
||||||
})
|
|
||||||
.pipe(Effect.flip)
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError)
|
|
||||||
expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("does not publish an event when refresh finds no directory changes", () =>
|
it.live("does not publish an event when refresh finds no directory changes", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const input = yield* setup()
|
const input = yield* setup()
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ const CopyQuery = Schema.Struct({
|
||||||
})
|
})
|
||||||
|
|
||||||
export const CreatePayload = Schema.Struct({
|
export const CreatePayload = Schema.Struct({
|
||||||
strategy: ProjectCopy.StrategyID,
|
strategy: Schema.optional(ProjectCopy.CreateInput.fields.strategy),
|
||||||
directory: ProjectCopy.CreateInput.fields.directory,
|
directory: Schema.optional(ProjectCopy.CreateInput.fields.directory),
|
||||||
name: ProjectCopy.CreateInput.fields.name,
|
name: ProjectCopy.CreateInput.fields.name,
|
||||||
context: ProjectCopy.CreateInput.fields.context,
|
context: ProjectCopy.CreateInput.fields.context,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||||
import { Git } from "@opencode-ai/core/git"
|
import { Git } from "@opencode-ai/core/git"
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||||
|
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Layer, Stream } from "effect"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { InstanceHttpApi } from "../api"
|
import { InstanceHttpApi } from "../api"
|
||||||
import { ApiProjectCopyError, CreatePayload, RemovePayload } from "../groups/project-copy"
|
import { ApiProjectCopyError, CreatePayload, RemovePayload } from "../groups/project-copy"
|
||||||
|
|
@ -43,7 +45,7 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
|
||||||
const llm = yield* LLM.Service
|
const llm = yield* LLM.Service
|
||||||
const agent = yield* Agent.Service
|
const agent = yield* Agent.Service
|
||||||
const provider = yield* Provider.Service
|
const provider = yield* Provider.Service
|
||||||
const service = yield* ProjectCopy.Service
|
const locations = yield* LocationServiceMap
|
||||||
|
|
||||||
const generateName = Effect.fn("ProjectCopyHttpApi.generateName")(function* (context: string | undefined) {
|
const generateName = Effect.fn("ProjectCopyHttpApi.generateName")(function* (context: string | undefined) {
|
||||||
const text = context?.trim()
|
const text = context?.trim()
|
||||||
|
|
@ -85,11 +87,7 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(Stream.filter(LLMEvent.is.textDelta), Stream.map((event) => event.text), Stream.mkString)
|
||||||
Stream.filter(LLMEvent.is.textDelta),
|
|
||||||
Stream.map((event) => event.text),
|
|
||||||
Stream.mkString,
|
|
||||||
)
|
|
||||||
const output = result.trim()
|
const output = result.trim()
|
||||||
return output ? slugify(output.split(/\s+/).slice(0, 4).join(" ")) : Slug.create()
|
return output ? slugify(output.split(/\s+/).slice(0, 4).join(" ")) : Slug.create()
|
||||||
})
|
})
|
||||||
|
|
@ -101,13 +99,18 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
|
||||||
const name =
|
const name =
|
||||||
ctx.payload.name ??
|
ctx.payload.name ??
|
||||||
(yield* generateName(ctx.payload.context).pipe(Effect.catch(() => Effect.succeed(Slug.create()))))
|
(yield* generateName(ctx.payload.context).pipe(Effect.catch(() => Effect.succeed(Slug.create()))))
|
||||||
|
const instance = yield* InstanceState.context
|
||||||
return yield* badRequest(
|
return yield* badRequest(
|
||||||
service.create({
|
Effect.gen(function* () {
|
||||||
...ctx.payload,
|
yield* (yield* PluginBoot.Service).wait()
|
||||||
name,
|
return yield* (yield* ProjectCopy.Service).create({
|
||||||
projectID: ctx.params.projectID,
|
...ctx.payload,
|
||||||
sourceDirectory: AbsolutePath.make((yield* InstanceState.context).worktree),
|
strategy: ctx.payload.strategy ?? "git_worktree",
|
||||||
}),
|
name,
|
||||||
|
projectID: ctx.params.projectID,
|
||||||
|
sourceDirectory: AbsolutePath.make(instance.worktree),
|
||||||
|
})
|
||||||
|
}).pipe(Effect.provide(locations.get({ directory: AbsolutePath.make(instance.directory) }))),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -115,25 +118,29 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
|
||||||
params: { projectID: ProjectV2.ID }
|
params: { projectID: ProjectV2.ID }
|
||||||
payload: typeof RemovePayload.Type
|
payload: typeof RemovePayload.Type
|
||||||
}) {
|
}) {
|
||||||
|
const instance = yield* InstanceState.context
|
||||||
yield* badRequest(
|
yield* badRequest(
|
||||||
service.remove({
|
ProjectCopy.Service.use((service) =>
|
||||||
...ctx.payload,
|
service.remove({
|
||||||
projectID: ctx.params.projectID,
|
...ctx.payload,
|
||||||
}),
|
projectID: ctx.params.projectID,
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(locations.get({ directory: AbsolutePath.make(instance.directory) }))),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const refresh = Effect.fn("ProjectCopyHttpApi.refresh")(function* (ctx: { params: { projectID: ProjectV2.ID } }) {
|
const refresh = Effect.fn("ProjectCopyHttpApi.refresh")(function* (ctx: { params: { projectID: ProjectV2.ID } }) {
|
||||||
|
const instance = yield* InstanceState.context
|
||||||
yield* badRequest(
|
yield* badRequest(
|
||||||
service.refresh({
|
ProjectCopy.Service.use((service) => service.refresh({ projectID: ctx.params.projectID })).pipe(
|
||||||
projectID: ctx.params.projectID,
|
Effect.provide(locations.get({ directory: AbsolutePath.make(instance.directory) })),
|
||||||
}),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return handlers.handle("create", create).handle("remove", remove).handle("refresh", refresh)
|
return handlers.handle("create", create).handle("remove", remove).handle("refresh", refresh)
|
||||||
}),
|
}),
|
||||||
)
|
).pipe(Layer.provide(LocationServiceMap.layer))
|
||||||
|
|
||||||
function slugify(input: string) {
|
function slugify(input: string) {
|
||||||
return input
|
return input
|
||||||
|
|
@ -153,5 +160,7 @@ function message(error: ProjectCopy.Error) {
|
||||||
return `Project copy directory unavailable: ${error.directory}`
|
return `Project copy directory unavailable: ${error.directory}`
|
||||||
if (error instanceof ProjectCopy.StrategyNotFoundError)
|
if (error instanceof ProjectCopy.StrategyNotFoundError)
|
||||||
return `Project copy strategy not found for: ${error.directory}`
|
return `Project copy strategy not found for: ${error.directory}`
|
||||||
|
if (error instanceof ProjectCopy.CreateUnavailableError)
|
||||||
|
return "Project copy directory and name could not be resolved"
|
||||||
return error.message
|
return error.message
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,6 @@ export function createRoutes(
|
||||||
Plugin.defaultLayer,
|
Plugin.defaultLayer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
ProjectV2.defaultLayer,
|
ProjectV2.defaultLayer,
|
||||||
ProjectCopy.defaultLayer,
|
|
||||||
MoveSession.defaultLayer,
|
MoveSession.defaultLayer,
|
||||||
ProviderAuth.defaultLayer,
|
ProviderAuth.defaultLayer,
|
||||||
Provider.defaultLayer,
|
Provider.defaultLayer,
|
||||||
|
|
|
||||||
|
|
@ -106,4 +106,43 @@ describe("project directories and copies endpoints", () => {
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
{ git: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.instance(
|
||||||
|
"expands the configured project copy directory template",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const test = yield* TestInstance
|
||||||
|
const current = yield* request(test.directory, "/project/current")
|
||||||
|
const projectID = (yield* json<{ id: string }>(current)).id
|
||||||
|
const response = yield* request(test.directory, `/experimental/project/${projectID}/copy`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: "Fix / Auth: Please" }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect((yield* json<{ directory: string }>(response)).directory).toBe(
|
||||||
|
path.join(test.directory, "copies", `${path.basename(test.directory)}-${projectID}-fix-auth-please`),
|
||||||
|
)
|
||||||
|
|
||||||
|
const duplicate = yield* request(test.directory, `/experimental/project/${projectID}/copy`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: "Fix / Auth: Please" }),
|
||||||
|
})
|
||||||
|
expect(duplicate.status).toBe(200)
|
||||||
|
expect((yield* json<{ directory: string }>(duplicate)).directory).toBe(
|
||||||
|
path.join(test.directory, "copies", `${path.basename(test.directory)}-${projectID}-fix-auth-please-2`),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
git: true,
|
||||||
|
config: {
|
||||||
|
projectCopy: {
|
||||||
|
strategy: "git_worktree",
|
||||||
|
directory: "{project.directory}/copies/{project.name}-{project.id}-{name}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ export type Event =
|
||||||
| EventQuestionV2Replied
|
| EventQuestionV2Replied
|
||||||
| EventQuestionV2Rejected
|
| EventQuestionV2Rejected
|
||||||
| EventTodoUpdated
|
| EventTodoUpdated
|
||||||
|
| EventProjectDirectoriesUpdated
|
||||||
| EventLspUpdated
|
| EventLspUpdated
|
||||||
| EventPermissionAsked
|
| EventPermissionAsked
|
||||||
| EventPermissionReplied
|
| EventPermissionReplied
|
||||||
|
|
@ -77,7 +78,6 @@ export type Event =
|
||||||
| EventMcpToolsChanged
|
| EventMcpToolsChanged
|
||||||
| EventMcpBrowserOpenFailed
|
| EventMcpBrowserOpenFailed
|
||||||
| EventCommandExecuted
|
| EventCommandExecuted
|
||||||
| EventProjectDirectoriesUpdated
|
|
||||||
| EventProjectUpdated
|
| EventProjectUpdated
|
||||||
| EventVcsBranchUpdated
|
| EventVcsBranchUpdated
|
||||||
| EventQuestionAsked
|
| EventQuestionAsked
|
||||||
|
|
@ -1378,6 +1378,13 @@ export type GlobalEvent = {
|
||||||
todos: Array<Todo>
|
todos: Array<Todo>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
type: "project.directories.updated"
|
||||||
|
properties: {
|
||||||
|
projectID: string
|
||||||
|
}
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
id: string
|
id: string
|
||||||
type: "lsp.updated"
|
type: "lsp.updated"
|
||||||
|
|
@ -1488,13 +1495,6 @@ export type GlobalEvent = {
|
||||||
messageID: string
|
messageID: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
id: string
|
|
||||||
type: "project.directories.updated"
|
|
||||||
properties: {
|
|
||||||
projectID: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
id: string
|
id: string
|
||||||
type: "project.updated"
|
type: "project.updated"
|
||||||
|
|
@ -1937,6 +1937,10 @@ export type Config = {
|
||||||
watcher?: {
|
watcher?: {
|
||||||
ignore?: Array<string>
|
ignore?: Array<string>
|
||||||
}
|
}
|
||||||
|
projectCopy?: {
|
||||||
|
strategy?: "git_worktree"
|
||||||
|
directory?: string
|
||||||
|
}
|
||||||
snapshot?: boolean
|
snapshot?: boolean
|
||||||
plugin?: Array<
|
plugin?: Array<
|
||||||
| string
|
| string
|
||||||
|
|
@ -5005,6 +5009,14 @@ export type EventTodoUpdated = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EventProjectDirectoriesUpdated = {
|
||||||
|
id: string
|
||||||
|
type: "project.directories.updated"
|
||||||
|
properties: {
|
||||||
|
projectID: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type EventLspUpdated = {
|
export type EventLspUpdated = {
|
||||||
id: string
|
id: string
|
||||||
type: "lsp.updated"
|
type: "lsp.updated"
|
||||||
|
|
@ -5070,14 +5082,6 @@ export type EventCommandExecuted = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventProjectDirectoriesUpdated = {
|
|
||||||
id: string
|
|
||||||
type: "project.directories.updated"
|
|
||||||
properties: {
|
|
||||||
projectID: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventProjectUpdated = {
|
export type EventProjectUpdated = {
|
||||||
id: string
|
id: string
|
||||||
type: "project.updated"
|
type: "project.updated"
|
||||||
|
|
@ -7013,8 +7017,8 @@ export type ExperimentalProjectCopyRemoveResponse =
|
||||||
|
|
||||||
export type ExperimentalProjectCopyCreateData = {
|
export type ExperimentalProjectCopyCreateData = {
|
||||||
body?: {
|
body?: {
|
||||||
strategy: "git_worktree"
|
strategy?: "git_worktree"
|
||||||
directory: string
|
directory?: string
|
||||||
name?: string
|
name?: string
|
||||||
context?: string
|
context?: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import path from "path"
|
|
||||||
import { useTuiPaths } from "../../context/runtime"
|
|
||||||
import { errorMessage } from "../../util/error"
|
import { errorMessage } from "../../util/error"
|
||||||
import { useDialog } from "../../ui/dialog"
|
import { useDialog } from "../../ui/dialog"
|
||||||
import { useSDK } from "../../context/sdk"
|
import { useSDK } from "../../context/sdk"
|
||||||
|
|
@ -20,7 +18,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const homeDestination = useHomeSessionDestination()
|
const homeDestination = useHomeSessionDestination()
|
||||||
const paths = useTuiPaths()
|
|
||||||
const [creating, setCreating] = createSignal(false)
|
const [creating, setCreating] = createSignal(false)
|
||||||
const [creatingDots, setCreatingDots] = createSignal(3)
|
const [creatingDots, setCreatingDots] = createSignal(3)
|
||||||
const [progress, setProgress] = createSignal<string>()
|
const [progress, setProgress] = createSignal<string>()
|
||||||
|
|
@ -34,8 +31,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||||
const result = await sdk.client.experimental.projectCopy.create(
|
const result = await sdk.client.experimental.projectCopy.create(
|
||||||
{
|
{
|
||||||
projectID,
|
projectID,
|
||||||
strategy: "git_worktree",
|
|
||||||
directory: path.join(paths.worktree, projectID.slice(0, 6)),
|
|
||||||
context,
|
context,
|
||||||
},
|
},
|
||||||
{ throwOnError: true },
|
{ throwOnError: true },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue