diff --git a/src/commands/create.ts b/src/commands/create.ts index f551d4dab..5e9002d0c 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -239,6 +239,28 @@ export class CreateCommand extends ApifyCommand { // For efficiency, don't install Puppeteer for templates that don't use it const cmdArgs = ['install']; + // Force devDependencies to be installed even if the caller's + // NODE_ENV is "production" or they've configured npm to omit dev deps. + // Scaffolded projects rely on devDependencies (tsx, typescript, etc.) + // to be runnable via `apify run` immediately after `apify create`. + switch (runtime.pmName) { + case 'npm': { + if (gte(runtime.pmVersion!, '7.0.0')) { + cmdArgs.push('--include=dev'); + } else { + cmdArgs.push('--no-production'); + } + break; + } + case 'bun': { + // bun install skips devDependencies only when --production is passed; + // nothing to force by default, but we still override NODE_ENV below. + break; + } + default: + // pnpm / yarn / deno respect NODE_ENV; overriding it below is enough. + } + if (skipOptionalDeps) { switch (runtime.pmName) { case 'npm': { @@ -263,10 +285,17 @@ export class CreateCommand extends ApifyCommand { } } + // Override NODE_ENV so package managers that key devDependency + // installation off it (pnpm, yarn, bun, npm) still install + // devDependencies even when the parent shell has + // NODE_ENV=production set (common in Dockerfiles, CI, and shells + // that persist env from previous `apify push` invocations). + const installEnv = { ...process.env, NODE_ENV: 'development' }; + await execWithLog({ cmd: runtime.pmPath!, args: cmdArgs, - opts: { cwd: actFolderDir }, + opts: { cwd: actFolderDir, env: installEnv }, overrideCommand: runtime.pmName, }); @@ -343,11 +372,22 @@ export class CreateCommand extends ApifyCommand { if (!skipGitInit && !cwdHasGit) { try { - await execWithLog({ - cmd: 'git', - args: ['init'], - opts: { cwd: actFolderDir }, - }); + // Use -b main so newly scaffolded projects start on `main` regardless + // of the host's `init.defaultBranch` git config. Fall back to plain + // `git init` for older git versions that don't support -b. + try { + await execWithLog({ + cmd: 'git', + args: ['init', '-b', 'main'], + opts: { cwd: actFolderDir }, + }); + } catch { + await execWithLog({ + cmd: 'git', + args: ['init'], + opts: { cwd: actFolderDir }, + }); + } } catch (err) { gitInitResult = { success: false, error: err as Error }; } diff --git a/src/commands/run.ts b/src/commands/run.ts index 3548472e4..15c1b9883 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -63,6 +63,61 @@ enum RunType { Script = 2, } +// Package managers / node runtimes that resolve their own subcommands and +// therefore do not need a matching node_modules/.bin entry. +const PREFLIGHT_SKIP_LEADING_TOKENS = new Set([ + 'node', + 'npm', + 'npx', + 'pnpm', + 'pnpx', + 'yarn', + 'bun', + 'bunx', + 'deno', + 'sh', + 'bash', + 'zsh', + 'python', + 'python3', + 'echo', + 'true', + 'false', +]); + +/** + * Extracts the leading binary from an npm script command and returns it iff + * it looks like a local dependency binary that is expected to live in + * `/node_modules/.bin/` but does not. Returns `null` if we can't + * confidently determine that a binary is missing (unknown command shapes, + * absolute paths, node_modules/.bin present, etc.) — the preflight is meant + * to catch the common failure mode, not to second-guess arbitrary scripts. + */ +async function findMissingScriptBinary(cwd: string, script: string): Promise { + if (!script) return null; + + // Strip leading env-var assignments (e.g. `NODE_ENV=production tsx src/main.ts`). + const tokens = script.trim().split(/\s+/); + let idx = 0; + while (idx < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[idx])) { + idx += 1; + } + const leading = tokens[idx]; + if (!leading) return null; + + // Skip package managers, node/deno, shells, etc. — none of these are expected + // to be locally installed binaries. + if (PREFLIGHT_SKIP_LEADING_TOKENS.has(leading)) return null; + + // If the script references an explicit path, we can't reliably infer a + // node_modules/.bin entry — skip the preflight to avoid false positives. + if (leading.includes('/') || leading.includes('\\') || leading.startsWith('.')) return null; + + const binPath = join(cwd, 'node_modules', '.bin', leading); + const exists = existsSync(binPath) || existsSync(`${binPath}.cmd`) || existsSync(`${binPath}.exe`); + return exists ? null : leading; +} + export class RunCommand extends ApifyCommand { static override name = 'run' as const; @@ -399,6 +454,20 @@ export class RunCommand extends ApifyCommand { ); } + // Preflight: catch the common "sh: tsx: not found" failure mode + // where devDependencies were skipped (typically because the caller + // had NODE_ENV=production when running `npm install`). Emit an + // actionable error before spawning the package manager, instead of + // letting the child fail with a bare `sh: : not found`. + const missingBin = await findMissingScriptBinary(cwd, packageJsonObj.scripts[entrypoint]); + if (missingBin) { + throw new Error( + `"${missingBin}" was not found in node_modules/.bin. This usually means ` + + `devDependencies were skipped during install (e.g. NODE_ENV=production). ` + + `Run \`npm install --include=dev\` (or \`pnpm install --prod=false\`) and retry.`, + ); + } + await execWithLog({ cmd: runtime.pmPath, args: ['run', entrypoint],