Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 46 additions & 6 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,28 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
// 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': {
Expand All @@ -263,10 +285,17 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
}
}

// 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,
});

Expand Down Expand Up @@ -343,11 +372,22 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {

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 };
}
Expand Down
69 changes: 69 additions & 0 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<cwd>/node_modules/.bin/<name>` 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<string | null> {
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<typeof RunCommand> {
static override name = 'run' as const;

Expand Down Expand Up @@ -399,6 +454,20 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
);
}

// 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: <bin>: 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],
Expand Down
Loading