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
26 changes: 15 additions & 11 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,17 +317,21 @@ Usage: cloudsqlctl upgrade [options]
Upgrade cloudsqlctl to the latest version

Options:
--check-only Only check for updates, do not download or install
--no-install Download only, do not install
--asset <mode> Asset type to download (auto, installer, exe) (default:
"auto")
--dir <path> Download directory (default:
"<USER_HOME>\\AppData\\Local\\CloudSQLCTL\\downloads\\updates")
--force Force update even if version is same or older
--no-silent Run installer in interactive mode (installer only)
--no-elevate Do not attempt to elevate privileges (installer only)
--json Output status in JSON format
-h, --help display help for command
--check-only Only check for updates, do not download or install
--no-install Download only, do not install
--asset <mode> Asset type to download (auto, installer, exe) (default:
"auto")
--dir <path> Download directory (default:
"<USER_HOME>\\AppData\\Local\\CloudSQLCTL\\downloads\\updates")
--force Force update even if version is same or older
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (typo): Consider adding articles for smoother grammar in the --force description.

For example: Force update even if the version is the same or older makes the sentence grammatically complete while keeping the intended meaning.

Suggested change
--force Force update even if version is same or older
--force Force update even if the version is the same or older

--no-silent Run installer in interactive mode (installer only)
--no-elevate Do not attempt to elevate privileges (installer only)
--channel <channel> Update channel (stable or beta)
--version <version> Install a specific version (e.g. 0.4.14 or v0.4.14)
--pin <version> Pin to a specific version for future upgrades
--unpin Clear pinned version
--json Output status in JSON format
-h, --help display help for command
```

### support
Expand Down
27 changes: 25 additions & 2 deletions src/commands/upgrade.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Command } from 'commander';
import path from 'path';
import { logger } from '../core/logger.js';
import { readConfig, writeConfig } from '../core/config.js';
import { USER_PATHS } from '../system/paths.js';
import {
checkForUpdates,
Expand Down Expand Up @@ -28,16 +29,38 @@ export const upgradeCommand = new Command('upgrade')
.option('--force', 'Force update even if version is same or older')
.option('--no-silent', 'Run installer in interactive mode (installer only)')
.option('--no-elevate', 'Do not attempt to elevate privileges (installer only)')
.option('--channel <channel>', 'Update channel (stable or beta)')
.option('--version <version>', 'Install a specific version (e.g. 0.4.14 or v0.4.14)')
.option('--pin <version>', 'Pin to a specific version for future upgrades')
.option('--unpin', 'Clear pinned version')
.option('--json', 'Output status in JSON format')
.action(async (options) => {
try {
const currentVersion = process.env.CLOUDSQLCTL_VERSION || '0.0.0';
const config = await readConfig();
const channel = (options.channel || config.updateChannel || 'stable') as 'stable' | 'beta';

if (channel !== 'stable' && channel !== 'beta') {
Comment on lines +41 to +43
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Invalid updateChannel values in existing config cause the command to error instead of gracefully defaulting.

With this change, any unexpected value in config.updateChannel will cause every upgrade run to fail until the config is manually fixed, making the command fragile to older configs or corruption. Consider treating invalid values as a warning and defaulting to 'stable' (and optionally persisting that change), e.g. compute a rawChannel, validate it, and on invalid values log a warning and fall back to 'stable' instead of throwing.

Suggested implementation:

        try {
            const currentVersion = process.env.CLOUDSQLCTL_VERSION || '0.0.0';
            const config = await readConfig();

            // Determine raw channel from CLI options or config without defaulting yet
            const rawChannel = options.channel ?? config.updateChannel;
            let channel: 'stable' | 'beta' = 'stable';

            if (rawChannel === 'stable' || rawChannel === 'beta') {
                channel = rawChannel;
            } else if (rawChannel !== undefined) {
                // Gracefully handle invalid values by warning and falling back to 'stable'
                console.warn(
                    `Warning: invalid update channel '${rawChannel}' in configuration. Falling back to 'stable'.`,
                );
            }

            if (options.unpin) {

If your project uses a structured logger instead of console.warn, replace the console.warn call with the appropriate logging utility (e.g. logger.warn(...)) and ensure any required imports are added at the top of src/commands/upgrade.ts.

throw new Error(`Invalid channel '${channel}'. Use 'stable' or 'beta'.`);
}
Comment on lines +41 to +45
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The channel validation occurs after reading the config and before storing it, but the validation only checks the final resolved channel value. If config.updateChannel contains an invalid value (e.g., from manual config file editing), it would pass through without error until it's used. Consider moving this validation earlier or adding validation when reading from config to provide better error messages about corrupted configuration.

Copilot uses AI. Check for mistakes.

if (options.unpin) {
await writeConfig({ pinnedVersion: undefined });
Comment on lines +41 to +48
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When using --unpin, the pinnedVersion is cleared but the updateChannel persists from the previous configuration. This could lead to unexpected behavior where a user unpins but remains on the beta channel if they previously set it. Consider clarifying the behavior: should --unpin also reset the channel to stable, or is keeping the channel intentional? If intentional, document this behavior clearly.

Suggested change
const channel = (options.channel || config.updateChannel || 'stable') as 'stable' | 'beta';
if (channel !== 'stable' && channel !== 'beta') {
throw new Error(`Invalid channel '${channel}'. Use 'stable' or 'beta'.`);
}
if (options.unpin) {
await writeConfig({ pinnedVersion: undefined });
const channel = (options.channel || (options.unpin ? 'stable' : config.updateChannel) || 'stable') as 'stable' | 'beta';
if (channel !== 'stable' && channel !== 'beta') {
throw new Error(`Invalid channel '${channel}'. Use 'stable' or 'beta'.`);
}
if (options.unpin) {
await writeConfig({ pinnedVersion: undefined, updateChannel: 'stable' });

Copilot uses AI. Check for mistakes.
}

if (options.pin) {
await writeConfig({ pinnedVersion: options.pin, updateChannel: channel });
} else if (options.channel) {
Comment on lines +47 to +53
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Multiple sequential writeConfig calls could be consolidated into a single update for consistency and to avoid potential race conditions.

In the current flow, a single invocation (e.g. --unpin + --channel) can trigger two writeConfig calls. Consider accumulating the changes in a configUpdate object and calling writeConfig once. This keeps the behavior easier to reason about and reduces the risk of subtle race issues if writeConfig becomes more complex or asynchronous in the future.

await writeConfig({ updateChannel: channel });
}
Comment on lines +47 to +55
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sequence of config writes could be problematic. If options.unpin is true followed by options.pin being set (though this seems unlikely in practice), line 48 would clear the pinnedVersion, then line 52 would immediately set it again. While this works, it results in unnecessary I/O. Consider restructuring the logic to have mutually exclusive branches or combining the config writes into a single operation.

Suggested change
if (options.unpin) {
await writeConfig({ pinnedVersion: undefined });
}
if (options.pin) {
await writeConfig({ pinnedVersion: options.pin, updateChannel: channel });
} else if (options.channel) {
await writeConfig({ updateChannel: channel });
}
const configUpdate: { pinnedVersion?: string | undefined; updateChannel?: 'stable' | 'beta' } = {};
if (options.pin) {
// Pin to a specific version and ensure the channel is updated accordingly
configUpdate.pinnedVersion = options.pin;
configUpdate.updateChannel = channel;
} else {
if (options.unpin) {
// Clear any existing pinned version
configUpdate.pinnedVersion = undefined;
}
if (options.channel) {
// Update only the channel when not pinning
configUpdate.updateChannel = channel;
}
}
if (Object.keys(configUpdate).length > 0) {
await writeConfig(configUpdate);
}

Copilot uses AI. Check for mistakes.
const targetVersion = options.version || options.pin || (!options.unpin ? config.pinnedVersion : undefined);
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for resolving the targetVersion on line 56 has a potential issue. When both options.pin and options.version are set, options.version will be ignored because options.pin takes precedence in the OR chain. This could be confusing to users. Consider validating that only one of these mutually exclusive options is set, or document the precedence order clearly in the command help.

Copilot uses AI. Check for mistakes.

if (!options.json) {
logger.info(`Checking for updates (Current: v${currentVersion})...`);
const suffix = targetVersion ? ` (target: ${targetVersion})` : '';
logger.info(`Checking for updates (Current: v${currentVersion}, channel: ${channel})${suffix}...`);
}

const status = await checkForUpdates(currentVersion);
const status = await checkForUpdates(currentVersion, { channel, targetVersion });

if (options.json) {
console.log(JSON.stringify(status, null, 2));
Expand Down
2 changes: 2 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface AppConfig {
lastUpdateCheck?: string;
lastUpdateAvailableVersion?: string;
gcloudPath?: string;
updateChannel?: 'stable' | 'beta';
pinnedVersion?: string;
}

export async function ensureDirs() {
Expand Down
51 changes: 49 additions & 2 deletions src/core/selfUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { writeConfig } from './config.js';
const REPO_OWNER = 'Kinin-Code-Offical';
const REPO_NAME = 'cloudsqlctl';
const GITHUB_API_URL = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`;
const GITHUB_RELEASES_URL = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases`;
const GITHUB_RELEASE_TAG_URL = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags`;
const TIMEOUT_MS = 60000;
const MAX_RETRIES = 2;

Expand Down Expand Up @@ -42,8 +44,53 @@ export async function getLatestRelease(): Promise<ReleaseInfo> {
}
}

export async function checkForUpdates(currentVersion: string): Promise<UpdateStatus> {
const release = await getLatestRelease();
function normalizeTag(tag: string): string {
return tag.startsWith('v') ? tag : `v${tag}`;
}

export async function getReleaseByTag(tag: string): Promise<ReleaseInfo> {
try {
const response = await axios.get(`${GITHUB_RELEASE_TAG_URL}/${normalizeTag(tag)}`, {
timeout: TIMEOUT_MS,
headers: { 'User-Agent': 'cloudsqlctl/upgrade' }
});
return response.data;
} catch (error) {
logger.error('Failed to fetch release by tag', error);
throw error;
}
}

export async function getLatestPrerelease(): Promise<ReleaseInfo> {
try {
const response = await axios.get(GITHUB_RELEASES_URL, {
timeout: TIMEOUT_MS,
headers: { 'User-Agent': 'cloudsqlctl/upgrade' }
});
const releases = Array.isArray(response.data) ? response.data : [];
const prerelease = releases.find((r: { prerelease?: boolean; draft?: boolean }) => r.prerelease && !r.draft);
if (!prerelease) {
throw new Error('No prerelease found');
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message "No prerelease found" could be more helpful by clarifying that this means there are no beta releases available on the configured channel. Consider changing it to something like "No beta/prerelease versions available" to be clearer to users.

Suggested change
throw new Error('No prerelease found');
throw new Error('No beta/prerelease versions available on the configured channel');

Copilot uses AI. Check for mistakes.
}
return prerelease;
} catch (error) {
logger.error('Failed to fetch latest prerelease info', error);
throw error;
}
}
Comment on lines +64 to +80
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getLatestPrerelease function fetches all releases from the API without pagination parameters. If the repository has many releases, this could return a large payload and potentially miss the latest prerelease if it's not in the first page of results (GitHub API typically returns 30 items per page by default). Consider adding pagination support or at least a per_page parameter to ensure you get enough releases, or document this limitation.

Copilot uses AI. Check for mistakes.

export async function checkForUpdates(
currentVersion: string,
options: { channel?: 'stable' | 'beta'; targetVersion?: string } = {}
): Promise<UpdateStatus> {
Comment on lines +82 to +85
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When targetVersion is specified, the function doesn't validate whether the version comparison makes sense. For example, if a user specifies a targetVersion that is older than the currentVersion and doesn't use --force, the function will still report updateAvailable: false based on semver comparison on line 98, but this might be confusing. Consider adding a separate flag in the UpdateStatus to indicate when a specific version was requested vs. when checking for latest, or validating that targetVersion is actually a valid semver format before making the API call.

Copilot uses AI. Check for mistakes.
let release: ReleaseInfo;
if (options.targetVersion) {
release = await getReleaseByTag(options.targetVersion);
} else if (options.channel === 'beta') {
release = await getLatestPrerelease();
} else {
release = await getLatestRelease();
}
Comment on lines +51 to +93
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new functions getReleaseByTag, getLatestPrerelease, and the updated checkForUpdates function with channel and targetVersion parameters lack test coverage. The existing test file only covers the basic checkForUpdates function without these new options. Consider adding tests for the beta channel, specific version targeting, and error scenarios such as when a release tag doesn't exist or when no prereleases are available.

Copilot uses AI. Check for mistakes.
// Remove 'v' prefix if present for semver comparison
const latestVer = release.tag_name.replace(/^v/, '');
const currentVer = currentVersion.replace(/^v/, '');
Expand Down