Skip to content
Draft
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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,17 @@ For a normal ChatGPT coding session:

## Platform Support

DevSpace supports Linux, macOS, and Windows environments with a Bash-compatible
shell.
DevSpace supports Linux, macOS, and Windows. On Windows, the default shell mode
uses native PowerShell so commands do not pass through Git Bash, MSYS, or WSL
before reaching PowerShell.

| Platform | Status | Notes |
| ------------------------------------------------- | ----------------- | ---------------------------------------------- |
| Linux | Supported | Requires Node, npm, Git, and Bash. |
| macOS | Supported | Requires Node, npm, Git, and Bash. |
| Windows with Git Bash, WSL, MSYS2, or Cygwin Bash | Supported | Git Bash is the simplest native Windows setup. |
| Windows PowerShell or `cmd.exe` only | Not supported yet | Install Git Bash or use WSL. |
| Windows PowerShell | Supported | Default on Windows through `DEVSPACE_SHELL=auto`. |
| Windows `cmd.exe` | Supported | Set `DEVSPACE_SHELL=cmd`. |
| Windows with Git Bash, WSL, MSYS2, or Cygwin Bash | Supported | Set `DEVSPACE_SHELL=bash`. |

Run this to inspect your local setup:

Expand Down
10 changes: 10 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com
| `DEVSPACE_OAUTH_OWNER_TOKEN` | Owner password for OAuth approval. Must be at least 16 characters. |
| `DEVSPACE_WORKTREE_ROOT` | Directory for managed Git worktrees. Defaults to `~/.devspace/worktrees`. |
| `DEVSPACE_STATE_DIR` | Directory for SQLite state. Defaults to `~/.local/share/devspace`. |
| `DEVSPACE_SHELL` | Shell backend for `bash`/`run_shell`. Defaults to `auto`. |

## OAuth

Expand Down Expand Up @@ -73,6 +74,15 @@ MCP clients discover metadata from:
| `minimal` | Default. Disables dedicated search and list tools. Clients use the shell tool with `rg`, `grep`, `find`, `ls`, or `tree` for inspection. |
| `full` | Enables dedicated `grep`, `glob`, and `ls` tools. |

`DEVSPACE_SHELL` controls how shell commands are executed.

| Value | Behavior |
| --- | --- |
| `auto` | Default. Uses native PowerShell on Windows and Bash on Linux/macOS. |
| `bash` | Uses Pi's Bash backend. On Windows this requires Git Bash, WSL, MSYS2, or Cygwin Bash. |
| `powershell` | Uses native PowerShell directly, without a Bash/MSYS layer. |
| `cmd` | Uses native `cmd.exe /d /s /c`. |

## Widgets

`DEVSPACE_WIDGETS` controls ChatGPT Apps iframe usage.
Expand Down
49 changes: 43 additions & 6 deletions docs/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,20 +170,57 @@ Uncommitted source checkout changes are not copied into the managed worktree.
Commit, stash, or ask the model to work in checkout mode if those changes are
needed.

## Windows Shell Commands Fail
## Windows Shell Behavior

DevSpace shell execution requires Bash. Native PowerShell and `cmd.exe` command
execution are not supported yet.

Install Git for Windows and use Git Bash, or use WSL, MSYS2, or Cygwin Bash.
On Windows, DevSpace uses native PowerShell by default. This avoids routing
commands through Bash/MSYS before PowerShell receives them, which can otherwise
expand PowerShell variables such as `$_` too early.

Run:

```bash
npx @waishnav/devspace doctor
```

Confirm Bash is detected.
Confirm `Shell mode` and `Shell command`.

To force a specific shell:

```powershell
$env:DEVSPACE_SHELL="powershell"; npx @waishnav/devspace serve
$env:DEVSPACE_SHELL="cmd"; npx @waishnav/devspace serve
$env:DEVSPACE_SHELL="bash"; npx @waishnav/devspace serve
```

When writing PowerShell commands that inspect Windows paths, prefer `-like` or
`.Contains()` for literal path fragments. `-match` uses regex, so a path segment
like `\profiles` can be parsed as the regex escape `\p`. If you need regex, wrap
literal paths with `[regex]::Escape($path)`.

DevSpace blocks fragile PowerShell commands that look like literal Windows paths
being passed to `-match`:

```powershell
$_.CommandLine -match "pydoll-mcp-server\profiles\chatgpt-linkedin-check"
$_.Path -match 'C:\Users\Yuri\Documents'
```

Use literal matching instead:

```powershell
$_.CommandLine.Contains("pydoll-mcp-server\profiles\chatgpt-linkedin-check")
$_.CommandLine -like "*pydoll-mcp-server*profiles*chatgpt-linkedin-check*"
```

If you really need `-match`, escape the literal first:

```powershell
$pattern = [regex]::Escape("pydoll-mcp-server\profiles\chatgpt-linkedin-check")
$_.CommandLine -match $pattern
```

Regex patterns that do not look like Windows paths, such as
`'remote-debugging-port=\d+'`, are allowed.

## Skills Do Not Appear

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"build:app": "vite build",
"dev": "node scripts/dev-server.mjs",
"start": "node dist/cli.js serve",
"test": "tsx src/config.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"test": "tsx src/config.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/pi-tools.test.ts && tsx src/cli.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
15 changes: 13 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { resolve } from "node:path";
import * as prompts from "@clack/prompts";
import { getShellConfig } from "@earendil-works/pi-coding-agent";
import { satisfies } from "semver";
import { loadConfig } from "./config.js";
import { loadConfig, type ShellMode } from "./config.js";
import { resolveShellCommand } from "./pi-tools.js";
import {
generateOwnerToken,
loadDevspaceFiles,
Expand Down Expand Up @@ -214,11 +215,12 @@ async function runDoctor(): Promise<void> {
console.log(`Node ABI: ${process.versions.modules}`);
console.log(`Platform: ${process.platform} ${process.arch}`);
console.log(`Git: ${checkGitAvailable()}`);
console.log(`Bash shell: ${checkBashShell()}`);
console.log(`SQLite native dependency: ${checkSqliteNative()}`);

try {
const config = loadConfig();
console.log(`Shell mode: ${config.shell}`);
console.log(`Shell command: ${checkShellCommand(config.shell)}`);
console.log(`Local MCP URL: http://${config.host}:${config.port}/mcp`);
console.log(`Public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`);
console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`);
Expand Down Expand Up @@ -393,6 +395,15 @@ function checkBashShell(): string {
}
}

function checkShellCommand(mode: ShellMode): string {
if (mode === "bash" || (mode === "auto" && process.platform !== "win32")) {
return checkBashShell();
}

const { command, args } = resolveShellCommand(mode);
return `${command} ${args.join(" ")}`;
}

main(process.argv.slice(2)).catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
Expand Down
9 changes: 9 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off")
assert.equal(loadConfig(baseEnv).toolNaming, "short");
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_NAMING: "short" }).toolNaming, "short");
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_NAMING: "legacy" }).toolNaming, "legacy");
assert.equal(loadConfig(baseEnv).shell, "auto");
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SHELL: "auto" }).shell, "auto");
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SHELL: "bash" }).shell, "bash");
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SHELL: "powershell" }).shell, "powershell");
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SHELL: "cmd" }).shell, "cmd");
assert.equal(loadConfig(baseEnv).minimalTools, true);
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "minimal" }).minimalTools, true);
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).minimalTools, false);
Expand Down Expand Up @@ -47,6 +52,10 @@ assert.throws(
() => loadConfig({ ...baseEnv, DEVSPACE_TOOL_NAMING: "invalid" }),
/Invalid DEVSPACE_TOOL_NAMING: invalid/,
);
assert.throws(
() => loadConfig({ ...baseEnv, DEVSPACE_SHELL: "invalid" }),
/Invalid DEVSPACE_SHELL: invalid/,
);

assert.deepEqual(loadConfig(baseEnv).logging, {
level: "info",
Expand Down
10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { loadDevspaceFiles } from "./user-config.js";

export type ToolNamingMode = "legacy" | "short";
export type WidgetMode = "off" | "changes" | "full";
export type ShellMode = "auto" | "bash" | "powershell" | "cmd";
const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;

Expand All @@ -19,6 +20,7 @@ export interface ServerConfig {
publicBaseUrl: string;
minimalTools: boolean;
toolNaming: ToolNamingMode;
shell: ShellMode;
widgets: WidgetMode;
stateDir: string;
worktreeRoot: string;
Expand Down Expand Up @@ -140,6 +142,13 @@ function parseToolNaming(value: string | undefined): ToolNamingMode {
throw new Error(`Invalid DEVSPACE_TOOL_NAMING: ${value}`);
}

function parseShellMode(value: string | undefined): ShellMode {
if (!value || value === "auto") return "auto";
if (value === "bash" || value === "powershell" || value === "cmd") return value;

throw new Error(`Invalid DEVSPACE_SHELL: ${value}`);
}

function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig {
return {
level: parseLogLevel(env.DEVSPACE_LOG_LEVEL),
Expand Down Expand Up @@ -229,6 +238,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
publicBaseUrl,
minimalTools: parseMinimalTools(env),
toolNaming: parseToolNaming(env.DEVSPACE_TOOL_NAMING),
shell: parseShellMode(env.DEVSPACE_SHELL),
widgets: parseWidgetMode(env.DEVSPACE_WIDGETS),
stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())),
worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())),
Expand Down
120 changes: 120 additions & 0 deletions src/pi-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runShellTool } from "./pi-tools.js";

const root = mkdtempSync(join(tmpdir(), "devspace-pi-tools-test-"));
writeFileSync(join(root, "marker.txt"), "marker\n");

if (process.platform === "win32") {
const simple = await runShellTool(
{ command: "Write-Output 'native-powershell-ok'" },
{ cwd: root, root, shell: "powershell" },
);
assert.equal(simple.isError, undefined);
assert.match(simple.content[0]?.type === "text" ? simple.content[0].text : "", /native-powershell-ok/);

const blockedMatch = await runShellTool(
{ command: "$_.Path -match 'C:\\Users\\Yuri\\Documents'" },
{ cwd: root, root, shell: "powershell" },
);
const blockedMatchText = blockedMatch.content[0]?.type === "text" ? blockedMatch.content[0].text : "";
assert.equal(blockedMatch.isError, true);
assert.match(blockedMatchText, /Blocked fragile PowerShell command/);
assert.match(blockedMatchText, /\.Contains/);
assert.match(blockedMatchText, /-like/);
assert.match(blockedMatchText, /\[regex\]::Escape/);

const blockedVariableMatch = await runShellTool(
{
command: "$path = 'pydoll-mcp-server\\profiles\\chatgpt-linkedin-check'; $_.CommandLine -match $path",
},
{ cwd: root, root, shell: "powershell" },
);
const blockedVariableMatchText = blockedVariableMatch.content[0]?.type === "text"
? blockedVariableMatch.content[0].text
: "";
assert.equal(blockedVariableMatch.isError, true);
assert.match(blockedVariableMatchText, /pydoll-mcp-server\\profiles\\chatgpt-linkedin-check/);
assert.match(blockedVariableMatchText, /\[regex\]::Escape/);

const regexDigitMatch = await runShellTool(
{ command: "'chrome.exe --remote-debugging-port=9224' -match 'remote-debugging-port=\\d+'" },
{ cwd: root, root, shell: "powershell" },
);
assert.equal(regexDigitMatch.isError, undefined);
assert.match(regexDigitMatch.content[0]?.type === "text" ? regexDigitMatch.content[0].text : "", /True/);

const regexDigitVariableMatch = await runShellTool(
{
command: "$pattern = 'remote-debugging-port=\\d+'; 'chrome.exe --remote-debugging-port=9224' -match $pattern",
},
{ cwd: root, root, shell: "powershell" },
);
assert.equal(regexDigitVariableMatch.isError, undefined);
assert.match(
regexDigitVariableMatch.content[0]?.type === "text" ? regexDigitVariableMatch.content[0].text : "",
/True/,
);

const contains = await runShellTool(
{ command: "'C:\\Users\\Yuri\\Documents'.Contains('C:\\Users\\Yuri')" },
{ cwd: root, root, shell: "powershell" },
);
assert.equal(contains.isError, undefined);
assert.match(contains.content[0]?.type === "text" ? contains.content[0].text : "", /True/);

const escapedMatch = await runShellTool(
{
command: "$pattern = [regex]::Escape('C:\\Users\\Yuri'); 'C:\\Users\\Yuri\\Documents' -match $pattern",
},
{ cwd: root, root, shell: "powershell" },
);
assert.equal(escapedMatch.isError, undefined);
assert.match(escapedMatch.content[0]?.type === "text" ? escapedMatch.content[0].text : "", /True/);

const pipeline = await runShellTool(
{
command: "@('alpha','beta') | Where-Object { $_ -like 'b*' } | ForEach-Object { \"item=$_\" }",
},
{ cwd: root, root, shell: "powershell" },
);
assert.equal(pipeline.isError, undefined);
assert.match(pipeline.content[0]?.type === "text" ? pipeline.content[0].text : "", /item=beta/);

const cwd = await runShellTool(
{ command: "(Get-Location).Path" },
{ cwd: root, root, shell: "powershell" },
);
assert.equal(cwd.isError, undefined);
assert.equal(cwd.content[0]?.type === "text" ? cwd.content[0].text : "", root);

const failed = await runShellTool(
{ command: "Write-Error 'expected failure'; exit 9" },
{ cwd: root, root, shell: "powershell" },
);
assert.equal(failed.isError, true);
assert.match(failed.content[0]?.type === "text" ? failed.content[0].text : "", /Command exited with code 9/);

const timedOut = await runShellTool(
{ command: "Start-Sleep -Seconds 5", timeout: 1 },
{ cwd: root, root, shell: "powershell" },
);
assert.equal(timedOut.isError, true);
assert.match(timedOut.content[0]?.type === "text" ? timedOut.content[0].text : "", /timed out after 1 seconds/);

const cmd = await runShellTool(
{ command: "echo native-cmd-ok" },
{ cwd: root, root, shell: "cmd" },
);
assert.equal(cmd.isError, undefined);
assert.match(cmd.content[0]?.type === "text" ? cmd.content[0].text : "", /native-cmd-ok/);
} else {
const bash = await runShellTool(
{ command: "printf 'native-bash-ok\\n'" },
{ cwd: root, root, shell: "bash" },
);
assert.equal(bash.isError, undefined);
assert.match(bash.content[0]?.type === "text" ? bash.content[0].text : "", /native-bash-ok/);
}
Loading