Skip to content

fix: pick the start script for node projects before main property #787

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
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
8 changes: 8 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ end_of_line = lf
# editorconfig-tools is unable to ignore longs strings or urls
max_line_length = null

[*.ts]
indent_style = tab
indent_size = 4

[*.js]
indent_style = tab
indent_size = 4

[*.md]
indent_size = 2

Expand Down
4 changes: 1 addition & 3 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
27 changes: 27 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[yaml]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"files.trimTrailingWhitespace": false
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import process from 'node:process';
import { APIFY_ENV_VARS } from '@apify/consts';
import { validateInputSchema, validateInputUsingValidator } from '@apify/input_schema';
import { Flags } from '@oclif/core';
import type { ExecaError } from 'execa';
import mime from 'mime';
import { minVersion } from 'semver';

Expand Down Expand Up @@ -378,6 +379,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) {
Expand Down
68 changes: 31 additions & 37 deletions src/lib/exec.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,50 @@
import { type SpawnOptions, type SpawnOptionsWithoutStdio, spawn } 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('spawnPromised2', { 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;
}
}
}
2 changes: 2 additions & 0 deletions src/lib/hooks/runtimes/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/lib/hooks/runtimes/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 25 additions & 5 deletions src/lib/hooks/useCwdProject.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
}
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/lib/hooks/useModuleVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading