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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1326,9 +1326,19 @@ The CLI automatically detects the environment:

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

Expand Down
20 changes: 13 additions & 7 deletions packages/workshop-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,10 @@ const cli = yargs(args)

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))
Comment thread
cursor[bot] marked this conversation as resolved.
}
}
Expand Down Expand Up @@ -1804,7 +1802,15 @@ try {
.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)
}
Expand Down
67 changes: 65 additions & 2 deletions packages/workshop-cli/src/commands/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import net from 'node:net'
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, '..', '..', '..', '..')
Expand Down Expand Up @@ -59,6 +63,65 @@ testIf(
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')
Expand All @@ -69,7 +132,7 @@ async function createRunnerFixture() {
path.join(appDir, 'package.json'),
JSON.stringify(
{
name: 'fake-workshop',
name: '@epic-web/workshop-app',
version: '0.0.0',
type: 'module',
epicshop: {
Expand Down
139 changes: 11 additions & 128 deletions packages/workshop-cli/src/commands/start.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -111,29 +115,12 @@ export function displayHelp() {
*/
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'

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'))
Comment thread
cursor[bot] marked this conversation as resolved.
console.error(
chalk.yellow(
' - Global installation: npm install -g @epic-web/workshop-app',
),
)
}
const errorMessage = buildWorkshopAppNotFoundMessage(resolution)

return {
success: false,
Expand Down Expand Up @@ -642,110 +629,6 @@ async function killChild(child: ChildProcess | null): Promise<void> {
})
}

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 (
Expand Down
Loading
Loading