diff --git a/packages/devextreme/build/gulp/npm.js b/packages/devextreme/build/gulp/npm.js index 9f7f9d5e67bd..024e709ce458 100644 --- a/packages/devextreme/build/gulp/npm.js +++ b/packages/devextreme/build/gulp/npm.js @@ -107,7 +107,7 @@ const sources = (src, dist, distGlob) => (() => merge( .pipe(gulp.dest(dist)), gulp - .src('build/npm-bin/*.js') + .src('build/npm-bin/**/*.js') .pipe(eol('\n')) .pipe(gulp.dest(`${dist}/bin`)), diff --git a/packages/devextreme/build/npm-bin/bundler-plugin/plugin-dx.d.ts b/packages/devextreme/build/npm-bin/bundler-plugin/plugin-dx.d.ts new file mode 100644 index 000000000000..a2c775ca7c76 --- /dev/null +++ b/packages/devextreme/build/npm-bin/bundler-plugin/plugin-dx.d.ts @@ -0,0 +1,7 @@ +declare module './plugin-dx.js' { + const plugin: { + vite: (...args: any[]) => any; + }; + export default plugin; +} +export {}; diff --git a/packages/devextreme/build/npm-bin/bundler-plugin/plugin-dx.js b/packages/devextreme/build/npm-bin/bundler-plugin/plugin-dx.js new file mode 100644 index 000000000000..394efb3f5013 --- /dev/null +++ b/packages/devextreme/build/npm-bin/bundler-plugin/plugin-dx.js @@ -0,0 +1,74 @@ +import path from 'node:path'; +import { createUnplugin } from 'unplugin'; + +const LICENSE_FILE_PATH = 'devextreme-license.js'; +const DEFAULT_PLACEHOLDER = '/* ___$$$$$___devextreme___lcp___placeholder____$$$$$ */'; + +function normalizeFilePath(filePath) { + return path.resolve(filePath).replace(/\\/g, '/').toLowerCase(); +} + +export default createUnplugin((options = {}) => { + const placeholder = options.placeholder ?? DEFAULT_PLACEHOLDER; + + let cachedLcpKey; + let cachedLcpKeyPromise; + + async function resolveLcpKey() { + if (cachedLcpKey) { + return cachedLcpKey; + } + if (cachedLcpKeyPromise) { + return cachedLcpKeyPromise; + } + + cachedLcpKeyPromise = (async () => { + let lcpKey ='=================@@@@@@@@@@@_LCP_Key_@@@@@@@@@====================='; + + return lcpKey; + })(); + + return cachedLcpKeyPromise; + } + + return { + name: 'devextreme-bundler-plugin', + enforce: 'pre', + transformInclude(id) { + return typeof id === 'string' && id.endsWith(LICENSE_FILE_PATH); + }, + async transform(code, id) { + try { + const targetFile = path.resolve(process.cwd(), normalizeFilePath(LICENSE_FILE_PATH)); + + if (!targetFile || !placeholder) { + return null; + } + + const normalizedId = normalizeFilePath(id.split('?')[0]); + const normalizedTarget = normalizeFilePath(targetFile); + + if (normalizedId !== normalizedTarget) { + return null; + } + + if (!code.includes(placeholder)) { + return null; + } + + const lcpKey = await resolveLcpKey(); + if (!lcpKey) { + return code; + } + + const modifedCode = code.split(placeholder).join(String(lcpKey)); + + return modifedCode; + } catch (error) { + console.warn('[devextreme-bundler-plugin] Failed.', error); + } + + return code; + } + }; +}); diff --git a/packages/devextreme/build/npm-bin/devextreme-license.js b/packages/devextreme/build/npm-bin/devextreme-license.js new file mode 100644 index 000000000000..ecab4acff18e --- /dev/null +++ b/packages/devextreme/build/npm-bin/devextreme-license.js @@ -0,0 +1,167 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const { getDevExpressLCXKey } = require("./get-lcx"); +const { convertLCXtoLCP } = require("./lcx2lcp"); + +const EXPORT_NAME = "LICENSE_KEY"; + +function fail(msg, code = 1) { + process.stderr.write(msg.endsWith("\n") ? msg : msg + "\n"); + process.exit(code); +} + +function printHelp() { + process.stdout.write( + [ + "Usage:", + " devextreme-license --out [options]", + "", + "Options:", + " --out Output file path (required)", + " --no-gitignore Do not modify .gitignore", + " --force Overwrite existing output file", + " --cwd Project root (default: process.cwd())", + " -h, --help Show help", + "", + "Example:", + ' "prebuild": "devextreme-license --out src/.devextreme/license-key.ts"', + "", + ].join("\n") + ); +} + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { + outPath: null, + gitignore: true, + force: false, + cwd: process.cwd(), + help: false, + }; + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + + if (a === "-h" || a === "--help") out.help = true; + else if (a === "--out") out.outPath = args[++i] || null; + else if (a.startsWith("--out=")) out.outPath = a.slice("--out=".length); + else if (a === "--no-gitignore") out.gitignore = false; + else if (a === "--force") out.force = true; + else if (a === "--cwd") out.cwd = args[++i] || process.cwd(); + else if (a.startsWith("--cwd=")) out.cwd = a.slice("--cwd=".length); + else fail(`Unknown argument: ${a}\nRun devextreme-license --help`); + } + + return out; +} + +function ensureDirExists(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function readTextIfExists(filePath) { + try { + if (!fs.existsSync(filePath)) return null; + return fs.readFileSync(filePath, "utf8"); + } catch { + return null; + } +} + +function writeFileAtomic(filePath, content) { + const dir = path.dirname(filePath); + const base = path.basename(filePath); + const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`); + fs.writeFileSync(tmp, content, "utf8"); + fs.renameSync(tmp, filePath); +} + +function toPosixPath(p) { + return p.split(path.sep).join("/"); +} + +function addToGitignore(projectRoot, outAbsPath) { + const gitignorePath = path.join(projectRoot, ".gitignore"); + + let rel = path.relative(projectRoot, outAbsPath); + if (rel.startsWith("..")) return; + + rel = toPosixPath(rel).trim(); + + const existing = readTextIfExists(gitignorePath); + if (existing == null) { + writeFileAtomic(gitignorePath, rel + "\n"); + return; + } + + const lines = existing.split(/\r?\n/).map((l) => l.trim()); + if (lines.includes(rel) || lines.includes("/" + rel)) return; + + const needsNewline = existing.length > 0 && !existing.endsWith("\n"); + fs.appendFileSync(gitignorePath, (needsNewline ? "\n" : "") + rel + "\n", "utf8"); +} + +function renderTsFile(lcpKey) { + return [ + "// Auto-generated by devextreme-license.", + "// Do not commit this file to source control.", + "", + `export const ${EXPORT_NAME} = ${JSON.stringify(lcpKey)} as const;`, + "", + ].join("\n"); +} + +function main() { + const opts = parseArgs(process.argv); + if (opts.help) { + printHelp(); + process.exit(0); + } + + if (!opts.outPath) { + fail("Missing required --out \nRun devextreme-license --help"); + } + + // Resolve LCX + const { key: lcx } = getDevExpressLCXKey(); + if (!lcx) { + fail( + "DevExpress license key (LCX) was not found on this machine.\n" + + "Set DevExpress_License env var or place DevExpress_License.txt in the standard location." + ); + } + + // Convert to LCP + let lcp; + try { + lcp = convertLCXtoLCP(lcx); + } catch { + fail("DevExpress license key was found but could not be converted to LCP."); + } + + const projectRoot = path.resolve(opts.cwd); + const outAbs = path.resolve(projectRoot, opts.outPath); + + ensureDirExists(path.dirname(outAbs)); + + if (!opts.force && fs.existsSync(outAbs)) { + fail(`Output file already exists: ${opts.outPath}\nUse --force to overwrite.`); + } + + writeFileAtomic(outAbs, renderTsFile(lcp)); + + if (opts.gitignore) { + try { + addToGitignore(projectRoot, outAbs); + } catch {} + } + + process.exit(0); +} + +main(); diff --git a/packages/devextreme/build/npm-bin/get-lcx.js b/packages/devextreme/build/npm-bin/get-lcx.js new file mode 100644 index 000000000000..171133d40653 --- /dev/null +++ b/packages/devextreme/build/npm-bin/get-lcx.js @@ -0,0 +1,98 @@ +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const LICENSE_ENV = "DevExpress_License"; +const LICENSE_PATH_ENV = "DevExpress_LicensePath"; +const LICENSE_FILE = "DevExpress_License.txt"; + +function isNonEmptyString(v) { + return typeof v === "string" && v.trim().length > 0; +} + +function readTextFileIfExists(filePath) { + try { + if (!filePath) return null; + if (!fs.existsSync(filePath)) return null; + const stat = fs.statSync(filePath); + if (!stat.isFile()) return null; + const raw = fs.readFileSync(filePath, "utf8"); + return isNonEmptyString(raw) ? raw : null; + } catch { + return null; + } +} + +function normalizeKey(raw) { + if (!isNonEmptyString(raw)) return null; + const lines = raw + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + + if (lines.length === 0) return null; + const lcxLike = lines.find((l) => l.startsWith("LCX")); + return (lcxLike || lines[0]).trim(); +} + +function getDefaultLicenseFilePath() { + const home = os.homedir(); + + if (process.platform === "win32") { + const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming"); + return path.join(appData, "DevExpress", LICENSE_FILE); + } + + if (process.platform === "darwin") { + return path.join( + home, + "Library", + "Application Support", + "DevExpress", + LICENSE_FILE + ); + } + + return path.join(home, ".config", "DevExpress", LICENSE_FILE); +} + +function resolveFromLicensePathEnv(licensePathValue) { + if (!isNonEmptyString(licensePathValue)) return null; + + const p = licensePathValue.trim(); + + try { + if (fs.existsSync(p)) { + const stat = fs.statSync(p); + if (stat.isFile()) return p; + if (stat.isDirectory()) return path.join(p, LICENSE_FILE); + } + } catch {} + + if (p.toLowerCase().endsWith(".txt")) return p; + return path.join(p, LICENSE_FILE); +} + +function getDevExpressLCXKey() { + // 1) env DevExpress_License + const envKey = normalizeKey(process.env[LICENSE_ENV]); + if (envKey) return { key: envKey, source: `env:${LICENSE_ENV}` }; + + // 2) env DevExpress_LicensePath + const licensePath = resolveFromLicensePathEnv(process.env[LICENSE_PATH_ENV]); + const fromCustom = normalizeKey(readTextFileIfExists(licensePath)); + if (fromCustom) return { key: fromCustom, source: `file:${licensePath}` }; + + // 3) default OS location + const defaultPath = getDefaultLicenseFilePath(); + const fromDefault = normalizeKey(readTextFileIfExists(defaultPath)); + if (fromDefault) return { key: fromDefault, source: `file:${defaultPath}` }; + + return { key: null, source: null }; +} + +module.exports = { + getDevExpressLCXKey, +}; \ No newline at end of file diff --git a/packages/devextreme/build/npm-bin/lcx2lcp.js b/packages/devextreme/build/npm-bin/lcx2lcp.js new file mode 100644 index 000000000000..6a746c079992 --- /dev/null +++ b/packages/devextreme/build/npm-bin/lcx2lcp.js @@ -0,0 +1,128 @@ +"use strict"; + +const LCX_SIGNATURE = "LCXv1"; +const LCP_SIGNATURE = "LCPv1"; +const SIGN_LENGTH = 68 * 2; // 136 chars + +const ENCODE_MAP_STR = + "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F" + + "\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F" + + '\x20x\x220]qA\'u`U?.wOCLyJnz@$*DmsMhlW/T)dKHQ+jNEa6G:VZk9!p>%e7i3S5\\^=P&(Ic,2#rtgYojOv\\$]m)JncBVsi state.s.length) { + throw new Error("Invalid license data"); + } + state.pos = end; + return state.s.slice(start, end); +} + +function safeBase64ToUtf8(b64) { + try { + return Buffer.from(b64, "base64").toString("utf8"); + } catch { + throw new Error("Invalid license data"); + } +} + +function convertLCXtoLCP(licenseString) { + assertNonEmptyString(licenseString, "licenseString"); + const input = licenseString.trim(); + + if (!input.startsWith(LCX_SIGNATURE)) { + throw new Error("Unsupported license format"); + } + + const base64Part = input.slice(LCX_SIGNATURE.length); + const lcx = safeBase64ToUtf8(base64Part); + + if (lcx.length < SIGN_LENGTH) { + throw new Error("Invalid license data"); + } + + const lcxData = decode(lcx.slice(SIGN_LENGTH)); + const state = { s: lcxData, pos: 0 }; + const signProducts = readString(state, SIGN_LENGTH); + + void readString(state); + const productsString = readString(state); + + const payloadText = signProducts + productsString; + const payloadB64 = Buffer.from(payloadText, "utf8").toString("base64"); + const encoded = encode(payloadB64); + + return LCP_SIGNATURE + encoded; +} + +function tryConvertLCXtoLCP(licenseString) { + try { + return convertLCXtoLCP(licenseString); + } catch { + return null; + } +} + +module.exports = { + convertLCXtoLCP, + tryConvertLCXtoLCP, + LCX_SIGNATURE, + LCP_SIGNATURE, +}; diff --git a/packages/devextreme/package.json b/packages/devextreme/package.json index f061a4cae140..37a7cdabdb00 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -63,7 +63,8 @@ "inferno-create-element": "catalog:", "inferno-hydrate": "catalog:", "jszip": "^3.10.1", - "rrule": "^2.7.1" + "rrule": "^2.7.1", + "unplugin": "^3.0.0" }, "devDependencies": { "@babel/core": "7.28.6", @@ -251,7 +252,8 @@ }, "bin": { "devextreme-bundler-init": "bin/bundler-init.js", - "devextreme-bundler": "bin/bundler.js" + "devextreme-bundler": "bin/bundler.js", + "devextreme-license": "bin/devextreme-license.js" }, "browserslist": [ "last 2 Chrome versions", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22e5581a8c8e..3395b66cfb1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1341,6 +1341,9 @@ importers: rrule: specifier: ^2.7.1 version: 2.8.1 + unplugin: + specifier: ^3.0.0 + version: 3.0.0 devDependencies: '@babel/core': specifier: 7.28.6 @@ -17908,6 +17911,10 @@ packages: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} + unplugin@3.0.0: + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + unquote@1.1.1: resolution: {integrity: sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==} @@ -23653,7 +23660,7 @@ snapshots: dependencies: '@babel/core': 7.28.6 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 babel-plugin-istanbul: 6.1.1 chalk: 4.1.2 convert-source-map: 2.0.0 @@ -41544,6 +41551,12 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 + unplugin@3.0.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.3 + webpack-virtual-modules: 0.6.2 + unquote@1.1.1: {} unset-value@1.0.0: