diff --git a/package.json b/package.json index 7e454d74..ea8d8a69 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@hono/node-server": "^1", "@napi-rs/keyring": "^1.2.0", "@workos-inc/node": "^8.7.0", + "@workos/migrations": "^2.0.0", "@workos/openapi-spec": "^0.1.0", "@workos/skills": "0.6.0", "chalk": "^5.6.2", @@ -59,11 +60,10 @@ "fast-glob": "^3.3.3", "hono": "^4", "ink": "^6.8.0", - "opn": "^5.4.0", + "open": "^11.0.0", "react": "^19.2.4", "semver": "^7.7.4", "uuid": "^13.0.0", - "@workos/migrations": "^2.0.0", "xstate": "^5.28.0", "yaml": "^2.8.2", "yargs": "^18.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a22a39b..605af021 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,9 +53,9 @@ importers: ink: specifier: ^6.8.0 version: 6.8.0(@types/react@19.2.14)(react@19.2.4) - opn: - specifier: ^5.4.0 - version: 5.5.0 + open: + specifier: ^11.0.0 + version: 11.0.0 react: specifier: ^19.2.4 version: 19.2.4 @@ -1406,6 +1406,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1478,6 +1482,18 @@ packages: csv-stringify@6.7.0: resolution: {integrity: sha512-UdtziYp5HuTz7e5j8Nvq+a/3HQo+2/aJZ9xntNTpmRRIg/3YYqDVgiS9fvAhtNbnyfbv2ZBe0bqCHqzhE7FqWQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -1635,6 +1651,11 @@ packages: iron-webcrypto@2.0.0: resolution: {integrity: sha512-rtffZKDUHciZElM8mjFCufBC7nVhCxHYyWHESqs89OioEDz4parOofd8/uhrejh/INhQFfYQfByS22LlezR9sQ==} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1660,6 +1681,15 @@ packages: engines: {node: '>=20'} hasBin: true + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-node-process@1.2.0: resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} @@ -1671,9 +1701,9 @@ packages: resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} engines: {node: '>=12.13'} - is-wsl@1.1.0: - resolution: {integrity: sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==} - engines: {node: '>=4'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} isomorphic-ws@5.0.0: resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} @@ -1758,9 +1788,9 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - opn@5.5.0: - resolution: {integrity: sha512-PqHpggC9bLV0VeWcdKhkpxY+3JTzetLSqTCWL/z/tFIbI6G8JCjondXklT1JinczLz2Xib62sSp0T/gKT4KksA==} - engines: {node: '>=4'} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -1816,6 +1846,10 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -1866,6 +1900,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2154,6 +2192,10 @@ packages: utf-8-validate: optional: true + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + xml-naming@0.1.0: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} @@ -3459,6 +3501,10 @@ snapshots: dependencies: fill-range: 7.1.1 + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + chai@6.2.2: {} chalk@5.6.2: {} @@ -3523,6 +3569,15 @@ snapshots: csv-stringify@6.7.0: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + diff@8.0.3: {} dotenv@17.3.1: {} @@ -3697,6 +3752,8 @@ snapshots: dependencies: uint8array-extras: 1.5.0 + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -3715,6 +3772,12 @@ snapshots: is-in-ci@2.0.0: {} + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-node-process@1.2.0: optional: true @@ -3722,7 +3785,9 @@ snapshots: is-what@4.1.16: {} - is-wsl@1.1.0: {} + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 isomorphic-ws@5.0.0(ws@8.19.0): dependencies: @@ -3814,9 +3879,14 @@ snapshots: dependencies: mimic-fn: 2.1.0 - opn@5.5.0: + open@11.0.0: dependencies: - is-wsl: 1.1.0 + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 outvariant@1.4.3: optional: true @@ -3896,6 +3966,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + powershell-utils@0.1.0: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -3967,6 +4039,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.55.1 fsevents: 2.3.3 + run-applescript@7.1.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -4209,6 +4283,11 @@ snapshots: ws@8.19.0: {} + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + xml-naming@0.1.0: {} xstate@5.28.0: {} diff --git a/src/bin.ts b/src/bin.ts index 8943f2f1..a8204a9f 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -4,7 +4,8 @@ if (process.argv.includes('--local') || process.env.INSTALLER_DEV) { const { config } = await import('dotenv'); // bin.ts compiles to dist/bin.js, so go up one level to find .env.local - config({ path: new URL('../.env.local', import.meta.url).pathname }); + const { fileURLToPath } = await import('node:url'); + config({ path: fileURLToPath(new URL('../.env.local', import.meta.url)) }); } import { satisfies } from 'semver'; diff --git a/src/commands/claim.spec.ts b/src/commands/claim.spec.ts index 58605d02..7c3ab64c 100644 --- a/src/commands/claim.spec.ts +++ b/src/commands/claim.spec.ts @@ -6,9 +6,8 @@ vi.mock('../utils/debug.js', () => ({ logError: vi.fn(), })); -// Mock opn (browser open) const mockOpen = vi.fn().mockResolvedValue(undefined); -vi.mock('opn', () => ({ default: mockOpen })); +vi.mock('open', () => ({ default: mockOpen })); // Mock clack const mockSpinner = { diff --git a/src/commands/claim.ts b/src/commands/claim.ts index 871ff484..bff7da13 100644 --- a/src/commands/claim.ts +++ b/src/commands/claim.ts @@ -6,7 +6,7 @@ * until the environment is claimed. */ -import open from 'opn'; +import open from 'open'; import clack from '../utils/clack.js'; import { getActiveEnvironment, isUnclaimedEnvironment, markEnvironmentClaimed } from '../lib/config-store.js'; import { createClaimNonce, UnclaimedEnvApiError } from '../lib/unclaimed-env-api.js'; diff --git a/src/commands/dev.ts b/src/commands/dev.ts index cfc8c6ab..4bb55444 100644 --- a/src/commands/dev.ts +++ b/src/commands/dev.ts @@ -5,6 +5,7 @@ import { readFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { parse as parseYaml } from 'yaml'; import chalk from 'chalk'; +import { IS_WINDOWS, SPAWN_OPTS } from '../utils/platform.js'; export interface DevArgs { port: number; @@ -122,6 +123,7 @@ export async function runDev(argv: DevArgs): Promise { ...process.env, ...buildDevEnv(emulator.url, emulator.apiKey), }, + ...SPAWN_OPTS, }); } catch { console.error(chalk.red(`Failed to start: ${devCmd.command} ${devCmd.args.join(' ')}`)); @@ -149,6 +151,9 @@ export async function runDev(argv: DevArgs): Promise { }; process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); + if (IS_WINDOWS) { + process.on('SIGBREAK', () => shutdown('SIGINT')); + } // 6. If child exits, close emulator and exit with same code child.on('exit', (code) => { diff --git a/src/commands/emulate.ts b/src/commands/emulate.ts index d257e808..0e8a87ad 100644 --- a/src/commands/emulate.ts +++ b/src/commands/emulate.ts @@ -3,6 +3,7 @@ import { readFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { parse as parseYaml } from 'yaml'; import chalk from 'chalk'; +import { IS_WINDOWS } from '../utils/platform.js'; export interface EmulateArgs { port: number; @@ -75,4 +76,7 @@ export async function runEmulate(argv: EmulateArgs): Promise { }; process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown); + if (IS_WINDOWS) { + process.once('SIGBREAK', shutdown); + } } diff --git a/src/commands/install-skill.ts b/src/commands/install-skill.ts index 6cecc35b..0af29c22 100644 --- a/src/commands/install-skill.ts +++ b/src/commands/install-skill.ts @@ -4,6 +4,7 @@ import { existsSync } from 'fs'; import { mkdir, mkdtemp, cp, rename, rm, readdir, readFile, stat, access, writeFile } from 'fs/promises'; import chalk from 'chalk'; import { getSkillsDir as getSkillsPackageDir } from '@workos/skills'; +import { IS_WINDOWS } from '../utils/platform.js'; export const SKILL_VERSION_MARKER_FILENAME = '.workos-skill-version'; @@ -48,30 +49,31 @@ export interface AgentConfig { } export function createAgents(home: string): Record { + const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming'); return { 'claude-code': { name: 'claude-code', displayName: 'Claude Code', - globalSkillsDir: join(home, '.claude/skills'), + globalSkillsDir: join(home, '.claude', 'skills'), detect: () => existsSync(join(home, '.claude')), }, codex: { name: 'codex', displayName: 'Codex', - globalSkillsDir: join(home, '.codex/skills'), + globalSkillsDir: join(home, '.codex', 'skills'), detect: () => existsSync(join(home, '.codex')), }, cursor: { name: 'cursor', displayName: 'Cursor', - globalSkillsDir: join(home, '.cursor/skills'), + globalSkillsDir: join(home, '.cursor', 'skills'), detect: () => existsSync(join(home, '.cursor')), }, goose: { name: 'goose', displayName: 'Goose', - globalSkillsDir: join(home, '.config/goose/skills'), - detect: () => existsSync(join(home, '.config/goose')), + globalSkillsDir: IS_WINDOWS ? join(appData, 'goose', 'skills') : join(home, '.config', 'goose', 'skills'), + detect: () => (IS_WINDOWS ? existsSync(join(appData, 'goose')) : existsSync(join(home, '.config', 'goose'))), }, }; } diff --git a/src/commands/login.spec.ts b/src/commands/login.spec.ts index dd877bad..ef6fb9bd 100644 --- a/src/commands/login.spec.ts +++ b/src/commands/login.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; const mockOpen = vi.fn(); -vi.mock('opn', () => ({ default: (...args: unknown[]) => mockOpen(...args) })); +vi.mock('open', () => ({ default: (...args: unknown[]) => mockOpen(...args) })); class MockDeviceAuthTimeoutError extends Error {} const mockRequestDeviceCode = vi.fn(); diff --git a/src/commands/login.ts b/src/commands/login.ts index 517268a2..3925f966 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,4 +1,4 @@ -import open from 'opn'; +import open from 'open'; import chalk from 'chalk'; import clack from '../utils/clack.js'; import { saveCredentials, getCredentials, getAccessToken, isTokenExpired, updateTokens } from '../lib/credentials.js'; diff --git a/src/doctor/checks/auth-patterns.ts b/src/doctor/checks/auth-patterns.ts index 018a704a..dfa19649 100644 --- a/src/doctor/checks/auth-patterns.ts +++ b/src/doctor/checks/auth-patterns.ts @@ -70,7 +70,7 @@ function findFilesShallow(dir: string, namePattern: RegExp, maxDepth = 3): strin function parseEnvFile(content: string): Record { const result: Record = {}; - for (const line of content.split('\n')) { + for (const line of content.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const entry = trimmed.startsWith('export ') ? trimmed.slice(7) : trimmed; @@ -525,7 +525,7 @@ function checkEnvFileNotGitignored(ctx: CheckContext): AuthPatternFinding[] { for (const envFile of envFiles) { const isIgnored = gitignore !== null && - gitignore.split('\n').some((line) => { + gitignore.split(/\r?\n/).some((line) => { const trimmed = line.trim(); if (trimmed.startsWith('#') || trimmed === '') return false; return trimmed === envFile || trimmed === '.env*' || trimmed === '.env.*' || trimmed === '.env'; diff --git a/src/doctor/checks/environment.ts b/src/doctor/checks/environment.ts index 88e9b779..b2556127 100644 --- a/src/doctor/checks/environment.ts +++ b/src/doctor/checks/environment.ts @@ -5,7 +5,7 @@ import type { EnvironmentCheckResult, DoctorOptions } from '../types.js'; function parseEnvFile(content: string): Record { const result: Record = {}; - for (const line of content.split('\n')) { + for (const line of content.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; diff --git a/src/lib/credential-discovery.ts b/src/lib/credential-discovery.ts index a0d03b1b..07b72e0d 100644 --- a/src/lib/credential-discovery.ts +++ b/src/lib/credential-discovery.ts @@ -54,7 +54,7 @@ export async function scanEnvFile(filePath: string): Promise<{ clientId?: string const content = await fs.readFile(filePath, 'utf-8'); // Filter out commented lines before matching - const lines = content.split('\n'); + const lines = content.split(/\r?\n/); const uncommentedContent = lines.filter((line) => !line.trim().startsWith('#')).join('\n'); const clientIdMatch = uncommentedContent.match(WORKOS_CLIENT_ID_PATTERN); diff --git a/src/lib/dev-command.ts b/src/lib/dev-command.ts index 60158aae..0afe0778 100644 --- a/src/lib/dev-command.ts +++ b/src/lib/dev-command.ts @@ -1,5 +1,6 @@ import { readFileSync, existsSync } from 'node:fs'; import { resolve, join } from 'node:path'; +import { IS_WINDOWS } from '../utils/platform.js'; export interface DevCommandResult { command: string; @@ -67,7 +68,12 @@ function hasDependency(pkg: PackageJson, dep: string): boolean { * otherwise returns the bare command name (assumes it's globally available). */ function resolveNodeBin(projectDir: string, command: string): string { - const binPath = join(projectDir, 'node_modules', '.bin', command); + const binDir = join(projectDir, 'node_modules', '.bin'); + if (IS_WINDOWS) { + const cmdPath = join(binDir, `${command}.cmd`); + if (existsSync(cmdPath)) return cmdPath; + } + const binPath = join(binDir, command); if (existsSync(binPath)) return binPath; return command; } diff --git a/src/lib/registry.ts b/src/lib/registry.ts index f2a9ebbf..69900e35 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -1,6 +1,6 @@ import { readdirSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import type { FrameworkConfig, Language } from './framework-config.js'; import type { InstallerOptions } from '../utils/types.js'; @@ -72,7 +72,7 @@ export async function buildRegistry(): Promise { } try { - const mod = (await import(join(integrationsDir, dir, 'index.js'))) as IntegrationModule; + const mod = (await import(pathToFileURL(join(integrationsDir, dir, 'index.js')).href)) as IntegrationModule; if (!mod.config || !mod.run) { console.warn(`Integration ${dir} missing 'config' or 'run' export, skipping`); diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 69971fad..aa2aae2f 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -1,5 +1,5 @@ import { createActor, fromPromise } from 'xstate'; -import open from 'opn'; +import open from 'open'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { installerMachine } from './installer-core.js'; @@ -377,7 +377,7 @@ export async function runWithCore(options: InstallerOptions): Promise { // Open browser try { - const { default: openFn } = await import('opn'); + const { default: openFn } = await import('open'); await openFn(deviceAuth.verification_uri_complete, { wait: false }); } catch (error) { observeHostFailure('browser-launch', error, { diff --git a/src/lib/validation/build-validator.ts b/src/lib/validation/build-validator.ts index f3f51c8e..af948a0a 100644 --- a/src/lib/validation/build-validator.ts +++ b/src/lib/validation/build-validator.ts @@ -3,6 +3,7 @@ import { existsSync, readdirSync } from 'fs'; import { readFile } from 'fs/promises'; import { join } from 'path'; import type { ValidationIssue } from './types.js'; +import { IS_WINDOWS, SPAWN_OPTS } from '../../utils/platform.js'; export interface BuildResult { success: boolean; @@ -32,6 +33,7 @@ export async function runBuildValidation(projectDir: string, timeoutMs: number = const proc = spawn(pm, args, { cwd: projectDir, timeout: timeoutMs, + ...SPAWN_OPTS, }); let stdout = ''; @@ -148,7 +150,10 @@ export async function detectBuildCommand(projectDir: string): Promise => { const map = new Map(); - for (const line of content.split('\n')) { + for (const line of content.split(/\r?\n/)) { const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); if (match) { map.set(match[1], match[2].trim()); diff --git a/src/steps/run-prettier.ts b/src/steps/run-prettier.ts index ceccbd29..961bdd0f 100644 --- a/src/steps/run-prettier.ts +++ b/src/steps/run-prettier.ts @@ -5,7 +5,8 @@ import clack from '../utils/clack.js'; import { getPackageDotJson, getUncommittedOrUntrackedFiles, isInGitRepo } from '../utils/clack-utils.js'; import { hasPackageInstalled } from '../utils/package-json.js'; import type { InstallerOptions } from '../utils/types.js'; -import * as childProcess from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { SPAWN_OPTS } from '../utils/platform.js'; export async function runPrettierStep({ installDir, @@ -20,14 +21,11 @@ export async function runPrettierStep({ return; } - const changedOrUntrackedFiles = getUncommittedOrUntrackedFiles() - .map((filename) => { - return filename.startsWith('- ') ? filename.slice(2) : filename; - }) - .join(' '); + const changedOrUntrackedFiles = getUncommittedOrUntrackedFiles().map((filename) => { + return filename.startsWith('- ') ? filename.slice(2) : filename; + }); if (!changedOrUntrackedFiles.length) { - // Likewise, if we can't find changed or untracked files, there's no point in running Prettier. return; } @@ -45,13 +43,12 @@ export async function runPrettierStep({ try { await new Promise((resolve, reject) => { - childProcess.exec(`npx prettier --ignore-unknown --write ${changedOrUntrackedFiles}`, (err) => { - if (err) { - reject(err); - } else { - resolve(); - } + const proc = spawn('npx', ['prettier', '--ignore-unknown', '--write', ...changedOrUntrackedFiles], { + stdio: 'ignore', + ...SPAWN_OPTS, }); + proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`prettier exited ${code}`)))); + proc.on('error', reject); }); } catch { prettierSpinner.stop('Prettier failed to run. You may want to format the changes manually.'); diff --git a/src/steps/upload-environment-variables/providers/vercel.ts b/src/steps/upload-environment-variables/providers/vercel.ts index b5d59c69..aeb814da 100644 --- a/src/steps/upload-environment-variables/providers/vercel.ts +++ b/src/steps/upload-environment-variables/providers/vercel.ts @@ -6,6 +6,7 @@ import type { InstallerOptions } from '../../../utils/types.js'; import clack from '../../../utils/clack.js'; import chalk from 'chalk'; import { analytics } from '../../../utils/analytics.js'; +import { SPAWN_OPTS } from '../../../utils/platform.js'; export class VercelEnvironmentProvider extends EnvironmentProvider { name = 'Vercel'; @@ -51,12 +52,13 @@ export class VercelEnvironmentProvider extends EnvironmentProvider { isAuthenticated(): boolean { const result = spawnSync('vercel', ['whoami'], { encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], // suppress prompts + stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, - FORCE_COLOR: '0', // avoid ANSI formatting - CI: '1', // hint to CLI that it's a non-interactive env + FORCE_COLOR: '0', + CI: '1', }, + ...SPAWN_OPTS, }); const output = (String(result.stdout) + String(result.stderr)).toLowerCase(); @@ -75,6 +77,7 @@ export class VercelEnvironmentProvider extends EnvironmentProvider { await new Promise((resolve, reject) => { const proc = spawn('vercel', ['env', 'add', key, environment], { stdio: ['pipe', 'pipe', 'pipe'], + ...SPAWN_OPTS, }); let stderr = ''; diff --git a/src/utils/clack-utils.ts b/src/utils/clack-utils.ts index ed256527..aca23bf4 100644 --- a/src/utils/clack-utils.ts +++ b/src/utils/clack-utils.ts @@ -16,6 +16,7 @@ import { ISSUES_URL, type Integration } from '../lib/constants.js'; import { analytics } from './analytics.js'; import clack from './clack.js'; import { INTEGRATION_CONFIG } from '../lib/config.js'; +import { SPAWN_OPTS } from './platform.js'; /** * Redact sensitive info (API keys, client secrets) from a string. @@ -318,29 +319,39 @@ export async function installPackage({ try { await new Promise((resolve, reject) => { - childProcess.exec( - `${pkgManager.installCommand} ${packageName} ${pkgManager.flags} ${ - forceInstall ? pkgManager.forceInstallFlag : '' - } ${legacyPeerDepsFlag}`.trim(), - { cwd: installDir }, - (err, stdout, stderr) => { - if (err) { - // Write a log file so we can better troubleshoot issues - fs.writeFileSync( - join(process.cwd(), `workos-installation-error-${Date.now()}.log`), - JSON.stringify({ - stdout: redactSensitiveInfo(stdout), - stderr: redactSensitiveInfo(stderr), - }), - { encoding: 'utf8' }, - ); - - reject(err); - } else { - resolve(); - } - }, - ); + const [cmd, ...baseArgs] = pkgManager.installCommand.split(' '); + const args = [ + ...baseArgs, + packageName, + ...pkgManager.flags.split(/\s+/).filter(Boolean), + ...(forceInstall && pkgManager.forceInstallFlag ? pkgManager.forceInstallFlag.split(/\s+/) : []), + ...(legacyPeerDepsFlag ? [legacyPeerDepsFlag] : []), + ]; + const proc = childProcess.spawn(cmd, args, { cwd: installDir, ...SPAWN_OPTS }); + let stdout = ''; + let stderr = ''; + proc.stdout?.on('data', (d: Buffer) => { + stdout += d.toString(); + }); + proc.stderr?.on('data', (d: Buffer) => { + stderr += d.toString(); + }); + proc.on('close', (code) => { + if (code !== 0) { + fs.writeFileSync( + join(process.cwd(), `workos-installation-error-${Date.now()}.log`), + JSON.stringify({ + stdout: redactSensitiveInfo(stdout), + stderr: redactSensitiveInfo(stderr), + }), + { encoding: 'utf8' }, + ); + reject(new Error(`${cmd} exited with code ${code}\n${stderr}`)); + } else { + resolve(); + } + }); + proc.on('error', reject); }); } catch (e) { sdkInstallSpinner.stop('Installation failed.'); diff --git a/src/utils/env-parser.ts b/src/utils/env-parser.ts index c12e8abf..68bfd5f6 100644 --- a/src/utils/env-parser.ts +++ b/src/utils/env-parser.ts @@ -4,7 +4,7 @@ */ export function parseEnvFile(content: string): Record { const result: Record = {}; - for (const line of content.split('\n')) { + for (const line of content.split(/\r?\n/)) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { const [key, ...valueParts] = trimmed.split('='); diff --git a/src/utils/exec-file.ts b/src/utils/exec-file.ts index 38d5e466..b4d16fb6 100644 --- a/src/utils/exec-file.ts +++ b/src/utils/exec-file.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import { SPAWN_OPTS } from './platform.js'; export interface ExecResult { status: number; @@ -22,7 +23,7 @@ export function execFileNoThrow(command: string, args: string[], options: ExecOp cwd: options.cwd, env: options.env ?? process.env, timeout: options.timeout, - shell: false, + ...SPAWN_OPTS, }); let stdout = ''; diff --git a/src/utils/platform.ts b/src/utils/platform.ts new file mode 100644 index 00000000..30cf8f90 --- /dev/null +++ b/src/utils/platform.ts @@ -0,0 +1,7 @@ +export const IS_WINDOWS = process.platform === 'win32'; + +/** + * Options for cross-platform spawn calls. + * On Windows, .cmd/.bat shims require shell: true to resolve. + */ +export const SPAWN_OPTS = { shell: IS_WINDOWS } as const;