1import * as Clock from "effect/Clock";
2import * as Console from "effect/Console";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as HttpClient from "effect/http/HttpClient";
9
10import { AlchemyContext } from "alchemy/AlchemyContext";
11import packageJson from "../../package.json" with { type: "json" };
12import {
13 ANSI_RESET,
14 ansiFg,
15 colorsEnabled,
16 glyphsFor,
17 theme,
18 unicodeEnabled,
19} from "./CliKit/index.ts";
20
21const NPM_DIST_TAGS_URL =
22 "https://registry.npmjs.org/-/package/alchemy/dist-tags";
23
24const CACHE_FILE = "version-check.json";
25const CACHE_TTL_MILLIS = Duration.toMillis(Duration.days(1));
26// npm's dist-tags endpoint is CDN-backed and typically answers in ~100ms;
27// the sync budget only needs to absorb a slow handshake, not a dead network.
28const SYNC_WAIT = Duration.seconds(3);
29
30interface VersionCheckCache {
31 checkedAt: number;
32 /** Absent when the last check attempt failed (offline, timeout, …). */
33 distTags?: Record<string, string>;
34}
35
36const parseVersion = (v: string) => {
37 const [core = "", pre] = v.split("-", 2);
38 return {
39 core: core.split(".").map(Number),
40 pre:
41 pre === undefined
42 ? []
43 : pre.split(".").map((id) => (/^\d+$/.test(id) ? Number(id) : id)),
44 };
45};
46
47/**
48 * Semver precedence (semver.org §11): negative when a < b, positive when
49 * a > b. Enough of the spec for registry versions — numeric core, dotted
50 * prerelease identifiers, release > prerelease.
51 */
52const compareVersions = (a: string, b: string): number => {
53 const pa = parseVersion(a);
54 const pb = parseVersion(b);
55 for (let i = 0; i < 3; i++) {
56 const d = (pa.core[i] ?? 0) - (pb.core[i] ?? 0);
57 if (d !== 0) return d;
58 }
59 // A release outranks any prerelease of the same core.
60 if (pa.pre.length === 0 || pb.pre.length === 0) {
61 return pb.pre.length - pa.pre.length;
62 }
63 for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
64 const x = pa.pre[i];
65 const y = pb.pre[i];
66 if (x === undefined) return -1;
67 if (y === undefined) return 1;
68 if (x === y) continue;
69 if (typeof x === "number" && typeof y === "number") return x - y;
70 // Numeric identifiers rank below alphanumeric ones.
71 if (typeof x === "number") return -1;
72 if (typeof y === "number") return 1;
73 return x < y ? -1 : 1;
74 }
75 return 0;
76};
77
78/**
79 * Pick the dist-tag matching the current channel. For pre-release versions
80 * like `2.0.0-beta.33`, we match the prerelease identifier (`beta`, `next`,
81 * etc.), falling back through `next` → `latest`. Because our releases can
82 * force prereleases onto `latest`, it may run ahead of the channel tag —
83 * in that case offer `latest` instead of the channel pick.
84 */
85const pickDistTag = (
86 current: string,
87 distTags: Record<string, string>,
88): string | undefined => {
89 const pre = current.split("-", 2)[1];
90 if (!pre) return distTags.latest;
91 const id = pre.split(".")[0];
92 const channelPick = (id && distTags[id]) || distTags.next;
93 const { latest } = distTags;
94 if (channelPick === undefined) return latest;
95 if (latest === undefined) return channelPick;
96 return compareVersions(latest, channelPick) > 0 ? latest : channelPick;
97};
98
99const readCache = Effect.fn(
100 function* (cachePath: string) {
101 const fs = yield* FileSystem.FileSystem;
102 const raw = yield* fs.readFileString(cachePath);
103 const parsed = yield* Effect.try(
104 () => JSON.parse(raw) as VersionCheckCache | null | undefined,
105 );
106 return typeof parsed?.checkedAt === "number" ? parsed : undefined;
107 },
108 Effect.catch(() => Effect.succeed(undefined)),
109);
110
111const writeCache = Effect.fn(
112 function* (cachePath: string, distTags?: Record<string, string>) {
113 const fs = yield* FileSystem.FileSystem;
114 const checkedAt = yield* Clock.currentTimeMillis;
115 const cache: VersionCheckCache = { checkedAt, distTags };
116 yield* fs.writeFileString(cachePath, JSON.stringify(cache));
117 },
118 Effect.catch(() => Effect.void),
119);
120
121const fetchDistTags = Effect.gen(function* () {
122 const http = yield* HttpClient.HttpClient;
123 const response = yield* http.get(NPM_DIST_TAGS_URL);
124 const distTags = (yield* response.json) as Record<string, string> | null;
125 if (typeof distTags !== "object" || distTags === null) {
126 return yield* Effect.fail(new Error("malformed dist-tags response"));
127 }
128 return distTags;
129});
130
131/**
132 * Refresh the dist-tags cache with a single request. The fetch is forked so
133 * it outlives the sync wait: we give it {@link SYNC_WAIT} to land inline,
134 * and if it's still in flight the same request keeps going in the
135 * background, writing the cache when it completes. The background path
136 * never logs, so it can't interleave with prompts.
137 */
138const refreshDistTags = Effect.fn(function* (cachePath: string) {
139 const fetch = yield* fetchDistTags.pipe(
140 // The fiber owns all cache writes, whenever it completes — even after
141 // the sync wait below has given up on it. A failed attempt is cached
142 // too (a bare checkedAt), so a dead network costs at most one blocking
143 // wait per TTL window, not one per run.
144 Effect.tap((distTags) => writeCache(cachePath, distTags)),
145 Effect.tapError(() => writeCache(cachePath)),
146 Effect.forkScoped,
147 );
148 return yield* Fiber.join(fetch).pipe(
149 Effect.timeout(SYNC_WAIT),
150 Effect.catch(() => Effect.succeed(undefined)),
151 );
152});
153
154/**
155 * Warn if a newer `alchemy` version is published on the dist-tag matching
156 * the current channel. Runs to completion before any interactive prompts so
157 * the warning never interleaves with prompt rendering, and is bounded so it
158 * can never stall the CLI: dist-tags are cached in
159 * `.alchemy/version-check.json` for a day (failed attempts included), so at
160 * most one run per day waits on the network, and only up to
161 * {@link SYNC_WAIT}. Best-effort: every failure is swallowed silently.
162 */
163export const checkLatestVersion = Effect.gen(function* () {
164 const path = yield* Path.Path;
165 const { dotAlchemy } = yield* AlchemyContext;
166 const cachePath = path.join(dotAlchemy, CACHE_FILE);
167 const now = yield* Clock.currentTimeMillis;
168
169 const cached = yield* readCache(cachePath);
170 const distTags =
171 cached !== undefined && now - cached.checkedAt <= CACHE_TTL_MILLIS
172 ? cached.distTags
173 : yield* refreshDistTags(cachePath);
174 if (distTags === undefined) return;
175
176 const current = packageJson.version;
177 const latest = pickDistTag(current, distTags);
178 // Strictly newer only: a dist-tag pointing at (or behind) the installed
179 // version — e.g. a stale `next` after a force-latest release — must not
180 // prompt a "downgrade".
181 if (latest === undefined || compareVersions(latest, current) <= 0) return;
182
183 const installCmd =
184 typeof process !== "undefined" && process.versions.bun !== undefined
185 ? `bun add alchemy@${latest}`
186 : `pnpm add alchemy@${latest}`;
187 // Print via the Console service, not Effect.logWarning: this is a
188 // user-facing notice, not a log record, so it should reach the terminal
189 // regardless of the installed loggers or the configured log floor.
190 const useColor = colorsEnabled();
191 const glyphs = glyphsFor(unicodeEnabled());
192 const message =
193 `alchemy ${latest} is available (you're on ${current}). ` +
194 `Run \`${installCmd}\` to upgrade.`;
195 yield* Console.warn(
196 useColor
197 ? `${ansiFg(theme.color.warning)}${glyphs.warning} ${message}${ANSI_RESET}`
198 : message,
199 );
200}).pipe(Effect.catch(() => Effect.void));
201
202// Exported for tests.
203export const _internal = { pickDistTag, compareVersions };