2026-05-10 23:12:26 +02:00
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
|
|
|
|
import { execFile } from 'node:child_process';
|
|
|
|
|
import { constants } from 'node:fs';
|
|
|
|
|
import { access as fsAccess, chmod as fsChmod, writeFile as fsWriteFile } from 'node:fs/promises';
|
|
|
|
|
import { delimiter, join } from 'node:path';
|
|
|
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
|
import { promisify } from 'node:util';
|
2026-05-10 23:51:24 +02:00
|
|
|
import { ensureCliBinExecutable, ktxRootDir } from './prepare-cli-bin.mjs';
|
2026-05-10 23:12:26 +02:00
|
|
|
|
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
|
|
|
|
|
|
function hasFlag(flag) {
|
|
|
|
|
return process.argv.includes(flag);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function optionValue(flag, fallback) {
|
|
|
|
|
const index = process.argv.indexOf(flag);
|
|
|
|
|
if (index === -1) {
|
|
|
|
|
return fallback;
|
|
|
|
|
}
|
|
|
|
|
const value = process.argv[index + 1];
|
|
|
|
|
if (!value || value.startsWith('-')) {
|
|
|
|
|
throw new Error(`${flag} requires a value`);
|
|
|
|
|
}
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function commandEnv(extraPath) {
|
|
|
|
|
if (!extraPath) {
|
|
|
|
|
return process.env;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...process.env,
|
|
|
|
|
PATH: `${extraPath}${delimiter}${process.env.PATH ?? ''}`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function execText(command, args, options = {}) {
|
|
|
|
|
const result = await execFileAsync(command, args, {
|
|
|
|
|
cwd: options.cwd,
|
|
|
|
|
env: options.env,
|
|
|
|
|
maxBuffer: 1024 * 1024,
|
|
|
|
|
});
|
|
|
|
|
return `${result.stdout}${result.stderr}`.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function optionalText(command, args, options = {}) {
|
|
|
|
|
try {
|
|
|
|
|
return await execText(command, args, options);
|
|
|
|
|
} catch {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function findPnpmGlobalBin() {
|
|
|
|
|
const output = await optionalText('pnpm', ['bin', '--global']);
|
|
|
|
|
return output.split(/\r?\n/).find((line) => line.trim().length > 0)?.trim() ?? '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shellDoubleQuote(value) {
|
|
|
|
|
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('$', '\\$').replaceAll('`', '\\`')}"`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertBinaryName(binaryName) {
|
|
|
|
|
if (!/^[A-Za-z][A-Za-z0-9._-]*$/.test(binaryName)) {
|
|
|
|
|
throw new Error(`Invalid binary name: ${binaryName}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function writePinnedPosixLauncher(globalBin, binPath, binaryName, writeFile, chmod) {
|
|
|
|
|
const launcherPath = join(globalBin, binaryName);
|
|
|
|
|
const script = [
|
|
|
|
|
'#!/bin/sh',
|
2026-05-10 23:51:24 +02:00
|
|
|
'# Generated by `pnpm run link:dev` in the KTX workspace.',
|
2026-05-10 23:12:26 +02:00
|
|
|
'# Keep this launcher pinned to the Node binary that built native dependencies.',
|
|
|
|
|
`exec ${shellDoubleQuote(process.execPath)} ${shellDoubleQuote(binPath)} "$@"`,
|
|
|
|
|
'',
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
|
|
|
|
await writeFile(launcherPath, script, 'utf-8');
|
|
|
|
|
await chmod(launcherPath, 0o755);
|
|
|
|
|
return launcherPath;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function writePinnedWindowsLauncher(globalBin, binPath, binaryName, writeFile) {
|
|
|
|
|
const launcherPath = join(globalBin, `${binaryName}.cmd`);
|
|
|
|
|
const script = [
|
|
|
|
|
'@echo off',
|
2026-05-10 23:51:24 +02:00
|
|
|
'REM Generated by `pnpm run link:dev` in the KTX workspace.',
|
2026-05-10 23:12:26 +02:00
|
|
|
`"${process.execPath}" "${binPath}" %*`,
|
|
|
|
|
'',
|
|
|
|
|
].join('\r\n');
|
|
|
|
|
|
|
|
|
|
await writeFile(launcherPath, script, 'utf-8');
|
|
|
|
|
return launcherPath;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function writePinnedLauncher(globalBin, binPath, binaryName, deps) {
|
|
|
|
|
if (!globalBin) {
|
|
|
|
|
throw new Error('Could not find pnpm global bin directory. Run `pnpm setup`, restart your shell, then retry.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (process.platform === 'win32') {
|
|
|
|
|
return writePinnedWindowsLauncher(globalBin, binPath, binaryName, deps.writeFile);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return writePinnedPosixLauncher(globalBin, binPath, binaryName, deps.writeFile, deps.chmod);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function verifyBinaryOnPath(binaryName, globalBin, execTextFn) {
|
|
|
|
|
try {
|
|
|
|
|
const output = await execTextFn(binaryName, ['--version']);
|
|
|
|
|
return { ok: true, output };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (!globalBin) {
|
|
|
|
|
return { ok: false, output: '', error };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const output = await execTextFn(binaryName, ['--version'], { env: commandEnv(globalBin) });
|
|
|
|
|
return { ok: false, output, globalBin, error };
|
|
|
|
|
} catch {
|
|
|
|
|
return { ok: false, output: '', error };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function assertBuiltCli(rootDir, access, binPathOverride) {
|
|
|
|
|
const binPath = binPathOverride ?? (await ensureCliBinExecutable(rootDir));
|
|
|
|
|
await access(binPath, constants.X_OK);
|
|
|
|
|
return binPath;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function linkDevCli(options = {}) {
|
2026-05-10 23:51:24 +02:00
|
|
|
const rootDir = options.rootDir ?? ktxRootDir();
|
|
|
|
|
const binaryName = options.binaryName ?? 'ktx-dev';
|
2026-05-10 23:12:26 +02:00
|
|
|
const access = options.access ?? fsAccess;
|
|
|
|
|
const chmod = options.chmod ?? fsChmod;
|
|
|
|
|
const writeFile = options.writeFile ?? fsWriteFile;
|
|
|
|
|
const execTextFn = options.execText ?? execText;
|
|
|
|
|
assertBinaryName(binaryName);
|
|
|
|
|
|
|
|
|
|
const binPath = await assertBuiltCli(rootDir, access, options.binPath);
|
|
|
|
|
const globalBin = options.globalBin ?? (await findPnpmGlobalBin());
|
|
|
|
|
|
|
|
|
|
if (options.checkOnly) {
|
|
|
|
|
return {
|
|
|
|
|
binaryName,
|
|
|
|
|
binPath,
|
|
|
|
|
linked: false,
|
|
|
|
|
verification: await verifyBinaryOnPath(binaryName, globalBin, execTextFn),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const launcherPath = await writePinnedLauncher(globalBin, binPath, binaryName, { writeFile, chmod });
|
|
|
|
|
const verification = await verifyBinaryOnPath(binaryName, globalBin, execTextFn);
|
|
|
|
|
if (!verification.ok) {
|
|
|
|
|
const pathHint = verification.globalBin
|
|
|
|
|
? `\nAdd pnpm's global bin directory to PATH, then retry:\n\n export PATH="${verification.globalBin}:$PATH"\n\n`
|
|
|
|
|
: '\nRun `pnpm setup`, restart your shell, then rerun `pnpm run link:dev`.\n\n';
|
|
|
|
|
|
|
|
|
|
throw new Error(`${binaryName} was linked at ${launcherPath}, but it is not available on PATH.${pathHint}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
binaryName,
|
|
|
|
|
binPath,
|
|
|
|
|
launcherPath,
|
|
|
|
|
linked: true,
|
|
|
|
|
verification,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
|
|
|
try {
|
|
|
|
|
const result = await linkDevCli({
|
|
|
|
|
checkOnly: hasFlag('--check-only'),
|
2026-05-10 23:51:24 +02:00
|
|
|
binaryName: optionValue('--name', 'ktx-dev'),
|
2026-05-10 23:12:26 +02:00
|
|
|
});
|
2026-05-10 23:51:24 +02:00
|
|
|
process.stdout.write(`KTX CLI bin: ${result.binPath}\n`);
|
2026-05-10 23:12:26 +02:00
|
|
|
if (result.linked) {
|
|
|
|
|
process.stdout.write(`Linked binary: ${result.binaryName}\n`);
|
|
|
|
|
process.stdout.write(`Verified: ${result.verification.output}\n`);
|
|
|
|
|
process.stdout.write(`Pinned Node: ${process.execPath} ${process.version} ABI ${process.versions.modules}\n`);
|
|
|
|
|
process.stdout.write(`You can now run \`${result.binaryName} --help\` from any directory.\n`);
|
|
|
|
|
} else if (result.verification.ok) {
|
|
|
|
|
process.stdout.write(`Already available: ${result.verification.output}\n`);
|
|
|
|
|
} else {
|
|
|
|
|
process.stdout.write(`${result.binaryName} is not linked on PATH yet.\n`);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
|
|
|
process.exitCode = 1;
|
|
|
|
|
}
|
|
|
|
|
}
|