Skip to content

Commit 72513af

Browse files
committed
fix: expose simple uninstall commands
1 parent 0af591c commit 72513af

8 files changed

Lines changed: 92 additions & 8 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ $ErrorActionPreference='Stop'; & ([scriptblock]::Create((Invoke-WebRequest https
3434
- 변경되지 않는 버전별 설치기: `https://install.ctxa.ai/<installer-tag>/install.sh` 또는 `install.ps1`
3535
- 고정 CLI 릴리스 설치: `CONTEXA_VERSION=<release-tag>`
3636
- 직전 정상 바이너리로 롤백: `CONTEXA_INSTALL_ACTION=rollback`
37-
- CLI 바이너리 제거: `CONTEXA_INSTALL_ACTION=uninstall`
37+
- Windows CLI 바이너리 제거: `irm https://install.ctxa.ai/uninstall.ps1 | iex`
38+
- Linux/macOS CLI 바이너리 제거: `curl -fsSL https://install.ctxa.ai/uninstall.sh | sh`
3839
- 사용자 프로젝트 원복: `contexa reset` — CLI 제거와 다른 작업입니다.
3940

4041
동일 버전을 다시 실행하면 설치 바이너리를 교체하지 않습니다. 업데이트가 성공하면 직전 정상 바이너리를 `.previous`로 보존해 롤백 대상으로 사용합니다. 이전 경로에서 발견된 다른 `contexa` 바이너리는 자동으로 삭제하지 않고 경고만 출력합니다.

api/index.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ const path = require('path');
77
// Routing:
88
// /install.ps1, /win, /windows, PowerShell UA -> install.ps1
99
// /install.sh, /sh, /linux, /mac, everything else -> install.sh
10+
// /uninstall.ps1 -> uninstall.ps1
11+
// /uninstall.sh -> uninstall.sh
1012
module.exports = (req, res) => {
1113
const pathname = new URL(req.url || '/', 'https://install.ctxa.ai').pathname;
1214
const routePath = pathname.toLowerCase();
@@ -26,14 +28,22 @@ module.exports = (req, res) => {
2628

2729
const explicitPs1 = routePath === '/install.ps1';
2830
const explicitSh = routePath === '/install.sh';
31+
const explicitUninstallPs1 = routePath === '/uninstall.ps1';
32+
const explicitUninstallSh = routePath === '/uninstall.sh';
2933
const windowsAlias = routePath === '/win' || routePath === '/windows';
3034
const shellAlias = routePath === '/sh' || routePath === '/linux' || routePath === '/mac';
3135
const wantsPs1 = explicitPs1
32-
|| (!explicitSh && !shellAlias && (windowsAlias || ua.includes('powershell') || ua.includes('windowspowershell')));
36+
|| (!explicitSh && !explicitUninstallSh && !shellAlias
37+
&& (windowsAlias || ua.includes('powershell') || ua.includes('windowspowershell')));
3338

34-
const fileName = wantsPs1 ? 'install.ps1' : 'install.sh';
39+
const uninstallRoute = explicitUninstallPs1 || explicitUninstallSh;
40+
const fileName = explicitUninstallPs1
41+
? 'uninstall.ps1'
42+
: explicitUninstallSh
43+
? 'uninstall.sh'
44+
: wantsPs1 ? 'install.ps1' : 'install.sh';
3545
const stableRef = process.env.CONTEXA_STABLE_INSTALLER_REF;
36-
if (stableRef) {
46+
if (stableRef && !uninstallRoute) {
3747
if (!/^(?:v[0-9A-Za-z][0-9A-Za-z._-]*|[0-9a-f]{40})$/.test(stableRef)) {
3848
res.statusCode = 503;
3949
res.setHeader('Content-Type', 'text/plain; charset=utf-8');

install.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -642,7 +642,7 @@ function Invoke-ContexaInstaller {
642642
Write-Host ' contexa reset --simulate'
643643
Write-Host (' ' + (Select-InstallerText 'Immutable reinstall: set CONTEXA_VERSION=' '64+Z7J28IOuyhOyghCDsnqzshKTsuZg6IENPTlRFWEFfVkVSU0lPTj0=') + $version + (Select-InstallerText ' and run this installer again.' '7J2EIOyEpOygle2VmOqzoCDshKTsuZgg7ZSE66Gc6re4656o7J2EIOuLpOyLnCDsi6TtlontlZjshLjsmpQu'))
644644
Write-Host (' ' + (Select-InstallerText 'Rollback: set CONTEXA_INSTALL_ACTION=rollback and run this installer.' '66Gk67CxOiBDT05URVhBX0lOU1RBTExfQUNUSU9OPXJvbGxiYWNr7J2EIOyEpOygle2VmOqzoCDshKTsuZgg7ZSE66Gc6re4656o7J2EIOyLpO2Wie2VmOyEuOyalC4='))
645-
Write-Host (' ' + (Select-InstallerText 'Uninstall: set CONTEXA_INSTALL_ACTION=uninstall and run this installer. Project reset is separate.' '7KCc6rGwOiBDT05URVhBX0lOU1RBTExfQUNUSU9OPXVuaW5zdGFsbOydhCDshKTsoJXtlZjqs6Ag7ISk7LmYIO2UhOuhnOq3uOueqOydhCDsi6TtlontlZjshLjsmpQuIO2UhOuhnOygne2KuCByZXNldOydgCDrs4Trj4TsnoXri4jri6Qu'))
645+
Write-Host (' ' + (Select-InstallerText 'Uninstall: irm https://install.ctxa.ai/uninstall.ps1 | iex (project reset is separate).' '7KCc6rGwOiBpcm0gaHR0cHM6Ly9pbnN0YWxsLmN0eGEuYWkvdW5pbnN0YWxsLnBzMSB8IGlleCAo7ZSE66Gc7KCd7Yq4IHJlc2V07J2AIOuzhOuPhCk='))
646646
} catch {
647647
if ($oldMoved -and -not (Test-Path -LiteralPath $finalPath) -and (Test-Path -LiteralPath $backupPath)) {
648648
[System.IO.File]::Move($backupPath, $finalPath)

install.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ msg() {
2424
primary) printf '%s' '주요 명령:' ;;
2525
immutable) printf '%s' '동일 버전 재설치: CONTEXA_VERSION=' ;;
2626
rollback) printf '%s' '롤백: CONTEXA_INSTALL_ACTION=rollback' ;;
27-
uninstall) printf '%s' '제거: CONTEXA_INSTALL_ACTION=uninstall (프로젝트 reset은 별도)' ;;
27+
uninstall) printf '%s' '제거: curl -fsSL https://install.ctxa.ai/uninstall.sh | sh (프로젝트 reset은 별도)' ;;
2828
unsupported_action) printf '%s' '지원하지 않는 CONTEXA_INSTALL_ACTION' ;;
2929
esac
3030
else
@@ -38,7 +38,7 @@ msg() {
3838
primary) printf '%s' 'Primary commands:' ;;
3939
immutable) printf '%s' 'Immutable reinstall: CONTEXA_VERSION=' ;;
4040
rollback) printf '%s' 'Rollback: CONTEXA_INSTALL_ACTION=rollback' ;;
41-
uninstall) printf '%s' 'Uninstall: CONTEXA_INSTALL_ACTION=uninstall (project reset is separate)' ;;
41+
uninstall) printf '%s' 'Uninstall: curl -fsSL https://install.ctxa.ai/uninstall.sh | sh (project reset is separate)' ;;
4242
unsupported_action) printf '%s' 'Unsupported CONTEXA_INSTALL_ACTION' ;;
4343
esac
4444
fi

test/installers.test.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const { spawnSync } = require('child_process');
88
const root = path.join(__dirname, '..');
99
const ps1Path = path.join(root, 'install.ps1');
1010
const shPath = path.join(root, 'install.sh');
11+
const uninstallPs1Path = path.join(root, 'uninstall.ps1');
12+
const uninstallShPath = path.join(root, 'uninstall.sh');
1113
const vercelPath = path.join(root, 'vercel.json');
1214
const packagePath = path.join(root, 'package.json');
1315
const api = require('../api/index');
@@ -117,6 +119,20 @@ test('installers use semantic product versions and never request administrator e
117119
test('installer files are written without UTF-8 BOM', () => {
118120
assert.notDeepEqual([...firstBytes(ps1Path, 3)], [0xef, 0xbb, 0xbf]);
119121
assert.notDeepEqual([...firstBytes(shPath, 3)], [0xef, 0xbb, 0xbf]);
122+
assert.notDeepEqual([...firstBytes(uninstallPs1Path, 3)], [0xef, 0xbb, 0xbf]);
123+
assert.notDeepEqual([...firstBytes(uninstallShPath, 3)], [0xef, 0xbb, 0xbf]);
124+
});
125+
126+
test('public uninstall entrypoints reuse the installer without leaking control state', () => {
127+
const ps = read(uninstallPs1Path);
128+
const sh = read(uninstallShPath);
129+
assert.ok(ps.startsWith('#Requires -Version 5.1'));
130+
assert.match(ps, /CONTEXA_INSTALL_ACTION = 'uninstall'/);
131+
assert.match(ps, /https:\/\/install\.ctxa\.ai\/install\.ps1/);
132+
assert.match(ps, /Remove-Item Env:CONTEXA_INSTALL_ACTION/);
133+
assert.ok(sh.startsWith('#!/bin/sh'));
134+
assert.match(sh, /CONTEXA_INSTALL_ACTION=uninstall sh/);
135+
assert.match(sh, /https:\/\/install\.ctxa\.ai\/install\.sh/);
120136
});
121137

122138
test('Vercel routes explicit installer paths through the secured API contract', () => {
@@ -127,7 +143,7 @@ test('Vercel routes explicit installer paths through the secured API contract',
127143
'direct redirects bypass the API response security headers');
128144
assert.ok(config.rewrites.some(entry =>
129145
entry.source === '/(.*)' && entry.destination === '/api'));
130-
for (const route of ['/install.ps1', '/install.sh']) {
146+
for (const route of ['/install.ps1', '/install.sh', '/uninstall.ps1', '/uninstall.sh']) {
131147
const entry = config.headers.find(candidate => candidate.source === route);
132148
assert.ok(entry, `missing Vercel header contract for ${route}`);
133149
const headers = Object.fromEntries(
@@ -163,6 +179,14 @@ test('api prioritizes explicit paths and exposes immutable version URLs', () =>
163179
assert.ok(sh.body.startsWith('#!/bin/sh'));
164180
assert.equal(sh.headers['cache-control'], 'no-store');
165181

182+
const uninstallPs = invoke('/uninstall.ps1');
183+
assert.ok(uninstallPs.body.startsWith('#Requires -Version 5.1'));
184+
assert.match(uninstallPs.body, /CONTEXA_INSTALL_ACTION = 'uninstall'/);
185+
186+
const uninstallSh = invoke('/uninstall.sh', 'WindowsPowerShell/5.1');
187+
assert.ok(uninstallSh.body.startsWith('#!/bin/sh'));
188+
assert.match(uninstallSh.body, /CONTEXA_INSTALL_ACTION=uninstall sh/);
189+
166190
const immutable = invoke('/v9.9.9-installer-test/install.ps1');
167191
assert.equal(immutable.statusCode, 302);
168192
assert.equal(immutable.headers.location, 'https://raw.githubusercontent.com/contexa-security/install-ctxa/v9.9.9-installer-test/install.ps1');
@@ -181,6 +205,9 @@ test('api prioritizes explicit paths and exposes immutable version URLs', () =>
181205
assert.equal(stable.headers['content-type'], 'text/plain; charset=utf-8');
182206
assert.equal(stable.headers['cache-control'], 'no-store');
183207
assert.equal(stable.headers['x-content-type-options'], 'nosniff');
208+
const stableUninstall = invoke('/uninstall.ps1');
209+
assert.equal(stableUninstall.statusCode, 200);
210+
assert.match(stableUninstall.body, /CONTEXA_INSTALL_ACTION = 'uninstall'/);
184211
} finally {
185212
if (originalStableRef === undefined) delete process.env.CONTEXA_STABLE_INSTALLER_REF;
186213
else process.env.CONTEXA_STABLE_INSTALLER_REF = originalStableRef;

uninstall.ps1

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#Requires -Version 5.1
2+
3+
& {
4+
$hadInstallAction = Test-Path Env:CONTEXA_INSTALL_ACTION
5+
$previousInstallAction = $env:CONTEXA_INSTALL_ACTION
6+
7+
try {
8+
$env:CONTEXA_INSTALL_ACTION = 'uninstall'
9+
$installer = Invoke-WebRequest 'https://install.ctxa.ai/install.ps1' -UseBasicParsing
10+
& ([scriptblock]::Create($installer.Content))
11+
} finally {
12+
if ($hadInstallAction) {
13+
$env:CONTEXA_INSTALL_ACTION = $previousInstallAction
14+
} else {
15+
Remove-Item Env:CONTEXA_INSTALL_ACTION -ErrorAction SilentlyContinue
16+
}
17+
}
18+
}

uninstall.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/bin/sh
2+
set -u
3+
4+
installer_file="$(mktemp)" || exit 1
5+
trap 'rm -f "$installer_file"' EXIT HUP INT TERM
6+
7+
if ! curl -fsSL --connect-timeout 5 --max-time 30 \
8+
https://install.ctxa.ai/install.sh -o "$installer_file"; then
9+
exit 1
10+
fi
11+
12+
CONTEXA_INSTALL_ACTION=uninstall sh "$installer_file"

vercel.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,22 @@
2121
{ "key": "Cache-Control", "value": "no-store" },
2222
{ "key": "X-Content-Type-Options", "value": "nosniff" }
2323
]
24+
},
25+
{
26+
"source": "/uninstall.ps1",
27+
"headers": [
28+
{ "key": "Content-Type", "value": "text/plain; charset=utf-8" },
29+
{ "key": "Cache-Control", "value": "no-store" },
30+
{ "key": "X-Content-Type-Options", "value": "nosniff" }
31+
]
32+
},
33+
{
34+
"source": "/uninstall.sh",
35+
"headers": [
36+
{ "key": "Content-Type", "value": "text/plain; charset=utf-8" },
37+
{ "key": "Cache-Control", "value": "no-store" },
38+
{ "key": "X-Content-Type-Options", "value": "nosniff" }
39+
]
2440
}
2541
]
2642
}

0 commit comments

Comments
 (0)