mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-03 22:13:19 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e5054f003 |
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@fission-ai/openspec": patch
|
||||
---
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **CLI path visibility**: OpenSpec now documents editor and agent PATH mismatches, warns during global installs when the detected CLI bin directory is not on PATH, and generates workflow skills with guidance for resolving `openspec` through `OPENSPEC_BIN` or an absolute path.
|
||||
@@ -70,6 +70,44 @@ Or add to your development environment in `flake.nix`:
|
||||
openspec --version
|
||||
```
|
||||
|
||||
## Troubleshooting PATH Visibility
|
||||
|
||||
If `openspec --version` works in one terminal but fails in an editor, AI agent,
|
||||
GUI app, or automation, OpenSpec is usually installed correctly but that process
|
||||
started with a different `PATH`.
|
||||
|
||||
Global package managers create an executable shim in a bin directory, then your
|
||||
shell or launcher must put that directory on `PATH`. Common ways to inspect the
|
||||
directory are:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
printf '%s/bin\n' "$(npm prefix -g)"
|
||||
|
||||
# pnpm
|
||||
pnpm bin -g
|
||||
|
||||
# bun
|
||||
bun pm bin -g
|
||||
|
||||
# current shell
|
||||
command -v openspec
|
||||
```
|
||||
|
||||
Make sure the environment that launches your editor, agent, GUI app, or
|
||||
automation includes the package-manager bin directory. For shell startup files,
|
||||
keep this to a minimal `PATH` export in a file that the target environment
|
||||
actually reads. Do not move interactive setup such as prompts, themes,
|
||||
completions, or commands that can block into always-loaded startup files.
|
||||
|
||||
To bypass global bin discovery while debugging, run OpenSpec through a package
|
||||
manager:
|
||||
|
||||
```bash
|
||||
npx -y @fission-ai/openspec@latest --version
|
||||
pnpm dlx @fission-ai/openspec@latest --version
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
After installing, initialize OpenSpec in your project:
|
||||
|
||||
+116
-4
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Postinstall script that hints about shell completions
|
||||
* Postinstall script that hints about shell completions and CLI path visibility
|
||||
*
|
||||
* Completion installation is opt-in: the user must run
|
||||
* `openspec completion install` explicitly. This script only
|
||||
* prints a one-line tip after npm install.
|
||||
* prints lightweight tips after npm install.
|
||||
*
|
||||
* The tip is suppressed when:
|
||||
* The tips are suppressed when:
|
||||
* - CI=true environment variable is set
|
||||
* - OPENSPEC_NO_COMPLETIONS=1 environment variable is set
|
||||
* - dist/ directory doesn't exist (dev setup scenario)
|
||||
@@ -15,12 +15,121 @@
|
||||
* The script never fails npm install - all errors are caught and handled gracefully.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { constants as fsConstants, promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const EXECUTABLE_NAMES = process.platform === 'win32'
|
||||
? ['openspec.cmd', 'openspec.ps1', 'openspec']
|
||||
: ['openspec'];
|
||||
|
||||
function getEnv(name) {
|
||||
return process.env[name] || process.env[name.toUpperCase()];
|
||||
}
|
||||
|
||||
function isTruthy(value) {
|
||||
return value ? ['1', 'true', 'yes'].includes(value.toLowerCase()) : false;
|
||||
}
|
||||
|
||||
function isLikelyGlobalInstall() {
|
||||
return isTruthy(getEnv('npm_config_global')) || getEnv('npm_config_location') === 'global';
|
||||
}
|
||||
|
||||
function normalizeForComparison(value) {
|
||||
const resolved = path.resolve(value);
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
function pathEntries() {
|
||||
return (process.env.PATH || '')
|
||||
.split(path.delimiter)
|
||||
.filter(Boolean)
|
||||
.map(normalizeForComparison);
|
||||
}
|
||||
|
||||
function isOnPath(dir) {
|
||||
const normalizedDir = normalizeForComparison(dir);
|
||||
return pathEntries().includes(normalizedDir);
|
||||
}
|
||||
|
||||
function addCandidateDir(dirs, dir) {
|
||||
if (!dir) return;
|
||||
dirs.set(normalizeForComparison(dir), dir);
|
||||
}
|
||||
|
||||
function getPrefixBinDir(prefix) {
|
||||
return process.platform === 'win32' ? prefix : path.join(prefix, 'bin');
|
||||
}
|
||||
|
||||
function getCandidateCliBinDirs() {
|
||||
const dirs = new Map();
|
||||
|
||||
addCandidateDir(dirs, getEnv('npm_config_global_bin_dir'));
|
||||
addCandidateDir(dirs, getEnv('npm_config_bin'));
|
||||
addCandidateDir(dirs, getEnv('PNPM_HOME'));
|
||||
|
||||
const npmPrefix = getEnv('npm_config_prefix');
|
||||
if (npmPrefix) {
|
||||
addCandidateDir(dirs, getPrefixBinDir(npmPrefix));
|
||||
}
|
||||
|
||||
const bunInstall = getEnv('BUN_INSTALL');
|
||||
if (bunInstall) {
|
||||
addCandidateDir(dirs, path.join(bunInstall, 'bin'));
|
||||
}
|
||||
|
||||
return [...dirs.values()];
|
||||
}
|
||||
|
||||
async function directoryHasOpenSpecBin(dir) {
|
||||
for (const executableName of EXECUTABLE_NAMES) {
|
||||
try {
|
||||
await fs.access(path.join(dir, executableName), fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
// Continue checking other executable names.
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getCliBinDirsMissingFromPath() {
|
||||
if (!isLikelyGlobalInstall()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const missingDirs = [];
|
||||
for (const dir of getCandidateCliBinDirs()) {
|
||||
if (isOnPath(dir)) continue;
|
||||
if (await directoryHasOpenSpecBin(dir)) {
|
||||
missingDirs.push(dir);
|
||||
}
|
||||
}
|
||||
|
||||
return missingDirs;
|
||||
}
|
||||
|
||||
function printPathVisibilityHint(missingDirs) {
|
||||
if (missingDirs.length === 0) return;
|
||||
|
||||
console.log('');
|
||||
console.log(
|
||||
'OpenSpec was installed, but this shell may not find the CLI because these bin directories are not on PATH:'
|
||||
);
|
||||
for (const dir of missingDirs) {
|
||||
console.log(` ${dir}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log(
|
||||
'If `openspec --version` fails in an editor, agent, GUI app, or automation, add the relevant package-manager bin directory to the PATH used by that environment.'
|
||||
);
|
||||
console.log(
|
||||
'See: https://github.com/Fission-AI/OpenSpec/blob/main/docs/installation.md#troubleshooting-path-visibility'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should skip installation
|
||||
@@ -71,6 +180,9 @@ async function main() {
|
||||
|
||||
// Completions are opt-in — just print a hint
|
||||
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
|
||||
|
||||
const missingDirs = await getCliBinDirsMissingFromPath();
|
||||
printPathVisibilityHint(missingDirs);
|
||||
} catch (error) {
|
||||
// Fail gracefully - never break npm install
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test script for postinstall.js
|
||||
# Tests different scenarios: normal install, CI, opt-out
|
||||
# Tests different scenarios: normal install, CI, opt-out, PATH hints
|
||||
|
||||
set -e
|
||||
|
||||
@@ -13,6 +13,10 @@ echo ""
|
||||
# Save original environment
|
||||
ORIGINAL_CI="${CI:-}"
|
||||
ORIGINAL_OPENSPEC_NO_COMPLETIONS="${OPENSPEC_NO_COMPLETIONS:-}"
|
||||
ORIGINAL_NPM_CONFIG_GLOBAL="${npm_config_global:-}"
|
||||
ORIGINAL_NPM_CONFIG_PREFIX="${npm_config_prefix:-}"
|
||||
ORIGINAL_PATH="$PATH"
|
||||
NODE_BIN="$(command -v node)"
|
||||
|
||||
# Test 1: Normal install
|
||||
echo "Test 1: Normal install (should print tip about completions)"
|
||||
@@ -22,16 +26,36 @@ unset OPENSPEC_NO_COMPLETIONS
|
||||
node scripts/postinstall.js
|
||||
echo ""
|
||||
|
||||
# Test 2: CI environment (should skip silently)
|
||||
echo "Test 2: CI=true (should skip silently)"
|
||||
# Test 2: Global install with CLI bin missing from PATH (should print PATH hint)
|
||||
echo "Test 2: Global install with CLI bin missing from PATH (should print PATH hint)"
|
||||
echo "--------------------------------------"
|
||||
TMP_PREFIX="$(mktemp -d)"
|
||||
TMP_HOME="$(mktemp -d)"
|
||||
mkdir -p "$TMP_PREFIX/bin"
|
||||
printf '#!/bin/sh\nexit 0\n' > "$TMP_PREFIX/bin/openspec"
|
||||
chmod +x "$TMP_PREFIX/bin/openspec"
|
||||
unset CI
|
||||
unset OPENSPEC_NO_COMPLETIONS
|
||||
export npm_config_global=true
|
||||
export npm_config_prefix="$TMP_PREFIX"
|
||||
HOME="$TMP_HOME" PNPM_HOME="$TMP_HOME/no-pnpm" PATH="/usr/bin:/bin" "$NODE_BIN" scripts/postinstall.js
|
||||
rm -rf "$TMP_PREFIX"
|
||||
rm -rf "$TMP_HOME"
|
||||
unset npm_config_global
|
||||
unset npm_config_prefix
|
||||
export PATH="$ORIGINAL_PATH"
|
||||
echo ""
|
||||
|
||||
# Test 3: CI environment (should skip silently)
|
||||
echo "Test 3: CI=true (should skip silently)"
|
||||
echo "--------------------------------------"
|
||||
export CI=true
|
||||
node scripts/postinstall.js
|
||||
echo "[No output expected - skipped due to CI]"
|
||||
echo ""
|
||||
|
||||
# Test 3: Opt-out flag (should skip silently)
|
||||
echo "Test 3: OPENSPEC_NO_COMPLETIONS=1 (should skip silently)"
|
||||
# Test 4: Opt-out flag (should skip silently)
|
||||
echo "Test 4: OPENSPEC_NO_COMPLETIONS=1 (should skip silently)"
|
||||
echo "--------------------------------------"
|
||||
unset CI
|
||||
export OPENSPEC_NO_COMPLETIONS=1
|
||||
@@ -52,6 +76,20 @@ else
|
||||
unset OPENSPEC_NO_COMPLETIONS
|
||||
fi
|
||||
|
||||
if [ -n "$ORIGINAL_NPM_CONFIG_GLOBAL" ]; then
|
||||
export npm_config_global="$ORIGINAL_NPM_CONFIG_GLOBAL"
|
||||
else
|
||||
unset npm_config_global
|
||||
fi
|
||||
|
||||
if [ -n "$ORIGINAL_NPM_CONFIG_PREFIX" ]; then
|
||||
export npm_config_prefix="$ORIGINAL_NPM_CONFIG_PREFIX"
|
||||
else
|
||||
unset npm_config_prefix
|
||||
fi
|
||||
|
||||
export PATH="$ORIGINAL_PATH"
|
||||
|
||||
echo "======================================"
|
||||
echo "All tests completed successfully!"
|
||||
echo "======================================"
|
||||
|
||||
@@ -31,6 +31,13 @@ import {
|
||||
} from '../templates/skill-templates.js';
|
||||
import type { CommandContent } from '../command-generation/index.js';
|
||||
|
||||
const DEFAULT_COMPATIBILITY = 'Requires openspec CLI.';
|
||||
const OPENSPEC_CLI_VISIBILITY_GUIDANCE = `## OpenSpec CLI Visibility
|
||||
|
||||
The workflow below uses the \`openspec\` command. If this agent shell cannot find it, do not assume OpenSpec is absent; editor, agent, GUI, and automation shells may inherit a different \`PATH\` than the user's terminal.
|
||||
|
||||
Use \`OPENSPEC_BIN\` when it is set, otherwise start with \`openspec\`. If that command is not found, resolve the package-manager global bin directory or ask the user for the absolute executable path, then use that path for every \`openspec\` invocation in this workflow.`;
|
||||
|
||||
/**
|
||||
* Skill template with directory name and workflow ID mapping.
|
||||
*/
|
||||
@@ -129,21 +136,25 @@ export function generateSkillContent(
|
||||
generatedByVersion: string,
|
||||
transformInstructions?: (instructions: string) => string
|
||||
): string {
|
||||
const compatibility = template.compatibility || DEFAULT_COMPATIBILITY;
|
||||
const instructions = transformInstructions
|
||||
? transformInstructions(template.instructions)
|
||||
: template.instructions;
|
||||
const body = compatibility.toLowerCase().includes('openspec cli')
|
||||
? `${OPENSPEC_CLI_VISIBILITY_GUIDANCE}\n\n${instructions}`
|
||||
: instructions;
|
||||
|
||||
return `---
|
||||
name: ${template.name}
|
||||
description: ${template.description}
|
||||
license: ${template.license || 'MIT'}
|
||||
compatibility: ${template.compatibility || 'Requires openspec CLI.'}
|
||||
compatibility: ${compatibility}
|
||||
metadata:
|
||||
author: ${template.metadata?.author || 'openspec'}
|
||||
version: "${template.metadata?.version || '1.0'}"
|
||||
generatedBy: "${generatedByVersion}"
|
||||
---
|
||||
|
||||
${instructions}
|
||||
${body}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -24,19 +24,26 @@ function getOnboardInstructions(): string {
|
||||
|
||||
## Preflight
|
||||
|
||||
Before starting, check if the OpenSpec CLI is installed:
|
||||
Before starting, check whether the OpenSpec CLI is visible to this shell:
|
||||
|
||||
\`\`\`bash
|
||||
# Unix/macOS
|
||||
openspec --version 2>&1 || echo "CLI_NOT_INSTALLED"
|
||||
OPENSPEC_CMD="\${OPENSPEC_BIN:-openspec}"
|
||||
if command -v "$OPENSPEC_CMD" >/dev/null 2>&1 || [ -x "$OPENSPEC_CMD" ]; then
|
||||
"$OPENSPEC_CMD" --version
|
||||
else
|
||||
echo "CLI_NOT_ON_PATH"
|
||||
fi
|
||||
|
||||
# Windows (PowerShell)
|
||||
# if (Get-Command openspec -ErrorAction SilentlyContinue) { openspec --version } else { echo "CLI_NOT_INSTALLED" }
|
||||
# $OpenSpecCmd = if ($env:OPENSPEC_BIN) { $env:OPENSPEC_BIN } else { "openspec" }
|
||||
# if (Get-Command $OpenSpecCmd -ErrorAction SilentlyContinue) { & $OpenSpecCmd --version } else { echo "CLI_NOT_ON_PATH" }
|
||||
\`\`\`
|
||||
|
||||
**If CLI not installed:**
|
||||
> OpenSpec CLI is not installed. Install it first, then come back to \`/opsx:onboard\`.
|
||||
**If CLI is not visible:**
|
||||
> OpenSpec CLI is not visible to this shell. If it is installed in another terminal, add its package-manager bin directory to the PATH used by this editor, agent, or automation, or set OPENSPEC_BIN to the absolute executable path.
|
||||
|
||||
Stop here if not installed.
|
||||
Stop here if no usable CLI command can be found.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -225,6 +225,23 @@ describe('skill-generation', () => {
|
||||
expect(content).toContain('author: openspec');
|
||||
expect(content).toContain('version: "1.0"');
|
||||
expect(content).toContain('generatedBy: "0.24.0"');
|
||||
expect(content).toContain('## OpenSpec CLI Visibility');
|
||||
expect(content).toContain('OPENSPEC_BIN');
|
||||
});
|
||||
|
||||
it('should prepend CLI visibility guidance for OpenSpec CLI skills', () => {
|
||||
const template = {
|
||||
name: 'cli-skill',
|
||||
description: 'Uses the CLI',
|
||||
instructions: 'Run openspec list.',
|
||||
compatibility: 'Requires openspec CLI.',
|
||||
};
|
||||
|
||||
const content = generateSkillContent(template, '0.24.0');
|
||||
|
||||
expect(content).toContain('## OpenSpec CLI Visibility');
|
||||
expect(content).toContain('agent, GUI, and automation shells may inherit a different `PATH`');
|
||||
expect(content).toContain('Run openspec list.');
|
||||
});
|
||||
|
||||
it('should embed the provided version in generatedBy field', () => {
|
||||
@@ -249,6 +266,7 @@ describe('skill-generation', () => {
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
instructions: 'Body content',
|
||||
compatibility: 'Standalone test fixture',
|
||||
};
|
||||
|
||||
const content = generateSkillContent(template, '0.23.0');
|
||||
|
||||
@@ -36,7 +36,7 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
|
||||
getApplyChangeSkillTemplate: '6238712ba8cd2fd099c4f3bac13436f758fc6ac776fb8be19547f2b195240bfd',
|
||||
getFfChangeSkillTemplate: 'a7332fb14c8dc3f9dec71f5d332790b4a8488191e7db4ab6132ccbefecf9ded9',
|
||||
getSyncSpecsSkillTemplate: 'bded184e4c345619148de2c0ad80a5b527d4ffe45c87cc785889b9329e0f465b',
|
||||
getOnboardSkillTemplate: 'c9e719a02d2ae7f74a0e978f9ad4e767c1921248a9e3724c3321c58a15c38ba9',
|
||||
getOnboardSkillTemplate: 'b78d8479da5dc92432d81a6d29c56b6cbfed723a0750c7868a07966d58d47f76',
|
||||
getOpsxExploreCommandTemplate: 'b421b88c7a532385f7b1404736d7893eb35a05573b4a04a96f72379ac1bbf148',
|
||||
getOpsxNewCommandTemplate: '62eee32d6d81a376e7be845d0891e28e6262ad07482f9bfe6af12a9f0366c364',
|
||||
getOpsxContinueCommandTemplate: '8bbaedcc95287f9e822572608137df4f49ad54cedfb08d3342d0d1c4e9716caa',
|
||||
@@ -47,7 +47,7 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
|
||||
getOpsxSyncCommandTemplate: '378d035fe7cc30be3e027b66dcc4b8afc78ef1c8369c39479c9b05a582fb5ccf',
|
||||
getVerifyChangeSkillTemplate: '40dde29051a0ba204295b74e49e87b6e9ff30c8b89ff0e791b4f955b4595de59',
|
||||
getOpsxArchiveCommandTemplate: 'b44cc9748109f61687f9f596604b037bc3ea803abc143b22f09a76aebd98b493',
|
||||
getOpsxOnboardCommandTemplate: 'fce531f952e939ee85a41848fc21e4cc720b0f3eb62737adc3a51ee6ad2dfc57',
|
||||
getOpsxOnboardCommandTemplate: '34c3081988e4a890adad5efb7c6474a3ddca96d9ed2d28a9daac7b4f182a5735',
|
||||
getOpsxBulkArchiveCommandTemplate: '0d77c82de43840a28c74f5181cb21e33b9a9d00454adf4bc92bdc9e69817d6f5',
|
||||
getOpsxVerifyCommandTemplate: 'd7c0444863faabb16abb091bc40ee56d985ae4bfa9a4db1e622ca8ba03c32fed',
|
||||
getOpsxProposeSkillTemplate: 'd67f937d44650e9c61d2158c865309fbab23cb3f50a3d4868a640a97776e3999',
|
||||
@@ -56,17 +56,17 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
|
||||
};
|
||||
|
||||
const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = {
|
||||
'openspec-explore': '08e1ec9958eb04653707dd3e198c3fd69cf1b3acd3cf95a1022693cca83c60fc',
|
||||
'openspec-new-change': 'c324a7ace1f244aa3f534ac8e3370a2c11190d6d1b85a315f26a211398310f0f',
|
||||
'openspec-continue-change': '463cf0b980ec9c3c24774414ef2a3e48e9faa8577bc8748990f45ab3d5efe960',
|
||||
'openspec-apply-change': '38ad2cb645827eda555f20e1ac9d483e1d75bae4c817c0669474aaa8c12c0421',
|
||||
'openspec-ff-change': '672c3a5b8df152d959b15bd7ae2be7a75ab7b8eaa2ec1e0daa15c02479b27937',
|
||||
'openspec-sync-specs': 'b8859cf454379a19ca35dbf59eedca67306607f44a355327f9dc851114e50bde',
|
||||
'openspec-archive-change': 'f83c85452bd47de0dee6b8efbcea6a62534f8a175480e9044f3043f887cebf0f',
|
||||
'openspec-bulk-archive-change': '10477399bb07c7ba67f78e315bd68fb1901af8866720545baf4c62a6a679493b',
|
||||
'openspec-verify-change': 'b6dc1b87940be9d6125b834831c8619019aec9a9748995f72bf981b6f08b67f8',
|
||||
'openspec-onboard': 'c1444e026028210efd699110f7e9079bcb486d85ccf27f743213a81cb1084303',
|
||||
'openspec-propose': '20e36dabefb90e232bad0667292bd5007ec280f8fc4fc995dbc4282bf45a22e7',
|
||||
'openspec-explore': 'ad1178ca328ee6c4a44c42d5a39bb115fc0708778feded3339f35ed7bd82800e',
|
||||
'openspec-new-change': 'f555ab9b1385e78e5d96e9e08bfe7c2035449c7349be21669e671aefbd0d7d4a',
|
||||
'openspec-continue-change': 'a9e827be428a5f300aa3ff4a379403a7a3ac378ce0b790c99c0fbe2e14404625',
|
||||
'openspec-apply-change': '8f8e6594ed8dffa854a2c3373b2674c814419063f0bb9ab79d98c5e18fd21aa6',
|
||||
'openspec-ff-change': 'a61b501beb63dd625dd1b7c46243705c2746005a7673b2120a2cbca36d9eb831',
|
||||
'openspec-sync-specs': '2075928ed531ab226bd3a05c67d386226eb0a3ec71e08f708e4837feeeb41a29',
|
||||
'openspec-archive-change': '66fdcdd43c2a51d70823cf4d3d1eb174273b9350dd651b1113b5458b4ec81a99',
|
||||
'openspec-bulk-archive-change': '7aa32f345f7b44d52619b4efa92589e0ab3cd671ae559bac1c156bb3251cda2b',
|
||||
'openspec-verify-change': '5ea8266080e006319a604e5deac6f4ecb3a4d0e03c3e77b99897d3b62f70892e',
|
||||
'openspec-onboard': '48511ff0e8aec12784dba58ec94f2ad61b1bbc8de4f73b47ca60ecea788a6db9',
|
||||
'openspec-propose': '217b1ca83dc520207b4da584d3699791ece48a24eb164b524a1320189f61549b',
|
||||
};
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
|
||||
Reference in New Issue
Block a user