diff --git a/apps/docs/content/docs/cli/cli-reference.mdx b/apps/docs/content/docs/cli/cli-reference.mdx index 4964e8bd371..40872a25677 100644 --- a/apps/docs/content/docs/cli/cli-reference.mdx +++ b/apps/docs/content/docs/cli/cli-reference.mdx @@ -635,6 +635,54 @@ superset workspaces create \ ``` +", required: true, description: "Project ID." }, + { flag: "--path ", required: true, description: "Absolute path of the existing worktree on the target host." }, + { flag: "--branch ", required: true, description: "Branch checked out in that worktree." }, + { flag: "--name ", description: "Workspace name. Defaults to the worktree directory name." }, + { flag: "--base-branch ", description: "Branch to record as the compare base for this workspace." }, + { flag: "--host ", description: "Target host machineId. Mutually exclusive with `--local`; one is required." }, + { flag: "--local", description: "Target this machine. Mutually exclusive with `--host`; one is required." }, + ]} + output={`{ + workspace: Workspace; + terminals: Array<{ terminalId: string; label?: string }>; + warnings: string[]; + branchMismatch: boolean; +}`} +> +Register a git worktree that **already exists** on the target host as a +workspace, instead of creating a new one. Use this when a script prepared the +branch and directory under its own naming rules and Superset only needs to +manage sessions for it — the CLI counterpart of Project Settings → Import in +the desktop app. + +`--path` is interpreted on the target host, so it must be absolute, and the +worktree must already be registered in that project's `git worktree list` +(run `git worktree add` first). A path that belongs to a different project's +repo is rejected. + +The host reads the branch actually checked out at `--path` rather than +trusting `--branch`. If the two disagree, the returned workspace keeps the +real branch and `branchMismatch` is `true`. + +Importing is idempotent: running it again for the same path reuses the +existing workspace, and a workspace whose branch was renamed in place is +re-pointed at the new branch rather than duplicated. + +```bash +git -C ~/code/repo worktree add -b eng-123/login-fix ~/code/repo/.worktrees/eng-123 + +superset workspaces import \ + --project prj_… \ + --path ~/code/repo/.worktrees/eng-123 \ + --branch eng-123/login-fix \ + --local +``` + + | undefined; +let adoptedBranch = "feature/from-linear"; + +mock.module("../../../lib/host-target", () => ({ + requireHostTarget: () => "host-1", + resolveHostTarget: () => ({ + hostId: "host-1", + client: { + workspaceCreation: { + adopt: { + mutate: async (input: Record) => { + adoptInput = input; + return { + workspace: { + id: "ws-1", + name: input.workspaceName, + branch: adoptedBranch, + }, + terminals: [], + warnings: [], + }; + }, + }, + }, + }, + }), +})); + +const { default: importWorkspaceCommand } = await import("./command"); + +interface ImportResult { + data: { + workspace: { id: string; name: string; branch: string }; + branchMismatch: boolean; + }; + message: string; +} + +function invoke( + overrides: { + path?: string; + branch?: string; + name?: string; + baseBranch?: string; + } = {}, +): Promise { + return importWorkspaceCommand.run({ + ctx: { + config: { organizationId: "org-1" }, + bearer: "bearer", + } as never, + args: {} as never, + options: { + local: true, + project: "project-1", + path: "/repo/.worktrees/from-linear", + branch: "feature/from-linear", + ...overrides, + } as never, + signal: new AbortController().signal, + }) as Promise; +} + +afterEach(() => { + adoptInput = undefined; + adoptedBranch = "feature/from-linear"; +}); + +describe("workspaces import", () => { + test("adopts a pre-existing worktree by path on the target host", async () => { + const result = await invoke(); + + expect(adoptInput).toMatchObject({ + projectId: "project-1", + worktreePath: "/repo/.worktrees/from-linear", + branch: "feature/from-linear", + }); + expect(result.data).toMatchObject({ + workspace: { id: "ws-1", branch: "feature/from-linear" }, + }); + }); + + test("defaults the workspace name to the worktree directory name", async () => { + await invoke(); + + expect(adoptInput).toMatchObject({ workspaceName: "from-linear" }); + }); + + test("honours an explicit --name", async () => { + await invoke({ name: "Linear ENG-123" }); + + expect(adoptInput).toMatchObject({ workspaceName: "Linear ENG-123" }); + }); + + test("forwards --base-branch so the host records the compare base", async () => { + await invoke({ baseBranch: "main" }); + + expect(adoptInput).toMatchObject({ baseBranch: "main" }); + }); + + test("rejects a relative --path instead of resolving it against the CLI cwd", async () => { + await expect(invoke({ path: ".worktrees/from-linear" })).rejects.toThrow( + /absolute path/i, + ); + expect(adoptInput).toBeUndefined(); + }); + + test("reports a branch mismatch when the worktree has another branch checked out", async () => { + adoptedBranch = "feature/actually-this-one"; + + const result = await invoke({ branch: "feature/from-linear" }); + + expect(result.data).toMatchObject({ branchMismatch: true }); + expect(result.message).toContain("feature/actually-this-one"); + }); +}); diff --git a/packages/cli/src/commands/workspaces/import/command.ts b/packages/cli/src/commands/workspaces/import/command.ts new file mode 100644 index 00000000000..b90b2c1688b --- /dev/null +++ b/packages/cli/src/commands/workspaces/import/command.ts @@ -0,0 +1,86 @@ +import { posix } from "node:path"; +import { boolean, CLIError, string } from "@superset/cli-framework"; +import { command } from "../../../lib/command"; +import { requireHostTarget, resolveHostTarget } from "../../../lib/host-target"; + +export default command({ + description: "Import an existing git worktree on a host as a workspace", + options: { + host: string().desc("Target host machineId"), + local: boolean().desc("Target this machine"), + project: string().required().desc("Project ID"), + path: string() + .required() + .desc("Absolute path of the existing worktree on the target host"), + branch: string().required().desc("Branch checked out in that worktree"), + name: string().desc( + "Workspace name (defaults to the worktree directory name)", + ), + baseBranch: string().desc("Branch to record as the compare base"), + }, + run: async ({ ctx, options }) => { + const organizationId = ctx.config.organizationId; + if (!organizationId) { + throw new CLIError("No active organization", "Run: superset auth login"); + } + + // The path is interpreted on the target host, which may not be this + // machine — resolving it against the CLI's cwd would silently point + // at the wrong directory. + if (!posix.isAbsolute(options.path)) { + throw new CLIError( + `--path must be an absolute path on the target host: ${options.path}`, + "Pass the full worktree path, e.g. --path /Users/me/code/repo/.worktrees/my-branch", + ); + } + const worktreePath = posix.normalize(options.path).replace(/\/+$/, ""); + + const branch = options.branch.trim(); + if (!branch) { + throw new CLIError( + "--branch is empty", + "Pass the branch checked out in the worktree", + ); + } + + const workspaceName = options.name ?? posix.basename(worktreePath); + if (!workspaceName) { + throw new CLIError( + "Could not derive a workspace name from --path", + "Pass --name ", + ); + } + + const hostId = requireHostTarget({ + host: options.host ?? undefined, + local: options.local ?? undefined, + }); + + const target = resolveHostTarget({ + requestedHostId: hostId, + organizationId, + userJwt: ctx.bearer, + }); + + const result = await target.client.workspaceCreation.adopt.mutate({ + projectId: options.project, + workspaceName, + branch, + worktreePath, + baseBranch: options.baseBranch ?? undefined, + }); + + // The host reads the branch actually checked out at the path rather + // than trusting `--branch`, so surface a disagreement instead of + // silently registering a different branch than the caller asked for. + const adoptedBranch = result.workspace.branch; + const branchMismatch = adoptedBranch !== branch; + + return { + data: { ...result, branchMismatch }, + message: branchMismatch + ? `Imported worktree ${worktreePath} as workspace "${result.workspace.name}" on host ${target.hostId} — it has "${adoptedBranch}" checked out, not "${branch}"` + : `Imported worktree ${worktreePath} as workspace "${result.workspace.name}" on host ${target.hostId}`, + }; + }, +}); diff --git a/packages/mcp-v2/src/tools/register.ts b/packages/mcp-v2/src/tools/register.ts index 94062e5bfbf..8e651edfe3b 100644 --- a/packages/mcp-v2/src/tools/register.ts +++ b/packages/mcp-v2/src/tools/register.ts @@ -33,6 +33,7 @@ import * as terminalsRead from "./terminals/read"; import * as terminalsSend from "./terminals/send"; import * as workspacesCreate from "./workspaces/create"; import * as workspacesDelete from "./workspaces/delete"; +import * as workspacesImport from "./workspaces/import"; import * as workspacesList from "./workspaces/list"; import * as workspacesUpdate from "./workspaces/update"; @@ -57,6 +58,7 @@ const REGISTRARS = [ automationsLogs, workspacesList, workspacesCreate, + workspacesImport, workspacesUpdate, workspacesDelete, agentsCreate, diff --git a/packages/mcp-v2/src/tools/workspaces/import.test.ts b/packages/mcp-v2/src/tools/workspaces/import.test.ts new file mode 100644 index 00000000000..fb3de3cbbc3 --- /dev/null +++ b/packages/mcp-v2/src/tools/workspaces/import.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpContext } from "../../auth"; + +interface CapturedTool { + name: string; + description: string; + inputSchema: Record; + handler: (input: never, ctx: McpContext) => Promise; +} + +let tool: CapturedTool | undefined; +let hostCall: + | { procedure: string; method: string; input: Record } + | undefined; + +// `define-tool` and `host-service-client` are the seams: the real +// `define-tool` pulls the cloud auth/db env in at import time, and the real +// client would issue a relay fetch. Mocking both leaves the tool's own +// wiring — name, schema, path handling, target procedure — under test. +mock.module("../../define-tool", () => ({ + defineTool: (_server: McpServer, def: CapturedTool) => { + tool = def; + }, +})); + +mock.module("../../host-service-client", () => ({ + hostServiceCall: async ( + _options: unknown, + procedure: string, + method: string, + input: Record, + ) => { + hostCall = { procedure, method, input }; + return { + workspace: { + id: "ws-1", + projectId: input.projectId, + name: input.workspaceName, + branch: input.branch, + }, + terminals: [], + warnings: [], + }; + }, +})); + +const { register } = await import("./import"); + +const ctx: McpContext = { + userId: "user-1", + email: "dev@example.com", + organizationId: "org-1", + organizationIds: ["org-1"], + source: "api-key", + clientLabel: null, + requestId: "req-1", + bearerToken: "jwt-1", + relayUrl: "https://relay.example.com", +}; + +function registerTool(): CapturedTool { + tool = undefined; + register({} as McpServer); + if (!tool) throw new Error("tool was not registered"); + return tool; +} + +function invoke(args: Record = {}) { + const registered = registerTool(); + return registered.handler( + { + projectId: "11111111-1111-1111-1111-111111111111", + hostId: "host-1", + worktreePath: "/repo/.worktrees/from-linear", + branch: "feature/from-linear", + ...args, + } as never, + ctx, + ); +} + +afterEach(() => { + hostCall = undefined; +}); + +describe("workspaces_import", () => { + test("is exposed as an MCP tool taking a worktree path", () => { + const registered = registerTool(); + + expect(registered.name).toBe("workspaces_import"); + expect(Object.keys(registered.inputSchema)).toEqual( + expect.arrayContaining(["projectId", "hostId", "worktreePath", "branch"]), + ); + }); + + test("adopts the existing worktree on the host instead of creating one", async () => { + await invoke(); + + expect(hostCall).toMatchObject({ + procedure: "workspaceCreation.adopt", + method: "mutation", + input: { + projectId: "11111111-1111-1111-1111-111111111111", + worktreePath: "/repo/.worktrees/from-linear", + branch: "feature/from-linear", + workspaceName: "from-linear", + }, + }); + }); + + test("honours an explicit name", async () => { + await invoke({ name: "Linear ENG-123" }); + + expect(hostCall?.input).toMatchObject({ + workspaceName: "Linear ENG-123", + }); + }); + + test("forwards baseBranch so the host records the compare base", async () => { + await invoke({ baseBranch: "main" }); + + expect(hostCall?.input).toMatchObject({ baseBranch: "main" }); + }); + + test("rejects a relative worktreePath without calling the host", async () => { + await expect( + invoke({ worktreePath: ".worktrees/from-linear" }), + ).rejects.toThrow(/absolute path/i); + expect(hostCall).toBeUndefined(); + }); +}); diff --git a/packages/mcp-v2/src/tools/workspaces/import.ts b/packages/mcp-v2/src/tools/workspaces/import.ts new file mode 100644 index 00000000000..e09a98e9048 --- /dev/null +++ b/packages/mcp-v2/src/tools/workspaces/import.ts @@ -0,0 +1,80 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { defineTool } from "../../define-tool"; +import { hostServiceCall } from "../../host-service-client"; + +export function register(server: McpServer): void { + defineTool(server, { + name: "workspaces_import", + annotations: { destructiveHint: false }, + description: + "Import a git worktree that already exists on a host into a Superset workspace, without creating a new worktree. Use this when something outside Superset (a script, a CI job, a manual `git worktree add`) already prepared the branch and directory and you only need Superset to manage sessions for it. `worktreePath` must be an absolute path on that host and must already be registered in the project's `git worktree list` — otherwise the call fails. Safe to repeat: importing the same path again reuses the existing workspace rather than duplicating it. To have Superset create the worktree itself, use workspaces_create instead. Use projects_list and hosts_list first to get the projectId and hostId.", + inputSchema: { + projectId: z.string().uuid().describe("Project UUID."), + hostId: z + .string() + .min(1) + .describe("Host machineId the worktree lives on."), + worktreePath: z + .string() + .min(1) + .describe( + "Absolute path of the existing worktree on the host, e.g. /Users/me/code/repo/.worktrees/my-branch.", + ), + branch: z + .string() + .min(1) + .describe( + "Branch checked out in that worktree. The host reads the branch actually checked out at `worktreePath` and returns it, so compare the returned `workspace.branch` against what you sent.", + ), + name: z + .string() + .min(1) + .optional() + .describe( + "Workspace name (display). Defaults to the worktree directory name.", + ), + baseBranch: z + .string() + .optional() + .describe("Branch to record as the compare base for this workspace."), + }, + handler: async (input, ctx) => { + const worktreePath = input.worktreePath.replace(/\/+$/, ""); + if (!worktreePath.startsWith("/")) { + throw new Error( + `worktreePath must be an absolute path on the host: ${input.worktreePath}`, + ); + } + const workspaceName = + input.name ?? (worktreePath.split("/").pop() as string); + + return hostServiceCall<{ + workspace: { + id: string; + projectId: string; + name: string; + branch: string; + }; + terminals: Array<{ terminalId: string; label?: string }>; + warnings: string[]; + }>( + { + relayUrl: ctx.relayUrl, + organizationId: ctx.organizationId, + hostId: input.hostId, + jwt: ctx.bearerToken, + }, + "workspaceCreation.adopt", + "mutation", + { + projectId: input.projectId, + workspaceName, + branch: input.branch, + worktreePath, + baseBranch: input.baseBranch, + }, + ); + }, + }); +}