sphere-cli 0.1.37 → 0.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -4
- package/bin/sphere.js +434 -14
- package/examples/nhanes_sample.csv +4900 -0
- package/package.json +8 -3
- package/scripts/postinstall.js +74 -122
- package/sphere-node.js +1 -0
package/README.md
CHANGED
|
@@ -49,6 +49,9 @@ sh install.sh --uninstall
|
|
|
49
49
|
## Quick start
|
|
50
50
|
|
|
51
51
|
```sh
|
|
52
|
+
# Try the built-in demo (no data needed)
|
|
53
|
+
sphere demo
|
|
54
|
+
|
|
52
55
|
# Activate your license (once)
|
|
53
56
|
sphere license activate sphere_xxxxxxxxxxxxxxxxxxxx
|
|
54
57
|
|
|
@@ -64,8 +67,75 @@ sphere certify real.csv synth.csv -o report.html
|
|
|
64
67
|
|
|
65
68
|
---
|
|
66
69
|
|
|
70
|
+
## First run
|
|
71
|
+
|
|
72
|
+
On the very first invocation the CLI cold-loads its bundled Python libraries (pandas, pyarrow, anonymeter, sklearn) from disk. On Apple Silicon this typically takes **15–25 seconds** and is shown in the progress bar as each library finishes:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Generating synthetic data from nhanes_sample.csv …
|
|
76
|
+
[░░░░░░░░░░░░░░░░░] 0.0% loading pandas . .
|
|
77
|
+
[█░░░░░░░░░░░░░░░░] 3.0% ✓ pandas (12.4 s)
|
|
78
|
+
[██░░░░░░░░░░░░░░░] 6.0% ✓ pyarrow (3.1 s)
|
|
79
|
+
[███░░░░░░░░░░░░░░] 9.0% ✓ sphere core (1.8 s)
|
|
80
|
+
…
|
|
81
|
+
✓ synth.csv 4,899 rows × 18 cols (load 17.4 s + run 1.8 s) seed 3721018536
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Subsequent calls in the same session skip loading entirely. The timing line always shows **load** (library startup) and **run** (actual SPHERE computation) separately so you can see which part is slow.
|
|
85
|
+
|
|
86
|
+
> Exact times vary by machine, OS page cache state, and whether the binary has been run recently.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
67
90
|
## Commands
|
|
68
91
|
|
|
92
|
+
### `sphere demo`
|
|
93
|
+
|
|
94
|
+
Run SPHERE end-to-end on the built-in NHANES sample dataset (4,899 rows × 18 columns, mix of continuous and categorical variables). No data or license required — good for testing an installation.
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
sphere demo
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
SPHERE demo — built-in NHANES dataset (4,899 rows × 18 cols, continuous + categorical)
|
|
102
|
+
────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
Generating synthetic data from nhanes_sample.csv …
|
|
105
|
+
[░░░░░░░░░░░░░░░░░] 0.0% loading pandas . .
|
|
106
|
+
[█░░░░░░░░░░░░░░░░] 3.0% ✓ pandas (12.4 s)
|
|
107
|
+
[██░░░░░░░░░░░░░░░] 6.0% ✓ pyarrow (3.1 s)
|
|
108
|
+
[███░░░░░░░░░░░░░░] 9.0% ✓ sphere core (1.8 s)
|
|
109
|
+
[████████████████░] 85.0% writing output
|
|
110
|
+
✓ /tmp/synth.csv 4,899 rows × 18 cols (load 17.4 s + run 1.8 s) seed 3721018536
|
|
111
|
+
|
|
112
|
+
Evaluating nhanes_sample.csv vs synth.csv …
|
|
113
|
+
[████░░░░░░░░░░░░░] 16.0% loading anonymeter . .
|
|
114
|
+
[████░░░░░░░░░░░░░] 17.0% ✓ anonymeter (3.2 s)
|
|
115
|
+
[█████░░░░░░░░░░░░] 18.0% ✓ sklearn (0.8 s)
|
|
116
|
+
[█████████████████] 89.0% inference 9/9
|
|
117
|
+
✓ Evaluation complete (load 4.0 s + run 14.2 s)
|
|
118
|
+
|
|
119
|
+
Fidelity
|
|
120
|
+
────────────────────────────────────
|
|
121
|
+
Mean 100.0 ████████████████████
|
|
122
|
+
Variance 99.7 ████████████████████
|
|
123
|
+
Correlation 95.1 ███████████████████░
|
|
124
|
+
KS 96.8 ███████████████████░
|
|
125
|
+
────────────────────────────────────
|
|
126
|
+
Composite 97.9 ████████████████████
|
|
127
|
+
|
|
128
|
+
Privacy
|
|
129
|
+
────────────────────────────────────
|
|
130
|
+
Singling Out 100.0 ████████████████████
|
|
131
|
+
Linkability 97.5 ███████████████████░
|
|
132
|
+
Inference 96.8 ███████████████████░
|
|
133
|
+
────────────────────────────────────
|
|
134
|
+
Composite 98.1 ████████████████████
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
69
139
|
### `sphere license`
|
|
70
140
|
|
|
71
141
|
Activate and manage your SPHERE license. A valid license is required to use `generate`, `evaluate`, and `certify`.
|
|
@@ -90,7 +160,7 @@ sphere generate <real.csv> [options]
|
|
|
90
160
|
Options:
|
|
91
161
|
-o, --output PATH Output CSV path (default: <input>_sphere.csv)
|
|
92
162
|
-n, --rows INT Number of synthetic rows (default: same as input)
|
|
93
|
-
-k INT
|
|
163
|
+
-k INT Synthesis depth (default: 2)
|
|
94
164
|
--seed INT Random seed for reproducibility
|
|
95
165
|
--mix-prob FLOAT Mixture probability 0–1 (default: 0.75)
|
|
96
166
|
--json Machine-readable JSON output
|
|
@@ -98,6 +168,8 @@ Options:
|
|
|
98
168
|
|
|
99
169
|
A `.sphere.json` provenance file is written alongside every output CSV and is automatically read by `sphere certify`.
|
|
100
170
|
|
|
171
|
+
---
|
|
172
|
+
|
|
101
173
|
### `sphere evaluate`
|
|
102
174
|
|
|
103
175
|
```
|
|
@@ -105,9 +177,14 @@ sphere evaluate <real.csv> <synth.csv> [options]
|
|
|
105
177
|
|
|
106
178
|
Options:
|
|
107
179
|
--skip-privacy Skip privacy metrics (faster)
|
|
180
|
+
--seed INT Fix the random seed for reproducible attack results
|
|
108
181
|
--json Machine-readable JSON output
|
|
109
182
|
```
|
|
110
183
|
|
|
184
|
+
Reports four fidelity metrics (mean, variance, correlation, KS) and three privacy metrics (singling-out, linkability, inference), each scored 0–100. Scores are normalised against a column-shuffled baseline so 100 = no measurable privacy leakage relative to a random permutation of the data.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
111
188
|
### `sphere certify`
|
|
112
189
|
|
|
113
190
|
```
|
|
@@ -118,7 +195,7 @@ Options:
|
|
|
118
195
|
--json Machine-readable JSON output
|
|
119
196
|
```
|
|
120
197
|
|
|
121
|
-
Generation parameters (`k`, `seed`, `theta`, etc.) are loaded automatically from the `.sphere.json` sidecar
|
|
198
|
+
Produces a self-contained HTML certificate with fidelity and privacy scores, dataset metadata, and generation provenance. Generation parameters (`k`, `seed`, `theta`, etc.) are loaded automatically from the `.sphere.json` sidecar; pass flags explicitly to override.
|
|
122
199
|
|
|
123
200
|
---
|
|
124
201
|
|
|
@@ -127,8 +204,9 @@ Generation parameters (`k`, `seed`, `theta`, etc.) are loaded automatically from
|
|
|
127
204
|
Every command supports `--json` for pipeline integration:
|
|
128
205
|
|
|
129
206
|
```sh
|
|
130
|
-
sphere generate real.csv -o synth.csv --json | jq .
|
|
207
|
+
sphere generate real.csv -o synth.csv --json | jq .seed
|
|
131
208
|
sphere evaluate real.csv synth.csv --json > metrics.json
|
|
209
|
+
sphere evaluate real.csv synth.csv --json | jq '.privacy.composite'
|
|
132
210
|
```
|
|
133
211
|
|
|
134
212
|
---
|
|
@@ -140,7 +218,7 @@ sphere evaluate real.csv synth.csv --json > metrics.json
|
|
|
140
218
|
| `SPHERE_LICENSE_REQUIRED` | Set to `false` to bypass license checks (research / unlocked builds) |
|
|
141
219
|
| `SPHERE_WORKER_URL` | Override the license validation endpoint |
|
|
142
220
|
| `SPHERE_PREFIX` | Override install prefix |
|
|
143
|
-
| `SPHERE_VERSION` | Pin a release tag, e.g. `v0.1.
|
|
221
|
+
| `SPHERE_VERSION` | Pin a release tag, e.g. `v0.1.38` |
|
|
144
222
|
| `SPHERE_BUNDLE_URL` | Full URL to a `sphere-cli-*.tar.gz` (skip auto-detect) |
|
|
145
223
|
| `SPHERE_GITHUB_REPO` | Override GitHub repo for downloads |
|
|
146
224
|
|
package/bin/sphere.js
CHANGED
|
@@ -1,24 +1,444 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
/**
|
|
5
|
+
* sphere — CLI entry point.
|
|
6
|
+
*
|
|
7
|
+
* This wrapper handles argument parsing, progress display, and license checks,
|
|
8
|
+
* then delegates generation to sphere-node.jsc (V8 bytecode, bytenode-loaded).
|
|
9
|
+
*
|
|
10
|
+
* Commands:
|
|
11
|
+
* sphere generate <input.csv> -o <output.csv> [options]
|
|
12
|
+
* sphere license activate|status|clear
|
|
13
|
+
* sphere demo
|
|
14
|
+
* sphere evaluate — not available in this version (requires Python sidecar)
|
|
15
|
+
* sphere certify — not available in this version (requires Python sidecar)
|
|
16
|
+
*/
|
|
7
17
|
|
|
8
|
-
const
|
|
18
|
+
const { spawn } = require('child_process');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const https = require('https');
|
|
22
|
+
const os = require('os');
|
|
9
23
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
24
|
+
const PKG_DIR = path.join(__dirname, '..');
|
|
25
|
+
const SPHERE_NODE_JS = path.join(PKG_DIR, 'sphere-node.js');
|
|
26
|
+
const VERSION = require(path.join(PKG_DIR, 'package.json')).version;
|
|
27
|
+
|
|
28
|
+
// ── License ───────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const LICENSE_WORKER_URL = process.env.SPHERE_WORKER_URL
|
|
31
|
+
|| 'https://sphere-license.statzihuai.workers.dev';
|
|
32
|
+
const LICENSE_CACHE_DAYS = 1;
|
|
33
|
+
const LICENSE_REQUIRED = (process.env.SPHERE_LICENSE_REQUIRED || 'true') !== 'false';
|
|
34
|
+
|
|
35
|
+
function sphereConfigDir() {
|
|
36
|
+
const d = path.join(os.homedir(), '.config', 'sphere');
|
|
37
|
+
fs.mkdirSync(d, { recursive: true });
|
|
38
|
+
return d;
|
|
39
|
+
}
|
|
40
|
+
function licenseKeyFile() { return path.join(sphereConfigDir(), 'license_key'); }
|
|
41
|
+
function licenseCacheFile() { return path.join(sphereConfigDir(), 'license_cache.json'); }
|
|
42
|
+
|
|
43
|
+
function readStoredLicenseKey() {
|
|
44
|
+
const f = licenseKeyFile();
|
|
45
|
+
if (!fs.existsSync(f)) return null;
|
|
46
|
+
return fs.readFileSync(f, 'utf8').trim() || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeLicenseKey(key) {
|
|
50
|
+
const f = licenseKeyFile();
|
|
51
|
+
fs.writeFileSync(f, key, { encoding: 'utf8', mode: 0o600 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readLicenseCache() {
|
|
55
|
+
const f = licenseCacheFile();
|
|
56
|
+
if (!fs.existsSync(f)) return null;
|
|
57
|
+
try {
|
|
58
|
+
const data = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
59
|
+
const ageDays = (Date.now() / 1000 - (data.cachedAt || 0)) / 86400;
|
|
60
|
+
return ageDays <= LICENSE_CACHE_DAYS ? data : null;
|
|
61
|
+
} catch { return null; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeLicenseCache(data) {
|
|
65
|
+
fs.writeFileSync(
|
|
66
|
+
licenseCacheFile(),
|
|
67
|
+
JSON.stringify({ ...data, cachedAt: Math.floor(Date.now() / 1000) }),
|
|
68
|
+
'utf8',
|
|
15
69
|
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validateKeyOnline(key) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const body = Buffer.from(JSON.stringify({ key }));
|
|
75
|
+
const req = https.request(
|
|
76
|
+
`${LICENSE_WORKER_URL}/validate`,
|
|
77
|
+
{
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: {
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
'Content-Length': body.length,
|
|
82
|
+
'User-Agent': 'sphere-cli/1.0',
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
(res) => {
|
|
86
|
+
let buf = '';
|
|
87
|
+
res.on('data', (d) => (buf += d));
|
|
88
|
+
res.on('end', () => {
|
|
89
|
+
try { resolve(JSON.parse(buf)); }
|
|
90
|
+
catch { reject(new Error('Invalid JSON from license server')); }
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
req.on('error', reject);
|
|
95
|
+
req.setTimeout(10000, () => { req.destroy(new Error('License server timeout')); });
|
|
96
|
+
req.write(body);
|
|
97
|
+
req.end();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function checkLicense() {
|
|
102
|
+
if (!LICENSE_REQUIRED) return;
|
|
103
|
+
const key = readStoredLicenseKey();
|
|
104
|
+
if (!key) {
|
|
105
|
+
process.stderr.write(
|
|
106
|
+
'✗ No SPHERE license found.\n' +
|
|
107
|
+
' Run: sphere license activate <key>\n' +
|
|
108
|
+
' Contact zihuai@stanford.edu to get a license.\n',
|
|
109
|
+
);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let result;
|
|
114
|
+
try {
|
|
115
|
+
result = await validateKeyOnline(key);
|
|
116
|
+
writeLicenseCache(result);
|
|
117
|
+
} catch {
|
|
118
|
+
result = readLicenseCache();
|
|
119
|
+
if (!result) {
|
|
120
|
+
process.stderr.write(
|
|
121
|
+
'✗ License server unreachable and local cache has expired.\n' +
|
|
122
|
+
' Connect to the internet and re-run to refresh your license.\n',
|
|
123
|
+
);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!result.valid) {
|
|
129
|
+
process.stderr.write(
|
|
130
|
+
`✗ License invalid: ${result.error || 'unknown error'}\n` +
|
|
131
|
+
' Run: sphere license activate <key>\n',
|
|
132
|
+
);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Progress bar ──────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
const BAR_WIDTH = 28;
|
|
140
|
+
|
|
141
|
+
function progressBar(frac, msg) {
|
|
142
|
+
const f = Math.min(1, Math.max(0, frac));
|
|
143
|
+
const filled = Math.round(BAR_WIDTH * f);
|
|
144
|
+
const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);
|
|
145
|
+
const pct = (f * 100).toFixed(1).padStart(5);
|
|
146
|
+
process.stderr.write(`\r [${bar}] ${pct}% ${(msg || '').slice(0, 45).padEnd(45)}\x1b[K`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clearLine() {
|
|
150
|
+
process.stderr.write(`\r${' '.repeat(BAR_WIDTH + 60)}\r`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Argument parsing helpers ──────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
function parseArgs(argv) {
|
|
156
|
+
const args = argv.slice(2);
|
|
157
|
+
const pos = [];
|
|
158
|
+
const flags = {};
|
|
159
|
+
for (let i = 0; i < args.length; i++) {
|
|
160
|
+
const a = args[i];
|
|
161
|
+
if (a === '-o') {
|
|
162
|
+
flags['output'] = args[++i];
|
|
163
|
+
} else if (a.startsWith('--')) {
|
|
164
|
+
const key = a.slice(2);
|
|
165
|
+
const next = args[i + 1];
|
|
166
|
+
if (next === undefined || next.startsWith('-')) {
|
|
167
|
+
flags[key] = true;
|
|
168
|
+
} else {
|
|
169
|
+
flags[key] = next;
|
|
170
|
+
i++;
|
|
171
|
+
}
|
|
172
|
+
} else if (a.startsWith('-') && a.length === 2) {
|
|
173
|
+
flags[a.slice(1)] = args[++i];
|
|
174
|
+
} else {
|
|
175
|
+
pos.push(a);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { pos, flags };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── sphere generate ───────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
async function cmdGenerate(pos, flags) {
|
|
184
|
+
await checkLicense();
|
|
185
|
+
|
|
186
|
+
const input = pos[0];
|
|
187
|
+
const output = flags.output;
|
|
188
|
+
|
|
189
|
+
if (!input) { process.stderr.write('Error: missing input CSV\n'); process.exit(1); }
|
|
190
|
+
if (!output) { process.stderr.write('Error: missing -o / --output path\n'); process.exit(1); }
|
|
191
|
+
if (!fs.existsSync(input)) {
|
|
192
|
+
process.stderr.write(`Error: file not found: ${input}\n`);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!flags.json) {
|
|
197
|
+
process.stderr.write(`Generating synthetic data from ${path.basename(input)} …\n`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Build args for sphere-node.js
|
|
201
|
+
const nodeArgs = [SPHERE_NODE_JS, input, output];
|
|
202
|
+
if (flags.k) nodeArgs.push('--k', String(flags.k));
|
|
203
|
+
if (flags.theta) nodeArgs.push('--theta', String(flags.theta));
|
|
204
|
+
if (flags.delta) nodeArgs.push('--delta', String(flags.delta));
|
|
205
|
+
if (flags['mix-prob']) nodeArgs.push('--mix-prob', String(flags['mix-prob']));
|
|
206
|
+
if (flags.seed) nodeArgs.push('--seed', String(flags.seed));
|
|
207
|
+
|
|
208
|
+
return new Promise((resolve) => {
|
|
209
|
+
const proc = spawn(process.execPath, nodeArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
210
|
+
|
|
211
|
+
let resultLine = '';
|
|
212
|
+
let errorLine = '';
|
|
213
|
+
|
|
214
|
+
proc.stdout.on('data', (d) => { resultLine += d.toString(); });
|
|
215
|
+
|
|
216
|
+
proc.stderr.on('data', (d) => {
|
|
217
|
+
for (const line of d.toString().split('\n')) {
|
|
218
|
+
const s = line.trim();
|
|
219
|
+
if (!s) continue;
|
|
220
|
+
try {
|
|
221
|
+
const msg = JSON.parse(s);
|
|
222
|
+
if (typeof msg.progress === 'number' && !flags.json) {
|
|
223
|
+
progressBar(msg.progress, '');
|
|
224
|
+
} else if (msg.error) {
|
|
225
|
+
errorLine = msg.error;
|
|
226
|
+
}
|
|
227
|
+
} catch { /* ignore non-JSON lines */ }
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
proc.on('close', (code) => {
|
|
232
|
+
if (!flags.json) clearLine();
|
|
233
|
+
|
|
234
|
+
if (code !== 0) {
|
|
235
|
+
process.stderr.write(`Error: ${errorLine || 'sphere-node exited with code ' + code}\n`);
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let result;
|
|
240
|
+
try { result = JSON.parse(resultLine.trim()); }
|
|
241
|
+
catch {
|
|
242
|
+
process.stderr.write('Error: could not parse sphere-node output\n');
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (flags.json) {
|
|
247
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
248
|
+
resolve();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const elapsedS = ((result.elapsedMs || 0) / 1000).toFixed(1);
|
|
253
|
+
process.stdout.write(
|
|
254
|
+
`✓ ${output} ${(result.rows || 0).toLocaleString()} rows \xd7 ${result.cols || '?'} cols` +
|
|
255
|
+
` (${elapsedS} s) seed ${result.seed}\n`,
|
|
256
|
+
);
|
|
257
|
+
if (result.idColDetected) {
|
|
258
|
+
process.stdout.write(` ID columns excluded: ${result.idColName}\n`);
|
|
259
|
+
}
|
|
260
|
+
resolve();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
proc.on('error', (err) => {
|
|
264
|
+
process.stderr.write(`Error: failed to start sphere-node: ${err.message}\n`);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── sphere demo ───────────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
async function cmdDemo() {
|
|
273
|
+
const exampleCsv = path.join(PKG_DIR, 'examples', 'nhanes_sample.csv');
|
|
274
|
+
if (!fs.existsSync(exampleCsv)) {
|
|
275
|
+
process.stderr.write('Error: built-in example not found at ' + exampleCsv + '\n');
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const { randomUUID } = require('crypto');
|
|
280
|
+
const tmpOut = path.join(os.tmpdir(), `sphere-demo-${randomUUID()}.csv`);
|
|
281
|
+
|
|
282
|
+
process.stdout.write('SPHERE demo — built-in NHANES dataset (4,899 rows \xd7 18 cols)\n');
|
|
283
|
+
process.stdout.write('─'.repeat(52) + '\n\n');
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
await cmdGenerate([exampleCsv], { output: tmpOut });
|
|
287
|
+
} finally {
|
|
288
|
+
try { fs.unlinkSync(tmpOut); } catch {}
|
|
289
|
+
try { fs.unlinkSync(tmpOut + '.sphere.json'); } catch {}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
process.stdout.write('\nTry it on your own data:\n');
|
|
293
|
+
process.stdout.write(' sphere generate your_data.csv -o synthetic.csv\n\n');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── sphere license ────────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
async function cmdLicense(sub, pos, flags) {
|
|
299
|
+
if (sub === 'activate') {
|
|
300
|
+
let key = (pos[0] || flags.key || '').trim();
|
|
301
|
+
if (!key) {
|
|
302
|
+
// Simple stdin prompt (no readline dependency)
|
|
303
|
+
process.stdout.write('SPHERE license key (sphere_…): ');
|
|
304
|
+
key = await new Promise((resolve) => {
|
|
305
|
+
let buf = '';
|
|
306
|
+
process.stdin.resume();
|
|
307
|
+
process.stdin.setEncoding('utf8');
|
|
308
|
+
process.stdin.on('data', (ch) => {
|
|
309
|
+
const c = ch.toString();
|
|
310
|
+
if (c.includes('\n') || c.includes('\r')) {
|
|
311
|
+
process.stdin.pause();
|
|
312
|
+
process.stdout.write('\n');
|
|
313
|
+
resolve(buf.trim());
|
|
314
|
+
} else {
|
|
315
|
+
buf += c;
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if (!key.startsWith('sphere_')) {
|
|
321
|
+
process.stderr.write("Error: key must start with 'sphere_'\n");
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
process.stdout.write('Validating …');
|
|
325
|
+
let result;
|
|
326
|
+
try { result = await validateKeyOnline(key); }
|
|
327
|
+
catch (e) {
|
|
328
|
+
process.stderr.write(`\nError: could not reach license server — ${e.message}\n`);
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
if (!result.valid) {
|
|
332
|
+
process.stderr.write(`\r✗ Invalid key: ${result.error || 'unknown error'}\n`);
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
writeLicenseKey(key);
|
|
336
|
+
writeLicenseCache(result);
|
|
337
|
+
process.stdout.write(`\r✓ License activated — ${result.customer || ''}\n`);
|
|
338
|
+
if (result.expiry) process.stdout.write(` Expires: ${result.expiry}\n`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (sub === 'status') {
|
|
343
|
+
const key = readStoredLicenseKey();
|
|
344
|
+
if (!key) {
|
|
345
|
+
process.stdout.write('✗ No license key configured.\n Run: sphere license activate <key>\n');
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
process.stdout.write('Checking …');
|
|
349
|
+
let offline = false;
|
|
350
|
+
let result;
|
|
351
|
+
try {
|
|
352
|
+
result = await validateKeyOnline(key);
|
|
353
|
+
writeLicenseCache(result);
|
|
354
|
+
} catch {
|
|
355
|
+
result = readLicenseCache();
|
|
356
|
+
offline = true;
|
|
357
|
+
if (!result) {
|
|
358
|
+
process.stdout.write('\r✗ License server unreachable and cache expired.\n');
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (result.valid) {
|
|
363
|
+
const suffix = offline ? ' (offline — cached)' : '';
|
|
364
|
+
process.stdout.write(`\r✓ License valid — ${result.customer || ''}${suffix}\n`);
|
|
365
|
+
if (result.expiry) process.stdout.write(` Expires: ${result.expiry}\n`);
|
|
366
|
+
} else {
|
|
367
|
+
process.stdout.write(`\r✗ License invalid: ${result.error || 'unknown'}\n`);
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (sub === 'clear') {
|
|
373
|
+
let removed = false;
|
|
374
|
+
for (const f of [licenseKeyFile(), licenseCacheFile()]) {
|
|
375
|
+
if (fs.existsSync(f)) { fs.unlinkSync(f); removed = true; }
|
|
376
|
+
}
|
|
377
|
+
process.stdout.write(removed ? '✓ License cleared.\n' : 'No license stored.\n');
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
process.stderr.write('Usage: sphere license activate|status|clear\n');
|
|
16
382
|
process.exit(1);
|
|
17
383
|
}
|
|
18
384
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
});
|
|
385
|
+
// ── Main dispatch ─────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
async function main() {
|
|
388
|
+
const { pos, flags } = parseArgs(process.argv);
|
|
389
|
+
const cmd = pos[0];
|
|
390
|
+
|
|
391
|
+
if (flags.version || flags.v) {
|
|
392
|
+
process.stdout.write(`${VERSION}\n`);
|
|
393
|
+
process.exit(0);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!cmd || flags.help || flags.h) {
|
|
397
|
+
process.stdout.write(
|
|
398
|
+
`SPHERE CLI v${VERSION} — synthetic data generation\n\n` +
|
|
399
|
+
'Usage:\n' +
|
|
400
|
+
' sphere generate <input.csv> -o <output.csv> [options]\n' +
|
|
401
|
+
' sphere license activate|status|clear\n' +
|
|
402
|
+
' sphere demo\n\n' +
|
|
403
|
+
'Generate options:\n' +
|
|
404
|
+
' --k <int> Synthesis depth (default: 2)\n' +
|
|
405
|
+
' --theta <float> Rotation angle in radians (default: π/6 ≈ 0.524)\n' +
|
|
406
|
+
' --delta <float> Per-pair angle jitter (default: 5° ≈ 0.087)\n' +
|
|
407
|
+
' --mix-prob <float> Privacy/utility trade-off (default: 0.75)\n' +
|
|
408
|
+
' --seed <int> Integer seed for reproducible output\n' +
|
|
409
|
+
' --json Machine-readable JSON output\n',
|
|
410
|
+
);
|
|
411
|
+
process.exit(0);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (cmd === 'generate') {
|
|
415
|
+
await cmdGenerate(pos.slice(1), flags);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
23
418
|
|
|
24
|
-
|
|
419
|
+
if (cmd === 'demo') {
|
|
420
|
+
await cmdDemo();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (cmd === 'license') {
|
|
425
|
+
await cmdLicense(pos[1], pos.slice(2), flags);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (cmd === 'evaluate' || cmd === 'certify') {
|
|
430
|
+
process.stderr.write(
|
|
431
|
+
`'sphere ${cmd}' requires the Python evaluation engine, which is not included\n` +
|
|
432
|
+
'in this version. Coming soon.\n',
|
|
433
|
+
);
|
|
434
|
+
process.exit(1);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
process.stderr.write(`Unknown command: ${cmd}\nRun 'sphere --help' for usage.\n`);
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
main().catch((err) => {
|
|
442
|
+
process.stderr.write(`\nError: ${err.message || err}\n`);
|
|
443
|
+
process.exit(1);
|
|
444
|
+
});
|