Skip to content
4 changes: 3 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ DESCRIPTION
directory.

USAGE
$ apify create [actorName]
$ apify create [actorName] [--json]
[-l javascript|js|typescript|ts|python|py]
[--omit-optional-deps] [--skip-dependency-install]
[--skip-git-init] [-t <value>]
Expand All @@ -287,6 +287,8 @@ ARGUMENTS
actorName Name of the Actor and its directory.

FLAGS
--json Format the command
output as JSON.
-l, --language=<option> Filter templates by
programming language. Ignored when --template is
provided.
Expand Down
1 change: 1 addition & 0 deletions skills/apify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ See https://apify.com/auth.md for how to authenticate. Do not assume `APIFY_TOKE
## Structured output

- `--json` is supported on most list/info commands (`apify actors ls --json`, `apify actors info <id> --json`, `apify datasets info <id> --json`, `apify runs ls --json`, etc.). Use it and parse with `jq`; don't scrape the human table.
- `apify create <name> --template <template> --json` prints `{ dir, actorJsonPath, template, source, nextSteps, postCreate, gitRepositoryInitialized }` on stdout. Everything else goes to stderr, so stdout is safe to pipe into `jq`. `postCreate` is non-null when the template needs extra setup before `apify run` works.
- List commands paginate — control with `--limit` / `--offset` (and `--desc`).
- Dataset items: `apify datasets get-items <datasetId> --format json`. Use `--limit` / `--offset`.

Expand Down
82 changes: 63 additions & 19 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
SUPPORTED_NODEJS_VERSION,
} from '../lib/consts.js';
import {
buildNextSteps,
enhanceReadmeWithLocalSuffix,
ensureValidActorName,
formatCreateSuccessMessage,
Expand All @@ -36,6 +37,7 @@ import {
getJsonFileContent,
isNodeVersionSupported,
isPythonVersionSupported,
printJsonToStdout,
setLocalConfig,
setLocalEnv,
} from '../lib/utils.js';
Expand Down Expand Up @@ -111,6 +113,13 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
description: 'Skip initializing a git repository in the Actor directory.',
required: false,
}),
origin: Flags.string({
description: 'Where the command was invoked from. Used for funnel telemetry.',
choices: ['console', 'cli'],
default: 'cli',
required: false,
hidden: true,
}),
};

static override args = {
Expand All @@ -120,15 +129,29 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
}),
};

static override enableJsonFlag = true;

async run() {
let { actorName } = this.args;
const { template: templateName, useCase, language, skipDependencyInstall, skipGitInit } = this.flags;
const { template: templateName, useCase, language, skipDependencyInstall, skipGitInit, origin, json } = 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
let { templateArchiveUrl } = this.flags;
let skipOptionalDeps = false;

// `--json` implies non-interactive: a caller parsing stdout cannot answer a prompt. Reject
// before creating any directories so a failed run leaves nothing behind.
if (json && !actorName) {
throw new Error('--json runs non-interactively. Pass the Actor name as an argument.');
}

if (json && !templateName && !templateArchiveUrl) {
throw new Error(
'--json runs non-interactively. Pass --template <name>; run `apify templates ls` to list values.',
);
}

// Start fetching manifest immediately to prevent
// annoying delays that sometimes happen on CLI startup.
const manifestPromise = fetchManifest().catch((err) => {
Expand All @@ -149,11 +172,15 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
.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.',
});
const message =
`Cannot create new Actor, directory '${actorName}' already exists. Provide a different name.` +
' To create a local Actor environment inside an existing directory, use "apify init".';

if (json) {
throw new Error(message);
}

error({ message });

actorName = await ensureValidActorName();
actFolderDir = join(cwd, actorName);
Expand All @@ -169,14 +196,17 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
}

let messages = null;
let templateId: string | null = null;

this.telemetryData.create = {
fromArchiveUrl: !!templateArchiveUrl,
origin,
};

if (!templateArchiveUrl) {
const templateDefinition = await getTemplateDefinition(templateName, manifestPromise, { useCase, language });
({ archiveUrl: templateArchiveUrl, messages } = templateDefinition);
templateId = templateDefinition.id;
this.telemetryData.create.templateId = templateDefinition.id;
this.telemetryData.create.templateName = templateDefinition.name;
this.telemetryData.create.templateLanguage = templateDefinition.category;
Expand Down Expand Up @@ -389,8 +419,9 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
// Initialize git repository before reporting success, but store result for later
let gitInitResult: { success: boolean; error?: Error } = { success: true };
const cwdHasGit = await stat(join(cwd, '.git')).catch(() => null);
const gitInitAttempted = !skipGitInit && !cwdHasGit;

if (!skipGitInit && !cwdHasGit) {
if (gitInitAttempted) {
try {
await execWithLog({
cmd: 'git',
Expand All @@ -405,20 +436,33 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
// Suggest install command if dependencies were not installed
const installCommandSuggestion = !dependenciesInstalled ? await getInstallCommandSuggestion(actFolderDir) : null;

// Success message with extra empty line
simpleLog({ message: '' });
success({
message: formatCreateSuccessMessage({
actorName,
dependenciesInstalled,
const gitRepositoryInitialized = gitInitAttempted && gitInitResult.success;

if (json) {
printJsonToStdout({
dir: actFolderDir,
actorJsonPath: join(actFolderDir, LOCAL_CONFIG_PATH),
template: templateId,
source: 'apify',
nextSteps: buildNextSteps({ actorName, dependenciesInstalled, installCommandSuggestion }),
// Some templates need extra setup (e.g. "playwright install") before "apify run" works.
postCreate: messages?.postCreate ?? null,
gitRepositoryInitialized: !skipGitInit && !cwdHasGit && gitInitResult.success,
installCommandSuggestion,
}),
});
gitRepositoryInitialized,
});
} else {
simpleLog({ message: '' });
success({
message: formatCreateSuccessMessage({
actorName,
dependenciesInstalled,
postCreate: messages?.postCreate ?? null,
gitRepositoryInitialized,
installCommandSuggestion,
}),
});
}

// Report git initialization result only if it failed (success already included in success message)
if (!skipGitInit && !cwdHasGit && !gitInitResult.success) {
if (gitInitAttempted && !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.' });
Expand Down
3 changes: 2 additions & 1 deletion src/commands/templates/ls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { fetchManifest } from '@apify/actor-templates';
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js';
import { info, simpleLog } from '../../lib/outputs.js';
import { languageLabel } from '../../lib/templates/consts.js';
import { printJsonToStdout } from '../../lib/utils.js';

const table = new ResponsiveTable({
Expand Down Expand Up @@ -50,7 +51,7 @@ export class TemplatesLsCommand extends ApifyCommand<typeof TemplatesLsCommand>
table.pushRow({
Template: template.name,
Label: template.label,
Language: template.category,
Language: languageLabel(template.category),
'Use cases': (template.useCases ?? []).join(', '),
});
}
Expand Down
5 changes: 5 additions & 0 deletions src/lib/command-framework/apify-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import widestLine from 'widest-line';
import wrapAnsi from 'wrap-ansi';

import { cachedStdinInput } from '../../entrypoints/_shared.js';
import { keepStdoutClean } from '../exec.js';
import { detectAiAgent, detectCi, detectIsInteractive } from '../hooks/telemetry/detectEnvironment.js';
import type { TrackEventMap } from '../hooks/telemetry/trackEvent.js';
import { trackEvent } from '../hooks/telemetry/trackEvent.js';
Expand Down Expand Up @@ -338,6 +339,10 @@ export abstract class ApifyCommand<T extends typeof BuiltApifyCommand = typeof B
} else {
this.flags.json = false;
}

if (this.flags.json) {
keepStdoutClean();
}
}

const missingRequiredArgs = new Map<string, TaggedArgBuilder<ArgTag, unknown>>();
Expand Down
29 changes: 22 additions & 7 deletions src/lib/create-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ export async function getTemplateDefinition(
if (manifest instanceof Error) throw manifest;

if (maybeTemplateName) {
const templateDefinition = manifest.templates.find((t) => t.name === maybeTemplateName);
// Accept both the template name and its id — `--json` output and older docs reference the id.
const templateDefinition = manifest.templates.find(
(t) => t.name === maybeTemplateName || t.id === maybeTemplateName,
);
if (!templateDefinition) {
throw new Error(`Could not find the selected template: ${maybeTemplateName} in the list of templates.`);
}
Expand Down Expand Up @@ -77,6 +80,22 @@ export async function enhanceReadmeWithLocalSuffix(readmePath: string, manifestP
}
}

export function buildNextSteps(params: {
actorName: string;
dependenciesInstalled: boolean;
installCommandSuggestion?: string | null;
}): string[] {
const { actorName, dependenciesInstalled, installCommandSuggestion } = params;

const steps = [`cd "${actorName}"`];
if (!dependenciesInstalled) {
steps.push(installCommandSuggestion || 'install dependencies with your package manager');
}
steps.push('apify run');

return steps;
}

export function formatCreateSuccessMessage(params: {
actorName: string;
dependenciesInstalled: boolean;
Expand All @@ -88,12 +107,8 @@ export function formatCreateSuccessMessage(params: {

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

if (dependenciesInstalled) {
message += `\n\nNext steps:\n\ncd "${actorName}"\napify run`;
} else {
const installLine = installCommandSuggestion || 'install dependencies with your package manager';
message += `\n\nNext steps:\n\ncd "${actorName}"\n${installLine}\napify run`;
}
const nextSteps = buildNextSteps({ actorName, dependenciesInstalled, installCommandSuggestion });
message += `\n\nNext steps:\n\n${nextSteps.join('\n')}`;

message += `\n\n💡 Tip: Use 'apify push' to deploy your Actor to the Apify platform\n📖 Docs: https://docs.apify.com/platform/actors/development`;

Expand Down
11 changes: 10 additions & 1 deletion src/lib/exec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import process from 'node:process';

import { Result } from '@sapphire/result';
import { execa, type ExecaError, type Options } from 'execa';

import { normalizeExecutablePath } from './hooks/runtimes/utils.js';
import { error, run } from './outputs.js';
import { cliDebugPrint } from './utils/cliDebugPrint.js';

let childStdout: 'inherit' | typeof process.stderr = 'inherit';

/** Route child process stdout to our stderr, so it cannot corrupt a machine-readable payload. */
export function keepStdoutClean() {
childStdout = process.stderr;
}

interface SpawnPromisedInternalOptions {
/**
* Signals that should be forwarded from the parent process to the spawned
Expand All @@ -31,7 +40,7 @@ const spawnPromised = async (
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'],
stdout: process.env.APIFY_NO_LOGS_IN_TESTS ? ['pipe'] : ['pipe', childStdout],
stderr: process.env.APIFY_NO_LOGS_IN_TESTS ? ['pipe'] : ['pipe', 'inherit'],
verbose: process.env.APIFY_CLI_DEBUG ? 'full' : undefined,
});
Expand Down
1 change: 1 addition & 0 deletions src/lib/hooks/telemetry/trackEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface CliCommandEvent {
templateId?: string;
templateName?: string;
templateLanguage?: string;
origin?: 'console' | 'cli';
};

push?: {
Expand Down
14 changes: 9 additions & 5 deletions src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import process from 'node:process';

import { isCI } from 'ci-info';

import { useStdin } from '../useStdin.js';

/**
* Inquirer renders to stdout by default. Prompts are UI, not command output, so they must stay off
* stdout, otherwise they corrupt the payload of commands invoked with `--json`.
*/
export const promptContext = { output: process.stderr };

export interface StdinCheckWrapperInput<ReturnedType> extends StdinCheckWrapperOptions {
/**
* When set, this value will be used in environments where stdin is not available.
Expand Down Expand Up @@ -45,11 +53,7 @@ export function stdinCheckWrapper<Fn extends (...args: any[]) => any>(

if (isCI || (!isTTY && !hasData)) {
if (typeof casted.providedConfirmFromStdin === 'undefined') {
throw new Error(
casted.errorMessageForStdin ??
errorMessageForStdin ??
`Please use the --${ConfirmFlag}/--${NoConfirmFlag} flags to confirm the action.`,
);
throw new Error(casted.errorMessageForStdin ?? errorMessageForStdin);
}

return casted.providedConfirmFromStdin;
Expand Down
21 changes: 12 additions & 9 deletions src/lib/hooks/user-confirmations/useInputConfirmation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import input from '@inquirer/input';

import { stdinCheckWrapper } from './_stdinCheckWrapper.js';
import { promptContext, stdinCheckWrapper } from './_stdinCheckWrapper.js';

interface UseInputConfirmationInput {
message: string;
Expand All @@ -10,16 +10,19 @@ interface UseInputConfirmationInput {

export const useInputConfirmation = stdinCheckWrapper(
async ({ message, expectedValue, failureMessage }: UseInputConfirmationInput) => {
const result = await input({
message,
validate(value) {
if (value === expectedValue) {
return true;
}
const result = await input(
{
message,
validate(value) {
if (value === expectedValue) {
return true;
}

return failureMessage ?? 'That is not the correct input!';
return failureMessage ?? 'That is not the correct input!';
},
},
});
promptContext,
);

return result;
},
Expand Down
4 changes: 2 additions & 2 deletions src/lib/hooks/user-confirmations/useMaskedInput.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import password from '@inquirer/password';

import { stdinCheckWrapper } from './_stdinCheckWrapper.js';
import { promptContext, stdinCheckWrapper } from './_stdinCheckWrapper.js';

interface UseMaskedInputInput {
message: string;
mask?: boolean | string;
}

export const useMaskedInput = stdinCheckWrapper(async ({ message, mask }: UseMaskedInputInput) => {
const result = await password({ message, mask: mask ?? '*' });
const result = await password({ message, mask: mask ?? '*' }, promptContext);

return result;
});
Loading