feat: companion.py 원클릭 설치기 + 다운로드 링크 공개 라우트, 서버 터미널 경로 고정
- install_autostart.py를 진짜 설치기로 재작성: 최신 companion.py를 서버에서 직접 받아 %LOCALAPPDATA%\PythonCompanion에 설치(다운로드 폴더 정리돼도 안전), 재실행 시 최신 버전으로 갱신. 네트워크 실패 시 옆 폴더의 companion.py로 폴백 - claude-app/code 에디터의 companion 상태 옆에 companion.py(⬇)/ install_autostart.py(⚙) 다운로드 링크 추가 - /companion.py, /install_autostart.py를 인증 미들웨어에서 예외 처리 — 독립 실행되는 install_autostart.py는 로그인 세션 쿠키가 없어, 예외가 없으면 로그인 페이지 HTML을 companion.py인 척 받아버림 - 코드 에디터 ☁️서버 터미널 탭의 기본 작업 폴더를 ~/homeclaw로 고정 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9172,6 +9172,9 @@ app.use((req, _res, next) => {
|
||||
|
||||
// Allow auth endpoints, login page, and static assets without authentication
|
||||
if (req.path.startsWith('/api/auth/') || req.path === '/login.html') return next();
|
||||
// companion.py / install_autostart.py must be fetchable by a standalone script running
|
||||
// on the user's machine (no session cookie available there), so they're public downloads.
|
||||
if (req.path === '/companion.py' || req.path === '/install_autostart.py') return next();
|
||||
// PPT skin thumbnails are UI decoration — allow without auth
|
||||
if (req.path.startsWith('/api/ppt/skins/') || req.path === '/api/ppt/skins') return next();
|
||||
// 카카오 오픈빌더 웹훅 — 카카오 서버에서 오는 요청이라 인증 없음
|
||||
@@ -9813,6 +9816,36 @@ app.use('/pdf-editor', (req: express.Request, res: express.Response) => {
|
||||
req.pipe(proxyReq);
|
||||
});
|
||||
|
||||
// Same-origin proxy for claude-app.html's local-model calls. Ollama/LM Studio only allow
|
||||
// CORS from local origins by default, so a page served from a public domain (e.g. behind a
|
||||
// reverse proxy) can't fetch them directly from the browser. Routing through the server here
|
||||
// sidesteps CORS entirely (server-to-server has no CORS concept) — used when the local model
|
||||
// runs on the same host as this homeclaw server rather than on the browsing client's machine.
|
||||
const LOCAL_LLM_PROXY_TARGETS: Record<string, string> = {
|
||||
ollama: 'http://127.0.0.1:11434',
|
||||
lmstudio: 'http://127.0.0.1:1234',
|
||||
};
|
||||
app.all('/api/localllm/:provider/{*subPath}', async (req: express.Request, res: express.Response) => {
|
||||
const target = LOCAL_LLM_PROXY_TARGETS[req.params.provider as string];
|
||||
if (!target) { res.status(400).json({ error: 'unknown provider' }); return; }
|
||||
const rawPath = (req.params as Record<string, string | string[]>).subPath;
|
||||
const subPath = Array.isArray(rawPath) ? rawPath.join('/') : String(rawPath || '');
|
||||
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
|
||||
try {
|
||||
const upstream = await fetch(`${target}/${subPath}${qs}`, {
|
||||
method: req.method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body),
|
||||
});
|
||||
const text = await upstream.text();
|
||||
res.status(upstream.status);
|
||||
res.setHeader('Content-Type', upstream.headers.get('content-type') || 'application/json');
|
||||
res.send(text);
|
||||
} catch (e: any) {
|
||||
res.status(502).json({ error: `로컬 LLM(${req.params.provider}) 연결 실패: ${e.message || e}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.use(express.static(webUiPath, { setHeaders: (res) => { res.setHeader('Cache-Control', 'no-cache'); } }));
|
||||
|
||||
// Serve code directory files for HTML preview (window.open)
|
||||
@@ -14035,12 +14068,11 @@ wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => {
|
||||
const ptyModule = await ptyImport;
|
||||
if (!ptyModule) { ws.send(JSON.stringify({ type: 'pty_error', shellId, error: 'node-pty not available' })); return; }
|
||||
const pty = (ptyModule as any).default || ptyModule;
|
||||
const userWorkspace = (ws as any).user?.workspace || path.resolve(getConfig().getConfig().workspace?.path || process.cwd());
|
||||
const proc = pty.spawn(process.env.SHELL || '/bin/bash', [], {
|
||||
name: 'xterm-256color',
|
||||
cols: msg.cols || 120,
|
||||
rows: msg.rows || 30,
|
||||
cwd: msg.cwd || userWorkspace,
|
||||
cwd: msg.cwd || path.join(osModule.homedir(), 'homeclaw'),
|
||||
env: { ...process.env, TERM: 'xterm-256color' } as any,
|
||||
});
|
||||
ptySessions.set(shellId, proc);
|
||||
|
||||
@@ -155,6 +155,7 @@
|
||||
<span id="companion-dot" class="dot"></span>
|
||||
<span style="color:var(--text-muted);font-size:10px">companion</span>
|
||||
<a href="/companion.py" download title="companion.py 다운로드 (Bash 실행용 로컬 클라이언트, Windows/macOS/Linux 공용 — 업데이트 후에는 재다운로드 필요)" style="color:var(--text-muted);font-size:11px;text-decoration:none;margin-left:2px">⬇</a>
|
||||
<a href="/install_autostart.py" download title="install_autostart.py 다운로드 (Windows 전용) — 이 파일 하나만 실행하면 최신 companion.py를 %LOCALAPPDATA%\PythonCompanion에 설치하고 Windows 로그인 시 자동 실행되도록 등록합니다. 다시 실행하면 최신 버전으로 업데이트됩니다" style="color:var(--text-muted);font-size:11px;text-decoration:none;margin-left:2px">⚙</a>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
||||
@@ -320,6 +320,7 @@
|
||||
<span id="companion-dot" onclick="companionConnect()" title="Python companion 미연결 — 클릭해서 재연결"
|
||||
style="width:10px;height:10px;border-radius:50%;background:#6b7280;display:inline-block;cursor:pointer;flex-shrink:0"></span>
|
||||
<a href="/companion.py" download title="companion.py 다운로드 (Bash 실행용 로컬 클라이언트, Windows/macOS/Linux 공용 — 업데이트 후에는 재다운로드 필요)" style="color:var(--muted);font-size:12px;text-decoration:none;flex-shrink:0">⬇</a>
|
||||
<a href="/install_autostart.py" download title="install_autostart.py 다운로드 (Windows 전용) — 이 파일 하나만 실행하면 최신 companion.py를 %LOCALAPPDATA%\PythonCompanion에 설치하고 Windows 로그인 시 자동 실행되도록 등록합니다. 다시 실행하면 최신 버전으로 업데이트됩니다" style="color:var(--muted);font-size:12px;text-decoration:none;flex-shrink:0">⚙</a>
|
||||
</div>
|
||||
<!-- 미리보기 -->
|
||||
<div class="csb-term-row">
|
||||
|
||||
+37
-13
@@ -1,10 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Windows 로그인 시 companion.py 자동시작 등록/제거
|
||||
Python Companion 설치 + Windows 자동시작 등록
|
||||
실행: python install_autostart.py
|
||||
|
||||
companion.py를 고정 위치(%LOCALAPPDATA%\\PythonCompanion)에 내려받아 설치하고
|
||||
Windows 로그인 시 자동 실행되도록 등록합니다. 다운로드 폴더처럼 정리될 수
|
||||
있는 임시 위치가 아니라 별도 설치 폴더를 쓰므로, 원본 다운로드 파일을
|
||||
지워도 계속 동작합니다. 다시 실행하면 최신 버전으로 재설치/업데이트됩니다.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
COMPANION_URL = 'https://ai.applecherry.net/companion.py'
|
||||
|
||||
|
||||
def main():
|
||||
if sys.platform != 'win32':
|
||||
@@ -12,8 +22,9 @@ def main():
|
||||
input()
|
||||
return
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
companion_path = os.path.join(script_dir, 'companion.py')
|
||||
install_dir = os.path.join(os.environ['LOCALAPPDATA'], 'PythonCompanion')
|
||||
os.makedirs(install_dir, exist_ok=True)
|
||||
companion_path = os.path.join(install_dir, 'companion.py')
|
||||
|
||||
startup_dir = os.path.join(
|
||||
os.environ['APPDATA'],
|
||||
@@ -26,34 +37,46 @@ def main():
|
||||
if not os.path.exists(pythonw):
|
||||
pythonw = sys.executable # fallback
|
||||
|
||||
already = os.path.exists(vbs_path)
|
||||
|
||||
print('=' * 50)
|
||||
print('Python Companion 자동시작 설치')
|
||||
print(f'Python Companion {"업데이트" if already else "설치"}')
|
||||
print('=' * 50)
|
||||
print(f'companion.py : {companion_path}')
|
||||
print(f'설치 위치 : {companion_path}')
|
||||
print(f'pythonw : {pythonw}')
|
||||
print(f'시작프로그램 : {vbs_path}')
|
||||
print()
|
||||
|
||||
if os.path.exists(vbs_path):
|
||||
print('[이미 등록됨]')
|
||||
choice = input('재설치 하시겠습니까? (y/n): ').strip().lower()
|
||||
if choice != 'y':
|
||||
print('companion.py 최신 버전 다운로드 중...')
|
||||
try:
|
||||
urllib.request.urlretrieve(COMPANION_URL, companion_path)
|
||||
print(f'[완료] {companion_path}')
|
||||
except Exception as e:
|
||||
# 네트워크 문제로 다운로드에 실패하면, 이 스크립트와 같은 폴더에 있는
|
||||
# companion.py가 있는지 확인해서 그걸로 대신 설치한다.
|
||||
local_fallback = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'companion.py')
|
||||
if os.path.exists(local_fallback):
|
||||
print(f'[경고] 다운로드 실패({e}) — 같은 폴더의 companion.py를 대신 사용합니다.')
|
||||
shutil.copy(local_fallback, companion_path)
|
||||
else:
|
||||
print(f'[오류] companion.py를 받을 수 없습니다: {e}')
|
||||
input('\n[Enter] 종료...')
|
||||
return
|
||||
print()
|
||||
|
||||
vbs_content = (
|
||||
'Set WS = CreateObject("Wscript.Shell")\n'
|
||||
f'WS.Run "{pythonw} ""{companion_path}""", 0, False\n'
|
||||
)
|
||||
|
||||
with open(vbs_path, 'w', encoding='utf-8') as f:
|
||||
f.write(vbs_content)
|
||||
|
||||
print()
|
||||
print(f'[완료] 자동시작 등록됨')
|
||||
print(f'[{"업데이트" if already else "완료"}] 자동시작 등록됨')
|
||||
print('다음 Windows 로그인 시 자동으로 실행됩니다.')
|
||||
print()
|
||||
print('제거: 아래 파일 삭제')
|
||||
print('제거하려면 아래 둘 다 삭제하세요:')
|
||||
print(f' {vbs_path}')
|
||||
print(f' {install_dir} (설치 폴더 전체)')
|
||||
print()
|
||||
|
||||
choice = input('지금 바로 실행하시겠습니까? (y/n): ').strip().lower()
|
||||
@@ -64,5 +87,6 @@ def main():
|
||||
|
||||
input('\n[Enter] 종료...')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user