xkat-cli 2.7.1 → 2.8.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/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
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
+
3
49
  ## 2.7.1
4
50
 
5
51
  - **"가상 머신이 돌고 있나" 를 두 곳이 다르게 답하던 문제.** 에이전트가
package/README.md CHANGED
@@ -165,12 +165,20 @@ xkat-cli includes the following safeguards:
165
165
  Requests from a browser are already constrained by the Origin allowlist.
166
166
  5. **Audit log**: Connection attempts are recorded locally with a timestamp in
167
167
  `~/.xkat/agent.log`; every host-shell grant is logged with its origin.
168
- 6. **Binary Integrity Check**: During installation, the postinstall script downloads
169
- the platform-specific prebuilt Rust binary over HTTPS and cryptographically
170
- verifies its hash (SHA-256) against the checksum manifest published with the
171
- package, to prevent MITM, tampering, or supply chain injection. Installation
172
- **fails** if the manifest is missing an entry or the hash does not match — an
173
- unverified binary is never installed.
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.
174
182
 
175
183
  ## Consumers
176
184
 
@@ -1,7 +1,7 @@
1
1
  {
2
- "xkat-cli-macos-aarch64": "c43d8536d1b517eda8c9742cb8a95bf902eb35e0f00a65258c16579ca2c9611d",
3
- "xkat-cli-macos-x64": "5b3a93211d095b8eb2f1ed574b3b06266aeb5e15ec127769a01650fca3f9c20b",
4
- "xkat-cli-linux-x64": "4cd7d742a0dbc854119f1cdf45ebd831ad617b6d746a130bd18c11f75baae18b",
5
- "xkat-cli-linux-aarch64": "1f8054b8647d010f49f00479da6f6c04b1296b3665d23d63d45914f376fadfa2",
6
- "xkat-cli-win-x64.exe": "c52ba038212395ef86dc96882d9b884dcb15f387ac57c7757aae7ef428776ab7"
2
+ "xkat-cli-macos-aarch64": "29d698aff99fe704c0c154223fdcb583efbbd99e692df2617567257af25c3135",
3
+ "xkat-cli-macos-x64": "323ba511978017a118337da7ba1dd085501d775563b2aa6b95b0ac92a3f40f30",
4
+ "xkat-cli-linux-x64": "4a02ccc48638279bc1118ef37ab3d169e5559f886e153143492ab0f8f6fe3380",
5
+ "xkat-cli-linux-aarch64": "cd52e4a1009107bbf4addfe7032419e56fa736017e6a010ac85350e4ec7af18d",
6
+ "xkat-cli-win-x64.exe": "5660b96abecc0e9f67608ef1e7d4fbaa1dd11b901333b08b18e557c77b63c325"
7
7
  }
package/bin/xkat-cli.js CHANGED
@@ -53,29 +53,194 @@ const binaryPath = candidatePaths.find((p) => {
53
53
  return stats.size > 0;
54
54
  });
55
55
 
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;
73
+ }
74
+ }
75
+
56
76
  const args = process.argv.slice(2);
57
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
101
+ }
102
+ }
103
+
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;
119
+ }
120
+ return false;
121
+ }
122
+
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);
150
+ }
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;
163
+ }
164
+
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;
192
+ }
193
+
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
+ }
201
+ }
202
+
58
203
  // Every subcommand — `down` included — is handled by the Rust binary. This
59
204
  // wrapper used to reimplement shutdown by killing everything on ports
60
205
  // 38000..38050 plus every process matching "xkat-cli", which included itself
61
206
  // and any unrelated process with that string in its command line.
62
- if (!binaryPath) {
63
- console.error('❌ xkat-cli binary not found.');
64
- console.error(' Reinstall to fetch it: npm install -g xkat-cli');
65
- console.error(' Or build from source: cd apps/xkat-agent && cargo build --release');
66
- process.exit(1);
67
- }
207
+ (async () => {
208
+ await noticeIfOutdated();
68
209
 
69
- // Forward to the Rust binary.
70
- const result = spawnSync(binaryPath, args, {
71
- stdio: 'inherit',
72
- env: process.env,
73
- });
210
+ let resolved = binaryPath;
211
+ let reason = 'first run';
74
212
 
75
- if (result.error) {
76
- console.error(`❌ Could not run xkat-cli: ${result.error.message}`);
77
- process.exit(1);
78
- }
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
+ }
79
243
 
80
- // A signalled child reports status === null; treat that as a failure, not success.
81
- process.exit(result.status ?? (result.signal ? 1 : 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,6 +1,6 @@
1
1
  {
2
2
  "name": "xkat-cli",
3
- "version": "2.7.1",
3
+ "version": "2.8.0",
4
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": {
@@ -1,8 +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 the release CDN and verifies
5
- * it against the checksum manifest published with this package.
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.
6
13
  */
7
14
  process.removeAllListeners('warning');
8
15
  process.emitWarning = () => {};
@@ -61,11 +68,53 @@ function fail(message, hint) {
61
68
  process.exitCode = 1;
62
69
  }
63
70
 
64
- async function main() {
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 = {}) {
65
112
  if (isSourceCheckout()) {
66
- console.log('🛠️ Agent source tree detected. Skipping binary download.');
67
- console.log(" (Use 'cargo build --release' for development)");
68
- return;
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;
69
118
  }
70
119
 
71
120
  const target = getPlatformTarget();
@@ -73,7 +122,7 @@ async function main() {
73
122
  // Not a failure: the package still installs, the user builds from source.
74
123
  console.log(`⚠️ xkat-cli: unsupported platform (${os.platform()}-${os.arch()})`);
75
124
  console.log(' Build manually: cd apps/xkat-agent && cargo build --release');
76
- return;
125
+ return null;
77
126
  }
78
127
 
79
128
  // Integrity verification is a documented guarantee, so a missing or
@@ -95,14 +144,17 @@ async function main() {
95
144
  );
96
145
  }
97
146
 
98
- const binDir = path.join(__dirname, '..', 'binaries');
99
- const destPath = path.join(binDir, target);
147
+ const destPath = opts.destPath || path.join(__dirname, '..', 'binaries', target);
148
+ const binDir = path.dirname(destPath);
100
149
 
101
150
  if (fs.existsSync(destPath)) {
102
151
  try {
103
152
  if ((await calculateSha256(destPath)) === expectedHash) {
104
- console.log(`✅ xkat-cli binary already present and verified: ${target}`);
105
- return;
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;
106
158
  }
107
159
  console.log('⚠️ Hash mismatch for existing binary. Re-downloading...');
108
160
  } catch (err) {
@@ -130,15 +182,22 @@ async function main() {
130
182
  fs.chmodSync(partPath, 0o755);
131
183
  }
132
184
  fs.renameSync(partPath, destPath);
185
+ try {
186
+ fs.writeFileSync(cachedVersionPath(destPath), VERSION);
187
+ } catch {
188
+ // 기록에 실패해도 바이너리는 쓸 수 있다. 다음 실행에서 한 번 더 받을 뿐이다.
189
+ }
133
190
 
134
191
  console.log('✅ Integrity verified: SHA-256 match');
135
- console.log('✅ Installed! Run: xkat-cli up');
192
+ if (!opts.quiet) console.log('✅ Installed! Run: xkat-cli up');
193
+ return destPath;
136
194
  } catch (err) {
137
195
  fs.rmSync(partPath, { force: true });
138
196
  fail(
139
197
  `binary download failed: ${err.message}`,
140
198
  'Retry the install, or build locally: cd apps/xkat-agent && cargo build --release'
141
199
  );
200
+ return null;
142
201
  }
143
202
  }
144
203
 
@@ -191,4 +250,9 @@ function downloadFile(urlStr, dest, redirectsLeft = MAX_REDIRECTS) {
191
250
  });
192
251
  }
193
252
 
194
- main();
253
+ module.exports = { ensureBinary, userBinaryPath, getPlatformTarget, readCachedVersion, VERSION };
254
+
255
+ // postinstall 로 실행됐을 때만 스스로 돈다. 래퍼가 require 할 때는 돌지 않는다.
256
+ if (require.main === module) {
257
+ ensureBinary();
258
+ }