diff --git a/README.md b/README.md index 1e0aec9..4749dd6 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ in their workflows. npm install @simplelocalize/cli ``` -After installing the package, it should automatically choose the right binary for -your system and install it into `node_modules/.bin` directory, to make it available for your -scripts in `package.json`. +The package installs a small `simplelocalize` launcher into `node_modules/.bin`, so it's available for your +scripts in `package.json`. The launcher downloads the right SimpleLocalize CLI binary for your system +on first run (or during installation, when install scripts are allowed) and caches it per version. ```json { @@ -20,7 +20,7 @@ scripts in `package.json`. "version": "1.0.0", "private": true, "dependencies": { - "@simplelocalize/cli": "^2.10.0", + "@simplelocalize/cli": "^3.0.0" }, "scripts": { "start": "react-scripts start", @@ -28,6 +28,9 @@ scripts in `package.json`. "sl:upload": "simplelocalize upload", "sl:autotranslate": "simplelocalize auto-translate" }, + "simplelocalize": { + "cliVersion": "2.12.0" + } } ``` @@ -43,13 +46,23 @@ globally: npx @simplelocalize/cli download ``` -## Versioning explanation +## Choosing the CLI version -`@simplelocalize/cli` versioning is synced with https://github.com/simplelocalize/simplelocalize-cli versioning. +The npm package version is independent of the SimpleLocalize CLI version. The CLI version is resolved in this order: -`@simplelocalize/cli@2.9.1` will install SimpleLocalize CLI 2.9.x and this npm package version is 1. -`@simplelocalize/cli@2.9.2` will install SimpleLocalize CLI 2.9.x and this npm package version is 2. +1. `SIMPLELOCALIZE_CLI_VERSION` environment variable, e.g. `SIMPLELOCALIZE_CLI_VERSION=2.12.0 npx @simplelocalize/cli download` +2. `simplelocalize.cliVersion` in the nearest `package.json` that defines it (searched from the current directory upwards) +3. the default CLI version bundled with the installed package (`cliVersion` in its `package.json`) -By using this versioning scheme, we can release new versions of this npm package -without changing the SimpleLocalize CLI version, for example, to fix bugs in this package, -and follow semantic versioning rules. +Use a full version, e.g. `2.12.0`. See [CLI releases](https://github.com/simplelocalize/simplelocalize-cli/releases) for available versions. +We recommend pinning `simplelocalize.cliVersion` in your project, so every developer and CI run uses the same CLI. + +Other environment variables: + +- `SIMPLELOCALIZE_CLI_CACHE_DIR` – where binaries are cached (default: `~/.cache/simplelocalize/cli`, `%LOCALAPPDATA%\simplelocalize\cli` on Windows). Cache this directory in CI to skip downloads. +- `SIMPLELOCALIZE_CLI_BINARY` – path to an already installed CLI binary; skips the download entirely. + +## Upgrading from 2.x + +`@simplelocalize/cli@2.x` versions were tied to the CLI version (`2.10.x` installed CLI `2.10.0`). +Since `3.0.0`, pin the CLI version with `simplelocalize.cliVersion` instead. The launcher requires Node.js 18 or newer. diff --git a/index.cjs b/index.cjs index d9281ce..48129bb 100644 --- a/index.cjs +++ b/index.cjs @@ -1,164 +1,159 @@ #!/usr/bin/env node -const { execSync } = require("child_process"); +const { spawnSync } = require("child_process"); const path = require("path"); const fs = require('fs'); const axios = require('axios'); const os = require('os'); -let binaryPath = path.join(__dirname, 'bin', 'simplelocalize'); -if (os.platform() === 'win32') { - binaryPath += '.exe'; -} - -const getExpectedCliVersion = () => { - const packageVersion = require('./package.json').version; - const parts = packageVersion.split('.'); - return parts[0] + '.' + parts[1] + '.0'; +const VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/; + +const isValidVersion = (version) => typeof version === 'string' && VERSION_PATTERN.test(version); + +// Walks up from the project directory and returns the first "simplelocalize.cliVersion" found in package.json. +const findPinnedVersion = (startDir) => { + let dir = path.resolve(startDir); + while (true) { + const packageJsonPath = path.join(dir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const cliVersion = packageJson.simplelocalize && packageJson.simplelocalize.cliVersion; + if (cliVersion) { + return { version: String(cliVersion), source: packageJsonPath }; + } + } catch (error) { + // ignore unreadable package.json and keep looking + } + } + const parent = path.dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } }; -const isBinaryInstalled = () => { - try { - const output = execSync(binaryPath + ' --version').toString().trim(); - const expectedVersion = getExpectedCliVersion(); - return output.includes(expectedVersion); - } - catch (error) { - return false; - } -} +const resolveCliVersion = (projectDir) => { + const fromEnv = process.env.SIMPLELOCALIZE_CLI_VERSION; + const resolved = fromEnv + ? { version: fromEnv, source: 'SIMPLELOCALIZE_CLI_VERSION' } + : findPinnedVersion(projectDir) || { version: require('./package.json').cliVersion, source: 'default' }; -const printBinaryVersion = () => { - try { - console.log(execSync(binaryPath + ' --version').toString()); + if (!isValidVersion(resolved.version)) { + throw new Error(`Invalid SimpleLocalize CLI version '${resolved.version}' (from ${resolved.source}), use a full version, e.g. 2.12.0`); } - catch (error) { - console.error('Error running SimpleLocalize CLI'); - process.exit(1); - } -} + return resolved.version; +}; -const buildDownloadBinaryUrl = (version) => { - let platform = ""; - if (os.platform() === "win32") { - platform = "windows.exe" +const getPlatformSuffix = () => { + const arch = os.arch() === "arm64" ? "-arm64" : ""; + switch (os.platform()) { + case "win32": + return "windows.exe"; + case "darwin": + return "mac" + arch; + case "linux": + return "linux" + arch; + default: + throw new Error(`Unsupported platform: ${os.platform()} ${os.arch()}, set SIMPLELOCALIZE_CLI_BINARY to a binary path or use the JAR`); } +}; - if (os.platform() === "darwin") { - platform = "mac" +const getCacheDir = () => { + if (process.env.SIMPLELOCALIZE_CLI_CACHE_DIR) { + return process.env.SIMPLELOCALIZE_CLI_CACHE_DIR; } - - if (os.platform() === "linux") { - platform = "linux" + if (os.platform() === 'win32' && process.env.LOCALAPPDATA) { + return path.join(process.env.LOCALAPPDATA, 'simplelocalize', 'cli'); } + const xdgCache = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); + return path.join(xdgCache, 'simplelocalize', 'cli'); +}; - let arch = ""; - if (os.arch() === "arm64") { - arch = "-arm64" - } - console.log(`Downloading SimpleLocalize CLI ${version} for ${platform}${arch}...`); - return `https://get.simplelocalize.io/binaries/${version}/simplelocalize-cli-${platform}${arch}` -} +const getBinaryPath = (version) => { + const fileName = 'simplelocalize-cli' + (os.platform() === 'win32' ? '.exe' : ''); + return path.join(getCacheDir(), version, fileName); +}; -async function installBinary() { - const cliVersion = getExpectedCliVersion(); - if (fs.existsSync(binaryPath)) { - console.log('Removing existing binary...'); - fs.unlinkSync(binaryPath); - } - const downloadUrl = buildDownloadBinaryUrl(cliVersion); - if (!fs.existsSync(path.join(__dirname, 'bin'))) { - fs.mkdirSync(path.join(__dirname, 'bin')); - } +async function installBinary(version, binaryPath) { + const downloadUrl = `https://get.simplelocalize.io/binaries/${version}/simplelocalize-cli-${getPlatformSuffix()}`; + console.error(`Downloading SimpleLocalize CLI ${version} from ${downloadUrl}...`); + fs.mkdirSync(path.dirname(binaryPath), { recursive: true }); + // Download to a temporary file and rename, so parallel runs never see a partial binary + const tempPath = `${binaryPath}.${process.pid}.tmp`; try { const response = await axios({ url: downloadUrl, method: 'GET', responseType: 'stream' }); - - const writer = fs.createWriteStream(binaryPath); + const writer = fs.createWriteStream(tempPath); response.data.pipe(writer); await new Promise((resolve, reject) => { writer.on('finish', resolve); writer.on('error', reject); + response.data.on('error', reject); }); + if (os.platform() !== 'win32') { + fs.chmodSync(tempPath, 0o755); + } + fs.renameSync(tempPath, binaryPath); } catch (error) { - console.error('Error downloading SimpleLocalize CLI binary:', error.message); - process.exit(1); + fs.rmSync(tempPath, { force: true }); + try { + fs.rmdirSync(path.dirname(binaryPath)); + } catch (ignored) { + // not empty, another run already installed this version + } + const status = error.response && error.response.status; + const reason = status === 404 ? `version ${version} not found` : error.message; + throw new Error(`Error downloading SimpleLocalize CLI binary: ${reason}`); } +} - // Make it executable (only on Unix-based systems) - if (os.platform() !== 'win32') { - console.log('Making binary executable...'); - fs.chmodSync(binaryPath, 0o755); +const ensureBinary = async (projectDir) => { + if (process.env.SIMPLELOCALIZE_CLI_BINARY) { + return process.env.SIMPLELOCALIZE_CLI_BINARY; } - - // Ensure binary exists and is executable before continuing + const version = resolveCliVersion(projectDir); + const binaryPath = getBinaryPath(version); if (!fs.existsSync(binaryPath)) { - throw new Error('Binary file was not created!'); - } -} - -const linkToNodeModulesBin = () => { - let nodeModulesBinPath = path.join(__dirname, '..', '..', '.bin', 'simplelocalize'); - - if (os.platform() === 'win32') { - nodeModulesBinPath += '.exe'; + await installBinary(version, binaryPath); } + return binaryPath; +}; - // Ensure the .bin directory exists - const binDir = path.dirname(nodeModulesBinPath); - if (!fs.existsSync(binDir)) { - fs.mkdirSync(binDir, { recursive: true }); +const run = async () => { + let binaryPath; + try { + binaryPath = await ensureBinary(process.cwd()); + } catch (error) { + console.error(error.message); + process.exit(1); } - - if (fs.existsSync(nodeModulesBinPath)) { - fs.unlinkSync(nodeModulesBinPath); + const result = spawnSync(binaryPath, process.argv.slice(2), { stdio: 'inherit' }); + if (result.error) { + console.error(`Error running SimpleLocalize CLI (${binaryPath}): ${result.error.message}`); + process.exit(1); } - fs.symlinkSync(binaryPath, nodeModulesBinPath); - console.log('Link to node_modules/.bin created.'); -} - -const init = async () => { - console.log("Checking if SimpleLocalize CLI is installed..."); - if (!isBinaryInstalled()) { - console.log("SimpleLocalize CLI not installed. Installing..."); - await installBinary(); - if (!isBinaryInstalled()) { - console.error('Error installing SimpleLocalize CLI'); - process.exit(1); - } - console.log("SimpleLocalize CLI installed successfully!"); - } else { - console.log("SimpleLocalize CLI already installed."); + if (result.signal) { + process.kill(process.pid, result.signal); } - printBinaryVersion(); - linkToNodeModulesBin(); - process.exit(0); -} + process.exit(result.status ?? 1); +}; -// Only run install logic if called with 'install' argument (for postinstall) -if (process.argv[2] === 'install') { - (async () => { - await init(); - })(); -} else { - // Forward all other CLI commands to the downloaded binary - if (!isBinaryInstalled()) { - console.error('SimpleLocalize CLI binary is not installed. Please run: npm install'); - process.exit(1); - } - // Build the command to forward - const args = process.argv.slice(2).map(arg => { - // Quote arguments with spaces for safety - if (/\s/.test(arg)) return `"${arg}"`; - return arg; - }).join(' '); +// Optional prefetch during installation; never fails the install (the binary is downloaded on first run anyway) +const prefetch = async () => { try { - execSync(`${binaryPath} ${args}`, { stdio: 'inherit' }); - process.exit(0); + await ensureBinary(process.env.INIT_CWD || process.cwd()); } catch (error) { - process.exit(error.status || 1); + console.warn(`${error.message}. SimpleLocalize CLI will be downloaded on first run.`); } +}; + +if (process.argv[2] === 'install' && process.env.npm_lifecycle_event === 'postinstall') { + prefetch(); +} else { + run(); } diff --git a/package-lock.json b/package-lock.json index e20b5e6..db30fd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@simplelocalize/cli", - "version": "2.9.0-rc.2", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@simplelocalize/cli", - "version": "2.9.0-rc.2", + "version": "3.0.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -14,6 +14,9 @@ }, "bin": { "simplelocalize": "index.cjs" + }, + "engines": { + "node": ">=18" } }, "node_modules/asynckit": { diff --git a/package.json b/package.json index ce10420..122465b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "@simplelocalize/cli", - "version": "2.10.0", + "version": "3.0.0", + "cliVersion": "2.12.0", "description": "SimpleLocalize CLI for NPM", "main": "index.cjs", "type": "module", @@ -9,11 +10,17 @@ }, "repository": { "type": "git", - "url": "https://github.com/simplelocalize/simplelicalize-cli-npm.git" + "url": "https://github.com/jpomykala/simplelocalize-cli-npm.git" }, "bin": { "simplelocalize": "index.cjs" }, + "files": [ + "index.cjs" + ], + "engines": { + "node": ">=18" + }, "dependencies": { "axios": "^1.8.3" },