-
Notifications
You must be signed in to change notification settings - Fork 0
P1: Upgrade channels (stable/beta) + pinned version + target version install #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Invalid With this change, any unexpected value in 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 |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error(`Invalid channel '${channel}'. Use 'stable' or 'beta'.`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+41
to
+45
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (options.unpin) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await writeConfig({ pinnedVersion: undefined }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+41
to
+48
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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' }); |
There was a problem hiding this comment.
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.
Copilot
AI
Dec 22, 2025
There was a problem hiding this comment.
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.
| 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
AI
Dec 22, 2025
There was a problem hiding this comment.
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.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||
|
|
||||||
|
|
@@ -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'); | ||||||
|
||||||
| throw new Error('No prerelease found'); | |
| throw new Error('No beta/prerelease versions available on the configured channel'); |
Copilot
AI
Dec 22, 2025
There was a problem hiding this comment.
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
AI
Dec 22, 2025
There was a problem hiding this comment.
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
AI
Dec 22, 2025
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
--forcedescription.For example:
Force update even if the version is the same or oldermakes the sentence grammatically complete while keeping the intended meaning.