Repository: apify/apify-cli
Pull request #787 (2025-04-24 16:42:53)

fix: pick the start script for node projects before main property
This solves a regression that was caused by the cleanup of project detections, where we would default to the `main` property of the package.json instead of the `start` script like we used to. This was shown to be an issue in https://github.com/apify/actor-templates/pull/392, which had a template with a main script that was invalid *and* incompatible with the runtime, while the `start` script would have worked.

I've also added test fixtures to ensure certain issues that we've kept having no longer happen (windows + spaced paths breaking, invalid entrypoint configs correctly throw the expected errors or handle gracefully)


Patch:
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 9eefdf6..6b4ead5 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -226,8 +226,6 @@ jobs:
                   brew bump-formula-pr apify-cli \
                       --version ${PACKAGE_VERSION} \
                       --no-browse \
-                      --message "Automatic update of the \`apify-cli\` formula.
-
-                      CC @B4nan @vladfrangu"
+                      --message "Automatic update of the \`apify-cli\` formula. CC @B4nan @vladfrangu"
               env:
                   HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }}
diff --git a/package.json b/package.json
index d25fc74..6075120 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
     "name": "apify-cli",
-    "version": "0.21.6",
+    "version": "0.21.7",
     "description": "Apify command-line interface (CLI) helps you manage the Apify cloud platform and develop, build, and deploy Apify Actors.",
     "exports": "./dist/index.js",
     "types": "./dist/index.d.ts",
diff --git a/src/commands/run.ts b/src/commands/run.ts
index 82ba957..d0e199e 100644
--- a/src/commands/run.ts
+++ b/src/commands/run.ts
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path';
 import process from 'node:process';
 
 import { Flags } from '@oclif/core';
+import type { ExecaError } from 'execa';
 import mime from 'mime';
 import { minVersion } from 'semver';
 
@@ -385,6 +386,12 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
 						message: `Failed to detect the language of your project. Please report this issue to the Apify team with your project structure over at https://github.com/apify/apify-cli/issues`,
 					});
 			}
+		} catch (err) {
+			const { stderr } = err as ExecaError;
+
+			if (stderr) {
+				// TODO: maybe throw in helpful tips for debugging issues (missing scripts, trying to start a ts file with old node, etc)
+			}
 		} finally {
 			if (storedInputResults) {
 				if (storedInputResults.existingInput) {
diff --git a/src/lib/exec.ts b/src/lib/exec.ts
index 7c98121..1ddf612 100644
--- a/src/lib/exec.ts
+++ b/src/lib/exec.ts
@@ -1,56 +1,50 @@
-import { spawn, type SpawnOptions, type SpawnOptionsWithoutStdio } from 'node:child_process';
+import { Result } from '@sapphire/result';
+import { execa, type ExecaError, type Options } from 'execa';
 
 import { normalizeExecutablePath } from './hooks/runtimes/utils.js';
-import { run } from './outputs.js';
+import { error, run } from './outputs.js';
 import { cliDebugPrint } from './utils/cliDebugPrint.js';
 
-const windowsOptions: SpawnOptions = {
-	shell: true,
-	windowsHide: true,
-};
-
-/**
- * Run child process and returns stdout and stderr to user stout
- */
-const spawnPromised = async (cmd: string, args: string[], opts: SpawnOptionsWithoutStdio) => {
+const spawnPromised = async (cmd: string, args: string[], opts: Options) => {
 	const escapedCommand = normalizeExecutablePath(cmd);
 
-	cliDebugPrint('SpawnPromised', { escapedCommand, args, opts });
-
-	// NOTE: Pipes stderr, stdout to main process
-	const childProcess = spawn(escapedCommand, args, {
-		...opts,
-		stdio: process.env.APIFY_NO_LOGS_IN_TESTS ? 'ignore' : 'inherit',
-		...(process.platform === 'win32' ? windowsOptions : {}),
-	});
-
-	// Catch ctrl-c (SIGINT) and kills child process
-	// NOTE: This fix kills also puppeteer child node process
-	process.on('SIGINT', () => {
-		try {
-			childProcess.kill('SIGINT');
-		} catch {
-			// SIGINT can come after the child process is finished, ignore it
-		}
+	cliDebugPrint('spawnPromised', { escapedCommand, args, opts });
+
+	const childProcess = execa(escapedCommand, args, {
+		shell: true,
+		windowsHide: true,
+		env: opts.env,
+		cwd: opts.cwd,
+		// Pipe means it gets collected by the parent process, inherit means it gets collected by the parent process and printed out to the console
+		stdout: process.env.APIFY_NO_LOGS_IN_TESTS ? ['pipe'] : ['pipe', 'inherit'],
+		stderr: process.env.APIFY_NO_LOGS_IN_TESTS ? ['pipe'] : ['pipe', 'inherit'],
+		verbose: process.env.APIFY_CLI_DEBUG ? 'full' : undefined,
 	});
 
-	return new Promise<void>((resolve, reject) => {
-		childProcess.on('error', reject);
-		childProcess.on('close', (code) => {
-			if (code !== 0) reject(new Error(`${cmd} exited with code ${code}`));
-			resolve();
-		});
-	});
+	return Result.fromAsync(
+		childProcess.catch((execaError: ExecaError) => {
+			throw new Error(`${cmd} exited with code ${execaError.exitCode}`, { cause: execaError });
+		}),
+	) as Promise<Result<Awaited<typeof childProcess>, Error & { cause: ExecaError }>>;
 };
 
 export interface ExecWithLogOptions {
 	cmd: string;
 	args?: string[];
-	opts?: SpawnOptionsWithoutStdio;
+	opts?: Options;
 	overrideCommand?: string;
 }
 
 export async function execWithLog({ cmd, args = [], opts = {}, overrideCommand }: ExecWithLogOptions) {
 	run({ message: `${overrideCommand || cmd} ${args.join(' ')}` });
-	await spawnPromised(cmd, args, opts);
+	const result = await spawnPromised(cmd, args, opts);
+
+	if (result.isErr()) {
+		const err = result.unwrapErr();
+		error({ message: err.message });
+
+		if (err.cause) {
+			throw err.cause;
+		}
+	}
 }
diff --git a/src/lib/hooks/runtimes/javascript.ts b/src/lib/hooks/runtimes/javascript.ts
index 9213e3c..80ece52 100644
--- a/src/lib/hooks/runtimes/javascript.ts
+++ b/src/lib/hooks/runtimes/javascript.ts
@@ -22,6 +22,7 @@ async function getRuntimeVersion(runtimePath: string, args: string[]) {
 		const result = await execa(runtimePath, args, {
 			shell: true,
 			windowsHide: true,
+			verbose: process.env.APIFY_CLI_DEBUG ? 'full' : undefined,
 		});
 
 		// No output -> issue or who knows
@@ -39,6 +40,7 @@ async function getNpmVersion(npmPath: string) {
 	const result = await execa(npmPath, ['--version'], {
 		shell: true,
 		windowsHide: true,
+		verbose: process.env.APIFY_CLI_DEBUG ? 'full' : undefined,
 	});
 
 	if (!result.stdout) {
diff --git a/src/lib/hooks/runtimes/python.ts b/src/lib/hooks/runtimes/python.ts
index ad9ece1..eae5e95 100644
--- a/src/lib/hooks/runtimes/python.ts
+++ b/src/lib/hooks/runtimes/python.ts
@@ -17,6 +17,7 @@ async function getPythonVersion(runtimePath: string) {
 		const result = await execa(runtimePath, ['-c', '"import platform; print(platform.python_version())"'], {
 			shell: true,
 			windowsHide: true,
+			verbose: process.env.APIFY_CLI_DEBUG ? 'full' : undefined,
 		});
 
 		// No output -> issue or who knows
diff --git a/src/lib/hooks/useCwdProject.ts b/src/lib/hooks/useCwdProject.ts
index 2513729..766d5c3 100644
--- a/src/lib/hooks/useCwdProject.ts
+++ b/src/lib/hooks/useCwdProject.ts
@@ -1,5 +1,5 @@
 import { access, readFile } from 'node:fs/promises';
-import { basename, dirname, join } from 'node:path';
+import { basename, dirname, join, resolve } from 'node:path';
 import process from 'node:process';
 
 import { ok, type Result } from '@sapphire/result';
@@ -144,13 +144,24 @@ async function checkNodeProject(cwd: string) {
 
 		const pkg = JSON.parse(rawString);
 
-		if (pkg.main) {
-			return { path: join(cwd, pkg.main), type: 'file' } as const;
-		}
-
+		// Always prefer start script if it exists
 		if (pkg.scripts?.start) {
 			return { type: 'script', script: 'start' } as const;
 		}
+
+		// Try to find the main entrypoint if it exists (if its a TypeScript file, the user has to deal with ensuring their runtime can run it directly)
+		if (pkg.main) {
+			try {
+				await access(resolve(cwd, pkg.main));
+
+				return { path: resolve(cwd, pkg.main), type: 'file' } as const;
+			} catch {
+				// Ignore errors
+			}
+		}
+
+		// We have a node project but we don't know what to do with it
+		return { type: 'unknown-entrypoint' } as const;
 	} catch {
 		// Ignore missing package.json and try some common files
 	}
@@ -159,12 +170,21 @@ async function checkNodeProject(cwd: string) {
 		join(cwd, 'index.js'),
 		join(cwd, 'index.mjs'),
 		join(cwd, 'index.cjs'),
+		join(cwd, 'main.js'),
+		join(cwd, 'main.mjs'),
+		join(cwd, 'main.cjs'),
 		join(cwd, 'src', 'index.js'),
 		join(cwd, 'src', 'index.mjs'),
 		join(cwd, 'src', 'index.cjs'),
+		join(cwd, 'src', 'main.js'),
+		join(cwd, 'src', 'main.mjs'),
+		join(cwd, 'src', 'main.cjs'),
 		join(cwd, 'dist', 'index.js'),
 		join(cwd, 'dist', 'index.mjs'),
 		join(cwd, 'dist', 'index.cjs'),
+		join(cwd, 'dist', 'main.js'),
+		join(cwd, 'dist', 'main.mjs'),
+		join(cwd, 'dist', 'main.cjs'),
 	];
 
 	for (const path of filesToCheck) {
diff --git a/src/lib/hooks/useModuleVersion.ts b/src/lib/hooks/useModuleVersion.ts
index e2c4e55..01ac268 100644
--- a/src/lib/hooks/useModuleVersion.ts
+++ b/src/lib/hooks/useModuleVersion.ts
@@ -104,6 +104,7 @@ export async function useModuleVersion({ moduleName, project }: UseModuleVersion
 		const result = await execa(project.runtime.executablePath, args, {
 			shell: true,
 			windowsHide: true,
+			verbose: process.env.APIFY_CLI_DEBUG ? 'full' : undefined,
 		});
 
 		if (result.stdout.trim() === 'n/a') {


Test patch:
diff --git a/test/fixtures/commands/run/javascript/prints-error-message-on-project-with-no-detected-start.test.ts b/test/fixtures/commands/run/javascript/prints-error-message-on-project-with-no-detected-start.test.ts
new file mode 100644
index 0000000..383230e
--- /dev/null
+++ b/test/fixtures/commands/run/javascript/prints-error-message-on-project-with-no-detected-start.test.ts
@@ -0,0 +1,51 @@
+import { readFile, writeFile } from 'node:fs/promises';
+
+import { useConsoleSpy } from '../../../../__setup__/hooks/useConsoleSpy.js';
+import { useTempPath } from '../../../../__setup__/hooks/useTempPath.js';
+import { resetCwdCaches } from '../../../../__setup__/reset-cwd-caches.js';
+
+const actorName = 'prints-error-message-on-node-project-with-no-detected-start';
+
+const { beforeAllCalls, afterAllCalls, joinPath, toggleCwdBetweenFullAndParentPath } = useTempPath(actorName, {
+	create: true,
+	remove: true,
+	cwd: true,
+	cwdParent: true,
+});
+
+const { logMessages } = useConsoleSpy();
+
+const { CreateCommand } = await import('../../../../../src/commands/create.js');
+const { RunCommand } = await import('../../../../../src/commands/run.js');
+
+describe('apify run', () => {
+	beforeAll(async () => {
+		await beforeAllCalls();
+
+		await CreateCommand.run([actorName, '--template', 'project_cheerio_crawler_js'], import.meta.url);
+		toggleCwdBetweenFullAndParentPath();
+
+		const pkgJsonPath = joinPath('package.json');
+		const pkgJson = await readFile(pkgJsonPath, 'utf8');
+
+		const pkgJsonObj = JSON.parse(pkgJson);
+
+		delete pkgJsonObj.main;
+		pkgJsonObj.scripts ??= {};
+		delete pkgJsonObj.scripts.start;
+
+		await writeFile(pkgJsonPath, JSON.stringify(pkgJsonObj, null, '\t'));
+
+		resetCwdCaches();
+	});
+
+	afterAll(async () => {
+		await afterAllCalls();
+	});
+
+	it('should print error message on node project with no detected start', async () => {
+		await expect(RunCommand.run([], import.meta.url)).resolves.toBeUndefined();
+
+		expect(logMessages.error[0]).toMatch(/No entrypoint detected/i);
+	});
+});
diff --git a/test/fixtures/commands/run/javascript/works-with-invalid-main-but-start.test.ts b/test/fixtures/commands/run/javascript/works-with-invalid-main-but-start.test.ts
new file mode 100644
index 0000000..821add1
--- /dev/null
+++ b/test/fixtures/commands/run/javascript/works-with-invalid-main-but-start.test.ts
@@ -0,0 +1,65 @@
+import { readFile, writeFile } from 'node:fs/promises';
+
+import { getLocalKeyValueStorePath } from '../../../../../src/lib/utils.js';
+import { useTempPath } from '../../../../__setup__/hooks/useTempPath.js';
+
+const actorName = 'works-with-invalid-main-but-start';
+
+const mainFile = `
+import { Actor } from 'apify';
+
+await Actor.init();
+
+await Actor.setValue('OUTPUT', 'worked');
+
+await Actor.exit();
+`;
+
+const { beforeAllCalls, afterAllCalls, joinPath, toggleCwdBetweenFullAndParentPath } = useTempPath(actorName, {
+	create: true,
+	remove: true,
+	cwd: true,
+	cwdParent: true,
+});
+
+const { CreateCommand } = await import('../../../../../src/commands/create.js');
+const { RunCommand } = await import('../../../../../src/commands/run.js');
+
+describe('apify run', () => {
+	let outputPath: string;
+
+	beforeAll(async () => {
+		await beforeAllCalls();
+
+		await CreateCommand.run([actorName, '--template', 'project_cheerio_crawler_js'], import.meta.url);
+		toggleCwdBetweenFullAndParentPath();
+
+		await writeFile(joinPath('src', 'index.js'), mainFile);
+
+		const pkgJsonPath = joinPath('package.json');
+		const pkgJson = await readFile(pkgJsonPath, 'utf8');
+		const pkgJsonObj = JSON.parse(pkgJson);
+
+		// Force a wrong main file
+		pkgJsonObj.main = 'src/main.ts';
+		pkgJsonObj.scripts ??= {};
+
+		// but a valid start script
+		pkgJsonObj.scripts.start = 'node src/index.js';
+
+		await writeFile(pkgJsonPath, JSON.stringify(pkgJsonObj, null, '\t'));
+
+		outputPath = joinPath(getLocalKeyValueStorePath(), 'OUTPUT.json');
+	});
+
+	afterAll(async () => {
+		await afterAllCalls();
+	});
+
+	it('should work with invalid main but valid start script', async () => {
+		await RunCommand.run([], import.meta.url);
+
+		const output = JSON.parse(await readFile(outputPath, 'utf8'));
+		expect(output).toBe('worked');
+	});
+});
diff --git a/test/fixtures/commands/run/javascript/works-with-spaces-in-path-to-actor.test.ts b/test/fixtures/commands/run/javascript/works-with-spaces-in-path-to-actor.test.ts
new file mode 100644
index 0000000..780c85d
--- /dev/null
+++ b/test/fixtures/commands/run/javascript/works-with-spaces-in-path-to-actor.test.ts
@@ -0,0 +1,64 @@
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+
+import { getLocalKeyValueStorePath } from '../../../../../src/lib/utils.js';
+import { useTempPath } from '../../../../__setup__/hooks/useTempPath.js';
+
+const actorName = 'works-with-invalid-main-but-start';
+
+const mainFile = `
+import { Actor } from 'apify';
+
+await Actor.init();
+
+await Actor.setValue('OUTPUT', 'worked');
+
+await Actor.exit();
+`;
+
+const { beforeAllCalls, afterAllCalls, joinCwdPath, forceNewCwd } = useTempPath(actorName.replaceAll('-', ' '), {
+	create: true,
+	remove: true,
+	cwd: true,
+	cwdParent: false,
+});
+
+const { CreateCommand } = await import('../../../../../src/commands/create.js');
+const { RunCommand } = await import('../../../../../src/commands/run.js');
+
+describe('apify run', () => {
+	let outputPath: string;
+
+	beforeAll(async () => {
+		await beforeAllCalls();
+
+		await CreateCommand.run([actorName, '--template', 'project_cheerio_crawler_js'], import.meta.url);
+
+		forceNewCwd(actorName);
+
+		const pkgJsonPath = joinCwdPath('package.json');
+		const pkgJson = await readFile(pkgJsonPath, 'utf8');
+		const pkgJsonObj = JSON.parse(pkgJson);
+
+		pkgJsonObj.scripts ??= {};
+		pkgJsonObj.scripts.start = 'node "./spaced test/main.js"';
+
+		await writeFile(pkgJsonPath, JSON.stringify(pkgJsonObj, null, '\t'));
+
+		await mkdir(joinCwdPath('spaced test'), { recursive: true });
+
+		await writeFile(joinCwdPath('spaced test', 'main.js'), mainFile);
+
+		outputPath = joinCwdPath(getLocalKeyValueStorePath(), 'OUTPUT.json');
+	});
+
+	afterAll(async () => {
+		await afterAllCalls();
+	});
+
+	it('should work with spaces in path to actor', async () => {
+		await RunCommand.run([], import.meta.url);
+
+		const output = JSON.parse(await readFile(outputPath, 'utf8'));
+		expect(output).toBe('worked');
+	});
+});
diff --git a/test/fixtures/commands/run/python/prints-error-message-on-project-with-no-detected-start.test.ts b/test/fixtures/commands/run/python/prints-error-message-on-project-with-no-detected-start.test.ts
new file mode 100644
index 0000000..a44feea
--- /dev/null
+++ b/test/fixtures/commands/run/python/prints-error-message-on-project-with-no-detected-start.test.ts
@@ -0,0 +1,38 @@
+import { rename } from 'node:fs/promises';
+
+import { useTempPath } from '../../../../__setup__/hooks/useTempPath.js';
+import { resetCwdCaches } from '../../../../__setup__/reset-cwd-caches.js';
+
+const actorName = 'prints-error-message-on-python-project-with-no-detected-start';
+
+const { beforeAllCalls, afterAllCalls, joinPath, toggleCwdBetweenFullAndParentPath } = useTempPath(actorName, {
+	create: true,
+	remove: true,
+	cwd: true,
+	cwdParent: true,
+});
+
+const { CreateCommand } = await import('../../../../../src/commands/create.js');
+const { RunCommand } = await import('../../../../../src/commands/run.js');
+
+describe('[python] apify run', () => {
+	beforeAll(async () => {
+		await beforeAllCalls();
+
+		await CreateCommand.run([actorName, '--template', 'python-start'], import.meta.url);
+		toggleCwdBetweenFullAndParentPath();
+
+		const srcFolder = joinPath('src');
+		await rename(srcFolder, joinPath('entrypoint'));
+
+		resetCwdCaches();
+	});
+
+	afterAll(async () => {
+		await afterAllCalls();
+	});
+
+	it('should print error message on python project with no detected start', async () => {
+		await expect(RunCommand.run([], import.meta.url)).rejects.toThrow(/Actor is of an unknown format./i);
+	});
+});
diff --git a/test/fixtures/commands/run/python/works-with-spaces-in-path-to-actor.test.ts b/test/fixtures/commands/run/python/works-with-spaces-in-path-to-actor.test.ts
new file mode 100644
index 0000000..984c8bb
--- /dev/null
+++ b/test/fixtures/commands/run/python/works-with-spaces-in-path-to-actor.test.ts
@@ -0,0 +1,51 @@
+import { readFile, writeFile } from 'node:fs/promises';
+
+import { getLocalKeyValueStorePath } from '../../../../../src/lib/utils.js';
+import { useTempPath } from '../../../../__setup__/hooks/useTempPath.js';
+
+const actorName = 'works-with-spaces-in-path-to-actor-python';
+
+const mainFile = `
+from apify import Actor
+
+async def main():
+	async with Actor:
+		await Actor.set_value('OUTPUT', 'worked')
+`;
+
+const { beforeAllCalls, afterAllCalls, joinCwdPath, forceNewCwd } = useTempPath(actorName.replaceAll('-', ' '), {
+	create: true,
+	remove: true,
+	cwd: true,
+	cwdParent: false,
+});
+
+const { CreateCommand } = await import('../../../../../src/commands/create.js');
+const { RunCommand } = await import('../../../../../src/commands/run.js');
+
+describe('[python] apify run', () => {
+	let outputPath: string;
+
+	beforeAll(async () => {
+		await beforeAllCalls();
+
+		await CreateCommand.run([actorName, '--template', 'python-start'], import.meta.url);
+
+		forceNewCwd(actorName);
+
+		await writeFile(joinCwdPath('src', 'main.py'), mainFile);
+
+		outputPath = joinCwdPath(getLocalKeyValueStorePath(), 'OUTPUT.txt');
+	});
+
+	afterAll(async () => {
+		await afterAllCalls();
+	});
+
+	it('should work with spaces in path to actor', async () => {
+		await RunCommand.run([], import.meta.url);
+
+		const output = await readFile(outputPath, 'utf8');
+		expect(output).toBe('worked');
+	});
+});