diff --git a/docs/superpowers/plans/2026-07-07-create-workspace-repo-clone.md b/docs/superpowers/plans/2026-07-07-create-workspace-repo-clone.md new file mode 100644 index 0000000..54fb4ec --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-create-workspace-repo-clone.md @@ -0,0 +1,343 @@ +# Create Workspace with Repository Clone — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add optional `repo_url` and `branch` parameters to `create_workspace` so that a DevWorkspace can start with a git repository pre-cloned. + +**Architecture:** When `repo_url` is provided, inject a `projects` entry into the DevWorkspace `spec.template`. The DevWorkspace operator handles the actual clone to `/projects/`. Project name is derived from the URL's last path segment, stripping `.git` and trailing slashes. + +**Tech Stack:** TypeScript, Zod (parameter validation), Vitest (testing), `@kubernetes/client-node` + +## Global Constraints + +- ESM modules — all imports use `.js` extensions +- Kebab-case filenames, snake_case for MCP tool names +- Strict TypeScript (ES2022, Node16 modules) +- Tests mirror source structure under `tests/` +- Build: `npm run build` — Lint: via Biome — Test: `npm test` + +--- + +### Task 1: Add repo_url and branch support to create-workspace + +**Files:** +- Modify: `src/tools/create-workspace.ts` (entire file — add params, validation, projects array building) +- Modify: `src/tools.ts:298-319` (add zod params to tool registration, pass through) +- Modify: `tests/tools/create-workspace.test.ts` (add 4 new test cases) + +**Interfaces:** +- Consumes: existing `createWorkspace` function, Kubernetes `createNamespacedCustomObject` API +- Produces: updated `createWorkspace({ name?, tools?, repo_url?, branch? })` — same return type `{ name, started, tools_injected }` + +#### Step 1: Write the failing tests + +- [ ] **Step 1a: Add test for `repo_url` only** + +Add this test to `tests/tools/create-workspace.test.ts` inside the existing `describe('createWorkspace')` block, after the last `it()`: + +```ts +it('creates a workspace with repo_url and projects entry', async () => { + const { getCustomObjectsApi, getNamespace } = await import( + '../../src/kube/client.js' + ); + const mockApi = { + createNamespacedCustomObject: vi.fn().mockResolvedValue({ + metadata: { name: 'my-workspace' }, + }), + patchNamespacedCustomObject: vi.fn().mockResolvedValue({}), + }; + vi.mocked(getCustomObjectsApi).mockReturnValue(mockApi as any); + vi.mocked(getNamespace).mockReturnValue('test-namespace'); + + const { createWorkspace } = await import( + '../../src/tools/create-workspace.js' + ); + const result = await createWorkspace({ + name: 'my-workspace', + repo_url: 'https://github.com/org/my-app.git', + }); + + expect(result).toEqual({ + name: 'my-workspace', + started: true, + tools_injected: [], + }); + + const body = mockApi.createNamespacedCustomObject.mock.calls[0][0].body; + expect(body.spec.template.projects).toEqual([ + { + name: 'my-app', + git: { + remotes: { origin: 'https://github.com/org/my-app.git' }, + }, + }, + ]); +}); +``` + +- [ ] **Step 1b: Add test for `repo_url` + `branch`** + +```ts +it('creates a workspace with repo_url and branch', async () => { + const { getCustomObjectsApi, getNamespace } = await import( + '../../src/kube/client.js' + ); + const mockApi = { + createNamespacedCustomObject: vi.fn().mockResolvedValue({ + metadata: { name: 'my-workspace' }, + }), + patchNamespacedCustomObject: vi.fn().mockResolvedValue({}), + }; + vi.mocked(getCustomObjectsApi).mockReturnValue(mockApi as any); + vi.mocked(getNamespace).mockReturnValue('test-namespace'); + + const { createWorkspace } = await import( + '../../src/tools/create-workspace.js' + ); + await createWorkspace({ + name: 'my-workspace', + repo_url: 'https://github.com/org/my-app', + branch: 'feature-branch', + }); + + const body = mockApi.createNamespacedCustomObject.mock.calls[0][0].body; + expect(body.spec.template.projects).toEqual([ + { + name: 'my-app', + git: { + remotes: { origin: 'https://github.com/org/my-app' }, + checkoutFrom: { revision: 'feature-branch' }, + }, + }, + ]); +}); +``` + +- [ ] **Step 1c: Add test for `branch` without `repo_url` (validation error)** + +```ts +it('throws when branch is provided without repo_url', async () => { + const { createWorkspace } = await import( + '../../src/tools/create-workspace.js' + ); + + await expect( + createWorkspace({ branch: 'main' }), + ).rejects.toThrow('branch requires repo_url'); +}); +``` + +- [ ] **Step 1d: Add test for project name derivation edge cases** + +```ts +it('derives project name from repo_url correctly', async () => { + const { getCustomObjectsApi, getNamespace } = await import( + '../../src/kube/client.js' + ); + const mockApi = { + createNamespacedCustomObject: vi.fn().mockResolvedValue({ + metadata: { name: 'ws' }, + }), + patchNamespacedCustomObject: vi.fn().mockResolvedValue({}), + }; + vi.mocked(getCustomObjectsApi).mockReturnValue(mockApi as any); + vi.mocked(getNamespace).mockReturnValue('test-namespace'); + + const { createWorkspace } = await import( + '../../src/tools/create-workspace.js' + ); + + // Trailing slash + await createWorkspace({ name: 'ws', repo_url: 'https://github.com/org/trailing/' }); + let body = mockApi.createNamespacedCustomObject.mock.calls[0][0].body; + expect(body.spec.template.projects[0].name).toBe('trailing'); + + // Nested path with .git + mockApi.createNamespacedCustomObject.mockResolvedValue({ metadata: { name: 'ws' } }); + await createWorkspace({ name: 'ws', repo_url: 'https://gitlab.com/group/sub/repo.git' }); + body = mockApi.createNamespacedCustomObject.mock.calls[1][0].body; + expect(body.spec.template.projects[0].name).toBe('repo'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -- tests/tools/create-workspace.test.ts` +Expected: 4 new tests FAIL (unknown parameters / missing implementation) + +#### Step 3: Implement the changes + +- [ ] **Step 3a: Update `src/tools/create-workspace.ts`** + +Replace the full file content with: + +```ts +import { getCustomObjectsApi, getNamespace } from '../kube/client.js'; +import { AGENT_BASE_IMAGE } from '../types.js'; +import { injectTool } from './inject-tool.js'; + +interface CreateWorkspaceParams { + name?: string; + tools?: string[]; + repo_url?: string; + branch?: string; +} + +export function deriveProjectName(repoUrl: string): string { + const stripped = repoUrl.replace(/\/+$/, ''); + const lastSegment = stripped.split('/').pop() ?? 'project'; + return lastSegment.replace(/\.git$/, ''); +} + +export async function createWorkspace(params: CreateWorkspaceParams): Promise<{ + name: string; + started: boolean; + tools_injected: string[]; +}> { + if (params.branch && !params.repo_url) { + throw new Error('branch requires repo_url'); + } + + const api = getCustomObjectsApi(); + const namespace = getNamespace(); + + const metadata: Record = params.name + ? { name: params.name } + : { generateName: 'empty-' }; + + const template: Record = { + components: [ + { + name: 'dev', + container: { + image: AGENT_BASE_IMAGE, + memoryLimit: '8Gi', + memoryRequest: '1Gi', + cpuRequest: '500m', + cpuLimit: '2000m', + endpoints: [ + { + name: 'ttyd-terminal', + targetPort: 7681, + exposure: 'public', + protocol: 'https', + attributes: { + type: 'main', + cookiesAuthEnabled: true, + discoverable: false, + urlRewriteSupported: true, + }, + }, + ], + }, + }, + ], + }; + + if (params.repo_url) { + const projectName = deriveProjectName(params.repo_url); + const gitSource: Record = { + remotes: { origin: params.repo_url }, + }; + if (params.branch) { + gitSource.checkoutFrom = { revision: params.branch }; + } + template.projects = [{ name: projectName, git: gitSource }]; + } + + const body = { + apiVersion: 'workspace.devfile.io/v1alpha2', + kind: 'DevWorkspace', + metadata, + spec: { + started: false, + template, + }, + }; + + const result = await api.createNamespacedCustomObject({ + group: 'workspace.devfile.io', + version: 'v1alpha2', + namespace, + plural: 'devworkspaces', + body, + }); + + const workspaceName: string = (result as any).metadata.name; + const toolsToInject = params.tools ?? []; + const injected: string[] = []; + + for (const tool of toolsToInject) { + await injectTool({ workspace: workspaceName, tool }); + injected.push(tool); + } + + await api.patchNamespacedCustomObject({ + group: 'workspace.devfile.io', + version: 'v1alpha2', + namespace, + plural: 'devworkspaces', + name: workspaceName, + body: [{ op: 'replace', path: '/spec/started', value: true }], + }); + + return { name: workspaceName, started: true, tools_injected: injected }; +} +``` + +- [ ] **Step 3b: Update tool registration in `src/tools.ts`** + +In `src/tools.ts`, find the `create_workspace` tool registration (around line 298-319). Replace the parameter object and handler to pass the new params: + +Replace: +```ts + { + name: z + .string() + .optional() + .describe('Workspace name (auto-generated if omitted)'), + tools: z + .array(TOOL_ENUM) + .optional() + .describe('Tools to pre-install on workspace creation'), + }, + async ({ name, tools }) => { + try { + const result = await createWorkspace({ name, tools }); +``` + +With: +```ts + { + name: z + .string() + .optional() + .describe('Workspace name (auto-generated if omitted)'), + tools: z + .array(TOOL_ENUM) + .optional() + .describe('Tools to pre-install on workspace creation'), + repo_url: z + .string() + .url() + .optional() + .describe('Git repository URL to clone into the workspace'), + branch: z + .string() + .optional() + .describe('Branch or revision to check out (requires repo_url)'), + }, + async ({ name, tools, repo_url, branch }) => { + try { + const result = await createWorkspace({ name, tools, repo_url, branch }); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test -- tests/tools/create-workspace.test.ts` +Expected: all 6 tests PASS (2 existing + 4 new) + +- [ ] **Step 5: Run full test suite and build** + +Run: `npm test && npm run build` +Expected: all tests pass, build succeeds with no errors diff --git a/docs/superpowers/specs/2026-07-07-create-workspace-repo-clone-design.md b/docs/superpowers/specs/2026-07-07-create-workspace-repo-clone-design.md new file mode 100644 index 0000000..71d9080 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-create-workspace-repo-clone-design.md @@ -0,0 +1,80 @@ +# Create Workspace with Repository Clone + +Add optional git repository cloning to `create_workspace` so that a workspace starts with a project already checked out. + +## Parameters + +Two new optional parameters on `create_workspace`: + +| Parameter | Type | Required | Description | +|------------|--------|----------|--------------------------------------------------| +| `repo_url` | string | no | Git repository URL to clone into the workspace | +| `branch` | string | no | Branch or revision to check out (requires `repo_url`) | + +Validation: passing `branch` without `repo_url` is an error. + +## DevWorkspace Spec Change + +When `repo_url` is provided, a `projects` array is added to `spec.template` alongside the existing `components`: + +```yaml +spec: + template: + projects: + - name: "my-app" + git: + remotes: + origin: "https://github.com/org/my-app" + checkoutFrom: # only present if branch is provided + revision: "feature-branch" + components: + - name: dev + container: ... # unchanged +``` + +The DevWorkspace operator clones the repo to `/projects/` on startup. + +### Project Name Derivation + +The project name is extracted from the last path segment of the URL, with `.git` suffix stripped: + +| URL | Project name | +|------------------------------------------|-------------| +| `https://github.com/org/my-app.git` | `my-app` | +| `https://github.com/org/my-app` | `my-app` | +| `https://gitlab.com/group/sub/repo.git` | `repo` | +| `https://github.com/org/my-app/` | `my-app` | + +## Tool Registration + +In `tools.ts`, add two zod parameters to the `create_workspace` tool: + +```ts +repo_url: z.string().url().optional() + .describe('Git repository URL to clone into the workspace') +branch: z.string().optional() + .describe('Branch or revision to check out (requires repo_url)') +``` + +The tool description remains unchanged. + +## Files Changed + +- `src/tools/create-workspace.ts` — add `repo_url` and `branch` to params interface, build `projects` array when `repo_url` is present, validate `branch` requires `repo_url` +- `src/tools.ts` — add `repo_url` and `branch` zod params to tool registration, pass them through to `createWorkspace` +- `tests/tools/create-workspace.test.ts` — new test cases + +## Testing + +- `create_workspace` with `repo_url` only: verify `projects` array in created body with correct name derivation +- `create_workspace` with `repo_url` + `branch`: verify `checkoutFrom.revision` is present +- `branch` without `repo_url`: verify it throws an error +- Project name derivation: URLs with/without `.git` suffix, trailing slashes + +## What Does Not Change + +- Container image, resources, endpoints +- Tool injection flow +- Start patch (`spec.started: true`) +- Return type (`{ name, started, tools_injected }`) +- Behavior when `repo_url` is omitted (identical to current) diff --git a/src/tools.ts b/src/tools.ts index 9cb1fcb..dfd76bc 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -318,10 +318,16 @@ export function createMcpServer(mode: ServerMode = 'orchestration'): McpServer { .string() .optional() .describe('Branch or revision to check out (requires repo_url)'), + post_start_command: z + .string() + .trim() + .min(1) + .optional() + .describe('Shell command to run in the dev container after workspace starts'), }, - async ({ name, tools, repo_url, branch }) => { + async ({ name, tools, repo_url, branch, post_start_command }) => { try { - const result = await createWorkspace({ name, tools, repo_url, branch }); + const result = await createWorkspace({ name, tools, repo_url, branch, post_start_command }); return { content: [{ type: 'text', text: JSON.stringify(result) }] }; } catch (error) { return toolError(error); diff --git a/src/tools/create-workspace.ts b/src/tools/create-workspace.ts index 67213b6..7bf9149 100644 --- a/src/tools/create-workspace.ts +++ b/src/tools/create-workspace.ts @@ -7,6 +7,7 @@ interface CreateWorkspaceParams { tools?: string[]; repo_url?: string; branch?: string; + post_start_command?: string; } export function deriveProjectName(repoUrl: string): string { @@ -79,6 +80,24 @@ export async function createWorkspace(params: CreateWorkspaceParams): Promise<{ template.projects = [{ name: projectName, git: gitSource }]; } + if (params.post_start_command) { + const existingCommands = Array.isArray(template.commands) + ? template.commands + : []; + template.commands = [ + ...existingCommands, + { + id: 'post-start', + exec: { component: 'dev', commandLine: params.post_start_command }, + }, + ]; + const existingEvents = + template.events && typeof template.events === 'object' + ? (template.events as Record) + : {}; + template.events = { ...existingEvents, postStart: ['post-start'] }; + } + const body = { apiVersion: 'workspace.devfile.io/v1alpha2', kind: 'DevWorkspace', diff --git a/tests/tools/create-workspace.test.ts b/tests/tools/create-workspace.test.ts index 38a58da..e799718 100644 --- a/tests/tools/create-workspace.test.ts +++ b/tests/tools/create-workspace.test.ts @@ -229,6 +229,68 @@ describe('createWorkspace', () => { ]); }); + it('creates a workspace with post_start_command', async () => { + const { getCustomObjectsApi, getNamespace } = await import( + '../../src/kube/client.js' + ); + const mockApi = { + createNamespacedCustomObject: vi.fn().mockResolvedValue({ + metadata: { name: 'my-workspace' }, + }), + patchNamespacedCustomObject: vi.fn().mockResolvedValue({}), + }; + vi.mocked(getCustomObjectsApi).mockReturnValue(mockApi as any); + vi.mocked(getNamespace).mockReturnValue('test-namespace'); + + const { createWorkspace } = await import( + '../../src/tools/create-workspace.js' + ); + const result = await createWorkspace({ + name: 'my-workspace', + post_start_command: 'npm install', + }); + + expect(result).toEqual({ + name: 'my-workspace', + started: true, + tools_injected: [], + }); + + const body = mockApi.createNamespacedCustomObject.mock.calls[0][0].body; + expect(body.spec.template.commands).toEqual([ + { + id: 'post-start', + exec: { component: 'dev', commandLine: 'npm install' }, + }, + ]); + expect(body.spec.template.events).toEqual({ + postStart: ['post-start'], + }); + }); + + it('does not add commands or events when post_start_command is omitted', async () => { + const { getCustomObjectsApi, getNamespace } = await import( + '../../src/kube/client.js' + ); + const mockApi = { + createNamespacedCustomObject: vi.fn().mockResolvedValue({ + metadata: { name: 'my-workspace' }, + }), + patchNamespacedCustomObject: vi.fn().mockResolvedValue({}), + }; + vi.mocked(getCustomObjectsApi).mockReturnValue(mockApi as any); + vi.mocked(getNamespace).mockReturnValue('test-namespace'); + + const { createWorkspace } = await import( + '../../src/tools/create-workspace.js' + ); + await createWorkspace({ name: 'my-workspace' }); + + const body = mockApi.createNamespacedCustomObject.mock.calls[0][0].body; + expect(body.spec.template.commands).toBeUndefined(); + expect(body.spec.template.events).toBeUndefined(); + }); + it('throws when branch is provided without repo_url', async () => { const { createWorkspace } = await import( '../../src/tools/create-workspace.js' @@ -268,6 +330,60 @@ describe('createWorkspace', () => { expect(body.spec.template.projects[0].name).toBe('repo'); }); + it('creates a workspace with both tools and post_start_command', async () => { + const { getCustomObjectsApi, getNamespace } = await import( + '../../src/kube/client.js' + ); + const mockApi = { + createNamespacedCustomObject: vi.fn().mockResolvedValue({ + metadata: { name: 'my-workspace' }, + }), + getNamespacedCustomObject: vi.fn().mockResolvedValue({ + spec: { + template: { + commands: [ + { + id: 'post-start', + exec: { component: 'dev', commandLine: 'npm install' }, + }, + ], + events: { postStart: ['post-start'] }, + }, + }, + metadata: {}, + }), + patchNamespacedCustomObject: vi.fn().mockResolvedValue({}), + }; + vi.mocked(getCustomObjectsApi).mockReturnValue(mockApi as any); + vi.mocked(getNamespace).mockReturnValue('test-namespace'); + + const { createWorkspace } = await import( + '../../src/tools/create-workspace.js' + ); + const result = await createWorkspace({ + name: 'my-workspace', + tools: ['opencode'], + post_start_command: 'npm install', + }); + + expect(result).toEqual({ + name: 'my-workspace', + started: true, + tools_injected: ['opencode'], + }); + + const body = mockApi.createNamespacedCustomObject.mock.calls[0][0].body; + expect(body.spec.template.commands).toEqual([ + { + id: 'post-start', + exec: { component: 'dev', commandLine: 'npm install' }, + }, + ]); + expect(body.spec.template.events).toEqual({ + postStart: ['post-start'], + }); + }); + describe('deriveProjectName', () => { it('lowercases uppercase letters', async () => { const { deriveProjectName } = await import('../../src/tools/create-workspace.js');