From acff7ac86dcd26d5036e904e71830f9a7a3a1081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 2 Jul 2026 00:55:24 +0200 Subject: [PATCH] feat(create): support `apify create .` or --here to scaffold in current directory Today `apify create ` always creates a subdirectory named after the positional argument. When you want to scaffold INTO an existing empty directory (which is common when the containing dir has already been created by `git clone`, an agent, or a task runner), users have to run `apify create foo && mv foo/* . && rmdir foo`, which is awkward and error-prone. This change adds two ways to scaffold into the current working directory: - Pass `.` as the positional argument: `apify create .` - Pass `--here` explicitly (name still optional): `apify create --here --template js-crawlee-cheerio` In both cases: - The Actor name is derived from the current directory basename (sanitized via `sanitizeActorName`) unless a non-`.` name is provided. - If the current directory is not empty, the command exits with a clear error unless `--force` is set. - The success message drops the `cd ""` hint since you are already there. - The existing "don't clobber a nested .git" behavior is preserved. Backward-compatible: named-path invocations behave exactly as before. Co-Authored-By: Claude Opus 4.7 --- src/commands/create.ts | 118 +++++++++++++++++++++++++++++++--------- src/lib/create-utils.ts | 21 +++++-- 2 files changed, 108 insertions(+), 31 deletions(-) diff --git a/src/commands/create.ts b/src/commands/create.ts index f551d4dab..2f42fb6b5 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -1,5 +1,5 @@ import { mkdir, readdir, stat } from 'node:fs/promises'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import process from 'node:process'; import { gte, minVersion } from 'semver'; @@ -34,6 +34,7 @@ import { getJsonFileContent, isNodeVersionSupported, isPythonVersionSupported, + sanitizeActorName, setLocalConfig, setLocalEnv, } from '../lib/utils.js'; @@ -64,6 +65,15 @@ export class CreateCommand extends ApifyCommand { description: 'Create without installing dependencies (faster; run install yourself later).', command: 'apify create my-actor --template python-start --skip-dependency-install', }, + { + description: + 'Scaffold into the current directory (no wrapper subdirectory). The Actor name is derived from the cwd basename.', + command: 'apify create . --template js-crawlee-cheerio', + }, + { + description: 'Same as above, using the --here flag explicitly.', + command: 'apify create --here --template js-crawlee-cheerio', + }, ]; static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-create'; @@ -92,6 +102,20 @@ export class CreateCommand extends ApifyCommand { description: 'Skip initializing a git repository in the Actor directory.', required: false, }), + here: Flags.boolean({ + description: + 'Scaffold the Actor into the current directory instead of creating a subdirectory. ' + + 'The Actor name is derived from the current directory basename unless explicitly provided. ' + + 'Refuses to overwrite a non-empty directory unless --force is set. ' + + 'You can also pass "." as the positional argument for the same effect.', + required: false, + }), + force: Flags.boolean({ + description: + 'Allow scaffolding into a non-empty current directory when combined with --here (or "."). ' + + 'Existing files will not be deleted, but template files may overwrite them.', + required: false, + }), }; static override args = { @@ -103,7 +127,7 @@ export class CreateCommand extends ApifyCommand { async run() { let { actorName } = this.args; - const { template: templateName, skipDependencyInstall, skipGitInit } = this.flags; + const { template: templateName, skipDependencyInstall, skipGitInit, here, force } = this.flags; // --template-archive-url is an internal, undocumented flag that's used // for testing of templates that are not yet published in the manifest @@ -116,37 +140,73 @@ export class CreateCommand extends ApifyCommand { return new Error(`Could not fetch template list from server. Cause: ${err?.message}`); }); - actorName = await ensureValidActorName(actorName); - const cwd = process.cwd(); - let actFolderDir = join(cwd, actorName); - while (true) { - const folderExists = await stat(actFolderDir).catch(() => null); - const folderHasFiles = - folderExists && - (await readdir(actFolderDir) - .then((files) => files.length > 0) - .catch(() => false)); + // Detect "scaffold into current directory" mode: either `apify create .` or `--here`. + const scaffoldHere = here || actorName === '.'; + + let actFolderDir: string; - if (folderExists?.isDirectory() && folderHasFiles) { + if (scaffoldHere) { + // When scaffolding into cwd, derive the Actor name from the cwd basename + // unless an explicit non-"." name was provided. + if (!actorName || actorName === '.') { + const derived = sanitizeActorName(basename(cwd)); + actorName = await ensureValidActorName(derived); + } else { + actorName = await ensureValidActorName(actorName); + } + + actFolderDir = cwd; + + const cwdFiles = await readdir(cwd).catch(() => [] as string[]); + if (cwdFiles.length > 0 && !force) { error({ message: - `Cannot create new Actor, directory '${actorName}' already exists. Please provide a different name.` + - ' You can use "apify init" to create a local Actor environment inside an existing directory.', + `Cannot scaffold into '${cwd}': directory is not empty. ` + + 'Re-run with --force to scaffold anyway (template files may overwrite existing files), ' + + 'or use "apify init" to add Actor scaffolding to an existing project.', }); - - actorName = await ensureValidActorName(); - actFolderDir = join(cwd, actorName); - - continue; + return; } + if (cwdFiles.length > 0 && force) { + warning({ + message: + `Scaffolding into non-empty directory '${cwd}' because --force was set. ` + + 'Template files may overwrite existing files.', + }); + } + } else { + actorName = await ensureValidActorName(actorName); + actFolderDir = join(cwd, actorName); + + while (true) { + const folderExists = await stat(actFolderDir).catch(() => null); + const folderHasFiles = + folderExists && + (await readdir(actFolderDir) + .then((files) => files.length > 0) + .catch(() => false)); + + if (folderExists?.isDirectory() && folderHasFiles) { + error({ + message: + `Cannot create new Actor, directory '${actorName}' already exists. Please provide a different name.` + + ' You can use "apify init" to create a local Actor environment inside an existing directory.', + }); + + actorName = await ensureValidActorName(); + actFolderDir = join(cwd, actorName); + + continue; + } - // Create Actor directory structure - if (!folderExists) { - await mkdir(actFolderDir, { recursive: true }); + // Create Actor directory structure + if (!folderExists) { + await mkdir(actFolderDir, { recursive: true }); + } + break; } - break; } let messages = null; @@ -339,9 +399,12 @@ export class CreateCommand extends ApifyCommand { // Initialize git repository before reporting success, but store result for later let gitInitResult: { success: boolean; error?: Error } = { success: true }; + // Check whether git is already initialized in a parent (for subdir mode) or in cwd itself. + // When scaffolding into cwd, checking `cwd/.git` correctly detects an existing repo. const cwdHasGit = await stat(join(cwd, '.git')).catch(() => null); + const actFolderHasGit = scaffoldHere ? cwdHasGit : await stat(join(actFolderDir, '.git')).catch(() => null); - if (!skipGitInit && !cwdHasGit) { + if (!skipGitInit && !cwdHasGit && !actFolderHasGit) { try { await execWithLog({ cmd: 'git', @@ -363,13 +426,14 @@ export class CreateCommand extends ApifyCommand { actorName, dependenciesInstalled, postCreate: messages?.postCreate ?? null, - gitRepositoryInitialized: !skipGitInit && !cwdHasGit && gitInitResult.success, + gitRepositoryInitialized: !skipGitInit && !cwdHasGit && !actFolderHasGit && gitInitResult.success, installCommandSuggestion, + scaffoldedIntoCwd: scaffoldHere, }), }); // Report git initialization result only if it failed (success already included in success message) - if (!skipGitInit && !cwdHasGit && !gitInitResult.success) { + if (!skipGitInit && !cwdHasGit && !actFolderHasGit && !gitInitResult.success) { // Git init is not critical, so we just warn if it fails warning({ message: `Failed to initialize git repository: ${gitInitResult.error!.message}` }); warning({ message: 'You can manually run "git init" in the Actor directory if needed.' }); diff --git a/src/lib/create-utils.ts b/src/lib/create-utils.ts index 72b973b83..a0890784e 100644 --- a/src/lib/create-utils.ts +++ b/src/lib/create-utils.ts @@ -71,22 +71,35 @@ export function formatCreateSuccessMessage(params: { postCreate?: string | null; gitRepositoryInitialized?: boolean; installCommandSuggestion?: string | null; + scaffoldedIntoCwd?: boolean; }) { - const { actorName, dependenciesInstalled, postCreate, gitRepositoryInitialized, installCommandSuggestion } = params; + const { + actorName, + dependenciesInstalled, + postCreate, + gitRepositoryInitialized, + installCommandSuggestion, + scaffoldedIntoCwd, + } = params; let message = `āœ… Actor '${actorName}' created successfully!`; + // When scaffolded into the current directory, skip the `cd` hint. + const cdLine = scaffoldedIntoCwd ? '' : `cd "${actorName}"\n`; + if (dependenciesInstalled) { - message += `\n\nNext steps:\n\ncd "${actorName}"\napify run`; + message += `\n\nNext steps:\n\n${cdLine}apify run`; } else { const installLine = installCommandSuggestion || 'install dependencies with your package manager'; - message += `\n\nNext steps:\n\ncd "${actorName}"\n${installLine}\napify run`; + message += `\n\nNext steps:\n\n${cdLine}${installLine}\napify run`; } message += `\n\nšŸ’” Tip: Use 'apify push' to deploy your Actor to the Apify platform\nšŸ“– Docs: https://docs.apify.com/platform/actors/development`; if (gitRepositoryInitialized) { - message += `\n🌱 Git repository initialized in '${actorName}'. You can now commit and push your Actor to Git.`; + message += scaffoldedIntoCwd + ? `\n🌱 Git repository initialized in the current directory. You can now commit and push your Actor to Git.` + : `\n🌱 Git repository initialized in '${actorName}'. You can now commit and push your Actor to Git.`; } if (postCreate) {