@luquimbo/bi-superpowers 1.2.2 → 2.0.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.claude-plugin/skill-manifest.json +1 -2
- package/.plugin/plugin.json +1 -1
- package/AGENTS.md +2 -3
- package/README.md +14 -5
- package/bin/cli.js +28 -234
- package/bin/commands/build-desktop.js +3 -1
- package/bin/commands/diff.js +3 -1
- package/bin/commands/install.js +4 -6
- package/bin/commands/lint.js +3 -1
- package/bin/commands/search.js +1 -1
- package/bin/commands/watch.js +3 -1
- package/bin/lib/generators/claude-plugin.js +2 -1
- package/bin/lib/skills.js +9 -34
- package/bin/postinstall.js +20 -18
- package/package.json +1 -1
- package/skills/contributions/SKILL.md +1 -1
- package/skills/data-model-design/SKILL.md +1 -1
- package/skills/data-modeling/SKILL.md +1 -1
- package/skills/data-quality/SKILL.md +1 -1
- package/skills/dax/SKILL.md +1 -1
- package/skills/dax-doctor/SKILL.md +1 -1
- package/skills/dax-udf/SKILL.md +1 -1
- package/skills/deployment/SKILL.md +1 -1
- package/skills/excel-formulas/SKILL.md +1 -1
- package/skills/fabric-scripts/SKILL.md +1 -1
- package/skills/fast-standard/SKILL.md +1 -1
- package/skills/governance/SKILL.md +1 -1
- package/skills/migration-assistant/SKILL.md +1 -1
- package/skills/model-documenter/SKILL.md +1 -1
- package/skills/pbi-connect/SKILL.md +1 -1
- package/skills/power-query/SKILL.md +1 -1
- package/skills/project-kickoff/SKILL.md +1 -1
- package/skills/query-performance/SKILL.md +1 -1
- package/skills/report-design/SKILL.md +1 -1
- package/skills/report-layout/SKILL.md +1 -1
- package/skills/rls-design/SKILL.md +1 -1
- package/skills/semantic-model/SKILL.md +1 -1
- package/skills/testing-validation/SKILL.md +1 -1
- package/skills/theme-tweaker/SKILL.md +1 -1
- package/bin/lib/licensing/index.js +0 -35
- package/bin/lib/licensing/storage.js +0 -404
- package/bin/lib/licensing/storage.test.js +0 -55
- package/bin/lib/licensing/validator.js +0 -213
- package/bin/lib/licensing/validator.test.js +0 -137
|
@@ -1,404 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* License Storage Module
|
|
3
|
-
* ======================
|
|
4
|
-
*
|
|
5
|
-
* Handles license persistence and premium content management.
|
|
6
|
-
* Licenses are stored in ~/.bi-superpowers-license as JSON.
|
|
7
|
-
*
|
|
8
|
-
* @module lib/licensing/storage
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
const fs = require('fs');
|
|
12
|
-
const path = require('path');
|
|
13
|
-
const os = require('os');
|
|
14
|
-
const https = require('https');
|
|
15
|
-
const AdmZip = require('adm-zip');
|
|
16
|
-
|
|
17
|
-
/** Path to the license file stored in user's home directory */
|
|
18
|
-
const LICENSE_FILE = path.join(os.homedir(), '.bi-superpowers-license');
|
|
19
|
-
|
|
20
|
-
/** Directory for cached premium content (skills, snippets, themes) */
|
|
21
|
-
const CONTENT_CACHE_DIR = path.join(os.homedir(), '.bi-superpowers');
|
|
22
|
-
|
|
23
|
-
/** Directory containing skill definition files (.md) */
|
|
24
|
-
const SKILLS_DIR = path.join(CONTENT_CACHE_DIR, '.agents', 'prompts', 'skills');
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Load saved license from disk
|
|
28
|
-
*
|
|
29
|
-
* Reads the license file from the user's home directory. The license contains:
|
|
30
|
-
* - license: The license key string
|
|
31
|
-
* - email: User's email address
|
|
32
|
-
* - name: User's name (optional)
|
|
33
|
-
* - activatedAt: ISO timestamp of activation
|
|
34
|
-
* - contentVersion: Version when content was last downloaded
|
|
35
|
-
*
|
|
36
|
-
* @returns {Object|null} License data object or null if not found/invalid
|
|
37
|
-
*/
|
|
38
|
-
function loadLicense() {
|
|
39
|
-
try {
|
|
40
|
-
if (fs.existsSync(LICENSE_FILE)) {
|
|
41
|
-
const data = JSON.parse(fs.readFileSync(LICENSE_FILE, 'utf8'));
|
|
42
|
-
return data;
|
|
43
|
-
}
|
|
44
|
-
} catch (e) {
|
|
45
|
-
if (process.env.DEBUG === 'true') {
|
|
46
|
-
console.error(`[DEBUG] Failed to load license: ${e.message}`);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Save license to disk
|
|
54
|
-
*
|
|
55
|
-
* Persists license data to ~/.bi-superpowers-license for future sessions.
|
|
56
|
-
*
|
|
57
|
-
* @param {Object} data - License data to save
|
|
58
|
-
* @param {string} data.license - The license key
|
|
59
|
-
* @param {string} data.email - User's email
|
|
60
|
-
* @param {string} [data.name] - User's name
|
|
61
|
-
* @param {string} data.activatedAt - Activation timestamp
|
|
62
|
-
* @param {string} data.contentVersion - Content version
|
|
63
|
-
*/
|
|
64
|
-
function saveLicense(data) {
|
|
65
|
-
try {
|
|
66
|
-
fs.writeFileSync(LICENSE_FILE, JSON.stringify(data, null, 2));
|
|
67
|
-
} catch (err) {
|
|
68
|
-
throw new Error(
|
|
69
|
-
`No pude guardar la licencia en ${LICENSE_FILE}: ${err.message}. ` +
|
|
70
|
-
'Revisá los permisos de tu directorio home.'
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Clear saved license from disk
|
|
77
|
-
*
|
|
78
|
-
* Removes the license file, effectively logging out the user.
|
|
79
|
-
* Used when a license becomes invalid or user wants to switch accounts.
|
|
80
|
-
*/
|
|
81
|
-
function clearLicense() {
|
|
82
|
-
try {
|
|
83
|
-
if (fs.existsSync(LICENSE_FILE)) {
|
|
84
|
-
fs.unlinkSync(LICENSE_FILE);
|
|
85
|
-
}
|
|
86
|
-
} catch (e) {
|
|
87
|
-
if (process.env.DEBUG === 'true') {
|
|
88
|
-
console.error(`[DEBUG] Failed to clear license: ${e.message}`);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Check if premium content is installed
|
|
95
|
-
*
|
|
96
|
-
* Verifies that the skills directory exists and contains at least one .md file.
|
|
97
|
-
* Used to determine if content needs to be downloaded during license validation.
|
|
98
|
-
*
|
|
99
|
-
* @returns {boolean} True if skills directory exists with content
|
|
100
|
-
*/
|
|
101
|
-
function isContentInstalled() {
|
|
102
|
-
return (
|
|
103
|
-
fs.existsSync(SKILLS_DIR) &&
|
|
104
|
-
fs.readdirSync(SKILLS_DIR).filter((f) => f.endsWith('.md')).length > 0
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Download file from URL to local filesystem
|
|
110
|
-
*
|
|
111
|
-
* Streams the response directly to a file to handle large downloads efficiently.
|
|
112
|
-
* Automatically follows HTTP 301/302 redirects.
|
|
113
|
-
*
|
|
114
|
-
* @param {string} url - URL of the file to download
|
|
115
|
-
* @param {string} destPath - Local path where file will be saved
|
|
116
|
-
* @returns {Promise<void>} Resolves when download is complete
|
|
117
|
-
* @throws {Error} On network errors or non-200 status codes
|
|
118
|
-
*/
|
|
119
|
-
function downloadFile(url, destPath) {
|
|
120
|
-
return downloadFileInternal(url, destPath, 0);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Resolve a redirect Location header to an absolute URL.
|
|
125
|
-
*
|
|
126
|
-
* @param {string|URL} currentUrl - The current request URL
|
|
127
|
-
* @param {string} location - The Location header value
|
|
128
|
-
* @returns {string} Absolute URL string
|
|
129
|
-
*/
|
|
130
|
-
function resolveRedirectUrl(currentUrl, location) {
|
|
131
|
-
const baseUrl = currentUrl instanceof URL ? currentUrl : new URL(currentUrl);
|
|
132
|
-
return new URL(location, baseUrl).toString();
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function safeUnlink(filePath) {
|
|
136
|
-
try {
|
|
137
|
-
fs.unlinkSync(filePath);
|
|
138
|
-
} catch (e) {
|
|
139
|
-
// Ignore missing file or cleanup failures
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function downloadFileInternal(url, destPath, redirectCount) {
|
|
144
|
-
return new Promise((resolve, reject) => {
|
|
145
|
-
const urlObj = new URL(url);
|
|
146
|
-
if (urlObj.protocol !== 'https:') {
|
|
147
|
-
reject(new Error(`Insecure download URL (HTTPS required): ${url}`));
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const MAX_REDIRECTS = 5;
|
|
152
|
-
const TIMEOUT_MS = 30000;
|
|
153
|
-
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
154
|
-
|
|
155
|
-
const request = https.get(urlObj, (response) => {
|
|
156
|
-
const status = response.statusCode || 0;
|
|
157
|
-
|
|
158
|
-
// Handle redirects (including relative Location headers)
|
|
159
|
-
if (REDIRECT_STATUSES.has(status)) {
|
|
160
|
-
response.resume();
|
|
161
|
-
|
|
162
|
-
if (redirectCount >= MAX_REDIRECTS) {
|
|
163
|
-
reject(new Error(`Download failed: too many redirects (${MAX_REDIRECTS})`));
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const location = response.headers.location;
|
|
168
|
-
if (!location) {
|
|
169
|
-
reject(new Error(`Download failed: redirect (${status}) without Location header`));
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const nextUrl = resolveRedirectUrl(urlObj, location);
|
|
174
|
-
downloadFileInternal(nextUrl, destPath, redirectCount + 1)
|
|
175
|
-
.then(resolve)
|
|
176
|
-
.catch(reject);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (status !== 200) {
|
|
181
|
-
response.resume();
|
|
182
|
-
reject(new Error(`Download failed with status ${status}`));
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Only create the output file once we know the response is successful.
|
|
187
|
-
const file = fs.createWriteStream(destPath);
|
|
188
|
-
|
|
189
|
-
file.on('error', (err) => {
|
|
190
|
-
response.destroy();
|
|
191
|
-
file.close(() => {
|
|
192
|
-
safeUnlink(destPath);
|
|
193
|
-
reject(err);
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
response.on('error', (err) => {
|
|
198
|
-
file.close(() => {
|
|
199
|
-
safeUnlink(destPath);
|
|
200
|
-
reject(err);
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
response.pipe(file);
|
|
205
|
-
|
|
206
|
-
file.on('finish', () => {
|
|
207
|
-
file.close(() => resolve());
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
request.setTimeout(TIMEOUT_MS, () => {
|
|
212
|
-
request.destroy(new Error(`Download request timed out after ${TIMEOUT_MS}ms`));
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
request.on('error', (err) => {
|
|
216
|
-
safeUnlink(destPath);
|
|
217
|
-
reject(err);
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Extract ZIP file to destination directory
|
|
224
|
-
* Uses native unzip command (macOS/Linux) or PowerShell (Windows)
|
|
225
|
-
*
|
|
226
|
-
* Security: Uses spawnSync with array arguments to prevent command injection
|
|
227
|
-
*
|
|
228
|
-
* @param {string} zipPath - Path to the ZIP file to extract
|
|
229
|
-
* @param {string} destDir - Destination directory for extraction
|
|
230
|
-
* @returns {Promise<void>} Resolves when extraction is complete
|
|
231
|
-
* @throws {Error} If extraction fails
|
|
232
|
-
*/
|
|
233
|
-
function isUnsafeZipEntry(entryName, destRoot) {
|
|
234
|
-
if (!entryName) return true;
|
|
235
|
-
|
|
236
|
-
const normalized = entryName.replace(/\\/g, '/');
|
|
237
|
-
|
|
238
|
-
// Disallow absolute paths and Windows drive letters
|
|
239
|
-
if (normalized.startsWith('/') || normalized.startsWith('\\')) return true;
|
|
240
|
-
if (/^[A-Za-z]:/.test(normalized)) return true;
|
|
241
|
-
if (normalized.startsWith('//')) return true;
|
|
242
|
-
|
|
243
|
-
const targetPath = path.resolve(destRoot, entryName);
|
|
244
|
-
const relative = path.relative(destRoot, targetPath);
|
|
245
|
-
|
|
246
|
-
return relative.startsWith('..') || path.isAbsolute(relative);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Verifica que el archivo tenga los magic bytes de un ZIP válido.
|
|
251
|
-
* Los ZIP empiezan con la signature PK\x03\x04 (o PK\x05\x06 si está vacío).
|
|
252
|
-
* @param {string} zipPath - Ruta al archivo a verificar
|
|
253
|
-
* @throws {Error} Si el archivo no tiene signature de ZIP
|
|
254
|
-
*/
|
|
255
|
-
function verifyZipMagicBytes(zipPath) {
|
|
256
|
-
const header = Buffer.alloc(4);
|
|
257
|
-
const fd = fs.openSync(zipPath, 'r');
|
|
258
|
-
try {
|
|
259
|
-
fs.readSync(fd, header, 0, 4, 0);
|
|
260
|
-
} finally {
|
|
261
|
-
fs.closeSync(fd);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// ZIP local file header: PK\x03\x04
|
|
265
|
-
// ZIP empty archive: PK\x05\x06
|
|
266
|
-
const isLocalFile =
|
|
267
|
-
header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x03 && header[3] === 0x04;
|
|
268
|
-
const isEmpty =
|
|
269
|
-
header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x05 && header[3] === 0x06;
|
|
270
|
-
|
|
271
|
-
if (!isLocalFile && !isEmpty) {
|
|
272
|
-
throw new Error(
|
|
273
|
-
`El archivo descargado no es un ZIP válido (magic bytes inesperados: ${header.toString('hex')}). ` +
|
|
274
|
-
'Puede que el servidor haya retornado HTML u otro formato.'
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function extractZip(zipPath, destDir) {
|
|
280
|
-
return new Promise((resolve, reject) => {
|
|
281
|
-
try {
|
|
282
|
-
// Ensure destination directory exists
|
|
283
|
-
if (!fs.existsSync(destDir)) {
|
|
284
|
-
fs.mkdirSync(destDir, { recursive: true });
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Verify magic bytes before handing to AdmZip
|
|
288
|
-
verifyZipMagicBytes(zipPath);
|
|
289
|
-
|
|
290
|
-
const destRoot = path.resolve(destDir);
|
|
291
|
-
const zip = new AdmZip(zipPath);
|
|
292
|
-
const entries = zip.getEntries();
|
|
293
|
-
|
|
294
|
-
for (const entry of entries) {
|
|
295
|
-
if (isUnsafeZipEntry(entry.entryName, destRoot)) {
|
|
296
|
-
throw new Error(`Unsafe ZIP entry detected: ${entry.entryName}`);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
zip.extractAllTo(destRoot, true);
|
|
301
|
-
|
|
302
|
-
resolve();
|
|
303
|
-
} catch (error) {
|
|
304
|
-
reject(new Error(`Failed to extract ZIP safely: ${error.message}`));
|
|
305
|
-
}
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Download and install premium content from license server
|
|
311
|
-
*
|
|
312
|
-
* This function:
|
|
313
|
-
* 1. Creates the content cache directory if needed
|
|
314
|
-
* 2. Downloads a ZIP file containing skills, snippets, themes, etc.
|
|
315
|
-
* 3. Extracts the ZIP to ~/.bi-superpowers/
|
|
316
|
-
* 4. Cleans up the temporary ZIP file
|
|
317
|
-
* 5. Verifies the skills directory was created correctly
|
|
318
|
-
*
|
|
319
|
-
* @param {string} downloadUrl - URL provided by license validation API
|
|
320
|
-
* @returns {Promise<boolean>} True if download and extraction succeeded
|
|
321
|
-
*/
|
|
322
|
-
async function downloadPremiumContent(downloadUrl) {
|
|
323
|
-
console.log('Downloading premium content...');
|
|
324
|
-
|
|
325
|
-
// Ensure cache directory exists
|
|
326
|
-
if (!fs.existsSync(CONTENT_CACHE_DIR)) {
|
|
327
|
-
fs.mkdirSync(CONTENT_CACHE_DIR, { recursive: true });
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const zipPath = path.join(CONTENT_CACHE_DIR, 'content.zip');
|
|
331
|
-
|
|
332
|
-
try {
|
|
333
|
-
// Download the ZIP file
|
|
334
|
-
await downloadFile(downloadUrl, zipPath);
|
|
335
|
-
console.log('✓ Download complete');
|
|
336
|
-
|
|
337
|
-
// Extract the ZIP file
|
|
338
|
-
console.log('Extracting content...');
|
|
339
|
-
await extractZip(zipPath, CONTENT_CACHE_DIR);
|
|
340
|
-
console.log('✓ Extraction complete');
|
|
341
|
-
|
|
342
|
-
// Clean up ZIP file
|
|
343
|
-
fs.unlinkSync(zipPath);
|
|
344
|
-
|
|
345
|
-
// Verify skills directory exists
|
|
346
|
-
if (!fs.existsSync(SKILLS_DIR)) {
|
|
347
|
-
throw new Error('Skills directory not found after extraction');
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const skillCount = fs.readdirSync(SKILLS_DIR).filter((f) => f.endsWith('.md')).length;
|
|
351
|
-
console.log(`✓ ${skillCount} skills installed`);
|
|
352
|
-
|
|
353
|
-
return true;
|
|
354
|
-
} catch (error) {
|
|
355
|
-
console.error('Content download failed:', error.message);
|
|
356
|
-
// Clean up failed download
|
|
357
|
-
if (fs.existsSync(zipPath)) {
|
|
358
|
-
fs.unlinkSync(zipPath);
|
|
359
|
-
}
|
|
360
|
-
return false;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Get the content cache directory path
|
|
366
|
-
* @returns {string} Path to content cache
|
|
367
|
-
*/
|
|
368
|
-
function getContentCacheDir() {
|
|
369
|
-
return CONTENT_CACHE_DIR;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* Get the skills directory path
|
|
374
|
-
* @returns {string} Path to skills directory
|
|
375
|
-
*/
|
|
376
|
-
function getSkillsDir() {
|
|
377
|
-
return SKILLS_DIR;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Get the license file path
|
|
382
|
-
* @returns {string} Path to license file
|
|
383
|
-
*/
|
|
384
|
-
function getLicenseFile() {
|
|
385
|
-
return LICENSE_FILE;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
module.exports = {
|
|
389
|
-
loadLicense,
|
|
390
|
-
saveLicense,
|
|
391
|
-
clearLicense,
|
|
392
|
-
isContentInstalled,
|
|
393
|
-
downloadFile,
|
|
394
|
-
extractZip,
|
|
395
|
-
downloadPremiumContent,
|
|
396
|
-
getContentCacheDir,
|
|
397
|
-
getSkillsDir,
|
|
398
|
-
getLicenseFile,
|
|
399
|
-
_isUnsafeZipEntry: isUnsafeZipEntry,
|
|
400
|
-
_resolveRedirectUrl: resolveRedirectUrl,
|
|
401
|
-
LICENSE_FILE,
|
|
402
|
-
CONTENT_CACHE_DIR,
|
|
403
|
-
SKILLS_DIR,
|
|
404
|
-
};
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
const test = require('node:test');
|
|
2
|
-
const assert = require('node:assert/strict');
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const os = require('os');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
const AdmZip = require('adm-zip');
|
|
7
|
-
const storage = require('./storage');
|
|
8
|
-
|
|
9
|
-
function makeTempDir() {
|
|
10
|
-
return fs.mkdtempSync(path.join(os.tmpdir(), 'bi-superpowers-'));
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
test('extractZip extracts safe entries', async () => {
|
|
14
|
-
const dir = makeTempDir();
|
|
15
|
-
const zipPath = path.join(dir, 'safe.zip');
|
|
16
|
-
const zip = new AdmZip();
|
|
17
|
-
zip.addFile('foo.txt', Buffer.from('ok'));
|
|
18
|
-
zip.writeZip(zipPath);
|
|
19
|
-
|
|
20
|
-
const dest = path.join(dir, 'out');
|
|
21
|
-
await storage.extractZip(zipPath, dest);
|
|
22
|
-
|
|
23
|
-
const content = fs.readFileSync(path.join(dest, 'foo.txt'), 'utf8');
|
|
24
|
-
assert.equal(content, 'ok');
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test('rejects path traversal entries', () => {
|
|
28
|
-
const destRoot = path.resolve(makeTempDir());
|
|
29
|
-
assert.equal(storage._isUnsafeZipEntry('../evil.txt', destRoot), true);
|
|
30
|
-
assert.equal(storage._isUnsafeZipEntry('..\\\\evil.txt', destRoot), true);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test('rejects absolute path entries', () => {
|
|
34
|
-
const destRoot = path.resolve(makeTempDir());
|
|
35
|
-
assert.equal(storage._isUnsafeZipEntry('/abs.txt', destRoot), true);
|
|
36
|
-
assert.equal(storage._isUnsafeZipEntry('C:\\\\abs.txt', destRoot), true);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test('downloadFile rejects non-https URLs', async () => {
|
|
40
|
-
const dir = makeTempDir();
|
|
41
|
-
const dest = path.join(dir, 'file.zip');
|
|
42
|
-
|
|
43
|
-
await assert.rejects(
|
|
44
|
-
() => storage.downloadFile('http://example.com/file.zip', dest),
|
|
45
|
-
/HTTPS required/
|
|
46
|
-
);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test('_resolveRedirectUrl resolves relative redirects', () => {
|
|
50
|
-
const resolved = storage._resolveRedirectUrl(
|
|
51
|
-
'https://example.com/path/file.zip',
|
|
52
|
-
'/downloads/content.zip'
|
|
53
|
-
);
|
|
54
|
-
assert.equal(resolved, 'https://example.com/downloads/content.zip');
|
|
55
|
-
});
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* License Validator Module
|
|
3
|
-
* ========================
|
|
4
|
-
*
|
|
5
|
-
* Handles license validation against the Acadevor API server.
|
|
6
|
-
*
|
|
7
|
-
* @module lib/licensing/validator
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
const https = require('https');
|
|
11
|
-
const http = require('http');
|
|
12
|
-
|
|
13
|
-
const storage = require('./storage');
|
|
14
|
-
|
|
15
|
-
/** Base URL for license validation and content download API */
|
|
16
|
-
const API_BASE_URL = process.env.BIAS_API_URL || 'https://acadevor.com';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Make HTTP(S) request with JSON body
|
|
20
|
-
*
|
|
21
|
-
* Generic HTTP client that:
|
|
22
|
-
* - Automatically selects http/https based on URL protocol
|
|
23
|
-
* - Handles JSON serialization/deserialization
|
|
24
|
-
* - Returns both status code and parsed data
|
|
25
|
-
*
|
|
26
|
-
* @param {string} url - Full URL to request
|
|
27
|
-
* @param {Object} options - Node.js http/https request options
|
|
28
|
-
* @param {string} [options.method='GET'] - HTTP method
|
|
29
|
-
* @param {Object} [options.headers] - Request headers
|
|
30
|
-
* @param {Object} [data] - Request body (will be JSON.stringify'd)
|
|
31
|
-
* @returns {Promise<{status: number, data: Object|string}>} Response object
|
|
32
|
-
* @throws {Error} On network errors
|
|
33
|
-
*/
|
|
34
|
-
function makeRequest(url, options, data) {
|
|
35
|
-
return new Promise((resolve, reject) => {
|
|
36
|
-
const urlObj = new URL(url);
|
|
37
|
-
const protocol = urlObj.protocol === 'https:' ? https : http;
|
|
38
|
-
|
|
39
|
-
const req = protocol.request(url, options, (res) => {
|
|
40
|
-
let body = '';
|
|
41
|
-
res.on('data', (chunk) => (body += chunk));
|
|
42
|
-
res.on('end', () => {
|
|
43
|
-
try {
|
|
44
|
-
resolve({
|
|
45
|
-
status: res.statusCode,
|
|
46
|
-
data: body ? JSON.parse(body) : null,
|
|
47
|
-
});
|
|
48
|
-
} catch (e) {
|
|
49
|
-
resolve({
|
|
50
|
-
status: res.statusCode,
|
|
51
|
-
data: body,
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
req.on('error', reject);
|
|
58
|
-
|
|
59
|
-
if (data) {
|
|
60
|
-
req.write(JSON.stringify(data));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
req.end();
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Validate license with the Acadevor API server
|
|
69
|
-
*
|
|
70
|
-
* Makes a POST request to /api/licenses/validate to check if a license key
|
|
71
|
-
* is valid and active. Returns license metadata and download URL if valid.
|
|
72
|
-
*
|
|
73
|
-
* @param {string} licenseKey - The license key to validate
|
|
74
|
-
* @returns {Promise<Object>} Validation result
|
|
75
|
-
* @returns {boolean} .valid - Whether the license is valid
|
|
76
|
-
* @returns {string} [.email] - User's email if valid
|
|
77
|
-
* @returns {string} [.name] - User's name if valid
|
|
78
|
-
* @returns {string} [.downloadUrl] - URL to download premium content
|
|
79
|
-
* @returns {string} [.error] - Error message if invalid
|
|
80
|
-
*/
|
|
81
|
-
async function validateLicense(licenseKey) {
|
|
82
|
-
// Skip validation in dev mode (requires secret env var)
|
|
83
|
-
if (process.env.BIAS_DEV_MODE === 'true') {
|
|
84
|
-
return { valid: true, email: 'developer@local' };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
const response = await makeRequest(
|
|
89
|
-
`${API_BASE_URL}/api/licenses/validate`,
|
|
90
|
-
{
|
|
91
|
-
method: 'POST',
|
|
92
|
-
headers: { 'Content-Type': 'application/json' },
|
|
93
|
-
},
|
|
94
|
-
{ license: licenseKey }
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
return response.data;
|
|
98
|
-
} catch (error) {
|
|
99
|
-
console.error('Validation error:', error.message);
|
|
100
|
-
return { valid: false, error: 'Connection failed' };
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Check if license is valid before running premium commands
|
|
106
|
-
*
|
|
107
|
-
* This function acts as a gatekeeper for commands that require premium content.
|
|
108
|
-
* It performs the following checks:
|
|
109
|
-
* 1. Verifies a local license file exists
|
|
110
|
-
* 2. Re-validates the license against the API server
|
|
111
|
-
* 3. Downloads content if not already installed
|
|
112
|
-
*
|
|
113
|
-
* Called by kickoff, recharge, and other premium commands.
|
|
114
|
-
*
|
|
115
|
-
* @param {string} version - Current package version for content versioning
|
|
116
|
-
* @returns {Promise<Object>} Validation result from API
|
|
117
|
-
* @throws {Error} Exits process if license is invalid or content download fails
|
|
118
|
-
*/
|
|
119
|
-
async function requireLicense(version) {
|
|
120
|
-
const license = storage.loadLicense();
|
|
121
|
-
|
|
122
|
-
if (!license) {
|
|
123
|
-
console.log(`
|
|
124
|
-
═══════════════════════════════════════════════════════════════
|
|
125
|
-
LICENSE REQUIRED
|
|
126
|
-
═══════════════════════════════════════════════════════════════
|
|
127
|
-
|
|
128
|
-
BI Agent Superpowers requires a valid license to use.
|
|
129
|
-
|
|
130
|
-
To activate your license, run:
|
|
131
|
-
|
|
132
|
-
super unlock
|
|
133
|
-
|
|
134
|
-
Get your license at: https://acadevor.com/bi-superpowers
|
|
135
|
-
|
|
136
|
-
═══════════════════════════════════════════════════════════════
|
|
137
|
-
`);
|
|
138
|
-
process.exit(1);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Re-validate license with server
|
|
142
|
-
console.log('Verifying license...');
|
|
143
|
-
const result = await validateLicense(license.license);
|
|
144
|
-
|
|
145
|
-
if (!result.valid) {
|
|
146
|
-
console.log(`\n✗ License no longer valid: ${result.error}`);
|
|
147
|
-
storage.clearLicense();
|
|
148
|
-
console.log('\nPlease run "bi-superpowers unlock" with a valid license key.');
|
|
149
|
-
process.exit(1);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
console.log(`✓ License valid (${license.email})`);
|
|
153
|
-
|
|
154
|
-
const contentInstalled = storage.isContentInstalled();
|
|
155
|
-
|
|
156
|
-
// Check if content is installed, download if needed
|
|
157
|
-
if (!contentInstalled) {
|
|
158
|
-
console.log('\nPremium content not found. Downloading...');
|
|
159
|
-
if (result.downloadUrl) {
|
|
160
|
-
const downloaded = await storage.downloadPremiumContent(result.downloadUrl);
|
|
161
|
-
if (!downloaded) {
|
|
162
|
-
console.log('\n✗ Content download failed. Please check your internet connection.');
|
|
163
|
-
console.log('You can retry with "bi-superpowers unlock"');
|
|
164
|
-
process.exit(1);
|
|
165
|
-
}
|
|
166
|
-
// Persist the content version once we successfully downloaded the content.
|
|
167
|
-
storage.saveLicense({ ...license, contentVersion: version });
|
|
168
|
-
} else {
|
|
169
|
-
console.log('\n✗ No download URL provided. Please re-activate your license.');
|
|
170
|
-
console.log('Run "bi-superpowers unlock" to download content.');
|
|
171
|
-
process.exit(1);
|
|
172
|
-
}
|
|
173
|
-
} else if (license.contentVersion !== version) {
|
|
174
|
-
// Best-effort refresh: do not block if the user already has content installed.
|
|
175
|
-
const previousVersion = license.contentVersion || 'unknown';
|
|
176
|
-
console.log(
|
|
177
|
-
`\nPremium content may be outdated (${previousVersion} → ${version}). Refreshing...`
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
if (result.downloadUrl) {
|
|
181
|
-
const refreshed = await storage.downloadPremiumContent(result.downloadUrl);
|
|
182
|
-
if (refreshed) {
|
|
183
|
-
storage.saveLicense({ ...license, contentVersion: version });
|
|
184
|
-
console.log('✓ Premium content refreshed');
|
|
185
|
-
} else {
|
|
186
|
-
console.log('⚠ Premium content refresh failed. Continuing with existing content.');
|
|
187
|
-
}
|
|
188
|
-
} else {
|
|
189
|
-
console.log(
|
|
190
|
-
'⚠ No download URL provided to refresh premium content. Continuing with existing content.'
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
console.log('');
|
|
196
|
-
return result;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Get the API base URL
|
|
201
|
-
* @returns {string} API base URL
|
|
202
|
-
*/
|
|
203
|
-
function getApiBaseUrl() {
|
|
204
|
-
return API_BASE_URL;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
module.exports = {
|
|
208
|
-
makeRequest,
|
|
209
|
-
validateLicense,
|
|
210
|
-
requireLicense,
|
|
211
|
-
getApiBaseUrl,
|
|
212
|
-
API_BASE_URL,
|
|
213
|
-
};
|