Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 24 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,27 @@ 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
{
"name": "My project",
"version": "1.0.0",
"private": true,
"dependencies": {
"@simplelocalize/cli": "^2.10.0",
"@simplelocalize/cli": "^3.0.0"
},
"scripts": {
"start": "react-scripts start",
"sl:download": "simplelocalize download",
"sl:upload": "simplelocalize upload",
"sl:autotranslate": "simplelocalize auto-translate"
},
"simplelocalize": {
"cliVersion": "2.12.0"
}
}
```

Expand All @@ -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.
235 changes: 115 additions & 120 deletions index.cjs
Original file line number Diff line number Diff line change
@@ -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();
}
7 changes: 5 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
},
Expand Down
Loading