diff --git a/.changeset/fix-ps-execution-policy.md b/.changeset/fix-ps-execution-policy.md new file mode 100644 index 000000000..6bd7304e8 --- /dev/null +++ b/.changeset/fix-ps-execution-policy.md @@ -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. diff --git a/README.md b/README.md index c19d85c18..ca3bd5c5a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packages/squad-cli/scripts/remove-ps1-shim.mjs b/packages/squad-cli/scripts/remove-ps1-shim.mjs new file mode 100644 index 000000000..4024deab6 --- /dev/null +++ b/packages/squad-cli/scripts/remove-ps1-shim.mjs @@ -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); +} diff --git a/packages/squad-cli/src/cli/commands/doctor.ts b/packages/squad-cli/src/cli/commands/doctor.ts index 1f4a1673b..994793035 100644 --- a/packages/squad-cli/src/cli/commands/doctor.ts +++ b/packages/squad-cli/src/cli/commands/doctor.ts @@ -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 ───────────────────────────────────────────── @@ -455,10 +482,14 @@ export async function runDoctor(cwd?: string): Promise { // 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; } diff --git a/test/cli/doctor.test.ts b/test/cli/doctor.test.ts index 68cebb4c0..12673f554 100644 --- a/test/cli/doctor.test.ts +++ b/test/cli/doctor.test.ts @@ -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')}`); @@ -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 () => { @@ -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; + } + }); });