@soulbatical/tetra-dev-toolkit 1.21.0 → 1.22.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/bin/tetra-license.js +198 -0
- package/lib/audits/doctor-audit.js +103 -2
- package/package.json +3 -2
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tetra Dev Toolkit - License Key CLI
|
|
5
|
+
*
|
|
6
|
+
* Generate and verify Tetra license keys.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* tetra-license generate --customer "Acme BV" --order ORD-2026-001 --plan pro --expiry 2027-04-09
|
|
10
|
+
* tetra-license verify <key>
|
|
11
|
+
* tetra-license verify # reads TETRA_LICENSE_KEY env var
|
|
12
|
+
* tetra-license decode <key> # show payload without HMAC validation
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { program } from 'commander'
|
|
16
|
+
import chalk from 'chalk'
|
|
17
|
+
import crypto from 'node:crypto'
|
|
18
|
+
|
|
19
|
+
// ─── Inline license logic (dev-toolkit has no tetra-core dependency) ───
|
|
20
|
+
|
|
21
|
+
const KEY_VERSION = 1
|
|
22
|
+
const KEY_SEPARATOR = '.'
|
|
23
|
+
const HMAC_ALGORITHM = 'sha256'
|
|
24
|
+
const SIGNING_SECRET = 'tetra-license-v1-soulbatical-bv-2026'
|
|
25
|
+
|
|
26
|
+
const PLAN_HIERARCHY = { starter: 0, pro: 1, enterprise: 2 }
|
|
27
|
+
|
|
28
|
+
function generateKey({ orderId, customer, plan, scope, model, expiryDate }) {
|
|
29
|
+
const payload = {
|
|
30
|
+
orderId,
|
|
31
|
+
customer,
|
|
32
|
+
plan,
|
|
33
|
+
scope: scope || ['core', 'ui'],
|
|
34
|
+
model: model || 'perpetual',
|
|
35
|
+
expiry: new Date(expiryDate).getTime(),
|
|
36
|
+
keyVersion: KEY_VERSION,
|
|
37
|
+
}
|
|
38
|
+
if (isNaN(payload.expiry)) throw new Error(`Invalid expiry date: ${expiryDate}`)
|
|
39
|
+
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
|
40
|
+
const hmac = crypto.createHmac(HMAC_ALGORITHM, SIGNING_SECRET).update(payloadBase64).digest('hex')
|
|
41
|
+
return `${hmac}${KEY_SEPARATOR}${payloadBase64}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function decodeKey(key) {
|
|
45
|
+
try {
|
|
46
|
+
const sep = key.indexOf(KEY_SEPARATOR)
|
|
47
|
+
if (sep === -1) return null
|
|
48
|
+
const json = Buffer.from(key.slice(sep + 1), 'base64url').toString('utf8')
|
|
49
|
+
return JSON.parse(json)
|
|
50
|
+
} catch { return null }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function verifyKey(key) {
|
|
54
|
+
const sep = key.indexOf(KEY_SEPARATOR)
|
|
55
|
+
if (sep === -1) return { valid: false, reason: 'Invalid format' }
|
|
56
|
+
const hmacProvided = key.slice(0, sep)
|
|
57
|
+
const payloadBase64 = key.slice(sep + 1)
|
|
58
|
+
const hmacComputed = crypto.createHmac(HMAC_ALGORITHM, SIGNING_SECRET).update(payloadBase64).digest('hex')
|
|
59
|
+
try {
|
|
60
|
+
if (!crypto.timingSafeEqual(Buffer.from(hmacProvided, 'hex'), Buffer.from(hmacComputed, 'hex'))) {
|
|
61
|
+
return { valid: false, reason: 'HMAC mismatch — key has been tampered with' }
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
return { valid: false, reason: 'HMAC mismatch — invalid key' }
|
|
65
|
+
}
|
|
66
|
+
const payload = decodeKey(key)
|
|
67
|
+
if (!payload) return { valid: false, reason: 'Payload decode failed' }
|
|
68
|
+
return { valid: true, payload }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Commands ────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
program
|
|
74
|
+
.name('tetra-license')
|
|
75
|
+
.description('Generate and verify Tetra license keys')
|
|
76
|
+
.version('1.0.0')
|
|
77
|
+
|
|
78
|
+
program
|
|
79
|
+
.command('generate')
|
|
80
|
+
.description('Generate a new license key')
|
|
81
|
+
.requiredOption('--customer <name>', 'Customer name')
|
|
82
|
+
.requiredOption('--order <id>', 'Order ID (e.g., ORD-2026-001)')
|
|
83
|
+
.requiredOption('--plan <plan>', 'Plan tier: starter, pro, enterprise')
|
|
84
|
+
.requiredOption('--expiry <date>', 'Expiry date (ISO format, e.g., 2027-04-09)')
|
|
85
|
+
.option('--scope <scopes>', 'Comma-separated scopes (default: core,ui)', 'core,ui')
|
|
86
|
+
.option('--model <model>', 'Licensing model: perpetual or subscription (default: perpetual)', 'perpetual')
|
|
87
|
+
.action((options) => {
|
|
88
|
+
try {
|
|
89
|
+
if (!['starter', 'pro', 'enterprise'].includes(options.plan)) {
|
|
90
|
+
console.error(chalk.red(`Invalid plan: ${options.plan}. Must be starter, pro, or enterprise.`))
|
|
91
|
+
process.exit(1)
|
|
92
|
+
}
|
|
93
|
+
if (!['perpetual', 'subscription'].includes(options.model)) {
|
|
94
|
+
console.error(chalk.red(`Invalid model: ${options.model}. Must be perpetual or subscription.`))
|
|
95
|
+
process.exit(1)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const key = generateKey({
|
|
99
|
+
orderId: options.order,
|
|
100
|
+
customer: options.customer,
|
|
101
|
+
plan: options.plan,
|
|
102
|
+
scope: options.scope.split(','),
|
|
103
|
+
model: options.model,
|
|
104
|
+
expiryDate: options.expiry,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const payload = decodeKey(key)
|
|
108
|
+
const expiryDate = new Date(payload.expiry).toISOString().split('T')[0]
|
|
109
|
+
|
|
110
|
+
console.log()
|
|
111
|
+
console.log(chalk.green.bold('✓ License key generated'))
|
|
112
|
+
console.log()
|
|
113
|
+
console.log(chalk.dim('─'.repeat(60)))
|
|
114
|
+
console.log(chalk.bold('Key: '), key)
|
|
115
|
+
console.log(chalk.dim('─'.repeat(60)))
|
|
116
|
+
console.log(chalk.bold('Customer: '), payload.customer)
|
|
117
|
+
console.log(chalk.bold('Order: '), payload.orderId)
|
|
118
|
+
console.log(chalk.bold('Plan: '), chalk.cyan(payload.plan))
|
|
119
|
+
console.log(chalk.bold('Scope: '), payload.scope.join(', '))
|
|
120
|
+
console.log(chalk.bold('Model: '), payload.model)
|
|
121
|
+
console.log(chalk.bold('Expiry: '), expiryDate)
|
|
122
|
+
console.log(chalk.bold('Version: '), `v${payload.keyVersion}`)
|
|
123
|
+
console.log()
|
|
124
|
+
console.log(chalk.dim('Set this in the customer\'s environment:'))
|
|
125
|
+
console.log(chalk.yellow(` TETRA_LICENSE_KEY=${key}`))
|
|
126
|
+
console.log(chalk.dim('For frontend (Next.js):'))
|
|
127
|
+
console.log(chalk.yellow(` NEXT_PUBLIC_TETRA_LICENSE_KEY=${key}`))
|
|
128
|
+
console.log()
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.error(chalk.red(`Error: ${err.message}`))
|
|
131
|
+
process.exit(1)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
program
|
|
136
|
+
.command('verify [key]')
|
|
137
|
+
.description('Verify a license key (reads TETRA_LICENSE_KEY if no key given)')
|
|
138
|
+
.action((keyArg) => {
|
|
139
|
+
const key = keyArg || process.env.TETRA_LICENSE_KEY
|
|
140
|
+
if (!key) {
|
|
141
|
+
console.error(chalk.red('No key provided. Pass as argument or set TETRA_LICENSE_KEY.'))
|
|
142
|
+
process.exit(1)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const result = verifyKey(key)
|
|
146
|
+
console.log()
|
|
147
|
+
|
|
148
|
+
if (!result.valid) {
|
|
149
|
+
console.log(chalk.red.bold('✗ Invalid license key'))
|
|
150
|
+
console.log(chalk.red(` Reason: ${result.reason}`))
|
|
151
|
+
console.log()
|
|
152
|
+
process.exit(1)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const { payload } = result
|
|
156
|
+
const now = Date.now()
|
|
157
|
+
const daysRemaining = Math.floor((payload.expiry - now) / (1000 * 60 * 60 * 24))
|
|
158
|
+
const expired = payload.model === 'subscription' ? now > payload.expiry : false
|
|
159
|
+
const expiryDate = new Date(payload.expiry).toISOString().split('T')[0]
|
|
160
|
+
|
|
161
|
+
console.log(expired ? chalk.red.bold('✗ License EXPIRED') : chalk.green.bold('✓ License valid'))
|
|
162
|
+
console.log()
|
|
163
|
+
console.log(chalk.bold('Customer: '), payload.customer)
|
|
164
|
+
console.log(chalk.bold('Order: '), payload.orderId)
|
|
165
|
+
console.log(chalk.bold('Plan: '), chalk.cyan(payload.plan))
|
|
166
|
+
console.log(chalk.bold('Scope: '), payload.scope.join(', '))
|
|
167
|
+
console.log(chalk.bold('Model: '), payload.model)
|
|
168
|
+
console.log(chalk.bold('Expiry: '), expired ? chalk.red(expiryDate) : expiryDate)
|
|
169
|
+
console.log(chalk.bold('Remaining: '), daysRemaining > 0
|
|
170
|
+
? (daysRemaining <= 30 ? chalk.yellow(`${daysRemaining} days`) : chalk.green(`${daysRemaining} days`))
|
|
171
|
+
: chalk.red(`expired ${Math.abs(daysRemaining)} days ago`))
|
|
172
|
+
console.log(chalk.bold('Version: '), `v${payload.keyVersion}`)
|
|
173
|
+
console.log()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
program
|
|
177
|
+
.command('decode [key]')
|
|
178
|
+
.description('Decode a license key payload (no HMAC validation)')
|
|
179
|
+
.action((keyArg) => {
|
|
180
|
+
const key = keyArg || process.env.TETRA_LICENSE_KEY
|
|
181
|
+
if (!key) {
|
|
182
|
+
console.error(chalk.red('No key provided.'))
|
|
183
|
+
process.exit(1)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const payload = decodeKey(key)
|
|
187
|
+
if (!payload) {
|
|
188
|
+
console.error(chalk.red('Could not decode key payload.'))
|
|
189
|
+
process.exit(1)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
console.log()
|
|
193
|
+
console.log(chalk.bold('Decoded payload:'))
|
|
194
|
+
console.log(JSON.stringify(payload, null, 2))
|
|
195
|
+
console.log()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
program.parse()
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
|
14
|
-
import { join } from 'path'
|
|
14
|
+
import { join, resolve, dirname } from 'path'
|
|
15
15
|
import { execSync } from 'child_process'
|
|
16
16
|
import { glob } from 'glob'
|
|
17
17
|
|
|
@@ -419,7 +419,107 @@ function checkDarkMode(files) {
|
|
|
419
419
|
}
|
|
420
420
|
|
|
421
421
|
/**
|
|
422
|
-
*
|
|
422
|
+
* CRITICAL 7: All @source directives in globals.css must resolve to existing paths,
|
|
423
|
+
* and at least one must point to @soulbatical/tetra-ui/dist so Tailwind v4 generates
|
|
424
|
+
* AppShell layout utility classes (ml-64, ml-16, etc.).
|
|
425
|
+
*
|
|
426
|
+
* Root cause this prevents: a path with one too few `../` segments silently scans
|
|
427
|
+
* nothing, causing AppShell margins to never be generated and pages to render
|
|
428
|
+
* underneath the fixed sidebar.
|
|
429
|
+
*/
|
|
430
|
+
function checkTetraUiSourcePath(files, projectRoot) {
|
|
431
|
+
if (!files.globalsCss) {
|
|
432
|
+
return {
|
|
433
|
+
id: 'tetraUiSourcePath',
|
|
434
|
+
severity: 'critical',
|
|
435
|
+
label: 'tetra-ui @source path',
|
|
436
|
+
pass: false,
|
|
437
|
+
detail: 'globals.css not found',
|
|
438
|
+
fixable: false,
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const globalsCssFullPath = join(projectRoot, files.globalsCss.path)
|
|
443
|
+
const globalsCssDir = dirname(globalsCssFullPath)
|
|
444
|
+
const content = files.globalsCss.content
|
|
445
|
+
|
|
446
|
+
// Collect all @source directives
|
|
447
|
+
const sourceRegex = /@source\s+["']([^"']+)["']/g
|
|
448
|
+
const sourcePaths = []
|
|
449
|
+
let match
|
|
450
|
+
while ((match = sourceRegex.exec(content)) !== null) {
|
|
451
|
+
sourcePaths.push(match[1])
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (sourcePaths.length === 0) {
|
|
455
|
+
// No @source directives — skip to avoid false positives on minimal setups
|
|
456
|
+
return {
|
|
457
|
+
id: 'tetraUiSourcePath',
|
|
458
|
+
severity: 'critical',
|
|
459
|
+
label: 'tetra-ui @source path',
|
|
460
|
+
pass: true,
|
|
461
|
+
detail: `no @source directives in ${files.globalsCss.path} (skipped)`,
|
|
462
|
+
fixable: false,
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Check every @source path resolves to an existing directory/file
|
|
467
|
+
const broken = []
|
|
468
|
+
for (const sourcePath of sourcePaths) {
|
|
469
|
+
const resolvedPath = resolve(globalsCssDir, sourcePath)
|
|
470
|
+
if (!existsSync(resolvedPath)) {
|
|
471
|
+
broken.push({ raw: sourcePath, resolved: resolvedPath })
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (broken.length > 0) {
|
|
476
|
+
const first = broken[0]
|
|
477
|
+
const extra = broken.length > 1 ? ` (and ${broken.length - 1} more)` : ''
|
|
478
|
+
return {
|
|
479
|
+
id: 'tetraUiSourcePath',
|
|
480
|
+
severity: 'critical',
|
|
481
|
+
label: 'tetra-ui @source path',
|
|
482
|
+
pass: false,
|
|
483
|
+
detail: `@source "${first.raw}" resolves to non-existent path: ${first.resolved}${extra}` +
|
|
484
|
+
` — fix: @source "../../../node_modules/@soulbatical/tetra-ui/dist"` +
|
|
485
|
+
` (adjust depth to reach node_modules from ${files.globalsCss.path})`,
|
|
486
|
+
fixable: false,
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Check at least one @source entry covers tetra-ui/dist so AppShell
|
|
491
|
+
// margin classes (ml-64, ml-16, etc.) are generated by Tailwind v4
|
|
492
|
+
const tetraUiDistPaths = sourcePaths.filter(sourcePath => {
|
|
493
|
+
const resolvedPath = resolve(globalsCssDir, sourcePath)
|
|
494
|
+
return resolvedPath.includes('@soulbatical/tetra-ui') && resolvedPath.includes('dist')
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
if (tetraUiDistPaths.length === 0) {
|
|
498
|
+
return {
|
|
499
|
+
id: 'tetraUiSourcePath',
|
|
500
|
+
severity: 'critical',
|
|
501
|
+
label: 'tetra-ui @source path',
|
|
502
|
+
pass: false,
|
|
503
|
+
detail: `no @source entry pointing to @soulbatical/tetra-ui/dist found in ${files.globalsCss.path}` +
|
|
504
|
+
` — AppShell layout classes (ml-64, ml-16, etc.) will not be generated by Tailwind v4.` +
|
|
505
|
+
` Add: @source "../../../node_modules/@soulbatical/tetra-ui/dist"` +
|
|
506
|
+
` (adjust relative depth as needed)`,
|
|
507
|
+
fixable: false,
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
id: 'tetraUiSourcePath',
|
|
513
|
+
severity: 'critical',
|
|
514
|
+
label: 'tetra-ui @source path',
|
|
515
|
+
pass: true,
|
|
516
|
+
detail: `@source "${tetraUiDistPaths[0]}" exists in ${files.globalsCss.path}`,
|
|
517
|
+
fixable: false,
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* HIGH 8: tetra-core version should be recent.
|
|
423
523
|
*/
|
|
424
524
|
function checkTetraCoreVersion(files) {
|
|
425
525
|
const pkgContent = files.backendPackageJson?.content
|
|
@@ -771,6 +871,7 @@ export async function runDoctorAudit(projectRoot) {
|
|
|
771
871
|
checkNextThemes(projectRoot),
|
|
772
872
|
checkCssTokens(files),
|
|
773
873
|
checkDarkMode(files),
|
|
874
|
+
checkTetraUiSourcePath(files, projectRoot),
|
|
774
875
|
// HIGH
|
|
775
876
|
checkTetraCoreVersion(files),
|
|
776
877
|
appShellCheck,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soulbatical/tetra-dev-toolkit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "restricted"
|
|
6
6
|
},
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"tetra-check-views": "./bin/tetra-check-views.js",
|
|
46
46
|
"tetra-doctor": "./bin/tetra-doctor.js",
|
|
47
47
|
"tetra-style-audit": "./bin/tetra-style-audit.js",
|
|
48
|
-
"tetra-init-smoke": "./bin/tetra-init-smoke.js"
|
|
48
|
+
"tetra-init-smoke": "./bin/tetra-init-smoke.js",
|
|
49
|
+
"tetra-license": "./bin/tetra-license.js"
|
|
49
50
|
},
|
|
50
51
|
"files": [
|
|
51
52
|
"bin/",
|