From cce2bcc8811753a30c166ebdda246c4c1ec29445 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Wed, 8 Oct 2025 16:38:20 -0500 Subject: [PATCH 01/40] feat(workspace): support any javascript runtime refs FP-595 --- apps/studio/src/client/vite-env.d.ts | 1 + apps/studio/src/electron-main/index.ts | 3 + .../lib/create-workspace-actor.ts | 2 + .../electron-main/lib/setup-bin-directory.ts | 130 ++++++++++++ cspell.json | 1 + packages/workspace/scripts/run-workspace.ts | 2 + packages/workspace/src/lib/runtime-config.ts | 130 ++++++++++++ packages/workspace/src/logic/spawn-runtime.ts | 187 +++++++----------- packages/workspace/src/machines/runtime.ts | 1 - .../workspace/src/machines/workspace/index.ts | 2 + .../src/test/helpers/mock-app-config.ts | 2 + packages/workspace/src/types.ts | 1 + turbo.json | 1 + 13 files changed, 347 insertions(+), 116 deletions(-) create mode 100644 apps/studio/src/electron-main/lib/setup-bin-directory.ts create mode 100644 packages/workspace/src/lib/runtime-config.ts diff --git a/apps/studio/src/client/vite-env.d.ts b/apps/studio/src/client/vite-env.d.ts index 0e9ccb487..1843ac7d4 100644 --- a/apps/studio/src/client/vite-env.d.ts +++ b/apps/studio/src/client/vite-env.d.ts @@ -33,6 +33,7 @@ declare namespace NodeJS { ELECTRON_USE_NEW_USER_FOLDER: string | undefined; FORCE_DEV_AUTO_UPDATE: string | undefined; NODE_ENV: string | undefined; + PATH: string | undefined; SIGNTOOL_PATH: string | undefined; WIN_CERT_PATH: string | undefined; WIN_GCP_KMS_KEY_VERSION: string | undefined; diff --git a/apps/studio/src/electron-main/index.ts b/apps/studio/src/electron-main/index.ts index 7f4e88255..11fd61fe4 100644 --- a/apps/studio/src/electron-main/index.ts +++ b/apps/studio/src/electron-main/index.ts @@ -20,6 +20,7 @@ import path from "node:path"; import { createWorkspaceActor } from "./lib/create-workspace-actor"; import { registerTelemetry } from "./lib/register-telemetry"; +import { setupBinDirectory } from "./lib/setup-bin-directory"; import { watchThemePreferenceAndApply } from "./lib/theme-utils"; import { initializeRPC } from "./rpc/initialize"; @@ -114,6 +115,8 @@ void app.whenReady().then(async () => { updateTitleBarOverlay(); }); + await setupBinDirectory(); + appUpdater = new StudioAppUpdater(); appUpdater.pollForUpdates(); diff --git a/apps/studio/src/electron-main/lib/create-workspace-actor.ts b/apps/studio/src/electron-main/lib/create-workspace-actor.ts index 4bf1eea1c..ea3fc7946 100644 --- a/apps/studio/src/electron-main/lib/create-workspace-actor.ts +++ b/apps/studio/src/electron-main/lib/create-workspace-actor.ts @@ -23,6 +23,7 @@ import { captureServerException } from "./capture-server-exception"; import { getFramework } from "./frameworks"; import { getAllPackageBinaryPaths } from "./link-bins"; import { getPnpmPath } from "./pnpm"; +import { getBinDirectoryPath } from "./setup-bin-directory"; const scopedLogger = logger.scope("workspace-actor"); @@ -36,6 +37,7 @@ export function createWorkspaceActor() { const actor = createActor(workspaceMachine, { input: { aiGatewayApp, + binDir: getBinDirectoryPath(), captureEvent: captureServerEvent, captureException: captureServerException, getAIProviders: () => { diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts new file mode 100644 index 000000000..bae69c63d --- /dev/null +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -0,0 +1,130 @@ +import { app } from "electron"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { logger } from "./electron-logger"; + +const BIN_DIR_NAME = "bin"; + +interface BinaryConfig { + getTargetPath: () => string; + name: string; +} + +export function getBinDirectoryPath(): string { + return path.join(app.getPath("userData"), BIN_DIR_NAME); +} + +export async function setupBinDirectory(): Promise { + const binDir = getBinDirectoryPath(); + + logger.info(`Setting up bin directory at: ${binDir}`); + + await ensureDirectoryExists(binDir); + + const binaries = getBinaryConfigs(); + + for (const binary of binaries) { + try { + const targetPath = binary.getTargetPath(); + + try { + await fs.access(targetPath); + } catch { + logger.warn(`Binary not found, skipping: ${targetPath}`); + continue; + } + + await createSymlinkOrShim(binDir, binary.name, targetPath); + } catch (error) { + logger.error(`Failed to setup binary ${binary.name}:`, error); + } + } + + logger.info(`Bin directory setup complete: ${binDir}`); + return binDir; +} + +async function createSymlinkOrShim( + binDir: string, + name: string, + targetPath: string, +): Promise { + const isWindows = process.platform === "win32"; + const symlinkPath = path.join(binDir, isWindows ? `${name}.cmd` : name); + + try { + try { + await fs.unlink(symlinkPath); + } catch { + // Ignore error + } + + if (isWindows) { + const batchContent = `@echo off\r\n"${targetPath}" %*\r\n`; + await fs.writeFile(symlinkPath, batchContent, "utf8"); + logger.info(`Created batch file: ${symlinkPath} -> ${targetPath}`); + } else { + await fs.symlink(targetPath, symlinkPath); + logger.info(`Created symlink: ${symlinkPath} -> ${targetPath}`); + } + } catch (error) { + logger.error(`Failed to create symlink/shim for ${name}:`, error); + throw error; + } +} + +async function ensureDirectoryExists(dirPath: string): Promise { + try { + await fs.mkdir(dirPath, { recursive: true }); + } catch (error) { + logger.error(`Failed to create directory ${dirPath}:`, error); + throw error; + } +} + +function getBinaryConfigs(): BinaryConfig[] { + const isWindows = process.platform === "win32"; + + return [ + { + getTargetPath: () => getNodeModulePath("pnpm", "bin", "pnpm.cjs"), + name: "pnpm", + }, + { + getTargetPath: () => getNodeModulePath("pnpm", "bin", "pnpx.cjs"), + name: "pnpx", + }, + { + getTargetPath: () => { + const basePath = getNodeModulePath("dugite", "git", "bin"); + return isWindows + ? path.join(basePath, "git.exe") + : path.join(basePath, "git"); + }, + name: "git", + }, + { + getTargetPath: () => { + const basePath = getNodeModulePath("@vscode/ripgrep", "bin"); + return isWindows + ? path.join(basePath, "rg.exe") + : path.join(basePath, "rg"); + }, + name: "rg", + }, + { + getTargetPath: getNodeBinaryPath, + name: "node", + }, + ]; +} + +function getNodeBinaryPath(): string { + return process.execPath; +} + +function getNodeModulePath(...parts: string[]): string { + const appPath = app.getAppPath(); + return path.join(appPath, "node_modules", ...parts); +} diff --git a/cspell.json b/cspell.json index dc17c9df9..a84d55c26 100644 --- a/cspell.json +++ b/cspell.json @@ -57,6 +57,7 @@ "nsis", "nums", "numstat", + "nuxt", "ollama", "oneline", "opencode", diff --git a/packages/workspace/scripts/run-workspace.ts b/packages/workspace/scripts/run-workspace.ts index 87f8b76a3..e62f0ddd4 100644 --- a/packages/workspace/scripts/run-workspace.ts +++ b/packages/workspace/scripts/run-workspace.ts @@ -43,6 +43,8 @@ const registryDir = path.resolve("../../registry"); const actor = createActor(workspaceMachine, { input: { aiGatewayApp, + // For this script, we depend on the developer's local pnpm, node, rg, etc. + binDir: path.resolve("/tmp/not-real"), captureEvent: (...args: unknown[]) => { // eslint-disable-next-line no-console console.log("captureEvent", args); diff --git a/packages/workspace/src/lib/runtime-config.ts b/packages/workspace/src/lib/runtime-config.ts new file mode 100644 index 000000000..f020a35ab --- /dev/null +++ b/packages/workspace/src/lib/runtime-config.ts @@ -0,0 +1,130 @@ +import { parseCommandString } from "execa"; +import { err, ok, type Result } from "neverthrow"; +import { type NormalizedPackageJson, readPackage } from "read-pkg"; + +import { type AppConfig } from "./app-config/types"; + +interface RuntimeConfig { + command: (options: { + appDir: string; + port: number; + }) => Promise | string[]; + detect: (appDir: string) => boolean | Promise; + envVars: (options: { port: number }) => Record; + installCommand: (appConfig: AppConfig) => string[]; +} + +function defaultInstallCommand(appConfig: AppConfig) { + return appConfig.type === "version" || appConfig.type === "sandbox" + ? // These app types are nested in the project directory, so we need + // to ignore the workspace config otherwise PNPM may not install the + // dependencies correctly + ["pnpm", "install", "--ignore-workspace"] + : ["pnpm", "install"]; +} + +async function detectJavaScriptRuntime( + appDir: string, + expectedCommand: string, +): Promise { + try { + const pkg: NormalizedPackageJson = await readPackage({ cwd: appDir }); + const script = pkg.scripts?.dev; + if (!script) { + return false; + } + + const [commandName] = parseCommandString(script); + return commandName === expectedCommand; + } catch { + return false; + } +} + +const UNKNOWN_CONFIG: RuntimeConfig = { + command: ({ port }) => ["pnpm", "run", "dev", "--port", port.toString()], + detect: (): boolean => true, + envVars: ({ port }) => ({ + PORT: port.toString(), + }), + installCommand: defaultInstallCommand, +}; + +const RUNTIME_CONFIGS: Record = { + nextjs: { + command: ({ port }) => ["pnpm", "run", "dev", "-p", port.toString()], + detect: (appDir) => detectJavaScriptRuntime(appDir, "next"), + envVars: ({ port }) => ({ + PORT: port.toString(), + }), + installCommand: defaultInstallCommand, + }, + + nuxt: { + command: ({ port }) => ["pnpm", "run", "dev", "--port", port.toString()], + detect: (appDir: string) => detectJavaScriptRuntime(appDir, "nuxt"), + envVars: ({ port }) => ({ + PORT: port.toString(), + }), + installCommand: defaultInstallCommand, + }, + + unknown: UNKNOWN_CONFIG, + + vite: { + command: ({ port }) => [ + "pnpm", + "run", + "dev", + "--port", + port.toString(), + "--strictPort", + ], + detect: (appDir: string) => detectJavaScriptRuntime(appDir, "vite"), + envVars: () => ({}), + installCommand: defaultInstallCommand, + }, +}; + +interface RuntimeDetectionError { + message: string; + scriptName?: string; +} + +export async function detectRuntimeTypeFromDirectory( + appDir: string, +): Promise> { + try { + const pkg: NormalizedPackageJson = await readPackage({ cwd: appDir }); + const scriptName = "dev"; + const script = pkg.scripts?.[scriptName]; + + if (!script) { + return err({ + message: `Script "${scriptName}" not found in package.json`, + scriptName, + }); + } + + const [commandName] = parseCommandString(script); + + for (const [runtimeType, config] of Object.entries(RUNTIME_CONFIGS)) { + if (runtimeType !== "unknown" && (await config.detect(appDir))) { + return ok(runtimeType); + } + } + + return err({ + message: `Unsupported command "${commandName ?? "missing"}" for script "${scriptName}" in package.json. Supported commands: vite, next, nuxt`, + scriptName, + }); + } catch (error) { + return err({ + message: `Failed to read package.json: ${error instanceof Error ? error.message : String(error)}`, + }); + } +} + +export function getRuntimeConfigByType(runtimeType: string): RuntimeConfig { + return RUNTIME_CONFIGS[runtimeType] ?? UNKNOWN_CONFIG; +} diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index 297c8b39b..250e5725c 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -1,7 +1,6 @@ import { envForProviders } from "@quests/ai-gateway"; -import { ExecaError, parseCommandString, type ResultPromise } from "execa"; +import { execa, ExecaError, type ResultPromise } from "execa"; import ms from "ms"; -import { type NormalizedPackageJson, readPackage } from "read-pkg"; import { type ActorRef, type ActorRefFrom, @@ -14,7 +13,10 @@ import { type AppConfig } from "../lib/app-config/types"; import { cancelableTimeout, TimeoutError } from "../lib/cancelable-timeout"; import { pathExists } from "../lib/path-exists"; import { PortManager } from "../lib/port-manager"; -import { type RunPackageJsonScript } from "../types"; +import { + detectRuntimeTypeFromDirectory, + getRuntimeConfigByType, +} from "../lib/runtime-config"; import { getWorkspaceServerURL } from "./server/url"; const BASE_RUNTIME_TIMEOUT_MS = ms("1 minute"); @@ -107,9 +109,8 @@ export const spawnRuntimeLogic = fromCallback< appConfig: AppConfig; attempt: number; parentRef: ActorRef; - runPackageJsonScript: RunPackageJsonScript; } ->(({ input: { appConfig, attempt, parentRef, runPackageJsonScript } }) => { +>(({ input: { appConfig, attempt, parentRef } }) => { const abortController = new AbortController(); const timeout = cancelableTimeout( BASE_RUNTIME_TIMEOUT_MS + attempt * RUNTIME_TIMEOUT_MULTIPLIER_MS, @@ -117,6 +118,13 @@ export const spawnRuntimeLogic = fromCallback< let port: number | undefined; + const baseEnv = { + PATH: `${appConfig.workspaceConfig.binDir}:${process.env.PATH || ""}`, + // Required for normal node processes to work + // See https://www.electronjs.org/docs/latest/api/environment-variables + ELECTRON_RUN_AS_NODE: "1", + }; + async function main() { port = await portManager.reservePort(); @@ -130,107 +138,67 @@ export const spawnRuntimeLogic = fromCallback< return; } - const installTimeout = cancelableTimeout(INSTALL_TIMEOUT_MS); - const installSignal = AbortSignal.any([ - abortController.signal, - installTimeout.controller.signal, - ]); - const installCommand = - appConfig.type === "version" || appConfig.type === "sandbox" - ? // These app types are nested in the project directory, so we need - // to ignore the workspace config otherwise PNPM may not install the - // dependencies correctly - "pnpm install --ignore-workspace" - : "pnpm install"; - - parentRef.send({ - type: "spawnRuntime.log", - value: { message: `$ ${installCommand}`, type: "normal" }, - }); - - const installResult = await appConfig.workspaceConfig.runShellCommand( - installCommand, - { - cwd: appConfig.appDir, - signal: installSignal, - }, - ); - installTimeout.cancel(); - if (installResult.isErr()) { + if (!(await pathExists(appConfig.appDir))) { parentRef.send({ - isRetryable: true, + isRetryable: false, shouldLog: true, - type: "spawnRuntime.error.install-failed", + type: "spawnRuntime.error.app-dir-does-not-exist", value: { - error: new Error(installResult.error.message, { - cause: installResult.error, - }), + error: new Error(`App directory does not exist: ${appConfig.appDir}`), }, }); return; } - const installProcessPromise = installResult.value; - sendProcessLogs(installProcessPromise, parentRef); - await installProcessPromise; - - const scriptName = "dev"; + const runtimeTypeResult = await detectRuntimeTypeFromDirectory( + appConfig.appDir, + ); - let pkg: NormalizedPackageJson; - try { - pkg = await readPackage({ cwd: appConfig.appDir }); - } catch (error) { + if (runtimeTypeResult.isErr()) { + const { message, scriptName } = runtimeTypeResult.error; parentRef.send({ isRetryable: false, shouldLog: true, - type: "spawnRuntime.error.package-json", + type: scriptName + ? "spawnRuntime.error.unsupported-script" + : "spawnRuntime.error.package-json", value: { - error: new Error("Unknown error reading package.json", { - cause: error instanceof Error ? error : new Error(String(error)), - }), + error: new Error(message), }, }); return; } - const script = pkg.scripts?.[scriptName]; - if (!script) { - parentRef.send({ - isRetryable: false, - shouldLog: true, - type: "spawnRuntime.error.package-json", - value: { - error: new Error(`No script \`${scriptName}\` found in package.json`), - }, - }); - return; - } - const [commandName] = parseCommandString(script); - if (commandName !== "vite") { - parentRef.send({ - isRetryable: false, - shouldLog: true, - type: "spawnRuntime.error.unsupported-script", - value: { - error: new Error( - `Unsupported command \`${commandName ?? "missing"}\` for script \`${scriptName}\` in package.json`, - ), - }, - }); - return; - } + const runtimeType = runtimeTypeResult.value; + const runtimeConfig = getRuntimeConfigByType(runtimeType); - if (!(await pathExists(appConfig.appDir))) { - parentRef.send({ - isRetryable: false, - shouldLog: true, - type: "spawnRuntime.error.app-dir-does-not-exist", - value: { - error: new Error(`App directory does not exist: ${appConfig.appDir}`), - }, - }); - return; - } + const installTimeout = cancelableTimeout(INSTALL_TIMEOUT_MS); + const installSignal = AbortSignal.any([ + abortController.signal, + installTimeout.controller.signal, + ]); + const installCommand = runtimeConfig.installCommand(appConfig); + + parentRef.send({ + type: "spawnRuntime.log", + value: { + message: `$ ${installCommand.join(" ")}`, + type: "normal", + }, + }); + + const installResult = execa({ + cancelSignal: installSignal, + cwd: appConfig.appDir, + env: { + ...baseEnv, + }, + // node: true, + })`${installCommand}`; + + sendProcessLogs(installResult, parentRef); + await installResult; + installTimeout.cancel(); const providerEnv = envForProviders({ providers: appConfig.workspaceConfig.getAIProviders(), @@ -242,39 +210,28 @@ export const spawnRuntimeLogic = fromCallback< timeout.controller.signal, ]); + const devServerCommand = await runtimeConfig.command({ + appDir: appConfig.appDir, + port, + }); + parentRef.send({ type: "spawnRuntime.log", - value: { message: `$ pnpm run ${script}`, type: "normal" }, + value: { message: `$ ${devServerCommand.join(" ")}`, type: "normal" }, }); timeout.start(); - const result = await runPackageJsonScript({ + const runtimeProcess = execa({ + cancelSignal: signal, cwd: appConfig.appDir, - script, - scriptOptions: { - env: { - ...providerEnv, - NO_COLOR: "1", // Disable color to avoid ANSI escape codes in logs - QUESTS_INSIDE_STUDIO: "true", // Used by apps to detect if they are running inside Studio - }, - port, + env: { + ...providerEnv, + NO_COLOR: "1", + QUESTS_INSIDE_STUDIO: "true", + ...baseEnv, }, - signal, - }); - if (result.isErr()) { - // Happens for immediate errors - parentRef.send({ - isRetryable: true, - shouldLog: false, - type: "spawnRuntime.error.unknown", - value: { - error: result.error, - }, - }); - return; - } - const processPromise = result.value; - sendProcessLogs(processPromise, parentRef); + })`${devServerCommand}`; + sendProcessLogs(runtimeProcess, parentRef); let shouldCheckServer = true; const checkServer = async () => { @@ -318,10 +275,10 @@ export const spawnRuntimeLogic = fromCallback< // Must abort check server if the process promise rejects because it // means the server is not running and we could accidentally check // another server and say it's running - processPromise.catch(() => (shouldCheckServer = false)); + runtimeProcess.catch(() => (shouldCheckServer = false)); // Ensures we catch errors from the process promise - await Promise.all([checkServer(), processPromise]); + await Promise.all([checkServer(), runtimeProcess]); } main() diff --git a/packages/workspace/src/machines/runtime.ts b/packages/workspace/src/machines/runtime.ts index 6369ff94b..7ae3adc68 100644 --- a/packages/workspace/src/machines/runtime.ts +++ b/packages/workspace/src/machines/runtime.ts @@ -324,7 +324,6 @@ export const runtimeMachine = setup({ appConfig: context.appConfig, attempt: context.retryCount, parentRef: self, - runPackageJsonScript: context.runPackageJsonScript, }, }), })), diff --git a/packages/workspace/src/machines/workspace/index.ts b/packages/workspace/src/machines/workspace/index.ts index ce9bc5d2e..5440179ab 100644 --- a/packages/workspace/src/machines/workspace/index.ts +++ b/packages/workspace/src/machines/workspace/index.ts @@ -180,6 +180,7 @@ export const workspaceMachine = setup({ events: {} as WorkspaceEvent, input: {} as { aiGatewayApp: AIGatewayApp; + binDir: string; captureEvent: CaptureEventFunction; captureException: CaptureExceptionFunction; getAIProviders: GetAIProviders; @@ -196,6 +197,7 @@ export const workspaceMachine = setup({ }).createMachine({ context: ({ input, self, spawn }) => { const workspaceConfig: WorkspaceConfig = { + binDir: AbsolutePathSchema.parse(input.binDir), captureEvent: input.captureEvent, captureException: input.captureException, getAIProviders: input.getAIProviders, diff --git a/packages/workspace/src/test/helpers/mock-app-config.ts b/packages/workspace/src/test/helpers/mock-app-config.ts index f2078d6ab..945a40024 100644 --- a/packages/workspace/src/test/helpers/mock-app-config.ts +++ b/packages/workspace/src/test/helpers/mock-app-config.ts @@ -7,6 +7,7 @@ import { type AppSubdomain } from "../../schemas/subdomains"; import { type WorkspaceConfig } from "../../types"; const MOCK_WORKSPACE_DIR = "/tmp/workspace"; +const MOCK_BIN_DIR = "/tmp/bin"; export const MOCK_WORKSPACE_DIRS = { previews: `${MOCK_WORKSPACE_DIR}/previews`, @@ -25,6 +26,7 @@ export function createMockAppConfig( return createAppConfig({ subdomain, workspaceConfig: { + binDir: AbsolutePathSchema.parse(MOCK_BIN_DIR), captureEvent: () => { // No-op }, diff --git a/packages/workspace/src/types.ts b/packages/workspace/src/types.ts index 46c9d5950..fb561eef7 100644 --- a/packages/workspace/src/types.ts +++ b/packages/workspace/src/types.ts @@ -21,6 +21,7 @@ export type RunPackageJsonScript = (options: { }) => Promise | ShellResult; export interface WorkspaceConfig { + binDir: AbsolutePath; captureEvent: CaptureEventFunction; captureException: CaptureExceptionFunction; getAIProviders: GetAIProviders; diff --git a/turbo.json b/turbo.json index eaaa7b700..cd249536e 100644 --- a/turbo.json +++ b/turbo.json @@ -13,6 +13,7 @@ "ELECTRON_USE_NEW_USER_FOLDER", "FORCE_DEV_AUTO_UPDATE", "NODE_ENV", + "PATH", "SIGNTOOL_PATH", "WIN_CERT_PATH", "WIN_GCP_KMS_KEY_VERSION", From 55028e41e90e8fd15aeee1e16ae6f87f2d3cdc97 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Wed, 8 Oct 2025 16:45:19 -0500 Subject: [PATCH 02/40] fix(studio): handle unpacked app path --- .../src/electron-main/lib/setup-bin-directory.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index bae69c63d..4bbe2c9f0 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -126,5 +126,15 @@ function getNodeBinaryPath(): string { function getNodeModulePath(...parts: string[]): string { const appPath = app.getAppPath(); - return path.join(appPath, "node_modules", ...parts); + const modulePath = path.join(appPath, "node_modules", ...parts); + + if (app.isPackaged && appPath.endsWith(".asar")) { + const unpackedPath = modulePath.replace( + /app\.asar([/\\])/, + "app.asar.unpacked$1", + ); + return unpackedPath; + } + + return modulePath; } From af5b184f7b856ea56d1896f7e9be0ffff45d3256 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Thu, 9 Oct 2025 15:23:12 -0500 Subject: [PATCH 03/40] fix(studio): ensure windows can run .cjs files with node prefix --- apps/studio/src/electron-main/lib/setup-bin-directory.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index 4bbe2c9f0..8ff5f2122 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -61,7 +61,10 @@ async function createSymlinkOrShim( } if (isWindows) { - const batchContent = `@echo off\r\n"${targetPath}" %*\r\n`; + const isCjsFile = targetPath.endsWith(".cjs"); + const batchContent = isCjsFile + ? `@echo off\r\nnode "${targetPath}" %*\r\n` + : `@echo off\r\n"${targetPath}" %*\r\n`; await fs.writeFile(symlinkPath, batchContent, "utf8"); logger.info(`Created batch file: ${symlinkPath} -> ${targetPath}`); } else { From 28f75599015d8243b802eb1cd422ba44bbabde80 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Thu, 9 Oct 2025 15:26:30 -0500 Subject: [PATCH 04/40] fix(studio): proper git folder --- apps/studio/src/electron-main/lib/setup-bin-directory.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index 8ff5f2122..ee72f2a0a 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -100,7 +100,9 @@ function getBinaryConfigs(): BinaryConfig[] { }, { getTargetPath: () => { - const basePath = getNodeModulePath("dugite", "git", "bin"); + const basePath = isWindows + ? getNodeModulePath("dugite", "git", "cmd") + : getNodeModulePath("dugite", "git", "bin"); return isWindows ? path.join(basePath, "git.exe") : path.join(basePath, "git"); From b477124c806e497efdf11fe53e850ac987b55660 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Thu, 9 Oct 2025 15:38:24 -0500 Subject: [PATCH 05/40] fix(workspace): proper PATH delimiter for windows --- packages/workspace/src/logic/spawn-runtime.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index 250e5725c..ba4833d0b 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -1,6 +1,7 @@ import { envForProviders } from "@quests/ai-gateway"; import { execa, ExecaError, type ResultPromise } from "execa"; import ms from "ms"; +import path from "node:path"; import { type ActorRef, type ActorRefFrom, @@ -119,12 +120,26 @@ export const spawnRuntimeLogic = fromCallback< let port: number | undefined; const baseEnv = { - PATH: `${appConfig.workspaceConfig.binDir}:${process.env.PATH || ""}`, + PATH: `${appConfig.workspaceConfig.binDir}${path.delimiter}${process.env.PATH || ""}`, // Required for normal node processes to work // See https://www.electronjs.org/docs/latest/api/environment-variables ELECTRON_RUN_AS_NODE: "1", }; + // FIXME: remove after done debugging + const pnpmVersionProcess = execa({ + cwd: appConfig.appDir, + env: baseEnv, + })`pnpm --version`; + sendProcessLogs(pnpmVersionProcess, parentRef); + + // FIXME: remove after done debugging + const nodeVersionProcess = execa({ + cwd: appConfig.appDir, + env: baseEnv, + })`node --version`; + sendProcessLogs(nodeVersionProcess, parentRef); + async function main() { port = await portManager.reservePort(); @@ -187,17 +202,16 @@ export const spawnRuntimeLogic = fromCallback< }, }); - const installResult = execa({ + const installProcess = execa({ cancelSignal: installSignal, cwd: appConfig.appDir, env: { ...baseEnv, }, - // node: true, })`${installCommand}`; - sendProcessLogs(installResult, parentRef); - await installResult; + sendProcessLogs(installProcess, parentRef); + await installProcess; installTimeout.cancel(); const providerEnv = envForProviders({ From 8342d7e84c83c039b66e5e07b0351079cbbc0f17 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Thu, 9 Oct 2025 15:43:57 -0500 Subject: [PATCH 06/40] feat(studio): tweaked cmd file for windows --- apps/studio/src/electron-main/lib/setup-bin-directory.ts | 4 ++-- cspell.json | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index ee72f2a0a..1cd6eda40 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -63,8 +63,8 @@ async function createSymlinkOrShim( if (isWindows) { const isCjsFile = targetPath.endsWith(".cjs"); const batchContent = isCjsFile - ? `@echo off\r\nnode "${targetPath}" %*\r\n` - : `@echo off\r\n"${targetPath}" %*\r\n`; + ? `@SETLOCAL\r\n@IF EXIST "%~dp0\\node.exe" (\r\n "%~dp0\\node.exe" "${targetPath}" %*\r\n) ELSE (\r\n @SET PATHEXT=%PATHEXT:;.JS;=;%\r\n node "${targetPath}" %*\r\n)\r\n@EXIT /b %errorlevel%\r\n` + : `@SETLOCAL\r\n@"${targetPath}" %*\r\n@EXIT /b %errorlevel%\r\n`; await fs.writeFile(symlinkPath, batchContent, "utf8"); logger.info(`Created batch file: ${symlinkPath} -> ${targetPath}`); } else { diff --git a/cspell.json b/cspell.json index a84d55c26..9cbed35a8 100644 --- a/cspell.json +++ b/cspell.json @@ -25,6 +25,7 @@ "dryrun", "dugite", "dyld", + "errorlevel", "esbenp", "esbuild", "eslintcache", @@ -45,6 +46,8 @@ "kimi", "kmscng", "konsole", + "kwallet", + "libsecret", "listify", "logprobs", "lucide", @@ -72,6 +75,7 @@ "rharkor", "rmrf", "serviceworker", + "SETLOCAL", "signtool", "softprops", "sonner", @@ -89,8 +93,6 @@ "winstaller", "workerd", "worktree", - "XAPI", - "libsecret", - "kwallet" + "XAPI" ] } From 61a9d7514f5cdce61540be5515dca59f2d4f3a57 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Thu, 9 Oct 2025 15:49:06 -0500 Subject: [PATCH 07/40] fix(studio): back to simpler cmd --- apps/studio/src/electron-main/lib/setup-bin-directory.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index 1cd6eda40..ee72f2a0a 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -63,8 +63,8 @@ async function createSymlinkOrShim( if (isWindows) { const isCjsFile = targetPath.endsWith(".cjs"); const batchContent = isCjsFile - ? `@SETLOCAL\r\n@IF EXIST "%~dp0\\node.exe" (\r\n "%~dp0\\node.exe" "${targetPath}" %*\r\n) ELSE (\r\n @SET PATHEXT=%PATHEXT:;.JS;=;%\r\n node "${targetPath}" %*\r\n)\r\n@EXIT /b %errorlevel%\r\n` - : `@SETLOCAL\r\n@"${targetPath}" %*\r\n@EXIT /b %errorlevel%\r\n`; + ? `@echo off\r\nnode "${targetPath}" %*\r\n` + : `@echo off\r\n"${targetPath}" %*\r\n`; await fs.writeFile(symlinkPath, batchContent, "utf8"); logger.info(`Created batch file: ${symlinkPath} -> ${targetPath}`); } else { From 9fbf461f8d6144736fc836b5164f6f6908988b03 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Thu, 9 Oct 2025 15:54:12 -0500 Subject: [PATCH 08/40] fix(studio): try to avoid visible cmd.exe --- apps/studio/src/electron-main/lib/setup-bin-directory.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index ee72f2a0a..65d5a9cbe 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -63,8 +63,8 @@ async function createSymlinkOrShim( if (isWindows) { const isCjsFile = targetPath.endsWith(".cjs"); const batchContent = isCjsFile - ? `@echo off\r\nnode "${targetPath}" %*\r\n` - : `@echo off\r\n"${targetPath}" %*\r\n`; + ? `@ECHO OFF\r\n@SETLOCAL\r\n@IF EXIST "%~dp0\\node.exe" (\r\n "%~dp0\\node.exe" "${targetPath}" %*\r\n) ELSE (\r\n @SET PATHEXT=%PATHEXT:;.JS;=;%\r\n node "${targetPath}" %*\r\n)\r\n@EXIT /b %errorlevel%\r\n` + : `@ECHO OFF\r\n@SETLOCAL\r\n@"${targetPath}" %*\r\n@EXIT /b %errorlevel%\r\n`; await fs.writeFile(symlinkPath, batchContent, "utf8"); logger.info(`Created batch file: ${symlinkPath} -> ${targetPath}`); } else { From 97ced048dd5128ab94434a0e81f286f4c7aaeb3c Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Thu, 9 Oct 2025 15:58:47 -0500 Subject: [PATCH 09/40] fix(workspace): attempt to hide cmd.exe --- cspell.json | 1 + packages/workspace/src/logic/spawn-runtime.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/cspell.json b/cspell.json index 9cbed35a8..56b2cb7c0 100644 --- a/cspell.json +++ b/cspell.json @@ -66,6 +66,7 @@ "opencode", "openrouter", "orpc", + "PATHEXT", "posthog", "pwsh", "qwen", diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index ba4833d0b..f96895929 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -130,6 +130,7 @@ export const spawnRuntimeLogic = fromCallback< const pnpmVersionProcess = execa({ cwd: appConfig.appDir, env: baseEnv, + windowsHide: true, })`pnpm --version`; sendProcessLogs(pnpmVersionProcess, parentRef); @@ -137,6 +138,7 @@ export const spawnRuntimeLogic = fromCallback< const nodeVersionProcess = execa({ cwd: appConfig.appDir, env: baseEnv, + windowsHide: true, })`node --version`; sendProcessLogs(nodeVersionProcess, parentRef); @@ -208,6 +210,7 @@ export const spawnRuntimeLogic = fromCallback< env: { ...baseEnv, }, + windowsHide: true, })`${installCommand}`; sendProcessLogs(installProcess, parentRef); @@ -244,6 +247,7 @@ export const spawnRuntimeLogic = fromCallback< QUESTS_INSIDE_STUDIO: "true", ...baseEnv, }, + windowsHide: true, })`${devServerCommand}`; sendProcessLogs(runtimeProcess, parentRef); From ccb6dd1f534ff74bb0357efadabd303ed2edaef7 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Thu, 9 Oct 2025 16:17:24 -0500 Subject: [PATCH 10/40] fix(workspace): lower verbosity logs --- packages/workspace/src/lib/runtime-config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/workspace/src/lib/runtime-config.ts b/packages/workspace/src/lib/runtime-config.ts index f020a35ab..0f3235a77 100644 --- a/packages/workspace/src/lib/runtime-config.ts +++ b/packages/workspace/src/lib/runtime-config.ts @@ -79,6 +79,11 @@ const RUNTIME_CONFIGS: Record = { "--port", port.toString(), "--strictPort", + "--clearScreen", + "false", + // Avoids logging confusing localhost and port info + "--logLevel", + "warn", ], detect: (appDir: string) => detectJavaScriptRuntime(appDir, "vite"), envVars: () => ({}), From 145c84f76c10a53f3b0f29dbdd7f16755239c6a7 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Thu, 9 Oct 2025 16:22:14 -0500 Subject: [PATCH 11/40] feat(studio): new shim files powered by PS1 --- .../electron-main/lib/setup-bin-directory.ts | 86 ++++++++++++++++--- 1 file changed, 73 insertions(+), 13 deletions(-) diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index 65d5a9cbe..f2726671d 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -51,23 +51,17 @@ async function createSymlinkOrShim( targetPath: string, ): Promise { const isWindows = process.platform === "win32"; - const symlinkPath = path.join(binDir, isWindows ? `${name}.cmd` : name); try { - try { - await fs.unlink(symlinkPath); - } catch { - // Ignore error - } - if (isWindows) { - const isCjsFile = targetPath.endsWith(".cjs"); - const batchContent = isCjsFile - ? `@ECHO OFF\r\n@SETLOCAL\r\n@IF EXIST "%~dp0\\node.exe" (\r\n "%~dp0\\node.exe" "${targetPath}" %*\r\n) ELSE (\r\n @SET PATHEXT=%PATHEXT:;.JS;=;%\r\n node "${targetPath}" %*\r\n)\r\n@EXIT /b %errorlevel%\r\n` - : `@ECHO OFF\r\n@SETLOCAL\r\n@"${targetPath}" %*\r\n@EXIT /b %errorlevel%\r\n`; - await fs.writeFile(symlinkPath, batchContent, "utf8"); - logger.info(`Created batch file: ${symlinkPath} -> ${targetPath}`); + await createWindowsShims(binDir, name, targetPath); } else { + const symlinkPath = path.join(binDir, name); + try { + await fs.unlink(symlinkPath); + } catch { + // Ignore error + } await fs.symlink(targetPath, symlinkPath); logger.info(`Created symlink: ${symlinkPath} -> ${targetPath}`); } @@ -77,6 +71,72 @@ async function createSymlinkOrShim( } } +async function createWindowsShims( + binDir: string, + name: string, + targetPath: string, +): Promise { + const isCjsFile = targetPath.endsWith(".cjs"); + const isJsFile = targetPath.endsWith(".js") || isCjsFile; + + const cmdPath = path.join(binDir, `${name}.cmd`); + const ps1Path = path.join(binDir, `${name}.ps1`); + + try { + await fs.unlink(cmdPath); + } catch { + // Ignore error + } + try { + await fs.unlink(ps1Path); + } catch { + // Ignore error + } + + if (isJsFile) { + const cmdContent = `@IF EXIST "%~dp0\\node.exe" (\r\n "%~dp0\\node.exe" "${targetPath}" %*\r\n) ELSE (\r\n @SET PATHEXT=%PATHEXT:;.JS;=;%\r\n node "${targetPath}" %*\r\n)\r\n`; + + const ps1Content = `#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + & "$basedir/node$exe" "${targetPath}" $args + $ret=$LASTEXITCODE +} else { + & "node$exe" "${targetPath}" $args + $ret=$LASTEXITCODE +} +exit $ret +`; + + await fs.writeFile(cmdPath, cmdContent, "utf8"); + await fs.writeFile(ps1Path, ps1Content, "utf8"); + logger.info( + `Created PowerShell shim: ${ps1Path} and CMD fallback: ${cmdPath} -> ${targetPath}`, + ); + } else { + const cmdContent = `@"${targetPath}" %*\r\n`; + + const ps1Content = `#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +& "${targetPath}" $args +exit $LASTEXITCODE +`; + + await fs.writeFile(cmdPath, cmdContent, "utf8"); + await fs.writeFile(ps1Path, ps1Content, "utf8"); + logger.info( + `Created PowerShell shim: ${ps1Path} and CMD fallback: ${cmdPath} -> ${targetPath}`, + ); + } +} + async function ensureDirectoryExists(dirPath: string): Promise { try { await fs.mkdir(dirPath, { recursive: true }); From 36f0577faf5063ab1b70e74b28619ad27a5206b1 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Thu, 9 Oct 2025 16:22:27 -0500 Subject: [PATCH 12/40] chore(workspace): remove fixme in favor of todo and windows hide --- packages/workspace/src/logic/spawn-runtime.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index f96895929..17324d4d4 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -126,19 +126,17 @@ export const spawnRuntimeLogic = fromCallback< ELECTRON_RUN_AS_NODE: "1", }; - // FIXME: remove after done debugging + // TODO(FP-595): remove after done debugging const pnpmVersionProcess = execa({ cwd: appConfig.appDir, env: baseEnv, - windowsHide: true, })`pnpm --version`; sendProcessLogs(pnpmVersionProcess, parentRef); - // FIXME: remove after done debugging + // TODO(FP-595): remove after done debugging const nodeVersionProcess = execa({ cwd: appConfig.appDir, env: baseEnv, - windowsHide: true, })`node --version`; sendProcessLogs(nodeVersionProcess, parentRef); @@ -210,7 +208,6 @@ export const spawnRuntimeLogic = fromCallback< env: { ...baseEnv, }, - windowsHide: true, })`${installCommand}`; sendProcessLogs(installProcess, parentRef); @@ -247,7 +244,6 @@ export const spawnRuntimeLogic = fromCallback< QUESTS_INSIDE_STUDIO: "true", ...baseEnv, }, - windowsHide: true, })`${devServerCommand}`; sendProcessLogs(runtimeProcess, parentRef); From dd413991757d8f8f248a72b021b8f1858638877e Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Thu, 9 Oct 2025 16:33:51 -0500 Subject: [PATCH 13/40] wip: try .cmd only with hide again --- .../electron-main/lib/setup-bin-directory.ts | 41 +------------------ packages/workspace/src/logic/spawn-runtime.ts | 4 ++ 2 files changed, 6 insertions(+), 39 deletions(-) diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index f2726671d..9606fd026 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -80,60 +80,23 @@ async function createWindowsShims( const isJsFile = targetPath.endsWith(".js") || isCjsFile; const cmdPath = path.join(binDir, `${name}.cmd`); - const ps1Path = path.join(binDir, `${name}.ps1`); try { await fs.unlink(cmdPath); } catch { // Ignore error } - try { - await fs.unlink(ps1Path); - } catch { - // Ignore error - } if (isJsFile) { const cmdContent = `@IF EXIST "%~dp0\\node.exe" (\r\n "%~dp0\\node.exe" "${targetPath}" %*\r\n) ELSE (\r\n @SET PATHEXT=%PATHEXT:;.JS;=;%\r\n node "${targetPath}" %*\r\n)\r\n`; - const ps1Content = `#!/usr/bin/env pwsh -$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent - -$exe="" -if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { - $exe=".exe" -} -$ret=0 -if (Test-Path "$basedir/node$exe") { - & "$basedir/node$exe" "${targetPath}" $args - $ret=$LASTEXITCODE -} else { - & "node$exe" "${targetPath}" $args - $ret=$LASTEXITCODE -} -exit $ret -`; - await fs.writeFile(cmdPath, cmdContent, "utf8"); - await fs.writeFile(ps1Path, ps1Content, "utf8"); - logger.info( - `Created PowerShell shim: ${ps1Path} and CMD fallback: ${cmdPath} -> ${targetPath}`, - ); + logger.info(`Created CMD shim: ${cmdPath} -> ${targetPath}`); } else { const cmdContent = `@"${targetPath}" %*\r\n`; - const ps1Content = `#!/usr/bin/env pwsh -$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent - -& "${targetPath}" $args -exit $LASTEXITCODE -`; - await fs.writeFile(cmdPath, cmdContent, "utf8"); - await fs.writeFile(ps1Path, ps1Content, "utf8"); - logger.info( - `Created PowerShell shim: ${ps1Path} and CMD fallback: ${cmdPath} -> ${targetPath}`, - ); + logger.info(`Created CMD shim: ${cmdPath} -> ${targetPath}`); } } diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index 17324d4d4..cd3469158 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -130,6 +130,7 @@ export const spawnRuntimeLogic = fromCallback< const pnpmVersionProcess = execa({ cwd: appConfig.appDir, env: baseEnv, + windowsHide: true, })`pnpm --version`; sendProcessLogs(pnpmVersionProcess, parentRef); @@ -137,6 +138,7 @@ export const spawnRuntimeLogic = fromCallback< const nodeVersionProcess = execa({ cwd: appConfig.appDir, env: baseEnv, + windowsHide: true, })`node --version`; sendProcessLogs(nodeVersionProcess, parentRef); @@ -208,6 +210,7 @@ export const spawnRuntimeLogic = fromCallback< env: { ...baseEnv, }, + windowsHide: true, })`${installCommand}`; sendProcessLogs(installProcess, parentRef); @@ -244,6 +247,7 @@ export const spawnRuntimeLogic = fromCallback< QUESTS_INSIDE_STUDIO: "true", ...baseEnv, }, + windowsHide: true, })`${devServerCommand}`; sendProcessLogs(runtimeProcess, parentRef); From 31a25bffd7b734e7a8a3b2f05423cf0d16f7a6d0 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Thu, 9 Oct 2025 17:02:48 -0500 Subject: [PATCH 14/40] wip: try cmd-shim --- apps/studio/package.json | 1 + .../electron-main/lib/setup-bin-directory.ts | 40 ++++++++----------- pnpm-lock.yaml | 25 ++++++++++-- 3 files changed, 40 insertions(+), 26 deletions(-) diff --git a/apps/studio/package.json b/apps/studio/package.json index 3f4d456e5..289eaabdf 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -83,6 +83,7 @@ "@tanstack/react-router-devtools": "^1.131.28", "@types/ws": "^8.18.1", "@vscode/ripgrep": "^1.15.13", + "@zkochan/cmd-shim": "^7.0.0", "arctic": "^3.6.0", "better-auth": "catalog:", "class-variance-authority": "^0.7.1", diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index 9606fd026..18a1f215b 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -1,3 +1,4 @@ +import cmdShim from "@zkochan/cmd-shim"; import { app } from "electron"; import fs from "node:fs/promises"; import path from "node:path"; @@ -20,6 +21,12 @@ export async function setupBinDirectory(): Promise { logger.info(`Setting up bin directory at: ${binDir}`); + try { + await fs.rm(binDir, { force: true, recursive: true }); + } catch { + /* empty */ + } + await ensureDirectoryExists(binDir); const binaries = getBinaryConfigs(); @@ -57,11 +64,6 @@ async function createSymlinkOrShim( await createWindowsShims(binDir, name, targetPath); } else { const symlinkPath = path.join(binDir, name); - try { - await fs.unlink(symlinkPath); - } catch { - // Ignore error - } await fs.symlink(targetPath, symlinkPath); logger.info(`Created symlink: ${symlinkPath} -> ${targetPath}`); } @@ -77,23 +79,19 @@ async function createWindowsShims( targetPath: string, ): Promise { const isCjsFile = targetPath.endsWith(".cjs"); - const isJsFile = targetPath.endsWith(".js") || isCjsFile; - - const cmdPath = path.join(binDir, `${name}.cmd`); - try { - await fs.unlink(cmdPath); - } catch { - // Ignore error - } - - if (isJsFile) { - const cmdContent = `@IF EXIST "%~dp0\\node.exe" (\r\n "%~dp0\\node.exe" "${targetPath}" %*\r\n) ELSE (\r\n @SET PATHEXT=%PATHEXT:;.JS;=;%\r\n node "${targetPath}" %*\r\n)\r\n`; + const shimPath = path.join(binDir, name); - await fs.writeFile(cmdPath, cmdContent, "utf8"); - logger.info(`Created CMD shim: ${cmdPath} -> ${targetPath}`); + if (isCjsFile) { + await cmdShim(targetPath, shimPath, { + createCmdFile: true, + createPwshFile: false, + nodeExecPath: getNodeBinaryPath(), + }); + logger.info(`Created cmd-shim: ${shimPath} -> ${targetPath}`); } else { - const cmdContent = `@"${targetPath}" %*\r\n`; + const cmdPath = path.join(binDir, `${name}.cmd`); + const cmdContent = `@echo off\r\n"${targetPath}" %*\r\n`; await fs.writeFile(cmdPath, cmdContent, "utf8"); logger.info(`Created CMD shim: ${cmdPath} -> ${targetPath}`); @@ -141,10 +139,6 @@ function getBinaryConfigs(): BinaryConfig[] { }, name: "rg", }, - { - getTargetPath: getNodeBinaryPath, - name: "node", - }, ]; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f7919309..12e2532ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,6 +225,9 @@ importers: '@vscode/ripgrep': specifier: ^1.15.13 version: 1.15.13 + '@zkochan/cmd-shim': + specifier: ^7.0.0 + version: 7.0.0 arctic: specifier: ^3.6.0 version: 3.6.0 @@ -3405,6 +3408,10 @@ packages: resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==} engines: {node: '>=10.0.0'} + '@zkochan/cmd-shim@7.0.0': + resolution: {integrity: sha512-E5mgrRS8Kk80n19Xxmrx5qO9UG03FyZd8Me5gxYi++VPZsOv8+OsclA+0Fth4KTDCrQ/FkJryNFKJ6/642lo4g==} + engines: {node: '>=18.12'} + abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -3894,6 +3901,10 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + cmd-extension@1.0.2: + resolution: {integrity: sha512-iWDjmP8kvsMdBmLTHxFaqXikO8EdFRDfim7k6vUHglY/2xJ5jLrPsnQGijdfp4U+sr/BeecG0wKm02dSIAeQ1g==} + engines: {node: '>=10'} + cmdk@1.1.1: resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} peerDependencies: @@ -10741,14 +10752,14 @@ snapshots: msw: 2.7.5(@types/node@22.16.0)(typescript@5.9.2) vite: 7.1.3(@types/node@22.16.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.0) - '@vitest/mocker@3.2.4(msw@2.7.5(@types/node@24.0.14)(typescript@5.9.2))(vite@7.1.3(@types/node@22.16.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.0))': + '@vitest/mocker@3.2.4(msw@2.7.5(@types/node@24.0.14)(typescript@5.9.2))(vite@7.1.3(@types/node@24.0.14)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: msw: 2.7.5(@types/node@24.0.14)(typescript@5.9.2) - vite: 7.1.3(@types/node@22.16.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.0) + vite: 7.1.3(@types/node@24.0.14)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.0) optional: true '@vitest/pretty-format@3.2.4': @@ -10787,6 +10798,12 @@ snapshots: '@xmldom/xmldom@0.8.10': {} + '@zkochan/cmd-shim@7.0.0': + dependencies: + cmd-extension: 1.0.2 + graceful-fs: 4.2.11 + is-windows: 1.0.2 + abbrev@1.1.1: {} acorn-jsx@5.3.2(acorn@8.15.0): @@ -11446,6 +11463,8 @@ snapshots: cluster-key-slot@1.1.2: optional: true + cmd-extension@1.0.2: {} + cmdk@1.1.1(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) @@ -16150,7 +16169,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.7.5(@types/node@24.0.14)(typescript@5.9.2))(vite@7.1.3(@types/node@22.16.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.0)) + '@vitest/mocker': 3.2.4(msw@2.7.5(@types/node@24.0.14)(typescript@5.9.2))(vite@7.1.3(@types/node@24.0.14)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 From ecc4d60ebaf9b3743c372c5f8a9b4e874e811bc5 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Fri, 10 Oct 2025 14:33:53 -0500 Subject: [PATCH 15/40] wip: extract bin locations and use links on windows --- .../studio/src/electron-main/lib/read-shim.ts | 64 ++++++++ .../electron-main/lib/setup-bin-directory.ts | 140 +++++++++++------- cspell.json | 3 +- packages/workspace/src/logic/spawn-runtime.ts | 8 + pnpm-lock.yaml | 6 +- 5 files changed, 164 insertions(+), 57 deletions(-) create mode 100644 apps/studio/src/electron-main/lib/read-shim.ts diff --git a/apps/studio/src/electron-main/lib/read-shim.ts b/apps/studio/src/electron-main/lib/read-shim.ts new file mode 100644 index 000000000..34289f8ed --- /dev/null +++ b/apps/studio/src/electron-main/lib/read-shim.ts @@ -0,0 +1,64 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +const SHEBANG = "#!/bin/sh"; + +export async function readShim(shimPath: string): Promise { + const isWindows = process.platform === "win32"; + const targetPath = isWindows ? `${shimPath}.cmd` : shimPath; + + try { + const content = await fs.readFile(targetPath, "utf8"); + + if (isWindows) { + return readWindowsShim(content, shimPath); + } + return readPosixShim(content, shimPath); + } catch { + return null; + } +} + +function readPosixShim(content: string, shimPath: string): null | string { + if (!content.startsWith(SHEBANG)) { + return null; + } + + const execPattern = + /exec\s+(?:node|"\$basedir\/node")\s+"?(\$basedir\/[^"\s]+|[^"\s]+)"?/; + const match = execPattern.exec(content); + + if (!match?.[1]) { + return null; + } + + let targetPath = match[1]; + targetPath = targetPath.replaceAll("$basedir/", ""); + + return resolveShimTarget(shimPath, targetPath); +} + +function readWindowsShim(content: string, shimPath: string): null | string { + const ifExistPattern = /@IF EXIST[^(]*\([^"]*"([^"]+)"/; + const elsePattern = /ELSE[^(]*\([^"]*node[^"]*"([^"]+)"/; + + let match = ifExistPattern.exec(content); + if (!match?.[1]) { + match = elsePattern.exec(content); + } + + if (!match?.[1]) { + return null; + } + + let targetPath = match[1]; + targetPath = targetPath.replaceAll("%~dp0\\", "").replaceAll("%~dp0", ""); + + return resolveShimTarget(`${shimPath}.cmd`, targetPath); +} + +function resolveShimTarget(shimPath: string, targetPath: string): string { + const shimDir = path.dirname(shimPath); + const resolvedPath = path.resolve(shimDir, targetPath); + return resolvedPath; +} diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index 18a1f215b..3f3c54619 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -4,12 +4,14 @@ import fs from "node:fs/promises"; import path from "node:path"; import { logger } from "./electron-logger"; +import { readShim } from "./read-shim"; const BIN_DIR_NAME = "bin"; interface BinaryConfig { getTargetPath: () => string; name: string; + type: "direct" | "node-modules-bin"; } export function getBinDirectoryPath(): string { @@ -21,13 +23,10 @@ export async function setupBinDirectory(): Promise { logger.info(`Setting up bin directory at: ${binDir}`); - try { - await fs.rm(binDir, { force: true, recursive: true }); - } catch { - /* empty */ - } - await ensureDirectoryExists(binDir); + await cleanBinDirectory(binDir); + + await setupNodeLink(binDir); const binaries = getBinaryConfigs(); @@ -42,7 +41,9 @@ export async function setupBinDirectory(): Promise { continue; } - await createSymlinkOrShim(binDir, binary.name, targetPath); + await (binary.type === "node-modules-bin" + ? linkFromNodeModulesBin(binDir, binary.name, targetPath) + : linkDirect(binDir, binary.name, targetPath)); } catch (error) { logger.error(`Failed to setup binary ${binary.name}:`, error); } @@ -52,49 +53,22 @@ export async function setupBinDirectory(): Promise { return binDir; } -async function createSymlinkOrShim( - binDir: string, - name: string, - targetPath: string, -): Promise { - const isWindows = process.platform === "win32"; - +async function cleanBinDirectory(binDir: string): Promise { try { - if (isWindows) { - await createWindowsShims(binDir, name, targetPath); - } else { - const symlinkPath = path.join(binDir, name); - await fs.symlink(targetPath, symlinkPath); - logger.info(`Created symlink: ${symlinkPath} -> ${targetPath}`); - } - } catch (error) { - logger.error(`Failed to create symlink/shim for ${name}:`, error); - throw error; - } -} - -async function createWindowsShims( - binDir: string, - name: string, - targetPath: string, -): Promise { - const isCjsFile = targetPath.endsWith(".cjs"); - - const shimPath = path.join(binDir, name); + const entries = await fs.readdir(binDir); - if (isCjsFile) { - await cmdShim(targetPath, shimPath, { - createCmdFile: true, - createPwshFile: false, - nodeExecPath: getNodeBinaryPath(), - }); - logger.info(`Created cmd-shim: ${shimPath} -> ${targetPath}`); - } else { - const cmdPath = path.join(binDir, `${name}.cmd`); - const cmdContent = `@echo off\r\n"${targetPath}" %*\r\n`; + for (const entry of entries) { + const entryPath = path.join(binDir, entry); + try { + await fs.rm(entryPath, { force: true, recursive: true }); + } catch (error) { + logger.warn(`Failed to remove ${entryPath}:`, error); + } + } - await fs.writeFile(cmdPath, cmdContent, "utf8"); - logger.info(`Created CMD shim: ${cmdPath} -> ${targetPath}`); + logger.info(`Cleaned bin directory: ${binDir}`); + } catch { + logger.info(`Bin directory does not exist yet, will be created: ${binDir}`); } } @@ -112,12 +86,14 @@ function getBinaryConfigs(): BinaryConfig[] { return [ { - getTargetPath: () => getNodeModulePath("pnpm", "bin", "pnpm.cjs"), + getTargetPath: () => getNodeModulePath(".bin"), name: "pnpm", + type: "node-modules-bin", }, { - getTargetPath: () => getNodeModulePath("pnpm", "bin", "pnpx.cjs"), + getTargetPath: () => getNodeModulePath(".bin"), name: "pnpx", + type: "node-modules-bin", }, { getTargetPath: () => { @@ -129,6 +105,7 @@ function getBinaryConfigs(): BinaryConfig[] { : path.join(basePath, "git"); }, name: "git", + type: "direct", }, { getTargetPath: () => { @@ -138,14 +115,11 @@ function getBinaryConfigs(): BinaryConfig[] { : path.join(basePath, "rg"); }, name: "rg", + type: "direct", }, ]; } -function getNodeBinaryPath(): string { - return process.execPath; -} - function getNodeModulePath(...parts: string[]): string { const appPath = app.getAppPath(); const modulePath = path.join(appPath, "node_modules", ...parts); @@ -160,3 +134,63 @@ function getNodeModulePath(...parts: string[]): string { return modulePath; } + +async function linkDirect( + binDir: string, + name: string, + targetPath: string, +): Promise { + const isWindows = process.platform === "win32"; + + if (isWindows && targetPath.endsWith(".exe")) { + const linkPath = path.join(binDir, `${name}.exe`); + await fs.link(targetPath, linkPath); + logger.info(`Created hard link: ${linkPath} -> ${targetPath}`); + } else { + const linkPath = path.join(binDir, name); + await fs.symlink(targetPath, linkPath); + logger.info(`Created symlink: ${linkPath} -> ${targetPath}`); + } +} + +async function linkFromNodeModulesBin( + binDir: string, + name: string, + nodeModulesBinPath: string, +): Promise { + const shimPath = path.join(nodeModulesBinPath, name); + const targetCjsPath = await readShim(shimPath); + + if (!targetCjsPath) { + logger.error(`Failed to read shim: ${shimPath}`); + throw new Error(`Failed to read shim: ${shimPath}`); + } + + const outputPath = path.join(binDir, name); + + await cmdShim(targetCjsPath, outputPath, { + createCmdFile: true, + createPwshFile: false, + }); + + logger.info(`Created shim: ${outputPath} -> ${targetCjsPath}`); +} + +async function setupNodeLink(binDir: string): Promise { + const isWindows = process.platform === "win32"; + const nodeExePath = process.execPath; + const linkPath = path.join(binDir, isWindows ? "node.exe" : "node"); + + try { + if (isWindows) { + await fs.link(nodeExePath, linkPath); + logger.info(`Created node.exe hard link: ${linkPath} -> ${nodeExePath}`); + } else { + await fs.symlink(nodeExePath, linkPath); + logger.info(`Created node symlink: ${linkPath} -> ${nodeExePath}`); + } + } catch (error) { + logger.error("Failed to create node link:", error); + throw error; + } +} diff --git a/cspell.json b/cspell.json index 56b2cb7c0..1ff997de1 100644 --- a/cspell.json +++ b/cspell.json @@ -94,6 +94,7 @@ "winstaller", "workerd", "worktree", - "XAPI" + "XAPI", + "zkochan" ] } diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index cd3469158..0f33d3dc8 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -142,6 +142,14 @@ export const spawnRuntimeLogic = fromCallback< })`node --version`; sendProcessLogs(nodeVersionProcess, parentRef); + // TODO(FP-595): remove after done debugging + const whichPnpmProcess = execa({ + cwd: appConfig.appDir, + env: baseEnv, + windowsHide: true, + })`which pnpm`; + sendProcessLogs(whichPnpmProcess, parentRef); + async function main() { port = await portManager.reservePort(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12e2532ed..5f7b6f6e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10752,14 +10752,14 @@ snapshots: msw: 2.7.5(@types/node@22.16.0)(typescript@5.9.2) vite: 7.1.3(@types/node@22.16.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.0) - '@vitest/mocker@3.2.4(msw@2.7.5(@types/node@24.0.14)(typescript@5.9.2))(vite@7.1.3(@types/node@24.0.14)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.0))': + '@vitest/mocker@3.2.4(msw@2.7.5(@types/node@24.0.14)(typescript@5.9.2))(vite@7.1.3(@types/node@22.16.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: msw: 2.7.5(@types/node@24.0.14)(typescript@5.9.2) - vite: 7.1.3(@types/node@24.0.14)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.0) + vite: 7.1.3(@types/node@22.16.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.0) optional: true '@vitest/pretty-format@3.2.4': @@ -16169,7 +16169,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.7.5(@types/node@24.0.14)(typescript@5.9.2))(vite@7.1.3(@types/node@24.0.14)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.0)) + '@vitest/mocker': 3.2.4(msw@2.7.5(@types/node@24.0.14)(typescript@5.9.2))(vite@7.1.3(@types/node@22.16.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 From d5b1e2a2cfe83d24cb9f9f68cac87d60b1a0b985 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Fri, 10 Oct 2025 14:50:10 -0500 Subject: [PATCH 16/40] wip: back to shim for node --- .../electron-main/lib/setup-bin-directory.ts | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index 3f3c54619..4fbc5c0ed 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -72,6 +72,20 @@ async function cleanBinDirectory(binDir: string): Promise { } } +async function createNodeShim( + binDir: string, + nodeExePath: string, +): Promise { + const shimCmdPath = path.join(binDir, "node.cmd"); + + const shimContent = `@ECHO OFF +SETLOCAL +"${nodeExePath}" %* +`; + + await fs.writeFile(shimCmdPath, shimContent, "utf8"); +} + async function ensureDirectoryExists(dirPath: string): Promise { try { await fs.mkdir(dirPath, { recursive: true }); @@ -142,10 +156,13 @@ async function linkDirect( ): Promise { const isWindows = process.platform === "win32"; - if (isWindows && targetPath.endsWith(".exe")) { - const linkPath = path.join(binDir, `${name}.exe`); - await fs.link(targetPath, linkPath); - logger.info(`Created hard link: ${linkPath} -> ${targetPath}`); + if (isWindows) { + const outputPath = path.join(binDir, name); + await cmdShim(targetPath, outputPath, { + createCmdFile: true, + createPwshFile: false, + }); + logger.info(`Created shim: ${outputPath} -> ${targetPath}`); } else { const linkPath = path.join(binDir, name); await fs.symlink(targetPath, linkPath); @@ -183,8 +200,8 @@ async function setupNodeLink(binDir: string): Promise { try { if (isWindows) { - await fs.link(nodeExePath, linkPath); - logger.info(`Created node.exe hard link: ${linkPath} -> ${nodeExePath}`); + await createNodeShim(binDir, nodeExePath); + logger.info(`Created node shim: ${binDir} -> ${nodeExePath}`); } else { await fs.symlink(nodeExePath, linkPath); logger.info(`Created node symlink: ${linkPath} -> ${nodeExePath}`); From ea671d24a6b694291e018814538397a454987273 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Fri, 10 Oct 2025 14:56:39 -0500 Subject: [PATCH 17/40] wip: fix regex for windows --- apps/studio/src/electron-main/lib/read-shim.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/studio/src/electron-main/lib/read-shim.ts b/apps/studio/src/electron-main/lib/read-shim.ts index 34289f8ed..8c653c001 100644 --- a/apps/studio/src/electron-main/lib/read-shim.ts +++ b/apps/studio/src/electron-main/lib/read-shim.ts @@ -39,8 +39,8 @@ function readPosixShim(content: string, shimPath: string): null | string { } function readWindowsShim(content: string, shimPath: string): null | string { - const ifExistPattern = /@IF EXIST[^(]*\([^"]*"([^"]+)"/; - const elsePattern = /ELSE[^(]*\([^"]*node[^"]*"([^"]+)"/; + const ifExistPattern = /@IF EXIST[^"]*"[^"]*node\.exe"[^"]*"([^"]+)"/; + const elsePattern = /node\s+"([^"]+)"/; let match = ifExistPattern.exec(content); if (!match?.[1]) { From 5497e07446a1d8fa87a6cafa9ddf3c5aa6409919 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Fri, 10 Oct 2025 15:11:48 -0500 Subject: [PATCH 18/40] wip: better shim extraction and correct absolute path with tests --- .../src/electron-main/lib/read-shim.test.ts | 62 +++++++++++++++++++ .../studio/src/electron-main/lib/read-shim.ts | 59 +++++++++--------- 2 files changed, 93 insertions(+), 28 deletions(-) create mode 100644 apps/studio/src/electron-main/lib/read-shim.test.ts diff --git a/apps/studio/src/electron-main/lib/read-shim.test.ts b/apps/studio/src/electron-main/lib/read-shim.test.ts new file mode 100644 index 000000000..45b55fd9c --- /dev/null +++ b/apps/studio/src/electron-main/lib/read-shim.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { readWindowsShim, resolveShimTarget } from "./read-shim"; + +const createWindowsShim = (targetPath: string) => { + return `@SETLOCAL +@IF NOT DEFINED NODE_PATH ( + @SET "NODE_PATH=C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules\\pnpm\\bin\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules\\pnpm\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\node_modules" +) ELSE ( + @SET "NODE_PATH=C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules\\pnpm\\bin\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules\\pnpm\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\node_modules;%NODE_PATH%" +) +@IF EXIST "%~dp0\\node.exe" ( + "%~dp0\\node.exe" "%~dp0\\${targetPath}" %* +) ELSE ( + @SET PATHEXT=%PATHEXT:;.JS;=;% + node "%~dp0\\${targetPath}" %* +)`; +}; + +describe("readWindowsShim", () => { + it("should extract the relative target path from a Windows pnpm shim with .cjs", () => { + const shimContent = createWindowsShim("..\\pnpm\\bin\\pnpm.cjs"); + const result = readWindowsShim(shimContent); + + expect(result).toBe("..\\pnpm\\bin\\pnpm.cjs"); + }); + + it("should extract path with .js extension", () => { + const shimContent = createWindowsShim("..\\pnpm\\bin\\pnpm.js"); + const result = readWindowsShim(shimContent); + + expect(result).toBe("..\\pnpm\\bin\\pnpm.js"); + }); + + it("should extract path with .mjs extension", () => { + const shimContent = createWindowsShim("..\\pnpm\\bin\\pnpm.mjs"); + const result = readWindowsShim(shimContent); + + expect(result).toBe("..\\pnpm\\bin\\pnpm.mjs"); + }); + + it("should extract path when using node.exe explicitly", () => { + const shimContent = createWindowsShim("..\\pnpm\\bin\\pnpm.cjs").replace( + 'node "%~dp0', + 'node.exe "%~dp0', + ); + const result = readWindowsShim(shimContent); + + expect(result).toBe("..\\pnpm\\bin\\pnpm.cjs"); + }); +}); + +describe("resolveShimTarget", () => { + it("should resolve a relative path from the shim file location", () => { + const shimFilePath = "/usr/local/bin/pnpm"; + const relativePath = "../lib/node_modules/pnpm/bin/pnpm.cjs"; + + const result = resolveShimTarget(shimFilePath, relativePath); + + expect(result).toBe("/usr/local/lib/node_modules/pnpm/bin/pnpm.cjs"); + }); +}); diff --git a/apps/studio/src/electron-main/lib/read-shim.ts b/apps/studio/src/electron-main/lib/read-shim.ts index 8c653c001..345dcc20e 100644 --- a/apps/studio/src/electron-main/lib/read-shim.ts +++ b/apps/studio/src/electron-main/lib/read-shim.ts @@ -5,60 +5,63 @@ const SHEBANG = "#!/bin/sh"; export async function readShim(shimPath: string): Promise { const isWindows = process.platform === "win32"; - const targetPath = isWindows ? `${shimPath}.cmd` : shimPath; + const shimFilePath = isWindows ? `${shimPath}.cmd` : shimPath; try { - const content = await fs.readFile(targetPath, "utf8"); + const content = await fs.readFile(shimFilePath, "utf8"); - if (isWindows) { - return readWindowsShim(content, shimPath); + const relativePath = isWindows + ? readWindowsShim(content) + : readPosixShim(content); + + if (!relativePath) { + return null; } - return readPosixShim(content, shimPath); + + return resolveShimTarget(shimFilePath, relativePath); } catch { return null; } } -function readPosixShim(content: string, shimPath: string): null | string { - if (!content.startsWith(SHEBANG)) { - return null; - } +export function readWindowsShim(content: string): null | string { + const pattern = /node(?:\.exe)?\s+"([^"]+)"/; - const execPattern = - /exec\s+(?:node|"\$basedir\/node")\s+"?(\$basedir\/[^"\s]+|[^"\s]+)"?/; - const match = execPattern.exec(content); + const match = pattern.exec(content); if (!match?.[1]) { return null; } let targetPath = match[1]; - targetPath = targetPath.replaceAll("$basedir/", ""); + targetPath = targetPath.replaceAll("%~dp0\\", "").replaceAll("%~dp0", ""); - return resolveShimTarget(shimPath, targetPath); + return targetPath; } -function readWindowsShim(content: string, shimPath: string): null | string { - const ifExistPattern = /@IF EXIST[^"]*"[^"]*node\.exe"[^"]*"([^"]+)"/; - const elsePattern = /node\s+"([^"]+)"/; +export function resolveShimTarget( + shimFilePath: string, + relativePath: string, +): string { + const shimDir = path.dirname(shimFilePath); + return path.resolve(shimDir, relativePath); +} - let match = ifExistPattern.exec(content); - if (!match?.[1]) { - match = elsePattern.exec(content); +function readPosixShim(content: string): null | string { + if (!content.startsWith(SHEBANG)) { + return null; } + const execPattern = + /exec\s+(?:node|"\$basedir\/node")\s+"?(\$basedir\/[^"\s]+|[^"\s]+)"?/; + const match = execPattern.exec(content); + if (!match?.[1]) { return null; } let targetPath = match[1]; - targetPath = targetPath.replaceAll("%~dp0\\", "").replaceAll("%~dp0", ""); - - return resolveShimTarget(`${shimPath}.cmd`, targetPath); -} + targetPath = targetPath.replaceAll("$basedir/", ""); -function resolveShimTarget(shimPath: string, targetPath: string): string { - const shimDir = path.dirname(shimPath); - const resolvedPath = path.resolve(shimDir, targetPath); - return resolvedPath; + return targetPath; } From 0a5b40a7369b53acf0030ef4e0003138c03283df Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Fri, 10 Oct 2025 15:36:22 -0500 Subject: [PATCH 19/40] wip: windows-specific task kill --- cspell.json | 1 + packages/workspace/src/logic/spawn-runtime.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/cspell.json b/cspell.json index 1ff997de1..1d2ca8187 100644 --- a/cspell.json +++ b/cspell.json @@ -83,6 +83,7 @@ "staticity", "stringbool", "tanstack", + "taskkill", "textnodes", "tipc", "togglefullscreen", diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index 0f33d3dc8..9ed8e93e4 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -118,6 +118,7 @@ export const spawnRuntimeLogic = fromCallback< ); let port: number | undefined; + let runtimeProcessPid: number | undefined; const baseEnv = { PATH: `${appConfig.workspaceConfig.binDir}${path.delimiter}${process.env.PATH || ""}`, @@ -257,6 +258,7 @@ export const spawnRuntimeLogic = fromCallback< }, windowsHide: true, })`${devServerCommand}`; + runtimeProcessPid = runtimeProcess.pid; sendProcessLogs(runtimeProcess, parentRef); let shouldCheckServer = true; @@ -400,6 +402,19 @@ export const spawnRuntimeLogic = fromCallback< } timeout.cancel(); abortController.abort(); + try { + if ( + process.platform === "win32" && + runtimeProcessPid && + Number.isFinite(runtimeProcessPid) + ) { + void execa({ + windowsHide: true, + })`taskkill /PID ${runtimeProcessPid} /T /F`; + } + } catch { + void 0; + } }; }); From 689aac5f992e42937e6a5c5eb7bc1f3dea103b04 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Fri, 10 Oct 2025 15:37:24 -0500 Subject: [PATCH 20/40] wip: direct exec --- packages/workspace/src/lib/runtime-config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/workspace/src/lib/runtime-config.ts b/packages/workspace/src/lib/runtime-config.ts index 0f3235a77..211ba1ab0 100644 --- a/packages/workspace/src/lib/runtime-config.ts +++ b/packages/workspace/src/lib/runtime-config.ts @@ -52,7 +52,7 @@ const UNKNOWN_CONFIG: RuntimeConfig = { const RUNTIME_CONFIGS: Record = { nextjs: { - command: ({ port }) => ["pnpm", "run", "dev", "-p", port.toString()], + command: ({ port }) => ["pnpm", "exec", "next", "-p", port.toString()], detect: (appDir) => detectJavaScriptRuntime(appDir, "next"), envVars: ({ port }) => ({ PORT: port.toString(), @@ -61,7 +61,7 @@ const RUNTIME_CONFIGS: Record = { }, nuxt: { - command: ({ port }) => ["pnpm", "run", "dev", "--port", port.toString()], + command: ({ port }) => ["pnpm", "exec", "nuxt", "--port", port.toString()], detect: (appDir: string) => detectJavaScriptRuntime(appDir, "nuxt"), envVars: ({ port }) => ({ PORT: port.toString(), @@ -74,8 +74,8 @@ const RUNTIME_CONFIGS: Record = { vite: { command: ({ port }) => [ "pnpm", - "run", - "dev", + "exec", + "vite", "--port", port.toString(), "--strictPort", From 3561b121de1adef0dc02b62be0cdbd08266de5b0 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Fri, 10 Oct 2025 16:00:41 -0500 Subject: [PATCH 21/40] wip: rollback ineffectual pid-based kill --- packages/workspace/src/logic/spawn-runtime.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index 9ed8e93e4..0f33d3dc8 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -118,7 +118,6 @@ export const spawnRuntimeLogic = fromCallback< ); let port: number | undefined; - let runtimeProcessPid: number | undefined; const baseEnv = { PATH: `${appConfig.workspaceConfig.binDir}${path.delimiter}${process.env.PATH || ""}`, @@ -258,7 +257,6 @@ export const spawnRuntimeLogic = fromCallback< }, windowsHide: true, })`${devServerCommand}`; - runtimeProcessPid = runtimeProcess.pid; sendProcessLogs(runtimeProcess, parentRef); let shouldCheckServer = true; @@ -402,19 +400,6 @@ export const spawnRuntimeLogic = fromCallback< } timeout.cancel(); abortController.abort(); - try { - if ( - process.platform === "win32" && - runtimeProcessPid && - Number.isFinite(runtimeProcessPid) - ) { - void execa({ - windowsHide: true, - })`taskkill /PID ${runtimeProcessPid} /T /F`; - } - } catch { - void 0; - } }; }); From c880905c6b28331e9f5df66961fda6e9e8deb897 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Fri, 10 Oct 2025 16:01:22 -0500 Subject: [PATCH 22/40] wip: process-specific which where --- packages/workspace/src/logic/spawn-runtime.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index 0f33d3dc8..9e089c0a3 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -130,7 +130,6 @@ export const spawnRuntimeLogic = fromCallback< const pnpmVersionProcess = execa({ cwd: appConfig.appDir, env: baseEnv, - windowsHide: true, })`pnpm --version`; sendProcessLogs(pnpmVersionProcess, parentRef); @@ -138,7 +137,6 @@ export const spawnRuntimeLogic = fromCallback< const nodeVersionProcess = execa({ cwd: appConfig.appDir, env: baseEnv, - windowsHide: true, })`node --version`; sendProcessLogs(nodeVersionProcess, parentRef); @@ -146,8 +144,7 @@ export const spawnRuntimeLogic = fromCallback< const whichPnpmProcess = execa({ cwd: appConfig.appDir, env: baseEnv, - windowsHide: true, - })`which pnpm`; + })`${process.platform === "win32" ? "where" : "which"} pnpm`; sendProcessLogs(whichPnpmProcess, parentRef); async function main() { @@ -218,7 +215,6 @@ export const spawnRuntimeLogic = fromCallback< env: { ...baseEnv, }, - windowsHide: true, })`${installCommand}`; sendProcessLogs(installProcess, parentRef); @@ -255,7 +251,6 @@ export const spawnRuntimeLogic = fromCallback< QUESTS_INSIDE_STUDIO: "true", ...baseEnv, }, - windowsHide: true, })`${devServerCommand}`; sendProcessLogs(runtimeProcess, parentRef); From 4b2defcb3da040fdcc3a833af0d75af4a613e85b Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Fri, 10 Oct 2025 16:08:49 -0500 Subject: [PATCH 23/40] wip: try taskkill on windows exclusively --- packages/workspace/src/logic/spawn-runtime.ts | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index 9e089c0a3..328648972 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -67,6 +67,22 @@ async function isLocalServerRunning(port: number) { } } +async function killProcessTree(pid: number) { + if (process.platform === "win32") { + try { + await execa`taskkill /pid ${pid.toString()} /T /F`; + } catch { + // Process might already be dead + } + } else { + try { + process.kill(pid, "SIGTERM"); + } catch { + // Process might already be dead + } + } +} + function sendProcessLogs( execaProcess: ResultPromise<{ cancelSignal: AbortSignal; cwd: string }>, parentRef: ActorRef, @@ -118,6 +134,12 @@ export const spawnRuntimeLogic = fromCallback< ); let port: number | undefined; + let runtimeProcess: + | ResultPromise<{ cancelSignal: AbortSignal; cwd: string }> + | undefined; + let installProcess: + | ResultPromise<{ cancelSignal: AbortSignal; cwd: string }> + | undefined; const baseEnv = { PATH: `${appConfig.workspaceConfig.binDir}${path.delimiter}${process.env.PATH || ""}`, @@ -209,7 +231,7 @@ export const spawnRuntimeLogic = fromCallback< }, }); - const installProcess = execa({ + installProcess = execa({ cancelSignal: installSignal, cwd: appConfig.appDir, env: { @@ -242,7 +264,7 @@ export const spawnRuntimeLogic = fromCallback< }); timeout.start(); - const runtimeProcess = execa({ + runtimeProcess = execa({ cancelSignal: signal, cwd: appConfig.appDir, env: { @@ -394,7 +416,17 @@ export const spawnRuntimeLogic = fromCallback< portManager.releasePort(port); } timeout.cancel(); - abortController.abort(); + + if (process.platform === "win32") { + if (runtimeProcess?.pid) { + void killProcessTree(runtimeProcess.pid); + } + if (installProcess?.pid) { + void killProcessTree(installProcess.pid); + } + } else { + abortController.abort(); + } }; }); From 6855d21e03b108a86fad86270be83abc47815a6f Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Fri, 10 Oct 2025 16:11:03 -0500 Subject: [PATCH 24/40] Revert "wip: try taskkill on windows exclusively" This reverts commit 4b2defcb3da040fdcc3a833af0d75af4a613e85b. --- packages/workspace/src/logic/spawn-runtime.ts | 38 ++----------------- 1 file changed, 3 insertions(+), 35 deletions(-) diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index 328648972..9e089c0a3 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -67,22 +67,6 @@ async function isLocalServerRunning(port: number) { } } -async function killProcessTree(pid: number) { - if (process.platform === "win32") { - try { - await execa`taskkill /pid ${pid.toString()} /T /F`; - } catch { - // Process might already be dead - } - } else { - try { - process.kill(pid, "SIGTERM"); - } catch { - // Process might already be dead - } - } -} - function sendProcessLogs( execaProcess: ResultPromise<{ cancelSignal: AbortSignal; cwd: string }>, parentRef: ActorRef, @@ -134,12 +118,6 @@ export const spawnRuntimeLogic = fromCallback< ); let port: number | undefined; - let runtimeProcess: - | ResultPromise<{ cancelSignal: AbortSignal; cwd: string }> - | undefined; - let installProcess: - | ResultPromise<{ cancelSignal: AbortSignal; cwd: string }> - | undefined; const baseEnv = { PATH: `${appConfig.workspaceConfig.binDir}${path.delimiter}${process.env.PATH || ""}`, @@ -231,7 +209,7 @@ export const spawnRuntimeLogic = fromCallback< }, }); - installProcess = execa({ + const installProcess = execa({ cancelSignal: installSignal, cwd: appConfig.appDir, env: { @@ -264,7 +242,7 @@ export const spawnRuntimeLogic = fromCallback< }); timeout.start(); - runtimeProcess = execa({ + const runtimeProcess = execa({ cancelSignal: signal, cwd: appConfig.appDir, env: { @@ -416,17 +394,7 @@ export const spawnRuntimeLogic = fromCallback< portManager.releasePort(port); } timeout.cancel(); - - if (process.platform === "win32") { - if (runtimeProcess?.pid) { - void killProcessTree(runtimeProcess.pid); - } - if (installProcess?.pid) { - void killProcessTree(installProcess.pid); - } - } else { - abortController.abort(); - } + abortController.abort(); }; }); From 55fa00e29dc7b97909acec685e9c48d4a1b11533 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 09:34:07 -0500 Subject: [PATCH 25/40] wip: remove notion of .bin usage, since it isn't present in final bundle --- .../electron-main/lib/setup-bin-directory.ts | 37 ++----------------- 1 file changed, 3 insertions(+), 34 deletions(-) diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index 4fbc5c0ed..f60f08042 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -4,14 +4,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import { logger } from "./electron-logger"; -import { readShim } from "./read-shim"; const BIN_DIR_NAME = "bin"; interface BinaryConfig { getTargetPath: () => string; name: string; - type: "direct" | "node-modules-bin"; } export function getBinDirectoryPath(): string { @@ -41,9 +39,7 @@ export async function setupBinDirectory(): Promise { continue; } - await (binary.type === "node-modules-bin" - ? linkFromNodeModulesBin(binDir, binary.name, targetPath) - : linkDirect(binDir, binary.name, targetPath)); + await linkDirect(binDir, binary.name, targetPath); } catch (error) { logger.error(`Failed to setup binary ${binary.name}:`, error); } @@ -100,14 +96,12 @@ function getBinaryConfigs(): BinaryConfig[] { return [ { - getTargetPath: () => getNodeModulePath(".bin"), + getTargetPath: () => getNodeModulePath("pnpm", "bin", "pnpm.cjs"), name: "pnpm", - type: "node-modules-bin", }, { - getTargetPath: () => getNodeModulePath(".bin"), + getTargetPath: () => getNodeModulePath("pnpm", "bin", "pnpx.cjs"), name: "pnpx", - type: "node-modules-bin", }, { getTargetPath: () => { @@ -119,7 +113,6 @@ function getBinaryConfigs(): BinaryConfig[] { : path.join(basePath, "git"); }, name: "git", - type: "direct", }, { getTargetPath: () => { @@ -129,7 +122,6 @@ function getBinaryConfigs(): BinaryConfig[] { : path.join(basePath, "rg"); }, name: "rg", - type: "direct", }, ]; } @@ -170,29 +162,6 @@ async function linkDirect( } } -async function linkFromNodeModulesBin( - binDir: string, - name: string, - nodeModulesBinPath: string, -): Promise { - const shimPath = path.join(nodeModulesBinPath, name); - const targetCjsPath = await readShim(shimPath); - - if (!targetCjsPath) { - logger.error(`Failed to read shim: ${shimPath}`); - throw new Error(`Failed to read shim: ${shimPath}`); - } - - const outputPath = path.join(binDir, name); - - await cmdShim(targetCjsPath, outputPath, { - createCmdFile: true, - createPwshFile: false, - }); - - logger.info(`Created shim: ${outputPath} -> ${targetCjsPath}`); -} - async function setupNodeLink(binDir: string): Promise { const isWindows = process.platform === "win32"; const nodeExePath = process.execPath; From d0cb0e363bbdd274c9390c41b3a5d7e51582e30b Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 09:45:17 -0500 Subject: [PATCH 26/40] wip: setup bins now injects path automatically --- .../src/electron-main/lib/read-shim.test.ts | 62 ----------------- .../studio/src/electron-main/lib/read-shim.ts | 67 ------------------- .../electron-main/lib/setup-bin-directory.ts | 24 +++++-- apps/studio/tsconfig.json | 10 +-- packages/workspace/src/logic/spawn-runtime.ts | 2 - 5 files changed, 21 insertions(+), 144 deletions(-) delete mode 100644 apps/studio/src/electron-main/lib/read-shim.test.ts delete mode 100644 apps/studio/src/electron-main/lib/read-shim.ts diff --git a/apps/studio/src/electron-main/lib/read-shim.test.ts b/apps/studio/src/electron-main/lib/read-shim.test.ts deleted file mode 100644 index 45b55fd9c..000000000 --- a/apps/studio/src/electron-main/lib/read-shim.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { readWindowsShim, resolveShimTarget } from "./read-shim"; - -const createWindowsShim = (targetPath: string) => { - return `@SETLOCAL -@IF NOT DEFINED NODE_PATH ( - @SET "NODE_PATH=C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules\\pnpm\\bin\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules\\pnpm\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\node_modules" -) ELSE ( - @SET "NODE_PATH=C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules\\pnpm\\bin\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules\\pnpm\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\node_modules;%NODE_PATH%" -) -@IF EXIST "%~dp0\\node.exe" ( - "%~dp0\\node.exe" "%~dp0\\${targetPath}" %* -) ELSE ( - @SET PATHEXT=%PATHEXT:;.JS;=;% - node "%~dp0\\${targetPath}" %* -)`; -}; - -describe("readWindowsShim", () => { - it("should extract the relative target path from a Windows pnpm shim with .cjs", () => { - const shimContent = createWindowsShim("..\\pnpm\\bin\\pnpm.cjs"); - const result = readWindowsShim(shimContent); - - expect(result).toBe("..\\pnpm\\bin\\pnpm.cjs"); - }); - - it("should extract path with .js extension", () => { - const shimContent = createWindowsShim("..\\pnpm\\bin\\pnpm.js"); - const result = readWindowsShim(shimContent); - - expect(result).toBe("..\\pnpm\\bin\\pnpm.js"); - }); - - it("should extract path with .mjs extension", () => { - const shimContent = createWindowsShim("..\\pnpm\\bin\\pnpm.mjs"); - const result = readWindowsShim(shimContent); - - expect(result).toBe("..\\pnpm\\bin\\pnpm.mjs"); - }); - - it("should extract path when using node.exe explicitly", () => { - const shimContent = createWindowsShim("..\\pnpm\\bin\\pnpm.cjs").replace( - 'node "%~dp0', - 'node.exe "%~dp0', - ); - const result = readWindowsShim(shimContent); - - expect(result).toBe("..\\pnpm\\bin\\pnpm.cjs"); - }); -}); - -describe("resolveShimTarget", () => { - it("should resolve a relative path from the shim file location", () => { - const shimFilePath = "/usr/local/bin/pnpm"; - const relativePath = "../lib/node_modules/pnpm/bin/pnpm.cjs"; - - const result = resolveShimTarget(shimFilePath, relativePath); - - expect(result).toBe("/usr/local/lib/node_modules/pnpm/bin/pnpm.cjs"); - }); -}); diff --git a/apps/studio/src/electron-main/lib/read-shim.ts b/apps/studio/src/electron-main/lib/read-shim.ts deleted file mode 100644 index 345dcc20e..000000000 --- a/apps/studio/src/electron-main/lib/read-shim.ts +++ /dev/null @@ -1,67 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; - -const SHEBANG = "#!/bin/sh"; - -export async function readShim(shimPath: string): Promise { - const isWindows = process.platform === "win32"; - const shimFilePath = isWindows ? `${shimPath}.cmd` : shimPath; - - try { - const content = await fs.readFile(shimFilePath, "utf8"); - - const relativePath = isWindows - ? readWindowsShim(content) - : readPosixShim(content); - - if (!relativePath) { - return null; - } - - return resolveShimTarget(shimFilePath, relativePath); - } catch { - return null; - } -} - -export function readWindowsShim(content: string): null | string { - const pattern = /node(?:\.exe)?\s+"([^"]+)"/; - - const match = pattern.exec(content); - - if (!match?.[1]) { - return null; - } - - let targetPath = match[1]; - targetPath = targetPath.replaceAll("%~dp0\\", "").replaceAll("%~dp0", ""); - - return targetPath; -} - -export function resolveShimTarget( - shimFilePath: string, - relativePath: string, -): string { - const shimDir = path.dirname(shimFilePath); - return path.resolve(shimDir, relativePath); -} - -function readPosixShim(content: string): null | string { - if (!content.startsWith(SHEBANG)) { - return null; - } - - const execPattern = - /exec\s+(?:node|"\$basedir\/node")\s+"?(\$basedir\/[^"\s]+|[^"\s]+)"?/; - const match = execPattern.exec(content); - - if (!match?.[1]) { - return null; - } - - let targetPath = match[1]; - targetPath = targetPath.replaceAll("$basedir/", ""); - - return targetPath; -} diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index f60f08042..fa8db85bb 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -45,6 +45,8 @@ export async function setupBinDirectory(): Promise { } } + prependBinDirectoryToPath(binDir); + logger.info(`Bin directory setup complete: ${binDir}`); return binDir; } @@ -99,10 +101,6 @@ function getBinaryConfigs(): BinaryConfig[] { getTargetPath: () => getNodeModulePath("pnpm", "bin", "pnpm.cjs"), name: "pnpm", }, - { - getTargetPath: () => getNodeModulePath("pnpm", "bin", "pnpx.cjs"), - name: "pnpx", - }, { getTargetPath: () => { const basePath = isWindows @@ -162,6 +160,24 @@ async function linkDirect( } } +function prependBinDirectoryToPath(binDir: string): void { + const currentPath = process.env.PATH || ""; + const pathSeparator = path.delimiter; + + const pathParts = currentPath.split(pathSeparator).filter(Boolean); + + const binDirIndex = pathParts.indexOf(binDir); + if (binDirIndex !== -1) { + pathParts.splice(binDirIndex, 1); + } + + const newPath = [binDir, ...pathParts].join(pathSeparator); + + process.env.PATH = newPath; + + logger.info(`Updated PATH: bin directory prepended (${binDir})`); +} + async function setupNodeLink(binDir: string): Promise { const isWindows = process.platform === "win32"; const nodeExePath = process.execPath; diff --git a/apps/studio/tsconfig.json b/apps/studio/tsconfig.json index 4cd8f66e9..6dccfcc35 100644 --- a/apps/studio/tsconfig.json +++ b/apps/studio/tsconfig.json @@ -5,13 +5,5 @@ "@/*": ["./src/*"] } }, - "include": [ - "src", - "*.ts", - "*.js", - "*.cjs", - "scripts", - "__mocks__/*.ts", - "../../packages/ai-gateway/src/lib/add-ref.ts" - ] + "include": ["src", "*.ts", "*.js", "*.cjs", "scripts", "__mocks__/*.ts"] } diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index 9e089c0a3..de100c4cb 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -1,7 +1,6 @@ import { envForProviders } from "@quests/ai-gateway"; import { execa, ExecaError, type ResultPromise } from "execa"; import ms from "ms"; -import path from "node:path"; import { type ActorRef, type ActorRefFrom, @@ -120,7 +119,6 @@ export const spawnRuntimeLogic = fromCallback< let port: number | undefined; const baseEnv = { - PATH: `${appConfig.workspaceConfig.binDir}${path.delimiter}${process.env.PATH || ""}`, // Required for normal node processes to work // See https://www.electronjs.org/docs/latest/api/environment-variables ELECTRON_RUN_AS_NODE: "1", From 62256c8c19a2da3c8e4af49210853f6ea3462c3b Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 10:08:52 -0500 Subject: [PATCH 27/40] wip: back to working node: true execution with shim file bin extraction in workspace --- .../lib/create-workspace-actor.ts | 7 +- apps/studio/src/electron-main/lib/pnpm.ts | 14 ++-- .../electron-main/lib/setup-bin-directory.ts | 8 +- packages/workspace/scripts/run-workspace.ts | 5 +- packages/workspace/src/lib/read-shim.test.ts | 62 +++++++++++++++ packages/workspace/src/lib/read-shim.ts | 69 ++++++++++++++++ packages/workspace/src/lib/runtime-config.ts | 78 +++++++++++-------- packages/workspace/src/logic/spawn-runtime.ts | 54 ++++++------- .../workspace/src/machines/workspace/index.ts | 4 +- .../src/test/helpers/mock-app-config.ts | 3 +- packages/workspace/src/types.ts | 2 +- 11 files changed, 227 insertions(+), 79 deletions(-) create mode 100644 packages/workspace/src/lib/read-shim.test.ts create mode 100644 packages/workspace/src/lib/read-shim.ts diff --git a/apps/studio/src/electron-main/lib/create-workspace-actor.ts b/apps/studio/src/electron-main/lib/create-workspace-actor.ts index ea3fc7946..4af79be22 100644 --- a/apps/studio/src/electron-main/lib/create-workspace-actor.ts +++ b/apps/studio/src/electron-main/lib/create-workspace-actor.ts @@ -22,8 +22,7 @@ import { captureServerEvent } from "./capture-server-event"; import { captureServerException } from "./capture-server-exception"; import { getFramework } from "./frameworks"; import { getAllPackageBinaryPaths } from "./link-bins"; -import { getPnpmPath } from "./pnpm"; -import { getBinDirectoryPath } from "./setup-bin-directory"; +import { getPNPMBinPath } from "./setup-bin-directory"; const scopedLogger = logger.scope("workspace-actor"); @@ -37,7 +36,6 @@ export function createWorkspaceActor() { const actor = createActor(workspaceMachine, { input: { aiGatewayApp, - binDir: getBinDirectoryPath(), captureEvent: captureServerEvent, captureException: captureServerException, getAIProviders: () => { @@ -56,6 +54,7 @@ export function createWorkspaceActor() { return providers; }, + pnpmBinPath: getPNPMBinPath(), previewCacheTimeMs: ms("24 hours"), registryDir: app.isPackaged ? path.join(process.resourcesPath, REGISTRY_DIR_NAME) @@ -88,7 +87,7 @@ export function createWorkspaceActor() { }, runShellCommand: async (command, { cwd, signal }) => { const [commandName, ...rest] = parseCommandString(command); - const pnpmPath = getPnpmPath(); + const pnpmPath = getPNPMBinPath(); if (commandName === "pnpm") { return ok( diff --git a/apps/studio/src/electron-main/lib/pnpm.ts b/apps/studio/src/electron-main/lib/pnpm.ts index 6d4bc8ea4..89f99f30a 100644 --- a/apps/studio/src/electron-main/lib/pnpm.ts +++ b/apps/studio/src/electron-main/lib/pnpm.ts @@ -2,16 +2,16 @@ import { forkExecCommand } from "@/electron-main/lib/exec-command"; import { createRequire } from "node:module"; import path from "node:path"; -export function getPnpmPath() { +export async function pnpmVersion() { + const pnpmPath = getPnpmPath(); + const result = await forkExecCommand(pnpmPath, ["-v"]); + return result.stdout; +} + +function getPnpmPath() { const require = createRequire(import.meta.url); const packageJsonPath = require.resolve("pnpm"); const unpackedPath = packageJsonPath.replace("app.asar", "app.asar.unpacked"); const pnpmPath = path.dirname(unpackedPath); return path.join(pnpmPath, "bin", "pnpm.cjs"); } - -export async function pnpmVersion() { - const pnpmPath = getPnpmPath(); - const result = await forkExecCommand(pnpmPath, ["-v"]); - return result.stdout; -} diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index fa8db85bb..d4d4242a1 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -12,8 +12,8 @@ interface BinaryConfig { name: string; } -export function getBinDirectoryPath(): string { - return path.join(app.getPath("userData"), BIN_DIR_NAME); +export function getPNPMBinPath(): string { + return getNodeModulePath("pnpm", "bin", "pnpm.cjs"); } export async function setupBinDirectory(): Promise { @@ -124,6 +124,10 @@ function getBinaryConfigs(): BinaryConfig[] { ]; } +function getBinDirectoryPath(): string { + return path.join(app.getPath("userData"), BIN_DIR_NAME); +} + function getNodeModulePath(...parts: string[]): string { const appPath = app.getAppPath(); const modulePath = path.join(appPath, "node_modules", ...parts); diff --git a/packages/workspace/scripts/run-workspace.ts b/packages/workspace/scripts/run-workspace.ts index e62f0ddd4..552c5b5d1 100644 --- a/packages/workspace/scripts/run-workspace.ts +++ b/packages/workspace/scripts/run-workspace.ts @@ -43,8 +43,6 @@ const registryDir = path.resolve("../../registry"); const actor = createActor(workspaceMachine, { input: { aiGatewayApp, - // For this script, we depend on the developer's local pnpm, node, rg, etc. - binDir: path.resolve("/tmp/not-real"), captureEvent: (...args: unknown[]) => { // eslint-disable-next-line no-console console.log("captureEvent", args); @@ -105,6 +103,9 @@ const actor = createActor(workspaceMachine, { return providers; }, + pnpmBinPath: await execa({ reject: false })`which pnpm`.then( + (result) => result.stdout.trim() || "pnpm", + ), registryDir, // Sibling directory to monorepo to avoid using same pnpm and git rootDir: path.resolve("../../../workspace.local"), diff --git a/packages/workspace/src/lib/read-shim.test.ts b/packages/workspace/src/lib/read-shim.test.ts new file mode 100644 index 000000000..45b55fd9c --- /dev/null +++ b/packages/workspace/src/lib/read-shim.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { readWindowsShim, resolveShimTarget } from "./read-shim"; + +const createWindowsShim = (targetPath: string) => { + return `@SETLOCAL +@IF NOT DEFINED NODE_PATH ( + @SET "NODE_PATH=C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules\\pnpm\\bin\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules\\pnpm\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\node_modules" +) ELSE ( + @SET "NODE_PATH=C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules\\pnpm\\bin\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules\\pnpm\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\pnpm@10.13.1\\node_modules;C:\\Users\\tests\\code\\quests\\node_modules\\.pnpm\\node_modules;%NODE_PATH%" +) +@IF EXIST "%~dp0\\node.exe" ( + "%~dp0\\node.exe" "%~dp0\\${targetPath}" %* +) ELSE ( + @SET PATHEXT=%PATHEXT:;.JS;=;% + node "%~dp0\\${targetPath}" %* +)`; +}; + +describe("readWindowsShim", () => { + it("should extract the relative target path from a Windows pnpm shim with .cjs", () => { + const shimContent = createWindowsShim("..\\pnpm\\bin\\pnpm.cjs"); + const result = readWindowsShim(shimContent); + + expect(result).toBe("..\\pnpm\\bin\\pnpm.cjs"); + }); + + it("should extract path with .js extension", () => { + const shimContent = createWindowsShim("..\\pnpm\\bin\\pnpm.js"); + const result = readWindowsShim(shimContent); + + expect(result).toBe("..\\pnpm\\bin\\pnpm.js"); + }); + + it("should extract path with .mjs extension", () => { + const shimContent = createWindowsShim("..\\pnpm\\bin\\pnpm.mjs"); + const result = readWindowsShim(shimContent); + + expect(result).toBe("..\\pnpm\\bin\\pnpm.mjs"); + }); + + it("should extract path when using node.exe explicitly", () => { + const shimContent = createWindowsShim("..\\pnpm\\bin\\pnpm.cjs").replace( + 'node "%~dp0', + 'node.exe "%~dp0', + ); + const result = readWindowsShim(shimContent); + + expect(result).toBe("..\\pnpm\\bin\\pnpm.cjs"); + }); +}); + +describe("resolveShimTarget", () => { + it("should resolve a relative path from the shim file location", () => { + const shimFilePath = "/usr/local/bin/pnpm"; + const relativePath = "../lib/node_modules/pnpm/bin/pnpm.cjs"; + + const result = resolveShimTarget(shimFilePath, relativePath); + + expect(result).toBe("/usr/local/lib/node_modules/pnpm/bin/pnpm.cjs"); + }); +}); diff --git a/packages/workspace/src/lib/read-shim.ts b/packages/workspace/src/lib/read-shim.ts new file mode 100644 index 000000000..abcdeb1db --- /dev/null +++ b/packages/workspace/src/lib/read-shim.ts @@ -0,0 +1,69 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { type AbsolutePath } from "../schemas/paths"; + +const SHEBANG = "#!/bin/sh"; + +export async function readShim(shimPath: AbsolutePath): Promise { + const isWindows = process.platform === "win32"; + const shimFilePath = isWindows ? `${shimPath}.cmd` : shimPath; + + try { + const content = await fs.readFile(shimFilePath, "utf8"); + + const relativePath = isWindows + ? readWindowsShim(content) + : readPosixShim(content); + + if (!relativePath) { + return null; + } + + return resolveShimTarget(shimFilePath, relativePath); + } catch { + return null; + } +} + +export function readWindowsShim(content: string): null | string { + const pattern = /node(?:\.exe)?\s+"([^"]+)"/; + + const match = pattern.exec(content); + + if (!match?.[1]) { + return null; + } + + let targetPath = match[1]; + targetPath = targetPath.replaceAll("%~dp0\\", "").replaceAll("%~dp0", ""); + + return targetPath; +} + +export function resolveShimTarget( + shimFilePath: string, + relativePath: string, +): string { + const shimDir = path.dirname(shimFilePath); + return path.resolve(shimDir, relativePath); +} + +function readPosixShim(content: string): null | string { + if (!content.startsWith(SHEBANG)) { + return null; + } + + const execPattern = + /exec\s+(?:node|"\$basedir\/node")\s+"?(\$basedir\/[^"\s]+|[^"\s]+)"?/; + const match = execPattern.exec(content); + + if (!match?.[1]) { + return null; + } + + let targetPath = match[1]; + targetPath = targetPath.replaceAll("$basedir/", ""); + + return targetPath; +} diff --git a/packages/workspace/src/lib/runtime-config.ts b/packages/workspace/src/lib/runtime-config.ts index 211ba1ab0..4a07c93f4 100644 --- a/packages/workspace/src/lib/runtime-config.ts +++ b/packages/workspace/src/lib/runtime-config.ts @@ -2,14 +2,17 @@ import { parseCommandString } from "execa"; import { err, ok, type Result } from "neverthrow"; import { type NormalizedPackageJson, readPackage } from "read-pkg"; +import { type AppDir } from "../schemas/paths"; +import { absolutePathJoin } from "./absolute-path-join"; import { type AppConfig } from "./app-config/types"; +import { readShim } from "./read-shim"; interface RuntimeConfig { - command: (options: { - appDir: string; - port: number; - }) => Promise | string[]; detect: (appDir: string) => boolean | Promise; + devCommand: (options: { + appDir: AppDir; + port: number; + }) => Promise; envVars: (options: { port: number }) => Record; installCommand: (appConfig: AppConfig) => string[]; } @@ -19,8 +22,8 @@ function defaultInstallCommand(appConfig: AppConfig) { ? // These app types are nested in the project directory, so we need // to ignore the workspace config otherwise PNPM may not install the // dependencies correctly - ["pnpm", "install", "--ignore-workspace"] - : ["pnpm", "install"]; + [appConfig.workspaceConfig.pnpmBinPath, "install", "--ignore-workspace"] + : [appConfig.workspaceConfig.pnpmBinPath, "install"]; } async function detectJavaScriptRuntime( @@ -41,19 +44,20 @@ async function detectJavaScriptRuntime( } } -const UNKNOWN_CONFIG: RuntimeConfig = { - command: ({ port }) => ["pnpm", "run", "dev", "--port", port.toString()], - detect: (): boolean => true, - envVars: ({ port }) => ({ - PORT: port.toString(), - }), - installCommand: defaultInstallCommand, -}; +function getBinShimPath(appDir: AppDir, command: string) { + return absolutePathJoin(appDir, "node_modules", ".bin", command); +} const RUNTIME_CONFIGS: Record = { nextjs: { - command: ({ port }) => ["pnpm", "exec", "next", "-p", port.toString()], detect: (appDir) => detectJavaScriptRuntime(appDir, "next"), + devCommand: async ({ appDir, port }) => { + const binPath = await readShim(getBinShimPath(appDir, "next")); + if (!binPath) { + return null; + } + return [binPath, "-p", port.toString()]; + }, envVars: ({ port }) => ({ PORT: port.toString(), }), @@ -61,31 +65,39 @@ const RUNTIME_CONFIGS: Record = { }, nuxt: { - command: ({ port }) => ["pnpm", "exec", "nuxt", "--port", port.toString()], detect: (appDir: string) => detectJavaScriptRuntime(appDir, "nuxt"), + devCommand: async ({ appDir, port }) => { + const binPath = await readShim(getBinShimPath(appDir, "nuxt")); + if (!binPath) { + return null; + } + return [binPath, "dev", "--port", port.toString()]; + }, envVars: ({ port }) => ({ PORT: port.toString(), }), installCommand: defaultInstallCommand, }, - unknown: UNKNOWN_CONFIG, - vite: { - command: ({ port }) => [ - "pnpm", - "exec", - "vite", - "--port", - port.toString(), - "--strictPort", - "--clearScreen", - "false", - // Avoids logging confusing localhost and port info - "--logLevel", - "warn", - ], detect: (appDir: string) => detectJavaScriptRuntime(appDir, "vite"), + devCommand: async ({ appDir, port }) => { + const binPath = await readShim(getBinShimPath(appDir, "vite")); + if (!binPath) { + return null; + } + return [ + binPath, + "--port", + port.toString(), + "--strictPort", + "--clearScreen", + "false", + // Avoids logging confusing localhost and port info + "--logLevel", + "warn", + ]; + }, envVars: () => ({}), installCommand: defaultInstallCommand, }, @@ -130,6 +142,6 @@ export async function detectRuntimeTypeFromDirectory( } } -export function getRuntimeConfigByType(runtimeType: string): RuntimeConfig { - return RUNTIME_CONFIGS[runtimeType] ?? UNKNOWN_CONFIG; +export function getRuntimeConfigByType(runtimeType: string) { + return RUNTIME_CONFIGS[runtimeType]; } diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index de100c4cb..bbe7e2d1a 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -124,27 +124,6 @@ export const spawnRuntimeLogic = fromCallback< ELECTRON_RUN_AS_NODE: "1", }; - // TODO(FP-595): remove after done debugging - const pnpmVersionProcess = execa({ - cwd: appConfig.appDir, - env: baseEnv, - })`pnpm --version`; - sendProcessLogs(pnpmVersionProcess, parentRef); - - // TODO(FP-595): remove after done debugging - const nodeVersionProcess = execa({ - cwd: appConfig.appDir, - env: baseEnv, - })`node --version`; - sendProcessLogs(nodeVersionProcess, parentRef); - - // TODO(FP-595): remove after done debugging - const whichPnpmProcess = execa({ - cwd: appConfig.appDir, - env: baseEnv, - })`${process.platform === "win32" ? "where" : "which"} pnpm`; - sendProcessLogs(whichPnpmProcess, parentRef); - async function main() { port = await portManager.reservePort(); @@ -197,6 +176,19 @@ export const spawnRuntimeLogic = fromCallback< abortController.signal, installTimeout.controller.signal, ]); + if (!runtimeConfig) { + parentRef.send({ + isRetryable: false, + shouldLog: true, + type: "spawnRuntime.error.unknown", + value: { + error: new Error( + "Unsupported runtime type. Supported: vite, next, nuxt", + ), + }, + }); + return; + } const installCommand = runtimeConfig.installCommand(appConfig); parentRef.send({ @@ -210,9 +202,8 @@ export const spawnRuntimeLogic = fromCallback< const installProcess = execa({ cancelSignal: installSignal, cwd: appConfig.appDir, - env: { - ...baseEnv, - }, + env: baseEnv, + node: true, })`${installCommand}`; sendProcessLogs(installProcess, parentRef); @@ -229,11 +220,21 @@ export const spawnRuntimeLogic = fromCallback< timeout.controller.signal, ]); - const devServerCommand = await runtimeConfig.command({ + const devServerCommand = await runtimeConfig.devCommand({ appDir: appConfig.appDir, port, }); + if (!devServerCommand) { + parentRef.send({ + isRetryable: false, + shouldLog: true, + type: "spawnRuntime.error.unknown", + value: { error: new Error("Failed to get dev server command") }, + }); + return; + } + parentRef.send({ type: "spawnRuntime.log", value: { message: `$ ${devServerCommand.join(" ")}`, type: "normal" }, @@ -245,10 +246,11 @@ export const spawnRuntimeLogic = fromCallback< cwd: appConfig.appDir, env: { ...providerEnv, + ...baseEnv, NO_COLOR: "1", QUESTS_INSIDE_STUDIO: "true", - ...baseEnv, }, + node: true, })`${devServerCommand}`; sendProcessLogs(runtimeProcess, parentRef); diff --git a/packages/workspace/src/machines/workspace/index.ts b/packages/workspace/src/machines/workspace/index.ts index 5440179ab..a5c817a3e 100644 --- a/packages/workspace/src/machines/workspace/index.ts +++ b/packages/workspace/src/machines/workspace/index.ts @@ -180,10 +180,10 @@ export const workspaceMachine = setup({ events: {} as WorkspaceEvent, input: {} as { aiGatewayApp: AIGatewayApp; - binDir: string; captureEvent: CaptureEventFunction; captureException: CaptureExceptionFunction; getAIProviders: GetAIProviders; + pnpmBinPath: string; previewCacheTimeMs?: number; registryDir: string; rootDir: string; @@ -197,10 +197,10 @@ export const workspaceMachine = setup({ }).createMachine({ context: ({ input, self, spawn }) => { const workspaceConfig: WorkspaceConfig = { - binDir: AbsolutePathSchema.parse(input.binDir), captureEvent: input.captureEvent, captureException: input.captureException, getAIProviders: input.getAIProviders, + pnpmBinPath: AbsolutePathSchema.parse(input.pnpmBinPath), previewCacheTimeMs: input.previewCacheTimeMs, previewsDir: AbsolutePathSchema.parse( path.join(input.rootDir, PREVIEWS_FOLDER), diff --git a/packages/workspace/src/test/helpers/mock-app-config.ts b/packages/workspace/src/test/helpers/mock-app-config.ts index 945a40024..d0a8e78e3 100644 --- a/packages/workspace/src/test/helpers/mock-app-config.ts +++ b/packages/workspace/src/test/helpers/mock-app-config.ts @@ -7,7 +7,6 @@ import { type AppSubdomain } from "../../schemas/subdomains"; import { type WorkspaceConfig } from "../../types"; const MOCK_WORKSPACE_DIR = "/tmp/workspace"; -const MOCK_BIN_DIR = "/tmp/bin"; export const MOCK_WORKSPACE_DIRS = { previews: `${MOCK_WORKSPACE_DIR}/previews`, @@ -26,7 +25,6 @@ export function createMockAppConfig( return createAppConfig({ subdomain, workspaceConfig: { - binDir: AbsolutePathSchema.parse(MOCK_BIN_DIR), captureEvent: () => { // No-op }, @@ -35,6 +33,7 @@ export function createMockAppConfig( console.error("captureException", args); }, getAIProviders: () => [], + pnpmBinPath: AbsolutePathSchema.parse("/tmp/pnpm"), previewsDir: AbsolutePathSchema.parse(MOCK_WORKSPACE_DIRS.previews), projectsDir: AbsolutePathSchema.parse(MOCK_WORKSPACE_DIRS.projects), registryDir: AbsolutePathSchema.parse(MOCK_WORKSPACE_DIRS.registry), diff --git a/packages/workspace/src/types.ts b/packages/workspace/src/types.ts index fb561eef7..48da04550 100644 --- a/packages/workspace/src/types.ts +++ b/packages/workspace/src/types.ts @@ -21,10 +21,10 @@ export type RunPackageJsonScript = (options: { }) => Promise | ShellResult; export interface WorkspaceConfig { - binDir: AbsolutePath; captureEvent: CaptureEventFunction; captureException: CaptureExceptionFunction; getAIProviders: GetAIProviders; + pnpmBinPath: AbsolutePath; previewCacheTimeMs?: number; previewsDir: AbsolutePath; projectsDir: AbsolutePath; From 81e845d565394d8f015d71a0437895db87cb1318 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 10:39:30 -0500 Subject: [PATCH 28/40] wip: simplify dev server info --- packages/workspace/src/logic/spawn-runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index bbe7e2d1a..19ed2fbe5 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -237,7 +237,7 @@ export const spawnRuntimeLogic = fromCallback< parentRef.send({ type: "spawnRuntime.log", - value: { message: `$ ${devServerCommand.join(" ")}`, type: "normal" }, + value: { message: `Starting ${runtimeType} dev server`, type: "normal" }, }); timeout.start(); From c20f672cfd08b7dc6d9c03223c39d46f84ee0c7d Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 11:25:25 -0500 Subject: [PATCH 29/40] wip: netlify build info based framework and package manager detection --- packages/workspace/package.json | 2 +- packages/workspace/src/lib/get-dev-command.ts | 100 ++++++++++++ .../workspace/src/lib/get-install-command.ts | 36 +++++ packages/workspace/src/lib/package-manager.ts | 11 ++ ...ad-shim.test.ts => read-pnpm-shim.test.ts} | 2 +- .../lib/{read-shim.ts => read-pnpm-shim.ts} | 22 ++- packages/workspace/src/lib/runtime-config.ts | 147 ----------------- packages/workspace/src/logic/spawn-runtime.ts | 81 ++++------ pnpm-lock.yaml | 152 +++++++++++++++++- 9 files changed, 349 insertions(+), 204 deletions(-) create mode 100644 packages/workspace/src/lib/get-dev-command.ts create mode 100644 packages/workspace/src/lib/get-install-command.ts create mode 100644 packages/workspace/src/lib/package-manager.ts rename packages/workspace/src/lib/{read-shim.test.ts => read-pnpm-shim.test.ts} (97%) rename packages/workspace/src/lib/{read-shim.ts => read-pnpm-shim.ts} (70%) delete mode 100644 packages/workspace/src/lib/runtime-config.ts diff --git a/packages/workspace/package.json b/packages/workspace/package.json index ab3707b4b..1eaf992ca 100644 --- a/packages/workspace/package.json +++ b/packages/workspace/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@hono/node-server": "^1.18.1", + "@netlify/build-info": "^10.0.9", "@orpc/server": "catalog:", "@quests/ai-gateway": "workspace:*", "@quests/shared": "workspace:*", @@ -48,7 +49,6 @@ "ms": "^2.1.3", "neverthrow": "^8.1.1", "radashi": "^12.6.0", - "read-pkg": "^9.0.1", "superjson": "^2.2.2", "tiny-invariant": "^1.3.3", "typescript": "catalog:", diff --git a/packages/workspace/src/lib/get-dev-command.ts b/packages/workspace/src/lib/get-dev-command.ts new file mode 100644 index 000000000..bccd7026f --- /dev/null +++ b/packages/workspace/src/lib/get-dev-command.ts @@ -0,0 +1,100 @@ +import { type Info } from "@netlify/build-info/node"; +import { parseCommandString } from "execa"; +import { err, ok } from "neverthrow"; +import { sort } from "radashi"; +import invariant from "tiny-invariant"; + +import { type AppDir } from "../schemas/paths"; +import { absolutePathJoin } from "./absolute-path-join"; +import { type AppConfig } from "./app-config/types"; +import { TypedError } from "./errors"; +import { PackageManager } from "./package-manager"; +import { readPNPMShim } from "./read-pnpm-shim"; + +export async function getDevCommand({ + appConfig, + buildInfo: { frameworks, packageManager }, + port, +}: { + appConfig: AppConfig; + buildInfo: Info; + port: number; +}) { + const sortedFrameworks = sort(frameworks, (f) => f.detected.accuracy); + const [framework] = sortedFrameworks; + invariant(framework, "No framework found"); + const [devCommand, ...devCommandArgs] = parseCommandString( + framework.dev?.command ?? "", + ); + if (!devCommand) { + return err( + new TypedError.NotFound( + "No dev command found in framework configuration", + ), + ); + } + + if (packageManager?.name !== PackageManager.PNPM) { + const commandArgs = ["--port", port.toString()]; + return ok({ + command: packageManager + ? [ + ...parseCommandString(packageManager.runCommand), + devCommand, + ...devCommandArgs, + ...commandArgs, + ] + : [devCommand, ...devCommandArgs, ...commandArgs], + framework, + }); + } + + const binPathResult = await readPNPMShim( + getBinShimPath(appConfig.appDir, devCommand), + ); + if (binPathResult.isErr()) { + return err(binPathResult.error); + } + + const binPath = binPathResult.value; + + switch (devCommand) { + case "next": { + return ok({ + command: [binPath, ...devCommandArgs, "-p", port.toString()], + framework, + }); + } + case "nuxt": { + return ok({ + command: [binPath, ...devCommandArgs, "--port", port.toString()], + framework, + }); + } + case "vite": { + return ok({ + command: [ + binPath, + ...devCommandArgs, + "--port", + port.toString(), + "--strictPort", + "--clearScreen", + "false", + "--logLevel", + "warn", + ], + framework, + }); + } + } + return err( + new TypedError.NotFound( + `Unsupported dev command: ${devCommand}. Supported commands are: next, nuxt, vite`, + ), + ); +} + +function getBinShimPath(appDir: AppDir, command: string) { + return absolutePathJoin(appDir, "node_modules", ".bin", command); +} diff --git a/packages/workspace/src/lib/get-install-command.ts b/packages/workspace/src/lib/get-install-command.ts new file mode 100644 index 000000000..c2e7b569e --- /dev/null +++ b/packages/workspace/src/lib/get-install-command.ts @@ -0,0 +1,36 @@ +import { type Info } from "@netlify/build-info/node"; +import { parseCommandString } from "execa"; + +import { type AppConfig } from "./app-config/types"; +import { PackageManager } from "./package-manager"; + +export function getInstallCommand({ + appConfig, + buildInfo: { packageManager }, +}: { + appConfig: AppConfig; + buildInfo: Info; +}) { + // eslint-disable-next-line unicorn/prefer-ternary + if (!packageManager || packageManager.name === PackageManager.PNPM) { + return { + installCommand: + appConfig.type === "version" || appConfig.type === "sandbox" + ? // These app types are nested in the project directory, so we need + // to ignore the workspace config otherwise PNPM may not install the + // dependencies correctly + [ + appConfig.workspaceConfig.pnpmBinPath, + "install", + "--ignore-workspace", + ] + : [appConfig.workspaceConfig.pnpmBinPath, "install"], + name: PackageManager.PNPM, + }; + } else { + return { + installCommand: parseCommandString(packageManager.installCommand), + name: packageManager.name, + }; + } +} diff --git a/packages/workspace/src/lib/package-manager.ts b/packages/workspace/src/lib/package-manager.ts new file mode 100644 index 000000000..d92ce43c1 --- /dev/null +++ b/packages/workspace/src/lib/package-manager.ts @@ -0,0 +1,11 @@ +import { type Info } from "@netlify/build-info/node"; + +type PackageManagerName = NonNullable["name"]; + +// Not exported by @netlify/build-info so we recreate it here +export const PackageManager = { + BUN: "bun" as PackageManagerName, + NPM: "npm" as PackageManagerName, + PNPM: "pnpm" as PackageManagerName, + YARN: "yarn" as PackageManagerName, +}; diff --git a/packages/workspace/src/lib/read-shim.test.ts b/packages/workspace/src/lib/read-pnpm-shim.test.ts similarity index 97% rename from packages/workspace/src/lib/read-shim.test.ts rename to packages/workspace/src/lib/read-pnpm-shim.test.ts index 45b55fd9c..caf38928e 100644 --- a/packages/workspace/src/lib/read-shim.test.ts +++ b/packages/workspace/src/lib/read-pnpm-shim.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { readWindowsShim, resolveShimTarget } from "./read-shim"; +import { readWindowsShim, resolveShimTarget } from "./read-pnpm-shim"; const createWindowsShim = (targetPath: string) => { return `@SETLOCAL diff --git a/packages/workspace/src/lib/read-shim.ts b/packages/workspace/src/lib/read-pnpm-shim.ts similarity index 70% rename from packages/workspace/src/lib/read-shim.ts rename to packages/workspace/src/lib/read-pnpm-shim.ts index abcdeb1db..69d43cb4e 100644 --- a/packages/workspace/src/lib/read-shim.ts +++ b/packages/workspace/src/lib/read-pnpm-shim.ts @@ -1,11 +1,15 @@ +import { err, ok, type Result } from "neverthrow"; import fs from "node:fs/promises"; import path from "node:path"; import { type AbsolutePath } from "../schemas/paths"; +import { TypedError } from "./errors"; const SHEBANG = "#!/bin/sh"; -export async function readShim(shimPath: AbsolutePath): Promise { +export async function readPNPMShim( + shimPath: AbsolutePath, +): Promise> { const isWindows = process.platform === "win32"; const shimFilePath = isWindows ? `${shimPath}.cmd` : shimPath; @@ -17,12 +21,20 @@ export async function readShim(shimPath: AbsolutePath): Promise { : readPosixShim(content); if (!relativePath) { - return null; + return err( + new TypedError.Parse( + `Failed to parse shim file at ${shimFilePath}: could not extract relative path`, + ), + ); } - return resolveShimTarget(shimFilePath, relativePath); - } catch { - return null; + return ok(resolveShimTarget(shimFilePath, relativePath)); + } catch (error) { + return err( + new TypedError.FileSystem(`Failed to read shim file at ${shimFilePath}`, { + cause: error, + }), + ); } } diff --git a/packages/workspace/src/lib/runtime-config.ts b/packages/workspace/src/lib/runtime-config.ts deleted file mode 100644 index 4a07c93f4..000000000 --- a/packages/workspace/src/lib/runtime-config.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { parseCommandString } from "execa"; -import { err, ok, type Result } from "neverthrow"; -import { type NormalizedPackageJson, readPackage } from "read-pkg"; - -import { type AppDir } from "../schemas/paths"; -import { absolutePathJoin } from "./absolute-path-join"; -import { type AppConfig } from "./app-config/types"; -import { readShim } from "./read-shim"; - -interface RuntimeConfig { - detect: (appDir: string) => boolean | Promise; - devCommand: (options: { - appDir: AppDir; - port: number; - }) => Promise; - envVars: (options: { port: number }) => Record; - installCommand: (appConfig: AppConfig) => string[]; -} - -function defaultInstallCommand(appConfig: AppConfig) { - return appConfig.type === "version" || appConfig.type === "sandbox" - ? // These app types are nested in the project directory, so we need - // to ignore the workspace config otherwise PNPM may not install the - // dependencies correctly - [appConfig.workspaceConfig.pnpmBinPath, "install", "--ignore-workspace"] - : [appConfig.workspaceConfig.pnpmBinPath, "install"]; -} - -async function detectJavaScriptRuntime( - appDir: string, - expectedCommand: string, -): Promise { - try { - const pkg: NormalizedPackageJson = await readPackage({ cwd: appDir }); - const script = pkg.scripts?.dev; - if (!script) { - return false; - } - - const [commandName] = parseCommandString(script); - return commandName === expectedCommand; - } catch { - return false; - } -} - -function getBinShimPath(appDir: AppDir, command: string) { - return absolutePathJoin(appDir, "node_modules", ".bin", command); -} - -const RUNTIME_CONFIGS: Record = { - nextjs: { - detect: (appDir) => detectJavaScriptRuntime(appDir, "next"), - devCommand: async ({ appDir, port }) => { - const binPath = await readShim(getBinShimPath(appDir, "next")); - if (!binPath) { - return null; - } - return [binPath, "-p", port.toString()]; - }, - envVars: ({ port }) => ({ - PORT: port.toString(), - }), - installCommand: defaultInstallCommand, - }, - - nuxt: { - detect: (appDir: string) => detectJavaScriptRuntime(appDir, "nuxt"), - devCommand: async ({ appDir, port }) => { - const binPath = await readShim(getBinShimPath(appDir, "nuxt")); - if (!binPath) { - return null; - } - return [binPath, "dev", "--port", port.toString()]; - }, - envVars: ({ port }) => ({ - PORT: port.toString(), - }), - installCommand: defaultInstallCommand, - }, - - vite: { - detect: (appDir: string) => detectJavaScriptRuntime(appDir, "vite"), - devCommand: async ({ appDir, port }) => { - const binPath = await readShim(getBinShimPath(appDir, "vite")); - if (!binPath) { - return null; - } - return [ - binPath, - "--port", - port.toString(), - "--strictPort", - "--clearScreen", - "false", - // Avoids logging confusing localhost and port info - "--logLevel", - "warn", - ]; - }, - envVars: () => ({}), - installCommand: defaultInstallCommand, - }, -}; - -interface RuntimeDetectionError { - message: string; - scriptName?: string; -} - -export async function detectRuntimeTypeFromDirectory( - appDir: string, -): Promise> { - try { - const pkg: NormalizedPackageJson = await readPackage({ cwd: appDir }); - const scriptName = "dev"; - const script = pkg.scripts?.[scriptName]; - - if (!script) { - return err({ - message: `Script "${scriptName}" not found in package.json`, - scriptName, - }); - } - - const [commandName] = parseCommandString(script); - - for (const [runtimeType, config] of Object.entries(RUNTIME_CONFIGS)) { - if (runtimeType !== "unknown" && (await config.detect(appDir))) { - return ok(runtimeType); - } - } - - return err({ - message: `Unsupported command "${commandName ?? "missing"}" for script "${scriptName}" in package.json. Supported commands: vite, next, nuxt`, - scriptName, - }); - } catch (error) { - return err({ - message: `Failed to read package.json: ${error instanceof Error ? error.message : String(error)}`, - }); - } -} - -export function getRuntimeConfigByType(runtimeType: string) { - return RUNTIME_CONFIGS[runtimeType]; -} diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index 19ed2fbe5..d79554301 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -1,3 +1,4 @@ +import { getBuildInfo } from "@netlify/build-info/node"; import { envForProviders } from "@quests/ai-gateway"; import { execa, ExecaError, type ResultPromise } from "execa"; import ms from "ms"; @@ -11,12 +12,10 @@ import { import { type AppConfig } from "../lib/app-config/types"; import { cancelableTimeout, TimeoutError } from "../lib/cancelable-timeout"; +import { getDevCommand } from "../lib/get-dev-command"; +import { getInstallCommand } from "../lib/get-install-command"; import { pathExists } from "../lib/path-exists"; import { PortManager } from "../lib/port-manager"; -import { - detectRuntimeTypeFromDirectory, - getRuntimeConfigByType, -} from "../lib/runtime-config"; import { getWorkspaceServerURL } from "./server/url"; const BASE_RUNTIME_TIMEOUT_MS = ms("1 minute"); @@ -125,76 +124,57 @@ export const spawnRuntimeLogic = fromCallback< }; async function main() { - port = await portManager.reservePort(); + const buildInfo = await getBuildInfo({ projectDir: appConfig.appDir }); - if (!port) { + if (buildInfo.frameworks.length === 0) { parentRef.send({ isRetryable: false, shouldLog: true, type: "spawnRuntime.error.unknown", - value: { error: new Error("Failed to initialize port") }, + value: { + error: new Error( + "No frameworks detected. Ensure a framework like Next.js, Nuxt.js, or Vite exists in the package.json.", + ), + }, }); return; } - if (!(await pathExists(appConfig.appDir))) { + port = await portManager.reservePort(); + + if (!port) { parentRef.send({ isRetryable: false, shouldLog: true, - type: "spawnRuntime.error.app-dir-does-not-exist", - value: { - error: new Error(`App directory does not exist: ${appConfig.appDir}`), - }, + type: "spawnRuntime.error.unknown", + value: { error: new Error("Failed to initialize port") }, }); return; } - const runtimeTypeResult = await detectRuntimeTypeFromDirectory( - appConfig.appDir, - ); - - if (runtimeTypeResult.isErr()) { - const { message, scriptName } = runtimeTypeResult.error; + if (!(await pathExists(appConfig.appDir))) { parentRef.send({ isRetryable: false, shouldLog: true, - type: scriptName - ? "spawnRuntime.error.unsupported-script" - : "spawnRuntime.error.package-json", + type: "spawnRuntime.error.app-dir-does-not-exist", value: { - error: new Error(message), + error: new Error(`App directory does not exist: ${appConfig.appDir}`), }, }); return; } - const runtimeType = runtimeTypeResult.value; - const runtimeConfig = getRuntimeConfigByType(runtimeType); - const installTimeout = cancelableTimeout(INSTALL_TIMEOUT_MS); const installSignal = AbortSignal.any([ abortController.signal, installTimeout.controller.signal, ]); - if (!runtimeConfig) { - parentRef.send({ - isRetryable: false, - shouldLog: true, - type: "spawnRuntime.error.unknown", - value: { - error: new Error( - "Unsupported runtime type. Supported: vite, next, nuxt", - ), - }, - }); - return; - } - const installCommand = runtimeConfig.installCommand(appConfig); + const packageManager = getInstallCommand({ appConfig, buildInfo }); parentRef.send({ type: "spawnRuntime.log", value: { - message: `$ ${installCommand.join(" ")}`, + message: `Installing dependencies with ${packageManager.name}`, type: "normal", }, }); @@ -204,7 +184,7 @@ export const spawnRuntimeLogic = fromCallback< cwd: appConfig.appDir, env: baseEnv, node: true, - })`${installCommand}`; + })`${packageManager.installCommand}`; sendProcessLogs(installProcess, parentRef); await installProcess; @@ -220,24 +200,30 @@ export const spawnRuntimeLogic = fromCallback< timeout.controller.signal, ]); - const devServerCommand = await runtimeConfig.devCommand({ - appDir: appConfig.appDir, + const devServerCommandResult = await getDevCommand({ + appConfig, + buildInfo, port, }); - if (!devServerCommand) { + if (devServerCommandResult.isErr()) { parentRef.send({ isRetryable: false, shouldLog: true, type: "spawnRuntime.error.unknown", - value: { error: new Error("Failed to get dev server command") }, + value: { error: devServerCommandResult.error }, }); return; } + const { command, framework } = devServerCommandResult.value; + parentRef.send({ type: "spawnRuntime.log", - value: { message: `Starting ${runtimeType} dev server`, type: "normal" }, + value: { + message: `Starting ${framework.name} dev server`, + type: "normal", + }, }); timeout.start(); @@ -248,10 +234,11 @@ export const spawnRuntimeLogic = fromCallback< ...providerEnv, ...baseEnv, NO_COLOR: "1", + PORT: port.toString(), QUESTS_INSIDE_STUDIO: "true", }, node: true, - })`${devServerCommand}`; + })`${command}`; sendProcessLogs(runtimeProcess, parentRef); let shouldCheckServer = true; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f7b6f6e5..7ad2804bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -690,6 +690,9 @@ importers: '@hono/node-server': specifier: ^1.18.1 version: 1.18.1(hono@4.8.5) + '@netlify/build-info': + specifier: ^10.0.9 + version: 10.0.9 '@orpc/server': specifier: 'catalog:' version: 1.8.8(@opentelemetry/api@1.9.0)(crossws@0.3.4)(ws@8.18.3) @@ -741,9 +744,6 @@ importers: radashi: specifier: ^12.6.0 version: 12.6.0 - read-pkg: - specifier: ^9.0.1 - version: 9.0.1 superjson: specifier: ^2.2.2 version: 2.2.2 @@ -1037,6 +1037,24 @@ packages: '@better-fetch/fetch@1.1.18': resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==} + '@bugsnag/browser@8.6.0': + resolution: {integrity: sha512-7UGqTGnQqXUQ09gOlWbDTFUSbeLIIrP+hML3kTOq8Zdc8nP/iuOEflXGLV2TxWBWW8xIUPc928caFPr9EcaDuw==} + + '@bugsnag/core@8.6.0': + resolution: {integrity: sha512-94Jo443JegaiKV8z8NXMFdyTGubiUnwppWhq3kG2ldlYKtEvrmIaO5+JA58B6oveySvoRu3cCe2W9ysY7G7mDw==} + + '@bugsnag/cuid@3.2.1': + resolution: {integrity: sha512-zpvN8xQ5rdRWakMd/BcVkdn2F8HKlDSbM3l7duueK590WmI1T0ObTLc1V/1e55r14WNjPd5AJTYX4yPEAFVi+Q==} + + '@bugsnag/js@8.6.0': + resolution: {integrity: sha512-U+ofNTTMA2Z6tCrOhK/QhHBhLoQHoalk8Y82WWc7FAcVSoJZYadND/QuXUriNRZpC4YgJ/s/AxPeQ2y+WvMxzw==} + + '@bugsnag/node@8.6.0': + resolution: {integrity: sha512-O91sELo6zBjflVeP3roRC9l68iYaafVs5lz2N0FDkrT08mP2UljtNWpjjoR/0h1so5Ny1OxHgnZ1IrsXhz5SMQ==} + + '@bugsnag/safe-json-stringify@6.1.0': + resolution: {integrity: sha512-ImA35rnM7bGr+J30R979FQ95BhRB4UO1KfJA0J2sVqc8nwnrS9hhE5mkTmQWMs8Vh1Da+hkLKs5jJB4JjNZp4A==} + '@bundled-es-modules/cookie@2.0.1': resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==} @@ -1609,6 +1627,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iarna/toml@2.2.5': + resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@img/sharp-darwin-arm64@0.34.3': resolution: {integrity: sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1881,6 +1902,11 @@ packages: '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@netlify/build-info@10.0.9': + resolution: {integrity: sha512-lkcEejs4D0gwDIVtyRpIXXIv4SPZOii9cstGI5eOsMwoMTlZRL/jniZOSeMk2ZS147l9ncD6vtKxaZPnW1MJew==} + engines: {node: '>=18.14.0'} + hasBin: true + '@noble/ciphers@0.6.0': resolution: {integrity: sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ==} @@ -3726,6 +3752,10 @@ packages: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} + byline@5.0.0: + resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} + engines: {node: '>=0.10.0'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -4464,6 +4494,9 @@ packages: error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + es-abstract@1.24.0: resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} engines: {node: '>= 0.4'} @@ -4877,6 +4910,10 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + find-up@7.0.0: + resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} + engines: {node: '>=18'} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -5497,6 +5534,9 @@ packages: resolution: {integrity: sha512-V3W56Hnztt4Wdh3VUlAMbdNicX/tOM38eChW3a2ixP6KEBJAeehxzYzTD59JrU5NCTgBZwRt9lRWr8D7eMZVYQ==} engines: {node: '>=18'} + iserror@0.0.2: + resolution: {integrity: sha512-oKGGrFVaWwETimP3SiWwjDeY27ovZoyZPHtxblC4hCq9fXxed/jasx+ATWFFjCVSRZng8VTMsN1nDnGo6zMBSw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -5746,6 +5786,10 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash.castarray@4.4.0: resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} @@ -6345,6 +6389,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -6353,6 +6401,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-map@4.0.0: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} @@ -6402,6 +6454,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -7133,9 +7189,15 @@ packages: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} + stack-generator@2.0.10: + resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -8023,6 +8085,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.1: + resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} + engines: {node: '>=12.20'} + yoctocolors-cjs@2.1.3: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} @@ -8341,6 +8407,36 @@ snapshots: '@better-fetch/fetch@1.1.18': {} + '@bugsnag/browser@8.6.0': + dependencies: + '@bugsnag/core': 8.6.0 + + '@bugsnag/core@8.6.0': + dependencies: + '@bugsnag/cuid': 3.2.1 + '@bugsnag/safe-json-stringify': 6.1.0 + error-stack-parser: 2.1.4 + iserror: 0.0.2 + stack-generator: 2.0.10 + + '@bugsnag/cuid@3.2.1': {} + + '@bugsnag/js@8.6.0': + dependencies: + '@bugsnag/browser': 8.6.0 + '@bugsnag/node': 8.6.0 + + '@bugsnag/node@8.6.0': + dependencies: + '@bugsnag/core': 8.6.0 + byline: 5.0.0 + error-stack-parser: 2.1.4 + iserror: 0.0.2 + pump: 3.0.3 + stack-generator: 2.0.10 + + '@bugsnag/safe-json-stringify@6.1.0': {} + '@bundled-es-modules/cookie@2.0.1': dependencies: cookie: 0.7.2 @@ -8910,6 +9006,8 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iarna/toml@2.2.5': {} + '@img/sharp-darwin-arm64@0.34.3': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.2.0 @@ -9211,6 +9309,18 @@ snapshots: '@neon-rs/load@0.0.4': optional: true + '@netlify/build-info@10.0.9': + dependencies: + '@bugsnag/js': 8.6.0 + '@iarna/toml': 2.2.5 + dot-prop: 9.0.0 + find-up: 7.0.0 + minimatch: 9.0.5 + read-pkg: 9.0.1 + semver: 7.7.2 + yaml: 2.8.0 + yargs: 17.7.2 + '@noble/ciphers@0.6.0': {} '@noble/hashes@1.8.0': {} @@ -11275,6 +11385,8 @@ snapshots: builtin-modules@3.3.0: {} + byline@5.0.0: {} + cac@6.7.14: {} cacache@16.1.3: @@ -12016,6 +12128,10 @@ snapshots: dependencies: is-arrayish: 0.2.1 + error-stack-parser@2.1.4: + dependencies: + stackframe: 1.3.4 + es-abstract@1.24.0: dependencies: array-buffer-byte-length: 1.0.2 @@ -12629,6 +12745,12 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + find-up@7.0.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + unicorn-magic: 0.1.0 + flat-cache@4.0.1: dependencies: flatted: 3.3.3 @@ -13317,6 +13439,8 @@ snapshots: isbot@5.1.27: {} + iserror@0.0.2: {} + isexe@2.0.0: {} isexe@3.1.1: {} @@ -13546,6 +13670,10 @@ snapshots: dependencies: p-locate: 5.0.0 + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + lodash.castarray@4.4.0: {} lodash.defaults@4.2.0: @@ -14466,6 +14594,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.1 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -14474,6 +14606,10 @@ snapshots: dependencies: p-limit: 3.1.0 + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + p-map@4.0.0: dependencies: aggregate-error: 3.1.0 @@ -14534,6 +14670,8 @@ snapshots: path-exists@4.0.0: {} + path-exists@5.0.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -15359,8 +15497,14 @@ snapshots: stable-hash-x@0.2.0: {} + stack-generator@2.0.10: + dependencies: + stackframe: 1.3.4 + stackback@0.0.2: {} + stackframe@1.3.4: {} + standard-as-callback@2.1.0: optional: true @@ -16386,6 +16530,8 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.1: {} + yoctocolors-cjs@2.1.3: optional: true From e4724b572f18bbb6f3dfcc4415da46acca6b2ea4 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 14:30:05 -0500 Subject: [PATCH 30/40] fix(workspace): preserve query parameters and forward proto for proxy --- packages/workspace/src/logic/server/routes/all-proxy.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/workspace/src/logic/server/routes/all-proxy.ts b/packages/workspace/src/logic/server/routes/all-proxy.ts index 64df7ea9c..1feba41ca 100644 --- a/packages/workspace/src/logic/server/routes/all-proxy.ts +++ b/packages/workspace/src/logic/server/routes/all-proxy.ts @@ -62,13 +62,15 @@ app.all("/*", async (c, next) => { return c.html(fallbackPage); } - const url = `http://localhost:${port}${c.req.path}`; + const requestUrl = new URL(c.req.url); + const url = `http://localhost:${port}${requestUrl.pathname}${requestUrl.search}`; const headers = new Headers(c.req.raw.headers); headers.set( "X-Forwarded-For", c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || "127.0.0.1", ); headers.set("X-Forwarded-Host", host); + headers.set("X-Forwarded-Proto", requestUrl.protocol.replace(":", "")); headers.delete("if-none-match"); headers.delete("if-modified-since"); From cc08e2ac6c08684e511b6774c34e47777f75f0f3 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 14:56:46 -0500 Subject: [PATCH 31/40] wip: refactor to use execa without literal and tsc via execa locally --- .../{get-dev-command.ts => get-framework.ts} | 38 +++++++++---------- ...tall-command.ts => get-package-manager.ts} | 16 ++++---- packages/workspace/src/lib/run-diagnostics.ts | 13 +++++-- packages/workspace/src/lib/run-pnpm-bin.ts | 33 ++++++++++++++++ packages/workspace/src/logic/spawn-runtime.ts | 34 +++++++++-------- 5 files changed, 85 insertions(+), 49 deletions(-) rename packages/workspace/src/lib/{get-dev-command.ts => get-framework.ts} (73%) rename packages/workspace/src/lib/{get-install-command.ts => get-package-manager.ts} (70%) create mode 100644 packages/workspace/src/lib/run-pnpm-bin.ts diff --git a/packages/workspace/src/lib/get-dev-command.ts b/packages/workspace/src/lib/get-framework.ts similarity index 73% rename from packages/workspace/src/lib/get-dev-command.ts rename to packages/workspace/src/lib/get-framework.ts index bccd7026f..21a4cd296 100644 --- a/packages/workspace/src/lib/get-dev-command.ts +++ b/packages/workspace/src/lib/get-framework.ts @@ -11,7 +11,7 @@ import { TypedError } from "./errors"; import { PackageManager } from "./package-manager"; import { readPNPMShim } from "./read-pnpm-shim"; -export async function getDevCommand({ +export async function getFramework({ appConfig, buildInfo: { frameworks, packageManager }, port, @@ -34,24 +34,18 @@ export async function getDevCommand({ ); } - if (packageManager?.name !== PackageManager.PNPM) { - const commandArgs = ["--port", port.toString()]; - return ok({ - command: packageManager - ? [ - ...parseCommandString(packageManager.runCommand), - devCommand, - ...devCommandArgs, - ...commandArgs, - ] - : [devCommand, ...devCommandArgs, ...commandArgs], - framework, - }); + if (packageManager && packageManager.name !== PackageManager.PNPM) { + return err( + new TypedError.NotFound( + `Unsupported package manager ${packageManager.name}`, + ), + ); } const binPathResult = await readPNPMShim( getBinShimPath(appConfig.appDir, devCommand), ); + if (binPathResult.isErr()) { return err(binPathResult.error); } @@ -61,20 +55,22 @@ export async function getDevCommand({ switch (devCommand) { case "next": { return ok({ - command: [binPath, ...devCommandArgs, "-p", port.toString()], - framework, + ...framework, + arguments: [...devCommandArgs, "-p", port.toString()], + command: binPath, }); } case "nuxt": { return ok({ - command: [binPath, ...devCommandArgs, "--port", port.toString()], - framework, + ...framework, + arguments: [...devCommandArgs, "--port", port.toString()], + command: binPath, }); } case "vite": { return ok({ - command: [ - binPath, + ...framework, + arguments: [ ...devCommandArgs, "--port", port.toString(), @@ -84,7 +80,7 @@ export async function getDevCommand({ "--logLevel", "warn", ], - framework, + command: binPath, }); } } diff --git a/packages/workspace/src/lib/get-install-command.ts b/packages/workspace/src/lib/get-package-manager.ts similarity index 70% rename from packages/workspace/src/lib/get-install-command.ts rename to packages/workspace/src/lib/get-package-manager.ts index c2e7b569e..51ed624c2 100644 --- a/packages/workspace/src/lib/get-install-command.ts +++ b/packages/workspace/src/lib/get-package-manager.ts @@ -4,7 +4,7 @@ import { parseCommandString } from "execa"; import { type AppConfig } from "./app-config/types"; import { PackageManager } from "./package-manager"; -export function getInstallCommand({ +export function getPackageManager({ appConfig, buildInfo: { packageManager }, }: { @@ -14,22 +14,20 @@ export function getInstallCommand({ // eslint-disable-next-line unicorn/prefer-ternary if (!packageManager || packageManager.name === PackageManager.PNPM) { return { - installCommand: + arguments: appConfig.type === "version" || appConfig.type === "sandbox" ? // These app types are nested in the project directory, so we need // to ignore the workspace config otherwise PNPM may not install the // dependencies correctly - [ - appConfig.workspaceConfig.pnpmBinPath, - "install", - "--ignore-workspace", - ] - : [appConfig.workspaceConfig.pnpmBinPath, "install"], + ["install", "--ignore-workspace"] + : ["install"], + command: appConfig.workspaceConfig.pnpmBinPath, name: PackageManager.PNPM, }; } else { return { - installCommand: parseCommandString(packageManager.installCommand), + arguments: parseCommandString(packageManager.installCommand), + command: packageManager.installCommand, name: packageManager.name, }; } diff --git a/packages/workspace/src/lib/run-diagnostics.ts b/packages/workspace/src/lib/run-diagnostics.ts index 33feaeaf9..50b832a31 100644 --- a/packages/workspace/src/lib/run-diagnostics.ts +++ b/packages/workspace/src/lib/run-diagnostics.ts @@ -3,6 +3,7 @@ import { ExecaError } from "execa"; import type { AppConfig } from "./app-config/types"; import { cancelableTimeout } from "./cancelable-timeout"; +import { runNodeModuleBin } from "./run-pnpm-bin"; export async function runDiagnostics( appConfig: AppConfig, @@ -20,9 +21,11 @@ export async function runDiagnostics( // not just the specific file. This is a limitation of TypeScript CLI. // In the future, this will be replaced with LSP-based diagnostics that // can target specific files efficiently. - const diagnosticsResult = await appConfig.workspaceConfig.runShellCommand( - "tsc --noEmit", - { cwd: appConfig.appDir, signal: diagnosticsSignal }, + const diagnosticsResult = await runNodeModuleBin( + appConfig.appDir, + "tsc", + ["--noEmit"], + { cancelSignal: diagnosticsSignal }, ); timeout.cancel(); @@ -30,7 +33,9 @@ export async function runDiagnostics( if (diagnosticsResult.isOk()) { try { const result = await diagnosticsResult.value; - const output = result.stderr + result.stdout; + const stderr = result.stderr?.toString() ?? ""; + const stdout = result.stdout?.toString() ?? ""; + const output = stderr + stdout; if (output.trim() && result.exitCode !== 0) { return output.trim(); } diff --git a/packages/workspace/src/lib/run-pnpm-bin.ts b/packages/workspace/src/lib/run-pnpm-bin.ts new file mode 100644 index 000000000..351ab662d --- /dev/null +++ b/packages/workspace/src/lib/run-pnpm-bin.ts @@ -0,0 +1,33 @@ +import { execa, type Options } from "execa"; +import { err, ok } from "neverthrow"; + +import { type AppDir } from "../schemas/paths"; +import { absolutePathJoin } from "./absolute-path-join"; +import { TypedError } from "./errors"; +import { readPNPMShim } from "./read-pnpm-shim"; + +export async function runNodeModuleBin( + appDir: AppDir, + bin: string, + args: string[], + options?: Options, +) { + const shimPath = absolutePathJoin(appDir, "node_modules", ".bin", bin); + const binPathResult = await readPNPMShim(shimPath); + + if (binPathResult.isErr()) { + return err(binPathResult.error); + } + + const binPath = binPathResult.value; + + try { + return ok(execa(binPath, args, { cwd: appDir, node: true, ...options })); + } catch (error) { + return err( + new TypedError.NotFound( + `Failed to execute ${bin}: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + } +} diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index d79554301..4e72cb7d1 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -12,8 +12,8 @@ import { import { type AppConfig } from "../lib/app-config/types"; import { cancelableTimeout, TimeoutError } from "../lib/cancelable-timeout"; -import { getDevCommand } from "../lib/get-dev-command"; -import { getInstallCommand } from "../lib/get-install-command"; +import { getFramework } from "../lib/get-framework"; +import { getPackageManager } from "../lib/get-package-manager"; import { pathExists } from "../lib/path-exists"; import { PortManager } from "../lib/port-manager"; import { getWorkspaceServerURL } from "./server/url"; @@ -169,7 +169,7 @@ export const spawnRuntimeLogic = fromCallback< abortController.signal, installTimeout.controller.signal, ]); - const packageManager = getInstallCommand({ appConfig, buildInfo }); + const packageManager = getPackageManager({ appConfig, buildInfo }); parentRef.send({ type: "spawnRuntime.log", @@ -179,12 +179,16 @@ export const spawnRuntimeLogic = fromCallback< }, }); - const installProcess = execa({ - cancelSignal: installSignal, - cwd: appConfig.appDir, - env: baseEnv, - node: true, - })`${packageManager.installCommand}`; + const installProcess = execa( + packageManager.command, + packageManager.arguments, + { + cancelSignal: installSignal, + cwd: appConfig.appDir, + env: baseEnv, + node: true, + }, + ); sendProcessLogs(installProcess, parentRef); await installProcess; @@ -200,23 +204,23 @@ export const spawnRuntimeLogic = fromCallback< timeout.controller.signal, ]); - const devServerCommandResult = await getDevCommand({ + const frameworkResult = await getFramework({ appConfig, buildInfo, port, }); - if (devServerCommandResult.isErr()) { + if (frameworkResult.isErr()) { parentRef.send({ isRetryable: false, shouldLog: true, type: "spawnRuntime.error.unknown", - value: { error: devServerCommandResult.error }, + value: { error: frameworkResult.error }, }); return; } - const { command, framework } = devServerCommandResult.value; + const framework = frameworkResult.value; parentRef.send({ type: "spawnRuntime.log", @@ -227,7 +231,7 @@ export const spawnRuntimeLogic = fromCallback< }); timeout.start(); - const runtimeProcess = execa({ + const runtimeProcess = execa(framework.command, framework.arguments, { cancelSignal: signal, cwd: appConfig.appDir, env: { @@ -238,7 +242,7 @@ export const spawnRuntimeLogic = fromCallback< QUESTS_INSIDE_STUDIO: "true", }, node: true, - })`${command}`; + }); sendProcessLogs(runtimeProcess, parentRef); let shouldCheckServer = true; From 7f452dd59a0af7f02e020d23bc5028c03f1d21dd Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 15:12:29 -0500 Subject: [PATCH 32/40] wip: use direct bin for shell command --- .../workspace/src/lib/execa-electron-node.ts | 18 ++++++ packages/workspace/src/logic/spawn-runtime.ts | 43 ++++++------- .../src/machines/execute-tool-call.test.ts | 59 +++++++++--------- .../workspace/src/tools/run-shell-command.ts | 60 ++++++++++++++----- 4 files changed, 112 insertions(+), 68 deletions(-) create mode 100644 packages/workspace/src/lib/execa-electron-node.ts diff --git a/packages/workspace/src/lib/execa-electron-node.ts b/packages/workspace/src/lib/execa-electron-node.ts new file mode 100644 index 000000000..0ad9db0b4 --- /dev/null +++ b/packages/workspace/src/lib/execa-electron-node.ts @@ -0,0 +1,18 @@ +import { execa, type Options } from "execa"; + +export function execaElectronNode( + file: string | URL, + arguments_?: readonly string[], + options?: OptionsType, +) { + return execa(file, arguments_, { + ...options, + env: { + ...options?.env, + // Required for normal node processes to work + // See https://www.electronjs.org/docs/latest/api/environment-variables + ELECTRON_RUN_AS_NODE: "1", + }, + node: true, + } as unknown as OptionsType & { env: Record; node: true }); +} diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index 4e72cb7d1..2f39d2c39 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -1,6 +1,6 @@ import { getBuildInfo } from "@netlify/build-info/node"; import { envForProviders } from "@quests/ai-gateway"; -import { execa, ExecaError, type ResultPromise } from "execa"; +import { ExecaError, type ResultPromise } from "execa"; import ms from "ms"; import { type ActorRef, @@ -12,6 +12,7 @@ import { import { type AppConfig } from "../lib/app-config/types"; import { cancelableTimeout, TimeoutError } from "../lib/cancelable-timeout"; +import { execaElectronNode } from "../lib/execa-electron-node"; import { getFramework } from "../lib/get-framework"; import { getPackageManager } from "../lib/get-package-manager"; import { pathExists } from "../lib/path-exists"; @@ -66,13 +67,13 @@ async function isLocalServerRunning(port: number) { } function sendProcessLogs( - execaProcess: ResultPromise<{ cancelSignal: AbortSignal; cwd: string }>, + execaProcess: ResultPromise, parentRef: ActorRef, options: { errorOnly?: boolean } = {}, ) { const { stderr, stdout } = execaProcess; - stderr.on("data", (data: Buffer) => { + stderr?.on("data", (data: Buffer) => { const message = data.toString().trim(); if (message) { if ( @@ -91,7 +92,7 @@ function sendProcessLogs( }); if (!options.errorOnly) { - stdout.on("data", (data: Buffer) => { + stdout?.on("data", (data: Buffer) => { const message = data.toString().trim(); if (message) { parentRef.send({ @@ -117,12 +118,6 @@ export const spawnRuntimeLogic = fromCallback< let port: number | undefined; - const baseEnv = { - // Required for normal node processes to work - // See https://www.electronjs.org/docs/latest/api/environment-variables - ELECTRON_RUN_AS_NODE: "1", - }; - async function main() { const buildInfo = await getBuildInfo({ projectDir: appConfig.appDir }); @@ -179,14 +174,12 @@ export const spawnRuntimeLogic = fromCallback< }, }); - const installProcess = execa( + const installProcess = execaElectronNode( packageManager.command, packageManager.arguments, { cancelSignal: installSignal, cwd: appConfig.appDir, - env: baseEnv, - node: true, }, ); @@ -231,18 +224,20 @@ export const spawnRuntimeLogic = fromCallback< }); timeout.start(); - const runtimeProcess = execa(framework.command, framework.arguments, { - cancelSignal: signal, - cwd: appConfig.appDir, - env: { - ...providerEnv, - ...baseEnv, - NO_COLOR: "1", - PORT: port.toString(), - QUESTS_INSIDE_STUDIO: "true", + const runtimeProcess = execaElectronNode( + framework.command, + framework.arguments, + { + cancelSignal: signal, + cwd: appConfig.appDir, + env: { + ...providerEnv, + NO_COLOR: "1", + PORT: port.toString(), + QUESTS_INSIDE_STUDIO: "true", + }, }, - node: true, - }); + ); sendProcessLogs(runtimeProcess, parentRef); let shouldCheckServer = true; diff --git a/packages/workspace/src/machines/execute-tool-call.test.ts b/packages/workspace/src/machines/execute-tool-call.test.ts index d6ecf74c3..c48a09db4 100644 --- a/packages/workspace/src/machines/execute-tool-call.test.ts +++ b/packages/workspace/src/machines/execute-tool-call.test.ts @@ -15,48 +15,48 @@ import { executeToolCallMachine } from "./execute-tool-call"; vi.mock(import("ulid")); vi.mock(import("../lib/session-store-storage")); vi.mock(import("../lib/get-current-date")); +vi.mock(import("../lib/execa-electron-node"), () => ({ + execaElectronNode: vi.fn(), +})); describe("executeToolCallMachine", () => { const projectAppConfig = createMockAppConfig( ProjectSubdomainSchema.parse("test"), - { - runShellCommand: vi.fn().mockImplementation((command: string) => { + ); + const sessionId = StoreId.newSessionId(); + const messageId = StoreId.newMessageId(); + const mockDate = new Date("2025-01-01T00:00:00.000Z"); + + beforeEach(async () => { + const { execaElectronNode } = await import("../lib/execa-electron-node"); + vi.mocked(execaElectronNode).mockImplementation( + async (file, args, _options) => { + const command = [file, ...(args ?? [])].join(" "); + if (command.includes("throw-error")) { throw new Error("Shell command failed"); } if (command.includes("hang-command")) { - return Promise.resolve({ - isErr: () => false, - value: new Promise((resolve) => { - setTimeout(() => { - resolve({ - exitCode: 0, - stderr: "mocked stderr", - stdout: "mocked stdout", - }); - }, 100); - }), + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + exitCode: 0, + stderr: "mocked stderr", + stdout: "mocked stdout", + }); + }, 100); }); } - // Default successful command - return Promise.resolve({ - isErr: () => false, - value: Promise.resolve({ - exitCode: 0, - stderr: "mocked stderr", - stdout: "mocked stdout", - }), - }); - }), - }, - ); - const sessionId = StoreId.newSessionId(); - const messageId = StoreId.newMessageId(); - const mockDate = new Date("2025-01-01T00:00:00.000Z"); + return { + exitCode: 0, + stderr: "mocked stderr", + stdout: "mocked stdout", + }; + }, + ); - beforeEach(async () => { mockFs({ [MOCK_WORKSPACE_DIRS.projects]: { [projectAppConfig.folderName]: { @@ -94,6 +94,7 @@ describe("executeToolCallMachine", () => { afterEach(() => { mockFs.restore(); + vi.clearAllMocks(); }); function createTestActor({ diff --git a/packages/workspace/src/tools/run-shell-command.ts b/packages/workspace/src/tools/run-shell-command.ts index 8f40c0f5c..d15fb7425 100644 --- a/packages/workspace/src/tools/run-shell-command.ts +++ b/packages/workspace/src/tools/run-shell-command.ts @@ -9,8 +9,10 @@ import { z } from "zod"; import type { AppConfig } from "../lib/app-config/types"; import { absolutePathJoin } from "../lib/absolute-path-join"; +import { execaElectronNode } from "../lib/execa-electron-node"; import { fixRelativePath } from "../lib/fix-relative-path"; import { pathExists } from "../lib/path-exists"; +import { readPNPMShim } from "../lib/read-pnpm-shim"; import { BaseInputSchema } from "./base"; import { createTool } from "./create-tool"; @@ -38,6 +40,11 @@ const AVAILABLE_COMMANDS = { example: "rm temp/cache.json or rm -r temp/", isFileOperation: true, }, + tsc: { + description: "TypeScript compiler", + example: "tsc", + isFileOperation: false, + }, } as const; const SHELL_COMMANDS = new Set( @@ -403,23 +410,46 @@ export const RunShellCommand = createTool({ }); } - const safeResult = await appConfig.workspaceConfig.runShellCommand( - input.command, - { cwd: appConfig.appDir, signal }, - ); - if (safeResult.isErr()) { - return err({ - message: safeResult.error.message, - type: "execute-error", + if (commandName === "pnpm") { + const process = await execaElectronNode( + appConfig.workspaceConfig.pnpmBinPath, + args, + { + cancelSignal: signal, + cwd: appConfig.appDir, + }, + ); + return ok({ + command: input.command, + exitCode: process.exitCode ?? 0, + stderr: process.stderr, + stdout: process.stdout, + }); + } else if (commandName === "tsc") { + // TODO: Allow more bins + const binPath = await readPNPMShim( + absolutePathJoin(appConfig.appDir, "node_modules", ".bin", commandName), + ); + if (binPath.isErr()) { + return err({ + message: binPath.error.message, + type: "execute-error", + }); + } + const process = await execaElectronNode(binPath.value, args, { + cancelSignal: signal, + cwd: appConfig.appDir, + }); + return ok({ + command: input.command, + exitCode: process.exitCode ?? 0, + stderr: process.stderr, + stdout: process.stdout, }); } - const result = await safeResult.value; - - return ok({ - command: input.command, - exitCode: result.exitCode ?? 0, - stderr: result.stderr, - stdout: result.stdout, + return err({ + message: `Invalid command. The available commands are: ${Object.keys(AVAILABLE_COMMANDS).join(", ")}.`, + type: "execute-error", }); }, inputSchema: BaseInputSchema.extend({ From d073df8d8b48e74b66fe6f99a7f9b30e9dbe3b5b Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 15:18:54 -0500 Subject: [PATCH 33/40] wip: remove runshellcommand --- .../lib/create-workspace-actor.ts | 46 +------------------ packages/workspace/scripts/run-workspace.ts | 24 ---------- .../workspace/src/machines/workspace/index.ts | 3 -- .../src/test/helpers/mock-app-config.ts | 28 +---------- packages/workspace/src/tools/types.ts | 8 ---- packages/workspace/src/types.ts | 3 +- 6 files changed, 3 insertions(+), 109 deletions(-) diff --git a/apps/studio/src/electron-main/lib/create-workspace-actor.ts b/apps/studio/src/electron-main/lib/create-workspace-actor.ts index 4af79be22..9f4412d73 100644 --- a/apps/studio/src/electron-main/lib/create-workspace-actor.ts +++ b/apps/studio/src/electron-main/lib/create-workspace-actor.ts @@ -11,7 +11,7 @@ import { workspacePublisher, } from "@quests/workspace/electron"; import { app, shell } from "electron"; -import { execa, parseCommandString } from "execa"; +import { execa } from "execa"; import ms from "ms"; import { err, ok } from "neverthrow"; import path from "node:path"; @@ -21,7 +21,6 @@ import { getProvidersStore } from "../stores/providers"; import { captureServerEvent } from "./capture-server-event"; import { captureServerException } from "./capture-server-exception"; import { getFramework } from "./frameworks"; -import { getAllPackageBinaryPaths } from "./link-bins"; import { getPNPMBinPath } from "./setup-bin-directory"; const scopedLogger = logger.scope("workspace-actor"); @@ -85,49 +84,6 @@ export function createWorkspaceActor() { return err(error instanceof Error ? error : new Error(String(error))); } }, - runShellCommand: async (command, { cwd, signal }) => { - const [commandName, ...rest] = parseCommandString(command); - const pnpmPath = getPNPMBinPath(); - - if (commandName === "pnpm") { - return ok( - execa({ - cancelSignal: signal, - cwd, - env: execaEnv, - node: true, - })`${pnpmPath} ${rest}`, - ); - } - - if (commandName === "tsc") { - const binaryPaths = await getAllPackageBinaryPaths(cwd); - const binPaths = binaryPaths.get("typescript"); - - if (!binPaths) { - return err(new Error(`tsc not found in ${cwd}`)); - } - - const modulePath = binPaths.find( - (binPath) => path.basename(binPath) === "tsc", - ); - - if (!modulePath) { - return err(new Error(`tsc not found in ${cwd}`)); - } - - return ok( - execa({ - cancelSignal: signal, - cwd, - env: execaEnv, - node: true, - })`${modulePath} ${rest}`, - ); - } - - return err(new Error(`Not implemented: ${command}`)); - }, shimClientDir: app.isPackaged ? path.resolve(process.resourcesPath, "shim-client") : // Uncomment to test built shim diff --git a/packages/workspace/scripts/run-workspace.ts b/packages/workspace/scripts/run-workspace.ts index 552c5b5d1..8c074e361 100644 --- a/packages/workspace/scripts/run-workspace.ts +++ b/packages/workspace/scripts/run-workspace.ts @@ -142,30 +142,6 @@ const actor = createActor(workspaceMachine, { ); } }, - runShellCommand: (command, { cwd, signal }) => { - const [commandName, ...rest] = parseCommandString(command); - if (commandName === "pnpm") { - return Promise.resolve( - ok( - execa({ - cancelSignal: signal, - cwd, - })`${commandName} ${rest} --ignore-workspace`, - ), - ); - } - if (commandName === "tsc") { - return Promise.resolve( - ok( - execa({ - cancelSignal: signal, - cwd, - })`pnpm exec tsc ${rest.join(" ")}`, - ), - ); - } - return Promise.resolve(err(new Error(`Not implemented: ${command}`))); - }, // Uncomment to test built shim // shimClientDir: path.resolve("../shim-client/dist"), shimClientDir: "dev-server", diff --git a/packages/workspace/src/machines/workspace/index.ts b/packages/workspace/src/machines/workspace/index.ts index a5c817a3e..c69c85b93 100644 --- a/packages/workspace/src/machines/workspace/index.ts +++ b/packages/workspace/src/machines/workspace/index.ts @@ -42,7 +42,6 @@ import { import { type SessionMessage } from "../../schemas/session/message"; import { type StoreId } from "../../schemas/store-id"; import { type AppSubdomain } from "../../schemas/subdomains"; -import { type RunShellCommand } from "../../tools/types"; import { type WorkspaceConfig } from "../../types"; import { type ToolCallUpdate } from "../agent"; import { runtimeMachine } from "../runtime"; @@ -188,7 +187,6 @@ export const workspaceMachine = setup({ registryDir: string; rootDir: string; runPackageJsonScript: WorkspaceContext["runPackageJsonScript"]; - runShellCommand: RunShellCommand; shimClientDir: string; trashItem: (path: AbsolutePath) => Promise; }, @@ -210,7 +208,6 @@ export const workspaceMachine = setup({ ), registryDir: AbsolutePathSchema.parse(input.registryDir), rootDir: WorkspaceDirSchema.parse(input.rootDir), - runShellCommand: input.runShellCommand, trashItem: input.trashItem, }; return { diff --git a/packages/workspace/src/test/helpers/mock-app-config.ts b/packages/workspace/src/test/helpers/mock-app-config.ts index d0a8e78e3..d8f559be0 100644 --- a/packages/workspace/src/test/helpers/mock-app-config.ts +++ b/packages/workspace/src/test/helpers/mock-app-config.ts @@ -1,10 +1,6 @@ -import { execa } from "execa"; -import { ok } from "neverthrow"; - import { createAppConfig } from "../../lib/app-config/create"; import { AbsolutePathSchema, WorkspaceDirSchema } from "../../schemas/paths"; import { type AppSubdomain } from "../../schemas/subdomains"; -import { type WorkspaceConfig } from "../../types"; const MOCK_WORKSPACE_DIR = "/tmp/workspace"; @@ -14,14 +10,7 @@ export const MOCK_WORKSPACE_DIRS = { registry: `${MOCK_WORKSPACE_DIR}/registry`, } as const; -export function createMockAppConfig( - subdomain: AppSubdomain, - { - runShellCommand, - }: { - runShellCommand?: WorkspaceConfig["runShellCommand"]; - } = {}, -) { +export function createMockAppConfig(subdomain: AppSubdomain) { return createAppConfig({ subdomain, workspaceConfig: { @@ -38,21 +27,6 @@ export function createMockAppConfig( projectsDir: AbsolutePathSchema.parse(MOCK_WORKSPACE_DIRS.projects), registryDir: AbsolutePathSchema.parse(MOCK_WORKSPACE_DIRS.registry), rootDir: WorkspaceDirSchema.parse(MOCK_WORKSPACE_DIR), - runShellCommand: - runShellCommand ?? - (( - command: string, - { cwd, signal }: { cwd: string; signal: AbortSignal }, - ) => { - return Promise.resolve( - ok( - execa({ - cancelSignal: signal, - cwd, - })`echo '${command} not mocked'`, - ), - ); - }), trashItem: () => Promise.resolve(), }, }); diff --git a/packages/workspace/src/tools/types.ts b/packages/workspace/src/tools/types.ts index 1cdbb0a0b..8ec9a7ce7 100644 --- a/packages/workspace/src/tools/types.ts +++ b/packages/workspace/src/tools/types.ts @@ -34,14 +34,6 @@ export interface AgentTool< // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyAgentTool = AgentTool; -export type RunShellCommand = ( - command: string, - options: { - cwd: string; - signal: AbortSignal; - }, -) => ShellResult; - export type ShellResult = Promise< Result, Error> >; diff --git a/packages/workspace/src/types.ts b/packages/workspace/src/types.ts index 48da04550..d57565b06 100644 --- a/packages/workspace/src/types.ts +++ b/packages/workspace/src/types.ts @@ -6,7 +6,7 @@ import { import { type APP_STATUSES } from "./constants"; import { type AbsolutePath, type WorkspaceDir } from "./schemas/paths"; -import { type RunShellCommand, type ShellResult } from "./tools/types"; +import { type ShellResult } from "./tools/types"; export type AppStatus = (typeof APP_STATUSES)[number]; @@ -30,6 +30,5 @@ export interface WorkspaceConfig { projectsDir: AbsolutePath; registryDir: AbsolutePath; rootDir: WorkspaceDir; - runShellCommand: RunShellCommand; trashItem: (path: AbsolutePath) => Promise; } From d8e1d9e07a2f058955e3d61a29ddf2c1063022c4 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 15:24:36 -0500 Subject: [PATCH 34/40] wip: remove runPackageJsonScript --- apps/studio/package.json | 7 -- .../lib/create-workspace-actor.ts | 33 ----- .../src/electron-main/lib/frameworks.ts | 63 ---------- .../studio/src/electron-main/lib/link-bins.ts | 102 --------------- packages/workspace/scripts/run-workspace.ts | 58 +-------- packages/workspace/src/machines/runtime.ts | 5 +- .../workspace/src/machines/workspace/index.ts | 3 - .../workspace/src/machines/workspace/types.ts | 3 +- packages/workspace/src/tools/types.ts | 5 - packages/workspace/src/types.ts | 11 -- pnpm-lock.yaml | 119 ------------------ 11 files changed, 3 insertions(+), 406 deletions(-) delete mode 100644 apps/studio/src/electron-main/lib/frameworks.ts delete mode 100644 apps/studio/src/electron-main/lib/link-bins.ts diff --git a/apps/studio/package.json b/apps/studio/package.json index 289eaabdf..651e68e34 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -56,9 +56,6 @@ "@orpc/client": "catalog:", "@orpc/server": "catalog:", "@orpc/tanstack-query": "catalog:", - "@pnpm/package-bins": "^1000.0.3", - "@pnpm/read-package-json": "^1000.0.4", - "@pnpm/types": "^1000.2.0", "@quests/ai-gateway": "workspace:*", "@quests/components": "workspace:*", "@quests/shared": "workspace:*", @@ -97,14 +94,11 @@ "electron-log": "^5.4.1", "electron-store": "^10.1.0", "electron-updater": "^6.6.8", - "execa": "^9.6.0", "framer-motion": "11.13.5", "hono": "catalog:", "jotai": "^2.14.0", "lucide-react": "catalog:", "ms": "^2.1.3", - "neverthrow": "^8.1.1", - "normalize-path": "^3.0.0", "pnpm": "^10.13.1", "posthog-js": "^1.258.6", "posthog-node": "^5.6.0", @@ -133,7 +127,6 @@ "@types/color-hash": "^2.0.0", "@types/ms": "^2.1.0", "@types/node": "22.16.0", - "@types/normalize-path": "^3.0.2", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@types/semver": "^7.7.0", diff --git a/apps/studio/src/electron-main/lib/create-workspace-actor.ts b/apps/studio/src/electron-main/lib/create-workspace-actor.ts index 9f4412d73..7089de2e9 100644 --- a/apps/studio/src/electron-main/lib/create-workspace-actor.ts +++ b/apps/studio/src/electron-main/lib/create-workspace-actor.ts @@ -11,27 +11,19 @@ import { workspacePublisher, } from "@quests/workspace/electron"; import { app, shell } from "electron"; -import { execa } from "execa"; import ms from "ms"; -import { err, ok } from "neverthrow"; import path from "node:path"; import { createActor } from "xstate"; import { getProvidersStore } from "../stores/providers"; import { captureServerEvent } from "./capture-server-event"; import { captureServerException } from "./capture-server-exception"; -import { getFramework } from "./frameworks"; import { getPNPMBinPath } from "./setup-bin-directory"; const scopedLogger = logger.scope("workspace-actor"); export function createWorkspaceActor() { const rootDir = path.join(app.getPath("userData"), WORKSPACE_FOLDER); - const execaEnv = { - // Required for normal node processes to work - // See https://www.electronjs.org/docs/latest/api/environment-variables - ELECTRON_RUN_AS_NODE: "1", - }; const actor = createActor(workspaceMachine, { input: { aiGatewayApp, @@ -59,31 +51,6 @@ export function createWorkspaceActor() { ? path.join(process.resourcesPath, REGISTRY_DIR_NAME) : path.resolve(import.meta.dirname, REGISTRY_DEV_DIR_PATH), rootDir, - runPackageJsonScript: async ({ cwd, script, scriptOptions, signal }) => { - const { framework, frameworkModulePath } = await getFramework({ - rootDir: cwd, - script, - }); - if (!framework) { - return err(new Error(`Unsupported framework: ${script}`)); - } - - try { - return ok( - execa({ - cancelSignal: signal, - cwd, - env: { - ...execaEnv, - ...scriptOptions.env, - }, - node: true, - })`${frameworkModulePath} ${framework.args("dev", scriptOptions.port)}`, - ); - } catch (error) { - return err(error instanceof Error ? error : new Error(String(error))); - } - }, shimClientDir: app.isPackaged ? path.resolve(process.resourcesPath, "shim-client") : // Uncomment to test built shim diff --git a/apps/studio/src/electron-main/lib/frameworks.ts b/apps/studio/src/electron-main/lib/frameworks.ts deleted file mode 100644 index a1583fe4c..000000000 --- a/apps/studio/src/electron-main/lib/frameworks.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { getAllPackageBinaryPaths } from "@/electron-main/lib/link-bins"; - -const FrameworkErrorCode = { - missingDevScript: "missing_dev_script", - missingPackageJson: "missing_package_json", - missingRootPath: "missing_root_path", - unsupportedFramework: "unsupported_framework", -} as const; - -const Frameworks = { - next: { - args: (subCommand: string, serverPort: number) => [ - subCommand, - "--port", - String(serverPort), - ], - bin: "next", - }, - vite: { - args: (subCommand: string, serverPort: number) => [ - subCommand, - "--port", - String(serverPort), - "--strictPort", - "--clearScreen", - "false", - // Avoids logging confusing localhost and port info - "--logLevel", - "warn", - ], - bin: "vite", - }, -}; - -export const getFramework = async ({ - rootDir, - script, -}: { - rootDir: string; - script: string; -}) => { - const binaryPaths = await getAllPackageBinaryPaths(rootDir); - - const frameworkType = script - ? Object.keys(Frameworks).find((type) => script.includes(type)) - : null; - - const framework = frameworkType - ? Frameworks[frameworkType as keyof typeof Frameworks] - : null; - const [frameworkModulePath] = framework?.bin - ? (binaryPaths.get(framework.bin) ?? []) - : []; - - return framework && frameworkModulePath - ? { - framework, - frameworkModulePath, - } - : { - error: FrameworkErrorCode.unsupportedFramework, - }; -}; diff --git a/apps/studio/src/electron-main/lib/link-bins.ts b/apps/studio/src/electron-main/lib/link-bins.ts deleted file mode 100644 index 4a727d841..000000000 --- a/apps/studio/src/electron-main/lib/link-bins.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { logger } from "@/electron-main/lib/electron-logger"; -import { getBinsFromPackageManifest } from "@pnpm/package-bins"; -import { readPackageJsonFromDir } from "@pnpm/read-package-json"; -import { type DependencyManifest } from "@pnpm/types"; -import { promises as fs } from "node:fs"; -import path from "node:path"; -import normalizePath from "normalize-path"; -import { isEmpty } from "radashi"; - -const scopedLogger = logger.scope("pnpm:bins"); - -/** - * Gets the binary paths for all packages in node_modules that have binaries - * @param projectPath Path to the project root (containing package.json) - * @returns Map of package names to their binary paths - */ -export async function getAllPackageBinaryPaths( - projectPath: string, -): Promise> { - const manifest = await safeReadPkgJson(projectPath); - if (!manifest) { - return new Map(); - } - - const binPaths = new Map(); - const allDeps = { - ...manifest.dependencies, - ...manifest.devDependencies, - }; - - for (const [pkgName] of Object.entries(allDeps)) { - const paths = await getPackageBinaryPaths(projectPath, pkgName); - if (paths.length > 0) { - binPaths.set(pkgName, paths); - } - } - - return binPaths; -} - -/** - * Gets the binary paths for a specific package in node_modules - * @param projectPath Path to the project root (containing package.json) - * @param packageName Name of the package to get binary paths for - * @returns Array of paths to the package's binaries - */ -async function getPackageBinaryPaths( - projectPath: string, - packageName: string, -): Promise { - const pkgPath = path.join(projectPath, "node_modules", packageName); - const bins = await getPackageBins(pkgPath); - return bins.map((bin) => bin.path); -} - -// Original source, which isn't exported: https://github.com/pnpm/pnpm/blob/cd8caece258e154274c2feb4d6dab3293cb8bc21/pkg-manager/link-bins/src/index.ts -// We need essentially the same functionality. -async function getPackageBins(target: string) { - const manifest = await safeReadPkgJson(target); - - if (manifest == null) { - // There's a directory in node_modules without package.json: ${target}. - // This used to be a warning but it didn't really cause any issues. - return []; - } - - if (isEmpty(manifest.bin) && !(await isFromModules(target))) { - scopedLogger.warn( - `Package in ${target} must have a non-empty bin field to get bin linked.`, - "EMPTY_BIN", - ); - return []; - } - - if (typeof manifest.bin === "string" && !manifest.name) { - scopedLogger.warn( - "INVALID_PACKAGE_NAME", - `Package in ${target} must have a name to get bin linked.`, - ); - return []; - } - - return getBinsFromPackageManifest(manifest, target); -} - -async function isFromModules(filename: string): Promise { - const real = await fs.realpath(filename); - return normalizePath(real).includes("/node_modules/"); -} - -async function safeReadPkgJson( - pkgDir: string, -): Promise { - try { - return (await readPackageJsonFromDir(pkgDir)) as DependencyManifest; - } catch (error: unknown) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return null; - } - throw error; - } -} diff --git a/packages/workspace/scripts/run-workspace.ts b/packages/workspace/scripts/run-workspace.ts index 8c074e361..334f88be4 100644 --- a/packages/workspace/scripts/run-workspace.ts +++ b/packages/workspace/scripts/run-workspace.ts @@ -5,8 +5,7 @@ import { type AIGatewayProvider, fetchAISDKModel, } from "@quests/ai-gateway"; -import { execa, parseCommandString } from "execa"; -import { err, ok, type Result } from "neverthrow"; +import { execa } from "execa"; import path from "node:path"; import readline from "node:readline"; import { createActor } from "xstate"; @@ -17,28 +16,6 @@ import { project } from "../src/rpc/routes/project"; import { StoreId } from "../src/schemas/store-id"; import { env } from "./lib/env"; -function scriptToCommand({ - port, - script, -}: { - port: number; - script: string; -}): Result { - const [firstPart, ...rest] = parseCommandString(script); - if (firstPart === "vite") { - return ok([ - "pnpm", - "exec", - firstPart, - ...rest, - "--port", - port.toString(), - "--strictPort", - ]); - } - return err(new Error(`Unknown script: ${script}`)); -} - const registryDir = path.resolve("../../registry"); const actor = createActor(workspaceMachine, { input: { @@ -109,39 +86,6 @@ const actor = createActor(workspaceMachine, { registryDir, // Sibling directory to monorepo to avoid using same pnpm and git rootDir: path.resolve("../../../workspace.local"), - runPackageJsonScript: ({ cwd, script, scriptOptions, signal }) => { - const command = scriptToCommand({ - port: scriptOptions.port, - script, - }); - if (command.isErr()) { - return Promise.resolve(err(command.error)); - } - try { - let finalCommand = command.value; - // Spawning directly to avoid orphaned processes - if ( - command.value[0] === "pnpm" && - command.value[1] === "exec" && - command.value[2] === "vite" - ) { - finalCommand = ["node_modules/.bin/vite", ...command.value.slice(3)]; - } - return Promise.resolve( - ok( - execa({ - cancelSignal: signal, - cwd, - env: scriptOptions.env, - })`${finalCommand}`, - ), - ); - } catch (error) { - return Promise.resolve( - err(error instanceof Error ? error : new Error(String(error))), - ); - } - }, // Uncomment to test built shim // shimClientDir: path.resolve("../shim-client/dist"), shimClientDir: "dev-server", diff --git a/packages/workspace/src/machines/runtime.ts b/packages/workspace/src/machines/runtime.ts index 7ae3adc68..d6e1756f5 100644 --- a/packages/workspace/src/machines/runtime.ts +++ b/packages/workspace/src/machines/runtime.ts @@ -19,7 +19,7 @@ import { type SpawnRuntimeRef, } from "../logic/spawn-runtime"; import { publisher } from "../rpc/publisher"; -import { type AppStatus, type RunPackageJsonScript } from "../types"; +import { type AppStatus } from "../types"; const MAX_RETRIES = 3; @@ -123,13 +123,11 @@ export const runtimeMachine = setup({ logs: RuntimeLogEntry[]; port?: number; retryCount: number; - runPackageJsonScript: RunPackageJsonScript; spawnRuntimeRef?: SpawnRuntimeRef; }, events: {} as RuntimeEvent, input: {} as { appConfig: AppConfig; - runPackageJsonScript: RunPackageJsonScript; }, output: {} as { error?: unknown }, tags: {} as Exclude, @@ -141,7 +139,6 @@ export const runtimeMachine = setup({ lastHeartbeat: new Date(), logs: [], retryCount: 0, - runPackageJsonScript: input.runPackageJsonScript, }; }, id: "runtime", diff --git a/packages/workspace/src/machines/workspace/index.ts b/packages/workspace/src/machines/workspace/index.ts index c69c85b93..e22d1858a 100644 --- a/packages/workspace/src/machines/workspace/index.ts +++ b/packages/workspace/src/machines/workspace/index.ts @@ -186,7 +186,6 @@ export const workspaceMachine = setup({ previewCacheTimeMs?: number; registryDir: string; rootDir: string; - runPackageJsonScript: WorkspaceContext["runPackageJsonScript"]; shimClientDir: string; trashItem: (path: AbsolutePath) => Promise; }, @@ -215,7 +214,6 @@ export const workspaceMachine = setup({ checkoutVersionRefs: new Map(), config: workspaceConfig, createPreviewRefs: new Map(), - runPackageJsonScript: input.runPackageJsonScript, runtimeRefs: new Map(), sessionRefsBySubdomain: new Map(), workspaceServerRef: spawn("workspaceServerLogic", { @@ -485,7 +483,6 @@ export const workspaceMachine = setup({ spawn("runtimeMachine", { input: { appConfig: event.value.appConfig, - runPackageJsonScript: context.runPackageJsonScript, }, }), ), diff --git a/packages/workspace/src/machines/workspace/types.ts b/packages/workspace/src/machines/workspace/types.ts index 1f5a3df0b..5eba2be1a 100644 --- a/packages/workspace/src/machines/workspace/types.ts +++ b/packages/workspace/src/machines/workspace/types.ts @@ -6,7 +6,7 @@ import { type PreviewSubdomain, type VersionSubdomain, } from "../../schemas/subdomains"; -import { type RunPackageJsonScript, type WorkspaceConfig } from "../../types"; +import { type WorkspaceConfig } from "../../types"; import { type RuntimeActorRef } from "../runtime"; import { type SessionActorRef } from "../session"; @@ -17,7 +17,6 @@ export interface WorkspaceContext { config: WorkspaceConfig; createPreviewRefs: Map; error?: unknown; - runPackageJsonScript: RunPackageJsonScript; runtimeRefs: Map; sessionRefsBySubdomain: Map; workspaceServerRef: WorkspaceServerActorRef; diff --git a/packages/workspace/src/tools/types.ts b/packages/workspace/src/tools/types.ts index 8ec9a7ce7..b0687b89b 100644 --- a/packages/workspace/src/tools/types.ts +++ b/packages/workspace/src/tools/types.ts @@ -2,7 +2,6 @@ import type { Tool } from "ai"; import type { Result } from "neverthrow"; import { type LanguageModelV2ToolResultOutput } from "@ai-sdk/provider"; -import { type ResultPromise } from "execa"; import { type z } from "zod"; import type { ToolNameSchema } from "./name"; @@ -34,10 +33,6 @@ export interface AgentTool< // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyAgentTool = AgentTool; -export type ShellResult = Promise< - Result, Error> ->; - export type ToolName = z.output; type ExecuteResult = Result; diff --git a/packages/workspace/src/types.ts b/packages/workspace/src/types.ts index d57565b06..49d1dbe4e 100644 --- a/packages/workspace/src/types.ts +++ b/packages/workspace/src/types.ts @@ -6,20 +6,9 @@ import { import { type APP_STATUSES } from "./constants"; import { type AbsolutePath, type WorkspaceDir } from "./schemas/paths"; -import { type ShellResult } from "./tools/types"; export type AppStatus = (typeof APP_STATUSES)[number]; -export type RunPackageJsonScript = (options: { - cwd: string; - script: string; - scriptOptions: { - env: Record; - port: number; - }; - signal: AbortSignal; -}) => Promise | ShellResult; - export interface WorkspaceConfig { captureEvent: CaptureEventFunction; captureException: CaptureExceptionFunction; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ad2804bf..3f4e9c8f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,15 +144,6 @@ importers: '@orpc/tanstack-query': specifier: 'catalog:' version: 1.8.8(@opentelemetry/api@1.9.0)(@orpc/client@1.8.8(@opentelemetry/api@1.9.0))(@tanstack/query-core@5.85.5) - '@pnpm/package-bins': - specifier: ^1000.0.3 - version: 1000.0.7 - '@pnpm/read-package-json': - specifier: ^1000.0.4 - version: 1000.0.8 - '@pnpm/types': - specifier: ^1000.2.0 - version: 1000.5.0 '@quests/ai-gateway': specifier: workspace:* version: link:../../packages/ai-gateway @@ -267,9 +258,6 @@ importers: electron-updater: specifier: ^6.6.8 version: 6.6.8 - execa: - specifier: ^9.6.0 - version: 9.6.0 framer-motion: specifier: 11.13.5 version: 11.13.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -285,12 +273,6 @@ importers: ms: specifier: ^2.1.3 version: 2.1.3 - neverthrow: - specifier: ^8.1.1 - version: 8.2.0 - normalize-path: - specifier: ^3.0.0 - version: 3.0.0 pnpm: specifier: ^10.13.1 version: 10.13.1 @@ -370,9 +352,6 @@ importers: '@types/node': specifier: 22.16.0 version: 22.16.0 - '@types/normalize-path': - specifier: ^3.0.2 - version: 3.0.2 '@types/react': specifier: 'catalog:' version: 19.1.8 @@ -2140,26 +2119,6 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@pnpm/constants@1001.1.0': - resolution: {integrity: sha512-xb9dfSGi1qfUKY3r4Zy9JdC9+ZeaDxwfE7HrrGIEsBVY1hvIn6ntbR7A97z3nk44yX7vwbINNf9sizTp0WEtEw==} - engines: {node: '>=18.12'} - - '@pnpm/error@1000.0.2': - resolution: {integrity: sha512-2SfE4FFL73rE1WVIoESbqlj4sLy5nWW4M/RVdHvCRJPjlQHa9MH7m7CVJM204lz6I+eHoB+E7rL3zmpJR5wYnQ==} - engines: {node: '>=18.12'} - - '@pnpm/package-bins@1000.0.7': - resolution: {integrity: sha512-STDyH1zejF7kBotEmqTzBRKygJxtwtS1fuXDTx03Wuz90wVKP94/QbsGG+teZJFlq//Qc1x74tO0x/GX0yY/5Q==} - engines: {node: '>=18.12'} - - '@pnpm/read-package-json@1000.0.8': - resolution: {integrity: sha512-ygpqkFbj6VyuAeBog5GMcD/XniRF/HDHLI1EYU8NIVIxVNCU017RZNABo7XhdW4dJ5FyrYl1UgYU9W4ArQpr7Q==} - engines: {node: '>=18.12'} - - '@pnpm/types@1000.5.0': - resolution: {integrity: sha512-R8yOeAfqTXW+REWa8UZ9KtDibpfybYhCKwvEtAp3HXcm8GHCg0JN4guZUybwh1yV9dYN354Kz9uAvd7VON2/WQ==} - engines: {node: '>=18.12'} - '@poppinss/cliui@6.4.4': resolution: {integrity: sha512-yJfm+3yglxdeH85C+YebxZ1zsTB4pBh+QwCuxJcxV/pVbxagn63uYyxqnQif2sKWi+nkNZxuyemON3WrtGMBCQ==} @@ -3166,9 +3125,6 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} - '@types/normalize-path@3.0.2': - resolution: {integrity: sha512-DO++toKYPaFn0Z8hQ7Tx+3iT9t77IJo/nDiqTXilgEP+kPNIYdpS9kh3fXuc53ugqwp9pxC1PVjCpV1tQDyqMA==} - '@types/pegjs@0.10.6': resolution: {integrity: sha512-eLYXDbZWXh2uxf+w8sXS8d6KSoXTswfps6fvCUuVAGN8eRpfe7h9eSRydxiSJvo9Bf+GzifsDOr9TMQlmJdmkw==} @@ -3685,10 +3641,6 @@ packages: better-call@1.0.15: resolution: {integrity: sha512-u4ZNRB1yBx5j3CltTEbY2ZoFPVcgsuvciAqTEmPvnZpZ483vlZf4LGJ5aVau1yMlrvlyHxOCica3OqXBLhmsUw==} - better-path-resolve@1.0.0: - resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} - engines: {node: '>=4'} - better-sqlite3@11.9.1: resolution: {integrity: sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==} @@ -5199,10 +5151,6 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} - hosted-git-info@6.1.3: - resolution: {integrity: sha512-HVJyzUrLIL1c0QmviVh5E8VGyUS7xCFPS6yydaVd1UegW+ibV/CohqTH9MkOLDp5o+rb82DMo77PTuc9F/8GKw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - hosted-git-info@7.0.2: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} @@ -5479,10 +5427,6 @@ packages: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} - is-subdir@1.2.0: - resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} - engines: {node: '>=4'} - is-symbol@1.1.1: resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} engines: {node: '>= 0.4'} @@ -5774,10 +5718,6 @@ packages: linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} - load-json-file@6.2.0: - resolution: {integrity: sha512-gUD/epcRms75Cw8RT1pUdHugZYM5ce64ucs2GEISABwkRsOQr0q2wm/MV2TKThycIe5e0ytRweW2RZxclogCdQ==} - engines: {node: '>=8'} - locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -6284,10 +6224,6 @@ packages: normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} - normalize-package-data@5.0.0: - resolution: {integrity: sha512-h9iPVIfrVZ9wVYQnxFgtw1ugSvGEMOlyPWWtm8BMJhnwyEL/FLbYbTY3V3PpjI/BUK67n9PEWDu6eHzu1fB15Q==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - normalize-package-data@6.0.2: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} @@ -7271,10 +7207,6 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - strip-bom@4.0.0: - resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} - engines: {node: '>=8'} - strip-final-newline@4.0.0: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} @@ -9572,27 +9504,6 @@ snapshots: '@pkgr/core@0.2.9': {} - '@pnpm/constants@1001.1.0': {} - - '@pnpm/error@1000.0.2': - dependencies: - '@pnpm/constants': 1001.1.0 - - '@pnpm/package-bins@1000.0.7': - dependencies: - '@pnpm/types': 1000.5.0 - is-subdir: 1.2.0 - tinyglobby: 0.2.14 - - '@pnpm/read-package-json@1000.0.8': - dependencies: - '@pnpm/error': 1000.0.2 - '@pnpm/types': 1000.5.0 - load-json-file: 6.2.0 - normalize-package-data: 5.0.0 - - '@pnpm/types@1000.5.0': {} - '@poppinss/cliui@6.4.4': dependencies: '@poppinss/colors': 4.1.5 @@ -10595,8 +10506,6 @@ snapshots: '@types/normalize-package-data@2.4.4': {} - '@types/normalize-path@3.0.2': {} - '@types/pegjs@0.10.6': {} '@types/pg@8.6.1': @@ -11268,10 +11177,6 @@ snapshots: set-cookie-parser: 2.7.1 uncrypto: 0.1.3 - better-path-resolve@1.0.0: - dependencies: - is-windows: 1.0.2 - better-sqlite3@11.9.1: dependencies: bindings: 1.5.0 @@ -13122,10 +13027,6 @@ snapshots: dependencies: lru-cache: 6.0.0 - hosted-git-info@6.1.3: - dependencies: - lru-cache: 7.18.3 - hosted-git-info@7.0.2: dependencies: lru-cache: 10.4.3 @@ -13398,10 +13299,6 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 - is-subdir@1.2.0: - dependencies: - better-path-resolve: 1.0.0 - is-symbol@1.1.1: dependencies: call-bound: 1.0.4 @@ -13655,13 +13552,6 @@ snapshots: dependencies: uc.micro: 2.1.0 - load-json-file@6.2.0: - dependencies: - graceful-fs: 4.2.11 - parse-json: 5.2.0 - strip-bom: 4.0.0 - type-fest: 0.6.0 - locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -14439,13 +14329,6 @@ snapshots: semver: 5.7.2 validate-npm-package-license: 3.0.4 - normalize-package-data@5.0.0: - dependencies: - hosted-git-info: 6.1.3 - is-core-module: 2.16.1 - semver: 7.7.2 - validate-npm-package-license: 3.0.4 - normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 @@ -15611,8 +15494,6 @@ snapshots: strip-bom@3.0.0: {} - strip-bom@4.0.0: {} - strip-final-newline@4.0.0: {} strip-indent@3.0.0: From bdff32f28715b3d2edb6c0ea3fc73a837fa78a5f Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 15:31:00 -0500 Subject: [PATCH 35/40] wip: move concept of node env variables to workspace config --- .../src/electron-main/lib/create-workspace-actor.ts | 5 +++++ packages/workspace/scripts/run-workspace.ts | 1 + .../{execa-electron-node.ts => execa-node-for-app.ts} | 9 +++++---- packages/workspace/src/logic/spawn-runtime.ts | 8 +++++--- .../workspace/src/machines/execute-tool-call.test.ts | 10 ++++++---- packages/workspace/src/machines/workspace/index.ts | 2 ++ packages/workspace/src/test/helpers/mock-app-config.ts | 1 + packages/workspace/src/tools/run-shell-command.ts | 7 ++++--- packages/workspace/src/types.ts | 1 + 9 files changed, 30 insertions(+), 14 deletions(-) rename packages/workspace/src/lib/{execa-electron-node.ts => execa-node-for-app.ts} (57%) diff --git a/apps/studio/src/electron-main/lib/create-workspace-actor.ts b/apps/studio/src/electron-main/lib/create-workspace-actor.ts index 7089de2e9..a24b87b2a 100644 --- a/apps/studio/src/electron-main/lib/create-workspace-actor.ts +++ b/apps/studio/src/electron-main/lib/create-workspace-actor.ts @@ -45,6 +45,11 @@ export function createWorkspaceActor() { return providers; }, + nodeExecEnv: { + // Required to allow Electron to operate as a node process + // See https://www.electronjs.org/docs/latest/api/environment-variables + ELECTRON_RUN_AS_NODE: "1", + }, pnpmBinPath: getPNPMBinPath(), previewCacheTimeMs: ms("24 hours"), registryDir: app.isPackaged diff --git a/packages/workspace/scripts/run-workspace.ts b/packages/workspace/scripts/run-workspace.ts index 334f88be4..136a99d0d 100644 --- a/packages/workspace/scripts/run-workspace.ts +++ b/packages/workspace/scripts/run-workspace.ts @@ -80,6 +80,7 @@ const actor = createActor(workspaceMachine, { return providers; }, + nodeExecEnv: {}, pnpmBinPath: await execa({ reject: false })`which pnpm`.then( (result) => result.stdout.trim() || "pnpm", ), diff --git a/packages/workspace/src/lib/execa-electron-node.ts b/packages/workspace/src/lib/execa-node-for-app.ts similarity index 57% rename from packages/workspace/src/lib/execa-electron-node.ts rename to packages/workspace/src/lib/execa-node-for-app.ts index 0ad9db0b4..933429aa8 100644 --- a/packages/workspace/src/lib/execa-electron-node.ts +++ b/packages/workspace/src/lib/execa-node-for-app.ts @@ -1,6 +1,9 @@ import { execa, type Options } from "execa"; -export function execaElectronNode( +import { type AppConfig } from "./app-config/types"; + +export function execaNodeForApp( + appConfig: AppConfig, file: string | URL, arguments_?: readonly string[], options?: OptionsType, @@ -9,9 +12,7 @@ export function execaElectronNode( ...options, env: { ...options?.env, - // Required for normal node processes to work - // See https://www.electronjs.org/docs/latest/api/environment-variables - ELECTRON_RUN_AS_NODE: "1", + ...appConfig.workspaceConfig.nodeExecEnv, }, node: true, } as unknown as OptionsType & { env: Record; node: true }); diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index 2f39d2c39..9481e5d9b 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -12,7 +12,7 @@ import { import { type AppConfig } from "../lib/app-config/types"; import { cancelableTimeout, TimeoutError } from "../lib/cancelable-timeout"; -import { execaElectronNode } from "../lib/execa-electron-node"; +import { execaNodeForApp } from "../lib/execa-node-for-app"; import { getFramework } from "../lib/get-framework"; import { getPackageManager } from "../lib/get-package-manager"; import { pathExists } from "../lib/path-exists"; @@ -174,7 +174,8 @@ export const spawnRuntimeLogic = fromCallback< }, }); - const installProcess = execaElectronNode( + const installProcess = execaNodeForApp( + appConfig, packageManager.command, packageManager.arguments, { @@ -224,7 +225,8 @@ export const spawnRuntimeLogic = fromCallback< }); timeout.start(); - const runtimeProcess = execaElectronNode( + const runtimeProcess = execaNodeForApp( + appConfig, framework.command, framework.arguments, { diff --git a/packages/workspace/src/machines/execute-tool-call.test.ts b/packages/workspace/src/machines/execute-tool-call.test.ts index c48a09db4..355f746c6 100644 --- a/packages/workspace/src/machines/execute-tool-call.test.ts +++ b/packages/workspace/src/machines/execute-tool-call.test.ts @@ -15,8 +15,8 @@ import { executeToolCallMachine } from "./execute-tool-call"; vi.mock(import("ulid")); vi.mock(import("../lib/session-store-storage")); vi.mock(import("../lib/get-current-date")); -vi.mock(import("../lib/execa-electron-node"), () => ({ - execaElectronNode: vi.fn(), +vi.mock(import("../lib/execa-node-for-app"), () => ({ + execaNodeForApp: vi.fn(), })); describe("executeToolCallMachine", () => { @@ -28,9 +28,11 @@ describe("executeToolCallMachine", () => { const mockDate = new Date("2025-01-01T00:00:00.000Z"); beforeEach(async () => { - const { execaElectronNode } = await import("../lib/execa-electron-node"); + const { execaNodeForApp: execaElectronNode } = await import( + "../lib/execa-node-for-app" + ); vi.mocked(execaElectronNode).mockImplementation( - async (file, args, _options) => { + async (_appConfig, file, args, _options) => { const command = [file, ...(args ?? [])].join(" "); if (command.includes("throw-error")) { diff --git a/packages/workspace/src/machines/workspace/index.ts b/packages/workspace/src/machines/workspace/index.ts index e22d1858a..d1fe27bbe 100644 --- a/packages/workspace/src/machines/workspace/index.ts +++ b/packages/workspace/src/machines/workspace/index.ts @@ -182,6 +182,7 @@ export const workspaceMachine = setup({ captureEvent: CaptureEventFunction; captureException: CaptureExceptionFunction; getAIProviders: GetAIProviders; + nodeExecEnv: Record; pnpmBinPath: string; previewCacheTimeMs?: number; registryDir: string; @@ -197,6 +198,7 @@ export const workspaceMachine = setup({ captureEvent: input.captureEvent, captureException: input.captureException, getAIProviders: input.getAIProviders, + nodeExecEnv: input.nodeExecEnv, pnpmBinPath: AbsolutePathSchema.parse(input.pnpmBinPath), previewCacheTimeMs: input.previewCacheTimeMs, previewsDir: AbsolutePathSchema.parse( diff --git a/packages/workspace/src/test/helpers/mock-app-config.ts b/packages/workspace/src/test/helpers/mock-app-config.ts index d8f559be0..692181912 100644 --- a/packages/workspace/src/test/helpers/mock-app-config.ts +++ b/packages/workspace/src/test/helpers/mock-app-config.ts @@ -22,6 +22,7 @@ export function createMockAppConfig(subdomain: AppSubdomain) { console.error("captureException", args); }, getAIProviders: () => [], + nodeExecEnv: {}, pnpmBinPath: AbsolutePathSchema.parse("/tmp/pnpm"), previewsDir: AbsolutePathSchema.parse(MOCK_WORKSPACE_DIRS.previews), projectsDir: AbsolutePathSchema.parse(MOCK_WORKSPACE_DIRS.projects), diff --git a/packages/workspace/src/tools/run-shell-command.ts b/packages/workspace/src/tools/run-shell-command.ts index d15fb7425..bd60689dd 100644 --- a/packages/workspace/src/tools/run-shell-command.ts +++ b/packages/workspace/src/tools/run-shell-command.ts @@ -9,7 +9,7 @@ import { z } from "zod"; import type { AppConfig } from "../lib/app-config/types"; import { absolutePathJoin } from "../lib/absolute-path-join"; -import { execaElectronNode } from "../lib/execa-electron-node"; +import { execaNodeForApp } from "../lib/execa-node-for-app"; import { fixRelativePath } from "../lib/fix-relative-path"; import { pathExists } from "../lib/path-exists"; import { readPNPMShim } from "../lib/read-pnpm-shim"; @@ -411,7 +411,8 @@ export const RunShellCommand = createTool({ } if (commandName === "pnpm") { - const process = await execaElectronNode( + const process = await execaNodeForApp( + appConfig, appConfig.workspaceConfig.pnpmBinPath, args, { @@ -436,7 +437,7 @@ export const RunShellCommand = createTool({ type: "execute-error", }); } - const process = await execaElectronNode(binPath.value, args, { + const process = await execaNodeForApp(appConfig, binPath.value, args, { cancelSignal: signal, cwd: appConfig.appDir, }); diff --git a/packages/workspace/src/types.ts b/packages/workspace/src/types.ts index 49d1dbe4e..0ff4cf232 100644 --- a/packages/workspace/src/types.ts +++ b/packages/workspace/src/types.ts @@ -13,6 +13,7 @@ export interface WorkspaceConfig { captureEvent: CaptureEventFunction; captureException: CaptureExceptionFunction; getAIProviders: GetAIProviders; + nodeExecEnv: Record; pnpmBinPath: AbsolutePath; previewCacheTimeMs?: number; previewsDir: AbsolutePath; From bad5d37c9e34a5ae0e41471231472cbe904b06ee Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 15:39:22 -0500 Subject: [PATCH 36/40] wip: use default accuracy sort --- packages/workspace/src/lib/get-framework.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/workspace/src/lib/get-framework.ts b/packages/workspace/src/lib/get-framework.ts index 21a4cd296..d2ef44a86 100644 --- a/packages/workspace/src/lib/get-framework.ts +++ b/packages/workspace/src/lib/get-framework.ts @@ -1,7 +1,6 @@ import { type Info } from "@netlify/build-info/node"; import { parseCommandString } from "execa"; import { err, ok } from "neverthrow"; -import { sort } from "radashi"; import invariant from "tiny-invariant"; import { type AppDir } from "../schemas/paths"; @@ -20,8 +19,7 @@ export async function getFramework({ buildInfo: Info; port: number; }) { - const sortedFrameworks = sort(frameworks, (f) => f.detected.accuracy); - const [framework] = sortedFrameworks; + const [framework] = frameworks; // First framework is already sorted by accuracy invariant(framework, "No framework found"); const [devCommand, ...devCommandArgs] = parseCommandString( framework.dev?.command ?? "", From e5a4f3d3099518cc85cb485d338d2c226e75da34 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 16:02:29 -0500 Subject: [PATCH 37/40] wip: assume pnpm for now and handle astro --- cspell.json | 1 + packages/workspace/src/lib/get-framework.ts | 48 ++++++++++++------- .../workspace/src/lib/get-package-manager.ts | 43 +++++------------ 3 files changed, 44 insertions(+), 48 deletions(-) diff --git a/cspell.json b/cspell.json index 1d2ca8187..018bb6c9f 100644 --- a/cspell.json +++ b/cspell.json @@ -70,6 +70,7 @@ "posthog", "pwsh", "qwen", + "qwik", "radashi", "recaptcha", "retryable", diff --git a/packages/workspace/src/lib/get-framework.ts b/packages/workspace/src/lib/get-framework.ts index d2ef44a86..0d5ffe444 100644 --- a/packages/workspace/src/lib/get-framework.ts +++ b/packages/workspace/src/lib/get-framework.ts @@ -7,12 +7,11 @@ import { type AppDir } from "../schemas/paths"; import { absolutePathJoin } from "./absolute-path-join"; import { type AppConfig } from "./app-config/types"; import { TypedError } from "./errors"; -import { PackageManager } from "./package-manager"; import { readPNPMShim } from "./read-pnpm-shim"; export async function getFramework({ appConfig, - buildInfo: { frameworks, packageManager }, + buildInfo: { frameworks }, port, }: { appConfig: AppConfig; @@ -32,14 +31,6 @@ export async function getFramework({ ); } - if (packageManager && packageManager.name !== PackageManager.PNPM) { - return err( - new TypedError.NotFound( - `Unsupported package manager ${packageManager.name}`, - ), - ); - } - const binPathResult = await readPNPMShim( getBinShimPath(appConfig.appDir, devCommand), ); @@ -51,23 +42,42 @@ export async function getFramework({ const binPath = binPathResult.value; switch (devCommand) { + case "astro": { + return ok({ + arguments: [...devCommandArgs, "--port", port.toString()], + command: binPath, + name: framework.name, + }); + } case "next": { return ok({ - ...framework, arguments: [...devCommandArgs, "-p", port.toString()], command: binPath, + name: framework.name, }); } case "nuxt": { return ok({ - ...framework, arguments: [...devCommandArgs, "--port", port.toString()], command: binPath, + name: framework.name, + }); + } + case "qwik": { + return ok({ + arguments: [ + ...devCommandArgs, + "--mode", + "ssr", + "--port", + port.toString(), + ], + command: binPath, + name: framework.name, }); } case "vite": { return ok({ - ...framework, arguments: [ ...devCommandArgs, "--port", @@ -79,14 +89,16 @@ export async function getFramework({ "warn", ], command: binPath, + name: framework.name, }); } } - return err( - new TypedError.NotFound( - `Unsupported dev command: ${devCommand}. Supported commands are: next, nuxt, vite`, - ), - ); + // Fallback to a --port argument + return ok({ + arguments: [...devCommandArgs, "--port", port.toString()], + command: binPath, + name: framework.name, + }); } function getBinShimPath(appDir: AppDir, command: string) { diff --git a/packages/workspace/src/lib/get-package-manager.ts b/packages/workspace/src/lib/get-package-manager.ts index 51ed624c2..6c69b7218 100644 --- a/packages/workspace/src/lib/get-package-manager.ts +++ b/packages/workspace/src/lib/get-package-manager.ts @@ -1,34 +1,17 @@ -import { type Info } from "@netlify/build-info/node"; -import { parseCommandString } from "execa"; - import { type AppConfig } from "./app-config/types"; import { PackageManager } from "./package-manager"; -export function getPackageManager({ - appConfig, - buildInfo: { packageManager }, -}: { - appConfig: AppConfig; - buildInfo: Info; -}) { - // eslint-disable-next-line unicorn/prefer-ternary - if (!packageManager || packageManager.name === PackageManager.PNPM) { - return { - arguments: - appConfig.type === "version" || appConfig.type === "sandbox" - ? // These app types are nested in the project directory, so we need - // to ignore the workspace config otherwise PNPM may not install the - // dependencies correctly - ["install", "--ignore-workspace"] - : ["install"], - command: appConfig.workspaceConfig.pnpmBinPath, - name: PackageManager.PNPM, - }; - } else { - return { - arguments: parseCommandString(packageManager.installCommand), - command: packageManager.installCommand, - name: packageManager.name, - }; - } +export function getPackageManager({ appConfig }: { appConfig: AppConfig }) { + // For now, we only support PNPM + return { + arguments: + appConfig.type === "version" || appConfig.type === "sandbox" + ? // These app types are nested in the project directory, so we need + // to ignore the workspace config otherwise PNPM may not install the + // dependencies correctly + ["install", "--ignore-workspace"] + : ["install"], + command: appConfig.workspaceConfig.pnpmBinPath, + name: PackageManager.PNPM, + }; } From 1eb9c4dcd3644fc27d52108b1f02aa7f068bc81c Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 16:14:09 -0500 Subject: [PATCH 38/40] wip: remove logging --- .../electron-main/lib/setup-bin-directory.ts | 45 +++++++++---------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/apps/studio/src/electron-main/lib/setup-bin-directory.ts b/apps/studio/src/electron-main/lib/setup-bin-directory.ts index d4d4242a1..8ec9bc52d 100644 --- a/apps/studio/src/electron-main/lib/setup-bin-directory.ts +++ b/apps/studio/src/electron-main/lib/setup-bin-directory.ts @@ -3,7 +3,7 @@ import { app } from "electron"; import fs from "node:fs/promises"; import path from "node:path"; -import { logger } from "./electron-logger"; +import { captureServerException } from "./capture-server-exception"; const BIN_DIR_NAME = "bin"; @@ -16,11 +16,12 @@ export function getPNPMBinPath(): string { return getNodeModulePath("pnpm", "bin", "pnpm.cjs"); } +// Added to PATH so that child processes (the users's apps) can access the binaries +// as if they were installed globally. We don't use these ourselves due to issues +// with orphaned processes on Windows. export async function setupBinDirectory(): Promise { const binDir = getBinDirectoryPath(); - logger.info(`Setting up bin directory at: ${binDir}`); - await ensureDirectoryExists(binDir); await cleanBinDirectory(binDir); @@ -35,19 +36,19 @@ export async function setupBinDirectory(): Promise { try { await fs.access(targetPath); } catch { - logger.warn(`Binary not found, skipping: ${targetPath}`); continue; } await linkDirect(binDir, binary.name, targetPath); } catch (error) { - logger.error(`Failed to setup binary ${binary.name}:`, error); + captureServerException(error, { + scopes: ["studio"], + }); } } prependBinDirectoryToPath(binDir); - logger.info(`Bin directory setup complete: ${binDir}`); return binDir; } @@ -60,13 +61,13 @@ async function cleanBinDirectory(binDir: string): Promise { try { await fs.rm(entryPath, { force: true, recursive: true }); } catch (error) { - logger.warn(`Failed to remove ${entryPath}:`, error); + captureServerException(error, { + scopes: ["studio"], + }); } } - - logger.info(`Cleaned bin directory: ${binDir}`); - } catch { - logger.info(`Bin directory does not exist yet, will be created: ${binDir}`); + } catch (error) { + captureServerException(error, { scopes: ["studio"] }); } } @@ -88,7 +89,9 @@ async function ensureDirectoryExists(dirPath: string): Promise { try { await fs.mkdir(dirPath, { recursive: true }); } catch (error) { - logger.error(`Failed to create directory ${dirPath}:`, error); + captureServerException(error, { + scopes: ["studio"], + }); throw error; } } @@ -156,11 +159,9 @@ async function linkDirect( createCmdFile: true, createPwshFile: false, }); - logger.info(`Created shim: ${outputPath} -> ${targetPath}`); } else { const linkPath = path.join(binDir, name); await fs.symlink(targetPath, linkPath); - logger.info(`Created symlink: ${linkPath} -> ${targetPath}`); } } @@ -178,8 +179,6 @@ function prependBinDirectoryToPath(binDir: string): void { const newPath = [binDir, ...pathParts].join(pathSeparator); process.env.PATH = newPath; - - logger.info(`Updated PATH: bin directory prepended (${binDir})`); } async function setupNodeLink(binDir: string): Promise { @@ -188,15 +187,13 @@ async function setupNodeLink(binDir: string): Promise { const linkPath = path.join(binDir, isWindows ? "node.exe" : "node"); try { - if (isWindows) { - await createNodeShim(binDir, nodeExePath); - logger.info(`Created node shim: ${binDir} -> ${nodeExePath}`); - } else { - await fs.symlink(nodeExePath, linkPath); - logger.info(`Created node symlink: ${linkPath} -> ${nodeExePath}`); - } + await (isWindows + ? createNodeShim(binDir, nodeExePath) + : fs.symlink(nodeExePath, linkPath)); } catch (error) { - logger.error("Failed to create node link:", error); + captureServerException(error, { + scopes: ["studio"], + }); throw error; } } From 6e1274bc04cab7ed4ff48effa4dd3fb95efd7537 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 16:14:14 -0500 Subject: [PATCH 39/40] wip: no build info --- packages/workspace/src/logic/spawn-runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index 9481e5d9b..61ad7ddb9 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -164,7 +164,7 @@ export const spawnRuntimeLogic = fromCallback< abortController.signal, installTimeout.controller.signal, ]); - const packageManager = getPackageManager({ appConfig, buildInfo }); + const packageManager = getPackageManager({ appConfig }); parentRef.send({ type: "spawnRuntime.log", From 73d81674ad1a2bfd447898b821a9cbf573934ac7 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 14 Oct 2025 16:18:44 -0500 Subject: [PATCH 40/40] wip: log and telemetry for unknown framework --- packages/shared/src/types/telemetry.ts | 3 +++ packages/workspace/src/lib/get-framework.ts | 22 ++++++++++++++++--- packages/workspace/src/logic/spawn-runtime.ts | 10 +++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/types/telemetry.ts b/packages/shared/src/types/telemetry.ts index 7e3e76cbc..9bc3673f6 100644 --- a/packages/shared/src/types/telemetry.ts +++ b/packages/shared/src/types/telemetry.ts @@ -15,6 +15,9 @@ export interface AnalyticsEvents { "app.sidebar_opened": never; "favorite.added": never; "favorite.removed": never; + "framework.not-supported": { + framework: string; + }; "llm.error": WithModelProperties; "llm.request_finished": WithModelProperties<{ cached_input_tokens?: number | undefined; diff --git a/packages/workspace/src/lib/get-framework.ts b/packages/workspace/src/lib/get-framework.ts index 0d5ffe444..caf786448 100644 --- a/packages/workspace/src/lib/get-framework.ts +++ b/packages/workspace/src/lib/get-framework.ts @@ -1,6 +1,6 @@ import { type Info } from "@netlify/build-info/node"; import { parseCommandString } from "execa"; -import { err, ok } from "neverthrow"; +import { err, ok, type Result } from "neverthrow"; import invariant from "tiny-invariant"; import { type AppDir } from "../schemas/paths"; @@ -17,7 +17,17 @@ export async function getFramework({ appConfig: AppConfig; buildInfo: Info; port: number; -}) { +}): Promise< + Result< + { + arguments: string[]; + command: string; + errorMessage?: string; + name: string; + }, + TypedError.FileSystem | TypedError.NotFound | TypedError.Parse + > +> { const [framework] = frameworks; // First framework is already sorted by accuracy invariant(framework, "No framework found"); const [devCommand, ...devCommandArgs] = parseCommandString( @@ -93,10 +103,16 @@ export async function getFramework({ }); } } - // Fallback to a --port argument + appConfig.workspaceConfig.captureEvent("framework.not-supported", { + framework: framework.name, + }); + + // Falling back to generic --port argument and hopefully it works return ok({ arguments: [...devCommandArgs, "--port", port.toString()], command: binPath, + errorMessage: + "Unsupported framework, falling back to generic --port argument", name: framework.name, }); } diff --git a/packages/workspace/src/logic/spawn-runtime.ts b/packages/workspace/src/logic/spawn-runtime.ts index 61ad7ddb9..24ee5b103 100644 --- a/packages/workspace/src/logic/spawn-runtime.ts +++ b/packages/workspace/src/logic/spawn-runtime.ts @@ -216,6 +216,16 @@ export const spawnRuntimeLogic = fromCallback< const framework = frameworkResult.value; + if (framework.errorMessage) { + parentRef.send({ + type: "spawnRuntime.log", + value: { + message: framework.errorMessage, + type: "error", + }, + }); + } + parentRef.send({ type: "spawnRuntime.log", value: {