diff --git a/packages/extension/esbuild.config.mjs b/packages/extension/esbuild.config.mjs index cc8ac6307..1cd42b19b 100644 --- a/packages/extension/esbuild.config.mjs +++ b/packages/extension/esbuild.config.mjs @@ -63,16 +63,27 @@ if (declaredBins.some((name) => existsSync(`${arRoot}/dist/${name}.js`))) { // #2001: the What's-new page's release cards (docs/releases/.md) ride // the VSIX in dist/releases, so GET /amicode/releases works offline (and on a // hub runner, from the same extension tree). dist/ ships (not in .vscodeignore). +// #2017: ALSO beside the standalone runner (bin/dist/releases) — the hub runs +// bin/dist/amicode-service-runner.mjs on its own, and resolveReleasesDir() +// checks /releases first, so a runner deploy carries its cards with it. const releasesSrc = "../../docs/releases"; if (existsSync(releasesSrc)) { - mkdirSync("dist/releases", { recursive: true }); - for (const name of readdirSync(releasesSrc)) { - if (name.endsWith(".md")) cpSync(`${releasesSrc}/${name}`, `dist/releases/${name}`); + for (const out of ["dist/releases", "bin/dist/releases"]) { + mkdirSync(out, { recursive: true }); + for (const name of readdirSync(releasesSrc)) { + if (name.endsWith(".md")) cpSync(`${releasesSrc}/${name}`, `${out}/${name}`); + } } } else { console.warn("[esbuild] docs/releases missing — the What's-new page will have no release cards"); } +// #2017: bake this build's version into the node bundles — the hub runner has +// no package.json near it, and a 0.0.0 fallback filtered every release card. +// Read via a typeof guard (src/build_defines.d.ts) so vitest without it works. +const ownVersion = JSON.parse(readFileSync("package.json", "utf8")).version; +const versionDefine = { __AMICODE_VERSION__: JSON.stringify(ownVersion) }; + const targets = [ // extension host entry point { @@ -83,6 +94,7 @@ const targets = [ format: "cjs", outfile: "dist/extension.js", external: ["vscode"], + define: versionDefine, sourcemap: true, minify: false, logLevel: "info", @@ -139,6 +151,7 @@ const targets = [ target: "node20", format: "esm", outfile: "bin/dist/amicode-service-runner.mjs", + define: versionDefine, sourcemap: true, minify: false, logLevel: "info", diff --git a/packages/extension/src/amicode_service/releases.ts b/packages/extension/src/amicode_service/releases.ts index 3b60ca7bf..d1b70b903 100644 --- a/packages/extension/src/amicode_service/releases.ts +++ b/packages/extension/src/amicode_service/releases.ts @@ -213,15 +213,16 @@ function moduleDir(): string { return typeof __dirname === "string" ? __dirname : process.cwd(); } -/** The bundled releases dir: $AMICODE_RELEASES_DIR, else the build's - * dist/releases (from dist/ or bin/dist/), else the repo's docs/releases - * (a dev checkout). undefined → no cards (the route still answers). */ -export function resolveReleasesDir(env: NodeJS.ProcessEnv = process.env): string | undefined { +/** The bundled releases dir: $AMICODE_RELEASES_DIR, else /releases + * (dist/releases beside the extension bundle, or bin/dist/releases beside the + * runner bundle — #2017: a hub deploy copies the runner + its releases/ only), + * else dist/releases from bin/dist, else the repo's docs/releases (a dev + * checkout). undefined → no cards (the route still answers). */ +export function resolveReleasesDir(env: NodeJS.ProcessEnv = process.env, here: string = moduleDir()): string | undefined { const override = env.AMICODE_RELEASES_DIR; if (typeof override === "string" && override.trim() !== "") return override; - const here = moduleDir(); const candidates = [ - path.resolve(here, "releases"), // dist/ → dist/releases + path.resolve(here, "releases"), // dist/ → dist/releases; bin/dist/ → bin/dist/releases path.resolve(here, "..", "..", "dist", "releases"), // bin/dist/ → dist/releases path.resolve(here, "..", "..", "..", "docs", "releases"), // dist/ in a checkout path.resolve(here, "..", "..", "..", "..", "docs", "releases"), // src/amicode_service or bin/dist in a checkout @@ -229,15 +230,42 @@ export function resolveReleasesDir(env: NodeJS.ProcessEnv = process.env): string return candidates.find((c) => existsSync(c)); } -let cachedVersion: string | undefined; -/** The extension's own version (its package.json, name "amicode"). */ -export function resolveOwnVersion(): string { - if (cachedVersion) return cachedVersion; - const here = moduleDir(); +/** The build-time version esbuild bakes into BOTH the extension and the runner + * bundle (#2017). Guarded so vitest (no define) sees undefined, not a + * ReferenceError. */ +function definedVersion(): string | undefined { + return typeof __AMICODE_VERSION__ === "string" ? __AMICODE_VERSION__ : undefined; +} + +export interface ResolveOwnVersionOpts { + env?: NodeJS.ProcessEnv; + /** The bundle's dir (the package.json walk starts here). Default: moduleDir(). */ + here?: string; + /** The build-time define. Default: __AMICODE_VERSION__ when defined. */ + defined?: string | undefined; +} + +const cachedByDir = new Map(); +/** The extension's own version: $AMICODE_VERSION, else the build-time define, + * else its package.json (name "amicode") near the bundle, else "0.0.0". + * (#2017: the hub's standalone runner has no package.json near it — the + * define is what keeps it from answering 0.0.0 and filtering every card.) */ +export function resolveOwnVersion(opts: ResolveOwnVersionOpts = {}): string { + const env = opts.env ?? process.env; + const fromEnv = env.AMICODE_VERSION?.trim(); + if (fromEnv && isVersion(fromEnv)) return fromEnv.replace(/^v/, ""); + const defined = "defined" in opts ? opts.defined : definedVersion(); + if (defined && isVersion(defined)) return defined.replace(/^v/, ""); + const here = opts.here ?? moduleDir(); + const cached = cachedByDir.get(here); + if (cached) return cached; for (const rel of [[".."], ["..", ".."], ["..", "..", ".."]]) { try { const pkg = JSON.parse(readFileSync(path.resolve(here, ...rel, "package.json"), "utf8")) as { name?: unknown; version?: unknown }; - if (pkg.name === "amicode" && isVersion(pkg.version)) return (cachedVersion = pkg.version); + if (pkg.name === "amicode" && isVersion(pkg.version)) { + cachedByDir.set(here, pkg.version); + return pkg.version; + } } catch { /* next candidate */ } diff --git a/packages/extension/src/build_defines.d.ts b/packages/extension/src/build_defines.d.ts new file mode 100644 index 000000000..b8700b006 --- /dev/null +++ b/packages/extension/src/build_defines.d.ts @@ -0,0 +1,6 @@ +// Build-time constants injected by esbuild.config.mjs `define` (#2017). +// Absent under vitest/tsc-only runs — always read through a `typeof` guard. + +/** packages/extension/package.json `version`, baked into the extension and + * the standalone service-runner bundles. */ +declare const __AMICODE_VERSION__: string | undefined; diff --git a/packages/extension/test/amicode_service_releases.test.ts b/packages/extension/test/amicode_service_releases.test.ts index 96ba0a70b..17c8e8e6d 100644 --- a/packages/extension/test/amicode_service_releases.test.ts +++ b/packages/extension/test/amicode_service_releases.test.ts @@ -14,6 +14,8 @@ import { parseReleaseFile, releaseBaselineMs, releasesResponse, + resolveOwnVersion, + resolveReleasesDir, selectReleaseCards, type ReleaseCard, } from "../src/amicode_service/releases"; @@ -227,6 +229,27 @@ describe("GET /amicode/releases", () => { } }); + it("#2017: the version resolves from AMICODE_VERSION, then the build-time define — never 0.0.0 when no package.json is near", () => { + const empty = mkdtempSync(join(tmpdir(), "amc-2017-empty-")); + // env override wins over everything + expect(resolveOwnVersion({ here: empty, env: { AMICODE_VERSION: "0.4.0-lane.7" }, defined: "0.4.0-lane.1" })).toBe("0.4.0-lane.7"); + // a malformed env value is ignored → the define + expect(resolveOwnVersion({ here: empty, env: { AMICODE_VERSION: "banana" }, defined: "0.4.0-lane.1" })).toBe("0.4.0-lane.1"); + // no env → the define (the hub runner bundle: no package.json near it) + expect(resolveOwnVersion({ here: empty, env: {}, defined: "0.4.0-lane.1" })).toBe("0.4.0-lane.1"); + // no env, no define (plain vitest) → the package.json lookup still works + const pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf8")) as { version: string }; + expect(resolveOwnVersion({ here: resolve(__dirname, "../src/amicode_service"), env: {}, defined: undefined })).toBe(pkg.version); + }); + + it("#2017: the releases dir is found at /releases (the runner bundle's bin/dist/releases)", () => { + const here = mkdtempSync(join(tmpdir(), "amc-2017-bin-dist-")); + mkdirSync(join(here, "releases")); + writeFileSync(join(here, "releases", "0.4.0.md"), md("0.4.0", "2026-10-29")); + expect(resolveReleasesDir({}, here)).toBe(resolve(here, "releases")); + expect(resolveReleasesDir({ AMICODE_RELEASES_DIR: "/x/y" }, here)).toBe("/x/y"); + }); + it("the default version is the extension's own package.json version", async () => { const pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf8")) as { version: string }; const body = JSON.parse(await releasesResponse({ since: pkg.version }, { dir: releasesDir(files) }));