diff --git a/docs/cli.md b/docs/cli.md index 18b1eb9a..0add7b7f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -313,6 +313,10 @@ The command will try to detect your editor in the following order: Supported editors include VS Code, Cursor, Sublime Text, Atom, Vim, Emacs, and many JetBrains IDEs. +You can also set a preferred editor via `epicshop config editor` (or +`epicshop config --editor `). The `open` command will prompt you to +confirm the detected editor the first time and then reuse your preference. + ### `config` View or update workshop configuration settings. @@ -324,6 +328,7 @@ epicshop config [options] #### Options - `--repos-dir ` - Set the default directory where workshops are cloned +- `--editor ` - Set the preferred editor command - `--silent, -s` - Run without output logs (default: false) #### Examples @@ -334,12 +339,19 @@ epicshop config # Set the repos directory epicshop config --repos-dir ~/epicweb-workshops + +# Choose a preferred editor +epicshop config editor + +# Set preferred editor to VS Code +epicshop config --editor code ``` #### Configuration - **Repos directory**: The default location where workshops are cloned. Defaults to `~/epicweb-workshops` on most systems. +- **Preferred editor**: The editor command the CLI uses when opening workshops. ### `update` / `upgrade` diff --git a/packages/workshop-cli/src/cli.ts b/packages/workshop-cli/src/cli.ts index 577b390d..010cb28c 100644 --- a/packages/workshop-cli/src/cli.ts +++ b/packages/workshop-cli/src/cli.ts @@ -387,12 +387,16 @@ const cli = yargs(args) .positional('subcommand', { describe: 'Config subcommand (reset)', type: 'string', - choices: ['reset'], + choices: ['reset', 'editor'], }) .option('repos-dir', { type: 'string', description: 'Set the default directory for workshop repos', }) + .option('editor', { + type: 'string', + description: 'Set the preferred editor command', + }) .option('silent', { alias: 's', type: 'boolean', @@ -402,18 +406,27 @@ const cli = yargs(args) .example('$0 config', 'View current configuration') .example('$0 config reset', 'Delete config file and reset to defaults') .example('$0 config --repos-dir ~/epicweb', 'Set the repos directory') + .example('$0 config editor', 'Choose a preferred editor') + .example('$0 config --editor code', 'Set preferred editor to VS Code') }, async ( argv: ArgumentsCamelCase<{ subcommand?: string reposDir?: string + editor?: string silent?: boolean }>, ) => { const { config } = await import('./commands/workshops.js') const result = await config({ - subcommand: argv.subcommand === 'reset' ? 'reset' : undefined, + subcommand: + argv.subcommand === 'reset' + ? 'reset' + : argv.subcommand === 'editor' + ? 'editor' + : undefined, reposDir: argv.reposDir, + preferredEditor: argv.editor, silent: argv.silent, }) if (!result.success) { diff --git a/packages/workshop-cli/src/commands/workshops.ts b/packages/workshop-cli/src/commands/workshops.ts index be12d6be..636ccb81 100644 --- a/packages/workshop-cli/src/commands/workshops.ts +++ b/packages/workshop-cli/src/commands/workshops.ts @@ -142,6 +142,25 @@ function resolvePathWithTilde(inputPath: string): string { return trimmed } +function formatEditorChoiceName(editor: { label: string; command: string }) { + return editor.label === editor.command + ? editor.label + : `${editor.label} (${editor.command})` +} + +async function getInstalledEditorChoices(): Promise< + Array<{ name: string; value: string; description?: string }> +> { + const { getAvailableEditors } = + await import('@epic-web/workshop-utils/launch-editor.server') + const editors = getAvailableEditors() + return editors.map((editor) => ({ + name: formatEditorChoiceName(editor), + value: editor.command, + description: editor.label === editor.command ? undefined : editor.command, + })) +} + function parseRepoSpecifier(value: string): { repoName: string repoRef?: string @@ -346,12 +365,16 @@ async function checkWorkshopDownloadStatus( */ async function checkWorkshopAccess( workshops: EnrichedWorkshop[], + authStatusMap?: Map, ): Promise { const accessResults = await Promise.all( workshops.map(async (workshop) => { if (!workshop.productHost || !workshop.productSlug) { return undefined } + if (authStatusMap?.get(workshop.productHost) === false) { + return undefined + } return userHasAccessToWorkshop({ productHost: workshop.productHost, workshopSlug: workshop.productSlug, @@ -372,8 +395,79 @@ export type StartOptions = { export type ConfigOptions = { reposDir?: string + preferredEditor?: string silent?: boolean - subcommand?: 'reset' | 'delete' + subcommand?: 'reset' | 'delete' | 'editor' +} + +async function resolvePreferredEditor({ + silent, +}: { + silent: boolean +}): Promise { + const { getPreferredEditor, setPreferredEditor } = + await import('@epic-web/workshop-utils/workshops.server') + const { getDefaultEditorCommand, formatEditorLabel } = + await import('@epic-web/workshop-utils/launch-editor.server') + + const preferredEditor = await getPreferredEditor() + if (preferredEditor) return preferredEditor + + const defaultEditor = getDefaultEditorCommand() + if (silent) return defaultEditor + + assertCanPrompt({ + reason: 'choose a preferred editor', + hints: ['Set it later with: npx epicshop config editor'], + }) + + const { select, confirm } = await import('@inquirer/prompts') + const availableEditors = await getInstalledEditorChoices() + + if (defaultEditor) { + const defaultLabel = formatEditorLabel(defaultEditor) + if (availableEditors.length === 0) { + const useDefault = await confirm({ + message: `Use ${defaultLabel} to open workshops?`, + default: true, + }) + if (useDefault) { + await setPreferredEditor(defaultEditor) + return defaultEditor + } + return null + } + + const decision = await select({ + message: `Open workshops with ${defaultLabel}?`, + choices: [ + { name: `Use ${defaultLabel}`, value: 'use' }, + { name: 'Choose a different editor', value: 'choose' }, + ], + }) + + if (decision === 'use') { + await setPreferredEditor(defaultEditor) + return defaultEditor + } + } + + if (availableEditors.length === 0) { + console.log( + chalk.yellow( + '⚠️ No supported editors detected. Set EPICSHOP_EDITOR or install a supported editor.', + ), + ) + return defaultEditor + } + + const selectedEditor = await select({ + message: 'Select your preferred editor:', + choices: availableEditors, + }) + + await setPreferredEditor(selectedEditor) + return selectedEditor } /** @@ -649,7 +743,10 @@ export async function add(options: AddOptions): Promise { } spinner.start('Checking access...') - enrichedWorkshops = await checkWorkshopAccess(enrichedWorkshops) + enrichedWorkshops = await checkWorkshopAccess( + enrichedWorkshops, + authStatusMap, + ) enrichedWorkshops.sort((a, b) => { const aHasAccess = a.hasAccess === true @@ -1604,6 +1701,11 @@ export async function openWorkshop( return { success: false, message } } + const preferredEditor = await resolvePreferredEditor({ silent }) + if (preferredEditor) { + process.env.EPICSHOP_EDITOR = preferredEditor + } + if (!silent) { console.log( chalk.cyan( @@ -1656,6 +1758,9 @@ export async function config( loadConfig, saveConfig, getDefaultReposDir, + getPreferredEditor, + setPreferredEditor, + clearPreferredEditor, deleteConfig, } = await import('@epic-web/workshop-utils/workshops.server') @@ -1702,6 +1807,13 @@ export async function config( if (!silent) console.log(chalk.green(`✅ ${message}`)) } + if (options.preferredEditor) { + await setPreferredEditor(options.preferredEditor) + const message = `Preferred editor set to: ${options.preferredEditor}` + messages.push(message) + if (!silent) console.log(chalk.green(`✅ ${message}`)) + } + // If either option was set, return now if (messages.length > 0) { return { success: true, message: messages.join('; ') } @@ -1710,7 +1822,14 @@ export async function config( if (silent) { // In silent mode, just return current config const reposDir = await getReposDirectory() - return { success: true, message: `Repos directory: ${reposDir}` } + const preferredEditor = await getPreferredEditor() + const editorMessage = preferredEditor + ? `Preferred editor: ${preferredEditor}` + : 'Preferred editor: not set' + return { + success: true, + message: `Repos directory: ${reposDir}; ${editorMessage}`, + } } // Interactive config selection @@ -1718,14 +1837,24 @@ export async function config( reason: 'select a configuration option', hints: [ 'Set repos dir directly: npx epicshop config --repos-dir ', + 'Set preferred editor: npx epicshop config --editor ', 'Delete config non-interactively: npx epicshop config reset --silent', ], }) - const { search, confirm } = await import('@inquirer/prompts') + const { search, confirm, select } = await import('@inquirer/prompts') + const { formatEditorLabel } = + await import('@epic-web/workshop-utils/launch-editor.server') const reposDir = await getReposDirectory() const isConfigured = await isReposDirectoryConfigured() const defaultDir = getDefaultReposDir() + const preferredEditor = await getPreferredEditor() + const preferredEditorDescription = preferredEditor + ? formatEditorChoiceName({ + label: formatEditorLabel(preferredEditor), + command: preferredEditor, + }) + : 'Not set' // Build config options const configOptions = [ @@ -1734,6 +1863,11 @@ export async function config( value: 'repos-dir', description: isConfigured ? reposDir : `${reposDir} (default)`, }, + { + name: 'Preferred editor', + value: 'preferred-editor', + description: preferredEditorDescription, + }, { name: `Reset config file`, value: 'reset', @@ -1741,8 +1875,97 @@ export async function config( }, ] + const handlePreferredEditorConfig = async (): Promise => { + console.log() + console.log(chalk.bold(' Current value:')) + if (preferredEditor) { + console.log(chalk.white(` ${preferredEditorDescription}`)) + } else { + console.log(chalk.gray(' Not set')) + } + console.log() + + const actionChoices = [ + { + name: 'Edit', + value: 'edit', + description: 'Choose a preferred editor', + }, + ...(preferredEditor + ? [ + { + name: 'Remove', + value: 'remove', + description: 'Clear the preferred editor', + }, + ] + : []), + { + name: 'Cancel', + value: 'cancel', + description: 'Go back without changes', + }, + ] + + const action = await search({ + message: 'What would you like to do?', + source: async (input) => { + if (!input) return actionChoices + return matchSorter(actionChoices, input, { + keys: ['name', 'value', 'description'], + }) + }, + }) + + if (action === 'edit') { + const editorChoices = await getInstalledEditorChoices() + if (editorChoices.length === 0) { + console.log( + chalk.yellow( + '⚠️ No supported editors detected. Set EPICSHOP_EDITOR or install a supported editor.', + ), + ) + return { + success: true, + message: 'No supported editors detected', + } + } + + const selectedEditor = await select({ + message: 'Select your preferred editor:', + choices: editorChoices, + }) + + await setPreferredEditor(selectedEditor) + console.log() + console.log( + chalk.green( + `✅ Preferred editor set to: ${chalk.bold(selectedEditor)}`, + ), + ) + return { + success: true, + message: `Preferred editor set to: ${selectedEditor}`, + } + } + + if (action === 'remove') { + await clearPreferredEditor() + console.log() + console.log(chalk.green('✅ Preferred editor cleared.')) + return { success: true, message: 'Preferred editor cleared' } + } + + console.log(chalk.gray('\nNo changes made.')) + return { success: true, message: 'Cancelled' } + } + console.log(chalk.bold.cyan('\n⚙️ Workshop Configuration\n')) + if (options.subcommand === 'editor') { + return await handlePreferredEditorConfig() + } + const selectedConfig = await search({ message: 'Select a setting to configure:', source: async (input) => { @@ -1778,6 +2001,10 @@ export async function config( } } + if (selectedConfig === 'preferred-editor') { + return await handlePreferredEditorConfig() + } + if (selectedConfig === 'repos-dir') { // Show current value and actions console.log() @@ -2333,7 +2560,10 @@ async function promptAndSetupAccessibleWorkshops(): Promise { spinner.start('Checking access...') } - enrichedWorkshops = await checkWorkshopAccess(enrichedWorkshops) + enrichedWorkshops = await checkWorkshopAccess( + enrichedWorkshops, + authStatusMap, + ) spinner.succeed(`Found ${enrichedWorkshops.length} available workshops`) } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -2346,11 +2576,18 @@ async function promptAndSetupAccessibleWorkshops(): Promise { return } - const candidates = enrichedWorkshops.filter( - (w) => w.name !== TUTORIAL_REPO && w.hasAccess === true && !w.isDownloaded, + const availableWorkshops = enrichedWorkshops.filter( + (w) => w.name !== TUTORIAL_REPO && !w.isDownloaded, ) + const accessibleWorkshops = availableWorkshops.filter( + (w) => w.hasAccess === true, + ) + const selectableWorkshops = + accessibleWorkshops.length > 0 + ? accessibleWorkshops + : availableWorkshops.filter((w) => w.hasAccess !== false) - if (candidates.length === 0) { + if (selectableWorkshops.length === 0) { console.log( chalk.gray( 'No additional workshops to set up right now (either none found, none accessible, or already downloaded).\n', @@ -2360,7 +2597,11 @@ async function promptAndSetupAccessibleWorkshops(): Promise { } console.log() - console.log(chalk.bold.cyan('Available Workshops You Have Access To\n')) + const header = + accessibleWorkshops.length > 0 + ? 'Available Workshops You Have Access To\n' + : 'Available Workshops\n' + console.log(chalk.bold.cyan(header)) console.log(chalk.gray('Icon Key:')) console.log(chalk.gray(` 🚀 EpicReact.dev`)) console.log(chalk.gray(` 🌌 EpicWeb.dev`)) @@ -2368,8 +2609,22 @@ async function promptAndSetupAccessibleWorkshops(): Promise { console.log(chalk.gray(` 🔑 You have access to this workshop`)) console.log() + if (accessibleWorkshops.length === 0) { + console.log( + chalk.yellow( + '💡 We could not confirm access for available workshops. You can still select them to try setup.', + ), + ) + console.log( + chalk.gray( + ` To verify access, log in with: ${chalk.cyan('npx epicshop auth')}`, + ), + ) + console.log() + } + // Filter workshops that are part of a product (for "All My Workshops" option) - const workshopsWithProduct = candidates.filter((w) => w.productSlug) + const workshopsWithProduct = accessibleWorkshops.filter((w) => w.productSlug) // Group workshops by product for quick-select options const workshopsByProduct = new Map() @@ -2463,11 +2718,11 @@ async function promptAndSetupAccessibleWorkshops(): Promise { ) } else { // Show checkbox for individual selection - const individualChoices = candidates.map((w) => { + const individualChoices = selectableWorkshops.map((w) => { const productIcon = w.productHost ? PRODUCT_ICONS[w.productHost] || '' : '' - const accessIcon = chalk.yellow('🔑') + const accessIcon = w.hasAccess === true ? chalk.yellow('🔑') : '' const name = [productIcon, w.title || w.name, accessIcon] .filter(Boolean) .join(' ') @@ -2505,7 +2760,7 @@ async function promptAndSetupAccessibleWorkshops(): Promise { // Create a map from repo name to workshop title for nice display const repoToTitle = new Map() - for (const w of candidates) { + for (const w of selectableWorkshops) { repoToTitle.set(w.name, w.title || w.name) } const getDisplayName = (repo: string) => repoToTitle.get(repo) || repo diff --git a/packages/workshop-utils/src/launch-editor.server.ts b/packages/workshop-utils/src/launch-editor.server.ts index d4b6028d..eefcb595 100644 --- a/packages/workshop-utils/src/launch-editor.server.ts +++ b/packages/workshop-utils/src/launch-editor.server.ts @@ -118,6 +118,35 @@ const COMMON_EDITORS_WIN = [ 'zed.exe', ] +// Map Windows executable names to their CLI commands +const WINDOWS_CLI_COMMANDS: Record = { + 'Code.exe': 'code', + 'Cursor.exe': 'cursor', + 'Code - Insiders.exe': 'code-insiders', + 'VSCodium.exe': 'vscodium', + 'atom.exe': 'atom', + 'Brackets.exe': 'brackets', + 'sublime_text.exe': 'subl', + 'notepad++.exe': 'notepad++', + 'clion.exe': 'clion', + 'clion64.exe': 'clion', + 'idea.exe': 'idea', + 'idea64.exe': 'idea', + 'phpstorm.exe': 'phpstorm', + 'phpstorm64.exe': 'phpstorm', + 'pycharm.exe': 'pycharm', + 'pycharm64.exe': 'pycharm', + 'rubymine.exe': 'rubymine', + 'rubymine64.exe': 'rubymine', + 'webstorm.exe': 'webstorm', + 'webstorm64.exe': 'webstorm', + 'goland.exe': 'goland', + 'goland64.exe': 'goland', + 'rider.exe': 'rider', + 'rider64.exe': 'rider', + 'zed.exe': 'zed', +} + // Transpiled version of: /^([A-Za-z]:[/\\])?[\p{L}0-9/.\-_\\]+$/u // Non-transpiled version requires support for Unicode property regex. Allows // alphanumeric characters, periods, dashes, slashes, and underscores. @@ -305,6 +334,113 @@ function guessEditor(): Array { return [null] } +export type EditorChoice = { + command: string + label: string +} + +const EDITOR_LABELS: Record = { + atom: 'Atom', + brackets: 'Brackets', + cursor: 'Cursor', + code: 'Visual Studio Code', + 'code-insiders': 'Visual Studio Code - Insiders', + vscodium: 'VSCodium', + emacs: 'Emacs', + gvim: 'GVim', + vim: 'Vim', + mvim: 'MacVim', + subl: 'Sublime Text', + sublime_text: 'Sublime Text', + 'notepad++': 'Notepad++', + clion: 'CLion', + idea: 'IntelliJ IDEA', + phpstorm: 'PhpStorm', + pycharm: 'PyCharm', + rubymine: 'RubyMine', + webstorm: 'WebStorm', + goland: 'GoLand', + rider: 'Rider', + appcode: 'AppCode', + zed: 'Zed', +} + +function normalizeEditorCommand(command: string): string { + const base = path.basename(command).replace(/\.(exe|cmd|bat)$/i, '') + return base.trim().toLowerCase() +} + +export function formatEditorLabel(command: string): string { + const normalized = normalizeEditorCommand(command) + return EDITOR_LABELS[normalized] ?? command +} + +function isCommandAvailable(command: string): boolean { + if (!command) return false + if (path.isAbsolute(command)) { + return fs.existsSync(command) + } + if (command.includes(path.sep)) { + return fs.existsSync(command) + } + return commandExistsInPath(command) +} + +function commandExistsInPath(command: string): boolean { + const pathValue = process.env.PATH ?? '' + if (!pathValue) return false + const pathExt = + process.platform === 'win32' + ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';') + : [''] + const hasExtension = Boolean(path.extname(command)) + + for (const dir of pathValue.split(path.delimiter)) { + if (!dir) continue + for (const ext of pathExt) { + const suffix = hasExtension ? '' : ext + const fullPath = path.join(dir, `${command}${suffix}`) + if (fs.existsSync(fullPath)) return true + } + } + return false +} + +function getSupportedEditorCommands(): string[] { + if (process.platform === 'darwin') { + return Array.from(new Set(Object.values(COMMON_EDITORS_OSX))) + } + if (process.platform === 'win32') { + return Array.from( + new Set( + COMMON_EDITORS_WIN.map((exe) => WINDOWS_CLI_COMMANDS[exe] ?? exe), + ), + ) + } + return Array.from(new Set(Object.values(COMMON_EDITORS_LINUX))) +} + +export function getAvailableEditors(): EditorChoice[] { + const available: EditorChoice[] = [] + const seen = new Set() + for (const command of getSupportedEditorCommands()) { + if (!isCommandAvailable(command)) continue + if (seen.has(command)) continue + seen.add(command) + available.push({ + command, + label: formatEditorLabel(command), + }) + } + return available.sort((a, b) => a.label.localeCompare(b.label)) +} + +export function getDefaultEditorCommand(): string | null { + const editorInfo = guessEditor() + const editor = editorInfo[0] + return editor ? String(editor) : null +} + let _childProcess: ReturnType | null = null export type Result = | { status: 'success' } diff --git a/packages/workshop-utils/src/workshops.server.ts b/packages/workshop-utils/src/workshops.server.ts index 626b395e..7e55d5f7 100644 --- a/packages/workshop-utils/src/workshops.server.ts +++ b/packages/workshop-utils/src/workshops.server.ts @@ -28,6 +28,7 @@ export type PackageManager = (typeof PACKAGE_MANAGERS)[number] // Schema for workshop configuration (stored settings only) const ConfigSchema = z.object({ reposDirectory: z.string().optional(), + preferredEditor: z.string().optional(), }) export type Workshop = { @@ -110,6 +111,23 @@ export async function setReposDirectory(directory: string): Promise { await saveConfig(config) } +export async function getPreferredEditor(): Promise { + const config = await loadConfig() + return config.preferredEditor +} + +export async function setPreferredEditor(editor: string): Promise { + const config = await loadConfig() + config.preferredEditor = editor + await saveConfig(config) +} + +export async function clearPreferredEditor(): Promise { + const config = await loadConfig() + delete config.preferredEditor + await saveConfig(config) +} + export type ReposDirectoryStatus = | { accessible: true } | { accessible: false; error: string; path: string }