diff --git a/scripts/postinstall.js b/scripts/postinstall.js index bfe6e12387..5e027e94b8 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -1,9 +1,13 @@ #!/usr/bin/env node /** - * Postinstall script for auto-installing shell completions + * Postinstall script that hints about shell completions * - * This script runs automatically after npm install unless: + * Completion installation is opt-in: the user must run + * `openspec completion install` explicitly. This script only + * prints a one-line tip after npm install. + * + * The tip is suppressed when: * - CI=true environment variable is set * - OPENSPEC_NO_COMPLETIONS=1 environment variable is set * - dist/ directory doesn't exist (dev setup scenario) @@ -48,65 +52,6 @@ async function distExists() { } } -/** - * Detect the user's shell - */ -async function detectShell() { - try { - const { detectShell } = await import('../dist/utils/shell-detection.js'); - const result = detectShell(); - return result.shell; - } catch (error) { - // Fail silently if detection module doesn't exist - return undefined; - } -} - -/** - * Install completions for the detected shell - */ -async function installCompletions(shell) { - try { - const { CompletionFactory } = await import('../dist/core/completions/factory.js'); - const { COMMAND_REGISTRY } = await import('../dist/core/completions/command-registry.js'); - - // Check if shell is supported - if (!CompletionFactory.isSupported(shell)) { - console.log(`\nTip: Run 'openspec completion install' for shell completions`); - return; - } - - // Generate completion script - const generator = CompletionFactory.createGenerator(shell); - const script = generator.generate(COMMAND_REGISTRY); - - // Install completion script - const installer = CompletionFactory.createInstaller(shell); - const result = await installer.install(script); - - if (result.success) { - // Show success message based on installation type - if (result.isOhMyZsh) { - console.log(`✓ Shell completions installed`); - console.log(` Restart shell: exec zsh`); - } else if (result.zshrcConfigured) { - console.log(`✓ Shell completions installed and configured`); - console.log(` Restart shell: exec zsh`); - } else { - console.log(`✓ Shell completions installed to ~/.zsh/completions/`); - console.log(` Add to ~/.zshrc: fpath=(~/.zsh/completions $fpath)`); - console.log(` Then: exec zsh`); - } - } else { - // Installation failed, show tip for manual install - console.log(`\nTip: Run 'openspec completion install' for shell completions`); - } - } catch (error) { - // Fail gracefully - show tip for manual install - console.log(`\nTip: Run 'openspec completion install' for shell completions`); - } -} - /** * Main function */ @@ -124,19 +69,10 @@ async function main() { return; } - // Detect shell - const shell = await detectShell(); - if (!shell) { - console.log(`\nTip: Run 'openspec completion install' for shell completions`); - return; - } - - // Install completions - await installCompletions(shell); + // Completions are opt-in — just print a hint + console.log(`\nTip: Run 'openspec completion install' for shell completions`); } catch (error) { // Fail gracefully - never break npm install - // Show tip for manual install - console.log(`\nTip: Run 'openspec completion install' for shell completions`); } } diff --git a/scripts/test-postinstall.sh b/scripts/test-postinstall.sh index 97b0ab5bf6..a6b637c836 100755 --- a/scripts/test-postinstall.sh +++ b/scripts/test-postinstall.sh @@ -15,7 +15,7 @@ ORIGINAL_CI="${CI:-}" ORIGINAL_OPENSPEC_NO_COMPLETIONS="${OPENSPEC_NO_COMPLETIONS:-}" # Test 1: Normal install -echo "Test 1: Normal install (should attempt to install completions)" +echo "Test 1: Normal install (should print tip about completions)" echo "--------------------------------------" unset CI unset OPENSPEC_NO_COMPLETIONS diff --git a/src/core/completions/installers/powershell-installer.ts b/src/core/completions/installers/powershell-installer.ts index d3504aaeed..21384fd919 100644 --- a/src/core/completions/installers/powershell-installer.ts +++ b/src/core/completions/installers/powershell-installer.ts @@ -23,6 +23,49 @@ export class PowerShellInstaller { this.homeDir = homeDir; } + /** + * Detect the encoding of a file by inspecting its BOM (Byte Order Mark). + * Returns the Node.js BufferEncoding and the raw BOM bytes to preserve on write. + */ + private detectEncoding(buffer: Buffer): { encoding: BufferEncoding; bom: Buffer } { + // UTF-16 LE BOM: FF FE + if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) { + return { encoding: 'utf16le', bom: Buffer.from([0xff, 0xfe]) }; + } + // UTF-16 BE BOM: FE FF — not natively supported by Node + if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) { + throw new Error( + 'File is encoded as UTF-16 BE which is not supported. ' + + 'Please re-save as UTF-8 or UTF-16 LE, then retry.', + ); + } + // UTF-8 BOM: EF BB BF + if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) { + return { encoding: 'utf-8', bom: Buffer.from([0xef, 0xbb, 0xbf]) }; + } + // No BOM → default UTF-8 + return { encoding: 'utf-8', bom: Buffer.alloc(0) }; + } + + /** + * Read a profile file, preserving its encoding metadata for round-trip writes. + * Throws if the file uses UTF-16 BE (unsupported by Node). + */ + private async readProfileFile(filePath: string): Promise<{ content: string; encoding: BufferEncoding; bom: Buffer }> { + const raw = await fs.readFile(filePath); + const { encoding, bom } = this.detectEncoding(raw); + const content = raw.subarray(bom.length).toString(encoding); + return { content, encoding, bom }; + } + + /** + * Write a profile file, preserving the original BOM and encoding. + */ + private async writeProfileFile(filePath: string, content: string, encoding: BufferEncoding, bom: Buffer): Promise { + const body = Buffer.from(content, encoding); + await fs.writeFile(filePath, Buffer.concat([bom, body])); + } + /** * Get PowerShell profile path * Prefers $PROFILE environment variable, falls back to platform defaults @@ -132,10 +175,22 @@ export class PowerShellInstaller { await fs.mkdir(profileDir, { recursive: true }); let profileContent = ''; + let fileEncoding: BufferEncoding = 'utf-8'; + let fileBom: Buffer = Buffer.alloc(0); try { - profileContent = await fs.readFile(profilePath, 'utf-8'); - } catch { - // Profile doesn't exist yet, that's fine + const file = await this.readProfileFile(profilePath); + profileContent = file.content; + fileEncoding = file.encoding; + fileBom = file.bom; + } catch (err: any) { + // If the file doesn't exist that's fine — we'll create it as UTF-8. + // Any other read error (permissions, unsupported encoding, etc.) → skip this profile. + if (err?.code === 'ENOENT') { + // keep defaults + } else { + console.warn(`Warning: Skipping ${profilePath}: ${err?.message ?? String(err)}`); + continue; + } } // Check if already configured @@ -154,7 +209,7 @@ export class PowerShellInstaller { ].join('\n'); const newContent = profileContent + openspecBlock; - await fs.writeFile(profilePath, newContent, 'utf-8'); + await this.writeProfileFile(profilePath, newContent, fileEncoding, fileBom); anyConfigured = true; } catch (error) { // Continue to next profile if this one fails @@ -177,12 +232,21 @@ export class PowerShellInstaller { for (const profilePath of profilePaths) { try { - // Read profile content + // Read profile content with encoding detection let profileContent: string; + let fileEncoding: BufferEncoding = 'utf-8'; + let fileBom: Buffer = Buffer.alloc(0); try { - profileContent = await fs.readFile(profilePath, 'utf-8'); - } catch { - continue; // Profile doesn't exist, nothing to remove + const file = await this.readProfileFile(profilePath); + profileContent = file.content; + fileEncoding = file.encoding; + fileBom = file.bom; + } catch (err: any) { + if (err?.code === 'ENOENT') { + continue; // Profile doesn't exist, nothing to remove + } + console.warn(`Warning: Could not read ${profilePath}: ${err?.message ?? String(err)}`); + continue; } // Remove OPENSPEC:START -> OPENSPEC:END block @@ -207,7 +271,7 @@ export class PowerShellInstaller { // Clean up extra newlines const newContent = (beforeBlock.trimEnd() + '\n' + afterBlock.trimStart()).trim() + '\n'; - await fs.writeFile(profilePath, newContent, 'utf-8'); + await this.writeProfileFile(profilePath, newContent, fileEncoding, fileBom); anyRemoved = true; } catch (error) { console.warn(`Warning: Could not clean ${profilePath}: ${error}`); diff --git a/test/core/completions/installers/powershell-installer.test.ts b/test/core/completions/installers/powershell-installer.test.ts index 27960179fe..a01fee860a 100644 --- a/test/core/completions/installers/powershell-installer.test.ts +++ b/test/core/completions/installers/powershell-installer.test.ts @@ -544,6 +544,172 @@ Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter }); }); + describe('encoding preservation', () => { + const mockScriptPath = '/path/to/OpenSpecCompletion.ps1'; + const utf16leBom = Buffer.from([0xff, 0xfe]); + const utf8Bom = Buffer.from([0xef, 0xbb, 0xbf]); + + /** + * Helper: write a file in UTF-16 LE with BOM, the way Windows PowerShell does. + */ + function writeUtf16LeFile(filePath: string, text: string): Promise { + const body = Buffer.from(text, 'utf16le'); + return fs.writeFile(filePath, Buffer.concat([utf16leBom, body])); + } + + /** + * Helper: write a file in UTF-8 with BOM. + */ + function writeUtf8BomFile(filePath: string, text: string): Promise { + const body = Buffer.from(text, 'utf-8'); + return fs.writeFile(filePath, Buffer.concat([utf8Bom, body])); + } + + it('should preserve UTF-16 LE BOM when configuring profile', async () => { + delete process.env.OPENSPEC_NO_AUTO_CONFIG; + const profilePath = installer.getProfilePath(); + await fs.mkdir(path.dirname(profilePath), { recursive: true }); + + const originalText = '. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"\r\n'; + await writeUtf16LeFile(profilePath, originalText); + + const result = await installer.configureProfile(mockScriptPath); + expect(result).toBe(true); + + // Read back raw bytes and verify BOM is preserved + const raw = await fs.readFile(profilePath); + expect(raw[0]).toBe(0xff); + expect(raw[1]).toBe(0xfe); + + // Decode and verify content is intact + const content = raw.subarray(2).toString('utf16le'); + expect(content).toContain('. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"'); + expect(content).toContain('# OPENSPEC:START'); + expect(content).toContain(`. "${mockScriptPath}"`); + expect(content).toContain('# OPENSPEC:END'); + }); + + it('should preserve UTF-16 LE BOM when removing profile config', async () => { + delete process.env.OPENSPEC_NO_AUTO_CONFIG; + const profilePath = installer.getProfilePath(); + await fs.mkdir(path.dirname(profilePath), { recursive: true }); + + const textWithBlock = [ + '. "C:\\Code\\profile.ps1"', + '# OPENSPEC:START', + '. "/path/to/OpenSpecCompletion.ps1"', + '# OPENSPEC:END', + '', + ].join('\n'); + await writeUtf16LeFile(profilePath, textWithBlock); + + const result = await installer.removeProfileConfig(); + expect(result).toBe(true); + + // Verify BOM is preserved + const raw = await fs.readFile(profilePath); + expect(raw[0]).toBe(0xff); + expect(raw[1]).toBe(0xfe); + + // Verify content: original line kept, OpenSpec block removed + const content = raw.subarray(2).toString('utf16le'); + expect(content).toContain('. "C:\\Code\\profile.ps1"'); + expect(content).not.toContain('# OPENSPEC:START'); + expect(content).not.toContain('# OPENSPEC:END'); + }); + + it('should preserve UTF-8 BOM when configuring profile', async () => { + delete process.env.OPENSPEC_NO_AUTO_CONFIG; + const profilePath = installer.getProfilePath(); + await fs.mkdir(path.dirname(profilePath), { recursive: true }); + + await writeUtf8BomFile(profilePath, '# My profile\n'); + + const result = await installer.configureProfile(mockScriptPath); + expect(result).toBe(true); + + const raw = await fs.readFile(profilePath); + expect(raw[0]).toBe(0xef); + expect(raw[1]).toBe(0xbb); + expect(raw[2]).toBe(0xbf); + + const content = raw.subarray(3).toString('utf-8'); + expect(content).toContain('# My profile'); + expect(content).toContain('# OPENSPEC:START'); + }); + + it('should skip UTF-16 BE profile and leave it unchanged', async () => { + delete process.env.OPENSPEC_NO_AUTO_CONFIG; + const profilePath = installer.getProfilePath(); + await fs.mkdir(path.dirname(profilePath), { recursive: true }); + + // Write a fake UTF-16 BE file (FE FF BOM + some bytes) + const utf16beBom = Buffer.from([0xfe, 0xff]); + const body = Buffer.from([0x00, 0x23]); // '#' in UTF-16 BE + const originalBytes = Buffer.concat([utf16beBom, body]); + await fs.writeFile(profilePath, originalBytes); + + const result = await installer.configureProfile(mockScriptPath); + expect(result).toBe(false); + + // File should be untouched + const raw = await fs.readFile(profilePath); + expect(Buffer.compare(raw, originalBytes)).toBe(0); + }); + + it('should handle plain UTF-8 files without BOM (no regression)', async () => { + delete process.env.OPENSPEC_NO_AUTO_CONFIG; + const profilePath = installer.getProfilePath(); + await fs.mkdir(path.dirname(profilePath), { recursive: true }); + + await fs.writeFile(profilePath, '# Plain UTF-8\n', 'utf-8'); + + const result = await installer.configureProfile(mockScriptPath); + expect(result).toBe(true); + + const raw = await fs.readFile(profilePath); + // Should NOT have any BOM + expect(raw[0]).not.toBe(0xff); + expect(raw[0]).not.toBe(0xfe); + expect(raw[0]).not.toBe(0xef); + + const content = raw.toString('utf-8'); + expect(content).toContain('# Plain UTF-8'); + expect(content).toContain('# OPENSPEC:START'); + }); + + it('should round-trip UTF-16 LE through install → uninstall without corruption', async () => { + delete process.env.OPENSPEC_NO_AUTO_CONFIG; + const profilePath = installer.getProfilePath(); + await fs.mkdir(path.dirname(profilePath), { recursive: true }); + + const originalText = '. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"\r\n'; + await writeUtf16LeFile(profilePath, originalText); + + // Install adds the OpenSpec block + const mockScript = '# completion script'; + await installer.install(mockScript); + + // Verify the profile was modified but encoding preserved + let raw = await fs.readFile(profilePath); + expect(raw[0]).toBe(0xff); + expect(raw[1]).toBe(0xfe); + let content = raw.subarray(2).toString('utf16le'); + expect(content).toContain('# OPENSPEC:START'); + expect(content).toContain(originalText.trimEnd()); + + // Uninstall removes the OpenSpec block + await installer.uninstall(); + + raw = await fs.readFile(profilePath); + expect(raw[0]).toBe(0xff); + expect(raw[1]).toBe(0xfe); + content = raw.subarray(2).toString('utf16le'); + expect(content).not.toContain('# OPENSPEC:START'); + expect(content).toContain('. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"'); + }); + }); + describe('uninstall', () => { const mockCompletionScript = `# PowerShell completion script $openspecCompleter = {}