Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions commands/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GlueDTO | undefined> {
const glues = await runStep("Loading glues...", () => getGlues());
if (!glues.length) {
Expand Down
3 changes: 2 additions & 1 deletion commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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({
Expand Down
21 changes: 19 additions & 2 deletions commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ 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 } 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;
Expand Down Expand Up @@ -74,6 +75,7 @@ export async function dev(options: DevOptions, filename: string) {
const lifelineReconnectionEvents = pushableV<void>({ objectMode: true });

const glueName = await getGlueName(filename, options.name);
await warnIfStreakRuntimeIsOutdated(filename, options.verbose);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Awaiting warnIfStreakRuntimeIsOutdated here blocks the startup of the dev session until the network request to JSR completes. This can cause the CLI to feel unresponsive for several seconds on slower connections. It is better to trigger this check in the background to allow the dev server to start immediately.

Suggested change
await warnIfStreakRuntimeIsOutdated(filename, options.verbose);
warnIfStreakRuntimeIsOutdated(filename, options.verbose);


const glueCliWebsocketAddr = await wsListen(() => {
if (!lifelineHasConnected) {
Expand Down Expand Up @@ -509,6 +511,21 @@ function analyzeCode(_filename: string): AnalysisResult {
return { errors: [] };
}

async function warnIfStreakRuntimeIsOutdated(filename: string, verbose: boolean): Promise<void> {
try {
const outdatedRuntime = await getOutdatedStreakRuntimeVersion(filename);
if (!outdatedRuntime) {
return;
}
devProgressProps.outdatedRuntimeWarningInfo = outdatedRuntime;
renderUI();
} catch (e) {
if (verbose) {
console.error("Caught error:", e);
}
}
}
Comment on lines +514 to +527
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When running the version check in the background, we should ensure that renderUI() is only called if the UI is still active. This prevents a "zombie" UI from being rendered if the check completes after the setup phase has finished and the UI has been unmounted.

async function warnIfStreakRuntimeIsOutdated(filename: string, verbose: boolean): Promise<void> {
  try {
    const outdatedRuntime = await getOutdatedStreakRuntimeVersion(filename);
    if (!outdatedRuntime) {
      return;
    }
    devProgressProps.outdatedRuntimeWarningInfo = outdatedRuntime;
    if (inkInstance) {
      renderUI();
    }
  } catch (e) {
    if (verbose) {
      console.error("Caught error:", e);
    }
  }
}


async function discoverRegistrations(signal?: AbortSignal): Promise<Registrations> {
signal?.throwIfAborted();
const res = await fetch(
Expand Down Expand Up @@ -650,7 +667,7 @@ const ServerWebsocketMessage = z.object({
});
type ServerWebsocketMessage = z.infer<typeof ServerWebsocketMessage>;

interface DevOptions {
interface DevOptions extends CommonCommandOptions {
name?: string;
debug?: boolean;
inspectWait?: boolean;
Expand Down
2 changes: 2 additions & 0 deletions common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ export function isPrefixId(query: string, prefix: string) {
const regex = new RegExp(`^${prefix}_[0-9a-f]{1,}$`);
return regex.test(query);
}

export const GLUE_RUNTIME_PACKAGE = "@streak-glue/runtime";
1 change: 1 addition & 0 deletions glue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
58 changes: 58 additions & 0 deletions lib/denoConfig.ts
Original file line number Diff line number Diff line change
@@ -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<DenoConfigPaths> {
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<string> {
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<string | undefined> {
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;
}
}
}
45 changes: 3 additions & 42 deletions lib/getCreateDeploymentParams.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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<string> {
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<string | undefined> {
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.
*
Expand Down
82 changes: 82 additions & 0 deletions lib/runtimeVersionCheck.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
76 changes: 76 additions & 0 deletions lib/runtimeVersionCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import * as path from "@std/path";
import z from "zod";
import { findDenoConfigPaths } from "./denoConfig.ts";
import { GLUE_RUNTIME_PACKAGE } from "../common.ts";

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<OutdatedRuntimeInfo | undefined> {
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 };
Comment on lines +29 to +32
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 No semver comparison — false positive warning if user is on a newer version

The check latestVersion === currentVersion uses strict string equality, meaning getOutdatedStreakRuntimeVersion will return an OutdatedRuntimeInfo object whenever the two strings differ — including the case where the user's locked version is newer than the JSR-published latest (e.g., they previously locked a pre-release like 0.3.0-beta.1, or latest was temporarily rolled back on JSR). In that scenario the UI would show:

A newer @streak-glue/runtime version is available (0.3.0-beta.1 -> 0.2.35).
Update it with: deno update --latest @streak-glue/runtime

…which is both misleading and incorrect. A semantic version comparison (e.g., using the semver standard library) should be used so a warning is only shown when latestVersion is strictly greater than currentVersion.

Consider importing @std/semver and using greaterThan:

import { greaterThan, parse } from "@std/semver";

// ...
if (!greaterThan(parse(latestVersion), parse(currentVersion))) {
  return undefined;
}

}

const DenoLockFile = z.object({
version: z.string(),
specifiers: z.record(z.string(), z.string()).optional(),
});
type DenoLockFile = z.infer<typeof DenoLockFile>;

export function extractStreakRuntimeVersionFromDenoLockText(text: string): string | undefined {
const parsed = DenoLockFile.parse(JSON.parse(text));
Comment thread
Macil marked this conversation as resolved.
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<typeof JsrPackageMeta>;

async function fetchLatestStreakRuntimeVersion(
fetchImpl: typeof fetch,
): Promise<string> {
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;
}
Loading