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
12 changes: 11 additions & 1 deletion src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import {
enhanceReadmeWithLocalSuffix,
ensureValidActorName,
findMissingRunnerBinary,
formatCreateSuccessMessage,
getTemplateDefinition,
} from '../lib/create-utils.js';
Expand Down Expand Up @@ -343,9 +344,12 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {

if (!skipGitInit && !cwdHasGit) {
try {
// -c init.defaultBranch=main avoids the "using master as the initial
// branch" hint on Git 2.28+. -q silences the remaining "Initialized
// empty Git repository" line so scaffolding output stays compact.
await execWithLog({
cmd: 'git',
args: ['init'],
args: ['-c', 'init.defaultBranch=main', 'init', '-q'],
opts: { cwd: actFolderDir },
});
} catch (err) {
Expand All @@ -356,6 +360,11 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
// Suggest install command if dependencies were not installed
const installCommandSuggestion = !dependenciesInstalled ? await getInstallCommandSuggestion(actFolderDir) : null;

// If we ran install but the start-script runner binary (tsx/ts-node/etc.)
// is missing from node_modules/.bin, dev dependencies were skipped. Warn
// so the Next-steps hint doesn't lead the user into `sh: tsx: not found`.
const missingRunnerBinary = dependenciesInstalled ? await findMissingRunnerBinary(actFolderDir) : null;

// Success message with extra empty line
simpleLog({ message: '' });
success({
Expand All @@ -365,6 +374,7 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
postCreate: messages?.postCreate ?? null,
gitRepositoryInitialized: !skipGitInit && !cwdHasGit && gitInitResult.success,
installCommandSuggestion,
missingRunnerBinary,
}),
});

Expand Down
68 changes: 65 additions & 3 deletions src/lib/create-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { createWriteStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { join } from 'node:path';
import { pipeline } from 'node:stream/promises';

import { Separator } from '@inquirer/core';
Expand All @@ -9,7 +11,7 @@ import type { ChoicesType } from './hooks/user-confirmations/useSelectFromList.j
import { useSelectFromList } from './hooks/user-confirmations/useSelectFromList.js';
import { useUserInput } from './hooks/user-confirmations/useUserInput.js';
import { warning } from './outputs.js';
import { httpsGet, validateActorName } from './utils.js';
import { getJsonFileContent, httpsGet, validateActorName } from './utils.js';

const PROGRAMMING_LANGUAGES = ['JavaScript', 'TypeScript', 'Python'];

Expand Down Expand Up @@ -71,13 +73,31 @@ export function formatCreateSuccessMessage(params: {
postCreate?: string | null;
gitRepositoryInitialized?: boolean;
installCommandSuggestion?: string | null;
missingRunnerBinary?: string | null;
}) {
const { actorName, dependenciesInstalled, postCreate, gitRepositoryInitialized, installCommandSuggestion } = params;
const {
actorName,
dependenciesInstalled,
postCreate,
gitRepositoryInitialized,
installCommandSuggestion,
missingRunnerBinary,
} = params;

let message = `✅ Actor '${actorName}' created successfully!`;

if (dependenciesInstalled) {
if (dependenciesInstalled && !missingRunnerBinary) {
message += `\n\nNext steps:\n\ncd "${actorName}"\napify run`;
} else if (missingRunnerBinary) {
// The install finished but the runner declared in package.json's start
// script is missing from node_modules/.bin — most commonly because
// NODE_ENV=production (or --omit=dev) caused npm to skip devDependencies.
// Point the user at that root cause instead of the misleading run hint,
// which would fail with "<runner>: not found".
message +=
`\n\n⚠️ Dev dependencies do not appear to be fully installed ` +
`(missing '${missingRunnerBinary}' in node_modules/.bin).` +
`\n\nNext steps:\n\ncd "${actorName}"\nnpm install --include=dev\napify run`;
} else {
const installLine = installCommandSuggestion || 'install dependencies with your package manager';
message += `\n\nNext steps:\n\ncd "${actorName}"\n${installLine}\napify run`;
Expand All @@ -96,6 +116,48 @@ export function formatCreateSuccessMessage(params: {
return message;
}

/**
* Detect the runner binary that the project's `npm start` script expects
* (e.g. `tsx`, `ts-node`) and check whether it landed in `node_modules/.bin`.
* Returns the runner name if it is expected but missing — a heuristic signal
* that devDependencies were skipped during install.
*/
export async function findMissingRunnerBinary(actorDir: string): Promise<string | null> {
const pkg = getJsonFileContent<{
scripts?: Record<string, string>;
devDependencies?: Record<string, string>;
}>(join(actorDir, 'package.json'));

if (!pkg?.scripts?.start) {
return null;
}

// First non-shell token of `start`. Skip env-var assignments like FOO=bar.
const tokens = pkg.scripts.start.split(/\s+/).filter((t) => t && !t.includes('='));
const runner = tokens[0];

if (!runner) {
return null;
}

// Absolute/relative paths and Node itself don't live in node_modules/.bin.
if (runner === 'node' || runner.startsWith('.') || runner.includes('/') || runner.includes('\\')) {
return null;
}

// Only flag runners the template declared as (dev)Dependencies — otherwise a
// missing binary is expected (system-provided command).
const declared = { ...pkg.devDependencies, ...(pkg as { dependencies?: Record<string, string> }).dependencies };
if (!Object.hasOwn(declared, runner)) {
return null;
}

const binPath = join(actorDir, 'node_modules', '.bin', runner);
const exists = await stat(binPath).catch(() => null);

return exists ? null : runner;
}

/**
* Inquirer does not have a native way to "go back" between prompts.
*/
Expand Down
28 changes: 21 additions & 7 deletions src/lib/hooks/useCLIVersionCheck.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { gt } from 'semver';

import { CHECK_VERSION_EVERY_MILLIS } from '../consts.js';
import { warning } from '../outputs.js';
import { cliDebugPrint } from '../utils/cliDebugPrint.js';
import { useCLIMetadata } from './useCLIMetadata.js';
import { type LatestState, updateLocalState, useLocalState } from './useLocalState.js';
Expand Down Expand Up @@ -58,16 +57,31 @@ async function getLatestVersion(state: LatestState) {
headers: {
'User-Agent': USER_AGENT,
},
}).catch((err) => {
cliDebugPrint('useCLIVersionCheck', 'Failed to fetch latest version', { error: (err as Error).message });
return null;
});

if (!res.ok) {
cliDebugPrint('useCLIVersionCheck', 'Failed to fetch latest version', {
statusCode: res.status,
body: await res.text(),
if (!res || !res.ok) {
if (res) {
cliDebugPrint('useCLIVersionCheck', 'Failed to fetch latest version', {
statusCode: res.status,
body: await res.text(),
});
}

// The version check is a best-effort background courtesy — the CLI still
// works without it. Record the attempt so we honour the 24h debounce
// window even on failure (previously every subsequent command re-tried
// and re-printed a warning). We intentionally do not surface the failure
// to the user; transient network errors here are not actionable.
updateLocalState(state, (stateToUpdate) => {
stateToUpdate.versionCheck = {
lastChecked: Date.now(),
lastVersion: state.versionCheck?.lastVersion,
};
});

warning({ message: 'Failed to fetch latest version of Apify CLI, using the cached version instead.' });

return null;
}

Expand Down
Loading