diff --git a/src/commands/create.ts b/src/commands/create.ts index f551d4dab..d0dfec020 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -19,6 +19,7 @@ import { import { enhanceReadmeWithLocalSuffix, ensureValidActorName, + findMissingRunnerBinary, formatCreateSuccessMessage, getTemplateDefinition, } from '../lib/create-utils.js'; @@ -343,9 +344,12 @@ export class CreateCommand extends ApifyCommand { 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) { @@ -356,6 +360,11 @@ export class CreateCommand extends ApifyCommand { // 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({ @@ -365,6 +374,7 @@ export class CreateCommand extends ApifyCommand { postCreate: messages?.postCreate ?? null, gitRepositoryInitialized: !skipGitInit && !cwdHasGit && gitInitResult.success, installCommandSuggestion, + missingRunnerBinary, }), }); diff --git a/src/lib/create-utils.ts b/src/lib/create-utils.ts index 72b973b83..112fe9926 100644 --- a/src/lib/create-utils.ts +++ b/src/lib/create-utils.ts @@ -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'; @@ -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']; @@ -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 ": 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`; @@ -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 { + const pkg = getJsonFileContent<{ + scripts?: Record; + devDependencies?: Record; + }>(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 }).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. */ diff --git a/src/lib/hooks/useCLIVersionCheck.ts b/src/lib/hooks/useCLIVersionCheck.ts index 6811bebcb..25e6b5f47 100644 --- a/src/lib/hooks/useCLIVersionCheck.ts +++ b/src/lib/hooks/useCLIVersionCheck.ts @@ -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'; @@ -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; }