diff --git a/src/cli.config.ts b/src/cli.config.ts index da176908..da855d2e 100644 --- a/src/cli.config.ts +++ b/src/cli.config.ts @@ -57,6 +57,38 @@ export const config = { port: 5173, callbackPath: '/callback', }, + python: { + port: 8000, + callbackPath: '/auth/callback/', + }, + ruby: { + port: 3000, + callbackPath: '/auth/callback', + }, + php: { + port: 8000, + callbackPath: '/auth/callback', + }, + phpLaravel: { + port: 8000, + callbackPath: '/auth/callback', + }, + go: { + port: 8080, + callbackPath: '/auth/callback', + }, + dotnet: { + port: 5000, + callbackPath: '/auth/callback', + }, + elixir: { + port: 4000, + callbackPath: '/auth/callback', + }, + kotlin: { + port: 8080, + callbackPath: '/auth/callback', + }, }, legacy: { diff --git a/src/integrations/dotnet/index.ts b/src/integrations/dotnet/index.ts index 3d4f4eed..d107c662 100644 --- a/src/integrations/dotnet/index.ts +++ b/src/integrations/dotnet/index.ts @@ -1,7 +1,16 @@ /* .NET (ASP.NET Core) integration — auto-discovered by registry */ +import { readdirSync } from 'node:fs'; import type { FrameworkConfig } from '../../lib/framework-config.js'; import type { InstallerOptions } from '../../utils/types.js'; import { enableDebugLogs } from '../../utils/debug.js'; + +function hasCsproj(installDir: string): boolean { + try { + return readdirSync(installDir).some((f) => f.endsWith('.csproj')); + } catch { + return false; + } +} import { SPINNER_MESSAGE } from '../../lib/framework-config.js'; import { getOrAskForWorkOSCredentials } from '../../utils/clack-utils.js'; import { analytics } from '../../utils/analytics.js'; @@ -22,6 +31,8 @@ export const config: FrameworkConfig = { priority: 35, packageManager: 'dotnet', manifestFile: '*.csproj', + // existsSync cannot glob, so match any *.csproj in the install dir. + detect: (options) => hasCsproj(options.installDir), }, detection: { diff --git a/src/integrations/kotlin/index.ts b/src/integrations/kotlin/index.ts index 102077e6..1e71aa4f 100644 --- a/src/integrations/kotlin/index.ts +++ b/src/integrations/kotlin/index.ts @@ -1,8 +1,31 @@ /* Kotlin (Spring Boot) integration — auto-discovered by registry */ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import type { FrameworkConfig } from '../../lib/framework-config.js'; import type { InstallerOptions } from '../../utils/types.js'; import { enableDebugLogs } from '../../utils/debug.js'; +function hasKotlinContent(path: string, pattern: RegExp): boolean { + try { + return pattern.test(readFileSync(path, 'utf-8')); + } catch { + return false; + } +} + +function isKotlinProject(installDir: string): boolean { + const kts = join(installDir, 'build.gradle.kts'); + if (existsSync(kts) && hasKotlinContent(kts, /org\.jetbrains\.kotlin|kotlin\(/)) return true; + + const gradle = join(installDir, 'build.gradle'); + if (existsSync(gradle) && hasKotlinContent(gradle, /kotlin/)) return true; + + const pom = join(installDir, 'pom.xml'); + if (existsSync(pom) && hasKotlinContent(pom, /kotlin/i)) return true; + + return false; +} + export const config: FrameworkConfig = { metadata: { name: 'Kotlin (Spring Boot)', @@ -14,6 +37,8 @@ export const config: FrameworkConfig = { priority: 40, packageManager: 'gradle', manifestFile: 'build.gradle.kts', + // Also match Groovy DSL (build.gradle) and Maven (pom.xml) Kotlin projects. + detect: (options) => isKotlinProject(options.installDir), }, detection: { diff --git a/src/integrations/python/index.ts b/src/integrations/python/index.ts index 07a7cc49..cf9af2c9 100644 --- a/src/integrations/python/index.ts +++ b/src/integrations/python/index.ts @@ -99,6 +99,7 @@ export const config: FrameworkConfig = { priority: 60, packageManager: 'pip', manifestFile: 'pyproject.toml', + detect: (options: Pick) => isDjangoProject(options.installDir), gatherContext: async (options: InstallerOptions) => { const pkgMgr = detectPythonPackageManager(options.installDir); return { diff --git a/src/lib/env-writer.ts b/src/lib/env-writer.ts index 096af739..9f07ad9d 100644 --- a/src/lib/env-writer.ts +++ b/src/lib/env-writer.ts @@ -3,29 +3,31 @@ import { join } from 'path'; import { parseEnvFile } from '../utils/env-parser.js'; const ENV_LOCAL_COVERING_PATTERNS = ['.env.local', '.env*.local', '.env*']; +const ENV_COVERING_PATTERNS = ['.env', '.env*']; /** - * Ensure .env.local is in .gitignore. + * Ensure the given env filename is in .gitignore. * Creates .gitignore if it doesn't exist. * No-ops if a covering pattern is already present. */ -function ensureGitignore(installDir: string): void { +function ensureGitignore(installDir: string, filename: '.env' | '.env.local'): void { const gitignorePath = join(installDir, '.gitignore'); + const coveringPatterns = filename === '.env' ? ENV_COVERING_PATTERNS : ENV_LOCAL_COVERING_PATTERNS; if (!existsSync(gitignorePath)) { - writeFileSync(gitignorePath, '.env.local\n'); + writeFileSync(gitignorePath, `${filename}\n`); return; } const content = readFileSync(gitignorePath, 'utf-8'); const lines = content.split('\n').map((line) => line.trim()); - if (lines.some((line) => ENV_LOCAL_COVERING_PATTERNS.includes(line))) { + if (lines.some((line) => coveringPatterns.includes(line))) { return; } const separator = content.endsWith('\n') ? '' : '\n'; - writeFileSync(gitignorePath, `${content}${separator}.env.local\n`); + writeFileSync(gitignorePath, `${content}${separator}${filename}\n`); } interface EnvVars { @@ -76,7 +78,40 @@ export function writeEnvLocal(installDir: string, envVars: Partial): vo .map(([key, value]) => `${key}=${value}`) .join('\n'); - ensureGitignore(installDir); + ensureGitignore(installDir, '.env.local'); + + writeFileSync(envPath, content + '\n'); +} + +/** + * Write WorkOS credentials to the appropriate env file for the project. + * Picks `.env.local` for JS projects (package.json present) or `.env` for + * everything else (Python/Django, Ruby/Rails, Go, ...). Skips cookie password + * generation outside the JS branch — non-JS SDKs don't use it. + * + * Used by pre-detection flows that write credentials before the framework + * integration is known (unclaimed env provisioning). + */ +export function writeCredentialsEnv(installDir: string, envVars: Partial): void { + const hasPackageJson = existsSync(join(installDir, 'package.json')); + if (hasPackageJson) { + writeEnvLocal(installDir, envVars); + return; + } + + const envPath = join(installDir, '.env'); + let existingEnv: Record = {}; + if (existsSync(envPath)) { + const content = readFileSync(envPath, 'utf-8'); + existingEnv = parseEnvFile(content); + } + + const merged = { ...existingEnv, ...envVars }; + const content = Object.entries(merged) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + + ensureGitignore(installDir, '.env'); writeFileSync(envPath, content + '\n'); } diff --git a/src/lib/framework-config.ts b/src/lib/framework-config.ts index bdb33fb5..5ff1e6d8 100644 --- a/src/lib/framework-config.ts +++ b/src/lib/framework-config.ts @@ -1,5 +1,9 @@ import type { InstallerOptions } from '../utils/types.js'; -import type { Language } from './language-detection.js'; + +/** + * Supported programming languages for framework integrations. + */ +export type Language = 'javascript' | 'python' | 'ruby' | 'php' | 'go' | 'kotlin' | 'dotnet' | 'elixir'; /** * Configuration interface for framework-specific agent integrations. @@ -60,6 +64,14 @@ export interface FrameworkMetadata { /** Primary manifest file (e.g., 'pyproject.toml', 'Gemfile'). Optional for JS integrations. */ manifestFile?: string; + + /** + * Optional custom detection override for non-JS integrations. When present, + * the registry calls this instead of falling back to `manifestFile` existence. + * Use when a single manifest file isn't enough (e.g., Django projects may + * use `manage.py` + `requirements.txt` without a `pyproject.toml`). + */ + detect?: (options: Pick) => boolean | Promise; } /** diff --git a/src/lib/language-detection.ts b/src/lib/language-detection.ts deleted file mode 100644 index 96c6da89..00000000 --- a/src/lib/language-detection.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; - -/** - * Supported programming languages for framework detection. - */ -export type Language = 'javascript' | 'python' | 'ruby' | 'php' | 'go' | 'kotlin' | 'dotnet' | 'elixir'; - -export interface LanguageSignal { - language: Language; - confidence: number; // 0-1 - manifestFile: string; -} - -export interface LanguageDetectionResult { - primary: Language; - signals: LanguageSignal[]; - ambiguous: boolean; -} - -function fileExists(cwd: string, filename: string): { found: boolean; manifestFile: string } { - const fullPath = join(cwd, filename); - return { found: existsSync(fullPath), manifestFile: filename }; -} - -function globExists(cwd: string, pattern: string): { found: boolean; manifestFile: string } { - // Simple glob for *.ext patterns in the root directory - const ext = pattern.replace('*', ''); - try { - const files = readdirSync(cwd); - const match = files.find((f) => f.endsWith(ext)); - return { found: !!match, manifestFile: match || pattern }; - } catch { - return { found: false, manifestFile: pattern }; - } -} - -function detectPython(cwd: string): { found: boolean; manifestFile: string } { - for (const file of ['pyproject.toml', 'requirements.txt', 'setup.py', 'Pipfile']) { - if (existsSync(join(cwd, file))) { - return { found: true, manifestFile: file }; - } - } - return { found: false, manifestFile: 'pyproject.toml' }; -} - -function detectKotlin(cwd: string): { found: boolean; manifestFile: string } { - const ktsPath = join(cwd, 'build.gradle.kts'); - if (existsSync(ktsPath)) { - try { - const content = readFileSync(ktsPath, 'utf-8'); - if (/org\.jetbrains\.kotlin/.test(content) || /kotlin\(/.test(content)) { - return { found: true, manifestFile: 'build.gradle.kts' }; - } - } catch { - // Can't read file - } - } - - // Also check build.gradle (Groovy DSL) - const gradlePath = join(cwd, 'build.gradle'); - if (existsSync(gradlePath)) { - try { - const content = readFileSync(gradlePath, 'utf-8'); - if (/kotlin/.test(content)) { - return { found: true, manifestFile: 'build.gradle' }; - } - } catch { - // Can't read file - } - } - - return { found: false, manifestFile: 'build.gradle.kts' }; -} - -/** - * Language detectors ordered by specificity. - * More specific languages are checked first. - * JavaScript is last because many non-JS projects also have package.json. - */ -const LANGUAGE_DETECTORS: Array<{ - language: Language; - detect: (cwd: string) => { found: boolean; manifestFile: string }; -}> = [ - { language: 'elixir', detect: (cwd) => fileExists(cwd, 'mix.exs') }, - { language: 'go', detect: (cwd) => fileExists(cwd, 'go.mod') }, - { language: 'dotnet', detect: (cwd) => globExists(cwd, '*.csproj') }, - { language: 'kotlin', detect: detectKotlin }, - { language: 'ruby', detect: (cwd) => fileExists(cwd, 'Gemfile') }, - { language: 'php', detect: (cwd) => fileExists(cwd, 'composer.json') }, - { language: 'python', detect: detectPython }, - { language: 'javascript', detect: (cwd) => fileExists(cwd, 'package.json') }, -]; - -/** - * Detect the primary programming language of a project. - * Runs all detectors and returns the highest-priority match. - * Sets `ambiguous: true` if multiple non-JS languages are detected. - */ -export function detectLanguage(cwd: string): LanguageDetectionResult | undefined { - const signals: LanguageSignal[] = []; - - for (const detector of LANGUAGE_DETECTORS) { - const result = detector.detect(cwd); - if (result.found) { - signals.push({ - language: detector.language, - confidence: 1.0, - manifestFile: result.manifestFile, - }); - } - } - - if (signals.length === 0) { - return undefined; - } - - const primary = signals[0].language; - - // Ambiguous if multiple non-JS languages detected - const nonJsSignals = signals.filter((s) => s.language !== 'javascript'); - const ambiguous = nonJsSignals.length > 1; - - return { primary, signals, ambiguous }; -} diff --git a/src/lib/port-detection.spec.ts b/src/lib/port-detection.spec.ts new file mode 100644 index 00000000..35591576 --- /dev/null +++ b/src/lib/port-detection.spec.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { detectPort, getCallbackPath } from './port-detection.js'; + +describe('port-detection — python/Django defaults', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'port-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('returns 8000 for python', () => { + expect(detectPort('python', dir)).toBe(8000); + }); + + it('returns /auth/callback/ for python', () => { + expect(getCallbackPath('python')).toBe('/auth/callback/'); + }); +}); + +describe('port-detection — non-JS integration defaults', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'port-defaults-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it.each([ + ['ruby', 3000], + ['php', 8000], + ['php-laravel', 8000], + ['go', 8080], + ['dotnet', 5000], + ['elixir', 4000], + ['kotlin', 8080], + ] as const)('%s falls back to port %i when no config file present', (integration, expectedPort) => { + expect(detectPort(integration, dir)).toBe(expectedPort); + }); +}); + +describe('port-detection — dotnet launchSettings.json', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'port-dotnet-')); + await mkdir(join(dir, 'Properties'), { recursive: true }); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('parses port from http applicationUrl', async () => { + await writeFile( + join(dir, 'Properties', 'launchSettings.json'), + JSON.stringify({ + profiles: { Example: { applicationUrl: 'http://localhost:5123;https://localhost:7123' } }, + }), + ); + expect(detectPort('dotnet', dir)).toBe(5123); + }); + + it('falls back to default when JSON is malformed', async () => { + await writeFile(join(dir, 'Properties', 'launchSettings.json'), '{ not json'); + expect(detectPort('dotnet', dir)).toBe(5000); + }); +}); + +describe('port-detection — elixir/phoenix config', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'port-elixir-')); + await mkdir(join(dir, 'config'), { recursive: true }); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('parses port from config/dev.exs', async () => { + await writeFile( + join(dir, 'config', 'dev.exs'), + 'config :my_app, MyAppWeb.Endpoint,\n http: [ip: {127, 0, 0, 1}, port: 4567]\n', + ); + expect(detectPort('elixir', dir)).toBe(4567); + }); + + it('falls back to runtime.exs when dev.exs missing', async () => { + await writeFile(join(dir, 'config', 'runtime.exs'), 'port: 4321\n'); + expect(detectPort('elixir', dir)).toBe(4321); + }); +}); + +describe('port-detection — kotlin/spring boot', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'port-kotlin-')); + await mkdir(join(dir, 'src', 'main', 'resources'), { recursive: true }); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('parses port from application.properties', async () => { + await writeFile(join(dir, 'src', 'main', 'resources', 'application.properties'), 'server.port=9090\n'); + expect(detectPort('kotlin', dir)).toBe(9090); + }); + + it('parses port from application.yml nested under server:', async () => { + await writeFile( + join(dir, 'src', 'main', 'resources', 'application.yml'), + 'spring:\n profiles:\n active: dev\nserver:\n port: 9191\n', + ); + expect(detectPort('kotlin', dir)).toBe(9191); + }); +}); + +describe('port-detection — ruby/rails puma', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'port-ruby-')); + await mkdir(join(dir, 'config'), { recursive: true }); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('parses port from ENV.fetch block form', async () => { + await writeFile(join(dir, 'config', 'puma.rb'), 'port ENV.fetch("PORT") { 3456 }\n'); + expect(detectPort('ruby', dir)).toBe(3456); + }); + + it('parses port from ENV.fetch two-arg form', async () => { + await writeFile(join(dir, 'config', 'puma.rb'), 'port ENV.fetch("PORT", 3789)\n'); + expect(detectPort('ruby', dir)).toBe(3789); + }); + + it('parses literal port directive', async () => { + await writeFile(join(dir, 'config', 'puma.rb'), 'workers 2\nport 4000\n'); + expect(detectPort('ruby', dir)).toBe(4000); + }); +}); diff --git a/src/lib/port-detection.ts b/src/lib/port-detection.ts index f8f64777..f0a8cb74 100644 --- a/src/lib/port-detection.ts +++ b/src/lib/port-detection.ts @@ -5,12 +5,20 @@ import { getConfig } from './settings.js'; const settings = getConfig(); -const INTEGRATION_TO_SETTINGS_KEY: Record = { +const INTEGRATION_TO_SETTINGS_KEY: Record = { nextjs: 'nextjs', react: 'react', 'tanstack-start': 'tanstackStart', 'react-router': 'reactRouter', 'vanilla-js': 'vanillaJs', + python: 'python', + ruby: 'ruby', + php: 'php', + 'php-laravel': 'phpLaravel', + go: 'go', + dotnet: 'dotnet', + elixir: 'elixir', + kotlin: 'kotlin', }; const DEFAULT_PORT = 3000; @@ -88,6 +96,103 @@ function parseTanStackPort(installDir: string): number | null { return null; } +/** + * Parse port from .NET Properties/launchSettings.json. + * VS/Rider scaffold: profiles[*].applicationUrl = "http://localhost:5000;https://localhost:5001" + */ +function parseDotnetPort(installDir: string): number | null { + try { + const configPath = join(installDir, 'Properties', 'launchSettings.json'); + const content = fs.readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(content) as { profiles?: Record }; + for (const profile of Object.values(parsed.profiles ?? {})) { + const match = profile.applicationUrl?.match(/http:\/\/[^:/]+:(\d+)/); + if (match) return parseInt(match[1], 10); + } + } catch { + // File doesn't exist or can't parse + } + return null; +} + +/** + * Parse port from Phoenix config/dev.exs or config/runtime.exs. + * Looks for `port: NNNN` — typically inside `http: [...]` but a bare regex is fine. + */ +function parseElixirPort(installDir: string): number | null { + for (const relPath of ['config/dev.exs', 'config/runtime.exs']) { + try { + const content = fs.readFileSync(join(installDir, relPath), 'utf-8'); + const match = content.match(/port:\s*(\d+)/); + if (match) return parseInt(match[1], 10); + } catch { + // skip + } + } + return null; +} + +/** + * Parse port from Spring Boot application.properties or application.yml. + * Both the default `src/main/resources/` location and a top-level file are checked. + */ +function parseKotlinPort(installDir: string): number | null { + const propsPaths = [ + join(installDir, 'src', 'main', 'resources', 'application.properties'), + join(installDir, 'application.properties'), + ]; + for (const propsPath of propsPaths) { + try { + const content = fs.readFileSync(propsPath, 'utf-8'); + const match = content.match(/^server\.port\s*=\s*(\d+)/m); + if (match) return parseInt(match[1], 10); + } catch { + // skip + } + } + + const ymlPaths = [ + join(installDir, 'src', 'main', 'resources', 'application.yml'), + join(installDir, 'src', 'main', 'resources', 'application.yaml'), + join(installDir, 'application.yml'), + join(installDir, 'application.yaml'), + ]; + for (const ymlPath of ymlPaths) { + try { + const content = fs.readFileSync(ymlPath, 'utf-8'); + // `server:\n port: 8080` — shallow YAML parse via regex + const match = content.match(/server\s*:\s*\n[^\S\n]+port\s*:\s*(\d+)/); + if (match) return parseInt(match[1], 10); + } catch { + // skip + } + } + return null; +} + +/** + * Parse port from Rails config/puma.rb. + * Common forms: + * port ENV.fetch("PORT") { 3000 } + * port ENV.fetch("PORT", 3000) + * port 3000 + */ +function parseRubyPort(installDir: string): number | null { + try { + const configPath = join(installDir, 'config', 'puma.rb'); + const content = fs.readFileSync(configPath, 'utf-8'); + const blockFetch = content.match(/port\s+ENV\.fetch\([^)]*\)\s*\{\s*(\d+)\s*\}/); + if (blockFetch) return parseInt(blockFetch[1], 10); + const argFetch = content.match(/port\s+ENV\.fetch\([^,]+,\s*(\d+)\)/); + if (argFetch) return parseInt(argFetch[1], 10); + const literal = content.match(/^\s*port\s+(\d+)/m); + if (literal) return parseInt(literal[1], 10); + } catch { + // skip + } + return null; +} + /** * Detect the dev server port for a framework. * Checks config files first, falls back to framework default. @@ -119,6 +224,22 @@ export function detectPort(integration: Integration, installDir: string): number } break; } + + case 'dotnet': + detectedPort = parseDotnetPort(installDir); + break; + + case 'elixir': + detectedPort = parseElixirPort(installDir); + break; + + case 'kotlin': + detectedPort = parseKotlinPort(installDir); + break; + + case 'ruby': + detectedPort = parseRubyPort(installDir); + break; } return detectedPort ?? getDefaultPort(integration); diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 9ea4edd7..f2a9ebbf 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -1,8 +1,7 @@ import { readdirSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { FrameworkConfig } from './framework-config.js'; -import type { Language } from './language-detection.js'; +import type { FrameworkConfig, Language } from './framework-config.js'; import type { InstallerOptions } from '../utils/types.js'; /** diff --git a/src/lib/run-with-core.spec.ts b/src/lib/run-with-core.spec.ts new file mode 100644 index 00000000..984492ea --- /dev/null +++ b/src/lib/run-with-core.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { detectSingleIntegration } from './run-with-core.js'; + +describe('detectSingleIntegration', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'detect-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + // Regression guard: getPackageDotJson() calls process.exit(1) when package.json + // is missing, which previously aborted the whole installer in Django projects + // before Python detection ran. + it('returns false for JS integrations when no package.json exists', async () => { + await writeFile(join(dir, 'manage.py'), '# django'); + await writeFile(join(dir, 'requirements.txt'), 'django>=5.0\n'); + + const result = await detectSingleIntegration('nextjs', { installDir: dir }); + expect(result).toBe(false); + }); + + it('detects python integration via pyproject.toml', async () => { + await writeFile(join(dir, 'pyproject.toml'), '[project]\nname = "demo"\ndependencies = ["django>=5.0"]\n'); + + const result = await detectSingleIntegration('python', { installDir: dir }); + expect(result).toBe(true); + }); + + it('detects python integration via manage.py alone', async () => { + await writeFile(join(dir, 'manage.py'), '# django entrypoint'); + + const result = await detectSingleIntegration('python', { installDir: dir }); + expect(result).toBe(true); + }); + + it('detects python integration via requirements.txt with django', async () => { + await writeFile(join(dir, 'requirements.txt'), 'django>=5.0\n'); + + const result = await detectSingleIntegration('python', { installDir: dir }); + expect(result).toBe(true); + }); + + it('does not detect python for a non-django python project', async () => { + await writeFile(join(dir, 'requirements.txt'), 'flask>=3.0\n'); + + const result = await detectSingleIntegration('python', { installDir: dir }); + expect(result).toBe(false); + }); + + it('detects dotnet via any *.csproj file (glob, not literal match)', async () => { + await writeFile(join(dir, 'Example.csproj'), '\n'); + + const result = await detectSingleIntegration('dotnet', { installDir: dir }); + expect(result).toBe(true); + }); + + it('detects kotlin via build.gradle (Groovy DSL), not just build.gradle.kts', async () => { + await writeFile(join(dir, 'build.gradle'), "plugins { id 'org.jetbrains.kotlin.jvm' version '1.9.0' }\n"); + + const result = await detectSingleIntegration('kotlin', { installDir: dir }); + expect(result).toBe(true); + }); + + it('detects kotlin via pom.xml (Maven)', async () => { + await writeFile(join(dir, 'pom.xml'), '\n'); + + const result = await detectSingleIntegration('kotlin', { installDir: dir }); + expect(result).toBe(true); + }); +}); diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index ef14508f..0e9893d8 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -98,7 +98,7 @@ async function detectIntegrationFn(options: Pick * Detect if a single integration matches the project. * Uses package.json detection for JS integrations, manifest files for others. */ -async function detectSingleIntegration( +export async function detectSingleIntegration( integration: string, options: Pick, ): Promise { @@ -115,6 +115,12 @@ async function detectSingleIntegration( // For JS integrations, check package.json if (config.metadata.language === 'javascript') { + // Without a package.json, no JS integration can match. Skip silently so + // non-JS integrations (Python/Django, Ruby, Go, ...) still get a chance — + // getPackageDotJson would otherwise call process.exit(1). + if (!existsSync(join(options.installDir, 'package.json'))) { + return false; + } const packageJson = await getPackageDotJson(options); switch (integration) { @@ -151,7 +157,12 @@ async function detectSingleIntegration( } } - // For non-JS integrations, check manifest files + // For non-JS integrations, prefer a custom detect() if provided + // (e.g., Django matches manage.py | pyproject.toml | requirements.txt), + // otherwise fall back to manifest file existence. + if (config.metadata.detect) { + return await config.metadata.detect(options); + } if (config.metadata.manifestFile) { return existsSync(join(options.installDir, config.metadata.manifestFile)); } @@ -262,6 +273,15 @@ export async function runWithCore(options: InstallerOptions): Promise { throw new Error('Missing integration or credentials'); } + // Non-JS integrations own their env file writing (e.g. Python writes + // .env inside its own run()). Skip here so we don't leak a .env.local + // with JS-flavored vars (WORKOS_COOKIE_PASSWORD, wrong redirect port). + const registry = await getRegistry(); + const mod = registry.get(integration); + if (mod?.config.metadata.language !== 'javascript') { + return; + } + const port = detectPort(integration, installerOptions.installDir); const callbackPath = getCallbackPath(integration); const redirectUri = installerOptions.redirectUri || `http://localhost:${port}${callbackPath}`; diff --git a/src/lib/unclaimed-env-provision.spec.ts b/src/lib/unclaimed-env-provision.spec.ts index 1758fd63..95a23d92 100644 --- a/src/lib/unclaimed-env-provision.spec.ts +++ b/src/lib/unclaimed-env-provision.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { existsSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -135,7 +135,8 @@ describe('unclaimed-env-provision', () => { ); }); - it('writes .env.local with all credentials including cookie password and claim token', async () => { + it('writes .env.local with all credentials including cookie password and claim token (JS project)', async () => { + writeFileSync(join(testDir, 'package.json'), '{}'); mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); await tryProvisionUnclaimedEnv({ installDir: testDir }); @@ -149,6 +150,21 @@ describe('unclaimed-env-provision', () => { expect(content).toContain('WORKOS_CLAIM_TOKEN=ct_token123'); }); + it('writes .env (no cookie password) when no package.json present (non-JS project)', async () => { + mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); + + await tryProvisionUnclaimedEnv({ installDir: testDir }); + + expect(existsSync(join(testDir, '.env.local'))).toBe(false); + const envPath = join(testDir, '.env'); + expect(existsSync(envPath)).toBe(true); + const content = readFileSync(envPath, 'utf-8'); + expect(content).toContain('WORKOS_API_KEY=sk_test_oneshot'); + expect(content).toContain('WORKOS_CLIENT_ID=client_01ABC'); + expect(content).toContain('WORKOS_CLAIM_TOKEN=ct_token123'); + expect(content).not.toContain('WORKOS_COOKIE_PASSWORD'); + }); + it('shows provisioning message to user', async () => { mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); const { renderStderrBox } = await import('../utils/box.js'); @@ -199,7 +215,8 @@ describe('unclaimed-env-provision', () => { expect(result).toBe(false); }); - it('writes redirect URI to .env.local when provided', async () => { + it('writes redirect URI to .env.local when provided (JS project)', async () => { + writeFileSync(join(testDir, 'package.json'), '{}'); mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); await tryProvisionUnclaimedEnv({ @@ -212,7 +229,8 @@ describe('unclaimed-env-provision', () => { expect(content).toContain('NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback'); }); - it('uses WORKOS_REDIRECT_URI key by default when redirect URI provided', async () => { + it('uses WORKOS_REDIRECT_URI key by default when redirect URI provided (JS project)', async () => { + writeFileSync(join(testDir, 'package.json'), '{}'); mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); await tryProvisionUnclaimedEnv({ diff --git a/src/lib/unclaimed-env-provision.ts b/src/lib/unclaimed-env-provision.ts index 707b35f4..e3149e6e 100644 --- a/src/lib/unclaimed-env-provision.ts +++ b/src/lib/unclaimed-env-provision.ts @@ -10,7 +10,7 @@ import chalk from 'chalk'; import { provisionUnclaimedEnvironment, UnclaimedEnvApiError } from './unclaimed-env-api.js'; import { getConfig, saveConfig, getActiveEnvironment } from './config-store.js'; import type { CliConfig } from './config-store.js'; -import { writeEnvLocal } from './env-writer.js'; +import { writeCredentialsEnv } from './env-writer.js'; import { logInfo, logError } from '../utils/debug.js'; import { renderStderrBox } from '../utils/box.js'; import clack from '../utils/clack.js'; @@ -50,7 +50,7 @@ export async function tryProvisionUnclaimedEnv(options: UnclaimedEnvProvisionOpt envVars[key] = options.redirectUri; } - writeEnvLocal(options.installDir, envVars); + writeCredentialsEnv(options.installDir, envVars); // Save to config store (after .env.local succeeds) const config: CliConfig = getConfig() ?? { environments: {} };