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
80 changes: 8 additions & 72 deletions scripts/postinstall.js
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
*/
Expand All @@ -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`);
}
}

Expand Down
2 changes: 1 addition & 1 deletion scripts/test-postinstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 73 additions & 9 deletions src/core/completions/installers/powershell-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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
Expand Down Expand Up @@ -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;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Check if already configured
Expand All @@ -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
Expand All @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Remove OPENSPEC:START -> OPENSPEC:END block
Expand All @@ -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}`);
Expand Down
Loading
Loading