uniner-cli 1.0.3 → 1.0.6

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.
@@ -1,428 +1,385 @@
1
- #!/usr/bin/env node
2
- // postinstall.js — runs after `npm install -g uniner-cli`.
3
- //
4
- // What it does:
5
- // 1. If the binary is already in place at ~/.uniner-cli/<version>/, exit (idempotent).
6
- // 2. Fetch latest.json from the OSS metadata URL to get platform URL + sha256.
7
- // 3. Stream-download the matching portable archive into temp dir.
8
- // - Windows: .zip
9
- // - Linux: .tar.gz
10
- // - Live progress every 2 seconds.
11
- // - Up to 3 retries with exponential backoff.
12
- // 4. Verify SHA-256.
13
- // 5. Extract via platform-native tools (PowerShell Expand-Archive / tar).
14
- // 6. Render SKILL.md.template with {{INSTALL_PATH}} / {{VERSION}} substituted
15
- // and write to ~/.claude/skills/uniner-cli/SKILL.md.
16
- // 7. Print "ready" and how to validate.
17
- //
18
- // Failures throw -> non-zero exit -> npm marks install as failed (strict mode).
19
- // User can then `npm install -g uniner-cli` again to retry cleanly.
20
-
21
- 'use strict';
22
-
23
- const fs = require('fs');
24
- const fsp = require('fs').promises;
25
- const path = require('path');
26
- const os = require('os');
27
- const https = require('https');
28
- const crypto = require('crypto');
29
- const { spawnSync } = require('child_process');
30
-
31
- const {
32
- pkg,
33
- versionDir,
34
- realBinaryPath,
35
- isInstalled,
36
- skillDir,
37
- skillFile,
38
- templatePath,
39
- } = require('../lib/paths');
40
-
41
- // ----------------------------------------------------------------------
42
- // Configuration
43
- // ----------------------------------------------------------------------
44
-
45
- const METADATA_URL =
46
- 'https://static.uniner.com/soft/editor/uninercli/latest.json';
47
-
48
- const DOWNLOAD_RETRIES = 3;
49
- const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 min per attempt
50
- const PROGRESS_INTERVAL_MS = 2000;
51
-
52
- // ----------------------------------------------------------------------
53
- // Logging
54
- // ----------------------------------------------------------------------
55
-
56
- function log(msg) { console.log(`[uniner-cli] ${msg}`); }
57
- function step(msg) { console.log(`[uniner-cli] ==> ${msg}`); }
58
- function fail(msg) { throw new Error(`[uniner-cli] ${msg}`); }
59
-
60
- // ----------------------------------------------------------------------
61
- // HTTP helpers
62
- // ----------------------------------------------------------------------
63
-
64
- function getJSON(url) {
65
- // Cache-bust to dodge any CDN that ignored the no-cache header.
66
- const sep = url.includes('?') ? '&' : '?';
67
- const bust = `${sep}t=${Date.now()}`;
68
- return new Promise((resolve, reject) => {
69
- const req = https.get(url + bust, { timeout: 30_000 }, res => {
70
- if (res.statusCode === 301 || res.statusCode === 302) {
71
- resolve(getJSON(res.headers.location));
72
- return;
73
- }
74
- if (res.statusCode !== 200) {
75
- reject(new Error(`HTTP ${res.statusCode} for ${url}`));
76
- return;
77
- }
78
- let buf = '';
79
- res.setEncoding('utf8');
80
- res.on('data', chunk => { buf += chunk; });
81
- res.on('end', () => {
82
- try { resolve(JSON.parse(buf)); }
83
- catch (e) { reject(new Error(`Bad JSON from ${url}: ${e.message}`)); }
84
- });
85
- });
86
- req.on('timeout', () => { req.destroy(new Error('timeout')); });
87
- req.on('error', reject);
88
- });
89
- }
90
-
91
- function downloadStream(url, outFile, totalBytesHint) {
92
- return new Promise((resolve, reject) => {
93
- const tmp = outFile + '.partial';
94
- const file = fs.createWriteStream(tmp);
95
- let received = 0;
96
- let total = totalBytesHint || 0;
97
- let lastReport = Date.now();
98
- let cleanup = () => {};
99
-
100
- const req = https.get(url, { timeout: DOWNLOAD_TIMEOUT_MS }, res => {
101
- if (res.statusCode === 301 || res.statusCode === 302) {
102
- file.close(() => fs.unlink(tmp, () => {}));
103
- resolve(downloadStream(res.headers.location, outFile, totalBytesHint));
104
- return;
105
- }
106
- if (res.statusCode !== 200) {
107
- file.close(() => fs.unlink(tmp, () => {}));
108
- reject(new Error(`HTTP ${res.statusCode} for ${url}`));
109
- return;
110
- }
111
-
112
- if (res.headers['content-length']) {
113
- total = parseInt(res.headers['content-length'], 10);
114
- }
115
-
116
- res.on('data', chunk => {
117
- received += chunk.length;
118
- file.write(chunk);
119
-
120
- const now = Date.now();
121
- if (now - lastReport >= PROGRESS_INTERVAL_MS) {
122
- const mbDone = (received / 1048576).toFixed(1);
123
- if (total > 0) {
124
- const mbTotal = (total / 1048576).toFixed(1);
125
- const pct = ((received * 100) / total).toFixed(1);
126
- log(` ${mbDone}/${mbTotal} MB (${pct}%)`);
127
- } else {
128
- log(` ${mbDone} MB downloaded`);
129
- }
130
- lastReport = now;
131
- }
132
- });
133
-
134
- res.on('end', () => {
135
- file.end(() => {
136
- fs.rename(tmp, outFile, err => {
137
- if (err) reject(err); else resolve(received);
138
- });
139
- });
140
- });
141
-
142
- cleanup = () => {
143
- res.destroy();
144
- file.close(() => fs.unlink(tmp, () => {}));
145
- };
146
- });
147
-
148
- req.on('timeout', () => {
149
- cleanup();
150
- req.destroy(new Error('download timed out'));
151
- });
152
- req.on('error', err => {
153
- cleanup();
154
- reject(err);
155
- });
156
- });
157
- }
158
-
159
- async function downloadWithRetry(url, outFile, totalBytesHint) {
160
- let lastErr;
161
- for (let attempt = 1; attempt <= DOWNLOAD_RETRIES; attempt++) {
162
- try {
163
- log(` Download attempt ${attempt}/${DOWNLOAD_RETRIES}...`);
164
- const bytes = await downloadStream(url, outFile, totalBytesHint);
165
- log(` Downloaded ${bytes} bytes`);
166
- return;
167
- } catch (e) {
168
- lastErr = e;
169
- log(` Attempt ${attempt} failed: ${e.message}`);
170
- try { await fsp.unlink(outFile); } catch (_) {}
171
- if (attempt < DOWNLOAD_RETRIES) {
172
- await new Promise(r => setTimeout(r, 5000 * attempt));
173
- }
174
- }
175
- }
176
- fail(`Download failed after ${DOWNLOAD_RETRIES} attempts: ${lastErr.message}`);
177
- }
178
-
179
- // ----------------------------------------------------------------------
180
- // SHA-256
181
- // ----------------------------------------------------------------------
182
-
183
- function sha256OfFile(file) {
184
- return new Promise((resolve, reject) => {
185
- const hash = crypto.createHash('sha256');
186
- const s = fs.createReadStream(file);
187
- s.on('data', c => hash.update(c));
188
- s.on('end', () => resolve(hash.digest('hex')));
189
- s.on('error', reject);
190
- });
191
- }
192
-
193
- // ----------------------------------------------------------------------
194
- // Extract via PowerShell (no native zip dep)
195
- // ----------------------------------------------------------------------
196
-
197
- function extractZipPS(zipFile, destDir) {
198
- fs.mkdirSync(destDir, { recursive: true });
199
-
200
- // Expand-Archive is built into Windows PowerShell 5.1+ (Win10/11 default).
201
- // -Force overwrites any partial leftover from a previous failed install.
202
- const ps = [
203
- '-NoProfile',
204
- '-NonInteractive',
205
- '-ExecutionPolicy', 'Bypass',
206
- '-Command',
207
- `Expand-Archive -LiteralPath '${zipFile}' -DestinationPath '${destDir}' -Force`,
208
- ];
209
- const result = spawnSync('powershell.exe', ps, { stdio: 'inherit' });
210
- if (result.error) {
211
- fail(`Could not invoke PowerShell to extract: ${result.error.message}`);
212
- }
213
- if (result.status !== 0) {
214
- fail(`Expand-Archive exited with status ${result.status}`);
215
- }
216
- }
217
-
218
- function extractTarGz(tarFile, destDir) {
219
- fs.mkdirSync(destDir, { recursive: true });
220
-
221
- const result = spawnSync('tar', ['-xzf', tarFile, '-C', destDir], {
222
- stdio: 'inherit',
223
- });
224
- if (result.error) {
225
- fail(`Could not invoke tar to extract: ${result.error.message}`);
226
- }
227
- if (result.status !== 0) {
228
- fail(`tar exited with status ${result.status}`);
229
- }
230
- }
231
-
232
- function getPlatformInfo(meta) {
233
- if (process.arch !== 'x64') {
234
- fail(`Unsupported architecture: ${process.arch}. Only x64 is supported.`);
235
- }
236
-
237
- if (process.platform === 'win32') {
238
- const info = meta && meta.windows && meta.windows.x64;
239
- if (!info || !info.portable_url || !info.portable_sha256) {
240
- fail('latest.json is missing windows.x64.portable_url / portable_sha256.');
241
- }
242
- return {
243
- platformKey: 'windows.x64',
244
- archiveUrl: info.portable_url,
245
- archiveSha256: info.portable_sha256,
246
- archiveSize: info.portable_size,
247
- extract: extractZipPS,
248
- expectedInnerBinary: path.join('bin', 'uniner-cli', 'uniner-cli.exe'),
249
- };
250
- }
251
-
252
- if (process.platform === 'linux') {
253
- const info = meta && meta.linux && meta.linux.x64;
254
- if (!info || !info.portable_url || !info.portable_sha256) {
255
- fail('latest.json is missing linux.x64.portable_url / portable_sha256.');
256
- }
257
- return {
258
- platformKey: 'linux.x64',
259
- archiveUrl: info.portable_url,
260
- archiveSha256: info.portable_sha256,
261
- archiveSize: info.portable_size,
262
- extract: extractTarGz,
263
- expectedInnerBinary: path.join('bin', 'uniner-cli', 'uniner-cli'),
264
- };
265
- }
266
-
267
- fail(`Unsupported platform: ${process.platform}. Supported: win32-x64, linux-x64.`);
268
- }
269
-
270
- // ----------------------------------------------------------------------
271
- // SKILL.md template render
272
- // ----------------------------------------------------------------------
273
-
274
- function writeSkillMd(installPath, version) {
275
- const tplPath = templatePath();
276
- if (!fs.existsSync(tplPath)) {
277
- log(` [WARN] SKILL.md.template missing at ${tplPath}; skipping skill install.`);
278
- return;
279
- }
280
- let content = fs.readFileSync(tplPath, 'utf8');
281
- // PowerShell-side StringChangeEx is case-insensitive; here we keep it strict
282
- // because our template uses exactly these tokens.
283
- content = content.split('{{INSTALL_PATH}}').join(installPath);
284
- content = content.split('{{VERSION}}').join(version);
285
-
286
- const dir = skillDir();
287
- fs.mkdirSync(dir, { recursive: true });
288
-
289
- // IMPORTANT: only writeFileSync the single SKILL.md file. Do NOT touch
290
- // anything else under ~/.claude/skills/uniner-cli/. In particular, the
291
- // scenarios/ subdirectory is the user's accumulated knowledge — if we
292
- // ever rmdir or rmSync this directory we'd wipe their library on every
293
- // upgrade. Single-file write is the contract.
294
- fs.writeFileSync(skillFile(), content, 'utf8');
295
- log(` SKILL.md written to ${skillFile()}`);
296
-
297
- // Tell the user we kept their scenarios (if any).
298
- const scenariosDir = path.join(dir, 'scenarios');
299
- if (fs.existsSync(scenariosDir)) {
300
- try {
301
- const count = fs.readdirSync(scenariosDir).filter(n => {
302
- const meta = path.join(scenariosDir, n, 'meta.json');
303
- return fs.existsSync(meta);
304
- }).length;
305
- log(` [INFO] Preserved ${count} scenario(s) in ${scenariosDir}`);
306
- } catch (_) {
307
- // best-effort; never fail postinstall over a logging stat
308
- }
309
- }
310
- }
311
-
312
- // ----------------------------------------------------------------------
313
- // Main
314
- // ----------------------------------------------------------------------
315
-
316
- async function main() {
317
- console.log('');
318
- console.log('==========================================');
319
- console.log(' uniner-cli postinstall');
320
- console.log('==========================================');
321
-
322
- const version = pkg.version;
323
-
324
- // Skip when a working binary is already there. This makes
325
- // `npm install -g uniner-cli` idempotent and survives `npm rebuild`.
326
- if (isInstalled(version)) {
327
- log(`Already installed at ${realBinaryPath(version)}; skipping download.`);
328
- log('To force a clean reinstall:');
329
- log(' npm uninstall -g uniner-cli && npm install -g uniner-cli');
330
- // Still re-render the skill — it's tiny and lets users edit the template
331
- // and re-run rebuild to refresh.
332
- writeSkillMd(versionDir(version), version);
333
- return;
334
- }
335
-
336
- step('Reading version metadata');
337
- log(` URL: ${METADATA_URL}`);
338
- const meta = await getJSON(METADATA_URL);
339
-
340
- const platform = getPlatformInfo(meta);
341
- log(` Latest version: ${meta.version}`);
342
- log(` Package version (this npm release): ${version}`);
343
- log(` Platform: ${platform.platformKey}`);
344
-
345
- if (meta.version !== version) {
346
- log(` [INFO] Server has ${meta.version} but this npm package is pinned ` +
347
- `to ${version}. Will fetch the package artifacts from latest.json.`);
348
- }
349
-
350
- step('Downloading portable archive');
351
- const tmpDir = path.join(os.tmpdir(), 'uniner-cli-npm-install');
352
- fs.mkdirSync(tmpDir, { recursive: true });
353
- const archivePath = path.join(tmpDir, path.basename(platform.archiveUrl));
354
-
355
- log(` From: ${platform.archiveUrl}`);
356
- log(` To: ${archivePath}`);
357
- await downloadWithRetry(platform.archiveUrl, archivePath, platform.archiveSize);
358
-
359
- step('Verifying SHA-256');
360
- const actual = await sha256OfFile(archivePath);
361
- if (actual.toLowerCase() !== platform.archiveSha256.toLowerCase()) {
362
- try { fs.unlinkSync(archivePath); } catch (_) {}
363
- fail(`SHA-256 mismatch.\n expected: ${platform.archiveSha256}\n got: ${actual}`);
364
- }
365
- log(' Checksum verified.');
366
-
367
- step('Extracting');
368
- const targetDir = versionDir(version);
369
- log(` Target: ${targetDir}`);
370
- // If a previous extraction left a half-written tree, blow it away.
371
- if (fs.existsSync(targetDir)) {
372
- log(' Removing previous half-extracted tree...');
373
- fs.rmSync(targetDir, { recursive: true, force: true });
374
- }
375
- platform.extract(archivePath, targetDir);
376
-
377
- // Portable archives put everything inside an extra top-level folder
378
- // (`uniner-cli-portable` on Windows, `uniner-cli-linux-portable` on Linux).
379
- // If we see exactly one subdir and the expected binary is one level deeper,
380
- // flatten so realBinaryPath() resolves cleanly.
381
- const entries = fs.readdirSync(targetDir);
382
- if (entries.length === 1) {
383
- const inner = path.join(targetDir, entries[0]);
384
- if (fs.statSync(inner).isDirectory() &&
385
- fs.existsSync(path.join(inner, platform.expectedInnerBinary))) {
386
- log(' Flattening extracted tree...');
387
- for (const item of fs.readdirSync(inner)) {
388
- fs.renameSync(path.join(inner, item), path.join(targetDir, item));
389
- }
390
- fs.rmdirSync(inner);
391
- }
392
- }
393
-
394
- if (!isInstalled(version)) {
395
- fail(`Extraction completed but the binary is not at ${realBinaryPath(version)}. ` +
396
- `Inspect ${targetDir} to debug.`);
397
- }
398
-
399
- step('Cleaning up');
400
- try { fs.unlinkSync(archivePath); } catch (_) {}
401
- try { fs.rmdirSync(tmpDir); } catch (_) {}
402
- log(' Removed temporary files.');
403
-
404
- step('Installing Claude Code skill');
405
- writeSkillMd(targetDir, version);
406
-
407
- console.log('');
408
- console.log('==========================================');
409
- console.log(` uniner-cli ${version} installed`);
410
- console.log('==========================================');
411
- console.log('');
412
- console.log(` Location: ${targetDir}`);
413
- console.log(` Skill: ${skillFile()}`);
414
- console.log('');
415
- console.log(' Validate: uniner-cli --help');
416
- console.log('');
417
- }
418
-
419
- main().catch(err => {
420
- console.error('');
421
- console.error(err.message || String(err));
422
- console.error('');
423
- console.error('Install failed. To retry:');
424
- console.error(' npm uninstall -g uniner-cli');
425
- console.error(' npm install -g uniner-cli');
426
- console.error('');
427
- process.exit(1);
428
- });
1
+ #!/usr/bin/env node
2
+ // postinstall.js — runs after `npm install -g uniner-cli`.
3
+ //
4
+ // What it does:
5
+ // 1. If the binary is already in place at ~/.uniner-cli/<version>/, exit (idempotent).
6
+ // 2. Fetch latest.json from the OSS metadata URL to get portable_url + sha256.
7
+ // 3. Stream-download the matching portable .zip into %TEMP%.
8
+ // - Live progress every 2 seconds.
9
+ // - Up to 3 retries with exponential backoff.
10
+ // 4. Verify SHA-256.
11
+ // 5. Extract via PowerShell's Expand-Archive (no native dep).
12
+ // 6. Render SKILL.md.template with {{INSTALL_PATH}} / {{VERSION}} substituted
13
+ // and write to ~/.claude/skills/uniner-cli/SKILL.md.
14
+ // 7. Print "ready" and how to validate.
15
+ //
16
+ // Failures throw -> non-zero exit -> npm marks install as failed (strict mode).
17
+ // User can then `npm install -g uniner-cli` again to retry cleanly.
18
+
19
+ 'use strict';
20
+
21
+ const fs = require('fs');
22
+ const fsp = require('fs').promises;
23
+ const path = require('path');
24
+ const os = require('os');
25
+ const https = require('https');
26
+ const crypto = require('crypto');
27
+ const { spawnSync } = require('child_process');
28
+
29
+ const {
30
+ pkg,
31
+ versionDir,
32
+ realBinaryPath,
33
+ isInstalled,
34
+ skillDir,
35
+ skillFile,
36
+ templatePath,
37
+ } = require('../lib/paths');
38
+
39
+ const {
40
+ getPlatformInfo,
41
+ selectMetadata,
42
+ extractArchive,
43
+ getBinaryName
44
+ } = require('./postinstall-helpers');
45
+
46
+ // ----------------------------------------------------------------------
47
+ // Configuration
48
+ // ----------------------------------------------------------------------
49
+
50
+ const METADATA_URL =
51
+ 'https://static.uniner.com/soft/editor/uninercli/latest.json';
52
+
53
+ const DOWNLOAD_RETRIES = 3;
54
+ const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 min per attempt
55
+ const PROGRESS_INTERVAL_MS = 2000;
56
+
57
+ // ----------------------------------------------------------------------
58
+ // Logging
59
+ // ----------------------------------------------------------------------
60
+
61
+ function log(msg) { console.log(`[uniner-cli] ${msg}`); }
62
+ function step(msg) { console.log(`[uniner-cli] ==> ${msg}`); }
63
+ function fail(msg) { throw new Error(`[uniner-cli] ${msg}`); }
64
+
65
+ // ----------------------------------------------------------------------
66
+ // HTTP helpers
67
+ // ----------------------------------------------------------------------
68
+
69
+ function getJSON(url) {
70
+ // Cache-bust to dodge any CDN that ignored the no-cache header.
71
+ const sep = url.includes('?') ? '&' : '?';
72
+ const bust = `${sep}t=${Date.now()}`;
73
+ return new Promise((resolve, reject) => {
74
+ const req = https.get(url + bust, { timeout: 30_000 }, res => {
75
+ if (res.statusCode === 301 || res.statusCode === 302) {
76
+ resolve(getJSON(res.headers.location));
77
+ return;
78
+ }
79
+ if (res.statusCode !== 200) {
80
+ reject(new Error(`HTTP ${res.statusCode} for ${url}`));
81
+ return;
82
+ }
83
+ let buf = '';
84
+ res.setEncoding('utf8');
85
+ res.on('data', chunk => { buf += chunk; });
86
+ res.on('end', () => {
87
+ try { resolve(JSON.parse(buf)); }
88
+ catch (e) { reject(new Error(`Bad JSON from ${url}: ${e.message}`)); }
89
+ });
90
+ });
91
+ req.on('timeout', () => { req.destroy(new Error('timeout')); });
92
+ req.on('error', reject);
93
+ });
94
+ }
95
+
96
+ function downloadStream(url, outFile, totalBytesHint) {
97
+ return new Promise((resolve, reject) => {
98
+ const tmp = outFile + '.partial';
99
+ const file = fs.createWriteStream(tmp);
100
+ let received = 0;
101
+ let total = totalBytesHint || 0;
102
+ let lastReport = Date.now();
103
+ let cleanup = () => {};
104
+
105
+ const req = https.get(url, { timeout: DOWNLOAD_TIMEOUT_MS }, res => {
106
+ if (res.statusCode === 301 || res.statusCode === 302) {
107
+ file.close(() => fs.unlink(tmp, () => {}));
108
+ resolve(downloadStream(res.headers.location, outFile, totalBytesHint));
109
+ return;
110
+ }
111
+ if (res.statusCode !== 200) {
112
+ file.close(() => fs.unlink(tmp, () => {}));
113
+ reject(new Error(`HTTP ${res.statusCode} for ${url}`));
114
+ return;
115
+ }
116
+
117
+ if (res.headers['content-length']) {
118
+ total = parseInt(res.headers['content-length'], 10);
119
+ }
120
+
121
+ res.on('data', chunk => {
122
+ received += chunk.length;
123
+ file.write(chunk);
124
+
125
+ const now = Date.now();
126
+ if (now - lastReport >= PROGRESS_INTERVAL_MS) {
127
+ const mbDone = (received / 1048576).toFixed(1);
128
+ if (total > 0) {
129
+ const mbTotal = (total / 1048576).toFixed(1);
130
+ const pct = ((received * 100) / total).toFixed(1);
131
+ log(` ${mbDone}/${mbTotal} MB (${pct}%)`);
132
+ } else {
133
+ log(` ${mbDone} MB downloaded`);
134
+ }
135
+ lastReport = now;
136
+ }
137
+ });
138
+
139
+ res.on('end', () => {
140
+ file.end(() => {
141
+ fs.rename(tmp, outFile, err => {
142
+ if (err) reject(err); else resolve(received);
143
+ });
144
+ });
145
+ });
146
+
147
+ cleanup = () => {
148
+ res.destroy();
149
+ file.close(() => fs.unlink(tmp, () => {}));
150
+ };
151
+ });
152
+
153
+ req.on('timeout', () => {
154
+ cleanup();
155
+ req.destroy(new Error('download timed out'));
156
+ });
157
+ req.on('error', err => {
158
+ cleanup();
159
+ reject(err);
160
+ });
161
+ });
162
+ }
163
+
164
+ async function downloadWithRetry(url, outFile, totalBytesHint) {
165
+ let lastErr;
166
+ for (let attempt = 1; attempt <= DOWNLOAD_RETRIES; attempt++) {
167
+ try {
168
+ log(` Download attempt ${attempt}/${DOWNLOAD_RETRIES}...`);
169
+ const bytes = await downloadStream(url, outFile, totalBytesHint);
170
+ log(` Downloaded ${bytes} bytes`);
171
+ return;
172
+ } catch (e) {
173
+ lastErr = e;
174
+ log(` Attempt ${attempt} failed: ${e.message}`);
175
+ try { await fsp.unlink(outFile); } catch (_) {}
176
+ if (attempt < DOWNLOAD_RETRIES) {
177
+ await new Promise(r => setTimeout(r, 5000 * attempt));
178
+ }
179
+ }
180
+ }
181
+ fail(`Download failed after ${DOWNLOAD_RETRIES} attempts: ${lastErr.message}`);
182
+ }
183
+
184
+ // ----------------------------------------------------------------------
185
+ // SHA-256
186
+ // ----------------------------------------------------------------------
187
+
188
+ function sha256OfFile(file) {
189
+ return new Promise((resolve, reject) => {
190
+ const hash = crypto.createHash('sha256');
191
+ const s = fs.createReadStream(file);
192
+ s.on('data', c => hash.update(c));
193
+ s.on('end', () => resolve(hash.digest('hex')));
194
+ s.on('error', reject);
195
+ });
196
+ }
197
+
198
+ // ----------------------------------------------------------------------
199
+ // SKILL.md template render
200
+ // ----------------------------------------------------------------------
201
+
202
+ function writeSkillMd(installPath, version) {
203
+ const tplPath = templatePath();
204
+ if (!fs.existsSync(tplPath)) {
205
+ log(` [WARN] SKILL.md.template missing at ${tplPath}; skipping skill install.`);
206
+ return;
207
+ }
208
+ let content = fs.readFileSync(tplPath, 'utf8');
209
+ // PowerShell-side StringChangeEx is case-insensitive; here we keep it strict
210
+ // because our template uses exactly these tokens.
211
+ content = content.split('{{INSTALL_PATH}}').join(installPath);
212
+ content = content.split('{{VERSION}}').join(version);
213
+
214
+ const dir = skillDir();
215
+ fs.mkdirSync(dir, { recursive: true });
216
+
217
+ // IMPORTANT: only writeFileSync the single SKILL.md file. Do NOT touch
218
+ // anything else under ~/.claude/skills/uniner-cli/. In particular, the
219
+ // scenarios/ subdirectory is the user's accumulated knowledge — if we
220
+ // ever rmdir or rmSync this directory we'd wipe their library on every
221
+ // upgrade. Single-file write is the contract.
222
+ fs.writeFileSync(skillFile(), content, 'utf8');
223
+ log(` SKILL.md written to ${skillFile()}`);
224
+
225
+ // Tell the user we kept their scenarios (if any).
226
+ const scenariosDir = path.join(dir, 'scenarios');
227
+ if (fs.existsSync(scenariosDir)) {
228
+ try {
229
+ const count = fs.readdirSync(scenariosDir).filter(n => {
230
+ const meta = path.join(scenariosDir, n, 'meta.json');
231
+ return fs.existsSync(meta);
232
+ }).length;
233
+ log(` [INFO] Preserved ${count} scenario(s) in ${scenariosDir}`);
234
+ } catch (_) {
235
+ // best-effort; never fail postinstall over a logging stat
236
+ }
237
+ }
238
+ }
239
+
240
+ // ----------------------------------------------------------------------
241
+ // Main
242
+ // ----------------------------------------------------------------------
243
+
244
+ async function main() {
245
+ console.log('');
246
+ console.log('==========================================');
247
+ console.log(' uniner-cli postinstall');
248
+ console.log('==========================================');
249
+
250
+ // 检查平台支持
251
+ const platformInfo = getPlatformInfo();
252
+ if (!platformInfo) {
253
+ fail(`不支持的平台: ${process.platform} ${process.arch}。` +
254
+ '当前仅支持 Windows x64 和 macOS ARM64。');
255
+ }
256
+
257
+ log(`平台: ${platformInfo.platform} ${platformInfo.arch}`);
258
+
259
+ const version = pkg.version;
260
+
261
+ // Skip when a working binary is already there. This makes
262
+ // `npm install -g uniner-cli` idempotent and survives `npm rebuild`.
263
+ if (isInstalled(version)) {
264
+ log(`Already installed at ${realBinaryPath(version)}; skipping download.`);
265
+ log('To force a clean reinstall:');
266
+ log(' npm uninstall -g uniner-cli && npm install -g uniner-cli');
267
+ // Still re-render the skill — it's tiny and lets users edit the template
268
+ // and re-run rebuild to refresh.
269
+ writeSkillMd(versionDir(version), version);
270
+ return;
271
+ }
272
+
273
+ step('Reading version metadata');
274
+ log(` URL: ${METADATA_URL}`);
275
+ const meta = await getJSON(METADATA_URL);
276
+
277
+ // 根据平台选择元数据
278
+ const platformMeta = selectMetadata(meta);
279
+ log(` Latest version: ${meta.version}`);
280
+ log(` Package version (this npm release): ${version}`);
281
+
282
+ if (meta.version !== version) {
283
+ log(` [INFO] Server has ${meta.version} but this npm package is pinned ` +
284
+ `to ${version}. Will fetch the package's own version artifacts ` +
285
+ `from the OSS path that the URL implies.`);
286
+ }
287
+
288
+ step('Downloading portable archive');
289
+ const tmpDir = path.join(os.tmpdir(), 'uniner-cli-npm-install');
290
+ fs.mkdirSync(tmpDir, { recursive: true });
291
+ const archivePath = path.join(tmpDir, path.basename(platformMeta.portable_url));
292
+
293
+ log(` From: ${platformMeta.portable_url}`);
294
+ log(` To: ${archivePath}`);
295
+ await downloadWithRetry(platformMeta.portable_url, archivePath, platformMeta.portable_size);
296
+
297
+ step('Verifying SHA-256');
298
+ const actual = await sha256OfFile(archivePath);
299
+ if (actual.toLowerCase() !== platformMeta.portable_sha256.toLowerCase()) {
300
+ try { fs.unlinkSync(archivePath); } catch (_) {}
301
+ fail(`SHA-256 mismatch.\n expected: ${platformMeta.portable_sha256}\n got: ${actual}`);
302
+ }
303
+ log(' Checksum verified.');
304
+
305
+ step('Extracting');
306
+ const targetDir = versionDir(version);
307
+ log(` Target: ${targetDir}`);
308
+ // If a previous extraction left a half-written tree, blow it away.
309
+ if (fs.existsSync(targetDir)) {
310
+ log(' Removing previous half-extracted tree...');
311
+ fs.rmSync(targetDir, { recursive: true, force: true });
312
+ }
313
+ extractArchive(archivePath, targetDir);
314
+
315
+ // 获取当前平台的二进制文件名
316
+ const binaryName = getBinaryName();
317
+
318
+ // build_portable.bat / build_cli_mac.sh puts everything inside an extra top-level
319
+ // folder. If we see exactly one subdir and the binary is one level deeper,
320
+ // flatten so realBinaryPath() resolves cleanly.
321
+ const entries = fs.readdirSync(targetDir);
322
+ if (entries.length === 1) {
323
+ const inner = path.join(targetDir, entries[0]);
324
+ if (fs.statSync(inner).isDirectory() &&
325
+ fs.existsSync(path.join(inner, 'bin', 'uniner-cli', binaryName))) {
326
+ log(' Flattening extracted tree...');
327
+ for (const item of fs.readdirSync(inner)) {
328
+ fs.renameSync(path.join(inner, item), path.join(targetDir, item));
329
+ }
330
+ fs.rmdirSync(inner);
331
+ }
332
+ }
333
+
334
+ if (!isInstalled(version)) {
335
+ fail(`Extraction completed but the binary is not at ${realBinaryPath(version)}. ` +
336
+ `Inspect ${targetDir} to debug.`);
337
+ }
338
+
339
+ step('Cleaning up');
340
+ try { fs.unlinkSync(archivePath); } catch (_) {}
341
+ try { fs.rmdirSync(tmpDir); } catch (_) {}
342
+ log(' Removed temporary files.');
343
+
344
+ step('Installing Claude Code skill');
345
+ writeSkillMd(targetDir, version);
346
+
347
+ console.log('');
348
+ console.log('==========================================');
349
+ console.log(` uniner-cli ${version} installed`);
350
+ console.log('==========================================');
351
+ console.log('');
352
+ console.log(` Location: ${targetDir}`);
353
+ console.log(` Skill: ${skillFile()}`);
354
+ console.log('');
355
+
356
+ // macOS 首次运行提示(无代码签名)
357
+ if (platformInfo.platform === 'darwin') {
358
+ console.log(' ⚠️ macOS 首次运行提示:');
359
+ console.log(' 如果系统提示"无法验证开发者",请前往:');
360
+ console.log(' 系统设置 → 隐私与安全性 → 仍要打开');
361
+ console.log('');
362
+ }
363
+
364
+ // Linux headless 环境提示
365
+ if (platformInfo.platform === 'linux') {
366
+ console.log(' ℹ️ Linux 环境提示:');
367
+ console.log(' 浏览器自动化(web 模块)在 headless 环境下');
368
+ console.log(' 可能需要 xvfb 或设置 Playwright HTTPS-only 模式');
369
+ console.log('');
370
+ }
371
+
372
+ console.log(' Validate: uniner-cli --help');
373
+ console.log('');
374
+ }
375
+
376
+ main().catch(err => {
377
+ console.error('');
378
+ console.error(err.message || String(err));
379
+ console.error('');
380
+ console.error('Install failed. To retry:');
381
+ console.error(' npm uninstall -g uniner-cli');
382
+ console.error(' npm install -g uniner-cli');
383
+ console.error('');
384
+ process.exit(1);
385
+ });