Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/fix-ps-execution-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bradygaster/squad-cli': patch
---

Add doctor check for unsigned .ps1 shim on Windows that blocks PowerShell execution. Documents workaround in README.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ squad init

**✓ Validate:** Check that `.squad/team.md` was created in your project.

#### Windows (PowerShell)

If you see `PSSecurityException: UnauthorizedAccess` after installing globally,
PowerShell is blocking the unsigned `.ps1` shim that npm creates. Fix it by
removing the shim — PowerShell will fall back to the `.cmd` version which works fine:

```powershell
Remove-Item "$env:APPDATA\npm\squad.ps1"
```

Or run `squad doctor` to detect this automatically. You can also use `cmd.exe` or invoke via `npx @bradygaster/squad-cli`.

### 3. Authenticate with GitHub (for Issues, PRs, and Ralph)

```bash
Expand Down
52 changes: 52 additions & 0 deletions packages/squad-cli/scripts/remove-ps1-shim.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* remove-ps1-shim.mjs — Remove the unsigned squad.ps1 shim on Windows.
*
* npm global install creates both squad.cmd and squad.ps1 in the npm prefix.
* PowerShell prefers .ps1 over .cmd, but blocks unsigned scripts with the
* default execution policy (Restricted / AllSigned). Removing the .ps1 shim
* lets PowerShell fall back to squad.cmd, which works fine.
*
* Usage:
* node node_modules/@bradygaster/squad-cli/scripts/remove-ps1-shim.mjs
*
* Or just run in PowerShell:
* Remove-Item "$env:APPDATA\npm\squad.ps1"
*
* See: https://github.com/bradygaster/squad/issues/758
*/

import { existsSync, unlinkSync } from 'node:fs';
import { join, isAbsolute } from 'node:path';

if (process.platform !== 'win32') {
console.log('ℹ️ Not on Windows — nothing to do.');
process.exit(0);
}

const configuredPrefix = process.env['npm_config_prefix'];
const appData = process.env['APPDATA'];
const npmPrefix = configuredPrefix || (appData ? join(appData, 'npm') : undefined);

if (!npmPrefix || !isAbsolute(npmPrefix)) {
console.error('❌ Unable to determine absolute npm prefix.');
console.error(' npm_config_prefix and APPDATA are not set.');
process.exit(1);
}

const ps1Path = join(npmPrefix, 'squad.ps1');

if (!existsSync(ps1Path)) {
console.log(`✅ ${ps1Path} does not exist — no action needed.`);
process.exit(0);
}

try {
unlinkSync(ps1Path);
console.log(`✅ Removed ${ps1Path}`);
console.log(' PowerShell will now use squad.cmd instead.');
} catch (err) {
console.error(`❌ Could not remove ${ps1Path}: ${err.message}`);
console.error(' Try running as Administrator, or manually delete the file.');
process.exit(1);
}
33 changes: 32 additions & 1 deletion packages/squad-cli/src/cli/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,33 @@ function formatAge(seconds: number): string {
return `${seconds}s`;
}

// ── Windows .ps1 shim check (#758) ──────────────────────────────────

/**
* Detect the unsigned .ps1 shim that npm creates on global install.
* PowerShell's default execution policy blocks unsigned scripts, so
* the .ps1 shim prevents `squad` from running in PowerShell on Windows.
* The .cmd shim works fine — users just need to remove the .ps1 file.
*/
export function checkWindowsPs1Shim(): DoctorCheck | undefined {
if (process.platform !== 'win32') return undefined;

const configuredPrefix = process.env['npm_config_prefix'];
const appData = process.env['APPDATA'];
const npmPrefix = configuredPrefix || (appData ? path.join(appData, 'npm') : undefined);
if (!npmPrefix || !path.isAbsolute(npmPrefix)) return undefined;
const ps1Path = path.join(npmPrefix, 'squad.ps1');

if (!fileExists(ps1Path)) return undefined;

return {
name: 'Windows PowerShell shim',
status: 'warn',
message: 'squad.ps1 exists and may be blocked by execution policy. ' +
'Fix: Remove-Item "' + ps1Path + '" — PowerShell will fall back to squad.cmd which works fine.',
};
}

// ── ESM compatibility checks ────────────────────────────────────────

// ── environment checks ─────────────────────────────────────────────
Expand Down Expand Up @@ -455,10 +482,14 @@ export async function runDoctor(cwd?: string): Promise<DoctorCheck[]> {
// 11. Node.js version (node:sqlite availability)
checks.push(checkNodeVersion());

// 11-12. ESM compatibility (Node 22/24+)
// 12-13. ESM compatibility (Node 22/24+)
checks.push(checkVscodeJsonrpcExports(resolvedCwd));
checks.push(checkCopilotSdkSessionPatch(resolvedCwd));

// 14. Windows .ps1 shim warning (#758)
const ps1Check = checkWindowsPs1Shim();
if (ps1Check) checks.push(ps1Check);

return checks;
}

Expand Down
101 changes: 98 additions & 3 deletions test/cli/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { mkdir, rm, writeFile } from 'fs/promises';
import { join } from 'path';
import { existsSync } from 'fs';
import { randomBytes } from 'crypto';
import { runDoctor, getDoctorMode, checkNodeVersion } from '@bradygaster/squad-cli/commands/doctor';
import { runDoctor, getDoctorMode, checkNodeVersion, checkWindowsPs1Shim } from '@bradygaster/squad-cli/commands/doctor';
import type { DoctorCheck } from '@bradygaster/squad-cli/commands/doctor';

const TEST_ROOT = join(process.cwd(), `.test-doctor-${randomBytes(4).toString('hex')}`);
Expand Down Expand Up @@ -68,8 +68,12 @@ describe('squad doctor', () => {

const squadDirCheck = checks.find((c: DoctorCheck) => c.name === '.squad/ directory exists');
expect(squadDirCheck?.status).toBe('fail');
// When .squad/ is missing the file checks are skipped — .squad/ + squad.agent.md + Node version + 2 ESM checks
expect(checks.length).toBe(5);
// When .squad/ is missing the file checks are skipped — .squad/ + squad.agent.md + Node version + 2 ESM checks = 5
// On Windows, the .ps1 shim check may add one more if squad.ps1 exists
const baseCount = 5;
const ps1Check = checks.find((c: DoctorCheck) => c.name === 'Windows PowerShell shim');
const expectedCount = ps1Check ? baseCount + 1 : baseCount;
expect(checks.length).toBe(expectedCount);
});

it('detects remote mode from config.json with teamRoot', async () => {
Expand Down Expand Up @@ -289,4 +293,95 @@ describe('squad doctor', () => {
expect(agentMdCheck?.status).toBe('fail');
expect(agentMdCheck?.message).toContain('squad upgrade');
});

// ── #758 — Windows .ps1 shim detection ────────────────────────────

it('checkWindowsPs1Shim returns undefined on non-Windows', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
try {
const result = checkWindowsPs1Shim();
expect(result).toBeUndefined();
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
}
});

it('checkWindowsPs1Shim returns warn when .ps1 file exists on Windows', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
const originalEnv = { ...process.env };

// Create a fake npm prefix directory with a squad.ps1 file
const fakeNpmPrefix = join(TEST_ROOT, 'fake-npm');
await mkdir(fakeNpmPrefix, { recursive: true });
await writeFile(join(fakeNpmPrefix, 'squad.ps1'), '# fake shim');

Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
process.env['npm_config_prefix'] = fakeNpmPrefix;

try {
const result = checkWindowsPs1Shim();
expect(result).toBeDefined();
expect(result?.status).toBe('warn');
expect(result?.name).toBe('Windows PowerShell shim');
expect(result?.message).toContain('squad.ps1');
expect(result?.message).toContain('Remove-Item');
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
process.env = originalEnv;
}
});

it('checkWindowsPs1Shim returns undefined when no .ps1 file found on Windows', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
const originalEnv = { ...process.env };

// Create a fake npm prefix directory WITHOUT squad.ps1
const fakeNpmPrefix = join(TEST_ROOT, 'fake-npm-empty');
await mkdir(fakeNpmPrefix, { recursive: true });

Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
process.env['npm_config_prefix'] = fakeNpmPrefix;

try {
const result = checkWindowsPs1Shim();
expect(result).toBeUndefined();
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
process.env = originalEnv;
}
});

it('checkWindowsPs1Shim falls back to APPDATA when npm_config_prefix is unset', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
const originalEnv = { ...process.env };

// Create fake APPDATA/npm with squad.ps1
const fakeAppData = join(TEST_ROOT, 'fake-appdata');
const fakeNpmDir = join(fakeAppData, 'npm');
await mkdir(fakeNpmDir, { recursive: true });
await writeFile(join(fakeNpmDir, 'squad.ps1'), '# fake shim');

Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
delete process.env['npm_config_prefix'];
process.env['APPDATA'] = fakeAppData;

try {
const result = checkWindowsPs1Shim();
expect(result).toBeDefined();
expect(result?.status).toBe('warn');
expect(result?.message).toContain(fakeNpmDir);
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
process.env = originalEnv;
}
});
});
Loading