Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ perry agent status
## Workspaces

```bash
perry create <name> [--clone URL]
perry start <name>
perry start <name> [--clone URL] # Start (creates if doesn't exist)
perry stop <name>
perry delete <name>
perry list
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ perry build
3. **Delete and recreate:**
```bash
perry delete <name>
perry create <name>
perry start <name>
```

4. **Check for port conflicts:**
Expand Down Expand Up @@ -168,7 +168,7 @@ perry build

4. **Use HTTPS URL instead:**
```bash
perry create myproject --clone https://github.com/user/repo.git
perry start myproject --clone https://github.com/user/repo.git
```

### Workspace deleted but container still running
Expand Down
4 changes: 2 additions & 2 deletions docs/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ perry build
# Start agent
perry agent run

# Create workspace
perry create myproject --clone git@github.com:user/repo.git
# Start workspace (creates if needed)
perry start myproject --clone git@github.com:user/repo.git

# Access via SSH
ssh -p 2201 workspace@localhost
Expand Down
13 changes: 11 additions & 2 deletions src/agent/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,20 @@ export function createRouter(ctx: RouterContext) {
});

const startWorkspace = os
.input(z.object({ name: z.string() }))
.input(
z.object({
name: z.string(),
clone: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
})
)
.output(WorkspaceInfoSchema)
.handler(async ({ input }) => {
try {
return await ctx.workspaces.start(input.name);
return await ctx.workspaces.start(input.name, {
clone: input.clone,
env: input.env,
});
} catch (err) {
mapErrorToORPC(err, 'Failed to start workspace');
}
Expand Down
7 changes: 5 additions & 2 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,12 @@ export class ApiClient {
}
}

async startWorkspace(name: string): Promise<WorkspaceInfo> {
async startWorkspace(
name: string,
options?: { clone?: string; env?: Record<string, string> }
): Promise<WorkspaceInfo> {
try {
return await this.client.workspaces.start({ name });
return await this.client.workspaces.start({ name, clone: options?.clone, env: options?.env });
} catch (err) {
throw this.wrapError(err);
}
Expand Down
30 changes: 5 additions & 25 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,40 +148,20 @@ program
}
});

program
.command('create <name>')
.description('Create a new workspace')
.option('--clone <url>', 'Git repository URL to clone')
.action(async (name, options) => {
try {
const client = await getClient();
console.log(`Creating workspace '${name}'...`);

const workspace = await client.createWorkspace({
name,
clone: options.clone,
});

console.log(`Workspace '${workspace.name}' created.`);
console.log(` Status: ${workspace.status}`);
console.log(` SSH Port: ${workspace.ports.ssh}`);
} catch (err) {
handleError(err);
}
});

program
.command('start <name>')
.description('Start a stopped workspace')
.action(async (name) => {
.description('Start a workspace (creates it if it does not exist)')
.option('--clone <url>', 'Git repository URL to clone (when creating)')
.action(async (name, options) => {
try {
const client = await getClient();
console.log(`Starting workspace '${name}'...`);

const workspace = await client.startWorkspace(name);
const workspace = await client.startWorkspace(name, { clone: options.clone });

console.log(`Workspace '${workspace.name}' started.`);
console.log(` Status: ${workspace.status}`);
console.log(` SSH Port: ${workspace.ports.ssh}`);
} catch (err) {
handleError(err);
}
Expand Down
7 changes: 5 additions & 2 deletions src/workspace/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,10 +547,13 @@ export class WorkspaceManager {
}
}

async start(name: string): Promise<Workspace> {
async start(
name: string,
options?: { clone?: string; env?: Record<string, string> }
): Promise<Workspace> {
const workspace = await this.state.getWorkspace(name);
if (!workspace) {
throw new Error(`Workspace '${name}' not found`);
return this.create({ name, clone: options?.clone, env: options?.env });
}

const containerName = getContainerName(name);
Expand Down
12 changes: 7 additions & 5 deletions test/integration/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,13 @@ describe('Agent - Workspace Lifecycle', () => {
}
});

it('returns 404 when starting non-existent workspace', async () => {
const result = await agent.api.startWorkspace('nonexistent');
expect(result.status).toBe(404);
expect((result.data as { code: string }).code).toBe('NOT_FOUND');
});
it('creates workspace when starting non-existent workspace', async () => {
const name = `nonexistent-${Date.now()}`;
const result = await agent.api.startWorkspace(name);
expect(result.status).toBe(200);
expect((result.data as { name: string }).name).toBe(name);
await agent.api.deleteWorkspace(name);
}, 60000);

it('returns 404 when stopping non-existent workspace', async () => {
const result = await agent.api.stopWorkspace('nonexistent');
Expand Down
30 changes: 10 additions & 20 deletions test/integration/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ describe('CLI commands', () => {
});
});

describe('workspace create', () => {
it('creates a workspace', async () => {
describe('workspace start (create)', () => {
it('creates a workspace when it does not exist', async () => {
const name = generateTestWorkspaceName();
const result = await runCLIExpecting(['create', name], [`Workspace '${name}' created`], {
const result = await runCLIExpecting(['start', name], [`Workspace '${name}' started`], {
env: cliEnv(),
timeout: 30000,
});
Expand All @@ -56,25 +56,25 @@ describe('CLI commands', () => {

it('creates workspace with --clone option', async () => {
const name = generateTestWorkspaceName();
const result = await runCLI(['create', name, '--clone', 'https://github.com/example/repo'], {
const result = await runCLI(['start', name, '--clone', 'https://github.com/example/repo'], {
env: cliEnv(),
timeout: 30000,
});
expect(result.code).toBe(0);
expect(result.stdout).toContain(`Workspace '${name}' created`);
expect(result.stdout).toContain(`Workspace '${name}' started`);

await agent.api.deleteWorkspace(name);
});

it('fails when workspace already exists', async () => {
it('starts existing workspace without error', async () => {
const name = generateTestWorkspaceName();
await agent.api.createWorkspace({ name });

const result = await runCLIExpectingError(['create', name], ['already exists'], {
const result = await runCLIExpecting(['start', name], [`Workspace '${name}' started`], {
env: cliEnv(),
timeout: 30000,
});
expect(result.code).not.toBe(0);
expect(result.code).toBe(0);

await agent.api.deleteWorkspace(name);
});
Expand Down Expand Up @@ -136,13 +136,6 @@ describe('CLI commands', () => {

await agent.api.deleteWorkspace(name);
});

it('fails to start nonexistent workspace', async () => {
const result = await runCLIExpectingError(['start', 'nonexistent-workspace'], ['not found'], {
env: cliEnv(),
});
expect(result.code).not.toBe(0);
});
});

describe('workspace delete', () => {
Expand Down Expand Up @@ -172,11 +165,8 @@ describe('CLI commands', () => {
});

it('fails to delete nonexistent workspace', async () => {
const result = await runCLIExpectingError(
['delete', 'nonexistent-workspace'],
['not found'],
{ env: cliEnv() }
);
const name = `nonexistent-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const result = await runCLIExpectingError(['delete', name], ['not found'], { env: cliEnv() });
expect(result.code).not.toBe(0);
});
});
Expand Down