diff --git a/.changeset/thirty-ideas-glow.md b/.changeset/thirty-ideas-glow.md new file mode 100644 index 000000000..e531216d4 --- /dev/null +++ b/.changeset/thirty-ideas-glow.md @@ -0,0 +1,5 @@ +--- +"@workflow/world-local": patch +--- + +Fix package info stored in data dir showing the wrong version diff --git a/packages/cli/src/lib/inspect/setup.ts b/packages/cli/src/lib/inspect/setup.ts index 92c93bdb2..e116290c3 100644 --- a/packages/cli/src/lib/inspect/setup.ts +++ b/packages/cli/src/lib/inspect/setup.ts @@ -1,8 +1,6 @@ -import { join } from 'node:path'; import { createWorld } from '@workflow/core/runtime'; import chalk from 'chalk'; import terminalLink from 'terminal-link'; -import XDGAppPaths from 'xdg-app-paths'; import { logger, setJsonMode, setVerboseMode } from '../config/log.js'; import { checkForUpdateCached } from '../update-check.js'; import { @@ -11,18 +9,6 @@ import { writeEnvVars, } from './env.js'; -// Get XDG-compliant cache directory for workflow -const getXDGAppPaths = (app: string) => { - return ( - XDGAppPaths as unknown as (app: string) => { dataDirs: () => string[] } - )(app); -}; - -const getWorkflowCacheDir = (): string => { - const dirs = getXDGAppPaths('workflow').dataDirs(); - return dirs[0]; -}; - /** * Setup CLI world configuration. * If throwOnConfigError is false, will return null world with the error message @@ -45,8 +31,7 @@ export const setupCliWorld = async ( setVerboseMode(Boolean(flags.verbose)); // Check for updates - const cacheFile = join(getWorkflowCacheDir(), 'version-check.json'); - const updateCheck = await checkForUpdateCached(version, cacheFile); + const updateCheck = await checkForUpdateCached(version); const withAnsiLinks = flags.json ? false : true; const docsUrl = withAnsiLinks diff --git a/packages/cli/src/lib/update-check.ts b/packages/cli/src/lib/update-check.ts index d30b39794..779cec316 100644 --- a/packages/cli/src/lib/update-check.ts +++ b/packages/cli/src/lib/update-check.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; +import { dirname, join } from 'node:path'; +import XDGAppPaths from 'xdg-app-paths'; import { logger } from './config/log.js'; // Constants @@ -7,6 +8,22 @@ const PACKAGE_NAME = '@workflow/cli'; const NPM_REGISTRY = 'https://registry.npmjs.org'; const CACHE_DURATION_MS = 3 * 24 * 60 * 60 * 1000; // 3 days const REQUEST_TIMEOUT_MS = 5000; +const VERSION_CHECK_CACHE_FILENAME = 'version-check.json'; + +// Get XDG-compliant cache directory for workflow +const getXDGAppPaths = (app: string) => { + return ( + XDGAppPaths as unknown as (app: string) => { dataDirs: () => string[] } + )(app); +}; + +/** + * Get the cache file path for version checks. + */ +export function getVersionCheckCacheFile(): string { + const dirs = getXDGAppPaths('workflow').dataDirs(); + return join(dirs[0], VERSION_CHECK_CACHE_FILENAME); +} interface VersionCheckResult { currentVersion: string; @@ -231,9 +248,10 @@ async function isCacheValid( * Cache is valid unless the local version changes */ export async function checkForUpdateCached( - currentVersion: string, - cacheFile: string + currentVersion: string ): Promise { + const cacheFile = getVersionCheckCacheFile(); + // Check if cache is valid if (await isCacheValid(cacheFile, currentVersion)) { logger.debug('Using cached version check result'); diff --git a/packages/world-local/src/init.test.ts b/packages/world-local/src/init.test.ts index 5ac2e13b4..935658734 100644 --- a/packages/world-local/src/init.test.ts +++ b/packages/world-local/src/init.test.ts @@ -15,6 +15,7 @@ import { ensureDataDir, formatVersion, formatVersionFile, + getPackageInfo, initDataDir, type ParsedVersion, parseVersion, @@ -165,39 +166,39 @@ describe('ensureDataDir', () => { }); describe('directory creation', () => { - it('should create the directory if it does not exist', () => { + it('should create the directory if it does not exist', async () => { const dataDir = path.join(testBaseDir, 'new-data-dir'); expect(existsSync(dataDir)).toBe(false); - ensureDataDir(dataDir); + await ensureDataDir(dataDir); expect(existsSync(dataDir)).toBe(true); }); - it('should create nested directories recursively', () => { + it('should create nested directories recursively', async () => { const dataDir = path.join(testBaseDir, 'level1', 'level2', 'level3'); expect(existsSync(dataDir)).toBe(false); - ensureDataDir(dataDir); + await ensureDataDir(dataDir); expect(existsSync(dataDir)).toBe(true); }); - it('should not throw if the directory already exists', () => { + it('should not throw if the directory already exists', async () => { const dataDir = path.join(testBaseDir, 'existing-dir'); mkdirSync(dataDir); expect(existsSync(dataDir)).toBe(true); - expect(() => ensureDataDir(dataDir)).not.toThrow(); + await expect(ensureDataDir(dataDir)).resolves.not.toThrow(); }); - it('should handle relative paths by resolving to absolute paths', () => { + it('should handle relative paths by resolving to absolute paths', async () => { const originalCwd = process.cwd(); try { process.chdir(testBaseDir); const relativeDir = 'relative-data-dir'; - ensureDataDir(relativeDir); + await ensureDataDir(relativeDir); expect(existsSync(path.join(testBaseDir, relativeDir))).toBe(true); } finally { @@ -211,14 +212,16 @@ describe('ensureDataDir', () => { it.skipIf(isWindows)( 'should throw DataDirAccessError if directory is not readable', - () => { + async () => { const dataDir = path.join(testBaseDir, 'unreadable-dir'); mkdirSync(dataDir); chmodSync(dataDir, 0o000); try { - expect(() => ensureDataDir(dataDir)).toThrow(DataDirAccessError); - expect(() => ensureDataDir(dataDir)).toThrow(/not readable/); + await expect(ensureDataDir(dataDir)).rejects.toThrow( + DataDirAccessError + ); + await expect(ensureDataDir(dataDir)).rejects.toThrow(/not readable/); } finally { chmodSync(dataDir, 0o755); } @@ -227,14 +230,16 @@ describe('ensureDataDir', () => { it.skipIf(isWindows)( 'should throw DataDirAccessError if directory is not writable', - () => { + async () => { const dataDir = path.join(testBaseDir, 'readonly-dir'); mkdirSync(dataDir); chmodSync(dataDir, 0o555); try { - expect(() => ensureDataDir(dataDir)).toThrow(DataDirAccessError); - expect(() => ensureDataDir(dataDir)).toThrow(/not writable/); + await expect(ensureDataDir(dataDir)).rejects.toThrow( + DataDirAccessError + ); + await expect(ensureDataDir(dataDir)).rejects.toThrow(/not writable/); } finally { chmodSync(dataDir, 0o755); } @@ -243,7 +248,7 @@ describe('ensureDataDir', () => { it.skipIf(isWindows)( 'should throw DataDirAccessError if parent directory is not writable', - () => { + async () => { const parentDir = path.join(testBaseDir, 'readonly-parent'); mkdirSync(parentDir); chmodSync(parentDir, 0o555); @@ -251,8 +256,10 @@ describe('ensureDataDir', () => { const dataDir = path.join(parentDir, 'new-child-dir'); try { - expect(() => ensureDataDir(dataDir)).toThrow(DataDirAccessError); - expect(() => ensureDataDir(dataDir)).toThrow( + await expect(ensureDataDir(dataDir)).rejects.toThrow( + DataDirAccessError + ); + await expect(ensureDataDir(dataDir)).rejects.toThrow( /Failed to create data directory/ ); } finally { @@ -263,7 +270,7 @@ describe('ensureDataDir', () => { }); describe('DataDirAccessError', () => { - it('should include the data directory path in the error', () => { + it('should include the data directory path in the error', async () => { const dataDir = path.join(testBaseDir, 'readonly-parent-for-error'); const isWindows = process.platform === 'win32'; @@ -274,7 +281,7 @@ describe('ensureDataDir', () => { const childDir = path.join(dataDir, 'child'); try { - ensureDataDir(childDir); + await ensureDataDir(childDir); } catch (error) { expect(error).toBeInstanceOf(DataDirAccessError); expect((error as DataDirAccessError).dataDir).toBe(childDir); @@ -284,7 +291,7 @@ describe('ensureDataDir', () => { } }); - it('should include the error code when available', () => { + it('should include the error code when available', async () => { const isWindows = process.platform === 'win32'; if (!isWindows) { @@ -295,7 +302,7 @@ describe('ensureDataDir', () => { const dataDir = path.join(parentDir, 'child'); try { - ensureDataDir(dataDir); + await ensureDataDir(dataDir); } catch (error) { expect(error).toBeInstanceOf(DataDirAccessError); expect((error as DataDirAccessError).code).toBeDefined(); @@ -307,28 +314,28 @@ describe('ensureDataDir', () => { }); describe('edge cases', () => { - it('should handle empty string by creating directory at current path', () => { + it('should handle empty string by creating directory at current path', async () => { const originalCwd = process.cwd(); try { process.chdir(testBaseDir); - expect(() => ensureDataDir('.')).not.toThrow(); + await expect(ensureDataDir('.')).resolves.not.toThrow(); } finally { process.chdir(originalCwd); } }); - it('should handle paths with special characters', () => { + it('should handle paths with special characters', async () => { const dataDir = path.join(testBaseDir, 'special-chars-dir-@#$%'); - ensureDataDir(dataDir); + await ensureDataDir(dataDir); expect(existsSync(dataDir)).toBe(true); }); - it('should handle paths with spaces', () => { + it('should handle paths with spaces', async () => { const dataDir = path.join(testBaseDir, 'dir with spaces'); - ensureDataDir(dataDir); + await ensureDataDir(dataDir); expect(existsSync(dataDir)).toBe(true); }); @@ -355,10 +362,10 @@ describe('initDataDir', () => { vi.restoreAllMocks(); }); - it('should create version.txt for new data directory', () => { + it('should create version.txt for new data directory', async () => { const dataDir = path.join(testBaseDir, 'new-data'); - initDataDir(dataDir); + await initDataDir(dataDir); const versionPath = path.join(dataDir, 'version.txt'); expect(existsSync(versionPath)).toBe(true); @@ -367,27 +374,29 @@ describe('initDataDir', () => { expect(content).toMatch(/^@workflow\/world-local@\d+\.\d+\.\d+/); }); - it('should not modify version.txt if version matches', () => { + it('should not modify version.txt if version matches', async () => { const dataDir = path.join(testBaseDir, 'existing-data'); mkdirSync(dataDir, { recursive: true }); // Write the current version + const packageInfo = await getPackageInfo(); const versionPath = path.join(dataDir, 'version.txt'); - writeFileSync(versionPath, '@workflow/world-local@4.0.1-beta.20'); + const currentVersion = `${packageInfo.name}@${packageInfo.version}`; + writeFileSync(versionPath, currentVersion); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - initDataDir(dataDir); + await initDataDir(dataDir); // Should not log upgrade message since versions match expect(consoleSpy).not.toHaveBeenCalled(); // File should remain unchanged const content = readFileSync(versionPath, 'utf-8'); - expect(content).toBe('@workflow/world-local@4.0.1-beta.20'); + expect(content).toBe(currentVersion); }); - it('should call upgradeVersion when versions differ', () => { + it('should call upgradeVersion when versions differ', async () => { const dataDir = path.join(testBaseDir, 'old-data'); mkdirSync(dataDir, { recursive: true }); @@ -397,7 +406,7 @@ describe('initDataDir', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - initDataDir(dataDir); + await initDataDir(dataDir); // Should log upgrade message expect(consoleSpy).toHaveBeenCalledWith( @@ -409,7 +418,7 @@ describe('initDataDir', () => { expect(content).toMatch(/^@workflow\/world-local@4\.0\.1/); }); - it('should handle data directory with newer version', () => { + it('should handle data directory with newer version', async () => { const dataDir = path.join(testBaseDir, 'newer-data'); mkdirSync(dataDir, { recursive: true }); @@ -420,7 +429,7 @@ describe('initDataDir', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); // This will call upgradeVersion which just logs for now - initDataDir(dataDir); + await initDataDir(dataDir); // Should log the upgrade message (even for "downgrades") expect(consoleSpy).toHaveBeenCalledWith( diff --git a/packages/world-local/src/init.ts b/packages/world-local/src/init.ts index 96a32c0c0..77733f526 100644 --- a/packages/world-local/src/init.ts +++ b/packages/world-local/src/init.ts @@ -1,18 +1,69 @@ import { - accessSync, + access, constants, - mkdirSync, - readFileSync, - unlinkSync, - writeFileSync, -} from 'node:fs'; + mkdir, + readFile, + unlink, + writeFile, +} from 'node:fs/promises'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -/** Package name for version tracking */ -export const PACKAGE_NAME = '@workflow/world-local'; +/** Package name - hardcoded since it doesn't change */ +const PACKAGE_NAME = '@workflow/world-local'; -/** Current package version - imported at build time */ -export const PACKAGE_VERSION = '4.0.1-beta.20'; +interface PackageInfo { + name: string; + version: string; +} + +let cachedPackageInfo: PackageInfo | null = null; + +/** + * Get the directory path for this module. + * Works in ESM and falls back to a constant in CJS contexts (which shouldn't happen) + */ +function getModuleDir(): string | null { + // In bundled CJS contexts, import.meta.url may be undefined or empty + if (typeof import.meta.url === 'string' && import.meta.url) { + return path.dirname(fileURLToPath(import.meta.url)); + } + return null; +} + +/** + * Returns the package name and version from package.json. + * The result is cached after the first read. + * + * In bundled contexts where package.json cannot be read, + * returns 'bundled' as the version. + */ +export async function getPackageInfo(): Promise { + if (cachedPackageInfo) { + return cachedPackageInfo; + } + + const moduleDir = getModuleDir(); + if (moduleDir) { + try { + const content = await readFile( + path.join(moduleDir, '../package.json'), + 'utf-8' + ); + cachedPackageInfo = JSON.parse(content) as PackageInfo; + return cachedPackageInfo; + } catch { + // Fall through to bundled fallback + } + } + + // Bundled context - package.json not accessible + cachedPackageInfo = { + name: PACKAGE_NAME, + version: 'bundled', + }; + return cachedPackageInfo; +} /** Filename for storing version information in the data directory */ const VERSION_FILENAME = 'version.txt'; @@ -157,12 +208,12 @@ export function upgradeVersion( * @param dataDir - The path to the data directory * @throws {DataDirAccessError} If the directory cannot be created or accessed */ -export function ensureDataDir(dataDir: string): void { +export async function ensureDataDir(dataDir: string): Promise { const absolutePath = path.resolve(dataDir); // Try to create the directory if it doesn't exist try { - mkdirSync(absolutePath, { recursive: true }); + await mkdir(absolutePath, { recursive: true }); } catch (error: unknown) { const nodeError = error as NodeJS.ErrnoException; // EEXIST is fine - directory already exists @@ -177,7 +228,7 @@ export function ensureDataDir(dataDir: string): void { // Verify the directory is accessible (readable) try { - accessSync(absolutePath, constants.R_OK); + await access(absolutePath, constants.R_OK); } catch (error: unknown) { const nodeError = error as NodeJS.ErrnoException; throw new DataDirAccessError( @@ -193,8 +244,8 @@ export function ensureDataDir(dataDir: string): void { `.workflow-write-test-${Date.now()}` ); try { - writeFileSync(testFile, ''); - unlinkSync(testFile); + await writeFile(testFile, ''); + await unlink(testFile); } catch (error: unknown) { const nodeError = error as NodeJS.ErrnoException; throw new DataDirAccessError( @@ -211,14 +262,14 @@ export function ensureDataDir(dataDir: string): void { * @param dataDir - Path to the data directory * @returns The parsed version info, or null if the file doesn't exist */ -function readVersionFile(dataDir: string): { +async function readVersionFile(dataDir: string): Promise<{ packageName: string; version: ParsedVersion; -} | null { +} | null> { const versionFilePath = path.join(path.resolve(dataDir), VERSION_FILENAME); try { - const content = readFileSync(versionFilePath, 'utf-8'); + const content = await readFile(versionFilePath, 'utf-8'); return parseVersionFile(content); } catch (error: unknown) { const nodeError = error as NodeJS.ErrnoException; @@ -235,10 +286,14 @@ function readVersionFile(dataDir: string): { * @param dataDir - Path to the data directory * @param version - The version to write */ -function writeVersionFile(dataDir: string, version: ParsedVersion): void { +async function writeVersionFile( + dataDir: string, + version: ParsedVersion +): Promise { const versionFilePath = path.join(path.resolve(dataDir), VERSION_FILENAME); - const content = formatVersionFile(PACKAGE_NAME, version); - writeFileSync(versionFilePath, content); + const packageInfo = await getPackageInfo(); + const content = formatVersionFile(packageInfo.name, version); + await writeFile(versionFilePath, content); } /** @@ -266,18 +321,19 @@ function getSuggestedDowngradeVersion( * @param dataDir - The path to the data directory * @throws {DataDirAccessError} If the directory cannot be created or accessed */ -export function initDataDir(dataDir: string): void { +export async function initDataDir(dataDir: string): Promise { // First ensure the directory exists and is accessible - ensureDataDir(dataDir); + await ensureDataDir(dataDir); - const currentVersion = parseVersion(PACKAGE_VERSION); + const packageInfo = await getPackageInfo(); + const currentVersion = parseVersion(packageInfo.version); // Read existing version file - const existingVersionInfo = readVersionFile(dataDir); + const existingVersionInfo = await readVersionFile(dataDir); if (existingVersionInfo === null) { // New data directory - write the current version - writeVersionFile(dataDir, currentVersion); + await writeVersionFile(dataDir, currentVersion); return; } @@ -292,7 +348,7 @@ export function initDataDir(dataDir: string): void { try { upgradeVersion(oldVersion, currentVersion); // Upgrade succeeded - write the new version - writeVersionFile(dataDir, currentVersion); + await writeVersionFile(dataDir, currentVersion); } catch (error: unknown) { const suggestedVersion = error instanceof DataDirVersionError ? error.suggestedVersion : undefined; @@ -308,7 +364,7 @@ export function initDataDir(dataDir: string): void { ); console.error( `[world-local] Data is not compatible with the current version. ` + - `Please downgrade to ${PACKAGE_NAME}@${downgradeTarget}` + `Please downgrade to ${packageInfo.name}@${downgradeTarget}` ); throw error; diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index d52ffc4e6..c5dbe9220 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -7,7 +7,7 @@ import { Agent } from 'undici'; import z from 'zod'; import type { Config } from './config.js'; import { resolveBaseUrl } from './config.js'; -import { PACKAGE_VERSION } from './init.js'; +import { getPackageInfo } from './init.js'; // For local queue, there is no technical limit on the message visibility lifespan, // but the environment variable can be used for testing purposes to set a max visibility limit. @@ -205,7 +205,8 @@ export function createQueue(config: Partial): Queue { }; const getDeploymentId: Queue['getDeploymentId'] = async () => { - return `dpl_local@${PACKAGE_VERSION}`; + const packageInfo = await getPackageInfo(); + return `dpl_local@${packageInfo.version}`; }; return { queue, createQueueHandler, getDeploymentId };