sphere-cli 0.2.7 → 0.2.8

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/bin/sphere.js CHANGED
@@ -5,30 +5,36 @@
5
5
  * sphere — thin launcher.
6
6
  *
7
7
  * The actual CLI is a sealed, signed+notarized native binary (PyInstaller bundle
8
- * of the SPHERE engine same engine as the desktop app). The npm `postinstall`
9
- * downloads the platform-specific binary into ./vendor/. This launcher just
10
- * forwards argv to it. No readable algorithm code ships in the npm package.
8
+ * of the SPHERE engine). It is auto-placed in a roomy directory by the install
9
+ * step (see scripts/engine.js). This launcher locates it, re-downloads it if it
10
+ * has gone missing (e.g. scratch purge), optionally caches it on node-local disk
11
+ * for fast startup on network filesystems, and forwards argv. No algorithm code
12
+ * ships in the npm package.
11
13
  */
12
14
 
13
- const path = require('path');
14
- const fs = require('fs');
15
15
  const { spawnSync } = require('child_process');
16
+ const engine = require('../scripts/engine.js');
16
17
 
17
- const BIN = path.join(__dirname, '..', 'vendor', 'sphere-cli', 'sphere');
18
+ const tell = (m) => process.stderr.write(`sphere: ${m}\n`);
18
19
 
19
- if (!fs.existsSync(BIN)) {
20
- process.stderr.write(
21
- '\nSPHERE binary not found.\n' +
22
- 'The platform binary is downloaded by the install step. Try reinstalling:\n' +
23
- ' npm install -g sphere-cli\n' +
24
- 'If your platform is unsupported, see https://github.com/statzihuai/sphere-cli\n',
25
- );
20
+ let bin;
21
+ try {
22
+ bin = engine.resolveBinary();
23
+ if (!bin) {
24
+ tell('engine not found — installing it now (one-time) ');
25
+ bin = engine.ensureInstalled((m) => process.stderr.write(` ${m}\n`));
26
+ }
27
+ } catch (e) {
28
+ tell(`could not locate or install the engine: ${e.message}`);
29
+ tell('try: npm install -g sphere-cli (or set SPHERE_HOME=/path/with/space)');
26
30
  process.exit(1);
27
31
  }
28
32
 
29
- const res = spawnSync(BIN, process.argv.slice(2), { stdio: 'inherit' });
33
+ const run = engine.fastBinary(bin, tell);
34
+
35
+ const res = spawnSync(run, process.argv.slice(2), { stdio: 'inherit' });
30
36
  if (res.error) {
31
- process.stderr.write(`Failed to launch sphere: ${res.error.message}\n`);
37
+ tell(`failed to launch: ${res.error.message}`);
32
38
  process.exit(1);
33
39
  }
34
40
  process.exit(res.status === null ? 1 : res.status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sphere-cli",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "SPHERE CLI — synthetic data generation, evaluation, and certification (sealed native binary)",
5
5
  "keywords": [
6
6
  "synthetic-data",
@@ -24,7 +24,8 @@
24
24
  },
25
25
  "files": [
26
26
  "bin/sphere.js",
27
- "scripts/postinstall.js"
27
+ "scripts/postinstall.js",
28
+ "scripts/engine.js"
28
29
  ],
29
30
  "engines": {
30
31
  "node": ">=18.0.0"
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared engine logic for the SPHERE CLI npm package — used by BOTH the
5
+ * postinstall (to place the binary) and the launcher (to find / self-heal /
6
+ * fast-start it). No algorithm code lives here; this only locates, downloads,
7
+ * verifies, and runs the sealed native binary.
8
+ *
9
+ * Design goals (works on ANY HPC / laptop, zero per-cluster config):
10
+ * 1. The ~500 MB binary is NOT kept inside node_modules (that forces users to
11
+ * repoint `npm config set prefix` to dodge home-dir quotas). Instead it is
12
+ * placed in an auto-detected roomy directory — a generic scan of common HPC
13
+ * scratch/project vars, falling back to the XDG data dir.
14
+ * 2. The launcher self-heals: if the binary is gone (scratch purged, moved),
15
+ * it re-downloads on next run.
16
+ * 3. Fast-start: if the binary lives on a *network* filesystem (Lustre/NFS/
17
+ * GPFS — slow to open the ~330 bundled libs), cache a copy on node-local
18
+ * disk so startup isn't dominated by network round-trips.
19
+ *
20
+ * Env overrides: SPHERE_HOME (install dir), SPHERE_NO_FAST=1, SPHERE_FAST_DIR,
21
+ * SPHERE_NO_PATH_SETUP=1, SPHERE_BINARY_BASEURL, SPHERE_SKIP_POSTINSTALL.
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const os = require('os');
26
+ const path = require('path');
27
+ const crypto = require('crypto');
28
+ const { execFileSync } = require('child_process');
29
+
30
+ const REPO = 'statzihuai/sphere-cli';
31
+ // Binary release tag — decoupled from the npm package version so JS-only patch
32
+ // releases reuse the same prebuilt/notarized binaries.
33
+ const BINARY_RELEASE = 'v0.2.6';
34
+
35
+ const PLATFORM = process.platform; // 'darwin' | 'linux'
36
+ const ARCH = process.arch; // 'arm64' | 'x64'
37
+ const KEY = `${PLATFORM}-${ARCH}`;
38
+ const SUPPORTED = new Set(['darwin-arm64', 'darwin-x64', 'linux-x64']);
39
+ const ASSET = `sphere-${KEY}.tar.gz`;
40
+ const BASE = process.env.SPHERE_BINARY_BASEURL
41
+ || `https://github.com/${REPO}/releases/download/${BINARY_RELEASE}`;
42
+
43
+ // ── small persistent record of where the binary was installed ────────────────
44
+ // Lives in the (tiny, always-writable) XDG config dir so the launcher can find
45
+ // the binary regardless of which roomy dir the installer chose.
46
+ function configFile() {
47
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
48
+ return path.join(base, 'sphere-cli', 'install.json');
49
+ }
50
+ function readRecord() {
51
+ try { return JSON.parse(fs.readFileSync(configFile(), 'utf8')); } catch (_) { return null; }
52
+ }
53
+ function writeRecord(rec) {
54
+ try {
55
+ fs.mkdirSync(path.dirname(configFile()), { recursive: true });
56
+ fs.writeFileSync(configFile(), JSON.stringify(rec, null, 2));
57
+ } catch (_) { /* best-effort */ }
58
+ }
59
+
60
+ // ── free space (GB) on the filesystem holding `dir` (best-effort) ────────────
61
+ function freeGB(dir) {
62
+ try {
63
+ let p = dir;
64
+ while (p && !fs.existsSync(p)) p = path.dirname(p);
65
+ if (!p) return Infinity; // unknown → don't exclude
66
+ if (typeof fs.statfsSync === 'function') {
67
+ const s = fs.statfsSync(p);
68
+ return (s.bavail * s.bsize) / 1e9;
69
+ }
70
+ } catch (_) {}
71
+ return Infinity; // can't tell → optimistic; real test is the extraction
72
+ }
73
+
74
+ // ── filesystem type magic (Linux statfs) → is this a slow network FS? ────────
75
+ const NETWORK_FS = new Set([
76
+ 0x6969, // NFS
77
+ 0xff534d42, // CIFS/SMB
78
+ 0x0bd00bd0, // Lustre
79
+ 0x47504653, // GPFS
80
+ 0x19830326, // BeeGFS/FhGFS
81
+ 0x65735546, // FUSE (often network-backed: sshfs, etc.)
82
+ 0x1161970, // CODA
83
+ ]);
84
+ function isNetworkFS(dir) {
85
+ try {
86
+ if (typeof fs.statfsSync !== 'function') return false;
87
+ let p = dir;
88
+ while (p && !fs.existsSync(p)) p = path.dirname(p);
89
+ const t = fs.statfsSync(p).type;
90
+ return NETWORK_FS.has(t);
91
+ } catch (_) { return false; }
92
+ }
93
+
94
+ // ── ordered list of dirs to try installing into (roomy, persistent-preferred) ─
95
+ function candidateDirs() {
96
+ const sub = path.join('sphere-cli', BINARY_RELEASE);
97
+ if (process.env.SPHERE_HOME) return [path.join(process.env.SPHERE_HOME, BINARY_RELEASE)];
98
+
99
+ const home = os.homedir();
100
+ const env = process.env;
101
+ const looksHpc = !!(env.SCRATCH || env.WORK || env.PROJECT || env.OAK
102
+ || env.GROUP_HOME || env.PI_HOME || env.SLURM_JOB_ID || env.PBS_JOBID
103
+ || env.LSB_JOBID || env.GROUP_SCRATCH || env.PSCRATCH);
104
+
105
+ const persistent = ['WORK', 'PROJECT', 'PROJECTS', 'OAK', 'GROUP_HOME', 'PI_HOME', 'DATA']
106
+ .map((v) => env[v]).filter(Boolean);
107
+ const scratch = ['SCRATCH', 'PSCRATCH', 'GROUP_SCRATCH']
108
+ .map((v) => env[v]).filter(Boolean);
109
+
110
+ const xdg = env.XDG_DATA_HOME
111
+ ? path.join(env.XDG_DATA_HOME, 'sphere-cli', BINARY_RELEASE)
112
+ : path.join(home, '.local', 'share', sub);
113
+
114
+ // On a cluster: prefer roomy persistent storage, then scratch; always keep the
115
+ // XDG home dir as a last resort. On a laptop: just the XDG dir.
116
+ let candidates = looksHpc
117
+ ? [...persistent, ...scratch].map((d) => path.join(d, '.' + sub)).concat([xdg])
118
+ : [xdg];
119
+
120
+ // Stable de-dup, then float the ones that *look* like they have space to the
121
+ // front (df can't see per-user quotas, so this is a hint — installInto() falls
122
+ // through to the next candidate if extraction actually runs out of space).
123
+ candidates = [...new Set(candidates)];
124
+ const withSpace = candidates.filter((c) => freeGB(path.dirname(path.dirname(c))) >= 1.5);
125
+ const without = candidates.filter((c) => !withSpace.includes(c));
126
+ return [...withSpace, ...without];
127
+ }
128
+
129
+ // Ensure the binary is installed; returns its path. Idempotent — if a matching
130
+ // install is already recorded and present, returns it without re-downloading.
131
+ // Tries each candidate dir until one works (handles quota/space failures).
132
+ function ensureInstalled(log) {
133
+ log = log || (() => {});
134
+ const existing = resolveBinary();
135
+ if (existing) return existing;
136
+ let lastErr;
137
+ for (const dir of candidateDirs()) {
138
+ try {
139
+ const bin = installInto(dir, log);
140
+ writeRecord({ release: BINARY_RELEASE, key: KEY, dir, bin });
141
+ return bin;
142
+ } catch (e) {
143
+ lastErr = e;
144
+ try { fs.rmSync(path.join(dir, 'sphere-cli'), { recursive: true, force: true }); } catch (_) {}
145
+ }
146
+ }
147
+ throw lastErr || new Error('no writable install location with enough space found');
148
+ }
149
+
150
+ // ── download + checksum + extract into <dir>/sphere-cli ──────────────────────
151
+ function curl(url, dest) {
152
+ execFileSync('curl', ['-fL', '--retry', '3', '--retry-delay', '2',
153
+ '--connect-timeout', '30', '-o', dest, url], { stdio: ['ignore', 'ignore', 'inherit'] });
154
+ }
155
+ function sha256(file) {
156
+ const h = crypto.createHash('sha256');
157
+ h.update(fs.readFileSync(file));
158
+ return h.digest('hex');
159
+ }
160
+
161
+ // Download the platform tarball into `dir`, verify SHA256, extract so that
162
+ // `dir/sphere-cli/sphere` exists. Returns the binary path. Throws on failure.
163
+ function installInto(dir, log) {
164
+ log = log || (() => {});
165
+ fs.mkdirSync(dir, { recursive: true });
166
+ const tarball = path.join(dir, ASSET);
167
+
168
+ log(`downloading ${ASSET} (${BINARY_RELEASE}) …`);
169
+ curl(`${BASE}/${ASSET}`, tarball);
170
+
171
+ const sumsFile = path.join(dir, 'SHA256SUMS.txt');
172
+ curl(`${BASE}/SHA256SUMS.txt`, sumsFile);
173
+ const expected = fs.readFileSync(sumsFile, 'utf8').split('\n')
174
+ .map((l) => l.trim().split(/\s+/))
175
+ .find((p) => p[1] && p[1].endsWith(ASSET));
176
+ if (!expected) throw new Error(`no checksum for ${ASSET}`);
177
+ const got = sha256(tarball);
178
+ if (got !== expected[0]) throw new Error(`checksum mismatch for ${ASSET}`);
179
+ log('checksum verified ✓');
180
+
181
+ fs.rmSync(path.join(dir, 'sphere-cli'), { recursive: true, force: true });
182
+ execFileSync('tar', ['-xzf', tarball, '-C', dir], { stdio: 'inherit' });
183
+ fs.rmSync(tarball, { force: true });
184
+ fs.rmSync(sumsFile, { force: true });
185
+
186
+ const bin = path.join(dir, 'sphere-cli', 'sphere');
187
+ if (!fs.existsSync(bin)) throw new Error('extraction did not produce sphere binary');
188
+ fs.chmodSync(bin, 0o755);
189
+ if (PLATFORM === 'darwin') {
190
+ try { execFileSync('xattr', ['-cr', path.join(dir, 'sphere-cli')], { stdio: 'ignore' }); } catch (_) {}
191
+ }
192
+ return bin;
193
+ }
194
+
195
+ // ── find the installed binary (record → that dir; else legacy vendor) ────────
196
+ function vendorBin() {
197
+ return path.join(__dirname, '..', 'vendor', 'sphere-cli', 'sphere');
198
+ }
199
+ function resolveBinary() {
200
+ const rec = readRecord();
201
+ if (rec && rec.release === BINARY_RELEASE && rec.bin && fs.existsSync(rec.bin)) return rec.bin;
202
+ const v = vendorBin();
203
+ if (fs.existsSync(v)) return v;
204
+ return null; // caller decides to (re)install
205
+ }
206
+
207
+ // ── fast-start: if `bin` is on a network FS, run from a node-local copy ───────
208
+ // Network filesystems (Lustre/NFS/GPFS) are slow to open the ~330 bundled libs.
209
+ // Caching to local disk makes startup fast for the rest of the job. Only kicks
210
+ // in on network FS (local installs gain nothing); opt out with SPHERE_NO_FAST=1.
211
+ function fastBinary(bin, log) {
212
+ log = log || (() => {});
213
+ try {
214
+ if (process.env.SPHERE_NO_FAST === '1') return bin;
215
+ const srcDir = path.dirname(bin); // …/sphere-cli
216
+ if (!isNetworkFS(srcDir)) return bin; // already local → nothing to gain
217
+ const localRoot = process.env.SPHERE_FAST_DIR
218
+ || process.env.L_SCRATCH || process.env.TMPDIR || os.tmpdir();
219
+ if (!localRoot) return bin;
220
+ const dest = path.join(localRoot, 'sphere-cli-cache', BINARY_RELEASE);
221
+ const localBin = path.join(dest, 'sphere-cli', 'sphere');
222
+ if (fs.existsSync(localBin)) return localBin; // already cached on this node
223
+ if (freeGB(localRoot) < 1.5) return bin; // not enough local space
224
+ // copy once (first run on this node pays it; later runs in the job are fast).
225
+ // Copy to a temp dir then rename so a partial/aborted copy is never used.
226
+ log('caching engine to node-local disk for faster startup (one-time) …');
227
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
228
+ const tmp = fs.mkdtempSync(path.join(localRoot, 'sphere-cache-'));
229
+ execFileSync('cp', ['-a', srcDir, path.join(tmp, 'sphere-cli')], { stdio: 'ignore' });
230
+ fs.rmSync(dest, { recursive: true, force: true });
231
+ fs.mkdirSync(dest, { recursive: true });
232
+ fs.renameSync(path.join(tmp, 'sphere-cli'), path.join(dest, 'sphere-cli'));
233
+ fs.rmSync(tmp, { recursive: true, force: true });
234
+ return fs.existsSync(localBin) ? localBin : bin;
235
+ } catch (_) { return bin; } // any trouble → just use the original
236
+ }
237
+
238
+ module.exports = {
239
+ KEY, ASSET, BINARY_RELEASE, SUPPORTED, PLATFORM,
240
+ configFile, readRecord, writeRecord,
241
+ candidateDirs, installInto, ensureInstalled, resolveBinary, fastBinary, vendorBin,
242
+ };
@@ -1,146 +1,76 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * SPHERE CLI postinstall — download the platform-specific sealed binary.
4
+ * SPHERE CLI postinstall.
5
5
  *
6
- * The SPHERE engine ships as a signed + notarized native binary (one per
7
- * platform) hosted on GitHub Releases. This script detects the platform,
8
- * downloads the matching tarball, verifies its SHA-256 against the published
9
- * SHA256SUMS.txt, and extracts it into ./vendor/. No algorithm source ships in
10
- * the npm package only this downloader + a thin launcher.
6
+ * Places the platform-specific sealed binary in a roomy, auto-detected location
7
+ * (NOT inside node_modules that would force users to repoint `npm config set
8
+ * prefix` to dodge home-dir quotas). All the location/download/verify logic lives
9
+ * in ./engine.js, shared with the launcher. This script just drives it, warms the
10
+ * binary once, and wires up PATH so `sphere` is callable without manual setup.
11
11
  *
12
- * Env:
13
- * SPHERE_SKIP_POSTINSTALL=1 skip download (CI / offline)
14
- * SPHERE_BINARY_BASEURL=... override the release base URL (testing)
12
+ * Env: SPHERE_SKIP_POSTINSTALL=1, SPHERE_HOME=<dir>, SPHERE_NO_PATH_SETUP=1.
15
13
  */
16
14
 
17
15
  const fs = require('fs');
16
+ const os = require('os');
18
17
  const path = require('path');
19
- const crypto = require('crypto');
20
18
  const { execFileSync } = require('child_process');
21
-
22
- const PKG = require('../package.json');
23
- const VERSION = PKG.version;
24
- const REPO = 'statzihuai/sphere-cli';
25
- // Binary release tag — decoupled from the npm package version so JS-only patch
26
- // releases reuse the same prebuilt/notarized binaries without re-uploading them.
27
- const BINARY_RELEASE = 'v0.2.6';
28
-
29
- const PLATFORM = process.platform; // 'darwin' | 'linux'
30
- const ARCH = process.arch; // 'arm64' | 'x64'
31
- const KEY = `${PLATFORM}-${ARCH}`;
32
- const SUPPORTED = new Set(['darwin-arm64', 'darwin-x64', 'linux-x64']);
19
+ const engine = require('./engine');
33
20
 
34
21
  const PKG_ROOT = path.join(__dirname, '..');
35
- const VENDOR = path.join(PKG_ROOT, 'vendor');
36
- const ASSET = `sphere-${KEY}.tar.gz`;
37
- const BASE = process.env.SPHERE_BINARY_BASEURL
38
- || `https://github.com/${REPO}/releases/download/${BINARY_RELEASE}`;
39
-
22
+ const PLATFORM = engine.PLATFORM;
40
23
  function log(m) { process.stdout.write(`sphere-cli: ${m}\n`); }
41
- function fail(m) { process.stderr.write(`sphere-cli: ${m}\n`); process.exit(1); }
42
24
 
43
25
  if (process.env.SPHERE_SKIP_POSTINSTALL === '1') {
44
26
  log('SPHERE_SKIP_POSTINSTALL=1 — skipping binary download.');
45
27
  process.exit(0);
46
28
  }
47
-
48
- if (!SUPPORTED.has(KEY)) {
49
- // Don't hard-fail the install; the launcher prints guidance if run.
29
+ if (!engine.SUPPORTED.has(engine.KEY)) {
50
30
  process.stderr.write(
51
- `sphere-cli: no prebuilt binary for ${KEY} yet ` +
52
- `(supported: ${[...SUPPORTED].join(', ')}).\n`,
31
+ `sphere-cli: no prebuilt binary for ${engine.KEY} ` +
32
+ `(supported: ${[...engine.SUPPORTED].join(', ')}).\n`,
53
33
  );
54
34
  process.exit(0);
55
35
  }
56
36
 
57
- // Already installed for this version?
58
- const STAMP = path.join(VENDOR, '.version');
59
- const BIN = path.join(VENDOR, 'sphere-cli', 'sphere');
60
- if (fs.existsSync(BIN) && fs.existsSync(STAMP) &&
61
- fs.readFileSync(STAMP, 'utf8').trim() === BINARY_RELEASE) {
62
- log(`binary already present (${BINARY_RELEASE}).`);
63
- process.exit(0);
64
- }
65
-
66
- function curl(url, dest) {
67
- execFileSync('curl', ['-fL', '--retry', '3', '--retry-delay', '2',
68
- '--connect-timeout', '30', '-o', dest, url], { stdio: ['ignore', 'ignore', 'inherit'] });
69
- }
70
-
71
- function sha256(file) {
72
- const h = crypto.createHash('sha256');
73
- h.update(fs.readFileSync(file));
74
- return h.digest('hex');
75
- }
76
-
77
37
  try {
78
- fs.mkdirSync(VENDOR, { recursive: true });
79
- const tarball = path.join(VENDOR, ASSET);
80
-
81
- log(`downloading ${ASSET} (v${VERSION}) …`);
82
- curl(`${BASE}/${ASSET}`, tarball);
83
-
84
- // Verify checksum against the published SHA256SUMS.txt
85
- const sumsFile = path.join(VENDOR, 'SHA256SUMS.txt');
86
- curl(`${BASE}/SHA256SUMS.txt`, sumsFile);
87
- const sums = fs.readFileSync(sumsFile, 'utf8');
88
- const expected = sums.split('\n')
89
- .map((l) => l.trim().split(/\s+/))
90
- .find((p) => p[1] && p[1].endsWith(ASSET));
91
- if (!expected) fail(`no checksum for ${ASSET} in SHA256SUMS.txt`);
92
- const got = sha256(tarball);
93
- if (got !== expected[0]) {
94
- fail(`checksum mismatch for ${ASSET}\n expected ${expected[0]}\n got ${got}`);
95
- }
96
- log('checksum verified ✓');
97
-
98
- // Extract
99
- fs.rmSync(path.join(VENDOR, 'sphere-cli'), { recursive: true, force: true });
100
- execFileSync('tar', ['-xzf', tarball, '-C', VENDOR], { stdio: 'inherit' });
101
- fs.chmodSync(BIN, 0o755);
102
- fs.unlinkSync(tarball);
103
- fs.rmSync(sumsFile, { force: true });
104
- fs.writeFileSync(STAMP, BINARY_RELEASE);
38
+ const bin = engine.ensureInstalled(log);
39
+ log(`engine ready at ${bin}`);
105
40
 
106
- // ── macOS: avoid a painfully slow FIRST run ────────────────────────────────
107
- // A downloaded, unstapled notarized onedir pays a one-time Gatekeeper check of
108
- // all ~330 bundled libraries on first launch (looks frozen on "loading pandas").
109
- // (1) strip quarantine/provenance xattrs → skips the slow *online* assessment;
110
- // (2) warm the binary now (during install, when waiting is expected) so the
111
- // user's first real command is instant. Both are best-effort.
112
- if (PLATFORM === 'darwin') {
113
- try { execFileSync('xattr', ['-cr', path.join(VENDOR, 'sphere-cli')], { stdio: 'ignore' }); } catch (_) {}
114
- try {
115
- log('warming binary (one-time macOS security scan, ~30–60s) …');
116
- const warmIn = path.join(VENDOR, '.warm.csv');
117
- const warmOut = path.join(VENDOR, '.warm_out.csv');
118
- fs.writeFileSync(warmIn, 'a,b\n1.0,2.0\n3.0,4.0\n5.0,6.0\n');
119
- execFileSync(BIN, ['generate', warmIn, '-o', warmOut], {
120
- stdio: 'ignore',
121
- timeout: 180000,
122
- env: { ...process.env, SPHERE_LICENSE_REQUIRED: 'false' },
123
- });
124
- for (const f of [warmIn, warmOut, warmOut + '.sphere.json']) fs.rmSync(f, { force: true });
125
- log('binary warmed ✓ — first run will be fast.');
126
- } catch (_) { /* best-effort; user just pays the scan on first run */ }
127
- }
41
+ // Warm once (best-effort): the first run loads ~330 bundled libraries; doing it
42
+ // now (while an install wait is expected) means the user's first real command
43
+ // is fast. On macOS this also clears the one-time Gatekeeper scan.
44
+ try {
45
+ log('warming (first run loads the analysis stack; one-time) …');
46
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sphere-warm-'));
47
+ const warmIn = path.join(tmp, 'in.csv');
48
+ const warmOut = path.join(tmp, 'out.csv');
49
+ fs.writeFileSync(warmIn, 'a,b\n1.0,2.0\n3.0,4.0\n5.0,6.0\n');
50
+ execFileSync(bin, ['generate', warmIn, '-o', warmOut], {
51
+ stdio: 'ignore',
52
+ timeout: 240000,
53
+ env: { ...process.env, SPHERE_LICENSE_REQUIRED: 'false' },
54
+ });
55
+ fs.rmSync(tmp, { recursive: true, force: true });
56
+ log('warmed ✓ — first run will be fast.');
57
+ } catch (_) { /* best-effort */ }
128
58
 
129
59
  setupGlobalPath();
130
-
131
- log(`installed sealed binary for ${KEY} ✓`);
60
+ log(`installed sealed binary for ${engine.KEY} ✓`);
132
61
  } catch (e) {
133
- fail(`failed to install binary: ${e.message}\n` +
134
- `You can retry with: npm rebuild sphere-cli`);
62
+ process.stderr.write(
63
+ `sphere-cli: failed to install binary: ${e.message}\n` +
64
+ `Retry: npm rebuild sphere-cli (or set SPHERE_HOME=/path/with/space and retry)\n`,
65
+ );
66
+ process.exit(1);
135
67
  }
136
68
 
137
69
  // ── Make `sphere` callable without manual PATH editing ──────────────────────
138
- // For a GLOBAL install whose prefix/bin is not already on PATH (common on HPC,
139
- // where users point `npm config set prefix` at a roomy shared dir like $OAK so
140
- // the install survives across login/compute nodes), append the bin dir to the
141
- // user's shell rc. Because the rc lives in the shared home dir, the `sphere`
142
- // command then works in every future session on every node — no reinstall.
143
- // Idempotent; skipped when already on PATH; opt out with SPHERE_NO_PATH_SETUP=1.
70
+ // For a GLOBAL install whose prefix/bin is not already on PATH (common on HPC),
71
+ // append the bin dir to the user's shell rc. Because the rc lives in the shared
72
+ // home dir, the `sphere` command then works in every future session on every
73
+ // node. Idempotent; skipped when already on PATH; opt out with SPHERE_NO_PATH_SETUP=1.
144
74
  function setupGlobalPath() {
145
75
  try {
146
76
  if (process.env.SPHERE_NO_PATH_SETUP === '1') return;
@@ -155,7 +85,6 @@ function setupGlobalPath() {
155
85
  const parts = (process.env.PATH || '').split(path.delimiter);
156
86
  if (parts.includes(binDir)) return; // already callable — nothing to do
157
87
 
158
- const os = require('os');
159
88
  const home = os.homedir();
160
89
  const block = `\n# added by sphere-cli — makes the \`sphere\` command available\n`
161
90
  + `export PATH="${binDir}:$PATH"\n`;
@@ -176,10 +105,10 @@ function setupGlobalPath() {
176
105
  }
177
106
 
178
107
  if (wrote.length) {
179
- log(`PATH: added ${binDir} to ${wrote.join(', ')} — \`sphere\` will work in new shells (and on every node, since home is shared).`);
108
+ log(`PATH: added ${binDir} to ${wrote.join(', ')} — \`sphere\` works in new shells (and every node, since home is shared).`);
180
109
  log(`for THIS shell now: export PATH="${binDir}:$PATH"`);
181
110
  } else {
182
111
  log(`to run \`sphere\`, add to your shell rc: export PATH="${binDir}:$PATH"`);
183
112
  }
184
- } catch (_) { /* best-effort; never fail the install over PATH setup */ }
113
+ } catch (_) { /* never fail the install over PATH setup */ }
185
114
  }