@twentyoz/alexx-cli 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -0
- package/dist/action.js +12 -0
- package/dist/cli-login.js +185 -0
- package/dist/client.js +196 -0
- package/dist/commands/api.js +43 -0
- package/dist/commands/auth.js +92 -0
- package/dist/commands/meta.js +20 -0
- package/dist/commands/rec.js +278 -0
- package/dist/config.js +48 -0
- package/dist/data.js +34 -0
- package/dist/errors.js +31 -0
- package/dist/generated/endpoint.js +3 -0
- package/dist/index.js +30 -0
- package/dist/output.js +59 -0
- package/dist/prompt.js +27 -0
- package/dist/recs.js +92 -0
- package/dist/types.js +2 -0
- package/dist/version.js +3 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# `alexx` — Alexx CLI
|
|
2
|
+
|
|
3
|
+
AI 에이전트(및 사람)가 터미널에서 Alexx 를 조작하기 위한 CLI. **Front Backend REST API(`/api`)** 를 호출하므로 인증·권한·UUIDv47 facade·비즈니스 로직을 그대로 따른다 (DB 직접 접근 없음).
|
|
4
|
+
|
|
5
|
+
## 설계 (AI 친화)
|
|
6
|
+
|
|
7
|
+
- **기본 출력은 JSON** (stdout, 단일 객체/배열). `--pretty` 로 들여쓰기
|
|
8
|
+
- **에러는 구조화 JSON** (stderr): `{"error":{"code","message","hint"}}`. `hint` 가 다음 행동을 안내
|
|
9
|
+
- **Exit code**: `0` 성공 / `1` 일반 / `2` 사용법 / `3` 인증 / `4` not found
|
|
10
|
+
- **비대화형 기본**: 프롬프트 없음(파괴적 명령만 TTY 확인). 파괴적 명령은 비-TTY 에서 `--yes` 필요
|
|
11
|
+
- **escape hatch**: `alexx api <METHOD> <path>` 로 CLI 가 감싸지 않은 엔드포인트도 직접 호출
|
|
12
|
+
|
|
13
|
+
## 설치 / 환경(endpoint)
|
|
14
|
+
|
|
15
|
+
endpoint 는 **빌드 시점에 baked** 되고 런타임/CLI 플래그/env 로는 **변경 불가** — AI 에이전트가 임의로 dev/prod 를 건드리지 못하게 하는 가드레일. `alexx --help` 에 현재 박힌 endpoint 가 표시된다.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# 일반 사용자 — npm 발행본 (운영 endpoint baked)
|
|
19
|
+
npm i -g @twentyoz/alexx-cli
|
|
20
|
+
|
|
21
|
+
# 로컬 개발 — 소스에서 빌드 (기본 local)
|
|
22
|
+
make install-cli # env=local — npm install + build + 글로벌 link
|
|
23
|
+
make install-cli env=dev # dev 로 박아서 설치
|
|
24
|
+
make build-cli env=prod # 빌드만, prod 로 박기
|
|
25
|
+
cd web/cli && ALEXX_ENV=dev npm run dev -- --help
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
환경별 URL 은 `.env` 의 `ALEXX_API_BASE_{LOCAL,DEV,PROD}` 에서 읽는다 (`.env.template` 참고).
|
|
29
|
+
배포 빌드는 `ALEXX_API_BASE_URL` 직접 주입 가능(우선). **local 만 미설정 시 localhost 폴백, dev/prod 는 미해결 시 빌드 실패**(오발행 방지).
|
|
30
|
+
|
|
31
|
+
### npm 발행
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
make release-cli # 현재 version 으로 발행 (운영 endpoint)
|
|
35
|
+
make release-cli version=patch # version bump 후 발행
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- 발행본은 항상 **운영 endpoint baked** — `npm publish` 는 `ALEXX_ENV=prod` 일 때만 허용(`prepublishOnly` 가드)
|
|
39
|
+
- 발행 머신의 `.env` 에 `ALEXX_API_BASE_PROD` 필요 (또는 `ALEXX_API_BASE_URL` 주입). 사전 `npm login` (스코프 `@twentyoz`)
|
|
40
|
+
- 레지스트리는 public npmjs 기본(`publishConfig.access=public`). private/GitHub Packages 로 바꾸려면 `publishConfig.registry` 추가
|
|
41
|
+
|
|
42
|
+
| 환경 | 기본 URL (`.env.template`) |
|
|
43
|
+
|------|----------------------------|
|
|
44
|
+
| local | `http://localhost:30042/api` |
|
|
45
|
+
| dev | `https://alexx.twentyoz.dev/api` |
|
|
46
|
+
| prod | `https://alexx.app/api` |
|
|
47
|
+
|
|
48
|
+
## 인증
|
|
49
|
+
|
|
50
|
+
| 항목 | 우선순위 |
|
|
51
|
+
|------|----------|
|
|
52
|
+
| 토큰 | `--token` / `ALEXX_TOKEN`(access only, refresh 없음) > 저장 토큰(`alexx auth login`, 401 시 자동 refresh) |
|
|
53
|
+
|
|
54
|
+
저장 위치: `${XDG_CONFIG_HOME:-~/.config}/alexx/config.json` (권한 0600). endpoint 는 baked 라 저장하지 않음.
|
|
55
|
+
|
|
56
|
+
로그인은 **브라우저 위임만** 지원한다(captcha·Google·이메일/비번 모두 브라우저에서). 비밀번호를 CLI
|
|
57
|
+
인자/env 로 직접 받지 않는다 — argv/`ps` 노출과 captcha 우회를 막기 위함. 헤드리스/CI 는 `ALEXX_TOKEN`.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# 대화형 로그인 (브라우저 자동 오픈 → loopback 으로 토큰 수신)
|
|
61
|
+
alexx auth login
|
|
62
|
+
alexx auth whoami
|
|
63
|
+
|
|
64
|
+
# SSH/헤드리스: 브라우저를 띄울 수 없으면 코드 붙여넣기 (자동 감지되며, 강제하려면 --code)
|
|
65
|
+
alexx auth login --code
|
|
66
|
+
|
|
67
|
+
# CI/에이전트(비대화형): 토큰 직접 주입 (refresh 없음)
|
|
68
|
+
ALEXX_TOKEN=<access_jwt> alexx auth whoami
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 워크플로 (Search → Fetch → Act)
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
alexx rec ls --limit 10 # 또는: alexx rec search "분기 회의"
|
|
75
|
+
alexx rec upload meeting.m4a --wait # 완료까지 대기(진행은 stderr) 후 상세 JSON
|
|
76
|
+
alexx rec transcript <id> --text # 화자: 발화
|
|
77
|
+
alexx rec summarize <id> --language ko # AI 요약 재생성
|
|
78
|
+
alexx rec chat <id> "핵심 결정사항만 정리해줘"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## 명령
|
|
82
|
+
|
|
83
|
+
| 명령 | 설명 |
|
|
84
|
+
|------|------|
|
|
85
|
+
| `alexx auth login\|logout\|whoami` | 인증/토큰 |
|
|
86
|
+
| `alexx rec ls [--cursor --limit --tag --trash --all]` | 목록 (alias: `alexx ls`). `--trash` = 휴지통 목록(`collection=trash`, 항목에 `deleted_at`·`purge_at` 포함) |
|
|
87
|
+
| `alexx rec search <query> [--limit --tag]` | 전문검색 |
|
|
88
|
+
| `alexx rec get <id> [--waveform]` | 상세 (기본 waveform 생략, alias: `alexx get`) |
|
|
89
|
+
| `alexx rec status <id>` | 경량 상태/진행률 (폴링용) |
|
|
90
|
+
| `alexx rec upload <file> [--wait --skip-summary --template --timezone --force --context]` | 업로드 (alias: `alexx upload`). `--context` = 주제·참석자·고유명사 배경 설명(≤2000자) — AI 후처리(다듬기·요약·화자 분석)가 표기 근거로 사용. 업로드 후 수정은 `alexx api PATCH /recs/<id> --data '{"context":"..."}'` |
|
|
91
|
+
| `alexx rec watch <id>` | 진행률 SSE → JSON 라인 스트림 |
|
|
92
|
+
| `alexx rec transcript <id> [--text]` | 전사 결과 |
|
|
93
|
+
| `alexx rec notes <id> [--ai]` | 노트 (`--ai` 로 AI 요약만) |
|
|
94
|
+
| `alexx rec summarize <id> [--language --template --data]` | 요약 재생성 |
|
|
95
|
+
| `alexx rec chat <id> <message...> [--thread --language --data]` | AI 채팅 (alias: `alexx chat`) |
|
|
96
|
+
| `alexx rec retry <id>` | 재시도 |
|
|
97
|
+
| `alexx rec rm <id> [--permanent --yes]` | 휴지통으로 이동(30일 뒤 자동 영구 삭제, 확인 없음). `--permanent` = 즉시 영구 삭제(DB+S3, 이때만 `--yes`/확인 프롬프트) |
|
|
98
|
+
| `alexx rec restore <id>` | 휴지통에서 복구 |
|
|
99
|
+
| `alexx rec empty-trash [--yes]` | 휴지통 비우기 — 전부 영구 삭제 (비-TTY 는 `--yes` 필수) |
|
|
100
|
+
| `alexx templates` / `tags` | 읽기 전용 보조 |
|
|
101
|
+
| `alexx api <METHOD> <path> [--data <json\|@file> -q k=v ...]` | escape hatch |
|
|
102
|
+
|
|
103
|
+
### `--data` / `api` 예시
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
alexx rec chat <id> --data '{"message":"요약","language":"en"}'
|
|
107
|
+
alexx api POST /recs/<id>/split --data @body.json
|
|
108
|
+
alexx api GET /recs -q limit=5 -q tag=<tagId>
|
|
109
|
+
alexx api PATCH /recs/<id>/speakers --data '{"name_map":{"s1":"홍길동"}}'
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
긴 꼬리 엔드포인트(split, detect-boundaries, speakers 변경, contacts, shares, tier, note/template CRUD)는 `alexx api` 로 호출한다.
|
|
113
|
+
|
|
114
|
+
## MCP
|
|
115
|
+
|
|
116
|
+
AI 도구 연동은 CLI 가 아니라 원격 MCP 서버(`https://alexx.app/mcp`, OAuth 로그인)를 쓴다. 등록 방법은 공개 문서 `/docs/mcp` 를 따른다.
|
|
117
|
+
|
|
118
|
+
## 검증
|
|
119
|
+
|
|
120
|
+
`npm test`로 SSE의 한글·CRLF 청크 경계와 콜백 오류·조기 종료 시 연결 정리, rec id 경로 인코딩을 검증한다. 실제 API 호출은 없다.
|
package/dist/action.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// commander action 공통 래퍼 — 모든 서브명령이 반복하던
|
|
2
|
+
// `handler(async (..., command) => { const client = clientFromCommand(command); ... })`
|
|
3
|
+
// 보일러플레이트를 흡수한다. 마지막 인자(Command)에서 클라이언트를 만들어 첫 인자로 주입.
|
|
4
|
+
import { clientFromCommand } from "./client.js";
|
|
5
|
+
import { handler } from "./output.js";
|
|
6
|
+
export function withClient(fn) {
|
|
7
|
+
return handler(async (...args) => {
|
|
8
|
+
const command = args[args.length - 1];
|
|
9
|
+
const rest = args.slice(0, -1);
|
|
10
|
+
await fn(clientFromCommand(command), ...rest);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// 브라우저 위임 로그인 — captcha·Google OAuth·이메일/비번을 전부 브라우저에서 처리하고
|
|
2
|
+
// 토큰만 CLI 로 돌려받는다(gh auth login 방식). CLI 는 자격증명·captcha 를 직접 다루지 않는다.
|
|
3
|
+
//
|
|
4
|
+
// loopback (기본): 127.0.0.1 임시 포트에 서버를 띄우고 브라우저가 /cli-login 에서 로그인하면
|
|
5
|
+
// 토큰이 http://127.0.0.1:<port>/callback 로 redirect 돼 자동 수신.
|
|
6
|
+
// code (--code) : loopback 불가 환경(SSH 등). 브라우저에서 받은 코드를 터미널에 붙여넣기.
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { ApiClient } from "./client.js";
|
|
11
|
+
import { resolveBaseUrl } from "./config.js";
|
|
12
|
+
import { AppError } from "./output.js";
|
|
13
|
+
import { prompt } from "./prompt.js";
|
|
14
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
15
|
+
/** CLI 는 API base 만 baked 로 알고 FE origin 은 모르므로 BE 에서 받아온다. */
|
|
16
|
+
async function fetchFrontFeUrl() {
|
|
17
|
+
const client = new ApiClient({ baseUrl: resolveBaseUrl(), useStore: false });
|
|
18
|
+
const cfg = await client.request("GET", "/auth/cli/config", { auth: false });
|
|
19
|
+
const url = cfg.front_fe_url?.replace(/\/$/, "");
|
|
20
|
+
if (!url) {
|
|
21
|
+
throw new AppError("config", "Server did not return a web URL for CLI login.", {
|
|
22
|
+
hint: "Use a personal token instead: ALEXX_TOKEN=<token> alexx …",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return url;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* loopback 콜백이 닿을 수 있는 "로컬" 브라우저를 띄울 수 있는 환경인가.
|
|
29
|
+
* 원격(SSH)·헤드리스(Linux 무 DISPLAY)면 false — 이 경우 호출부가 코드 흐름으로 전환한다
|
|
30
|
+
* (원격에선 브라우저가 사용자 로컬 머신에 떠서 remote 127.0.0.1 에 못 닿기 때문).
|
|
31
|
+
*/
|
|
32
|
+
export function canOpenLocalBrowser() {
|
|
33
|
+
const env = process.env;
|
|
34
|
+
// SSH 원격 세션 — 브라우저는 사용자 로컬 머신, remote loopback 도달 불가.
|
|
35
|
+
if (env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT)
|
|
36
|
+
return false;
|
|
37
|
+
// Linux 무 디스플레이 — GUI 브라우저 자체가 없음 (macOS/Windows 는 DISPLAY 미사용).
|
|
38
|
+
// $BROWSER 가 있으면(원격개발 헬퍼 등) 띄울 수 있다고 본다.
|
|
39
|
+
if (process.platform === "linux" &&
|
|
40
|
+
!env.DISPLAY &&
|
|
41
|
+
!env.WAYLAND_DISPLAY &&
|
|
42
|
+
!env.BROWSER) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
/** 시스템 기본(또는 $BROWSER) 브라우저로 URL 열기. 자식 프로세스 반환(스폰 실패 시 null). */
|
|
48
|
+
function spawnBrowser(url) {
|
|
49
|
+
const custom = process.env.BROWSER;
|
|
50
|
+
const [cmd, args] = custom
|
|
51
|
+
? [custom, [url]]
|
|
52
|
+
: process.platform === "darwin"
|
|
53
|
+
? ["open", [url]]
|
|
54
|
+
: process.platform === "win32"
|
|
55
|
+
? // `cmd /c start` 는 URL 의 &(쿼리 구분자)를 명령 구분자로 오해해 첫 파라미터만 연다
|
|
56
|
+
// (state/mode 유실 → 콜백 미수신 → 5분 타임아웃). rundll32 FileProtocolHandler 는
|
|
57
|
+
// 셸 재파싱 없이 URL 전체를 기본 브라우저로 연다.
|
|
58
|
+
["rundll32", ["url.dll,FileProtocolHandler", url]]
|
|
59
|
+
: ["xdg-open", [url]];
|
|
60
|
+
try {
|
|
61
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
62
|
+
child.unref();
|
|
63
|
+
return child;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* loopback 흐름. 127.0.0.1 임시 포트에 일회성 서버를 띄우고, /cli-login 이 토큰을
|
|
71
|
+
* /cb 로 redirect 할 때까지 기다린다. `open=false` 면 자동 실행 없이 URL 만 안내한다.
|
|
72
|
+
*/
|
|
73
|
+
export async function loginViaLoopback(open = true) {
|
|
74
|
+
const frontFe = await fetchFrontFeUrl();
|
|
75
|
+
const state = randomBytes(16).toString("hex");
|
|
76
|
+
return await new Promise((resolve, reject) => {
|
|
77
|
+
let settled = false;
|
|
78
|
+
const finish = (fn) => {
|
|
79
|
+
if (settled)
|
|
80
|
+
return;
|
|
81
|
+
settled = true;
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
server.close();
|
|
84
|
+
fn();
|
|
85
|
+
};
|
|
86
|
+
const server = createServer((req, res) => {
|
|
87
|
+
const u = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
88
|
+
if (u.pathname !== "/callback") {
|
|
89
|
+
res.writeHead(404, { connection: "close" });
|
|
90
|
+
res.end();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const accessToken = u.searchParams.get("access_token");
|
|
94
|
+
const refreshToken = u.searchParams.get("refresh_token");
|
|
95
|
+
const st = u.searchParams.get("state");
|
|
96
|
+
const ok = st === state && !!accessToken && !!refreshToken;
|
|
97
|
+
// 토큰만 받고 브라우저는 FE 의 성공/에러 화면(로고·i18n)으로 302 되돌린다 — CLI 가 응답한
|
|
98
|
+
// 맨 HTML 을 사용자에게 안 보이게. Connection: close + 응답 후 소켓 정리로 keep-alive
|
|
99
|
+
// 소켓이 이벤트 루프를 붙잡아 프로세스가 안 끝나던 문제를 방지.
|
|
100
|
+
res.writeHead(302, {
|
|
101
|
+
location: `${frontFe}/cli-login?status=${ok ? "success" : "error"}`,
|
|
102
|
+
connection: "close",
|
|
103
|
+
});
|
|
104
|
+
res.on("finish", () => setTimeout(() => server.closeAllConnections?.(), 200));
|
|
105
|
+
res.end();
|
|
106
|
+
if (ok) {
|
|
107
|
+
finish(() => resolve({ access_token: accessToken, refresh_token: refreshToken }));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
finish(() => reject(new AppError("auth", "Login callback was invalid (state mismatch).", {
|
|
111
|
+
exitCode: 3,
|
|
112
|
+
})));
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
server.on("error", (err) => finish(() => reject(new AppError("network", `Could not start local login server: ${err.message}`, {
|
|
116
|
+
hint: "Try `alexx auth login --code` (no local server, works over SSH).",
|
|
117
|
+
}))));
|
|
118
|
+
const timer = setTimeout(() => finish(() => reject(new AppError("timeout", "Login timed out.", {
|
|
119
|
+
hint: "Try `alexx auth login --code` — open the URL on any device and paste the code back.",
|
|
120
|
+
exitCode: 3,
|
|
121
|
+
}))), LOGIN_TIMEOUT_MS);
|
|
122
|
+
server.listen(0, "127.0.0.1", () => {
|
|
123
|
+
const addr = server.address();
|
|
124
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
125
|
+
const url = `${frontFe}/cli-login?port=${port}&state=${state}&mode=lb`;
|
|
126
|
+
process.stderr.write((open
|
|
127
|
+
? "Opening your browser to sign in…\n"
|
|
128
|
+
: "Open this URL in your browser to sign in:\n") +
|
|
129
|
+
` ${url}\n` +
|
|
130
|
+
"Waiting for you to finish in the browser… (Ctrl+C to cancel)\n");
|
|
131
|
+
if (open) {
|
|
132
|
+
const child = spawnBrowser(url);
|
|
133
|
+
// 브라우저를 못 띄우면 loopback 으로는 진행 불가 → 전용 코드로 즉시 reject 해 호출부가
|
|
134
|
+
// 코드 흐름으로 폴백하게 한다(5분 타임아웃 대기 X). 두 실패 경로를 모두 잡는다:
|
|
135
|
+
// ① spawn 자체 실패(런처 부재 ENOENT 등) → 'error'
|
|
136
|
+
// ② 런처는 떴지만 핸들러 없음/실패로 0 아닌 코드 종료(xdg-open 3/4, open 비0) → 'exit'
|
|
137
|
+
// 정상 핸드오프는 exit 0 이라 무시되고, 그 사이 /cb 가 먼저 오면 finish 가 멱등이라 안전.
|
|
138
|
+
const failOpen = () => finish(() => reject(new AppError("browser_open_failed", "Could not open a browser.", {
|
|
139
|
+
exitCode: 3,
|
|
140
|
+
})));
|
|
141
|
+
if (!child) {
|
|
142
|
+
failOpen();
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
child.on("error", failOpen);
|
|
146
|
+
child.on("exit", (code) => {
|
|
147
|
+
if (code)
|
|
148
|
+
failOpen();
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/** code 흐름. 브라우저에서 로그인 후 표시되는 코드를 터미널에 붙여넣는다(loopback 불필요). */
|
|
156
|
+
export async function loginViaCode() {
|
|
157
|
+
const frontFe = await fetchFrontFeUrl();
|
|
158
|
+
const state = randomBytes(16).toString("hex");
|
|
159
|
+
const url = `${frontFe}/cli-login?state=${state}&mode=code`;
|
|
160
|
+
process.stderr.write("Open this URL in any browser, sign in, then paste the code below:\n" + ` ${url}\n`);
|
|
161
|
+
const raw = await prompt("Paste code: ");
|
|
162
|
+
if (!raw) {
|
|
163
|
+
throw new AppError("usage", "No code provided.", {
|
|
164
|
+
hint: "Re-run `alexx auth login --code` and paste the code shown in the browser.",
|
|
165
|
+
exitCode: 2,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
let decoded;
|
|
169
|
+
try {
|
|
170
|
+
decoded = JSON.parse(Buffer.from(raw.trim(), "base64url").toString("utf8"));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new AppError("usage", "The code could not be decoded.", {
|
|
174
|
+
hint: "Copy the full code from the browser and paste it again.",
|
|
175
|
+
exitCode: 2,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (decoded.state !== state) {
|
|
179
|
+
throw new AppError("auth", "Code did not match this login session.", { exitCode: 3 });
|
|
180
|
+
}
|
|
181
|
+
if (!decoded.access_token || !decoded.refresh_token) {
|
|
182
|
+
throw new AppError("auth", "The code did not contain valid tokens.", { exitCode: 3 });
|
|
183
|
+
}
|
|
184
|
+
return { access_token: decoded.access_token, refresh_token: decoded.refresh_token };
|
|
185
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// HTTP 클라이언트: fetch 래퍼 + 401 자동 refresh + SSE + multipart 업로드 + 에러 정규화.
|
|
2
|
+
// 외부 HTTP 의존성 없이 Node 22 내장 fetch/FormData/openAsBlob/ReadableStream 사용.
|
|
3
|
+
import { openAsBlob } from "node:fs";
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import { httpError } from "./errors.js";
|
|
6
|
+
import { loadConfig, saveConfig, resolveBaseUrl, } from "./config.js";
|
|
7
|
+
export class ApiClient {
|
|
8
|
+
baseUrl;
|
|
9
|
+
token;
|
|
10
|
+
refreshToken;
|
|
11
|
+
useStore;
|
|
12
|
+
constructor(opts) {
|
|
13
|
+
this.baseUrl = opts.baseUrl;
|
|
14
|
+
this.token = opts.token;
|
|
15
|
+
this.refreshToken = opts.refreshToken;
|
|
16
|
+
this.useStore = opts.useStore;
|
|
17
|
+
}
|
|
18
|
+
// ── URL 조립 ──
|
|
19
|
+
// baseUrl 은 .../api 를 포함. path 가 /api/ 로 시작하면 중복 제거.
|
|
20
|
+
// URL 파서가 `..` 를 정규화하므로 외부 입력 id 는 recPath() 처럼 인코딩해 넣는다.
|
|
21
|
+
buildUrl(path, query) {
|
|
22
|
+
let p = path.startsWith("/") ? path : "/" + path;
|
|
23
|
+
if (this.baseUrl.endsWith("/api") && p.startsWith("/api/")) {
|
|
24
|
+
p = p.slice(4);
|
|
25
|
+
}
|
|
26
|
+
const url = new URL(this.baseUrl + p);
|
|
27
|
+
if (query) {
|
|
28
|
+
for (const [k, v] of Object.entries(query)) {
|
|
29
|
+
if (v !== undefined && v !== "")
|
|
30
|
+
url.searchParams.set(k, v);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return url;
|
|
34
|
+
}
|
|
35
|
+
async fetchOnce(method, url, opts, token) {
|
|
36
|
+
const headers = { ...(opts.headers ?? {}) };
|
|
37
|
+
if (token)
|
|
38
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
39
|
+
let payload;
|
|
40
|
+
if (opts.form) {
|
|
41
|
+
payload = opts.form;
|
|
42
|
+
}
|
|
43
|
+
else if (opts.body !== undefined) {
|
|
44
|
+
headers["Content-Type"] = "application/json";
|
|
45
|
+
payload = JSON.stringify(opts.body);
|
|
46
|
+
}
|
|
47
|
+
return fetch(url, { method, headers, body: payload });
|
|
48
|
+
}
|
|
49
|
+
/** 저장된 refresh_token 으로 토큰 재발급 후 config 갱신. 성공 시 true. */
|
|
50
|
+
async tryRefresh() {
|
|
51
|
+
if (!this.refreshToken)
|
|
52
|
+
return false;
|
|
53
|
+
try {
|
|
54
|
+
const res = await this.fetchOnce("POST", this.buildUrl("/auth/refresh").toString(), { body: { refresh_token: this.refreshToken }, headers: { "X-Client": "cli" } });
|
|
55
|
+
if (!res.ok)
|
|
56
|
+
return false;
|
|
57
|
+
const data = (await res.json());
|
|
58
|
+
this.token = data.access_token;
|
|
59
|
+
this.refreshToken = data.refresh_token;
|
|
60
|
+
const cfg = loadConfig();
|
|
61
|
+
saveConfig({
|
|
62
|
+
...cfg,
|
|
63
|
+
access_token: data.access_token,
|
|
64
|
+
refresh_token: data.refresh_token,
|
|
65
|
+
user: data.user ?? cfg.user,
|
|
66
|
+
});
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async request(method, path, opts = {}) {
|
|
74
|
+
const useAuth = opts.auth !== false;
|
|
75
|
+
const url = this.buildUrl(path, opts.query).toString();
|
|
76
|
+
let res = await this.fetchOnce(method, url, opts, useAuth ? this.token : undefined);
|
|
77
|
+
if (res.status === 401 && useAuth && this.useStore && (await this.tryRefresh())) {
|
|
78
|
+
res = await this.fetchOnce(method, url, opts, this.token);
|
|
79
|
+
}
|
|
80
|
+
return this.handle(res);
|
|
81
|
+
}
|
|
82
|
+
/** multipart 파일 업로드. `file` 필드 + 추가 문자열 필드. */
|
|
83
|
+
async upload(path, filePath, fields) {
|
|
84
|
+
const form = new FormData();
|
|
85
|
+
const blob = await openAsBlob(filePath);
|
|
86
|
+
form.append("file", blob, basename(filePath));
|
|
87
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
88
|
+
if (v !== undefined)
|
|
89
|
+
form.append(k, v);
|
|
90
|
+
}
|
|
91
|
+
return this.request("POST", path, { form });
|
|
92
|
+
}
|
|
93
|
+
/** SSE 스트림. onEvent 가 true 를 반환하면 스트림 종료. */
|
|
94
|
+
async sse(path, onEvent) {
|
|
95
|
+
const url = this.buildUrl(path).toString();
|
|
96
|
+
const doFetch = (token) => fetch(url, {
|
|
97
|
+
headers: {
|
|
98
|
+
Accept: "text/event-stream",
|
|
99
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
let res = await doFetch(this.token);
|
|
103
|
+
if (res.status === 401 && this.useStore && (await this.tryRefresh())) {
|
|
104
|
+
res = await doFetch(this.token);
|
|
105
|
+
}
|
|
106
|
+
if (!res.ok || !res.body) {
|
|
107
|
+
await this.handle(res); // throws
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const reader = res.body.getReader();
|
|
111
|
+
const decoder = new TextDecoder();
|
|
112
|
+
let buf = "";
|
|
113
|
+
let ended = false;
|
|
114
|
+
try {
|
|
115
|
+
for (;;) {
|
|
116
|
+
const { value, done } = await reader.read();
|
|
117
|
+
if (done) {
|
|
118
|
+
ended = true;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
buf = (buf + decoder.decode(value, { stream: true })).replace(/\r\n/g, "\n");
|
|
122
|
+
let idx;
|
|
123
|
+
while ((idx = buf.indexOf("\n\n")) !== -1) {
|
|
124
|
+
const chunk = buf.slice(0, idx);
|
|
125
|
+
buf = buf.slice(idx + 2);
|
|
126
|
+
const ev = parseSseEvent(chunk);
|
|
127
|
+
if (ev && onEvent(ev) === true)
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
if (!ended)
|
|
134
|
+
await reader.cancel().catch(() => { });
|
|
135
|
+
reader.releaseLock();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async handle(res) {
|
|
139
|
+
const text = await res.text();
|
|
140
|
+
let data = undefined;
|
|
141
|
+
if (text) {
|
|
142
|
+
try {
|
|
143
|
+
data = JSON.parse(text);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
data = text;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (res.ok)
|
|
150
|
+
return data;
|
|
151
|
+
let rawMsg;
|
|
152
|
+
if (data && typeof data === "object") {
|
|
153
|
+
rawMsg = data.message ?? data.error;
|
|
154
|
+
}
|
|
155
|
+
rawMsg = rawMsg ?? res.statusText ?? `HTTP ${res.status}`;
|
|
156
|
+
const message = Array.isArray(rawMsg) ? rawMsg.join(", ") : String(rawMsg);
|
|
157
|
+
throw httpError(res.status, message);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function parseSseEvent(chunk) {
|
|
161
|
+
let type = "message";
|
|
162
|
+
const dataLines = [];
|
|
163
|
+
for (const line of chunk.split("\n")) {
|
|
164
|
+
if (line.startsWith(":"))
|
|
165
|
+
continue; // comment
|
|
166
|
+
if (line.startsWith("event:"))
|
|
167
|
+
type = line.slice(6).trim();
|
|
168
|
+
else if (line.startsWith("data:"))
|
|
169
|
+
dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
170
|
+
}
|
|
171
|
+
if (dataLines.length === 0)
|
|
172
|
+
return null;
|
|
173
|
+
return { type, data: dataLines.join("\n") };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* 클라이언트 구성. base URL 은 빌드 시 baked 된 고정값 (변경 불가).
|
|
177
|
+
* 토큰만 --token/ALEXX_TOKEN 으로 주입 가능 (access only, refresh 불가), 없으면 저장 토큰.
|
|
178
|
+
*/
|
|
179
|
+
export function makeClient(globalOpts) {
|
|
180
|
+
const baseUrl = resolveBaseUrl();
|
|
181
|
+
const envToken = globalOpts.token || process.env.ALEXX_TOKEN;
|
|
182
|
+
if (envToken) {
|
|
183
|
+
return new ApiClient({ baseUrl, token: envToken, useStore: false });
|
|
184
|
+
}
|
|
185
|
+
const cfg = loadConfig();
|
|
186
|
+
return new ApiClient({
|
|
187
|
+
baseUrl,
|
|
188
|
+
token: cfg.access_token,
|
|
189
|
+
refreshToken: cfg.refresh_token,
|
|
190
|
+
useStore: true,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
export function clientFromCommand(cmd) {
|
|
194
|
+
const g = cmd.optsWithGlobals();
|
|
195
|
+
return makeClient({ token: g.token });
|
|
196
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// api: escape hatch — 임의 /api 엔드포인트를 인증·refresh 적용해 직접 호출.
|
|
2
|
+
// CLI 가 감싸지 않은 엔드포인트(split, detect-boundaries, speakers, contacts, shares,
|
|
3
|
+
// tier, note/template CRUD 등)를 에이전트가 그대로 사용할 수 있게 한다.
|
|
4
|
+
import { clientFromCommand } from "../client.js";
|
|
5
|
+
import { AppError, printJson, handler } from "../output.js";
|
|
6
|
+
import { parseDataOption } from "../data.js";
|
|
7
|
+
const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]);
|
|
8
|
+
export function registerApi(program) {
|
|
9
|
+
program
|
|
10
|
+
.command("api <method> <path>")
|
|
11
|
+
.description("Call any /api endpoint directly (e.g. alexx api POST /recs/<id>/split --data @body.json)")
|
|
12
|
+
.option("--data <json>", "raw JSON body (@file / @- stdin)")
|
|
13
|
+
.option("-q, --query <kv...>", "query parameter k=v (repeatable)")
|
|
14
|
+
.action(handler(async (method, path, opts, command) => {
|
|
15
|
+
const m = method.toUpperCase();
|
|
16
|
+
if (!METHODS.has(m)) {
|
|
17
|
+
throw new AppError("usage", `Unsupported method: ${method}`, {
|
|
18
|
+
hint: `Use one of: ${[...METHODS].join(", ")}.`,
|
|
19
|
+
exitCode: 2,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const query = {};
|
|
23
|
+
for (const kv of opts.query ?? []) {
|
|
24
|
+
const i = kv.indexOf("=");
|
|
25
|
+
if (i === -1) {
|
|
26
|
+
throw new AppError("usage", `--query must be in k=v form: ${kv}`, {
|
|
27
|
+
exitCode: 2,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const k = kv.slice(0, i);
|
|
31
|
+
const v = kv.slice(i + 1);
|
|
32
|
+
// 중복 키는 덮어쓰지 않고 콤마로 결합 — 서버가 ?ids=a,b,c 콤마 리스트를 지원하므로
|
|
33
|
+
// `-q ids=a -q ids=b` 가 ids=a,b 로 합쳐진다 (silent 손실 방지).
|
|
34
|
+
query[k] = k in query ? `${query[k]},${v}` : v;
|
|
35
|
+
}
|
|
36
|
+
const client = clientFromCommand(command);
|
|
37
|
+
const res = await client.request(m, path, {
|
|
38
|
+
query,
|
|
39
|
+
body: parseDataOption(opts.data),
|
|
40
|
+
});
|
|
41
|
+
printJson(res ?? null);
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// auth: login / logout / whoami
|
|
2
|
+
import { ApiClient, clientFromCommand } from "../client.js";
|
|
3
|
+
import { resolveBaseUrl, saveConfig, clearConfig } from "../config.js";
|
|
4
|
+
import { loginViaLoopback, loginViaCode, canOpenLocalBrowser, } from "../cli-login.js";
|
|
5
|
+
import { AppError, printJson, handler } from "../output.js";
|
|
6
|
+
/**
|
|
7
|
+
* 브라우저 위임 로그인 진입점. loopback(로컬 브라우저) 우선, 불가하면 코드 붙여넣기로 폴백.
|
|
8
|
+
* --code : 코드 흐름 강제 (SSH/헤드리스에서 가장 확실).
|
|
9
|
+
* --no-browser : 자동 실행만 끄고 URL 출력 후 loopback 대기 (같은 머신에서 직접 열기).
|
|
10
|
+
* 비대화형(no TTY) : 브라우저·붙여넣기 둘 다 불가 → ALEXX_TOKEN 안내하며 종료.
|
|
11
|
+
* 원격/헤드리스 감지: 코드 흐름으로 자동 전환 (loopback 콜백이 안 닿으므로).
|
|
12
|
+
* 그 외(로컬) : loopback 시도, 브라우저를 못 띄우면 코드 흐름으로 폴백.
|
|
13
|
+
*/
|
|
14
|
+
async function browserLogin(opts) {
|
|
15
|
+
if (opts.code)
|
|
16
|
+
return loginViaCode();
|
|
17
|
+
if (!process.stdin.isTTY) {
|
|
18
|
+
throw new AppError("no_tty", "No interactive terminal for browser login.", {
|
|
19
|
+
hint: "Set ALEXX_TOKEN=<token> for headless use, or run `alexx auth login` in an interactive terminal.",
|
|
20
|
+
exitCode: 3,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
// --no-browser: 사용자가 같은 머신에서 URL 을 직접 연다 → 자동 실행만 끄고 loopback 대기.
|
|
24
|
+
if (opts.browser === false)
|
|
25
|
+
return loginViaLoopback(false);
|
|
26
|
+
// 원격(SSH)·헤드리스면 loopback 콜백이 안 닿으므로 코드 흐름으로 자동 전환.
|
|
27
|
+
if (!canOpenLocalBrowser()) {
|
|
28
|
+
process.stderr.write("No local browser available (remote/headless session) — using paste-code login.\n");
|
|
29
|
+
return loginViaCode();
|
|
30
|
+
}
|
|
31
|
+
// 로컬: loopback 시도. 브라우저를 못 띄우면 코드 흐름으로 폴백.
|
|
32
|
+
try {
|
|
33
|
+
return await loginViaLoopback(true);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (err?.code === "browser_open_failed") {
|
|
37
|
+
process.stderr.write("Couldn't open a browser — switching to paste-code login.\n");
|
|
38
|
+
return loginViaCode();
|
|
39
|
+
}
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function registerAuth(program) {
|
|
44
|
+
const auth = program.command("auth").description("Authentication and token management");
|
|
45
|
+
auth
|
|
46
|
+
.command("login")
|
|
47
|
+
.description("Sign in via the browser (captcha + Google + email/password) and store tokens.\n" +
|
|
48
|
+
"For headless/CI use, set ALEXX_TOKEN=<token> instead.")
|
|
49
|
+
.option("--code", "paste-code flow instead of a local callback (works over SSH/headless)")
|
|
50
|
+
.option("--no-browser", "don't auto-open a browser; print the URL and wait (same machine)")
|
|
51
|
+
.action(handler(async (options) => {
|
|
52
|
+
// 브라우저 위임 로그인만 지원 — captcha·Google·이메일/비번 모두 브라우저에서 처리한다.
|
|
53
|
+
// 비밀번호를 CLI 인자/env 로 직접 받지 않는다(argv/ps 노출·captcha 우회 방지). loopback
|
|
54
|
+
// 우선, 원격/헤드리스/실패 시 코드 붙여넣기로 폴백. 헤드리스/CI 는 ALEXX_TOKEN 을 쓴다.
|
|
55
|
+
const tokens = await browserLogin(options);
|
|
56
|
+
// 토큰만 받았으므로 프로필을 조회해 저장/출력에 채운다.
|
|
57
|
+
const client = new ApiClient({
|
|
58
|
+
baseUrl: resolveBaseUrl(),
|
|
59
|
+
token: tokens.access_token,
|
|
60
|
+
useStore: false,
|
|
61
|
+
});
|
|
62
|
+
const user = await client.request("GET", "/auth/me");
|
|
63
|
+
saveConfig({
|
|
64
|
+
access_token: tokens.access_token,
|
|
65
|
+
refresh_token: tokens.refresh_token,
|
|
66
|
+
user,
|
|
67
|
+
});
|
|
68
|
+
printJson({ ok: true, user });
|
|
69
|
+
}));
|
|
70
|
+
auth
|
|
71
|
+
.command("logout")
|
|
72
|
+
.description("Revoke the server session and delete stored tokens")
|
|
73
|
+
.action(handler(async (_options, command) => {
|
|
74
|
+
// 서버측 세션을 먼저 폐기(베스트에포트) — 실패해도 로컬 토큰은 지운다.
|
|
75
|
+
try {
|
|
76
|
+
const client = clientFromCommand(command);
|
|
77
|
+
await client.request("POST", "/auth/logout", {});
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* 토큰 만료/네트워크 실패 등 — 무시 */
|
|
81
|
+
}
|
|
82
|
+
clearConfig();
|
|
83
|
+
printJson({ ok: true });
|
|
84
|
+
}));
|
|
85
|
+
auth
|
|
86
|
+
.command("whoami")
|
|
87
|
+
.description("Show the current signed-in user (GET /auth/me)")
|
|
88
|
+
.action(handler(async (_options, command) => {
|
|
89
|
+
const client = clientFromCommand(command);
|
|
90
|
+
printJson(await client.request("GET", "/auth/me"));
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// meta: 읽기 전용 보조 — templates / tags
|
|
2
|
+
// (모델 선택은 CLI 에서 제외 — admin 기본 모델만 사용. models 명령·--*-model 플래그 없음)
|
|
3
|
+
import { clientFromCommand } from "../client.js";
|
|
4
|
+
import { printJson, handler } from "../output.js";
|
|
5
|
+
export function registerMeta(program) {
|
|
6
|
+
program
|
|
7
|
+
.command("templates")
|
|
8
|
+
.description("List summary templates (system + your own)")
|
|
9
|
+
.action(handler(async (_opts, command) => {
|
|
10
|
+
const client = clientFromCommand(command);
|
|
11
|
+
printJson(await client.request("GET", "/summary-templates"));
|
|
12
|
+
}));
|
|
13
|
+
program
|
|
14
|
+
.command("tags")
|
|
15
|
+
.description("List tags")
|
|
16
|
+
.action(handler(async (_opts, command) => {
|
|
17
|
+
const client = clientFromCommand(command);
|
|
18
|
+
printJson(await client.request("GET", "/tags"));
|
|
19
|
+
}));
|
|
20
|
+
}
|