Skip to content

Commit 02ba250

Browse files
committed
fix: source app version from cli package
1 parent 0d6b849 commit 02ba250

13 files changed

Lines changed: 210 additions & 26 deletions

File tree

packages/cli/src/cli.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { existsSync, readFileSync } from "fs";
1+
import { existsSync } from "fs";
22
import { dirname, join } from "path";
33
import { fileURLToPath } from "url";
44
import { clearAuthBlockByIp, listAuthBlocks } from "./auth-control.js";
55
import { openBrowser } from "./browser.js";
66
import { type CliConfig, readCliConfig, writeCliConfig } from "./config-store.js";
77
import { readLogExcerpt } from "./log-excerpt.js";
88
import { assertSupportedNodeVersion } from "./node-version.js";
9+
import { getCliVersion } from "./package-manifest.js";
910
import { parseArgs } from "./parse-args.js";
1011
import { startManagedServer } from "./pm2-control.js";
1112
import { confirmYesNo, isInteractiveSession } from "./prompts.js";
@@ -133,16 +134,7 @@ EXAMPLES:
133134
}
134135

135136
function showVersion(): void {
136-
const manifestPath = [
137-
new URL("../package.json", import.meta.url),
138-
new URL("../../package.json", import.meta.url),
139-
].find((candidate) => existsSync(candidate));
140-
if (!manifestPath) {
141-
throw new Error("Unable to locate CLI package.json");
142-
}
143-
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { version?: string };
144-
const version = manifest.version ?? "0.0.0";
145-
console.log(`@spencer-kit/coder-studio v${version}`);
137+
console.log(`@spencer-kit/coder-studio v${getCliVersion(import.meta.url)}`);
146138
}
147139

148140
function formatAuthBlocks(blocks: Awaited<ReturnType<typeof listAuthBlocks>>): string {

packages/cli/src/package-manifest.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { readFileSync } from "fs";
2+
import { fileURLToPath } from "url";
23
import { describe, expect, it } from "vitest";
4+
import { getCliVersion, resolveCliPackageManifestUrl } from "./package-manifest.js";
35

46
interface PackageManifest {
57
dependencies?: Record<string, string>;
@@ -12,6 +14,18 @@ function readPackageManifest(relativePath: string): PackageManifest {
1214
}
1315

1416
describe("cli package manifest", () => {
17+
it("resolves the CLI package manifest instead of the workspace root manifest", () => {
18+
expect(fileURLToPath(resolveCliPackageManifestUrl(import.meta.url))).toBe(
19+
fileURLToPath(new URL("../package.json", import.meta.url))
20+
);
21+
});
22+
23+
it("reads the published CLI version from the CLI package manifest", () => {
24+
const cliPackage = readPackageManifest("../package.json") as { version?: string };
25+
26+
expect(getCliVersion(import.meta.url)).toBe(cliPackage.version);
27+
});
28+
1529
it("declares every external server runtime dependency", () => {
1630
const cliPackage = readPackageManifest("../package.json");
1731
const serverPackage = readPackageManifest("../../server/package.json");
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { existsSync, readFileSync } from "fs";
2+
3+
interface CliPackageManifest {
4+
version?: string;
5+
}
6+
7+
export function resolveCliPackageManifestUrl(importMetaUrl: string): URL {
8+
const manifestUrl = [
9+
new URL("../package.json", importMetaUrl),
10+
new URL("../../package.json", importMetaUrl),
11+
].find((candidate) => existsSync(candidate));
12+
13+
if (!manifestUrl) {
14+
throw new Error("Unable to locate CLI package.json");
15+
}
16+
17+
return manifestUrl;
18+
}
19+
20+
export function getCliPackageManifest(importMetaUrl: string): CliPackageManifest {
21+
return JSON.parse(
22+
readFileSync(resolveCliPackageManifestUrl(importMetaUrl), "utf-8")
23+
) as CliPackageManifest;
24+
}
25+
26+
export function getCliVersion(importMetaUrl: string): string {
27+
return getCliPackageManifest(importMetaUrl).version ?? "0.0.0";
28+
}

packages/cli/src/server-runner.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { fileURLToPath } from "url";
22
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { getCliVersion } from "./package-manifest.js";
34

45
const { createServer, readCliConfig, hasWebAssets, getStaticAssetsDir } = vi.hoisted(() => ({
56
createServer: vi.fn(),
@@ -29,6 +30,17 @@ describe("server-runner", () => {
2930
vi.clearAllMocks();
3031
});
3132

33+
it("includes the CLI package version in the server config", () => {
34+
readCliConfig.mockReturnValue(null);
35+
hasWebAssets.mockReturnValue(true);
36+
getStaticAssetsDir.mockReturnValue("/tmp/web");
37+
38+
expect(buildServerConfig()).toMatchObject({
39+
appVersion: getCliVersion(import.meta.url),
40+
webRoot: "/tmp/web",
41+
});
42+
});
43+
3244
it("ignores ephemeral port zero from saved cli config", () => {
3345
readCliConfig.mockReturnValue({
3446
host: "127.0.0.1",
@@ -40,6 +52,7 @@ describe("server-runner", () => {
4052
getStaticAssetsDir.mockReturnValue("/tmp/web");
4153

4254
expect(buildServerConfig()).toEqual({
55+
appVersion: getCliVersion(import.meta.url),
4356
host: "127.0.0.1",
4457
dataDir: "/tmp/cs-data/coder-studio.db",
4558
auth: {
@@ -67,6 +80,7 @@ describe("server-runner", () => {
6780
const runningServer = await startServer();
6881

6982
expect(createServer).toHaveBeenCalledWith({
83+
appVersion: getCliVersion(import.meta.url),
7084
host: "127.0.0.1",
7185
port: 4173,
7286
webRoot: "/tmp/web",

packages/cli/src/server-runner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { fileURLToPath } from "url";
33
import { readCliConfig } from "./config-store.js";
44
import { getStaticAssetsDir, hasWebAssets } from "./embed.js";
55
import { assertSupportedNodeVersion } from "./node-version.js";
6+
import { getCliVersion } from "./package-manifest.js";
67

78
const MISSING_WEB_ASSETS_WARNING = "Warning: Web assets not found. Frontend will not be available.";
89

910
export const buildServerConfig = (): Partial<ServerConfig> => {
1011
const savedConfig = readCliConfig();
1112
const config: Partial<ServerConfig> = {
13+
appVersion: getCliVersion(import.meta.url),
1214
...(savedConfig?.host !== undefined ? { host: savedConfig.host } : {}),
1315
...(savedConfig?.port !== undefined && savedConfig.port > 0 ? { port: savedConfig.port } : {}),
1416
...(savedConfig?.dataDir !== undefined ? { dataDir: savedConfig.dataDir } : {}),

packages/server/src/__tests__/ws-hub.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ type MessageHandler = ((data: Buffer, isBinary?: boolean) => void) | undefined;
3636

3737
type ResultMessage = Extract<ServerToClient, { kind: "result" }>;
3838

39-
const TEST_CONFIG: Pick<ServerConfig, "auth"> = {
39+
const TEST_CONFIG: Pick<ServerConfig, "auth" | "appVersion"> = {
4040
auth: { enabled: false },
41+
appVersion: "0.3.0",
4142
};
4243

4344
const createMockSocket = (): MockSocket => ({
@@ -123,11 +124,21 @@ describe("WsHub", () => {
123124
eventBus.clear();
124125
});
125126

126-
it("should accept first connection as writer", () => {
127+
it("sends connection metadata including the CLI version on connect", () => {
127128
const socket = createMockSocket();
128129
hub.handleConnection(socket as never, createMockRequest());
129130

130-
expect(socket.send).toHaveBeenCalledWith(expect.stringContaining("connected"));
131+
const sentEvents = parseSentEvents(socket);
132+
expect(sentEvents[0]).toMatchObject({
133+
kind: "event",
134+
topic: "connection.status",
135+
data: expect.objectContaining({
136+
status: "connected",
137+
version: "0.3.0",
138+
serverInstanceId: expect.stringMatching(/^server-\d+$/),
139+
isWriter: false,
140+
}),
141+
});
131142
});
132143

133144
it("should accept multiple connections (writer tracking moved to FencingManager)", () => {

packages/server/src/config.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ describe("parseServerConfig", () => {
2626
expect(config.port).toBe(8080);
2727
});
2828

29+
it("defaults appVersion to the CLI package version", () => {
30+
delete process.env.CODER_STUDIO_APP_VERSION;
31+
32+
const config = parseServerConfig();
33+
34+
expect(config.appVersion).toBe("0.3.0");
35+
});
36+
37+
it("prefers explicit appVersion override over inferred CLI version", () => {
38+
const config = parseServerConfig({ appVersion: "9.9.9" });
39+
40+
expect(config.appVersion).toBe("9.9.9");
41+
});
42+
2943
it("uses the temp sqlite file by default outside production", () => {
3044
delete process.env.NODE_ENV;
3145
delete process.env.DATA_DIR;

packages/server/src/config.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ export interface ServerConfig {
1919
uploadsDir: string;
2020
logLevel: "trace" | "debug" | "info" | "warn" | "error";
2121
webRoot?: string;
22+
appVersion?: string;
2223
auth: {
2324
enabled: boolean;
2425
password?: string;
2526
};
2627
}
2728

2829
let cachedTestUploadsDir: string | undefined;
30+
let cachedAppVersion: string | undefined;
2931

3032
function parseLogLevel(value: string | undefined): ServerConfig["logLevel"] | undefined {
3133
switch (value) {
@@ -40,6 +42,30 @@ function parseLogLevel(value: string | undefined): ServerConfig["logLevel"] | un
4042
}
4143
}
4244

45+
function resolveDefaultAppVersion(): string {
46+
if (cachedAppVersion) {
47+
return cachedAppVersion;
48+
}
49+
50+
const packageJsonPath = [path.resolve(__dirname, "../../cli/package.json")].find((candidate) =>
51+
fs.existsSync(candidate)
52+
);
53+
54+
if (!packageJsonPath) {
55+
cachedAppVersion = "0.0.0";
56+
return cachedAppVersion;
57+
}
58+
59+
try {
60+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { version?: unknown };
61+
cachedAppVersion = typeof pkg.version === "string" ? pkg.version : "0.0.0";
62+
} catch {
63+
cachedAppVersion = "0.0.0";
64+
}
65+
66+
return cachedAppVersion;
67+
}
68+
4369
/**
4470
* Resolve the database file path.
4571
*
@@ -96,6 +122,8 @@ export function parseServerConfig(overrides?: Partial<ServerConfig>): ServerConf
96122
uploadsDir,
97123
logLevel: overrides?.logLevel ?? parseLogLevel(process.env.LOG_LEVEL) ?? "info",
98124
webRoot: overrides?.webRoot,
125+
appVersion:
126+
overrides?.appVersion ?? process.env.CODER_STUDIO_APP_VERSION ?? resolveDefaultAppVersion(),
99127
auth: overrides?.auth || {
100128
enabled: !noAuth && !!password,
101129
password,

packages/server/src/ws/hub.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,16 @@ export class WsHub implements Broadcaster {
9292
const client = new WsClient(socket, uuidv4(), this.deps.logger);
9393
this.clients.set(client.id, client);
9494

95-
// Send connection ready (controller status determined later by fencing.request command)
95+
// Send initial connection metadata. Writer status is established later by
96+
// fencing.request, but the UI still needs the app version immediately.
9697
client.sendEvent("connection.status", {
9798
status: "connected",
9899
clientId: client.id,
99100
authEnabled: this.deps.config.auth.enabled,
100101
binaryTerminalTransport: true,
102+
version: this.deps.config.appVersion ?? "0.0.0",
103+
serverInstanceId: `server-${process.pid}`,
104+
isWriter: false,
101105
});
102106

103107
// Setup handlers

packages/web/src/app/providers.test.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Supervisor, SupervisorCycle } from "@coder-studio/core";
22
import { createStore } from "jotai";
33
import { afterEach, beforeEach, describe, expect, it } from "vitest";
4-
import { sessionsAtom } from "../atoms";
4+
import { isWriterAtom, serverInfoAtom, sessionsAtom } from "../atoms";
55
import {
66
activeWorkspaceAtom,
77
activeWorkspaceIdAtom,
@@ -63,6 +63,29 @@ describe("routeEventToAtom", () => {
6363
expect(store.get(supervisorCyclesAtom).size).toBe(0);
6464
});
6565

66+
it("stores server metadata from the initial connected status event", () => {
67+
const store = createStore();
68+
69+
routeEventToAtom(
70+
"connection.status",
71+
{
72+
status: "connected",
73+
authEnabled: false,
74+
version: "0.3.0",
75+
serverInstanceId: "server-123",
76+
isWriter: true,
77+
},
78+
store
79+
);
80+
81+
expect(store.get(serverInfoAtom)).toEqual({
82+
version: "0.3.0",
83+
serverInstanceId: "server-123",
84+
authEnabled: false,
85+
});
86+
expect(store.get(isWriterAtom)).toBe(true);
87+
});
88+
6689
it("appends brand-new workspace meta events to workspace order without reordering existing entries", () => {
6790
const store = createStore();
6891

0 commit comments

Comments
 (0)