@controlplane/cli
Version:
Control Plane Corporation CLI
483 lines • 20.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.UpgradeCmd = void 0;
exports.resolveUpgradeAction = resolveUpgradeAction;
const child_process_1 = require("child_process");
const command_1 = require("../cli/command");
const functions_1 = require("../util/functions");
const format_1 = require("../format/format");
const options_1 = require("./options");
const version_1 = require("../util/version");
const install_method_1 = require("../upgrade/install-method");
const binary_upgrade_1 = require("../upgrade/binary-upgrade");
// ANCHOR - Constants
/** Where to point users when no automated upgrade path exists for their install. */
const DOWNLOAD_DOCS_URL = 'https://docs.controlplane.com/reference/cli';
/** The manual-action explanation for a project-local (package.json / npx) install. */
const LOCAL_INSTALL_REASON = "cpln appears to be installed as a project dependency (or was run via npx); a global upgrade would not affect the copy in use. Update the version in the project's package.json instead, or upgrade a global install directly (e.g. npm install -g @controlplane/cli@latest).";
/** The manual-action explanation for a source checkout. */
const DEV_BUILD_REASON = 'This is a development build of cpln; there is nothing to upgrade.';
// ANCHOR - Options
/**
* Adds the `cpln upgrade` flags controlling inspection and forcing.
*
* @param {Argv} yargs - The yargs instance to configure.
* @returns {Argv} The configured yargs instance.
*/
function withUpgradeOptions(yargs) {
const group = 'Upgrade options:';
return yargs.options({
check: {
group,
alias: 'c',
boolean: true,
describe: 'Report the installed and latest versions without upgrading',
},
force: {
group,
boolean: true,
describe: 'Upgrade even when already on the latest version',
},
});
}
// ANCHOR - Command
/**
* Upgrades the CLI in place. This command must work on a machine that has never
* logged in, so it writes through the injected out/err streams and never touches
* this.session — the session getter throws when no profile is configured.
*/
class UpgradeCmd extends command_1.Command {
constructor(overrides = {}) {
super();
this.command = 'upgrade';
this.aliases = ['update'];
this.describe = 'Upgrade the cpln CLI to the latest version';
this.deps = { ...this.defaultDeps(), ...overrides };
}
// Public Methods //
builder(yargs) {
return (0, functions_1.pipe)(
//
withUpgradeOptions, options_1.withFormatOptions, options_1.withDebugOptions)(yargs);
}
async handle(args) {
// Infer how this CLI was installed; it dictates how it can be upgraded
const probe = this.deps.probe();
const method = (0, install_method_1.detectInstallMethod)(probe);
// --check only reports; it never touches anything
if (args.check) {
await this.runCheck(method, probe, args);
return;
}
// A source checkout is upgraded by pulling, not by this command
if (method === 'source') {
this.end(DEV_BUILD_REASON);
}
const action = resolveUpgradeAction(method, probe);
await this.runAction(action, method, probe, Boolean(args.force));
}
// Private Methods //
/**
* Executes the resolved upgrade action for the detected install method.
*
* @param {UpgradeAction} action - The method-specific action to perform.
* @param {InstallMethod} method - The detected install method, used for messaging.
* @param {InstallProbe} probe - The environment probe, reused for the binary path.
* @param {boolean} force - Whether to upgrade even when already current.
* @returns {Promise<void>}
*/
async runAction(action, method, probe, force) {
// No automated path exists; automation must be able to detect the no-op
if (action.kind === 'manual') {
this.fail(`${action.reason}\n\nSee ${DOWNLOAD_DOCS_URL} for installation instructions.`);
}
if (action.kind === 'managed') {
await this.runManaged(action, method, force);
return;
}
await this.runBinary(action.artifact, probe, force);
}
/**
* Handles an npm/Homebrew upgrade by delegating to the owning package manager.
*
* @param {ManagedAction} action - The managed action, carrying the command and any preflight.
* @param {InstallMethod} method - The detected install method, used for messaging.
* @param {boolean} force - Whether to skip the up-to-date gate.
* @returns {Promise<void>}
*/
async runManaged(action, method, force) {
// Only the npm channel is gated on the registry dist-tag; `brew upgrade` performs its own check
if (method === 'npm' && !force) {
await this.gateNpmChannel();
}
this.runPreflight(action, method);
const rendered = (0, install_method_1.renderCommand)(action.command);
this.deps.out(`Running '${rendered}'...`);
const result = this.deps.spawn(action.command);
// A non-zero status, a null status (killed by a signal), or a spawn error all mean failure
if (result.error || result.status !== 0) {
const cause = result.signal ? `was terminated by ${result.signal}` : 'failed';
this.fail(`ERROR: '${rendered}' ${cause}. Run it manually to upgrade; you may need elevated permissions (e.g. sudo) or to ensure ${action.command.program} is installed and on your PATH.`);
}
this.deps.out(`✓ cpln upgrade completed.`);
}
/**
* Ends the command early when the npm channel is already current, or fails when
* the registry cannot be reached.
*
* @returns {Promise<void>}
*/
async gateNpmChannel() {
const latestVersion = await this.deps.fetchLatestVersion();
if (latestVersion === undefined) {
this.fail('ERROR: Unable to determine the latest version. Check your connection, or re-run with --force to upgrade anyway.');
}
if (!(0, version_1.isNewerVersion)(latestVersion, version_1.About.npm)) {
this.end(`cpln is already up to date (${version_1.About.npm}).`);
}
}
/**
* Runs a managed action's preflight step (e.g. `brew update`). A failed refresh
* is non-fatal; the upgrade proceeds with the currently available version.
*
* @param {ManagedAction} action - The managed action, possibly carrying a preflight.
* @param {InstallMethod} method - The detected install method, used for messaging.
* @returns {void}
*/
runPreflight(action, method) {
if (!action.preflight) {
return;
}
const rendered = (0, install_method_1.renderCommand)(action.preflight);
this.deps.out(`Refreshing ${method} ('${rendered}')...`);
const refresh = this.deps.spawn(action.preflight);
if (refresh.error || refresh.status !== 0) {
this.deps.out(`Warning: '${rendered}' did not complete; continuing with the currently available version.`);
}
}
/**
* Handles a standalone-binary upgrade by downloading, verifying, and replacing it.
*
* The release bucket — not the npm registry — is the authoritative source for this
* channel, so both the up-to-date check and the download resolve from it.
*
* @param {BinaryArtifact} artifact - The release archive selected for this platform.
* @param {InstallProbe} probe - The environment probe carrying the executable path.
* @param {boolean} force - Whether to reinstall even when already current.
* @returns {Promise<void>}
*/
async runBinary(artifact, probe, force) {
const baseUrl = (0, binary_upgrade_1.getBaseUrl)(probe.env);
const tag = await this.resolveReleaseTag(baseUrl, force);
// Download, verify, and atomically replace the installed executables
this.deps.out(`Downloading the latest cpln release...`);
let replaced;
try {
replaced = await (0, binary_upgrade_1.performBinaryUpgrade)({
baseUrl,
execPath: probe.execPath,
artifact,
tag,
download: this.deps.download,
fileOps: this.deps.fileOps,
});
}
catch (error) {
this.fail(binaryFailureMessage(error, probe.execPath));
}
const fileCount = `${replaced.length} file${replaced.length === 1 ? '' : 's'}`;
this.deps.out(`✓ cpln upgrade completed (replaced ${fileCount}).`);
}
/**
* Resolves the release tag to install, ending early when this build is already
* current or newer than the published release.
*
* @param {string} baseUrl - The release-artifact base URL.
* @param {boolean} force - Whether to bypass the up-to-date and ordering gates.
* @returns {Promise<string>} The tag to install.
*/
async resolveReleaseTag(baseUrl, force) {
let tag;
try {
tag = await (0, binary_upgrade_1.fetchLatestTag)(baseUrl, this.deps.download);
}
catch (error) {
const detail = error instanceof Error ? error.message : 'Unknown error';
this.fail(`ERROR: Unable to reach the release server: ${detail}`);
}
if (force) {
return tag;
}
// The tag embeds the build's git revision, so a matching revision means current
if (version_1.About.version !== 'dev' && tag.endsWith(`-${version_1.About.version}`)) {
this.end(`cpln is already up to date.`);
}
// Never step backwards onto an older release (e.g. a stale or rolled-back pointer)
if (isOlderRelease(tag)) {
this.end(`The published release is older than this build; nothing to upgrade. Re-run with --force to install it anyway.`);
}
return tag;
}
/**
* Reports the version/install-method status, honoring the requested output format.
*
* @param {InstallMethod} method - The detected install method.
* @param {InstallProbe} probe - The environment probe, used for the release bucket URL.
* @param {Arguments<UpgradeOptions & FormatOptions>} args - The parsed command-line arguments.
* @returns {Promise<void>}
*/
async runCheck(method, probe, args) {
const status = await this.buildStatus(method, probe);
// Structured formats serialize the status object; everything else gets the human report
if (args.output && format_1.STRUCTURED_FORMATS.includes(args.output)) {
this.deps.out((0, format_1.format)(status, args));
return;
}
this.deps.out(buildCheckReport(status).join('\n'));
}
/**
* Builds the current-versus-latest picture, consulting the source of truth for
* the channel: the release bucket for a standalone binary, the npm registry
* otherwise.
*
* @param {InstallMethod} method - The detected install method.
* @param {InstallProbe} probe - The environment probe, used for the release bucket URL.
* @returns {Promise<UpgradeStatus>} The status to report.
*/
async buildStatus(method, probe) {
if (method === 'binary') {
let latestRelease;
try {
latestRelease = await (0, binary_upgrade_1.fetchLatestTag)((0, binary_upgrade_1.getBaseUrl)(probe.env), this.deps.download);
}
catch (_a) {
latestRelease = undefined;
}
return {
method,
currentVersion: version_1.About.npm,
currentRelease: installedRelease(),
latestRelease,
selfUpdatable: resolveBinaryAction().kind === 'binary',
updateAvailable: latestRelease !== undefined && isNewerRelease(latestRelease),
};
}
const latestVersion = await this.deps.fetchLatestVersion();
return {
method,
currentVersion: version_1.About.npm,
latestVersion,
updateAvailable: (0, version_1.isNewerVersion)(latestVersion, version_1.About.npm),
};
}
/**
* Builds the default collaborators backed by the real environment.
*
* @returns {UpgradeDeps} The production dependencies.
*/
defaultDeps() {
return {
probe: install_method_1.probeInstall,
fetchLatestVersion: version_1.getLatestPackageVersion,
download: binary_upgrade_1.httpDownloader,
fileOps: binary_upgrade_1.realFileOps,
spawn: runCommand,
out: (message) => (0, format_1.write)(this.env.out, message),
err: (message) => (0, format_1.write)(this.env.err, message),
exit: (code) => process.exit(code),
};
}
/**
* Prints a message to stdout and exits with code 0.
*
* @param {string} message - The message to print.
* @returns {never}
*/
end(message) {
this.deps.out(message);
return this.deps.exit(0);
}
/**
* Prints an error message to stderr and exits with code 1.
*
* @param {string} message - The message to print.
* @returns {never}
*/
fail(message) {
this.deps.err(message);
return this.deps.exit(1);
}
}
exports.UpgradeCmd = UpgradeCmd;
// ANCHOR - Functions
/**
* Resolves the concrete upgrade action for the detected install method.
*
* @param {InstallMethod} method - The detected install method.
* @param {InstallProbe} probe - The environment probe (for package-manager detection).
* @returns {UpgradeAction} The action to perform.
*/
function resolveUpgradeAction(method, probe) {
switch (method) {
case 'homebrew':
// Refresh the tap first so a stale formula cannot report a false "already up to date"
return { kind: 'managed', command: (0, install_method_1.homebrewUpgradeCommand)(), preflight: (0, install_method_1.homebrewRefreshCommand)() };
case 'npm':
return { kind: 'managed', command: (0, install_method_1.packageManagerUpgradeCommand)((0, install_method_1.detectPackageManager)(probe.scriptPath)) };
case 'binary':
return resolveBinaryAction();
case 'local':
return { kind: 'manual', reason: LOCAL_INSTALL_REASON };
case 'source':
return { kind: 'manual', reason: DEV_BUILD_REASON };
default: {
const exhaustive = method;
throw new Error(`Unhandled install method: ${exhaustive}`);
}
}
}
/**
* Resolves the binary-channel action for the running platform.
*
* @returns {UpgradeAction} A binary action, or a manual action when no standalone
* binary is published for this platform.
*/
function resolveBinaryAction() {
const platform = process.platform;
const artifact = (0, binary_upgrade_1.resolveBinaryArtifact)({
platform,
arch: process.arch,
isMusl: (0, binary_upgrade_1.isMuslLinux)(platform),
});
if (!artifact) {
return { kind: 'manual', reason: 'No self-updatable cpln binary is published for your platform.' };
}
return { kind: 'binary', artifact };
}
/**
* Runs a command with its output streamed to the user's terminal.
*
* @param {RunnableCommand} command - The command to run.
* @returns {SpawnOutcome} The observed result.
*/
function runCommand(command) {
// Windows exposes npm/pnpm/yarn/brew as shims that spawn only resolves via a shell
const result = (0, child_process_1.spawnSync)(command.program, command.args, {
stdio: 'inherit',
shell: process.platform === 'win32',
});
return { status: result.status, signal: result.signal, error: result.error };
}
/**
* Returns the release tag of the running build ({epoch}-{gitsha}).
*
* @returns {string} The installed release tag.
*/
function installedRelease() {
return `${version_1.About.epoch}-${version_1.About.version}`;
}
/**
* Determines whether a published release tag is strictly older than the running
* build, using the orderable epoch prefix both carry.
*
* @param {string} tag - The published release tag.
* @returns {boolean} True when the tag predates the installed build.
*/
function isOlderRelease(tag) {
const tagEpoch = (0, binary_upgrade_1.parseTagEpoch)(tag);
const currentEpoch = Number(version_1.About.epoch);
return tagEpoch !== undefined && Number.isFinite(currentEpoch) && currentEpoch > 0 && tagEpoch < currentEpoch;
}
/**
* Determines whether a published release tag names a different, non-older build.
*
* @param {string} tag - The published release tag.
* @returns {boolean} True when installing the tag would move this build forward.
*/
function isNewerRelease(tag) {
if (version_1.About.version !== 'dev' && tag.endsWith(`-${version_1.About.version}`)) {
return false;
}
return !isOlderRelease(tag);
}
/**
* Builds the user-facing error for a failed binary self-replace, adding a
* permissions hint when the install location is not writable.
*
* @param {unknown} error - The error that aborted the upgrade.
* @param {string} execPath - The path of the installed executable.
* @returns {string} The complete failure message.
*/
function binaryFailureMessage(error, execPath) {
const detail = error instanceof Error ? error.message : 'Unknown error';
const code = error === null || error === void 0 ? void 0 : error.code;
const hint = code === 'EACCES' || code === 'EPERM'
? ` cpln is installed at ${execPath}; re-run with elevated permissions (e.g. sudo) or ensure that directory is writable.`
: '';
return `ERROR: Binary upgrade failed: ${detail}${hint}`;
}
/**
* Builds the human-readable report lines for `--check`.
*
* @param {UpgradeStatus} status - The version picture to report.
* @returns {string[]} The report lines.
*/
function buildCheckReport(status) {
var _a, _b;
const lines = [`Installed version: ${status.currentVersion}`];
// The binary channel is versioned by release tag, not by the npm dist-tag
if (status.method === 'binary') {
lines.push(`Installed release: ${status.currentRelease}`);
lines.push(`Latest release: ${(_a = status.latestRelease) !== null && _a !== void 0 ? _a : 'unknown'}`);
}
else {
lines.push(`Latest version: ${(_b = status.latestVersion) !== null && _b !== void 0 ? _b : 'unknown'}`);
}
lines.push(`Install method: ${status.method}`);
lines.push(buildCheckVerdict(status));
return lines;
}
/**
* Builds the human-readable verdict line for `--check`.
*
* @param {UpgradeStatus} status - The version picture to summarize.
* @returns {string} The verdict sentence.
*/
function buildCheckVerdict(status) {
if (status.method === 'source' || status.currentVersion === 'dev') {
return 'This is a development build; there is nothing to upgrade.';
}
if (status.method === 'local') {
return "cpln is installed as a project dependency. Update the version in the project's package.json.";
}
if (status.method === 'binary') {
return buildBinaryVerdict(status);
}
if (status.latestVersion === undefined) {
return 'Unable to determine the latest version. Check your connection and try again.';
}
if (status.updateAvailable) {
return `A newer version is available: ${status.latestVersion}. Run 'cpln upgrade' to install it.`;
}
return 'You are on the latest version.';
}
/**
* Builds the binary-channel verdict line for `--check`.
*
* @param {UpgradeStatus} status - The version picture to summarize.
* @returns {string} The verdict sentence.
*/
function buildBinaryVerdict(status) {
if (status.latestRelease === undefined) {
return 'Unable to determine the latest release. Check your connection and try again.';
}
if (!status.updateAvailable) {
return 'You are on the latest release.';
}
// Never recommend a command that cannot succeed on this platform
if (status.selfUpdatable === false) {
return `A newer release is available, but no self-updatable cpln binary is published for your platform. See ${DOWNLOAD_DOCS_URL} for installation instructions.`;
}
return `A newer release is available. Run 'cpln upgrade' to install it.`;
}
//# sourceMappingURL=upgrade.js.map