diff --git a/.changeset/doctor-cli-release-channel-advisory.md b/.changeset/doctor-cli-release-channel-advisory.md new file mode 100644 index 00000000..37c84035 --- /dev/null +++ b/.changeset/doctor-cli-release-channel-advisory.md @@ -0,0 +1,5 @@ +--- +"@caatinga/cli": minor +--- + +`caatinga doctor` and `caatinga version` now compare the running CLI version against the npm `dist-tags` for `@caatinga/cli` and print an informational note when the install is ahead of the `latest` tag (for example a `next`-tagged pre-release, naming the matching tag) or behind it (with the update command). The check never fails a command or affects doctor readiness: it is skipped silently when npm or the registry is unavailable, times out after five seconds, and honors `CAATINGA_SKIP_UPDATE_CHECK=1`. diff --git a/docs/cli.md b/docs/cli.md index 1d79fe88..676c4ea4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -70,8 +70,26 @@ Strict flags: - `--strict-bindings` fails when bindings are not `fresh` - WASM drift and postDeploy alias advisories are **always advisory** +Release channel: + +- Doctor compares the running CLI version against the published npm `dist-tags` for + `@caatinga/cli` and prints a `⚠` advisory when the running version is **ahead of the + `latest` tag** (for example a `next`-tagged pre-release, naming the tag when it matches) + or **behind it** (with the update command). +- The check is best-effort and **always advisory** — it never blocks readiness. It is + skipped silently when npm or the registry is unavailable, times out after five seconds, + and can be disabled with `CAATINGA_SKIP_UPDATE_CHECK=1`. + Use `--all-networks` for a per-network deploy/bindings matrix. Doctor may also print a version matrix including `soroban-sdk` from each contract's `Cargo.toml`. +## `ctg version` + +Prints the installed CLI version (`@caatinga/cli: `) and runs the same +release-channel advisory as `ctg doctor`: a `⚠` note appears when the running version is +ahead of the npm `latest` dist-tag (for example a `next`-tagged pre-release) or behind it. +The registry lookup is best-effort and skippable with `CAATINGA_SKIP_UPDATE_CHECK=1`. The +plain `-v`/`--version` flag stays offline and prints only the version. + ## `ctg deploy` Flags: `--source` (required), `--network`, `--force`, `--upgrade`, `--if-changed`, `--no-deps`, `--verify-deps`, `--no-stale-check`, `--no-generate`, `--no-wire`, `--no-sync-env`, `--allow-dev-ceremony` diff --git a/packages/cli/src/commands/doctor-cli-version.test.ts b/packages/cli/src/commands/doctor-cli-version.test.ts new file mode 100644 index 00000000..915ebb9b --- /dev/null +++ b/packages/cli/src/commands/doctor-cli-version.test.ts @@ -0,0 +1,236 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { evaluateCliVersionChannel, reportCliVersionChannel } from "./doctor-cli-version.js"; + +const CLI_PACKAGE_NAME = "@caatinga/cli"; + +function npmView(result: { failed: boolean; stdout: string }) { + return vi.fn().mockResolvedValue(result); +} + +describe("reportCliVersionChannel registry lookup", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should_query_npm_for_the_cli_dist_tags_json", async () => { + const runNpmView = npmView({ failed: false, stdout: '{"latest":"3.8.0","next":"3.9.1"}' }); + + const report = await reportCliVersionChannel({ + runningVersion: "3.9.1", + env: {}, + runNpmView, + }); + + expect(runNpmView).toHaveBeenCalledWith( + ["view", CLI_PACKAGE_NAME, "dist-tags", "--json"], + 5000 + ); + expect(report.latestVersion).toBe("3.8.0"); + expect(report.matchingTags).toEqual(["next"]); + }); + + it("should_honor_a_custom_timeout", async () => { + const runNpmView = npmView({ failed: false, stdout: '{"latest":"3.8.0"}' }); + + await reportCliVersionChannel({ + runningVersion: "3.8.0", + env: {}, + runNpmView, + timeoutMs: 1234, + }); + + expect(runNpmView).toHaveBeenCalledWith( + ["view", CLI_PACKAGE_NAME, "dist-tags", "--json"], + 1234 + ); + }); + + it("should_skip_the_check_when_CAATINGA_SKIP_UPDATE_CHECK_is_set", async () => { + const runNpmView = npmView({ failed: false, stdout: '{"latest":"3.8.0"}' }); + + const report = await reportCliVersionChannel({ + runningVersion: "3.9.1", + env: { CAATINGA_SKIP_UPDATE_CHECK: "1" }, + runNpmView, + }); + + expect(runNpmView).not.toHaveBeenCalled(); + expect(report.latestVersion).toBeUndefined(); + expect(report.note).toBeUndefined(); + }); + + it("should_stay_silent_when_npm_fails_or_output_is_unparseable", async () => { + const cases = [ + npmView({ failed: true, stdout: "" }), + npmView({ failed: false, stdout: "not json" }), + npmView({ failed: false, stdout: '"3.8.0"' }), + npmView({ failed: false, stdout: "{}" }), + vi.fn().mockRejectedValue(new Error("spawn npm ENOENT")), + ]; + + for (const runNpmView of cases) { + const report = await reportCliVersionChannel({ + runningVersion: "3.9.1", + env: {}, + runNpmView, + }); + expect(report.latestVersion).toBeUndefined(); + expect(report.note).toBeUndefined(); + } + }); + + it("should_drop_non_string_dist_tag_entries", async () => { + const report = await reportCliVersionChannel({ + runningVersion: "3.8.0", + env: {}, + runNpmView: npmView({ failed: false, stdout: '{"latest":"3.8.0","broken":42}' }), + }); + + expect(report.latestVersion).toBe("3.8.0"); + expect(report.matchingTags).toEqual(["latest"]); + }); + + it("should_print_the_advisory_note_as_a_warning", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const report = await reportCliVersionChannel({ + runningVersion: "3.9.1", + env: {}, + runNpmView: npmView({ failed: false, stdout: '{"latest":"3.8.0","next":"3.9.1"}' }), + }); + + expect(report.aheadOfLatest).toBe(true); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("ahead of the 'latest' npm tag")); + expect(logSpy).toHaveBeenCalled(); + }); + + it("should_print_nothing_when_running_the_latest_version", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const report = await reportCliVersionChannel({ + runningVersion: "3.8.0", + env: {}, + runNpmView: npmView({ failed: false, stdout: '{"latest":"3.8.0"}' }), + }); + + expect(report.note).toBeUndefined(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(logSpy).not.toHaveBeenCalled(); + }); +}); + +describe("evaluateCliVersionChannel", () => { + it("should_flag_a_version_ahead_of_latest_published_under_next", () => { + // The exact scenario from the issue: running 3.9.1 while latest is 3.8.0. + const report = evaluateCliVersionChannel({ + runningVersion: "3.9.1", + distTags: { latest: "3.8.0", next: "3.9.1" }, + }); + + expect(report.aheadOfLatest).toBe(true); + expect(report.behindLatest).toBe(false); + expect(report.matchingTags).toEqual(["next"]); + expect(report.note).toBe( + `Running ${CLI_PACKAGE_NAME} 3.9.1 (published under the 'next' npm tag), which is ahead of the 'latest' npm tag (3.8.0) — this may be a pre-release build.` + ); + }); + + it("should_flag_ahead_versions_without_a_matching_tag_too", () => { + const report = evaluateCliVersionChannel({ + runningVersion: "3.9.1", + distTags: { latest: "3.8.0" }, + }); + + expect(report.note).toBe( + `Running ${CLI_PACKAGE_NAME} 3.9.1, which is ahead of the 'latest' npm tag (3.8.0) — this may be a pre-release build.` + ); + }); + + it("should_stay_silent_when_running_the_latest_tagged_version", () => { + const report = evaluateCliVersionChannel({ + runningVersion: "3.8.0", + distTags: { latest: "3.8.0", next: "3.9.1" }, + }); + + expect(report.aheadOfLatest).toBe(false); + expect(report.behindLatest).toBe(false); + expect(report.matchingTags).toEqual(["latest"]); + expect(report.note).toBeUndefined(); + }); + + it("should_note_when_running_behind_the_latest_tag", () => { + const report = evaluateCliVersionChannel({ + runningVersion: "3.8.0", + distTags: { latest: "3.9.2" }, + }); + + expect(report.behindLatest).toBe(true); + expect(report.note).toBe( + `Running ${CLI_PACKAGE_NAME} 3.8.0, but the 'latest' npm tag is 3.9.2 — update with: npm install -g ${CLI_PACKAGE_NAME}@latest` + ); + }); + + it("should_call_an_ahead_prerelease_suffix_a_definite_prerelease", () => { + const report = evaluateCliVersionChannel({ + runningVersion: "3.9.1-beta.1", + distTags: { latest: "3.9.0" }, + }); + + expect(report.prerelease).toBe(true); + expect(report.aheadOfLatest).toBe(true); + expect(report.note).toContain("this is a pre-release build"); + }); + + it("should_flag_a_prerelease_that_matches_the_latest_tag", () => { + const report = evaluateCliVersionChannel({ + runningVersion: "3.9.1-rc.0", + distTags: { latest: "3.9.1-rc.0" }, + }); + + expect(report.prerelease).toBe(true); + expect(report.note).toBe( + `Running ${CLI_PACKAGE_NAME} 3.9.1-rc.0 is a pre-release build (it matches the 'latest' npm tag).` + ); + }); + + it("should_flag_a_prerelease_suffix_even_without_registry_data", () => { + const report = evaluateCliVersionChannel({ runningVersion: "3.9.1-beta.1" }); + + expect(report.note).toBe(`Running ${CLI_PACKAGE_NAME} 3.9.1-beta.1 is a pre-release build.`); + }); + + it("should_prefer_the_prerelease_signal_over_the_behind_latest_hint", () => { + const report = evaluateCliVersionChannel({ + runningVersion: "0.0.0-dev", + distTags: { latest: "3.8.0" }, + }); + + expect(report.aheadOfLatest).toBe(false); + expect(report.behindLatest).toBe(true); + expect(report.prerelease).toBe(true); + expect(report.note).toBe( + `Running ${CLI_PACKAGE_NAME} 0.0.0-dev is a pre-release build (the 'latest' npm tag is 3.8.0).` + ); + }); + + it("should_stay_silent_without_registry_data_for_stable_versions", () => { + expect(evaluateCliVersionChannel({ runningVersion: "3.9.1" }).note).toBeUndefined(); + expect( + evaluateCliVersionChannel({ runningVersion: "3.9.1", distTags: { next: "3.9.2" } }).note + ).toBeUndefined(); + }); + + it("should_not_crash_on_unparseable_versions", () => { + const report = evaluateCliVersionChannel({ + runningVersion: "dev", + distTags: { latest: "3.8.0" }, + }); + + expect(report.aheadOfLatest).toBe(false); + expect(report.behindLatest).toBe(false); + expect(report.prerelease).toBe(false); + expect(report.note).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/doctor-cli-version.ts b/packages/cli/src/commands/doctor-cli-version.ts new file mode 100644 index 00000000..49b46104 --- /dev/null +++ b/packages/cli/src/commands/doctor-cli-version.ts @@ -0,0 +1,199 @@ +import { execa } from "execa"; +import { logger } from "../utils/logger.js"; +import { compareSemverVersions, hasPrereleaseTag } from "../utils/semver-compare.js"; +import { CAATINGA_CLI_VERSION } from "../version.js"; + +/** + * Release-channel advisory for the CLI itself. + * + * Caatinga publishes pre-releases under the `next` npm dist-tag and promotes stable + * releases to `latest` (see scripts/pre-publish.sh and scripts/promote-latest.sh). + * Nothing in `--version` or `doctor` used to surface which channel the installed CLI + * came from, so users had to run `npm view @caatinga/cli dist-tags` themselves. + * + * These helpers compare the running version against the published dist-tags and emit + * an informational note. The check is best-effort: it never fails a command, silently + * skips when npm or the registry is unavailable, and honors CAATINGA_SKIP_UPDATE_CHECK=1. + */ + +const CLI_PACKAGE_NAME = "@caatinga/cli"; +const CLI_DIST_TAGS_TIMEOUT_MS = 5_000; + +export type CliDistTags = Record; + +export type CliVersionChannelReport = { + runningVersion: string; + latestVersion: string | undefined; + /** Dist-tags whose version equals the running version, for example `["next"]`. */ + matchingTags: string[]; + aheadOfLatest: boolean; + behindLatest: boolean; + prerelease: boolean; + /** Informational advisory; undefined when there is nothing worth surfacing. */ + note: string | undefined; +}; + +function parseCliDistTags(raw: string): CliDistTags | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return undefined; + } + + const tags: CliDistTags = {}; + for (const [tag, version] of Object.entries(parsed as Record)) { + if (typeof version === "string" && version.length > 0) { + tags[tag] = version; + } + } + + return Object.keys(tags).length > 0 ? tags : undefined; +} + +type NpmViewResult = { + failed: boolean; + stdout: string; +}; + +export type ReportCliVersionChannelOptions = { + runningVersion?: string; + timeoutMs?: number; + env?: NodeJS.ProcessEnv; + /** Test seam replacing the `npm view` subprocess. */ + runNpmView?: (args: string[], timeoutMs: number) => Promise; +}; + +async function fetchCliDistTags( + options: ReportCliVersionChannelOptions +): Promise { + const env = options.env ?? process.env; + if (env.CAATINGA_SKIP_UPDATE_CHECK === "1") { + return undefined; + } + + const runNpmView = + options.runNpmView ?? + (async (args: string[], timeoutMs: number): Promise => { + const result = await execa("npm", args, { reject: false, timeout: timeoutMs }); + return { failed: result.failed, stdout: result.stdout }; + }); + + try { + const result = await runNpmView( + ["view", CLI_PACKAGE_NAME, "dist-tags", "--json"], + options.timeoutMs ?? CLI_DIST_TAGS_TIMEOUT_MS + ); + if (result.failed) { + return undefined; + } + return parseCliDistTags(result.stdout); + } catch { + // npm missing, registry unreachable, or timed out: doctor must never fail for this. + return undefined; + } +} + +function formatTagList(tags: string[]): string { + const quoted = tags.map((tag) => `'${tag}'`).join("/"); + return `${quoted} npm ${tags.length === 1 ? "tag" : "tags"}`; +} + +function buildCliVersionNote(input: { + runningVersion: string; + latestVersion: string | undefined; + matchingTags: string[]; + aheadOfLatest: boolean; + behindLatest: boolean; + prerelease: boolean; +}): string | undefined { + const { runningVersion, latestVersion, matchingTags, aheadOfLatest, behindLatest, prerelease } = + input; + + if (aheadOfLatest && latestVersion) { + const publishedUnder = + matchingTags.length > 0 ? ` (published under the ${formatTagList(matchingTags)})` : ""; + const certainty = prerelease + ? "this is a pre-release build" + : "this may be a pre-release build"; + return `Running ${CLI_PACKAGE_NAME} ${runningVersion}${publishedUnder}, which is ahead of the 'latest' npm tag (${latestVersion}) — ${certainty}.`; + } + + if (prerelease) { + // The running version carries a pre-release suffix; that is the dominant + // signal, ahead of any behind-latest wording (for example 0.0.0-dev source + // builds should not be told to upgrade). + if (latestVersion && behindLatest) { + return `Running ${CLI_PACKAGE_NAME} ${runningVersion} is a pre-release build (the 'latest' npm tag is ${latestVersion}).`; + } + if (latestVersion) { + return `Running ${CLI_PACKAGE_NAME} ${runningVersion} is a pre-release build (it matches the 'latest' npm tag).`; + } + return `Running ${CLI_PACKAGE_NAME} ${runningVersion} is a pre-release build.`; + } + + if (behindLatest && latestVersion) { + return `Running ${CLI_PACKAGE_NAME} ${runningVersion}, but the 'latest' npm tag is ${latestVersion} — update with: npm install -g ${CLI_PACKAGE_NAME}@latest`; + } + + return undefined; +} + +export function evaluateCliVersionChannel(input: { + runningVersion: string; + distTags?: CliDistTags; +}): CliVersionChannelReport { + const distTags = input.distTags ?? {}; + const latestVersion = distTags["latest"]; + const matchingTags = Object.entries(distTags) + .filter(([, version]) => version === input.runningVersion) + .map(([tag]) => tag) + .sort(); + + const comparison = latestVersion + ? compareSemverVersions(input.runningVersion, latestVersion) + : undefined; + const aheadOfLatest = comparison === 1; + const behindLatest = comparison === -1; + const prerelease = hasPrereleaseTag(input.runningVersion); + + return { + runningVersion: input.runningVersion, + latestVersion, + matchingTags, + aheadOfLatest, + behindLatest, + prerelease, + note: buildCliVersionNote({ + runningVersion: input.runningVersion, + latestVersion, + matchingTags, + aheadOfLatest, + behindLatest, + prerelease, + }), + }; +} + +/** + * Prints the release-channel advisory (never a failure, never affects readiness). + * Returns the evaluated report so callers and tests can inspect it. + */ +export async function reportCliVersionChannel( + options: ReportCliVersionChannelOptions = {} +): Promise { + const runningVersion = options.runningVersion ?? CAATINGA_CLI_VERSION; + const distTags = await fetchCliDistTags(options); + const report = evaluateCliVersionChannel({ runningVersion, distTags }); + + if (report.note) { + logger.info(""); + logger.warn(report.note); + } + + return report; +} diff --git a/packages/cli/src/commands/doctor.command.test.ts b/packages/cli/src/commands/doctor.command.test.ts index 0cca6a00..090f1684 100644 --- a/packages/cli/src/commands/doctor.command.test.ts +++ b/packages/cli/src/commands/doctor.command.test.ts @@ -6,6 +6,7 @@ import { registerDoctorCommand } from "./doctor.command.js"; const runAllDiagnosticsMock = vi.hoisted(() => vi.fn()); const evaluateDeployCoverageMock = vi.hoisted(() => vi.fn()); const evaluateBindingCoverageMock = vi.hoisted(() => vi.fn()); +const reportCliVersionChannelMock = vi.hoisted(() => vi.fn()); vi.mock("../diagnostics/run-all.js", () => ({ runAllDiagnostics: runAllDiagnosticsMock, @@ -31,6 +32,10 @@ vi.mock("./doctor-post-deploy.js", () => ({ evaluatePostDeployDiagnostics: vi.fn().mockReturnValue([]), })); +vi.mock("./doctor-cli-version.js", () => ({ + reportCliVersionChannel: reportCliVersionChannelMock, +})); + const config: CaatingaConfig = { project: "minimal-app", defaultNetwork: "testnet", @@ -62,6 +67,8 @@ describe("doctor command", () => { runAllDiagnosticsMock.mockReset(); evaluateDeployCoverageMock.mockReset(); evaluateBindingCoverageMock.mockReset(); + reportCliVersionChannelMock.mockReset(); + reportCliVersionChannelMock.mockResolvedValue({ note: undefined }); process.exitCode = undefined; runAllDiagnosticsMock.mockResolvedValue({ @@ -119,4 +126,45 @@ describe("doctor command", () => { expect(evaluateDeployCoverageMock).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); }); + + it("should_report_the_cli_release_channel_without_blocking_readiness", async () => { + evaluateDeployCoverageMock.mockResolvedValue({ complete: true, lines: [] }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await createDoctorProgram().parseAsync(["node", "caatinga", "doctor"]); + + expect(reportCliVersionChannelMock).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + } finally { + warnSpy.mockRestore(); + } + }); + + it("should_keep_doctor_advisory_when_running_a_prerelease_ahead_of_latest", async () => { + evaluateDeployCoverageMock.mockResolvedValue({ complete: true, lines: [] }); + reportCliVersionChannelMock.mockImplementation(async () => { + console.warn( + "Running @caatinga/cli 3.9.1 (published under the 'next' npm tag), which is ahead of the 'latest' npm tag (3.8.0) — this may be a pre-release build." + ); + return { note: "pre-release" }; + }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await createDoctorProgram().parseAsync(["node", "caatinga", "doctor"]); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("ahead of the 'latest' npm tag") + ); + const output = logSpy.mock.calls.map(([chunk]) => String(chunk)).join("\n"); + expect(output).toContain("Status: ready"); + expect(process.exitCode).toBeUndefined(); + } finally { + warnSpy.mockRestore(); + logSpy.mockRestore(); + } + }); }); diff --git a/packages/cli/src/commands/doctor.command.ts b/packages/cli/src/commands/doctor.command.ts index c46ac45a..f22ac442 100644 --- a/packages/cli/src/commands/doctor.command.ts +++ b/packages/cli/src/commands/doctor.command.ts @@ -6,6 +6,7 @@ import { evaluateBindingCoverage, type BindingCoverageLine } from "./doctor-bind import { evaluateEnvSyncDiagnostics } from "./doctor-env-sync.js"; import { evaluatePostDeployDiagnostics } from "./doctor-post-deploy.js"; import { evaluateWasmDriftDiagnostics } from "./doctor-wasm-drift.js"; +import { reportCliVersionChannel } from "./doctor-cli-version.js"; import { runCliAction } from "../utils/errors.js"; import { logger } from "../utils/logger.js"; import { loadConfig, readContractSorobanSdkVersions, WELL_KNOWN_NETWORKS } from "@caatinga/core"; @@ -215,6 +216,10 @@ export function registerDoctorCommand(program: Command): void { const ready = diagnostics.every((diagnostic) => diagnostic.ok); + // Advisory only: never contributes to `blocked`. Surfaces when this install + // is a pre-release (for example published under the `next` dist-tag). + await reportCliVersionChannel(); + let deployNetwork = options.network; if (!deployNetwork && ready && config) { deployNetwork = config.defaultNetwork; diff --git a/packages/cli/src/commands/version.command.test.ts b/packages/cli/src/commands/version.command.test.ts new file mode 100644 index 00000000..abf9255e --- /dev/null +++ b/packages/cli/src/commands/version.command.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Command } from "commander"; +import { registerVersionCommand } from "./version.command.js"; + +const reportCliVersionChannelMock = vi.hoisted(() => vi.fn()); + +vi.mock("./doctor-cli-version.js", () => ({ + reportCliVersionChannel: reportCliVersionChannelMock, +})); + +function createVersionProgram(): Command { + const program = new Command(); + program.exitOverride(); + registerVersionCommand(program); + return program; +} + +describe("version command", () => { + beforeEach(() => { + reportCliVersionChannelMock.mockReset(); + reportCliVersionChannelMock.mockResolvedValue({ note: undefined }); + process.exitCode = undefined; + }); + + it("should_print_the_running_version_and_check_the_release_channel", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await createVersionProgram().parseAsync(["node", "caatinga", "version"]); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("@caatinga/cli: ")); + expect(reportCliVersionChannelMock).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); +}); diff --git a/packages/cli/src/commands/version.command.ts b/packages/cli/src/commands/version.command.ts index 6393afe6..f19806ee 100644 --- a/packages/cli/src/commands/version.command.ts +++ b/packages/cli/src/commands/version.command.ts @@ -2,14 +2,20 @@ import { type Command } from "commander"; import { CAATINGA_CLI_VERSION } from "../version.js"; import { logger } from "../utils/logger.js"; import { runCliAction } from "../utils/errors.js"; +import { reportCliVersionChannel } from "./doctor-cli-version.js"; export function registerVersionCommand(program: Command): void { program .command("version") .description("Show the version of Caatinga CLI") - .action(() => { + // Return the promise so Commander awaits the registry check before the + // process is allowed to exit. + .action(() => runCliAction(async () => { logger.info(`@caatinga/cli: ${CAATINGA_CLI_VERSION}`); - }); - }); + // Advisory only: notes when this install is ahead of the npm `latest` + // dist-tag (for example a `next`-tagged pre-release) or behind it. + await reportCliVersionChannel(); + }) + ); } diff --git a/packages/cli/src/program.test.ts b/packages/cli/src/program.test.ts index b943d632..add4b219 100644 --- a/packages/cli/src/program.test.ts +++ b/packages/cli/src/program.test.ts @@ -7,6 +7,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createProgram } from "./program.js"; import chalk from "chalk"; +// Keep the release-channel advisory (npm dist-tags lookup) out of these tests. +vi.mock("./commands/doctor-cli-version.js", () => ({ + reportCliVersionChannel: vi.fn().mockResolvedValue(undefined), +})); + const __dirname = path.dirname(fileURLToPath(import.meta.url)); describe("createProgram", () => { diff --git a/packages/cli/src/utils/semver-compare.test.ts b/packages/cli/src/utils/semver-compare.test.ts new file mode 100644 index 00000000..c44da27b --- /dev/null +++ b/packages/cli/src/utils/semver-compare.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { compareSemverVersions, hasPrereleaseTag } from "./semver-compare.js"; + +describe("hasPrereleaseTag", () => { + it("should_detect_prerelease_tags", () => { + expect(hasPrereleaseTag("3.9.1-beta.1")).toBe(true); + expect(hasPrereleaseTag("3.9.1-rc.0")).toBe(true); + }); + + it("should_return_false_for_stable_or_invalid_versions", () => { + expect(hasPrereleaseTag("3.9.1")).toBe(false); + expect(hasPrereleaseTag("3.9.1+build.7")).toBe(false); + expect(hasPrereleaseTag("dev")).toBe(false); + expect(hasPrereleaseTag("")).toBe(false); + }); +}); + +describe("compareSemverVersions", () => { + it("should_order_major_minor_patch_numerically", () => { + expect(compareSemverVersions("3.9.1", "3.8.0")).toBe(1); + expect(compareSemverVersions("3.8.0", "3.9.1")).toBe(-1); + expect(compareSemverVersions("3.9.1", "3.9.1")).toBe(0); + expect(compareSemverVersions("10.0.0", "9.9.9")).toBe(1); + }); + + it("should_rank_prerelease_below_the_matching_stable_release", () => { + expect(compareSemverVersions("3.9.1-beta.1", "3.9.1")).toBe(-1); + expect(compareSemverVersions("3.9.1", "3.9.1-beta.1")).toBe(1); + }); + + it("should_follow_the_semver_spec_precedence_chain", () => { + const chain = [ + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + ]; + + for (let index = 0; index < chain.length - 1; index += 1) { + const lower = chain[index]; + const higher = chain[index + 1]; + expect(compareSemverVersions(lower, higher)).toBe(-1); + expect(compareSemverVersions(higher, lower)).toBe(1); + } + }); + + it("should_ignore_build_metadata_when_comparing", () => { + expect(compareSemverVersions("3.9.1+build.1", "3.9.1+build.2")).toBe(0); + }); + + it("should_return_undefined_when_either_side_is_invalid", () => { + expect(compareSemverVersions("dev", "3.9.1")).toBeUndefined(); + expect(compareSemverVersions("3.9.1", "latest")).toBeUndefined(); + expect(compareSemverVersions("3.9", "3.9.1")).toBeUndefined(); + expect(compareSemverVersions("03.9.1", "3.9.1")).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/utils/semver-compare.ts b/packages/cli/src/utils/semver-compare.ts new file mode 100644 index 00000000..dd506df1 --- /dev/null +++ b/packages/cli/src/utils/semver-compare.ts @@ -0,0 +1,88 @@ +/** + * Minimal semver precedence helpers used by the CLI's own release-channel advisory. + * + * The CLI package keeps its runtime dependency surface small, and these checks only + * need ordering between plain semver versions (with optional pre-release tags), so + * this is a small spec-conformant implementation instead of a `semver` dependency. + * Precedence follows https://semver.org/ spec item 11; build metadata is ignored. + */ + +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +type ParsedSemver = { + major: number; + minor: number; + patch: number; + /** Dot-separated pre-release identifiers; empty for stable releases. */ + prerelease: string[]; +}; + +function parseSemverVersion(input: string): ParsedSemver | undefined { + const match = SEMVER_PATTERN.exec(input.trim()); + if (!match) return undefined; + + const [, major, minor, patch, prerelease] = match; + return { + major: Number(major), + minor: Number(minor), + patch: Number(patch), + prerelease: prerelease ? prerelease.split(".") : [], + }; +} + +export function hasPrereleaseTag(input: string): boolean { + const parsed = parseSemverVersion(input); + return parsed !== undefined && parsed.prerelease.length > 0; +} + +function comparePrereleaseIdentifier(a: string, b: string): number { + const aNumeric = /^\d+$/.test(a); + const bNumeric = /^\d+$/.test(b); + + if (aNumeric && bNumeric) { + return Math.sign(Number(a) - Number(b)); + } + + // Numeric identifiers always have lower precedence than alphanumeric identifiers. + if (aNumeric !== bNumeric) { + return aNumeric ? -1 : 1; + } + + if (a === b) return 0; + return a < b ? -1 : 1; +} + +function comparePrereleaseIdentifiers(a: string[], b: string[]): number { + const shared = Math.min(a.length, b.length); + for (let index = 0; index < shared; index += 1) { + const result = comparePrereleaseIdentifier(a[index], b[index]); + if (result !== 0) return result; + } + + // A larger set of pre-release fields has higher precedence when all preceding + // identifiers are equal (1.0.0-alpha < 1.0.0-alpha.1). + return Math.sign(a.length - b.length); +} + +/** + * Compares two semver version strings. Returns -1, 0, or 1, or `undefined` + * when either input is not a valid semver version. + */ +export function compareSemverVersions(a: string, b: string): number | undefined { + const parsedA = parseSemverVersion(a); + const parsedB = parseSemverVersion(b); + if (!parsedA || !parsedB) return undefined; + + for (const field of ["major", "minor", "patch"] as const) { + if (parsedA[field] !== parsedB[field]) { + return parsedA[field] < parsedB[field] ? -1 : 1; + } + } + + // A pre-release version has lower precedence than the associated normal version. + if (parsedA.prerelease.length === 0 && parsedB.prerelease.length > 0) return 1; + if (parsedA.prerelease.length > 0 && parsedB.prerelease.length === 0) return -1; + + return comparePrereleaseIdentifiers(parsedA.prerelease, parsedB.prerelease); +}