diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 9eefdf62..6b4ead58 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 d25fc74f..60751205 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 82ba9570..d0e199e7 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 7c981212..1ddf6128 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 9213e3cc..80ece52a 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 ad9ece15..eae5e95c 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 2513729f..766d5c3e 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 e2c4e552..01ac2684 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') { 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 00000000..383230ed --- /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 00000000..821add15 --- /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 00000000..780c85d4 --- /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 00000000..a44feea1 --- /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 00000000..984c8bb0 --- /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'); + }); +});