From f70b63622c94345c38f3124c2d3b1a4d32ddc381 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Apr 2026 18:06:06 -0700 Subject: [PATCH 1/4] fix: support Django projects end-to-end in workos install Previously `workos install` crashed with "Could not find package.json" in any project without a package.json: JS integration detection calls getPackageDotJson() which exits the process on missing file, killing the installer before non-JS integrations (Python/Django) got a turn. - Skip JS integration detection when package.json is missing - Widen Python detection: match manage.py or requirements.txt with django, not just pyproject.toml (via a new optional detect() override on FrameworkMetadata, wired to the existing isDjangoProject helper) - Write credentials to .env (not .env.local) and skip cookie password generation for non-JS projects in unclaimed env provisioning - Gate state-machine configureEnvironment actor on JS-only to prevent a second .env.local leak post-detection - Add Django port/callback defaults (8000, /auth/callback/) so detectPort returns the right values for Python Tests: +8 net covering detection matrix (no-package.json skip, pyproject.toml, manage.py alone, requirements.txt+django, non-Django python), python port defaults, and .env vs .env.local selection in unclaimed provisioning. --- src/cli.config.ts | 4 ++ src/integrations/python/index.ts | 1 + src/lib/env-writer.ts | 31 ++++++++++++++ src/lib/framework-config.ts | 8 ++++ src/lib/port-detection.spec.ts | 25 +++++++++++ src/lib/port-detection.ts | 3 +- src/lib/run-with-core.spec.ts | 56 +++++++++++++++++++++++++ src/lib/run-with-core.ts | 24 ++++++++++- src/lib/unclaimed-env-provision.spec.ts | 26 ++++++++++-- src/lib/unclaimed-env-provision.ts | 4 +- 10 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 src/lib/port-detection.spec.ts create mode 100644 src/lib/run-with-core.spec.ts diff --git a/src/cli.config.ts b/src/cli.config.ts index da176908..340b3f4d 100644 --- a/src/cli.config.ts +++ b/src/cli.config.ts @@ -57,6 +57,10 @@ export const config = { port: 5173, callbackPath: '/callback', }, + python: { + port: 8000, + callbackPath: '/auth/callback/', + }, }, legacy: { 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..8bf2a2ab 100644 --- a/src/lib/env-writer.ts +++ b/src/lib/env-writer.ts @@ -80,3 +80,34 @@ export function writeEnvLocal(installDir: string, envVars: Partial): vo 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'); + + writeFileSync(envPath, content + '\n'); +} diff --git a/src/lib/framework-config.ts b/src/lib/framework-config.ts index bdb33fb5..c2ca9a8c 100644 --- a/src/lib/framework-config.ts +++ b/src/lib/framework-config.ts @@ -60,6 +60,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/port-detection.spec.ts b/src/lib/port-detection.spec.ts new file mode 100644 index 00000000..f616d484 --- /dev/null +++ b/src/lib/port-detection.spec.ts @@ -0,0 +1,25 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm } 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/'); + }); +}); diff --git a/src/lib/port-detection.ts b/src/lib/port-detection.ts index f8f64777..6d0aba54 100644 --- a/src/lib/port-detection.ts +++ b/src/lib/port-detection.ts @@ -5,12 +5,13 @@ 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', }; const DEFAULT_PORT = 3000; diff --git a/src/lib/run-with-core.spec.ts b/src/lib/run-with-core.spec.ts new file mode 100644 index 00000000..07328aa1 --- /dev/null +++ b/src/lib/run-with-core.spec.ts @@ -0,0 +1,56 @@ +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); + }); +}); 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: {} }; From 304fb462b03e1215a5988ed4ff98e3c9bfa50d62 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Apr 2026 18:11:36 -0700 Subject: [PATCH 2/4] fix: broaden non-JS integration detection, add port defaults for all languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beyond the Django fixes in the previous commit, several other non-JS integrations had parallel problems that would bite the next user with a .NET, Kotlin, or non-trivial project layout. - .NET detection was completely broken: manifestFile '*.csproj' went through existsSync() which does literal-filename matching, so it never matched. Now uses globExists() from language-detection.ts (which has the right logic but wasn't wired up anywhere). - Kotlin detection only matched build.gradle.kts (Kotlin DSL). Now also matches build.gradle (Groovy DSL) and pom.xml (Maven) via the existing detectKotlin() helper, which already had Gradle Groovy support. Extended it to cover Maven too. - Port defaults added for ruby (3000), php (8000), php-laravel (8000), go (8080), dotnet (5000), elixir (4000), kotlin (8080). Previously all fell back to DEFAULT_PORT=3000, which is wrong for every one of them. - ensureGitignore() now also writes .env to .gitignore for non-JS projects (previously only handled .env.local for JS). Exported globExists() and detectKotlin() from language-detection.ts so the integrations can share them. detectLanguage() itself still isn't called anywhere in the install path — that's a larger cleanup for later. --- src/cli.config.ts | 28 ++++++++++++++++++++++++++++ src/integrations/dotnet/index.ts | 3 +++ src/integrations/kotlin/index.ts | 3 +++ src/lib/env-writer.ts | 16 ++++++++++------ src/lib/language-detection.ts | 17 +++++++++++++++-- src/lib/port-detection.spec.ts | 16 ++++++++++++++++ src/lib/port-detection.ts | 7 +++++++ src/lib/run-with-core.spec.ts | 21 +++++++++++++++++++++ 8 files changed, 103 insertions(+), 8 deletions(-) diff --git a/src/cli.config.ts b/src/cli.config.ts index 340b3f4d..da855d2e 100644 --- a/src/cli.config.ts +++ b/src/cli.config.ts @@ -61,6 +61,34 @@ export const config = { 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..46f6b33f 100644 --- a/src/integrations/dotnet/index.ts +++ b/src/integrations/dotnet/index.ts @@ -2,6 +2,7 @@ import type { FrameworkConfig } from '../../lib/framework-config.js'; import type { InstallerOptions } from '../../utils/types.js'; import { enableDebugLogs } from '../../utils/debug.js'; +import { globExists } from '../../lib/language-detection.js'; import { SPINNER_MESSAGE } from '../../lib/framework-config.js'; import { getOrAskForWorkOSCredentials } from '../../utils/clack-utils.js'; import { analytics } from '../../utils/analytics.js'; @@ -22,6 +23,8 @@ export const config: FrameworkConfig = { priority: 35, packageManager: 'dotnet', manifestFile: '*.csproj', + // existsSync cannot glob, so match any *.csproj in the install dir. + detect: (options) => globExists(options.installDir, '*.csproj').found, }, detection: { diff --git a/src/integrations/kotlin/index.ts b/src/integrations/kotlin/index.ts index 102077e6..f7d13a7a 100644 --- a/src/integrations/kotlin/index.ts +++ b/src/integrations/kotlin/index.ts @@ -2,6 +2,7 @@ import type { FrameworkConfig } from '../../lib/framework-config.js'; import type { InstallerOptions } from '../../utils/types.js'; import { enableDebugLogs } from '../../utils/debug.js'; +import { detectKotlin } from '../../lib/language-detection.js'; export const config: FrameworkConfig = { metadata: { @@ -14,6 +15,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) => detectKotlin(options.installDir).found, }, detection: { diff --git a/src/lib/env-writer.ts b/src/lib/env-writer.ts index 8bf2a2ab..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,7 @@ 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'); } @@ -109,5 +111,7 @@ export function writeCredentialsEnv(installDir: string, envVars: Partial `${key}=${value}`) .join('\n'); + ensureGitignore(installDir, '.env'); + writeFileSync(envPath, content + '\n'); } diff --git a/src/lib/language-detection.ts b/src/lib/language-detection.ts index 96c6da89..201e1bd2 100644 --- a/src/lib/language-detection.ts +++ b/src/lib/language-detection.ts @@ -23,7 +23,7 @@ function fileExists(cwd: string, filename: string): { found: boolean; manifestFi return { found: existsSync(fullPath), manifestFile: filename }; } -function globExists(cwd: string, pattern: string): { found: boolean; manifestFile: string } { +export function globExists(cwd: string, pattern: string): { found: boolean; manifestFile: string } { // Simple glob for *.ext patterns in the root directory const ext = pattern.replace('*', ''); try { @@ -44,7 +44,7 @@ function detectPython(cwd: string): { found: boolean; manifestFile: string } { return { found: false, manifestFile: 'pyproject.toml' }; } -function detectKotlin(cwd: string): { found: boolean; manifestFile: string } { +export function detectKotlin(cwd: string): { found: boolean; manifestFile: string } { const ktsPath = join(cwd, 'build.gradle.kts'); if (existsSync(ktsPath)) { try { @@ -70,6 +70,19 @@ function detectKotlin(cwd: string): { found: boolean; manifestFile: string } { } } + // And pom.xml (Maven) — common for Kotlin/Spring Boot projects + const pomPath = join(cwd, 'pom.xml'); + if (existsSync(pomPath)) { + try { + const content = readFileSync(pomPath, 'utf-8'); + if (/kotlin/i.test(content)) { + return { found: true, manifestFile: 'pom.xml' }; + } + } catch { + // Can't read file + } + } + return { found: false, manifestFile: 'build.gradle.kts' }; } diff --git a/src/lib/port-detection.spec.ts b/src/lib/port-detection.spec.ts index f616d484..8d51816f 100644 --- a/src/lib/port-detection.spec.ts +++ b/src/lib/port-detection.spec.ts @@ -23,3 +23,19 @@ describe('port-detection — python/Django defaults', () => { expect(getCallbackPath('python')).toBe('/auth/callback/'); }); }); + +describe('port-detection — non-JS integration defaults', () => { + const dir = '/'; + + it.each([ + ['ruby', 3000], + ['php', 8000], + ['php-laravel', 8000], + ['go', 8080], + ['dotnet', 5000], + ['elixir', 4000], + ['kotlin', 8080], + ] as const)('%s defaults to port %i', (integration, expectedPort) => { + expect(detectPort(integration, dir)).toBe(expectedPort); + }); +}); diff --git a/src/lib/port-detection.ts b/src/lib/port-detection.ts index 6d0aba54..2ce131c5 100644 --- a/src/lib/port-detection.ts +++ b/src/lib/port-detection.ts @@ -12,6 +12,13 @@ const INTEGRATION_TO_SETTINGS_KEY: Record = { '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; diff --git a/src/lib/run-with-core.spec.ts b/src/lib/run-with-core.spec.ts index 07328aa1..984492ea 100644 --- a/src/lib/run-with-core.spec.ts +++ b/src/lib/run-with-core.spec.ts @@ -53,4 +53,25 @@ describe('detectSingleIntegration', () => { 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); + }); }); From 5963b2f6d1a29a7cf01cdd331a9456aed31b2ca2 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Apr 2026 18:16:15 -0700 Subject: [PATCH 3/4] feat: parse dev-server port from config for .NET, Phoenix, Spring Boot, Rails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the hardcoded ecosystem defaults. These four languages have a canonical config file where the dev server port lives; parse it so we don't misconfigure redirect URIs when the user has customized the port. - .NET: Properties/launchSettings.json → profiles[*].applicationUrl - Phoenix: config/dev.exs or config/runtime.exs → port: NNNN - Spring Boot: src/main/resources/application.{properties,yml} → server.port - Rails: config/puma.rb → port ENV.fetch("PORT") { N } or literal port N Falls back to the ecosystem default when the file is missing or malformed. Python (Django), Go, plain PHP, and Laravel are unchanged — none has a canonical config file; ports are CLI args or hardcoded in source. --- src/lib/port-detection.spec.ts | 122 ++++++++++++++++++++++++++++++++- src/lib/port-detection.ts | 113 ++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 3 deletions(-) diff --git a/src/lib/port-detection.spec.ts b/src/lib/port-detection.spec.ts index 8d51816f..35591576 100644 --- a/src/lib/port-detection.spec.ts +++ b/src/lib/port-detection.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm } from 'node:fs/promises'; +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'; @@ -25,7 +25,15 @@ describe('port-detection — python/Django defaults', () => { }); describe('port-detection — non-JS integration defaults', () => { - const dir = '/'; + 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], @@ -35,7 +43,115 @@ describe('port-detection — non-JS integration defaults', () => { ['dotnet', 5000], ['elixir', 4000], ['kotlin', 8080], - ] as const)('%s defaults to port %i', (integration, expectedPort) => { + ] 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 2ce131c5..f0a8cb74 100644 --- a/src/lib/port-detection.ts +++ b/src/lib/port-detection.ts @@ -96,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. @@ -127,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); From 5e578d5eb1e5ac75e5362cc2f6ceaa92a3bc5575 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Apr 2026 18:19:21 -0700 Subject: [PATCH 4/4] refactor: delete dead language-detection.ts, inline helpers into callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detectLanguage() and its LANGUAGE_DETECTORS table were never called from the install path. globExists() and detectKotlin() were the only pieces still used after wiring up the dotnet/kotlin detect() overrides — both are caller-specific enough to inline: - dotnet/index.ts gets hasCsproj() (6 lines, readdirSync + endsWith) - kotlin/index.ts gets isKotlinProject() (handles kts, gradle, pom.xml) - The Language type moves into framework-config.ts where it is consumed Net: one fewer module, no lost functionality. --- src/integrations/dotnet/index.ts | 12 ++- src/integrations/kotlin/index.ts | 26 +++++- src/lib/framework-config.ts | 6 +- src/lib/language-detection.ts | 138 ------------------------------- src/lib/registry.ts | 3 +- 5 files changed, 40 insertions(+), 145 deletions(-) delete mode 100644 src/lib/language-detection.ts diff --git a/src/integrations/dotnet/index.ts b/src/integrations/dotnet/index.ts index 46f6b33f..d107c662 100644 --- a/src/integrations/dotnet/index.ts +++ b/src/integrations/dotnet/index.ts @@ -1,8 +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'; -import { globExists } from '../../lib/language-detection.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'; @@ -24,7 +32,7 @@ export const config: FrameworkConfig = { packageManager: 'dotnet', manifestFile: '*.csproj', // existsSync cannot glob, so match any *.csproj in the install dir. - detect: (options) => globExists(options.installDir, '*.csproj').found, + detect: (options) => hasCsproj(options.installDir), }, detection: { diff --git a/src/integrations/kotlin/index.ts b/src/integrations/kotlin/index.ts index f7d13a7a..1e71aa4f 100644 --- a/src/integrations/kotlin/index.ts +++ b/src/integrations/kotlin/index.ts @@ -1,8 +1,30 @@ /* 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'; -import { detectKotlin } from '../../lib/language-detection.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: { @@ -16,7 +38,7 @@ export const config: FrameworkConfig = { packageManager: 'gradle', manifestFile: 'build.gradle.kts', // Also match Groovy DSL (build.gradle) and Maven (pom.xml) Kotlin projects. - detect: (options) => detectKotlin(options.installDir).found, + detect: (options) => isKotlinProject(options.installDir), }, detection: { diff --git a/src/lib/framework-config.ts b/src/lib/framework-config.ts index c2ca9a8c..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. diff --git a/src/lib/language-detection.ts b/src/lib/language-detection.ts deleted file mode 100644 index 201e1bd2..00000000 --- a/src/lib/language-detection.ts +++ /dev/null @@ -1,138 +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 }; -} - -export 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' }; -} - -export 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 - } - } - - // And pom.xml (Maven) — common for Kotlin/Spring Boot projects - const pomPath = join(cwd, 'pom.xml'); - if (existsSync(pomPath)) { - try { - const content = readFileSync(pomPath, 'utf-8'); - if (/kotlin/i.test(content)) { - return { found: true, manifestFile: 'pom.xml' }; - } - } 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/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'; /**