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
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
56 changes: 42 additions & 14 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,9 +129,11 @@ 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
Expand Down Expand Up @@ -169,14 +180,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 +403,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 +420,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;
});
7 changes: 5 additions & 2 deletions src/lib/hooks/user-confirmations/useSelectFromList.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import select from '@inquirer/select';

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

export type ChoicesType<T = unknown> = Parameters<typeof select<T>>[0]['choices'];

Expand All @@ -14,8 +14,11 @@ interface UseSelectFromListInput<T> {

export const useSelectFromList = stdinCheckWrapper(
async ({ message, choices, pageSize, loop, default: defaultValue }: UseSelectFromListInput<unknown>) => {
const result = await select({ message, choices, pageSize, loop, default: defaultValue });
const result = await select({ message, choices, pageSize, loop, default: defaultValue }, promptContext);

return result;
},
{
errorMessageForStdin: 'Please provide the selection using the command options.',
},
) as <T>(input: UseSelectFromListInput<T>) => Promise<T>;
4 changes: 2 additions & 2 deletions src/lib/hooks/user-confirmations/useUserInput.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 UseUserInputInput {
message: string;
Expand All @@ -10,7 +10,7 @@ interface UseUserInputInput {

export const useUserInput = stdinCheckWrapper(
async ({ message, validate, default: defaultValue }: UseUserInputInput) => {
const result = await input({ message, validate, default: defaultValue });
const result = await input({ message, validate, default: defaultValue }, promptContext);

return result;
},
Expand Down
Loading