Skip to content

Commit 0e2a9d3

Browse files
authored
fix: pick the start script for node projects before main property (#787)
1 parent 2a6da6c commit 0e2a9d3

13 files changed

+338
-46
lines changed

.github/workflows/release.yaml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,8 +226,6 @@ jobs:
226226
brew bump-formula-pr apify-cli \
227227
--version ${PACKAGE_VERSION} \
228228
--no-browse \
229-
--message "Automatic update of the \`apify-cli\` formula.
230-
231-
CC @B4nan @vladfrangu"
229+
--message "Automatic update of the \`apify-cli\` formula. CC @B4nan @vladfrangu"
232230
env:
233231
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "apify-cli",
3-
"version": "0.21.6",
3+
"version": "0.21.7",
44
"description": "Apify command-line interface (CLI) helps you manage the Apify cloud platform and develop, build, and deploy Apify Actors.",
55
"exports": "./dist/index.js",
66
"types": "./dist/index.d.ts",

src/commands/run.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path';
44
import process from 'node:process';
55

66
import { Flags } from '@oclif/core';
7+
import type { ExecaError } from 'execa';
78
import mime from 'mime';
89
import { minVersion } from 'semver';
910

@@ -385,6 +386,12 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
385386
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`,
386387
});
387388
}
389+
} catch (err) {
390+
const { stderr } = err as ExecaError;
391+
392+
if (stderr) {
393+
// TODO: maybe throw in helpful tips for debugging issues (missing scripts, trying to start a ts file with old node, etc)
394+
}
388395
} finally {
389396
if (storedInputResults) {
390397
if (storedInputResults.existingInput) {

src/lib/exec.ts

Lines changed: 31 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,50 @@
1-
import { spawn, type SpawnOptions, type SpawnOptionsWithoutStdio } from 'node:child_process';
1+
import { Result } from '@sapphire/result';
2+
import { execa, type ExecaError, type Options } from 'execa';
23

34
import { normalizeExecutablePath } from './hooks/runtimes/utils.js';
4-
import { run } from './outputs.js';
5+
import { error, run } from './outputs.js';
56
import { cliDebugPrint } from './utils/cliDebugPrint.js';
67

7-
const windowsOptions: SpawnOptions = {
8-
shell: true,
9-
windowsHide: true,
10-
};
11-
12-
/**
13-
* Run child process and returns stdout and stderr to user stout
14-
*/
15-
const spawnPromised = async (cmd: string, args: string[], opts: SpawnOptionsWithoutStdio) => {
8+
const spawnPromised = async (cmd: string, args: string[], opts: Options) => {
169
const escapedCommand = normalizeExecutablePath(cmd);
1710

18-
cliDebugPrint('SpawnPromised', { escapedCommand, args, opts });
19-
20-
// NOTE: Pipes stderr, stdout to main process
21-
const childProcess = spawn(escapedCommand, args, {
22-
...opts,
23-
stdio: process.env.APIFY_NO_LOGS_IN_TESTS ? 'ignore' : 'inherit',
24-
...(process.platform === 'win32' ? windowsOptions : {}),
25-
});
26-
27-
// Catch ctrl-c (SIGINT) and kills child process
28-
// NOTE: This fix kills also puppeteer child node process
29-
process.on('SIGINT', () => {
30-
try {
31-
childProcess.kill('SIGINT');
32-
} catch {
33-
// SIGINT can come after the child process is finished, ignore it
34-
}
11+
cliDebugPrint('spawnPromised', { escapedCommand, args, opts });
12+
13+
const childProcess = execa(escapedCommand, args, {
14+
shell: true,
15+
windowsHide: true,
16+
env: opts.env,
17+
cwd: opts.cwd,
18+
// Pipe means it gets collected by the parent process, inherit means it gets collected by the parent process and printed out to the console
19+
stdout: process.env.APIFY_NO_LOGS_IN_TESTS ? ['pipe'] : ['pipe', 'inherit'],
20+
stderr: process.env.APIFY_NO_LOGS_IN_TESTS ? ['pipe'] : ['pipe', 'inherit'],
21+
verbose: process.env.APIFY_CLI_DEBUG ? 'full' : undefined,
3522
});
3623

37-
return new Promise<void>((resolve, reject) => {
38-
childProcess.on('error', reject);
39-
childProcess.on('close', (code) => {
40-
if (code !== 0) reject(new Error(`${cmd} exited with code ${code}`));
41-
resolve();
42-
});
43-
});
24+
return Result.fromAsync(
25+
childProcess.catch((execaError: ExecaError) => {
26+
throw new Error(`${cmd} exited with code ${execaError.exitCode}`, { cause: execaError });
27+
}),
28+
) as Promise<Result<Awaited<typeof childProcess>, Error & { cause: ExecaError }>>;
4429
};
4530

4631
export interface ExecWithLogOptions {
4732
cmd: string;
4833
args?: string[];
49-
opts?: SpawnOptionsWithoutStdio;
34+
opts?: Options;
5035
overrideCommand?: string;
5136
}
5237

5338
export async function execWithLog({ cmd, args = [], opts = {}, overrideCommand }: ExecWithLogOptions) {
5439
run({ message: `${overrideCommand || cmd} ${args.join(' ')}` });
55-
await spawnPromised(cmd, args, opts);
40+
const result = await spawnPromised(cmd, args, opts);
41+
42+
if (result.isErr()) {
43+
const err = result.unwrapErr();
44+
error({ message: err.message });
45+
46+
if (err.cause) {
47+
throw err.cause;
48+
}
49+
}
5650
}

src/lib/hooks/runtimes/javascript.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ async function getRuntimeVersion(runtimePath: string, args: string[]) {
2222
const result = await execa(runtimePath, args, {
2323
shell: true,
2424
windowsHide: true,
25+
verbose: process.env.APIFY_CLI_DEBUG ? 'full' : undefined,
2526
});
2627

2728
// No output -> issue or who knows
@@ -39,6 +40,7 @@ async function getNpmVersion(npmPath: string) {
3940
const result = await execa(npmPath, ['--version'], {
4041
shell: true,
4142
windowsHide: true,
43+
verbose: process.env.APIFY_CLI_DEBUG ? 'full' : undefined,
4244
});
4345

4446
if (!result.stdout) {

src/lib/hooks/runtimes/python.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ async function getPythonVersion(runtimePath: string) {
1717
const result = await execa(runtimePath, ['-c', '"import platform; print(platform.python_version())"'], {
1818
shell: true,
1919
windowsHide: true,
20+
verbose: process.env.APIFY_CLI_DEBUG ? 'full' : undefined,
2021
});
2122

2223
// No output -> issue or who knows

src/lib/hooks/useCwdProject.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { access, readFile } from 'node:fs/promises';
2-
import { basename, dirname, join } from 'node:path';
2+
import { basename, dirname, join, resolve } from 'node:path';
33
import process from 'node:process';
44

55
import { ok, type Result } from '@sapphire/result';
@@ -144,13 +144,24 @@ async function checkNodeProject(cwd: string) {
144144

145145
const pkg = JSON.parse(rawString);
146146

147-
if (pkg.main) {
148-
return { path: join(cwd, pkg.main), type: 'file' } as const;
149-
}
150-
147+
// Always prefer start script if it exists
151148
if (pkg.scripts?.start) {
152149
return { type: 'script', script: 'start' } as const;
153150
}
151+
152+
// 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)
153+
if (pkg.main) {
154+
try {
155+
await access(resolve(cwd, pkg.main));
156+
157+
return { path: resolve(cwd, pkg.main), type: 'file' } as const;
158+
} catch {
159+
// Ignore errors
160+
}
161+
}
162+
163+
// We have a node project but we don't know what to do with it
164+
return { type: 'unknown-entrypoint' } as const;
154165
} catch {
155166
// Ignore missing package.json and try some common files
156167
}
@@ -159,12 +170,21 @@ async function checkNodeProject(cwd: string) {
159170
join(cwd, 'index.js'),
160171
join(cwd, 'index.mjs'),
161172
join(cwd, 'index.cjs'),
173+
join(cwd, 'main.js'),
174+
join(cwd, 'main.mjs'),
175+
join(cwd, 'main.cjs'),
162176
join(cwd, 'src', 'index.js'),
163177
join(cwd, 'src', 'index.mjs'),
164178
join(cwd, 'src', 'index.cjs'),
179+
join(cwd, 'src', 'main.js'),
180+
join(cwd, 'src', 'main.mjs'),
181+
join(cwd, 'src', 'main.cjs'),
165182
join(cwd, 'dist', 'index.js'),
166183
join(cwd, 'dist', 'index.mjs'),
167184
join(cwd, 'dist', 'index.cjs'),
185+
join(cwd, 'dist', 'main.js'),
186+
join(cwd, 'dist', 'main.mjs'),
187+
join(cwd, 'dist', 'main.cjs'),
168188
];
169189

170190
for (const path of filesToCheck) {

src/lib/hooks/useModuleVersion.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export async function useModuleVersion({ moduleName, project }: UseModuleVersion
104104
const result = await execa(project.runtime.executablePath, args, {
105105
shell: true,
106106
windowsHide: true,
107+
verbose: process.env.APIFY_CLI_DEBUG ? 'full' : undefined,
107108
});
108109

109110
if (result.stdout.trim() === 'n/a') {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { readFile, writeFile } from 'node:fs/promises';
2+
3+
import { useConsoleSpy } from '../../../../__setup__/hooks/useConsoleSpy.js';
4+
import { useTempPath } from '../../../../__setup__/hooks/useTempPath.js';
5+
import { resetCwdCaches } from '../../../../__setup__/reset-cwd-caches.js';
6+
7+
const actorName = 'prints-error-message-on-node-project-with-no-detected-start';
8+
9+
const { beforeAllCalls, afterAllCalls, joinPath, toggleCwdBetweenFullAndParentPath } = useTempPath(actorName, {
10+
create: true,
11+
remove: true,
12+
cwd: true,
13+
cwdParent: true,
14+
});
15+
16+
const { logMessages } = useConsoleSpy();
17+
18+
const { CreateCommand } = await import('../../../../../src/commands/create.js');
19+
const { RunCommand } = await import('../../../../../src/commands/run.js');
20+
21+
describe('apify run', () => {
22+
beforeAll(async () => {
23+
await beforeAllCalls();
24+
25+
await CreateCommand.run([actorName, '--template', 'project_cheerio_crawler_js'], import.meta.url);
26+
toggleCwdBetweenFullAndParentPath();
27+
28+
const pkgJsonPath = joinPath('package.json');
29+
const pkgJson = await readFile(pkgJsonPath, 'utf8');
30+
31+
const pkgJsonObj = JSON.parse(pkgJson);
32+
33+
delete pkgJsonObj.main;
34+
pkgJsonObj.scripts ??= {};
35+
delete pkgJsonObj.scripts.start;
36+
37+
await writeFile(pkgJsonPath, JSON.stringify(pkgJsonObj, null, '\t'));
38+
39+
resetCwdCaches();
40+
});
41+
42+
afterAll(async () => {
43+
await afterAllCalls();
44+
});
45+
46+
it('should print error message on node project with no detected start', async () => {
47+
await expect(RunCommand.run([], import.meta.url)).resolves.toBeUndefined();
48+
49+
expect(logMessages.error[0]).toMatch(/No entrypoint detected/i);
50+
});
51+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { readFile, writeFile } from 'node:fs/promises';
2+
3+
import { getLocalKeyValueStorePath } from '../../../../../src/lib/utils.js';
4+
import { useTempPath } from '../../../../__setup__/hooks/useTempPath.js';
5+
6+
const actorName = 'works-with-invalid-main-but-start';
7+
8+
const mainFile = `
9+
import { Actor } from 'apify';
10+
11+
await Actor.init();
12+
13+
await Actor.setValue('OUTPUT', 'worked');
14+
15+
await Actor.exit();
16+
`;
17+
18+
const { beforeAllCalls, afterAllCalls, joinPath, toggleCwdBetweenFullAndParentPath } = useTempPath(actorName, {
19+
create: true,
20+
remove: true,
21+
cwd: true,
22+
cwdParent: true,
23+
});
24+
25+
const { CreateCommand } = await import('../../../../../src/commands/create.js');
26+
const { RunCommand } = await import('../../../../../src/commands/run.js');
27+
28+
describe('apify run', () => {
29+
let outputPath: string;
30+
31+
beforeAll(async () => {
32+
await beforeAllCalls();
33+
34+
await CreateCommand.run([actorName, '--template', 'project_cheerio_crawler_js'], import.meta.url);
35+
toggleCwdBetweenFullAndParentPath();
36+
37+
await writeFile(joinPath('src', 'index.js'), mainFile);
38+
39+
const pkgJsonPath = joinPath('package.json');
40+
const pkgJson = await readFile(pkgJsonPath, 'utf8');
41+
const pkgJsonObj = JSON.parse(pkgJson);
42+
43+
// Force a wrong main file
44+
pkgJsonObj.main = 'src/main.ts';
45+
pkgJsonObj.scripts ??= {};
46+
47+
// but a valid start script
48+
pkgJsonObj.scripts.start = 'node src/index.js';
49+
50+
await writeFile(pkgJsonPath, JSON.stringify(pkgJsonObj, null, '\t'));
51+
52+
outputPath = joinPath(getLocalKeyValueStorePath(), 'OUTPUT.json');
53+
});
54+
55+
afterAll(async () => {
56+
await afterAllCalls();
57+
});
58+
59+
it('should work with invalid main but valid start script', async () => {
60+
await RunCommand.run([], import.meta.url);
61+
62+
const output = JSON.parse(await readFile(outputPath, 'utf8'));
63+
expect(output).toBe('worked');
64+
});
65+
});

0 commit comments

Comments
 (0)