Skip to content

Improve workshop app startup errors#587

Open
kentcdodds wants to merge 3 commits intomainfrom
cursor/helpful-error-message-3ac9
Open

Improve workshop app startup errors#587
kentcdodds wants to merge 3 commits intomainfrom
cursor/helpful-error-message-3ac9

Conversation

@kentcdodds
Copy link
Copy Markdown
Member

@kentcdodds kentcdodds commented Apr 2, 2026

Summary

  • improve epicshop start when @epic-web/workshop-app cannot be resolved
  • explain which lookup paths failed and highlight the common workshop-repo case where the local epicshop install is missing
  • stop printing the same startup error twice and document the new troubleshooting guidance
  • fix the resolver test helper typing so the CLI package typechecks cleanly

Testing

  • npm run typecheck -- --projects epicshop
  • npm run test -- packages/workshop-cli/src/commands/start.test.ts
  • npm run build -- --projects epicshop
  • generated the new diagnostic output in a demo log

helpful-startup-message-demo.log

To show artifacts inline, enable in settings.

Open in Web Open in Cursor 

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@nx-cloud
Copy link
Copy Markdown

nx-cloud bot commented Apr 2, 2026

View your CI Pipeline Execution ↗ for commit 6cacbda

Command Status Duration Result
nx run-many --target typecheck ✅ Succeeded 5s View ↗
nx run-many --target build ✅ Succeeded <1s View ↗
nx lint ✅ Succeeded 2s View ↗

☁️ Nx Cloud last updated this comment at 2026-04-02 16:37:51 UTC

@kentcdodds kentcdodds marked this pull request as ready for review April 2, 2026 16:24
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Removing error detail printing loses unexpected startup error info
    • Added conditional error-detail printing when the error message differs from the main failure message in the start command handler.
  • ✅ Fixed: Interactive menu start failure produces no error output
    • Interactive menu start now prints the failure message (and distinct error detail) before exiting.
Preview (6cacbda73a)
diff --git a/docs/cli.md b/docs/cli.md
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -1326,10 +1326,20 @@
 
 If the CLI can't find the workshop app:
 
-1. Ensure the workshop app is installed: `npm install -g @epic-web/workshop-app`
-2. Set the `EPICSHOP_APP_LOCATION` environment variable
-3. Use the `--app-location` flag to specify the path
+1. If you are inside a workshop repository, run `npm install` from the workshop
+   root first so its local `epicshop` install can resolve
+   `@epic-web/workshop-app`
+2. If the workshop keeps a local `epicshop` directory (for example `./epicshop`
+   or `./.epicshop`), reinstall that local directory if it is missing or
+   incomplete
+3. Set the `EPICSHOP_APP_LOCATION` environment variable or pass `--app-location`
+   if your workshop app lives in a separate checkout
+4. If you are relying on a global install, run
+   `npm install -g @epic-web/workshop-app`
 
+When startup fails, the CLI now lists each lookup it attempted so you can see
+which path or installation method was checked.
+
 ### Port Conflicts
 
 The CLI automatically finds available ports starting from 3742. If you encounter

diff --git a/packages/workshop-cli/src/cli.ts b/packages/workshop-cli/src/cli.ts
--- a/packages/workshop-cli/src/cli.ts
+++ b/packages/workshop-cli/src/cli.ts
@@ -148,12 +148,10 @@
 
 					if (!result.success) {
 						if (!argv.silent) {
-							console.error(
-								chalk.red(
-									`❌ ${result.message || 'Failed to start workshop application'}`,
-								),
-							)
-							if (result.error) {
+							const message =
+								result.message || 'Failed to start workshop application'
+							console.error(chalk.red(`❌ ${message}`))
+							if (result.error?.message && result.error.message !== message) {
 								console.error(chalk.red(result.error.message))
 							}
 						}
@@ -1804,7 +1802,15 @@
 							.catch(() => {})
 						const { start } = await import('./commands/start.js')
 						const result = await start({})
-						if (!result.success) process.exit(1)
+						if (!result.success) {
+							const message =
+								result.message || 'Failed to start workshop application'
+							console.error(chalk.red(`❌ ${message}`))
+							if (result.error?.message && result.error.message !== message) {
+								console.error(chalk.red(result.error.message))
+							}
+							process.exit(1)
+						}
 					} finally {
 						process.chdir(originalCwd)
 					}

diff --git a/packages/workshop-cli/src/commands/start.test.ts b/packages/workshop-cli/src/commands/start.test.ts
--- a/packages/workshop-cli/src/commands/start.test.ts
+++ b/packages/workshop-cli/src/commands/start.test.ts
@@ -5,7 +5,11 @@
 import os from 'node:os'
 import path from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
-import { test } from 'vitest'
+import { expect, test } from 'vitest'
+import {
+	buildWorkshopAppNotFoundMessage,
+	resolveWorkshopAppLocation,
+} from './workshop-app-location.ts'
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url))
 const repoRoot = path.resolve(__dirname, '..', '..', '..', '..')
@@ -59,6 +63,65 @@
 	20000,
 )
 
+test('start explains when a workshop repo is missing its local epicshop install (aha)', async () => {
+	const workshopRoot = '/tmp/advanced-react-apis'
+	const resolution = await resolveWorkshopAppLocation(
+		{},
+		{
+			cwd: () => path.join(workshopRoot, 'exercises', '01.problem'),
+			env: {},
+			homedir: () => '/home/tester',
+			readTextFile: async (filePath) => {
+				if (filePath === path.join(workshopRoot, 'package.json')) {
+					return JSON.stringify({
+						name: 'advanced-react-apis',
+						epicshop: { title: 'Advanced React APIs' },
+						scripts: {
+							start: 'npx --prefix ./.epicshop epicshop start',
+						},
+					})
+				}
+				throw Object.assign(
+					new Error(`ENOENT: no such file or directory, open '${filePath}'`),
+					{
+						code: 'ENOENT',
+					},
+				)
+			},
+			accessPath: async (filePath) => {
+				if (filePath === path.join(workshopRoot, 'package.json')) return
+				throw Object.assign(
+					new Error(`ENOENT: no such file or directory, access '${filePath}'`),
+					{
+						code: 'ENOENT',
+					},
+				)
+			},
+			resolveImport: () => {
+				throw new Error('not resolved in test')
+			},
+			runCommand: () => '/global/node_modules',
+		},
+	)
+
+	expect(resolution.appDir).toBeNull()
+	expect(resolution.workshopContext).toEqual({
+		workshopRoot,
+		localCliPrefix: './.epicshop',
+		localCliDir: path.join(workshopRoot, '.epicshop'),
+		localCliDirExists: false,
+	})
+
+	const message = buildWorkshopAppNotFoundMessage(resolution)
+
+	expect(message).toContain('This looks like a workshop repository')
+	expect(message).toContain('Run `npm install` in the workshop root')
+	expect(message).toContain('`./.epicshop`')
+	expect(message).toContain('Lookups attempted:')
+	expect(message).toContain('EPICSHOP_APP_LOCATION: not set')
+	expect(message).toContain('--app-location: not provided')
+})
+
 async function createRunnerFixture() {
 	const rootDir = await mkdtemp(path.join(os.tmpdir(), 'epicshop-start-'))
 	const appDir = path.join(rootDir, 'fake-workshop')
@@ -69,7 +132,7 @@
 		path.join(appDir, 'package.json'),
 		JSON.stringify(
 			{
-				name: 'fake-workshop',
+				name: '@epic-web/workshop-app',
 				version: '0.0.0',
 				type: 'module',
 				epicshop: {

diff --git a/packages/workshop-cli/src/commands/start.ts b/packages/workshop-cli/src/commands/start.ts
--- a/packages/workshop-cli/src/commands/start.ts
+++ b/packages/workshop-cli/src/commands/start.ts
@@ -1,17 +1,21 @@
 // oxlint-disable-next-line import/order -- must appear first
 import { getEnv } from '@epic-web/workshop-utils/init-env'
 
-import { spawn, type ChildProcess, execSync } from 'node:child_process'
+import { spawn, type ChildProcess } from 'node:child_process'
 import crypto from 'node:crypto'
 import fs from 'node:fs'
 import http from 'node:http'
 import os from 'node:os'
 import path from 'node:path'
-import { fileURLToPath, pathToFileURL } from 'node:url'
+import { pathToFileURL } from 'node:url'
 import chalk from 'chalk'
 import closeWithGrace from 'close-with-grace'
 import getPort from 'get-port'
 import open from 'open'
+import {
+	buildWorkshopAppNotFoundMessage,
+	resolveWorkshopAppLocation,
+} from './workshop-app-location.ts'
 
 export type StartOptions = {
 	appLocation?: string
@@ -111,30 +115,13 @@
  */
 export async function start(options: StartOptions = {}): Promise<StartResult> {
 	try {
-		// Find workshop-app directory using new resolution order
-		const appDir = await findWorkshopAppDir(options.appLocation)
+		const resolution = await resolveWorkshopAppLocation({
+			appLocation: options.appLocation,
+		})
+		const appDir = resolution.appDir
 		if (!appDir) {
-			const errorMessage =
-				'Could not locate workshop-app directory. Please ensure the workshop app is installed or specify its location using:\n  - Environment variable: EPICSHOP_APP_LOCATION\n  - Command line flag: --app-location\n  - Global installation: npm install -g @epic-web/workshop-app'
+			const errorMessage = buildWorkshopAppNotFoundMessage(resolution)
 
-			if (!options.silent) {
-				console.error(chalk.red('❌ Could not locate workshop-app directory'))
-				console.error(
-					chalk.yellow(
-						'Please ensure the workshop app is installed or specify its location using:',
-					),
-				)
-				console.error(
-					chalk.yellow('  - Environment variable: EPICSHOP_APP_LOCATION'),
-				)
-				console.error(chalk.yellow('  - Command line flag: --app-location'))
-				console.error(
-					chalk.yellow(
-						'  - Global installation: npm install -g @epic-web/workshop-app',
-					),
-				)
-			}
-
 			return {
 				success: false,
 				message: errorMessage,
@@ -642,110 +629,6 @@
 	})
 }
 
-async function findWorkshopAppDir(
-	appLocation?: string,
-): Promise<string | null> {
-	// 1. Check process.env.EPICSHOP_APP_LOCATION
-	if (process.env.EPICSHOP_APP_LOCATION) {
-		const envDir = path.resolve(process.env.EPICSHOP_APP_LOCATION)
-		try {
-			await fs.promises.access(path.join(envDir, 'package.json'))
-			return envDir
-		} catch {
-			// Continue to next step
-		}
-	}
-
-	// 2. Check command line flag --app-location
-	if (appLocation) {
-		const flagDir = path.resolve(appLocation)
-		try {
-			await fs.promises.access(path.join(flagDir, 'package.json'))
-			return flagDir
-		} catch {
-			// Continue to next step
-		}
-	}
-
-	// 3. Node's resolution process
-	try {
-		const workshopAppPath = import.meta
-			.resolve('@epic-web/workshop-app/package.json')
-		const packagePath = fileURLToPath(workshopAppPath)
-		return path.dirname(packagePath)
-	} catch {
-		// Continue to next step
-	}
-
-	// 4. Global installation lookup
-	try {
-		const globalDir = await findGlobalWorkshopApp()
-		if (globalDir) {
-			return globalDir
-		}
-	} catch {
-		// Continue to next step
-	}
-
-	// Fallback for development (when running from a monorepo)
-	try {
-		const cliPkgPath = import.meta.resolve('epicshop/package.json')
-		const cliPkgDir = path.dirname(fileURLToPath(cliPkgPath))
-		const relativePath = path.resolve(cliPkgDir, '..', '..', 'workshop-app')
-		try {
-			await fs.promises.access(path.join(relativePath, 'package.json'))
-			return relativePath
-		} catch {
-			// Continue to final return
-		}
-	} catch {
-		// Continue to final return
-	}
-
-	return null
-}
-
-async function findGlobalWorkshopApp(): Promise<string | null> {
-	// Try to find globally installed workshop app
-	try {
-		const npmRoot = execSync('npm root -g', { encoding: 'utf-8' }).trim()
-		const globalAppPath = path.join(npmRoot, '@epic-web/workshop-app')
-		try {
-			await fs.promises.access(path.join(globalAppPath, 'package.json'))
-			return globalAppPath
-		} catch {
-			// Continue to common global locations
-		}
-	} catch {
-		// If npm root -g fails, try common global locations
-	}
-
-	// Try common global locations
-	const commonGlobalPaths = [
-		path.join(
-			os.homedir(),
-			'.npm-global/lib/node_modules/@epic-web/workshop-app',
-		),
-		path.join(
-			os.homedir(),
-			'.npm-packages/lib/node_modules/@epic-web/workshop-app',
-		),
-		'/usr/local/lib/node_modules/@epic-web/workshop-app',
-		'/usr/lib/node_modules/@epic-web/workshop-app',
-	]
-
-	for (const globalPath of commonGlobalPaths) {
-		try {
-			await fs.promises.access(path.join(globalPath, 'package.json'))
-			return globalPath
-		} catch {
-			// Continue to next path
-		}
-	}
-
-	return null
-}
-
 async function appIsPublished(appDir: string): Promise<boolean> {
 	if (process.env.EPICSHOP_IS_PUBLISHED) {
 		return (

diff --git a/packages/workshop-cli/src/commands/workshop-app-location.ts b/packages/workshop-cli/src/commands/workshop-app-location.ts
new file mode 100644
--- /dev/null
+++ b/packages/workshop-cli/src/commands/workshop-app-location.ts
@@ -1,0 +1,434 @@
+import { execSync } from 'node:child_process'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+type ReadTextFile = (filePath: string) => Promise<string>
+type AccessPath = (filePath: string) => Promise<void>
+type ResolveImport = (specifier: string) => string
+type RunCommand = (command: string) => string
+
+type PackageJson = {
+	name?: string
+	epicshop?: unknown
+	scripts?: Record<string, string | undefined>
+}
+
+type WorkshopContext = {
+	workshopRoot: string
+	localCliPrefix?: string
+	localCliDir?: string
+	localCliDirExists: boolean
+}
+
+export type WorkshopAppResolutionAttempt = {
+	label: string
+	detail: string
+}
+
+export type WorkshopAppResolution = {
+	appDir: string | null
+	attempts: Array<WorkshopAppResolutionAttempt>
+	workshopContext: WorkshopContext | null
+}
+
+type ResolutionDeps = {
+	env?: Partial<NodeJS.ProcessEnv>
+	cwd?: () => string
+	homedir?: () => string
+	readTextFile?: ReadTextFile
+	accessPath?: AccessPath
+	resolveImport?: ResolveImport
+	runCommand?: RunCommand
+}
+
+export async function resolveWorkshopAppLocation(
+	options: { appLocation?: string } = {},
+	deps: ResolutionDeps = {},
+): Promise<WorkshopAppResolution> {
+	const attempts: Array<WorkshopAppResolutionAttempt> = []
+	const env = deps.env ?? process.env
+	const readTextFile =
+		deps.readTextFile ??
+		(async (filePath: string) => fs.promises.readFile(filePath, 'utf8'))
+	const accessPath =
+		deps.accessPath ??
+		(async (filePath: string) => fs.promises.access(filePath))
+	const resolveImport =
+		deps.resolveImport ??
+		((specifier: string) => import.meta.resolve(specifier))
+	const runCommand =
+		deps.runCommand ??
+		((command: string) => execSync(command, { encoding: 'utf-8' }).trim())
+	const cwd = deps.cwd ?? (() => process.cwd())
+	const homedir = deps.homedir ?? (() => os.homedir())
+
+	const workshopContext = await findWorkshopContext(cwd(), {
+		readTextFile,
+		accessPath,
+	})
+
+	const envLocation = env.EPICSHOP_APP_LOCATION?.trim()
+	if (envLocation) {
+		const envDir = path.resolve(envLocation)
+		const envAttempt = await inspectWorkshopAppDir(
+			envDir,
+			'EPICSHOP_APP_LOCATION',
+			{ readTextFile, accessPath },
+		)
+		attempts.push(envAttempt.attempt)
+		if (envAttempt.appDir) {
+			return { appDir: envAttempt.appDir, attempts, workshopContext }
+		}
+	} else {
+		attempts.push({
+			label: 'EPICSHOP_APP_LOCATION',
+			detail: 'not set',
+		})
+	}
+
+	const flagLocation = options.appLocation?.trim()
+	if (flagLocation) {
+		const flagDir = path.resolve(flagLocation)
+		const flagAttempt = await inspectWorkshopAppDir(flagDir, '--app-location', {
+			readTextFile,
+			accessPath,
+		})
+		attempts.push(flagAttempt.attempt)
+		if (flagAttempt.appDir) {
+			return { appDir: flagAttempt.appDir, attempts, workshopContext }
+		}
+	} else {
+		attempts.push({
+			label: '--app-location',
+			detail: 'not provided',
+		})
+	}
+
+	try {
+		const packagePath = toFilePath(
+			resolveImport('@epic-web/workshop-app/package.json'),
+		)
+		const resolvedAttempt = await inspectWorkshopAppDir(
+			path.dirname(packagePath),
+			'local package resolution',
+			{ readTextFile, accessPath },
+		)
+		attempts.push(resolvedAttempt.attempt)
+		if (resolvedAttempt.appDir) {
+			return { appDir: resolvedAttempt.appDir, attempts, workshopContext }
+		}
+	} catch (error) {
+		attempts.push({
+			label: 'local package resolution',
+			detail: `could not resolve @epic-web/workshop-app/package.json (${formatError(
+				error,
+			)})`,
+		})
+	}
+
+	const globalResult = await findGlobalWorkshopApp({
+		homedir,
+		readTextFile,
+		accessPath,
+		runCommand,
+	})
+	attempts.push(...globalResult.attempts)
+	if (globalResult.appDir) {
+		return { appDir: globalResult.appDir, attempts, workshopContext }
+	}
+
+	try {
+		const cliPkgPath = toFilePath(resolveImport('epicshop/package.json'))
+		const cliPkgDir = path.dirname(cliPkgPath)
+		const relativePath = path.resolve(cliPkgDir, '..', '..', 'workshop-app')
+		const fallbackAttempt = await inspectWorkshopAppDir(
+			relativePath,
+			'monorepo fallback',
+			{ readTextFile, accessPath },
+		)
+		attempts.push(fallbackAttempt.attempt)
+		if (fallbackAttempt.appDir) {
+			return { appDir: fallbackAttempt.appDir, attempts, workshopContext }
+		}
+	} catch (error) {
+		attempts.push({
+			label: 'monorepo fallback',
+			detail: `could not resolve epicshop/package.json (${formatError(error)})`,
+		})
+	}
+
+	return { appDir: null, attempts, workshopContext }
+}
+
+export function buildWorkshopAppNotFoundMessage(
+	resolution: WorkshopAppResolution,
+): string {
+	const lines = [
+		'Could not find `@epic-web/workshop-app`, so `epicshop start` does not know which app to launch.',
+	]
+
+	if (resolution.workshopContext) {
+		const { workshopRoot, localCliPrefix, localCliDir, localCliDirExists } =
+			resolution.workshopContext
+		lines.push('', `This looks like a workshop repository: ${workshopRoot}`)
+
+		if (localCliPrefix && localCliDir) {
+			lines.push(
+				localCliDirExists
+					? `Its start script uses a local epicshop install at \`${localCliPrefix}\` (${localCliDir}), but that install could not resolve the workshop app package.`
+					: `Its start script points at a local epicshop install \`${localCliPrefix}\` (${localCliDir}), but that directory does not exist.`,
+			)
+		} else {
+			lines.push(
+				'That usually means the workshop dependencies in this repository have not been installed yet.',
+			)
+		}
+	}
+
+	lines.push('', 'Lookups attempted:')
+	for (const attempt of resolution.attempts) {
+		lines.push(`- ${attempt.label}: ${attempt.detail}`)
+	}
+
+	lines.push('', 'Try this:')
+	if (resolution.workshopContext) {
+		const { workshopRoot, localCliPrefix } = resolution.workshopContext
+		lines.push(`1. Run \`npm install\` in the workshop root: ${workshopRoot}`)
+		if (localCliPrefix) {
+			lines.push(
+				`2. If that still fails, remove and reinstall the local epicshop directory at \`${localCliPrefix}\`.`,
+			)
+		} else {
+			lines.push(
+				'2. If that still fails, reinstall the workshop dependencies from the workshop root.',
+			)
+		}
+		lines.push(
+			'3. If you keep `@epic-web/workshop-app` in a separate checkout, point to it with `--app-location` or `EPICSHOP_APP_LOCATION`.',
+		)
+	} else {
+		lines.push(
+			'1. Install `@epic-web/workshop-app` where this `epicshop` CLI can resolve it.',
+		)
+		lines.push(
+			'2. Or point to an existing checkout with `--app-location` or `EPICSHOP_APP_LOCATION`.',
+		)
+		lines.push(
+			'3. For a global install, run `npm install -g @epic-web/workshop-app`.',
+		)
+	}
+
+	return lines.join('\n')
+}
+
+async function findWorkshopContext(
+	startDir: string,
+	deps: { readTextFile: ReadTextFile; accessPath: AccessPath },
+): Promise<WorkshopContext | null> {
+	let currentDir = path.resolve(startDir)
+	const root = path.parse(currentDir).root
+
+	while (true) {
+		const packageJsonPath = path.join(currentDir, 'package.json')
+		const packageJson = await readPackageJson(
+			packageJsonPath,
+			deps.readTextFile,
+		)
+		if (packageJson?.epicshop) {
+			const localCliPrefix = getLocalCliPrefix(packageJson)
+			const localCliDir = localCliPrefix
+				? path.resolve(currentDir, localCliPrefix)
+				: undefined
+
+			return {
+				workshopRoot: currentDir,
+				localCliPrefix,
+				localCliDir,
+				localCliDirExists: localCliDir
+					? await pathExists(
+							path.join(localCliDir, 'package.json'),
+							deps.accessPath,
+						)
+					: false,
+			}
+		}
+
+		if (currentDir === root) {
+			return null
+		}
+		currentDir = path.dirname(currentDir)
+	}
+}
+
+function getLocalCliPrefix(packageJson: PackageJson): string | undefined {
+	const candidateScripts = [
+		packageJson.scripts?.start,
+		packageJson.scripts?.dev,
+	]
+	for (const script of candidateScripts) {
+		if (!script) continue
+		const match = script.match(/--prefix\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))/)
+		const value = match?.[1] ?? match?.[2] ?? match?.[3]
+		if (value) {
+			return value
+		}
+	}
+	return undefined
+}
+
+async function inspectWorkshopAppDir(
+	appDir: string,
+	label: string,
+	deps: { readTextFile: ReadTextFile; accessPath: AccessPath },
+): Promise<{ appDir: string | null; attempt: WorkshopAppResolutionAttempt }> {
+	const packageJsonPath = path.join(appDir, 'package.json')
+	try {
+		await deps.accessPath(packageJsonPath)
+	} catch (error) {
+		return {
+			appDir: null,
+			attempt: {
+				label,
+				detail: `checked ${packageJsonPath}, but it was not readable (${formatError(
+					error,
+				)})`,
+			},
+		}
+	}
+
+	const packageJson = await readPackageJson(packageJsonPath, deps.readTextFile)
+	if (!packageJson) {
+		return {
+			appDir: null,
+			attempt: {
+				label,
+				detail: `found ${packageJsonPath}, but it could not be parsed as JSON`,
+			},
+		}
+	}
+
+	if (packageJson.name !== '@epic-web/workshop-app') {
+		const packageName = packageJson.name
+			? `\`${packageJson.name}\``
+			: 'an unnamed package'
+		return {
+			appDir: null,
+			attempt: {
+				label,
+				detail: `checked ${packageJsonPath}, but it contains ${packageName} instead of \`@epic-web/workshop-app\``,
+			},
+		}
+	}
+
+	return {
+		appDir,
+		attempt: {
+			label,
+			detail: `resolved to ${appDir}`,
+		},
+	}
+}
+
+async function findGlobalWorkshopApp(deps: {
+	homedir: () => string
+	readTextFile: ReadTextFile
+	accessPath: AccessPath
+	runCommand: RunCommand
+}): Promise<WorkshopAppResolution> {
+	const attempts: Array<WorkshopAppResolutionAttempt> = []
+
+	try {
+		const npmRoot = deps.runCommand('npm root -g')
+		const globalAppPath = path.join(npmRoot, '@epic-web/workshop-app')
+		const npmRootAttempt = await inspectWorkshopAppDir(
+			globalAppPath,
+			'global npm install',
+			{
+				readTextFile: deps.readTextFile,
+				accessPath: deps.accessPath,
+			},
+		)
+		attempts.push(npmRootAttempt.attempt)
+		if (npmRootAttempt.appDir) {
+			return { appDir: npmRootAttempt.appDir, attempts, workshopContext: null }
+		}
+	} catch (error) {
+		attempts.push({
+			label: 'global npm install',
+			detail: `failed to run \`npm root -g\` (${formatError(error)})`,
+		})
+	}
+
+	const commonGlobalPaths = [
+		path.join(
+			deps.homedir(),
+			'.npm-global/lib/node_modules/@epic-web/workshop-app',
+		),
+		path.join(
+			deps.homedir(),
+			'.npm-packages/lib/node_modules/@epic-web/workshop-app',
+		),
+		'/usr/local/lib/node_modules/@epic-web/workshop-app',
+		'/usr/lib/node_modules/@epic-web/workshop-app',
+	]
+
+	for (const globalPath of commonGlobalPaths) {
+		const globalAttempt = await inspectWorkshopAppDir(
+			globalPath,
+			'common global install paths',
+			{
+				readTextFile: deps.readTextFile,
+				accessPath: deps.accessPath,
+			},
+		)
+		if (globalAttempt.appDir) {
+			attempts.push(globalAttempt.attempt)
+			return { appDir: globalAttempt.appDir, attempts, workshopContext: null }
+		}
+	}
+
+	attempts.push({
+		label: 'common global install paths',
+		detail: `did not find the package in: ${commonGlobalPaths.join(', ')}`,
+	})
+
+	return { appDir: null, attempts, workshopContext: null }
+}
+
+async function readPackageJson(
+	packageJsonPath: string,
+	readTextFile: ReadTextFile,
+): Promise<PackageJson | null> {
+	try {
+		return JSON.parse(await readTextFile(packageJsonPath)) as PackageJson
+	} catch {
+		return null
+	}
+}
+
+async function pathExists(
+	filePath: string,
+	accessPath: AccessPath,
+): Promise<boolean> {
+	try {
+		await accessPath(filePath)
+		return true
+	} catch {
+		return false
+	}
+}
+
+function toFilePath(resolvedPath: string): string {
+	return resolvedPath.startsWith('file:')
+		? fileURLToPath(resolvedPath)
+		: resolvedPath
+}
+
+function formatError(error: unknown): string {
+	if (error instanceof Error && error.message) {
+		return error.message
+	}
+	return String(error)
+}

You can send follow-ups to this agent here.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants