Skip to content

Commit 43bcd0a

Browse files
andersonlealAnderson Leal
andauthored
fix: improve Windows and WSL compatibility for create command (#1089)
Co-authored-by: Anderson Leal <anderson.leal@gmail.com>
1 parent cbd6395 commit 43bcd0a

File tree

12 files changed

+408
-60
lines changed

12 files changed

+408
-60
lines changed

packages/snap/src/__tests__/validate-python-environment.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,7 @@ describe('validatePythonEnvironment', () => {
111111
expect(result.success).toBe(false)
112112
expect(result.hasPythonFiles).toBe(true)
113113
expect(mockInternalLogger.error).toHaveBeenCalledWith('Python environment is incomplete')
114-
expect(mockInternalLogger.info).toHaveBeenCalledWith(
115-
'The python_modules directory exists but appears to be corrupted',
116-
)
114+
expect(mockInternalLogger.info).toHaveBeenCalledWith('The python_modules/lib directory was not found')
117115
expect(mockInternalLogger.info).toHaveBeenCalledWith('Run npm install to recreate your Python environment')
118116
})
119117

packages/snap/src/create/index.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { CliContext, Message } from '../cloud/config-utils'
66
import { generateTypes } from '../generate-types'
77
import { pythonInstall } from '../install'
88
import { pluginDependencies } from '../plugins/plugin-dependencies'
9+
import { getInstallCommands, getInstallSaveCommands } from '../utils/build-npm-command'
910
import { executeCommand } from '../utils/execute-command'
1011
import { getPackageManager, getPackageManagerFromEnv } from '../utils/get-package-manager'
1112
import { version } from '../version'
@@ -18,12 +19,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
1819
const installRequiredDependencies = async (packageManager: string, rootDir: string, context: CliContext) => {
1920
context.log('installing-dependencies', (message: Message) => message.tag('info').append('Installing dependencies...'))
2021

21-
const installCommand = {
22-
npm: 'npm install --save',
23-
yarn: 'yarn add',
24-
pnpm: 'pnpm add',
25-
bun: 'bun add',
26-
}[packageManager]
22+
const installCommand = getInstallSaveCommands(rootDir)[packageManager]
2723

2824
const dependencies = [
2925
`motia@${version}`,
@@ -269,15 +265,13 @@ export const create = async ({
269265
message.tag('info').append('Installing plugin dependencies...'),
270266
)
271267

272-
const installCommand = {
273-
npm: 'npm install',
274-
yarn: 'yarn',
275-
pnpm: 'pnpm install',
276-
bun: 'bun install',
277-
}[packageManager]
268+
const installCommands: Record<string, string> = {
269+
...getInstallCommands(rootDir),
270+
}
271+
const installCommand = installCommands[packageManager] || installCommands['npm']
278272

279273
try {
280-
await executeCommand(installCommand!, rootDir)
274+
await executeCommand(installCommand, rootDir)
281275
context.log('plugin-dependencies-installed', (message: Message) =>
282276
message.tag('success').append('Plugin dependencies installed'),
283277
)

packages/snap/src/create/pull-rules.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'fs'
22
import path from 'path'
33
import { fileURLToPath } from 'url'
44
import type { CliContext, Message } from '../cloud/config-utils'
5+
import { copyWithWSLCompat } from './utils'
56

67
type PullRulesArgs = {
78
rootDir: string
@@ -19,14 +20,22 @@ export const pullRules = async (args: PullRulesArgs, context: CliContext) => {
1920
const type = isFolder ? 'Folder' : 'File'
2021

2122
if (args.force || !fs.existsSync(targetFile)) {
22-
fs.cpSync(path.join(cursorTemplateDir, file), targetFile, {
23-
recursive: isFolder,
24-
force: true,
25-
})
26-
27-
context.log(`${file}-created`, (message: Message) =>
28-
message.tag('success').append(type).append(file, 'cyan').append('has been created.'),
29-
)
23+
try {
24+
copyWithWSLCompat(path.join(cursorTemplateDir, file), targetFile, isFolder)
25+
context.log(`${file}-created`, (message: Message) =>
26+
message.tag('success').append(type).append(file, 'cyan').append('has been created.'),
27+
)
28+
} catch (error: any) {
29+
context.log(`${file}-error`, (message: Message) =>
30+
message
31+
.tag('error')
32+
.append('Failed to create')
33+
.append(type, 'yellow')
34+
.append(file, 'cyan')
35+
.append(`: ${error.message}`),
36+
)
37+
throw error
38+
}
3039
} else {
3140
context.log(`${file}-skipped`, (message: Message) =>
3241
message.tag('warning').append(type).append(file, 'cyan').append('already exists, skipping...'),

packages/snap/src/create/utils.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,68 @@ export const checkIfDirectoryExists = (dir: string): boolean => {
1212
return false
1313
}
1414
}
15+
16+
/**
17+
* Recursively copies a directory using read/write operations.
18+
* This is a fallback method for WSL/Windows compatibility.
19+
*/
20+
function copyDirectoryRecursive(src: string, dest: string): void {
21+
fs.mkdirSync(dest, { recursive: true })
22+
const entries = fs.readdirSync(src, { withFileTypes: true })
23+
24+
for (const entry of entries) {
25+
const srcPath = path.join(src, entry.name)
26+
const destPath = path.join(dest, entry.name)
27+
28+
if (entry.isDirectory()) {
29+
copyDirectoryRecursive(srcPath, destPath)
30+
} else {
31+
// For files, read and write manually
32+
const content = fs.readFileSync(srcPath)
33+
fs.writeFileSync(destPath, content)
34+
}
35+
}
36+
}
37+
38+
/**
39+
* Copies a file or directory with WSL/Windows compatibility.
40+
* Attempts to use fs.cpSync first for better performance, but falls back
41+
* to manual read/write operations if permission errors occur (common in WSL).
42+
*
43+
* @param src - Source file or directory path
44+
* @param dest - Destination file or directory path
45+
* @param isDirectory - Whether the source is a directory
46+
* @throws Error if copy fails after all fallback attempts
47+
*/
48+
export function copyWithWSLCompat(src: string, dest: string, isDirectory: boolean): void {
49+
// Ensure the destination *parent* directory exists.
50+
// - For directory copies, fs.cpSync/copyDirectoryRecursive will create `dest` as needed.
51+
// - For file copies, we only need the parent directory.
52+
fs.mkdirSync(path.dirname(dest), { recursive: true })
53+
54+
try {
55+
// Try the standard cpSync first (faster and works in most cases)
56+
fs.cpSync(src, dest, {
57+
recursive: isDirectory,
58+
force: true,
59+
})
60+
} catch (error: unknown) {
61+
// If we get permission errors (common in WSL when writing to Windows paths),
62+
// fall back to manual copy using read/write operations
63+
const code =
64+
typeof error === 'object' && error !== null && 'code' in error ? (error as NodeJS.ErrnoException).code : undefined
65+
66+
if (code === 'EPERM' || code === 'EACCES') {
67+
if (isDirectory) {
68+
copyDirectoryRecursive(src, dest)
69+
} else {
70+
// For files, read and write manually
71+
const content = fs.readFileSync(src)
72+
fs.writeFileSync(dest, content)
73+
}
74+
} else {
75+
// Re-throw other errors
76+
throw error
77+
}
78+
}
79+
}

packages/snap/src/plugins/install-plugin-dependencies.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from 'node:fs'
22
import path from 'node:path'
33
import type { Printer } from '@motiadev/core'
4+
import { getInstallCommands } from '../utils/build-npm-command'
45
import { executeCommand } from '../utils/execute-command'
56
import { getPackageManager } from '../utils/get-package-manager'
67
import { version } from '../version'
@@ -50,13 +51,8 @@ export const installPluginDependencies = async (baseDir: string, printer: Printe
5051
}
5152
printer.printPluginLog(`Installing dependencies using ${packageManager}...`)
5253

53-
const installCommands: Record<string, string> = {
54-
npm: 'npm install',
55-
yarn: 'yarn install',
56-
pnpm: 'pnpm install',
57-
}
58-
59-
const installCommand = installCommands[packageManager] || 'npm install'
54+
const installCommands = getInstallCommands(baseDir)
55+
const installCommand = installCommands[packageManager] || installCommands['npm']
6056

6157
try {
6258
await executeCommand(installCommand, baseDir, { silent: false })

packages/snap/src/redis/memory-manager.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { mkdirSync } from 'fs'
22
import type { RedisClientType } from 'redis'
33
import { RedisMemoryServer } from 'redis-memory-server'
44
import type { RedisMemoryInstancePropT } from 'redis-memory-server/lib/types'
5+
import { ensureBuildTools } from '../utils/check-build-tools'
6+
import { internalLogger } from '../utils/internal-logger'
57
import type { RedisConnectionInfo } from './types'
68

79
export class RedisMemoryManager {
@@ -25,6 +27,9 @@ export class RedisMemoryManager {
2527
async startServer(baseDir: string): Promise<RedisConnectionInfo> {
2628
if (!this.server) {
2729
try {
30+
// Check for required build tools before attempting to start
31+
await ensureBuildTools()
32+
2833
mkdirSync(baseDir, { recursive: true })
2934

3035
const instance: RedisMemoryInstancePropT = {
@@ -36,27 +41,61 @@ export class RedisMemoryManager {
3641
}
3742

3843
this.server = new RedisMemoryServer({ instance })
39-
console.log('Redis Memory Server started')
40-
this.running = true
4144
this.registerCleanupHandlers()
4245
} catch (error) {
43-
console.error('[Redis Memory Server] Failed to start:', error)
46+
internalLogger.error('Failed to initialize Redis Memory Server')
47+
if (error instanceof Error) {
48+
console.error(error.message)
49+
if (process.env.LOG_LEVEL === 'debug' && error.stack) {
50+
console.error('\nStack trace:')
51+
console.error(error.stack)
52+
}
53+
}
4454
throw error
4555
}
4656
}
4757

48-
const host = await this.server.getHost()
49-
const port = await this.server.getPort()
58+
try {
59+
const host = await this.server.getHost()
60+
const port = await this.server.getPort()
61+
62+
this.running = true
63+
internalLogger.info('Redis Memory Server started', `${host}:${port}`)
64+
65+
return { host, port }
66+
} catch (error) {
67+
internalLogger.error('Failed to start Redis Memory Server')
68+
69+
if (error instanceof Error) {
70+
console.error(error.message)
71+
72+
// Provide helpful suggestions based on common error patterns
73+
if (error.message.includes('make') || error.message.includes('compile')) {
74+
console.error('\nThis error typically occurs when build tools are missing.')
75+
console.error('Please ensure you have "make" and a C compiler installed.')
76+
}
77+
78+
if (process.env.LOG_LEVEL === 'debug' && error.stack) {
79+
console.error('\nStack trace:')
80+
console.error(error.stack)
81+
}
82+
}
83+
84+
console.error('\nAlternative: Use an external Redis server')
85+
console.error(' Set MOTIA_DISABLE_MEMORY_SERVER=true')
86+
console.error(' Set MOTIA_REDIS_HOST=<your-redis-host>')
87+
console.error(' Set MOTIA_REDIS_PORT=<your-redis-port> (default: 6379)')
5088

51-
return { host, port }
89+
throw error
90+
}
5291
}
5392

5493
async stop(): Promise<void> {
5594
if (this.server && this.running) {
5695
try {
5796
await this.server.stop()
5897
} catch (error: unknown) {
59-
console.error('[Redis Memory Server] Error stopping:', (error as Error)?.message)
98+
internalLogger.error('Error stopping Redis Memory Server', (error as Error)?.message)
6099
} finally {
61100
this.running = false
62101
this.server = null

packages/snap/src/utils/activate-python-env.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ interface VenvConfig {
1111

1212
export const getSitePackagesPath = ({ baseDir, pythonVersion = '3.13' }: VenvConfig): string => {
1313
const venvPath = path.join(baseDir, 'python_modules')
14+
15+
// Windows virtualenv layout uses "Lib/site-packages"
16+
if (process.platform === 'win32') {
17+
return path.join(venvPath, 'Lib', 'site-packages')
18+
}
19+
20+
// Unix-like layout uses "lib/python3.x/site-packages"
1421
const libPath = path.join(venvPath, 'lib')
1522
const actualPythonVersionPath = findPythonSitePackagesDir(libPath, pythonVersion)
1623
return path.join(venvPath, 'lib', actualPythonVersionPath, 'site-packages')
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { shouldUseNoBinLinks } from './detect-wsl'
2+
3+
/**
4+
* Build an npm install command with appropriate flags for the environment.
5+
* Adds --no-bin-links when running in WSL2 on Windows filesystem to prevent
6+
* EPERM errors during npm install.
7+
*
8+
* @param baseCommand - The base npm command (e.g., 'npm install --save' or 'npm install')
9+
* @param targetPath - The path where the command will be executed
10+
* @returns The command with appropriate flags added
11+
*/
12+
export function buildNpmCommand(baseCommand: string, targetPath: string): string {
13+
// Only modify npm commands
14+
if (!baseCommand.startsWith('npm ')) {
15+
return baseCommand
16+
}
17+
18+
// Add --no-bin-links if needed for WSL2 on Windows filesystem
19+
if (shouldUseNoBinLinks(targetPath)) {
20+
// Insert --no-bin-links after 'npm install' but before any other flags
21+
// Handle both 'npm install' and 'npm install --save' patterns
22+
if (baseCommand.includes('npm install')) {
23+
return baseCommand.replace('npm install', 'npm install --no-bin-links')
24+
}
25+
}
26+
27+
return baseCommand
28+
}
29+
30+
/**
31+
* Get install commands for all package managers, with WSL2 compatibility for npm.
32+
*
33+
* @param targetPath - The path where the command will be executed
34+
* @returns Record of package manager to install command
35+
*/
36+
export function getInstallCommands(targetPath: string): Record<string, string> {
37+
return {
38+
npm: buildNpmCommand('npm install', targetPath),
39+
yarn: 'yarn install',
40+
pnpm: 'pnpm install',
41+
bun: 'bun install',
42+
}
43+
}
44+
45+
/**
46+
* Get install commands with --save flag for all package managers, with WSL2 compatibility for npm.
47+
*
48+
* @param targetPath - The path where the command will be executed
49+
* @returns Record of package manager to install command
50+
*/
51+
export function getInstallSaveCommands(targetPath: string): Record<string, string> {
52+
return {
53+
npm: buildNpmCommand('npm install --save', targetPath),
54+
yarn: 'yarn add',
55+
pnpm: 'pnpm add',
56+
bun: 'bun add',
57+
}
58+
}

0 commit comments

Comments
 (0)