From b63445c4138f3ad6a98decd782eb7824d66f72dd Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Thu, 2 Apr 2026 17:24:15 -0700 Subject: [PATCH 1/2] Show upgrade notice if you glue dev with old runtime version --- commands/create.ts | 3 +- commands/dev.ts | 23 ++++++++- common.ts | 4 ++ glue.ts | 1 + lib/denoConfig.ts | 58 ++++++++++++++++++++++ lib/getCreateDeploymentParams.ts | 45 ++--------------- lib/runtimeVersionCheck.test.ts | 82 +++++++++++++++++++++++++++++++ lib/runtimeVersionCheck.ts | 84 ++++++++++++++++++++++++++++++++ 8 files changed, 255 insertions(+), 45 deletions(-) create mode 100644 lib/denoConfig.ts create mode 100644 lib/runtimeVersionCheck.test.ts create mode 100644 lib/runtimeVersionCheck.ts diff --git a/commands/create.ts b/commands/create.ts index b5273b1..d8c8a35 100644 --- a/commands/create.ts +++ b/commands/create.ts @@ -5,6 +5,7 @@ import { Confirm } from "@cliffy/prompt/confirm"; import { delay } from "@std/async/delay"; import { Spinner } from "@std/cli/unstable-spinner"; import { promptToInstallSkills } from "./skills.ts"; +import type { CommonCommandOptions } from "../common.ts"; const DEFAULT_FILENAME = "myGlue.ts"; const TEMPLATE_CONTENT = `import { glue } from "jsr:@streak-glue/runtime"; @@ -14,7 +15,7 @@ glue.webhook.onGet((_event) => { }); `; -export async function create(_options: void) { +export async function create(_options: CommonCommandOptions) { await promptToInstallSkills(); let filename = await Input.prompt({ diff --git a/commands/dev.ts b/commands/dev.ts index 2472af4..e487af4 100644 --- a/commands/dev.ts +++ b/commands/dev.ts @@ -37,9 +37,13 @@ import { toLines } from "@std/streams/unstable-to-lines"; import { pushable, pushableV } from "it-pushable"; import { Select } from "@cliffy/prompt/select"; import { toSortedByTypeThenLabel } from "../ui/utils.ts"; - import { getGlueName } from "../lib/glueNaming.ts"; +import { + getOutdatedStreakRuntimeVersion, + logOutdatedStreakRuntimeMessage, +} from "../lib/runtimeVersionCheck.ts"; import { once } from "node:events"; +import type { CommonCommandOptions } from "../common.ts"; const GLUE_DEV_PORT = getAvailablePort({ preferredPort: 8001 }); const DEFAULT_DEBUG_PORT = 9229; @@ -74,6 +78,7 @@ export async function dev(options: DevOptions, filename: string) { const lifelineReconnectionEvents = pushableV({ objectMode: true }); const glueName = await getGlueName(filename, options.name); + await warnIfStreakRuntimeIsOutdated(filename, options.verbose); const glueCliWebsocketAddr = await wsListen(() => { if (!lifelineHasConnected) { @@ -509,6 +514,20 @@ function analyzeCode(_filename: string): AnalysisResult { return { errors: [] }; } +async function warnIfStreakRuntimeIsOutdated(filename: string, verbose: boolean): Promise { + try { + const outdatedRuntime = await getOutdatedStreakRuntimeVersion(filename); + if (!outdatedRuntime) { + return; + } + logOutdatedStreakRuntimeMessage(outdatedRuntime); + } catch (e) { + if (verbose) { + console.error("Caught error:", e); + } + } +} + async function discoverRegistrations(signal?: AbortSignal): Promise { signal?.throwIfAborted(); const res = await fetch( @@ -650,7 +669,7 @@ const ServerWebsocketMessage = z.object({ }); type ServerWebsocketMessage = z.infer; -interface DevOptions { +interface DevOptions extends CommonCommandOptions { name?: string; debug?: boolean; inspectWait?: boolean; diff --git a/common.ts b/common.ts index c3ce0db..1ab9f7c 100644 --- a/common.ts +++ b/common.ts @@ -8,3 +8,7 @@ export function isPrefixId(query: string, prefix: string) { const regex = new RegExp(`^${prefix}_[0-9a-f]{1,}$`); return regex.test(query); } + +export interface CommonCommandOptions { + verbose: boolean; +} diff --git a/glue.ts b/glue.ts index 09e91ca..0db0b5f 100755 --- a/glue.ts +++ b/glue.ts @@ -30,6 +30,7 @@ const cmd = new Command() .action(() => { cmd.showHelp(); }) + .globalOption("--verbose", "Enable verbose logging for debugging purposes", { default: false }) .command( "upgrade", new UpgradeCommand({ diff --git a/lib/denoConfig.ts b/lib/denoConfig.ts new file mode 100644 index 0000000..19f6c17 --- /dev/null +++ b/lib/denoConfig.ts @@ -0,0 +1,58 @@ +import { exists } from "@std/fs/exists"; +import * as path from "@std/path"; + +export interface DenoConfigPaths { + denoJsonPath?: string; + denoLockPath?: string; +} + +export async function findDenoConfigPaths(startDir: string): Promise { + const denoJsonPath = await findFileInDirectoryOrAbove(startDir, ["deno.json", "deno.jsonc"]); + if (!denoJsonPath) { + return {}; + } + + const denoLockPath = path.join(path.dirname(denoJsonPath), "deno.lock"); + return { + denoJsonPath, + denoLockPath: await exists(denoLockPath) ? denoLockPath : undefined, + }; +} + +/** Generator that yields a directory and its parent directories */ +export function* parentDirectories(startDir: string): Generator { + let currentDir = path.resolve(startDir); + while (true) { + yield currentDir; + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } +} + +/** Find any of the `searchNames` files in fileDir or its parent directories */ +export async function findFileInDirectoryOrAbove( + fileDir: string, + searchNames: string[], +): Promise { + try { + for (const currentDir of parentDirectories(fileDir)) { + for (const searchName of searchNames) { + const currentPath = path.join(currentDir, searchName); + if (await exists(currentPath)) { + return currentPath; + } + } + } + } catch (err) { + if (globalThis.Deno?.errors?.NotCapable && err instanceof Deno.errors.NotCapable) { + // If we only have permissions to a specific directory, then we may hit + // this error when trying to access parent directories. Assume we're not + // meant to access them and stop searching. + } else { + throw err; + } + } +} diff --git a/lib/getCreateDeploymentParams.ts b/lib/getCreateDeploymentParams.ts index 18bdc04..ffddfb8 100644 --- a/lib/getCreateDeploymentParams.ts +++ b/lib/getCreateDeploymentParams.ts @@ -1,8 +1,8 @@ import { load as dotenvLoad } from "@std/dotenv"; -import { exists } from "@std/fs/exists"; import * as path from "@std/path"; import { join as posixPathJoin } from "@std/path/posix/join"; import type { CreateDeploymentParams, DeploymentAsset, Runner } from "../backend.ts"; +import { findDenoConfigPaths } from "./denoConfig.ts"; import { parseImports } from "./parseImports.ts"; /** @@ -83,12 +83,11 @@ export async function getCreateDeploymentParams( const entryPointPromise = addAsset(entryPointUrl, true); // Find deno.json if present - const denoJsonPath = await findFileInDirectoryOrAbove(fileDir, ["deno.json", "deno.jsonc"]); + const { denoJsonPath, denoLockPath } = await findDenoConfigPaths(fileDir); if (denoJsonPath) { await addAsset(path.relative(fileDir, denoJsonPath), false); - const denoLockPath = path.join(path.dirname(denoJsonPath), "deno.lock"); - if (await exists(denoLockPath)) { + if (denoLockPath) { await addAsset(path.relative(fileDir, denoLockPath), false); } } @@ -141,44 +140,6 @@ function defaultCompareFn(a: string, b: string) { return 0; } -/** Generator that yields a directory and its parent directories */ -function* parentDirectories(startDir: string): Generator { - let currentDir = path.resolve(startDir); - while (true) { - yield currentDir; - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) { - break; - } - currentDir = parentDir; - } -} - -/** Find any of the `searchNames` files in fileDir or its parent directories */ -async function findFileInDirectoryOrAbove( - fileDir: string, - searchNames: string[], -): Promise { - try { - for (const currentDir of parentDirectories(fileDir)) { - for (const searchName of searchNames) { - const currentPath = path.join(currentDir, searchName); - if (await exists(currentPath)) { - return currentPath; - } - } - } - } catch (err) { - if (globalThis.Deno?.errors?.NotCapable && err instanceof Deno.errors.NotCapable) { - // If we only have permissions to a specific directory, then we may hit - // this error when trying to access parent directories. Assume we're not - // meant to access them and stop searching. - } else { - throw err; - } - } -} - /** * Count how many "../" are at the start of the path. * diff --git a/lib/runtimeVersionCheck.test.ts b/lib/runtimeVersionCheck.test.ts new file mode 100644 index 0000000..14c7315 --- /dev/null +++ b/lib/runtimeVersionCheck.test.ts @@ -0,0 +1,82 @@ +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import { findDenoConfigPaths } from "./denoConfig.ts"; +import { + extractStreakRuntimeVersionFromDenoLockText, + getOutdatedStreakRuntimeVersion, +} from "./runtimeVersionCheck.ts"; + +async function createTempDir(): Promise<{ path: string } & AsyncDisposable> { + const tempDir = await Deno.makeTempDir({ prefix: "runtime-version-check-" }); + return { + path: tempDir, + async [Symbol.asyncDispose]() { + await Deno.remove(tempDir, { recursive: true }); + }, + }; +} + +Deno.test("findDenoConfigPaths finds deno.json and deno.lock above the entrypoint", async () => { + await using tempDir = await createTempDir(); + const projectDir = join(tempDir.path, "project"); + const nestedDir = join(projectDir, "src", "workers"); + await Deno.mkdir(nestedDir, { recursive: true }); + + const denoJsonPath = join(projectDir, "deno.json"); + const denoLockPath = join(projectDir, "deno.lock"); + await Deno.writeTextFile(denoJsonPath, "{}\n"); + await Deno.writeTextFile(denoLockPath, "{}\n"); + + assertEquals(await findDenoConfigPaths(nestedDir), { denoJsonPath, denoLockPath }); +}); + +Deno.test("extractStreakRuntimeVersionFromDenoLockText prefers the resolved lock version", () => { + const text = JSON.stringify({ + version: "5", + specifiers: { + "jsr:@std/path@^1.1.4": "1.1.4", + "jsr:@streak-glue/runtime@~0.2.23": "0.2.31", + }, + }); + assertEquals(extractStreakRuntimeVersionFromDenoLockText(text), "0.2.31"); +}); + +Deno.test("getOutdatedStreakRuntimeVersion returns latest mismatch and formats warning", async () => { + await using tempDir = await createTempDir(); + const projectDir = join(tempDir.path, "project"); + const sourceDir = join(projectDir, "src"); + await Deno.mkdir(sourceDir, { recursive: true }); + + const filename = join(sourceDir, "myGlue.ts"); + await Deno.writeTextFile(filename, "console.log('hello');\n"); + await Deno.writeTextFile( + join(projectDir, "deno.json"), + JSON.stringify({ + imports: { + "@streak-glue/runtime": "jsr:@streak-glue/runtime@~0.2.22", + }, + }), + ); + await Deno.writeTextFile( + join(projectDir, "deno.lock"), + JSON.stringify({ + version: "5", + specifiers: { + "jsr:@streak-glue/runtime@~0.2.22": "0.2.23", + }, + }), + ); + const outdatedInfo = await getOutdatedStreakRuntimeVersion( + filename, + () => + Promise.resolve( + new Response(JSON.stringify({ scope: "@streak-glue", name: "runtime", latest: "0.2.33" }), { + status: 200, + }), + ), + ); + assertEquals(outdatedInfo, { + currentVersion: "0.2.23", + latestVersion: "0.2.33", + }); +}); diff --git a/lib/runtimeVersionCheck.ts b/lib/runtimeVersionCheck.ts new file mode 100644 index 0000000..3d9a1e1 --- /dev/null +++ b/lib/runtimeVersionCheck.ts @@ -0,0 +1,84 @@ +import * as path from "@std/path"; +import { findDenoConfigPaths } from "./denoConfig.ts"; +import z from "zod"; + +const GLUE_RUNTIME_PACKAGE = "@streak-glue/runtime"; +const GLUE_RUNTIME_JSR_SPECIFIER = `jsr:${GLUE_RUNTIME_PACKAGE}`; +const GLUE_RUNTIME_META_URL = `https://jsr.io/${GLUE_RUNTIME_PACKAGE}/meta.json`; + +export interface OutdatedRuntimeInfo { + currentVersion: string; + latestVersion: string; +} + +export async function getOutdatedStreakRuntimeVersion( + filename: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const { denoLockPath } = await findDenoConfigPaths(path.dirname(filename)); + if (!denoLockPath) { + return undefined; + } + const currentVersion = extractStreakRuntimeVersionFromDenoLockText( + await Deno.readTextFile(denoLockPath), + ); + if (!currentVersion) { + return undefined; + } + const latestVersion = await fetchLatestStreakRuntimeVersion(fetchImpl); + if (latestVersion === currentVersion) { + return undefined; + } + return { currentVersion, latestVersion }; +} + +const DenoLockFile = z.object({ + version: z.string(), + specifiers: z.record(z.string(), z.string()).optional(), +}); +type DenoLockFile = z.infer; + +export function extractStreakRuntimeVersionFromDenoLockText(text: string): string | undefined { + const parsed = DenoLockFile.parse(JSON.parse(text)); + if (!parsed.specifiers) { + return undefined; + } + const searchString = `${GLUE_RUNTIME_JSR_SPECIFIER}@`; + for (const [specifier, resolvedVersion] of Object.entries(parsed.specifiers)) { + if ( + specifier === GLUE_RUNTIME_JSR_SPECIFIER || + specifier.startsWith(searchString) + ) { + return resolvedVersion; + } + } + return undefined; +} + +const JsrPackageMeta = z.object({ + scope: z.string(), + name: z.string(), + latest: z.string(), +}); +type JsrPackageMeta = z.infer; + +async function fetchLatestStreakRuntimeVersion( + fetchImpl: typeof fetch, +): Promise { + const response = await fetchImpl(GLUE_RUNTIME_META_URL); + if (!response.ok) { + throw new Error( + `Failed to fetch ${GLUE_RUNTIME_META_URL}: ${response.status} ${response.statusText}`, + ); + } + const payload = JsrPackageMeta.parse(await response.json()); + return payload.latest; +} + +export function logOutdatedStreakRuntimeMessage(info: OutdatedRuntimeInfo) { + console.warn( + `A newer ${GLUE_RUNTIME_PACKAGE} version is available (${info.currentVersion} -> ${info.latestVersion}).\n` + + `Update it with: %cdeno update --latest ${GLUE_RUNTIME_PACKAGE}`, + "color: yellow;", + ); +} From 8023fb7e2d2607f427fa6ecad5a5b2c70c2aff15 Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Thu, 2 Apr 2026 17:52:39 -0700 Subject: [PATCH 2/2] use ink to render warning so it doesn't get cleared off screen --- commands/common.ts | 4 ++++ commands/create.ts | 2 +- commands/dev.ts | 10 ++++------ common.ts | 4 +--- lib/runtimeVersionCheck.ts | 12 ++---------- ui/dev.tsx | 27 +++++++++++++++++++++++---- 6 files changed, 35 insertions(+), 24 deletions(-) diff --git a/commands/common.ts b/commands/common.ts index a96efe2..d4457b5 100644 --- a/commands/common.ts +++ b/commands/common.ts @@ -3,6 +3,10 @@ import { Checkbox } from "@cliffy/prompt/checkbox"; import { Select } from "@cliffy/prompt/select"; import { runStep } from "../ui/utils.ts"; +export interface CommonCommandOptions { + verbose: boolean; +} + export async function askUserForGlue(): Promise { const glues = await runStep("Loading glues...", () => getGlues()); if (!glues.length) { diff --git a/commands/create.ts b/commands/create.ts index d8c8a35..828811d 100644 --- a/commands/create.ts +++ b/commands/create.ts @@ -5,7 +5,7 @@ import { Confirm } from "@cliffy/prompt/confirm"; import { delay } from "@std/async/delay"; import { Spinner } from "@std/cli/unstable-spinner"; import { promptToInstallSkills } from "./skills.ts"; -import type { CommonCommandOptions } from "../common.ts"; +import type { CommonCommandOptions } from "./common.ts"; const DEFAULT_FILENAME = "myGlue.ts"; const TEMPLATE_CONTENT = `import { glue } from "jsr:@streak-glue/runtime"; diff --git a/commands/dev.ts b/commands/dev.ts index e487af4..8243b1e 100644 --- a/commands/dev.ts +++ b/commands/dev.ts @@ -38,12 +38,9 @@ import { pushable, pushableV } from "it-pushable"; import { Select } from "@cliffy/prompt/select"; import { toSortedByTypeThenLabel } from "../ui/utils.ts"; import { getGlueName } from "../lib/glueNaming.ts"; -import { - getOutdatedStreakRuntimeVersion, - logOutdatedStreakRuntimeMessage, -} from "../lib/runtimeVersionCheck.ts"; +import { getOutdatedStreakRuntimeVersion } from "../lib/runtimeVersionCheck.ts"; import { once } from "node:events"; -import type { CommonCommandOptions } from "../common.ts"; +import type { CommonCommandOptions } from "./common.ts"; const GLUE_DEV_PORT = getAvailablePort({ preferredPort: 8001 }); const DEFAULT_DEBUG_PORT = 9229; @@ -520,7 +517,8 @@ async function warnIfStreakRuntimeIsOutdated(filename: string, verbose: boolean) if (!outdatedRuntime) { return; } - logOutdatedStreakRuntimeMessage(outdatedRuntime); + devProgressProps.outdatedRuntimeWarningInfo = outdatedRuntime; + renderUI(); } catch (e) { if (verbose) { console.error("Caught error:", e); diff --git a/common.ts b/common.ts index 1ab9f7c..825a754 100644 --- a/common.ts +++ b/common.ts @@ -9,6 +9,4 @@ export function isPrefixId(query: string, prefix: string) { return regex.test(query); } -export interface CommonCommandOptions { - verbose: boolean; -} +export const GLUE_RUNTIME_PACKAGE = "@streak-glue/runtime"; diff --git a/lib/runtimeVersionCheck.ts b/lib/runtimeVersionCheck.ts index 3d9a1e1..a7c8f32 100644 --- a/lib/runtimeVersionCheck.ts +++ b/lib/runtimeVersionCheck.ts @@ -1,8 +1,8 @@ import * as path from "@std/path"; -import { findDenoConfigPaths } from "./denoConfig.ts"; import z from "zod"; +import { findDenoConfigPaths } from "./denoConfig.ts"; +import { GLUE_RUNTIME_PACKAGE } from "../common.ts"; -const GLUE_RUNTIME_PACKAGE = "@streak-glue/runtime"; const GLUE_RUNTIME_JSR_SPECIFIER = `jsr:${GLUE_RUNTIME_PACKAGE}`; const GLUE_RUNTIME_META_URL = `https://jsr.io/${GLUE_RUNTIME_PACKAGE}/meta.json`; @@ -74,11 +74,3 @@ async function fetchLatestStreakRuntimeVersion( const payload = JsrPackageMeta.parse(await response.json()); return payload.latest; } - -export function logOutdatedStreakRuntimeMessage(info: OutdatedRuntimeInfo) { - console.warn( - `A newer ${GLUE_RUNTIME_PACKAGE} version is available (${info.currentVersion} -> ${info.latestVersion}).\n` + - `Update it with: %cdeno update --latest ${GLUE_RUNTIME_PACKAGE}`, - "color: yellow;", - ); -} diff --git a/ui/dev.tsx b/ui/dev.tsx index 56609fc..2892beb 100644 --- a/ui/dev.tsx +++ b/ui/dev.tsx @@ -9,13 +9,16 @@ import { import { Box, Text } from "ink"; import { Newline } from "ink"; import type { DebugMode, SetupReplayResult } from "../commands/dev.ts"; +import type { OutdatedRuntimeInfo } from "../lib/runtimeVersionCheck.ts"; +import { GLUE_RUNTIME_PACKAGE } from "../common.ts"; -export type Step = { +export interface Step { state: StepStatus; duration: number; -}; +} -export type DevUIProps = { +export interface DevUIProps { + outdatedRuntimeWarningInfo?: OutdatedRuntimeInfo; steps: { codeAnalysis?: Step; bootingCode: Step; @@ -28,7 +31,7 @@ export type DevUIProps = { deployment?: DeploymentDTO; debugMode: DebugMode; setupReplayResult?: SetupReplayResult; -}; +} export const DevUI = ( props: DevUIProps, @@ -56,6 +59,12 @@ export const DevUI = ( return ( <> + {props.outdatedRuntimeWarningInfo && ( + + + + )} + {props.restarting && ( <> @@ -164,6 +173,16 @@ export const DevUI = ( ); }; +export function OutdatedRuntimeWarning({ info }: { info: OutdatedRuntimeInfo }) { + return ( + + A newer {GLUE_RUNTIME_PACKAGE} version is available ({info.currentVersion} {"->"}{" "} + {info.latestVersion}). + Update it with: deno update --latest {GLUE_RUNTIME_PACKAGE} + + ); +} + export function ReplayResultRow({ execution, compatible }: SetupReplayResult) { return ( <>