Skip to content

Commit 839ec36

Browse files
Improve workshop app startup errors
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
1 parent fb2f38e commit 839ec36

5 files changed

Lines changed: 523 additions & 136 deletions

File tree

docs/cli.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,9 +1326,19 @@ The CLI automatically detects the environment:
13261326
13271327
If the CLI can't find the workshop app:
13281328
1329-
1. Ensure the workshop app is installed: `npm install -g @epic-web/workshop-app`
1330-
2. Set the `EPICSHOP_APP_LOCATION` environment variable
1331-
3. Use the `--app-location` flag to specify the path
1329+
1. If you are inside a workshop repository, run `npm install` from the workshop
1330+
root first so its local `epicshop` install can resolve
1331+
`@epic-web/workshop-app`
1332+
2. If the workshop keeps a local `epicshop` directory (for example `./epicshop`
1333+
or `./.epicshop`), reinstall that local directory if it is missing or
1334+
incomplete
1335+
3. Set the `EPICSHOP_APP_LOCATION` environment variable or pass `--app-location`
1336+
if your workshop app lives in a separate checkout
1337+
4. If you are relying on a global install, run
1338+
`npm install -g @epic-web/workshop-app`
1339+
1340+
When startup fails, the CLI now lists each lookup it attempted so you can see
1341+
which path or installation method was checked.
13321342
13331343
### Port Conflicts
13341344

packages/workshop-cli/src/cli.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,6 @@ const cli = yargs(args)
153153
`❌ ${result.message || 'Failed to start workshop application'}`,
154154
),
155155
)
156-
if (result.error) {
157-
console.error(chalk.red(result.error.message))
158-
}
159156
}
160157
process.exit(1)
161158
}

packages/workshop-cli/src/commands/start.test.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import net from 'node:net'
55
import os from 'node:os'
66
import path from 'node:path'
77
import { fileURLToPath, pathToFileURL } from 'node:url'
8-
import { test } from 'vitest'
8+
import { expect, test } from 'vitest'
9+
import {
10+
buildWorkshopAppNotFoundMessage,
11+
resolveWorkshopAppLocation,
12+
} from './workshop-app-location.ts'
913

1014
const __dirname = path.dirname(fileURLToPath(import.meta.url))
1115
const repoRoot = path.resolve(__dirname, '..', '..', '..', '..')
@@ -59,6 +63,65 @@ testIf(
5963
20000,
6064
)
6165

66+
test('start explains when a workshop repo is missing its local epicshop install (aha)', async () => {
67+
const workshopRoot = '/tmp/advanced-react-apis'
68+
const resolution = await resolveWorkshopAppLocation(
69+
{},
70+
{
71+
cwd: () => path.join(workshopRoot, 'exercises', '01.problem'),
72+
env: {},
73+
homedir: () => '/home/tester',
74+
readTextFile: async (filePath) => {
75+
if (filePath === path.join(workshopRoot, 'package.json')) {
76+
return JSON.stringify({
77+
name: 'advanced-react-apis',
78+
epicshop: { title: 'Advanced React APIs' },
79+
scripts: {
80+
start: 'npx --prefix ./.epicshop epicshop start',
81+
},
82+
})
83+
}
84+
throw Object.assign(
85+
new Error(`ENOENT: no such file or directory, open '${filePath}'`),
86+
{
87+
code: 'ENOENT',
88+
},
89+
)
90+
},
91+
accessPath: async (filePath) => {
92+
if (filePath === path.join(workshopRoot, 'package.json')) return
93+
throw Object.assign(
94+
new Error(`ENOENT: no such file or directory, access '${filePath}'`),
95+
{
96+
code: 'ENOENT',
97+
},
98+
)
99+
},
100+
resolveImport: () => {
101+
throw new Error('not resolved in test')
102+
},
103+
runCommand: () => '/global/node_modules',
104+
},
105+
)
106+
107+
expect(resolution.appDir).toBeNull()
108+
expect(resolution.workshopContext).toEqual({
109+
workshopRoot,
110+
localCliPrefix: './.epicshop',
111+
localCliDir: path.join(workshopRoot, '.epicshop'),
112+
localCliDirExists: false,
113+
})
114+
115+
const message = buildWorkshopAppNotFoundMessage(resolution)
116+
117+
expect(message).toContain('This looks like a workshop repository')
118+
expect(message).toContain('Run `npm install` in the workshop root')
119+
expect(message).toContain('`./.epicshop`')
120+
expect(message).toContain('Lookups attempted:')
121+
expect(message).toContain('EPICSHOP_APP_LOCATION: not set')
122+
expect(message).toContain('--app-location: not provided')
123+
})
124+
62125
async function createRunnerFixture() {
63126
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'epicshop-start-'))
64127
const appDir = path.join(rootDir, 'fake-workshop')
@@ -69,7 +132,7 @@ async function createRunnerFixture() {
69132
path.join(appDir, 'package.json'),
70133
JSON.stringify(
71134
{
72-
name: 'fake-workshop',
135+
name: '@epic-web/workshop-app',
73136
version: '0.0.0',
74137
type: 'module',
75138
epicshop: {

packages/workshop-cli/src/commands/start.ts

Lines changed: 11 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
// oxlint-disable-next-line import/order -- must appear first
22
import { getEnv } from '@epic-web/workshop-utils/init-env'
33

4-
import { spawn, type ChildProcess, execSync } from 'node:child_process'
4+
import { spawn, type ChildProcess } from 'node:child_process'
55
import crypto from 'node:crypto'
66
import fs from 'node:fs'
77
import http from 'node:http'
88
import os from 'node:os'
99
import path from 'node:path'
10-
import { fileURLToPath, pathToFileURL } from 'node:url'
10+
import { pathToFileURL } from 'node:url'
1111
import chalk from 'chalk'
1212
import closeWithGrace from 'close-with-grace'
1313
import getPort from 'get-port'
1414
import open from 'open'
15+
import {
16+
buildWorkshopAppNotFoundMessage,
17+
resolveWorkshopAppLocation,
18+
} from './workshop-app-location.ts'
1519

1620
export type StartOptions = {
1721
appLocation?: string
@@ -111,29 +115,12 @@ export function displayHelp() {
111115
*/
112116
export async function start(options: StartOptions = {}): Promise<StartResult> {
113117
try {
114-
// Find workshop-app directory using new resolution order
115-
const appDir = await findWorkshopAppDir(options.appLocation)
118+
const resolution = await resolveWorkshopAppLocation({
119+
appLocation: options.appLocation,
120+
})
121+
const appDir = resolution.appDir
116122
if (!appDir) {
117-
const errorMessage =
118-
'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'
119-
120-
if (!options.silent) {
121-
console.error(chalk.red('❌ Could not locate workshop-app directory'))
122-
console.error(
123-
chalk.yellow(
124-
'Please ensure the workshop app is installed or specify its location using:',
125-
),
126-
)
127-
console.error(
128-
chalk.yellow(' - Environment variable: EPICSHOP_APP_LOCATION'),
129-
)
130-
console.error(chalk.yellow(' - Command line flag: --app-location'))
131-
console.error(
132-
chalk.yellow(
133-
' - Global installation: npm install -g @epic-web/workshop-app',
134-
),
135-
)
136-
}
123+
const errorMessage = buildWorkshopAppNotFoundMessage(resolution)
137124

138125
return {
139126
success: false,
@@ -642,110 +629,6 @@ async function killChild(child: ChildProcess | null): Promise<void> {
642629
})
643630
}
644631

645-
async function findWorkshopAppDir(
646-
appLocation?: string,
647-
): Promise<string | null> {
648-
// 1. Check process.env.EPICSHOP_APP_LOCATION
649-
if (process.env.EPICSHOP_APP_LOCATION) {
650-
const envDir = path.resolve(process.env.EPICSHOP_APP_LOCATION)
651-
try {
652-
await fs.promises.access(path.join(envDir, 'package.json'))
653-
return envDir
654-
} catch {
655-
// Continue to next step
656-
}
657-
}
658-
659-
// 2. Check command line flag --app-location
660-
if (appLocation) {
661-
const flagDir = path.resolve(appLocation)
662-
try {
663-
await fs.promises.access(path.join(flagDir, 'package.json'))
664-
return flagDir
665-
} catch {
666-
// Continue to next step
667-
}
668-
}
669-
670-
// 3. Node's resolution process
671-
try {
672-
const workshopAppPath = import.meta
673-
.resolve('@epic-web/workshop-app/package.json')
674-
const packagePath = fileURLToPath(workshopAppPath)
675-
return path.dirname(packagePath)
676-
} catch {
677-
// Continue to next step
678-
}
679-
680-
// 4. Global installation lookup
681-
try {
682-
const globalDir = await findGlobalWorkshopApp()
683-
if (globalDir) {
684-
return globalDir
685-
}
686-
} catch {
687-
// Continue to next step
688-
}
689-
690-
// Fallback for development (when running from a monorepo)
691-
try {
692-
const cliPkgPath = import.meta.resolve('epicshop/package.json')
693-
const cliPkgDir = path.dirname(fileURLToPath(cliPkgPath))
694-
const relativePath = path.resolve(cliPkgDir, '..', '..', 'workshop-app')
695-
try {
696-
await fs.promises.access(path.join(relativePath, 'package.json'))
697-
return relativePath
698-
} catch {
699-
// Continue to final return
700-
}
701-
} catch {
702-
// Continue to final return
703-
}
704-
705-
return null
706-
}
707-
708-
async function findGlobalWorkshopApp(): Promise<string | null> {
709-
// Try to find globally installed workshop app
710-
try {
711-
const npmRoot = execSync('npm root -g', { encoding: 'utf-8' }).trim()
712-
const globalAppPath = path.join(npmRoot, '@epic-web/workshop-app')
713-
try {
714-
await fs.promises.access(path.join(globalAppPath, 'package.json'))
715-
return globalAppPath
716-
} catch {
717-
// Continue to common global locations
718-
}
719-
} catch {
720-
// If npm root -g fails, try common global locations
721-
}
722-
723-
// Try common global locations
724-
const commonGlobalPaths = [
725-
path.join(
726-
os.homedir(),
727-
'.npm-global/lib/node_modules/@epic-web/workshop-app',
728-
),
729-
path.join(
730-
os.homedir(),
731-
'.npm-packages/lib/node_modules/@epic-web/workshop-app',
732-
),
733-
'/usr/local/lib/node_modules/@epic-web/workshop-app',
734-
'/usr/lib/node_modules/@epic-web/workshop-app',
735-
]
736-
737-
for (const globalPath of commonGlobalPaths) {
738-
try {
739-
await fs.promises.access(path.join(globalPath, 'package.json'))
740-
return globalPath
741-
} catch {
742-
// Continue to next path
743-
}
744-
}
745-
746-
return null
747-
}
748-
749632
async function appIsPublished(appDir: string): Promise<boolean> {
750633
if (process.env.EPICSHOP_IS_PUBLISHED) {
751634
return (

0 commit comments

Comments
 (0)