xkat-cli 2.0.17 → 2.7.3

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/CHANGELOG.md ADDED
@@ -0,0 +1,311 @@
1
+ # Changelog
2
+
3
+ ## 2.7.3
4
+
5
+ - **패키지를 올려도 옛 바이너리를 계속 쓰던 문제.** 바로 아래 npm 12 대응으로
6
+ 바이너리를 `~/.xkat/` 에 받게 되면서 생긴 구멍이다. 다운로드 주소는 버전별인데
7
+ 받아 둔 파일에는 버전이 적혀 있지 않았고, npm 12 에서는 postinstall 이 갱신해
8
+ 주던 패키지 안 `binaries/` 가 아예 없다. 그래서 `npm i -g xkat-cli@latest` 로
9
+ 래퍼만 새것이 되고 **실행은 예전 바이너리로 계속됐다.**
10
+
11
+ 받을 때 `~/.xkat/xkat-cli.version` 에 버전을 같이 적는다. 래퍼는 자기 버전과
12
+ 다르면 새로 받는다. 소스 트리 빌드나 손으로 놓은 바이너리는 건드리지 않는다.
13
+
14
+ - **새 버전이 나온 걸 알려준다.** 지금은 배포가 잦아서, 몇 주 전 CLI 를 쓰는
15
+ 학습자가 이미 고쳐진 버그를 만나고도 알 길이 없었다. 하루에 한 번, 터미널일
16
+ 때만, 1.5초 안에 대답이 없으면 포기하고, 실패는 전부 조용히 넘어간다. 오프라인
17
+ 학습자에게는 아무것도 보이지 않아야 하고 이 확인 때문에 명령이 느려지면 안 된다.
18
+
19
+ ```
20
+ ⬆️ A newer xkat-cli is available: 2.7.3 → 2.8.0
21
+ Update with: npm install -g xkat-cli@latest
22
+ ```
23
+
24
+ `XKAT_NO_UPDATE_CHECK=1` 로 끌 수 있다.
25
+
26
+ - **첫 실행이 오래 걸린다는 것을 웹이 말해준다.** 아래 변경으로 다운로드가 설치
27
+ 시점에서 `xkat-cli up` 안으로 옮겨졌다. 환경 설정 모달 1단계는 "연결 대기" 만
28
+ 보여주고 있어서, 10~30초 동안 먹통과 구분되지 않았다.
29
+
30
+ - **npm 12 에서 설치해도 바이너리가 오지 않던 문제.** npm 12 는 의존성의
31
+ lifecycle 스크립트를 기본으로 끈다. 이 패키지는 `postinstall` 에서 바이너리를
32
+ 내려받으므로, `npm i -g xkat-cli` 를 해도 래퍼만 깔리고 실행하면
33
+ "binary not found" 가 뜬다. 안내대로 재설치해도 같은 결과라 빠져나갈 수 없다.
34
+ 실제 npm 12.0.2 로 재현했다:
35
+
36
+ ```
37
+ npm warn install-scripts 1 package had install scripts blocked because they
38
+ are not covered by allowScripts: xkat-cli@2.7.2 (postinstall: …)
39
+ ```
40
+
41
+ 래퍼가 바이너리를 못 찾으면 **첫 실행 때 직접 내려받는다.** 받는 곳은
42
+ `~/.xkat/` 다 — 전역 설치는 사용자가 쓸 수 없는 위치에 있을 수 있지만 홈은
43
+ 언제나 쓸 수 있고, 래퍼가 이미 그 경로를 후보로 보고 있었다.
44
+
45
+ 검증은 그대로다. 해시를 확인한 뒤에야 임시 파일을 최종 이름으로 옮기므로,
46
+ 중단된 다운로드가 쓸 수 있는 바이너리로 남지 않는다. `postinstall` 도 그대로
47
+ 두어, 스크립트를 실행하는 패키지 매니저에서는 첫 실행이 즉시 시작된다.
48
+
49
+ ## 2.7.1
50
+
51
+ - **"가상 머신이 돌고 있나" 를 두 곳이 다르게 답하던 문제.** 에이전트가
52
+ `pgrep -f qemu-system` 으로 직접 세고 있었는데, 그 검사를 감싼 `sh -c` 의
53
+ 명령줄에 같은 문자열이 들어 있어 늘 "돌고 있다" 가 나왔다. 스크립트의
54
+ `xkat-lab-vm status` 와 답이 갈렸고, 갈린 줄도 몰랐다.
55
+
56
+ 판정은 스크립트 하나가 갖는다. `xkat-lab-vm status --porcelain` 이
57
+ `running=` · `installed=` · `media=` 를 내보내고 에이전트는 그것을 읽는다.
58
+ 밖에서 흉내내지 않으면 갈릴 수 없다.
59
+
60
+ - **승격 실행이 남긴 root 소유 가상 디스크.** KVM 을 쓰려고 qemu 를 승격해서
61
+ 띄우면 그 프로세스가 만진 파일이 root 소유로 남을 수 있다. 학습자가 나중에
62
+ 그 파일을 다룰 때 막히므로, 시작 전에 쓰기 권한을 확인하고 되돌린다.
63
+
64
+ ## 2.7.0
65
+
66
+ - **`xkat-lab-vm reinstall` 이 초기화만 하고 부팅하지 않는다.** 부팅은 강의가
67
+ 가르치는 단계다(1강 끝의 `xkat-lab-vm start`). 여기서 켜 버리면 학습자가 그
68
+ 단계에 닿기도 전에 설치 화면이 떠 있고, 그 뒤로 가이드와 화면이 어긋난 채
69
+ 진행된다. 초기화까지만 하고 순서는 강의에 돌려준다.
70
+
71
+ - **가속을 쓸 수 있는데도 소프트웨어 에뮬레이션으로 돌던 문제.** 호스트에
72
+ `/dev/kvm` 이 있고 접근 권한도 있는데 랩은 TCG 로 부팅하고 있었다. rootless
73
+ podman 에서 컨테이너의 `ubuntu` 는 호스트의 subuid 로 매핑되므로, 호스트가
74
+ 자기 사용자에게만 열어 둔 장치에 닿지 못한다(컨테이너 root 는 호스트 사용자
75
+ 자신이라 접근된다). 접근이 막혔을 때 **qemu 프로세스만** 승격해서 띄운다.
76
+ 디스크는 이미 학습자 소유로 만들어져 있으므로 파일 소유권은 그대로다.
77
+
78
+ 데스크톱 설치에서 이 차이는 몇 분과 십수 분의 차이다. 학습자에게 느린 부팅은
79
+ "먹통"과 구분되지 않는다.
80
+
81
+ - `xkat-lab-vm start` 가 부팅 중 화면이 검은 것이 정상이라고 알린다.
82
+
83
+ ## 2.6.0
84
+
85
+ - **가상 머신 제어를 `lab_vm` 한 곳으로.** `POST /lab/vm { profile, action }` 로
86
+ start·stop·reinstall 을 지시한다. 어느 컨테이너에서 무엇을 실행할지는 이
87
+ 모듈만 알고, 밖에서는 부르기만 한다. **언제나 그 프로필의 컨테이너 하나만**
88
+ 건드린다 — 랩이 여럿 떠 있을 때 학습자가 보고 있지도 않은 강의의 가상 머신을
89
+ 멈추는 일이 없어야 한다.
90
+
91
+ - **`xkat-cli down` 이 랩 가상 머신도 멈춘다.** QEMU 는 `setsid` 로 분리돼 있어
92
+ 아무도 멈추라고 하지 않으면 계속 돌았다. 브라우저를 닫아도 4GB RAM 과 2 vCPU
93
+ 가 물려 있던 이유다. 에이전트를 내린다는 것은 이 컴퓨터에서 실습을 멈춘다는
94
+ 뜻이므로 함께 멈춘다. 컨테이너는 그대로 두므로 작업물은 보존된다.
95
+
96
+ ## 2.5.0
97
+
98
+ - **가상 머신이 무엇을 하고 있는지 밖에서 물을 수 있다.** `/lab/status` 가
99
+ `vm: { installed, media, running }` 을 보고한다. 지금까지 이 상태는 컨테이너
100
+ 안의 마커 파일에만 있었고, 그래서 가이드와 QEMU 는 서로를 모른 채 각자
101
+ 진행했다 — 설치 강의를 열었는데 이미 설치를 마친 디스크로 부팅되면 학습자는
102
+ 로그인 화면 앞에서 설치 설명을 읽었다. 강의가 통째로 무의미해지는데 아무도
103
+ 알아차릴 수 없었다. 새 모듈 `src/lab_vm.rs` 가 이 상태를 소유한다.
104
+
105
+ - **`xkat-lab-vm start` 가 무엇으로 부팅하는지 말한다.** 설치된 디스크인지
106
+ 설치 이미지인지, 그리고 이미 실행 중이면 지금 화면이 그 세션이라는 것까지.
107
+ 예전에는 조용히 골라서 학습자가 화면과 강의가 다른 이유를 알 수 없었다.
108
+
109
+ - **`xkat-lab-vm reinstall` 추가.** 마커와 가상 디스크를 지우고 설치 이미지로
110
+ 다시 부팅한다. 파괴적이라 자동으로는 절대 하지 않는다 — 학습자가 명시적으로
111
+ 고른 경우에만 실행된다. `status` 도 설치 여부와 이미지 유무를 함께 보고한다.
112
+
113
+ ## 2.4.0
114
+
115
+ - **실습 프로젝트 폴더를 플랫폼이 만든다.** 강의마다 규약을 정하게 두었더니
116
+ `~/rpi-lab`, `~/fedora-lab`, `~/xkat-practice/…`, 그리고 아예 만들지 않고
117
+ 작업 공간 루트에 파일을 떨어뜨리던 설치 강의까지 네 갈래로 갈렸다.
118
+
119
+ 프로필이 `workdir` 로 이름을 정하고(`ubuntu_qemu` → `ubuntu-lab`), 컨테이너를
120
+ 켤 때 플랫폼이 만든다. 지정하지 않으면 프로필 이름에서 만들어 내고, `""` 면
121
+ 만들지 않는다 — 루트에서 작업하는 강의를 위한 옵션이고, 터미널 전용
122
+ 프로필(`std-bash-*`)이 그렇게 두었다.
123
+
124
+ 학습자의 터미널은 프로젝트 폴더 안이 아니라 **작업 공간 루트(`~/lab`)** 에서
125
+ 시작한다. 폴더를 만들고 들어가는 것은 강의가 가르칠 내용이라, 이미 그 안에서
126
+ 시작하면 `mkdir` 도 `cd` 도 시늉이 되어 학습자가 자기가 무엇을 했는지 알 수
127
+ 없다. 폴더 자체는 플랫폼이 만들어 두므로 강의가 그 단계를 어떻게 쓰든
128
+ `xkat-lab-vm` 은 설치 이미지를 찾을 수 있다.
129
+
130
+ 경로·사용자·소유권·폴더 규약이 새 모듈 `src/lab_workspace.rs` 한 곳에 모였다.
131
+ `xkat-lab-vm` 도 `LAB_DIR` 을 이 모듈이 쓴 설정에서 받는다.
132
+
133
+ - **설치 이미지는 프로젝트 폴더에서만 찾는다.** `xkat-lab-vm` 은
134
+ `/opt/xkat/iso`(공유 캐시)와 `$LAB_DIR/*/<파일명>`(프로젝트 폴더) 두 곳만
135
+ 본다. 작업 공간 루트는 더 이상 보지 않는다 — 예전 안내로 이미 받아 둔 파일
136
+ 하나를 위해 예외를 남기면 규칙이 선택 사항이 되고, 그러면 설치 이미지가
137
+ 어디 있는지 아무도 말할 수 없게 된다. 옛 위치에 파일이 있다면 프로젝트
138
+ 폴더로 옮기면 된다.
139
+
140
+ - **학습자가 자기 작업 폴더에 쓸 수 없던 문제.** 마운트에 붙어 있던 podman `U`
141
+ 플래그가 컨테이너를 켤 때마다 볼륨을 컨테이너 사용자(root)로 재귀 chown 해서,
142
+ 생성 시점에 맞춰 둔 소유권을 매번 되돌리고 있었다. 학습자 대면 명령은 전부
143
+ `ubuntu` 로 실행되므로 `mkdir` 조차 막혔고, 15GB 트리를 부팅마다 훑는 비용도
144
+ 함께 치렀다. `U` 를 떼고, 소유자가 실제로 어긋났을 때만 한 번 바로잡는다.
145
+
146
+ ## 2.3.0
147
+
148
+ - **랩 호스트 포트를 컨테이너마다 배정한다.** 지금까지 모든 랩이 호스트의
149
+ `6080`(noVNC)과 `5022`(SSH)를 고정으로 공개했다. 두 컨테이너가 동시에 뜨면
150
+ 바인드에 실패하므로 애초에 동시 실행이 불가능했고, 1-Active 정책이 그 한계를
151
+ 가리고 있었다. 이제 생성 시점에 빈 포트를 고른다(`6080~6099` / `5022~5041`,
152
+ 범위의 첫 값을 우선). 랩을 하나만 쓰면 예전과 같은 매핑이다.
153
+
154
+ 포트 번호를 아는 곳은 새 모듈 `src/lab_ports.rs` 하나다. 배정값을 기억하지
155
+ 않고 `container port` 로 **매번 실제 매핑을 읽는다** — 매핑은 컨테이너 생성
156
+ 시점에 확정되므로, 구버전이 만든 컨테이너도 자기 포트를 정확히 보고한다.
157
+
158
+ - **`/lab/status` 가 `vnc_port` · `ssh_port` 를 보고한다.** 프론트엔드가 포트를
159
+ 가정하지 않고 이 값을 쓴다. 구버전 에이전트는 이 필드가 없어 프론트가 6080
160
+ 기본값으로 떨어진다 — 랩이 하나면 그대로 동작하지만, 다중 랩은 이 버전이
161
+ 필요하다.
162
+
163
+ - **`/vnc-proxy` 가 요청된 포트에서 에셋을 가져온다.** 클라이언트가 웹소켓을
164
+ 붙일 `port=` 값을 프록시도 그대로 쓰므로 둘이 어긋날 수 없다. 랩이 공개할 수
165
+ 있는 범위 밖의 포트는 거부한다 — 임의의 로컬 포트를 대신 열어 주지 않는다.
166
+
167
+ - **컨테이너 엔진 표기를 Podman 으로 일원화.** 엔진은 처음부터 podman 하나였고
168
+ (`container_bin()` 은 podman 만 찾고, 자동 설치는 `RedHat.Podman` 을 깐다)
169
+ 문구만 Docker 를 함께 지원하는 것처럼 말하고 있었다. Windows 셸 PATH 에
170
+ Docker Desktop 경로를 넣던 줄과 `xkat-cli lab docker …` 인자 흡수를 제거했다.
171
+
172
+ ## 2.2.11
173
+
174
+ - **`/vnc-proxy` 가 자기 자신의 요청을 거부하던 문제.** noVNC 의 `vnc.html` 은
175
+ 자기 코드(`app/ui.js`)를 ES 모듈로 불러오는데, 모듈 스크립트는 항상 CORS 모드라
176
+ 브라우저가 iframe 문서의 오리진(= 이 에이전트)을 실어 보낸다. 그 오리진이
177
+ 허용 목록에 없어 `origin_guard` 가 403 을 돌려주었고, noVNC 는 자기 코드를
178
+ 못 받아 데스크톱이 끝내 연결되지 않았다.
179
+
180
+ 에이전트가 실제로 바인드한 포트를 기록해 허용 목록에 자기 오리진을 넣는다.
181
+ CORS 레이어도 목록을 고정하지 않고 요청마다 판단하도록 바꿨다 — 라우터를
182
+ 만드는 시점에는 포트(38000..=38050 자동 이동)가 아직 정해지지 않는다.
183
+
184
+ ## 2.2.10
185
+
186
+ - **라즈베리파이 랩이 ARM64 컨테이너를 요구하지 않는다.** `profiles.json` 의
187
+ `raspberry_pi` 에서 `platform: linux/arm64` 와 arm64v8 이미지를 걷어냈다. 이
188
+ 강의는 x86 컨테이너 안에 ARM 에뮬레이션 툴체인을 깔고 QEMU 로 라즈베리파이 OS
189
+ 이미지를 부팅하는 방식이라, binfmt 등록이 없어도 동작해야 한다.
190
+
191
+ 2.2.9 이하 바이너리는 옛 정의를 갖고 있어 `Profile 'raspberry_pi' needs
192
+ linux/arm64 emulation` 으로 실패한다. **프로필 정의는 바이너리에 컴파일 타임에
193
+ 박히므로, profiles.json 을 고치면 반드시 버전을 올려야 한다** — 그러지 않으면
194
+ 구버전이 도는지 알 방법이 없다.
195
+
196
+ ## Unreleased — 2.2.x
197
+
198
+ > **The npm registry still serves 2.0.17** (latest published; the newest git tag
199
+ > is `agent-v2.0.9`, so 2.0.10–2.0.17 were published outside the tag workflow).
200
+ > The whole 2.1.x/2.2.x line is unreleased: the release workflow had been broken
201
+ > since the `xkat-agent` → `xkat-cli` rename. It is fixed but not yet exercised —
202
+ > see `.github/workflows/release-agent.yml`.
203
+
204
+ ### Behavior changes
205
+
206
+ These differ from 2.0.17 and are worth knowing before upgrading.
207
+
208
+ - **`down` no longer kills everything on ports 38000..38050.** It signals only
209
+ processes it can verify are xkat agents; anything else holding a port in the
210
+ range is left running and reported. Previously `fuser -k` (and an equivalent
211
+ PowerShell pipeline) took unrelated development servers down with it.
212
+ - **An unrecognised command no longer starts the agent.** Starting requires a
213
+ bare `xkat-cli`, `up`, or `start`; anything else prints help and exits 1. Before,
214
+ `xkat-cli --version` or a typo silently launched a daemon.
215
+ - **`lab reset` and `lab delete` keep learner data.** Data lives in a host volume
216
+ at `~/.xkat/labs/<profile>`, so rebuilding a container no longer destroys an
217
+ installed system. Use `lab purge <profile>` to remove data as well.
218
+ - **Lab containers are named per profile, and aliases are normalized.** `ubuntu`
219
+ and `ubuntu_qemu` resolve to the same container instead of provisioning two.
220
+ - **`/lab/exec` has no host fallback.** A lecture command that cannot reach its
221
+ container now fails with a clear error instead of running on the host machine.
222
+ - **File watching is limited to the practice folder** (`xkat-practice`, override
223
+ with `XKAT_WATCH_DIR`). The agent used to watch its whole working directory and
224
+ stream absolute paths to the browser.
225
+ - **Install media is never downloaded during provisioning.** Creating a lab used
226
+ to block on a multi-GB fetch. Lectures walk the learner through the download;
227
+ `lab media <profile>` pre-stages it for classroom or offline use.
228
+
229
+ ### Added
230
+
231
+ - `xkat-cli status --json` and `xkat-cli lab status --json` — machine-readable
232
+ status for the desktop app and scripts. The desktop status bar used to grep the
233
+ human summary for words like "active", which silently stopped matching when the
234
+ wording changed.
235
+
236
+ - `xkat-cli token`, `lab media`, `lab purge`, `version` / `--version` / `-V`.
237
+ - `profiles.json` — the single source of truth for how a lab is provisioned
238
+ (base image, CPU platform, devices, VM definition, install media). Adding an OS
239
+ is a config entry, not a code change.
240
+ - `xkat-lab-vm` control script, installed into VM-backed lab containers:
241
+ `start`, `boot-disk`, `stop`, `status`. Detects KVM and falls back to software
242
+ emulation, boots from install media until the lab is marked installed and from
243
+ disk afterwards, and detaches qemu/websockify so they outlive the exec session.
244
+ - Opt-in pairing token (`XKAT_REQUIRE_TOKEN=1`), stored owner-readable only at
245
+ `~/.xkat/token`.
246
+ - Foreign-architecture labs pass `--platform`, with a preflight that reports
247
+ missing binfmt handlers instead of failing later with "exec format error".
248
+ - Install-media download progress reported through `/lab/status`.
249
+
250
+ ### Fixed
251
+
252
+ - **Release pipeline.** Artifact names, R2 upload paths and the checksum manifest
253
+ keys disagreed, so tagged releases failed to build and, had they built, the
254
+ client would have requested a name that was never uploaded.
255
+ - **Binary integrity verification never ran.** The manifest was keyed
256
+ `xkat-agent-*` while the installer looked up `xkat-cli-*`, so every platform
257
+ silently skipped the SHA-256 check the README promised. Installation now fails
258
+ rather than installing an unverified binary.
259
+ - **Interrupted downloads were cached as usable files** — both the installer and
260
+ the install-media fetcher now write to a `.part` file and rename only after
261
+ verification.
262
+ - **Package installs could hang forever** on an interactive `debconf` prompt
263
+ (`tzdata` asking for a timezone). Blocked at three levels: the install commands,
264
+ the container's debconf frontend, and the exec session environment.
265
+ - Setup reported success it had not verified: the "verify" step printed messages
266
+ without running anything, and the install step never blocked the pipeline.
267
+ Completion is now gated on an exit-code sentinel and a real probe.
268
+ - Lab profile resolution: the lecture's profile is used instead of a substring
269
+ guess at `techStack`, a DB-joined profile is read by `slug` rather than its
270
+ cuid, and every consumer shares one fallback.
271
+ - The agent identified itself by "any HTTP 200 on the port", which combined with
272
+ the port-range kill above. It now serves and checks an explicit marker.
273
+ - `agent.json` is parsed as JSON (a hand-rolled parser corrupted Windows paths),
274
+ the recorded port is the one actually bound, and `agent.log` rotates at 5MB.
275
+ - A VNC proxy module that listened on `0.0.0.0:8444` and forwarded to itself.
276
+
277
+ ### Security
278
+
279
+ - REST endpoints enforce the same origin allowlist as the WebSocket, and CORS is
280
+ no longer permissive (`XKAT_ALLOWED_ORIGINS` extends it).
281
+ - Session modes are parsed explicitly (`lab:<profile>`, `host`,
282
+ `ssh:<user>:<password>`). An unparseable token is refused; it used to fall
283
+ through to a shell on the user's machine.
284
+ - Host-shell grants are logged with their origin.
285
+ - The VNC server binds `127.0.0.1` only, and the KasmVNC client URL no longer
286
+ carries hardcoded credentials.
287
+
288
+ ### Desktop app
289
+
290
+ - `apps/desktop` looked for a binary named `xkat-agent` or `xkat` and so never
291
+ found the renamed CLI. Where a pre-rename leftover existed (`~/.cargo/bin`, an
292
+ old `target/`) it ran that instead — a build without any of the fixes above.
293
+ It now resolves `xkat-cli` only, prefers the more recently built monorepo
294
+ profile, and reports a leftover instead of running it.
295
+ - The `Tools` menu gained `CLI: Version` and `Agent: Pairing Token`, and the
296
+ lab entries say which ones keep the learner's saved work.
297
+ - **The desktop app starts this agent on launch** — it checks first and only
298
+ starts one when none is serving, so an agent you already had running (and any
299
+ terminal attached to it) is left alone.
300
+ - **The desktop app now ships this agent.** It is bundled as a Tauri sidecar, so
301
+ installing the app is enough — no separate `npm install -g xkat-cli` — and the
302
+ bundled copy outranks whatever is on `PATH`.
303
+
304
+ ### Desktop labs
305
+
306
+ - The guest display follows the panel: QEMU runs with a virtio display, which
307
+ accepts client-initiated resize, and the viewer uses noVNC's `resize=remote`.
308
+ Previously a fixed framebuffer was letterboxed inside a widescreen panel.
309
+ - noVNC's own floating toolbar is hidden (`xkat_controls=1` restores it), and the
310
+ proxy signals the player when the canvas is live instead of leaving the panel
311
+ to wait out a fixed timeout.
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
- # xkat Agent
1
+ # xkat-cli
2
2
 
3
- xkat Agent is an AI agent for your machine. It securely connects your browser
3
+ xkat-cli is an AI agent for your machine. It securely connects your browser
4
4
  to your local environment, turning the power of your computer into capabilities
5
5
  the web experience can use.
6
6
 
7
7
  ## Features
8
8
 
9
9
  - **Zero-install local shell bridge**: Connects the web IDE terminal in your
10
- browser directly to your local shell. A single `npx xkat up` spawns a native
10
+ browser directly to your local shell. A single `npx xkat-cli up` spawns a native
11
11
  shell (PTY) with your current user permissions — no extra install or setup.
12
12
  - **Extensible agent**: Designed to grow beyond the terminal, bridging more
13
13
  local capabilities over the same secure channel over time.
@@ -21,18 +21,38 @@ the web experience can use.
21
21
  Run instantly via npm without a separate install:
22
22
 
23
23
  ```bash
24
- npx xkat up
24
+ npx xkat-cli up
25
25
  ```
26
26
 
27
27
  `up` starts the agent in the background and returns immediately, so your
28
28
  terminal stays free. Manage it with:
29
29
 
30
30
  ```bash
31
- xkat up # start the agent in the background (alias: start)
32
- xkat down # stop the background agent (alias: stop)
33
- xkat status # show whether the agent is running
31
+ xkat-cli up # start the agent in the background (alias: start)
32
+ xkat-cli down # stop the background agent (alias: stop)
33
+ xkat-cli status # show whether the agent is running
34
+ xkat-cli logs # tail the background agent log
35
+ xkat-cli token # show the pairing token (see Security guidelines)
36
+ xkat-cli version # print the CLI version
34
37
  ```
35
38
 
39
+ `down` stops only processes it can verify are xkat agents. Anything else holding
40
+ a port in 38000..38050 is left running and reported.
41
+
42
+ ### Environment variables
43
+
44
+ | Variable | Effect |
45
+ | :--- | :--- |
46
+ | `XKAT_REQUIRE_TOKEN=1` | Require the pairing token for every session (see Security guidelines). |
47
+ | `XKAT_ALLOWED_ORIGINS` | Comma-separated extra origins allowed to reach the agent. |
48
+ | `XKAT_WATCH_DIR` | Directory mirrored to the browser editor. Defaults to `xkat-practice`. |
49
+ | `XKAT_UBUNTU_ISO_URL` | Mirror for the Ubuntu install image. |
50
+ | `XKAT_OPENSUSE_ISO_URL` | Mirror for the openSUSE install image. |
51
+
52
+ Each VM profile declares its own mirror variable as `vm.media.urlEnv` in
53
+ `profiles.json`; the defaults point at a Korean mirror because that is where the
54
+ audience is.
55
+
36
56
  ### Lab (lecture practice environment)
37
57
 
38
58
  Some courses (Linux, networking, agent tools) run inside an isolated **Lab**
@@ -40,14 +60,16 @@ so the learner’s host OS stays consistent and safer. Learners only use these
40
60
  commands — they do not need to know about hypervisors or VMs:
41
61
 
42
62
  ```bash
43
- xkat lab install # one-time: prepare Lab tools (may ask for admin)
44
- xkat lab up # start Lab
45
- xkat lab shell # open a shell inside Lab (starts Lab if needed)
46
- xkat lab stop # stop Lab (keeps data)
47
- xkat lab delete # remove Lab completely (frees disk; no leftovers)
48
- xkat lab status # is Lab ready?
49
- xkat lab doctor # setup checks + fix hints
50
- xkat lab reset # wipe and recreate Lab
63
+ xkat-cli lab install # one-time: prepare Lab tools (may ask for admin)
64
+ xkat-cli lab up <profile> # start Lab
65
+ xkat-cli lab shell <profile> # open a shell inside Lab (starts Lab if needed)
66
+ xkat-cli lab stop # stop every Lab (keeps data)
67
+ xkat-cli lab delete # remove all Lab containers (saved work is kept)
68
+ xkat-cli lab status <profile> # is Lab ready? (alias: doctor)
69
+ xkat-cli lab reset <profile> # rebuild this Lab (saved work is kept)
70
+ xkat-cli lab purge <profile> # delete this Lab AND its saved work (cannot be undone)
71
+ xkat-cli lab media <profile> # pre-download a VM lab's install image (optional;
72
+ # lectures normally download it themselves)
51
73
  ```
52
74
 
53
75
  `install` is explicit so the learner consents to system setup once.
@@ -61,40 +83,145 @@ Or install globally:
61
83
 
62
84
  ```bash
63
85
  npm install -g xkat-cli
64
- xkat up
86
+ xkat-cli up
87
+ ```
88
+
89
+ ### Lab profiles
90
+
91
+ How a lab is provisioned — base image, CPU platform, devices, and the optional
92
+ QEMU virtual machine with its install media — is declared in
93
+ [`profiles.json`](./profiles.json). Adding an OS means adding an entry there, not
94
+ changing Rust. Lectures select one with the `labProfile` attribute
95
+ (see [the lecture spec](../../lectures/xkat-lecture-spec.md)).
96
+
97
+ A profile with a `vm` block gets the `xkat-lab-vm` control script installed into
98
+ its container:
99
+
100
+ ```bash
101
+ xkat-lab-vm start # boot; says whether it booted the install
102
+ # image or the system you already installed
103
+ xkat-lab-vm reinstall # wipe the install marker and virtual disk so
104
+ # the lecture can install from scratch.
105
+ # Does not boot — the lecture teaches that
106
+ xkat-lab-vm boot-disk # detach the install media and boot from disk
107
+ xkat-lab-vm stop
108
+ xkat-lab-vm status [--porcelain] # running / installed / install image present
65
109
  ```
66
110
 
111
+ The script owns that judgement: the agent reads `status --porcelain` rather than
112
+ guessing from process lists.
113
+
114
+ It uses KVM whenever it can. Under rootless podman the container's `ubuntu` user
115
+ maps to a host subuid that cannot reach `/dev/kvm`, so the qemu process alone is
116
+ elevated to keep hardware acceleration — the difference between a few minutes and
117
+ a quarter of an hour on a desktop install. Only when KVM is genuinely absent does
118
+ it fall back to software emulation, and it says so.
119
+
120
+ qemu and websockify are detached, so they outlive the command that started them
121
+ and a closed terminal never interrupts an install.
122
+
123
+ ### Where lab data lives
124
+
125
+ | Path | Contents |
126
+ | :--- | :--- |
127
+ | `~/.xkat/labs/<profile>/` | Learner data — VM disks, downloaded images, workspace. Mounted at `/home/ubuntu/lab`. Survives `reset` and `delete`. |
128
+ | `~/.xkat/labs/<profile>/<workdir>/` | Per-lecture project folder, created by the platform from the profile's `workdir`. The learner's terminal starts one level above it. |
129
+ | `~/.xkat/iso/` | Shared install-media cache, mounted read-only at `/opt/xkat/iso`. |
130
+ | `~/.xkat/agent.json` | Runtime state (pid, port). Cleared by `down`. |
131
+ | `~/.xkat/token` | Pairing token, owner-readable only. |
132
+ | `~/.xkat/agent.log` | Agent log, rotated at 5MB. |
133
+
134
+ Under rootless podman the lab data belongs to a subuid, so `rm -rf` on it fails.
135
+ Use `xkat-cli lab purge <profile>` to remove a lab together with its data.
136
+
67
137
  ## System requirements
68
138
 
69
139
  - **Node.js**: 16.x or newer.
70
140
 
71
141
  ## Security guidelines
72
142
 
73
- xkat Agent includes the following safeguards:
143
+ xkat-cli includes the following safeguards:
74
144
 
75
- 1. **Localhost only**: Port 10022 is reachable only from localhost.
145
+ 1. **Localhost only**: The agent listens on 38000~38050, and a lab publishes its
146
+ desktop and SSH ports (6080~6099 / 5022~5041, chosen when the container is
147
+ created) — all bound to 127.0.0.1 and reachable only from this machine.
76
148
  2. **Origin verification**: Only connection requests from the official service
77
- domains are accepted.
78
- 3. **Audit log**: Every connection attempt and file-change event is recorded
79
- locally with a timestamp.
80
- 4. **Binary Integrity Check**: During installation, the postinstall script downloads
81
- the platform-specific prebuilt Rust binary over HTTPS and cryptographically
82
- verifies its hash (SHA-256) against a build-time checksum manifest to prevent
83
- MITM, tampering, or supply chain injection.
149
+ domains are accepted (extend with `XKAT_ALLOWED_ORIGINS`).
150
+ 3. **Explicit session modes**: a session must ask for `lab:<profile>`, `host`, or
151
+ `ssh:<user>:<password>`. An unrecognised request is refused rather than
152
+ falling back to a shell on your machine.
153
+ 4. **Optional pairing token** (`XKAT_REQUIRE_TOKEN=1`): the agent then requires a
154
+ token before granting any session. Read it with `xkat-cli token`; it is stored
155
+ at `~/.xkat/token` with owner-only permissions.
156
+
157
+ ```bash
158
+ XKAT_REQUIRE_TOKEN=1 xkat-cli # the browser asks for the token once
159
+ ```
160
+
161
+ **What this does and does not protect.** On a single-user machine a local
162
+ process already runs with your privileges and can read the token file, so the
163
+ token adds little there — it is off by default for that reason. On a shared or
164
+ managed machine it stops *another account's* process from taking a session.
165
+ Requests from a browser are already constrained by the Origin allowlist.
166
+ 5. **Audit log**: Connection attempts are recorded locally with a timestamp in
167
+ `~/.xkat/agent.log`; every host-shell grant is logged with its origin.
168
+ 6. **Binary integrity check**: The platform-specific Rust binary is downloaded
169
+ over HTTPS and verified (SHA-256) against the checksum manifest published
170
+ with this package, to prevent MITM, tampering, or supply-chain injection.
171
+ The download is written to a temporary file and renamed into place only
172
+ after it verifies, so an interrupted download is never left behind as a
173
+ usable binary. A missing manifest entry or a hash mismatch **fails** — an
174
+ unverified binary is never used.
175
+
176
+ The fetch happens at install time when the package manager runs install
177
+ scripts, and **on first run otherwise**. npm 12 turned dependency lifecycle
178
+ scripts off by default; without the first-run path, `npm i -g xkat-cli`
179
+ under npm 12 would leave the wrapper with no binary and re-installing would
180
+ change nothing. First-run downloads go to `~/.xkat/`, which the user can
181
+ always write to — a global install may not be.
182
+
183
+ ## Consumers
184
+
185
+ Two things drive this CLI, and both break if a command is renamed or its meaning
186
+ changes:
187
+
188
+ - **The web player** talks to the running agent over HTTP/WebSocket
189
+ (`/lab/exec`, `/lab/status`, `/ws`) to provision labs and attach terminals.
190
+ - **The desktop app** (`apps/desktop`) resolves this binary on disk and runs it
191
+ for its `Tools` menu — see
192
+ [docs/architecture/desktop_app.md](../../docs/architecture/desktop_app.md).
193
+ It accepts only a binary named `xkat-cli`.
194
+
195
+ ## Changelog
196
+
197
+ See [CHANGELOG.md](./CHANGELOG.md). The 2.1.x–2.7.x line carries behavior
198
+ changes worth reading before upgrading — dynamic lab ports, per-lecture project
199
+ folders, virtual-machine state reported to the player, and `down` now stopping
200
+ lab virtual machines.
84
201
 
85
202
  ## Releasing (maintainers)
86
203
 
87
- See `docs/xkat-agent/deployment-pipeline.md` and
88
- `.github/workflows/release-agent.yml` for the release pipeline. Summary:
204
+ See `.github/workflows/release-agent.yml` for the release pipeline. Binaries go
205
+ to R2; npm carries only this wrapper.
206
+
207
+ npm publishing uses **trusted publishing (OIDC)** — there is no long-lived token
208
+ in repository secrets. The package's Trusted Publisher must list this repository
209
+ and `release-agent.yml`; without it `npm publish` is refused.
210
+
211
+ `package.json` and `Cargo.toml` versions must match the tag — CI rejects the
212
+ release otherwise, because the npm version selects the CDN folder while the
213
+ binary reports its own crate version.
89
214
 
90
215
  ```bash
91
216
  cd apps/xkat-agent
92
- npm version patch --no-git-tag-version
93
- git add package.json
94
- git commit -m "chore(agent): release v$(node -p "require('./package.json').version")"
217
+ VER=2.1.12 # bump both files to the same value
218
+ npm version "$VER" --no-git-tag-version
219
+ sed -i "0,/^version = /s//version = \"$VER\"\n/" Cargo.toml # or edit by hand
220
+ cargo check # refresh Cargo.lock
221
+ git add package.json Cargo.toml Cargo.lock
222
+ git commit -m "chore(agent): release v$VER"
95
223
  git push origin main
96
- git tag agent-v$(node -p "require('./package.json').version")
97
- git push origin agent-v$(node -p "require('./package.json').version")
224
+ git tag "agent-v$VER" && git push origin "agent-v$VER"
98
225
  ```
99
226
 
100
227
  ## License
@@ -1,7 +1,7 @@
1
1
  {
2
- "xkat-agent-macos-aarch64": "2302708ec3671a348d7097ca37c8fdfb8f7e7075677b77bdcd3a81f925c7a08d",
3
- "xkat-agent-macos-x64": "0327efb22cb81327f139fe93745453c561ccfd8e63255e3277c9feb5de497d0a",
4
- "xkat-agent-linux-x64": "2ca8bfd35f05398b8e90e4dfa1b5d702f7a81fc5881e4be9b3e8df11345860e9",
5
- "xkat-agent-linux-aarch64": "6d2db70dd5e7a02c275106f3d3d0abf6ebfdc19d526f6deb952753df3f1f7584",
6
- "xkat-agent-win-x64.exe": "7116843eaf46be1c75f3170f91b15d745e4a8a0bf6cdf54ec436095fd27082f1"
2
+ "xkat-cli-macos-aarch64": "cc72291b82610f80a14c0e85a64c454f213799f37c4cf29fb1b08a7be03ca425",
3
+ "xkat-cli-macos-x64": "cc4d5239b44e06637dc69599ddeaafb466f4dccff908f80decca832195f55c4d",
4
+ "xkat-cli-linux-x64": "2668ece7b705fb9bd36edf5b83762e2bfc8d05133c2d694703eb7f486e58b4e1",
5
+ "xkat-cli-linux-aarch64": "88c7cb100b490c93a8463eb8aec72463392ce138daa46918ba0942f39d02ee62",
6
+ "xkat-cli-win-x64.exe": "97929b4a6cf41dc5510d1f546fa11bc1df111491d6cd5c41140fbf09efae1a26"
7
7
  }
package/bin/xkat-cli.js CHANGED
@@ -21,11 +21,11 @@ function getBinaryName() {
21
21
  const arch = os.arch();
22
22
 
23
23
  const map = {
24
- 'darwin-arm64': 'xkat-agent-macos-aarch64',
25
- 'darwin-x64': 'xkat-agent-macos-x64',
26
- 'linux-x64': 'xkat-agent-linux-x64',
27
- 'linux-arm64': 'xkat-agent-linux-aarch64',
28
- 'win32-x64': 'xkat-agent-win-x64.exe',
24
+ 'darwin-arm64': 'xkat-cli-macos-aarch64',
25
+ 'darwin-x64': 'xkat-cli-macos-x64',
26
+ 'linux-x64': 'xkat-cli-linux-x64',
27
+ 'linux-arm64': 'xkat-cli-linux-aarch64',
28
+ 'win32-x64': 'xkat-cli-win-x64.exe',
29
29
  };
30
30
 
31
31
  const key = `${platform}-${arch}`;
@@ -40,97 +40,207 @@ function getBinaryName() {
40
40
  return binary;
41
41
  }
42
42
 
43
- const LAB_NAME = 'xkat-lab';
44
-
45
43
  const candidatePaths = [
46
- path.join(__dirname, '..', 'target', 'release', os.platform() === 'win32' ? 'xkat-agent.exe' : 'xkat-agent'),
47
- path.join(__dirname, '..', 'target', 'debug', os.platform() === 'win32' ? 'xkat-agent.exe' : 'xkat-agent'),
44
+ path.join(__dirname, '..', 'target', 'release', os.platform() === 'win32' ? 'xkat-cli.exe' : 'xkat-cli'),
45
+ path.join(__dirname, '..', 'target', 'debug', os.platform() === 'win32' ? 'xkat-cli.exe' : 'xkat-cli'),
48
46
  path.join(__dirname, '..', 'binaries', getBinaryName()),
49
- path.join(os.homedir(), '.xkat', os.platform() === 'win32' ? 'xkat-agent.exe' : 'xkat-agent'),
47
+ path.join(os.homedir(), '.xkat', os.platform() === 'win32' ? 'xkat-cli.exe' : 'xkat-cli'),
50
48
  ];
51
49
 
52
- const binaryPath = candidatePaths.find((p) => fs.existsSync(p));
50
+ const binaryPath = candidatePaths.find((p) => {
51
+ if (!fs.existsSync(p)) return false;
52
+ const stats = fs.statSync(p);
53
+ return stats.size > 0;
54
+ });
53
55
 
54
- const args = process.argv.slice(2);
55
- const subCmd = args.join(' ').trim();
56
-
57
- // ── 1. Zero-Touch Port 38000..38050 & 10022 Cleanup Command ──
58
- if (subCmd === 'down' || subCmd === 'stop' || subCmd === 'kill' || subCmd === 'clean') {
59
- console.log('🛑 Cleaning up agent ports (38000..38050, 10022) and stopping existing xkat agent instances...');
60
- if (os.platform() === 'win32') {
61
- spawnSync('powershell', [
62
- '-NoProfile',
63
- '-NonInteractive',
64
- '-Command',
65
- '38000..38050 + @(10022) | ForEach-Object { Get-NetTCPConnection -LocalPort $_ -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | Where-Object { $_ -gt 0 } | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue } }; Get-Process -Name xkat-agent*, xkat* -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue'
66
- ], { stdio: 'inherit' });
67
- } else {
68
- spawnSync('sh', ['-c', 'fuser -k 38000-38050/tcp 2>/dev/null || true; fuser -k 10022/tcp 2>/dev/null || true; pgrep -f "xkat-agent" | xargs -r kill -9 2>/dev/null || true'], { stdio: 'inherit' });
56
+ /**
57
+ * True when the binary we found is one *we* fetched for an older version.
58
+ *
59
+ * The download URL is versioned, so a wrapper updated to 2.8.0 alongside a
60
+ * 2.7.x binary in `~/.xkat/` would keep running the old one and under npm 12
61
+ * the package's own `binaries/` directory (which the postinstall would have
62
+ * refreshed) does not exist at all, so the stale copy always wins. Only the
63
+ * fetched cache is checked; a source-tree build or a hand-placed binary is the
64
+ * developer's business.
65
+ */
66
+ function isStaleFetchedBinary(found) {
67
+ try {
68
+ const { userBinaryPath, readCachedVersion, VERSION } = require('../scripts/install.js');
69
+ if (path.resolve(found) !== path.resolve(userBinaryPath())) return false;
70
+ return readCachedVersion(found) !== VERSION;
71
+ } catch {
72
+ return false;
69
73
  }
70
- console.log('✅ Agent ports are now completely free.');
71
- process.exit(0);
72
74
  }
73
75
 
74
- function getMultipassCmd() {
75
- if (os.platform() === 'win32') {
76
- const candidates = [
77
- 'multipass',
78
- path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Multipass', 'bin', 'multipass.exe'),
79
- path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Multipass', 'bin', 'multipass.exe'),
80
- path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Multipass', 'bin', 'multipass.exe'),
81
- ];
82
- for (const c of candidates) {
83
- if (c === 'multipass' || fs.existsSync(c)) return c;
84
- }
76
+ const args = process.argv.slice(2);
77
+
78
+ /**
79
+ * Tells the learner when a newer xkat-cli has been published.
80
+ *
81
+ * Releases are frequent right now, and a learner running a month-old CLI hits
82
+ * bugs that were fixed weeks ago with no way to know. The check is deliberately
83
+ * timid: it is skipped unless stdout is a terminal, it runs at most once a day,
84
+ * it gives up after 1.5 seconds, and every failure is silent. It must never be
85
+ * the reason a command is slow or fails — an offline learner sees nothing.
86
+ */
87
+ const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
88
+
89
+ function updateCheckStampPath() {
90
+ return path.join(os.homedir(), '.xkat', 'update-check');
91
+ }
92
+
93
+ function dueForUpdateCheck() {
94
+ if (!process.stdout.isTTY) return false;
95
+ if (process.env.XKAT_NO_UPDATE_CHECK) return false;
96
+ try {
97
+ const age = Date.now() - fs.statSync(updateCheckStampPath()).mtimeMs;
98
+ return age > UPDATE_CHECK_INTERVAL_MS;
99
+ } catch {
100
+ return true; // never checked
85
101
  }
86
- return 'multipass';
87
102
  }
88
103
 
89
- // ── 2. Fallback for Lab VM commands when native agent is not built ──
90
- if (!binaryPath) {
91
- const mp = getMultipassCmd();
92
- if (subCmd === 'lab shell') {
93
- const result = spawnSync(mp, ['shell', LAB_NAME], { stdio: 'inherit', shell: os.platform() === 'win32' });
94
- process.exit(result.status || 0);
104
+ function markUpdateChecked() {
105
+ try {
106
+ fs.mkdirSync(path.dirname(updateCheckStampPath()), { recursive: true });
107
+ fs.writeFileSync(updateCheckStampPath(), new Date().toISOString());
108
+ } catch {}
109
+ }
110
+
111
+ /** Compares dotted numeric versions. Returns true when `latest` is newer. */
112
+ function isNewer(latest, current) {
113
+ const toParts = (v) => String(v).split('.').map((n) => parseInt(n, 10) || 0);
114
+ const a = toParts(latest);
115
+ const b = toParts(current);
116
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
117
+ if ((a[i] || 0) > (b[i] || 0)) return true;
118
+ if ((a[i] || 0) < (b[i] || 0)) return false;
95
119
  }
120
+ return false;
121
+ }
96
122
 
97
- if (subCmd === 'lab reset') {
98
- console.log('🔄 Resetting Lab VM...');
99
- spawnSync(mp, ['delete', LAB_NAME, '--purge'], { stdio: 'inherit', shell: os.platform() === 'win32' });
100
- console.log('🚀 Launching fresh Lab VM...');
101
- const launch = spawnSync(mp, ['launch', '--name', LAB_NAME, '24.04', '--cpus', '2', '--memory', '2G', '--disk', '10G'], { stdio: 'inherit', shell: os.platform() === 'win32' });
102
- if (launch.status === 0) {
103
- console.log('✅ Lab reset complete.');
104
- console.log(' Enter: npx -y xkat-cli@latest lab shell');
105
- } else {
106
- console.error('❌ Could not recreate Lab. Check multipass installation.');
123
+ function fetchLatestVersion() {
124
+ return new Promise((resolve) => {
125
+ let settled = false;
126
+ const done = (v) => { if (!settled) { settled = true; resolve(v); } };
127
+
128
+ try {
129
+ const https = require('https');
130
+ // The `/latest` document is ~2KB. The whole packument is ~17KB and grows
131
+ // with every release, and the abbreviated Accept header it wants is a 406
132
+ // on this endpoint.
133
+ const req = https.get(
134
+ 'https://registry.npmjs.org/xkat-cli/latest',
135
+ { headers: { Accept: 'application/json' }, timeout: 1500 },
136
+ (res) => {
137
+ if (res.statusCode !== 200) { res.resume(); return done(null); }
138
+ let body = '';
139
+ res.setEncoding('utf8');
140
+ res.on('data', (c) => { body += c; });
141
+ res.on('end', () => {
142
+ try { done(JSON.parse(body).version || null); } catch { done(null); }
143
+ });
144
+ }
145
+ );
146
+ req.on('timeout', () => { req.destroy(); done(null); });
147
+ req.on('error', () => done(null));
148
+ } catch {
149
+ done(null);
107
150
  }
108
- process.exit(launch.status || 0);
151
+ });
152
+ }
153
+
154
+ async function noticeIfOutdated() {
155
+ if (!dueForUpdateCheck()) return;
156
+ markUpdateChecked();
157
+
158
+ let current;
159
+ try {
160
+ current = require('../package.json').version;
161
+ } catch {
162
+ return;
109
163
  }
110
164
 
111
- if (subCmd === 'lab up' || subCmd === 'lab install') {
112
- const info = spawnSync(mp, ['info', LAB_NAME], { encoding: 'utf8', shell: os.platform() === 'win32' });
113
- if (info.status !== 0) {
114
- console.log('🚀 Creating Lab VM...');
115
- const launch = spawnSync(mp, ['launch', '--name', LAB_NAME, '24.04', '--cpus', '2', '--memory', '2G', '--disk', '10G'], { stdio: 'inherit', shell: os.platform() === 'win32' });
116
- process.exit(launch.status || 0);
117
- } else {
118
- console.log(`✅ Lab VM '${LAB_NAME}' already exists.`);
119
- console.log(' Enter: npx -y xkat-cli@latest lab shell');
120
- process.exit(0);
121
- }
165
+ const latest = await fetchLatestVersion();
166
+ if (!latest || !isNewer(latest, current)) return;
167
+
168
+ console.log('');
169
+ console.log(`⬆️ A newer xkat-cli is available: ${current} ${latest}`);
170
+ console.log(' Update with: npm install -g xkat-cli@latest');
171
+ console.log(' (or run once without installing: npx -y xkat-cli@latest up)');
172
+ console.log('');
173
+ }
174
+
175
+ /**
176
+ * Fetches the binary on first run when the install script never ran.
177
+ *
178
+ * npm 12 turned dependency lifecycle scripts off by default, so `npm i -g
179
+ * xkat-cli` installs this wrapper and nothing else — and the old advice
180
+ * ("reinstall to fetch it") led nowhere because re-installing did the same.
181
+ *
182
+ * The download goes to `~/.xkat/`, not into the package directory: a global
183
+ * install may live somewhere the user cannot write, and that path is already
184
+ * one of the places this wrapper looks.
185
+ */
186
+ async function fetchBinary(reason) {
187
+ let ensureBinary, userBinaryPath;
188
+ try {
189
+ ({ ensureBinary, userBinaryPath } = require('../scripts/install.js'));
190
+ } catch (e) {
191
+ return null;
122
192
  }
123
193
 
124
- console.error('❌ xkat-agent binary not found.');
125
- console.error(' Try reinstalling: npm install -g xkat-cli');
126
- console.error(' Or for local development: cargo build');
127
- process.exit(1);
194
+ console.log(`📥 xkat-cli: ${reason} — fetching the binary for this platform...`);
195
+ try {
196
+ return await ensureBinary({ destPath: userBinaryPath(), quiet: true });
197
+ } catch (e) {
198
+ console.error(`❌ xkat-cli: could not fetch the binary (${e.message})`);
199
+ return null;
200
+ }
128
201
  }
129
202
 
130
- // ── 3. Forward to Rust binary ──
131
- const result = spawnSync(binaryPath, args, {
132
- stdio: 'inherit',
133
- env: process.env,
134
- });
203
+ // Every subcommand `down` included — is handled by the Rust binary. This
204
+ // wrapper used to reimplement shutdown by killing everything on ports
205
+ // 38000..38050 plus every process matching "xkat-cli", which included itself
206
+ // and any unrelated process with that string in its command line.
207
+ (async () => {
208
+ await noticeIfOutdated();
209
+
210
+ let resolved = binaryPath;
211
+ let reason = 'first run';
212
+
213
+ if (resolved && isStaleFetchedBinary(resolved)) {
214
+ reason = 'this is a new version';
215
+ resolved = null;
216
+ }
217
+
218
+ if (!resolved) {
219
+ resolved = await fetchBinary(reason);
220
+ }
221
+
222
+ if (!resolved) {
223
+ console.error('❌ xkat-cli binary not found.');
224
+ console.error(' Check your network and try again, or build from source:');
225
+ console.error(' cd apps/xkat-agent && cargo build --release');
226
+ process.exit(1);
227
+ }
228
+
229
+ // Forward to the Rust binary.
230
+ //
231
+ // The cached binary is not re-verified here. The download is atomic — hashed,
232
+ // then renamed into place — so a file that exists is a file that passed the
233
+ // check. Hashing 14MB on every invocation would buy nothing.
234
+ const result = spawnSync(resolved, args, {
235
+ stdio: 'inherit',
236
+ env: process.env,
237
+ });
238
+
239
+ if (result.error) {
240
+ console.error(`❌ Could not run xkat-cli: ${result.error.message}`);
241
+ process.exit(1);
242
+ }
135
243
 
136
- process.exit(result.status || 0);
244
+ // A signalled child reports status === null; treat that as a failure, not success.
245
+ process.exit(result.status ?? (result.signal ? 1 : 0));
246
+ })();
package/package.json CHANGED
@@ -1,19 +1,20 @@
1
1
  {
2
2
  "name": "xkat-cli",
3
- "version": "2.0.17",
4
- "description": "xkat Agent - an AI agent that securely connects your browser to your machine",
3
+ "version": "2.7.3",
4
+ "description": "xkat-cli - an AI agent that securely connects your browser to your machine",
5
5
  "main": "bin/xkat-cli.js",
6
6
  "bin": {
7
- "xkat": "bin/xkat-cli.js"
7
+ "xkat-cli": "bin/xkat-cli.js"
8
8
  },
9
9
  "files": [
10
10
  "bin",
11
11
  "scripts",
12
- "README.md"
12
+ "README.md",
13
+ "CHANGELOG.md"
13
14
  ],
14
15
  "scripts": {
15
- "build:win": "cargo build --target x86_64-pc-windows-gnu --release && cp target/x86_64-pc-windows-gnu/release/xkat-agent.exe binaries/xkat-agent-win-x64.exe",
16
- "build:linux": "cargo build --release && cp target/release/xkat-agent binaries/xkat-agent-linux-x64",
16
+ "build:win": "cargo build --target x86_64-pc-windows-gnu --release && cp target/x86_64-pc-windows-gnu/release/xkat-cli.exe binaries/xkat-cli-win-x64.exe",
17
+ "build:linux": "cargo build --release && cp target/release/xkat-cli binaries/xkat-cli-linux-x64",
17
18
  "postinstall": "node ./scripts/install.js"
18
19
  },
19
20
  "keywords": [
@@ -21,12 +21,14 @@ for (const dir of candidateDirs) {
21
21
 
22
22
  const destFile = path.resolve(__dirname, '..', 'bin', 'checksums.json');
23
23
 
24
+ // Must stay identical to the keys install.js looks up (getPlatformTarget),
25
+ // otherwise integrity verification silently degrades to "no check".
24
26
  const targets = [
25
- 'xkat-agent-macos-aarch64',
26
- 'xkat-agent-macos-x64',
27
- 'xkat-agent-linux-x64',
28
- 'xkat-agent-linux-aarch64',
29
- 'xkat-agent-win-x64.exe',
27
+ 'xkat-cli-macos-aarch64',
28
+ 'xkat-cli-macos-x64',
29
+ 'xkat-cli-linux-x64',
30
+ 'xkat-cli-linux-aarch64',
31
+ 'xkat-cli-win-x64.exe',
30
32
  ];
31
33
 
32
34
  const checksums = {};
@@ -1,7 +1,15 @@
1
1
  /**
2
- * install.js — postinstall script
3
- * Runs automatically right after `npm install`. Downloads the prebuilt Rust
4
- * binary matching the user's OS/architecture from GitHub Releases (or R2).
2
+ * install.js — fetches the prebuilt Rust binary for this platform.
3
+ *
4
+ * Two callers:
5
+ * 1. `postinstall`, when the package manager still runs install scripts.
6
+ * 2. `bin/xkat-cli.js` on first run, when it does not. npm 12 turned
7
+ * dependency lifecycle scripts off by default, so a learner who installs
8
+ * with it gets the wrapper and no binary — and re-installing changes
9
+ * nothing. The wrapper therefore fetches it on demand.
10
+ *
11
+ * Both paths verify the download against the checksum manifest published with
12
+ * this package; an unverified binary is never used.
5
13
  */
6
14
  process.removeAllListeners('warning');
7
15
  process.emitWarning = () => {};
@@ -14,20 +22,21 @@ const https = require('https');
14
22
  const crypto = require('crypto');
15
23
 
16
24
  // Version is the single source of truth from package.json, so a release bump
17
- // (npm version patch) automatically points install at the matching R2 folder.
25
+ // automatically points install at the matching CDN folder.
18
26
  const VERSION = require('../package.json').version;
19
27
  const BASE_URL = `https://dl.xkat.space/v${VERSION}`;
28
+ const MAX_REDIRECTS = 5;
20
29
 
21
30
  function getPlatformTarget() {
22
31
  const platform = os.platform();
23
32
  const arch = os.arch();
24
33
 
25
34
  const map = {
26
- 'darwin-arm64': 'xkat-agent-macos-aarch64',
27
- 'darwin-x64': 'xkat-agent-macos-x64',
28
- 'linux-x64': 'xkat-agent-linux-x64',
29
- 'linux-arm64': 'xkat-agent-linux-aarch64',
30
- 'win32-x64': 'xkat-agent-win-x64.exe',
35
+ 'darwin-arm64': 'xkat-cli-macos-aarch64',
36
+ 'darwin-x64': 'xkat-cli-macos-x64',
37
+ 'linux-x64': 'xkat-cli-linux-x64',
38
+ 'linux-arm64': 'xkat-cli-linux-aarch64',
39
+ 'win32-x64': 'xkat-cli-win-x64.exe',
31
40
  };
32
41
 
33
42
  return map[`${platform}-${arch}`] || null;
@@ -39,107 +48,211 @@ function calculateSha256(filePath) {
39
48
  const stream = fs.createReadStream(filePath);
40
49
  stream.on('data', (data) => hash.update(data));
41
50
  stream.on('end', () => resolve(hash.digest('hex')));
42
- stream.on('error', (err) => reject(err));
51
+ stream.on('error', reject);
43
52
  });
44
53
  }
45
54
 
46
- async function main() {
47
- const target = getPlatformTarget();
55
+ /**
56
+ * True when running from the agent's own source tree rather than an installed
57
+ * package. `Cargo.toml` is not in package.json "files", so it can only exist
58
+ * here during development — unlike a pnpm-workspace probe, which also matches
59
+ * a user installing xkat-cli into their own monorepo.
60
+ */
61
+ function isSourceCheckout() {
62
+ return fs.existsSync(path.join(__dirname, '..', 'Cargo.toml'));
63
+ }
48
64
 
65
+ function fail(message, hint) {
66
+ console.error(`❌ xkat-cli: ${message}`);
67
+ if (hint) console.error(` ${hint}`);
68
+ process.exitCode = 1;
69
+ }
70
+
71
+ /** Where a first-run fetch puts the binary: always writable by the user. */
72
+ function userBinaryPath() {
73
+ return path.join(
74
+ os.homedir(),
75
+ '.xkat',
76
+ os.platform() === 'win32' ? 'xkat-cli.exe' : 'xkat-cli'
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Which version the cached binary is.
82
+ *
83
+ * The file itself carries no version we can read cheaply, and the download URL
84
+ * is versioned — so a wrapper updated to 2.8.0 would otherwise keep running a
85
+ * 2.7.x binary fetched earlier, forever. The marker is written next to the
86
+ * binary when it is fetched.
87
+ */
88
+ function cachedVersionPath(binaryPath) {
89
+ return `${binaryPath}.version`;
90
+ }
91
+
92
+ function readCachedVersion(binaryPath) {
93
+ try {
94
+ return fs.readFileSync(cachedVersionPath(binaryPath), 'utf8').trim();
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Ensures a verified binary exists at `destPath` and returns it.
102
+ *
103
+ * @param {object} [opts]
104
+ * @param {string} [opts.destPath] Where to put it. Defaults to the package's
105
+ * own `binaries/` directory — right for postinstall, wrong for first-run
106
+ * fetch, because a global install may sit in a root-owned directory.
107
+ * @param {boolean} [opts.quiet] Suppress the "already present" chatter.
108
+ * @returns {Promise<string|null>} Path to the binary, or null when there is
109
+ * nothing to fetch (source checkout, unsupported platform).
110
+ */
111
+ async function ensureBinary(opts = {}) {
112
+ if (isSourceCheckout()) {
113
+ if (!opts.quiet) {
114
+ console.log('🛠️ Agent source tree detected. Skipping binary download.');
115
+ console.log(" (Use 'cargo build --release' for development)");
116
+ }
117
+ return null;
118
+ }
119
+
120
+ const target = getPlatformTarget();
49
121
  if (!target) {
50
- console.log(`⚠️ xkat-agent: unsupported platform (${os.platform()}-${os.arch()})`);
122
+ // Not a failure: the package still installs, the user builds from source.
123
+ console.log(`⚠️ xkat-cli: unsupported platform (${os.platform()}-${os.arch()})`);
51
124
  console.log(' Build manually: cd apps/xkat-agent && cargo build --release');
52
- return;
125
+ return null;
53
126
  }
54
127
 
55
- const binDir = path.join(__dirname, '..', 'binaries');
56
- const destPath = path.join(binDir, target);
57
-
58
- // Load checksums manifest if it exists
128
+ // Integrity verification is a documented guarantee, so a missing or
129
+ // incomplete manifest must fail loudly instead of silently skipping the check.
59
130
  const checksumsPath = path.join(__dirname, '..', 'bin', 'checksums.json');
60
131
  let expectedHash = null;
61
- if (fs.existsSync(checksumsPath)) {
62
- try {
63
- const checksums = JSON.parse(fs.readFileSync(checksumsPath, 'utf8'));
64
- expectedHash = checksums[target] || null;
65
- } catch (e) {
66
- console.warn(`⚠️ Failed to parse checksums manifest: ${e.message}`);
67
- }
132
+ try {
133
+ expectedHash = JSON.parse(fs.readFileSync(checksumsPath, 'utf8'))[target] || null;
134
+ } catch (e) {
135
+ return fail(
136
+ `checksum manifest is unreadable (${e.message}).`,
137
+ 'This package looks corrupted — reinstall with: npm install -g xkat-cli'
138
+ );
139
+ }
140
+ if (!expectedHash) {
141
+ return fail(
142
+ `checksum manifest has no entry for ${target}.`,
143
+ 'Refusing to install an unverifiable binary. Please report this release.'
144
+ );
68
145
  }
69
146
 
70
- // Skip if already downloaded and verified
147
+ const destPath = opts.destPath || path.join(__dirname, '..', 'binaries', target);
148
+ const binDir = path.dirname(destPath);
149
+
71
150
  if (fs.existsSync(destPath)) {
72
- if (expectedHash) {
73
- try {
74
- const currentHash = await calculateSha256(destPath);
75
- if (currentHash === expectedHash) {
76
- console.log(`✅ xkat-agent binary already present and verified: ${target}`);
77
- return;
78
- }
79
- console.log(`⚠️ Hash mismatch for existing binary. Re-downloading...`);
80
- } catch (err) {
81
- console.log(`⚠️ Failed to verify existing binary hash: ${err.message}. Re-downloading...`);
151
+ try {
152
+ if ((await calculateSha256(destPath)) === expectedHash) {
153
+ if (!opts.quiet) console.log(`✅ xkat-cli binary already present and verified: ${target}`);
154
+ try {
155
+ fs.writeFileSync(cachedVersionPath(destPath), VERSION);
156
+ } catch {}
157
+ return destPath;
82
158
  }
83
- } else {
84
- console.log(`✅ xkat-agent binary already present: ${target}`);
85
- return;
159
+ console.log('⚠️ Hash mismatch for existing binary. Re-downloading...');
160
+ } catch (err) {
161
+ console.log(`⚠️ Could not verify existing binary (${err.message}). Re-downloading...`);
86
162
  }
87
163
  }
88
164
 
89
- console.log(`📥 Downloading xkat-agent... (${target})`);
90
-
91
- // Create the binaries directory
92
- if (!fs.existsSync(binDir)) {
93
- fs.mkdirSync(binDir, { recursive: true });
94
- }
95
-
96
- const url = `${BASE_URL}/${target}`;
165
+ console.log(`📥 Downloading xkat-cli... (${target})`);
166
+ fs.mkdirSync(binDir, { recursive: true });
97
167
 
168
+ // Download to a temporary file and only publish it under the final name once
169
+ // it is complete and verified, so an interrupted download can never be cached
170
+ // as a usable binary.
171
+ const partPath = `${destPath}.part`;
98
172
  try {
99
- await downloadFile(url, destPath);
100
-
101
- // Verify integrity after download
102
- if (expectedHash) {
103
- const downloadedHash = await calculateSha256(destPath);
104
- if (downloadedHash !== expectedHash) {
105
- // Remove corrupted/tampered file
106
- try { fs.unlinkSync(destPath); } catch (_) {}
107
- throw new Error(`Integrity check failed: expected ${expectedHash}, got ${downloadedHash}`);
108
- }
109
- console.log(`✅ Integrity verified: SHA-256 match`);
173
+ fs.rmSync(partPath, { force: true });
174
+ await downloadFile(`${BASE_URL}/${target}`, partPath);
175
+
176
+ const downloadedHash = await calculateSha256(partPath);
177
+ if (downloadedHash !== expectedHash) {
178
+ throw new Error(`integrity check failed: expected ${expectedHash}, got ${downloadedHash}`);
110
179
  }
111
180
 
112
- // Grant execute permission (Unix only)
113
181
  if (os.platform() !== 'win32') {
114
- fs.chmodSync(destPath, 0o755);
182
+ fs.chmodSync(partPath, 0o755);
183
+ }
184
+ fs.renameSync(partPath, destPath);
185
+ try {
186
+ fs.writeFileSync(cachedVersionPath(destPath), VERSION);
187
+ } catch {
188
+ // 기록에 실패해도 바이너리는 쓸 수 있다. 다음 실행에서 한 번 더 받을 뿐이다.
115
189
  }
116
- console.log(`✅ Installed! Run: xkat up`);
190
+
191
+ console.log('✅ Integrity verified: SHA-256 match');
192
+ if (!opts.quiet) console.log('✅ Installed! Run: xkat-cli up');
193
+ return destPath;
117
194
  } catch (err) {
118
- console.log(`⚠️ Binary download failed: ${err.message}`);
119
- console.log(' To build locally: cd apps/xkat-agent && cargo build --release');
195
+ fs.rmSync(partPath, { force: true });
196
+ fail(
197
+ `binary download failed: ${err.message}`,
198
+ 'Retry the install, or build locally: cd apps/xkat-agent && cargo build --release'
199
+ );
200
+ return null;
120
201
  }
121
202
  }
122
203
 
123
- function downloadFile(urlStr, dest) {
204
+ function downloadFile(urlStr, dest, redirectsLeft = MAX_REDIRECTS) {
124
205
  return new Promise((resolve, reject) => {
125
- const file = fs.createWriteStream(dest);
126
- const parsedUrl = new URL(urlStr);
127
- https.get(parsedUrl, (response) => {
128
- // GitHub Releases use 302 redirects
129
- if (response.statusCode === 302 || response.statusCode === 301) {
130
- const redirectUrl = new URL(response.headers.location, parsedUrl);
131
- https.get(redirectUrl, (redirected) => {
132
- redirected.pipe(file);
133
- file.on('finish', () => { file.close(); resolve(); });
134
- }).on('error', reject);
135
- } else if (response.statusCode === 200) {
136
- response.pipe(file);
137
- file.on('finish', () => { file.close(); resolve(); });
138
- } else {
139
- reject(new Error(`HTTP ${response.statusCode}`));
206
+ const request = https.get(new URL(urlStr), (response) => {
207
+ const { statusCode, headers } = response;
208
+
209
+ if (statusCode >= 300 && statusCode < 400 && headers.location) {
210
+ response.resume(); // drain so the socket can be reused
211
+ if (redirectsLeft <= 0) {
212
+ reject(new Error('too many redirects'));
213
+ return;
214
+ }
215
+ const next = new URL(headers.location, urlStr).toString();
216
+ downloadFile(next, dest, redirectsLeft - 1).then(resolve, reject);
217
+ return;
218
+ }
219
+
220
+ if (statusCode !== 200) {
221
+ response.resume();
222
+ reject(new Error(`HTTP ${statusCode} for ${urlStr}`));
223
+ return;
140
224
  }
141
- }).on('error', reject);
225
+
226
+ const expectedBytes = Number(headers['content-length']) || 0;
227
+ let received = 0;
228
+ response.on('data', (chunk) => { received += chunk.length; });
229
+
230
+ const file = fs.createWriteStream(dest);
231
+ response.pipe(file);
232
+
233
+ response.on('error', reject);
234
+ file.on('error', reject);
235
+ file.on('finish', () => {
236
+ file.close(() => {
237
+ if (expectedBytes && received !== expectedBytes) {
238
+ reject(new Error(`truncated download: got ${received} of ${expectedBytes} bytes`));
239
+ return;
240
+ }
241
+ resolve();
242
+ });
243
+ });
244
+ });
245
+
246
+ request.on('error', reject);
247
+ request.setTimeout(120000, () => {
248
+ request.destroy(new Error('download timed out'));
249
+ });
142
250
  });
143
251
  }
144
252
 
145
- main();
253
+ module.exports = { ensureBinary, userBinaryPath, getPlatformTarget, readCachedVersion, VERSION };
254
+
255
+ // postinstall 로 실행됐을 때만 스스로 돈다. 래퍼가 require 할 때는 돌지 않는다.
256
+ if (require.main === module) {
257
+ ensureBinary();
258
+ }