Skip to content
Open
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
20 changes: 17 additions & 3 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,9 +432,9 @@ DESCRIPTION
path.

When no path is provided, validates all schemas found in '.actor/actor.json':
- Input schema (from "input" key or default locations)
- Dataset schema (from "storages.dataset")
- Output schema (from "output")
- Input schema (from "input" or "inputSchema" key, or default locations)
- Dataset schema (from "storages.dataset" or "storages.datasets")
- Output schema (from "output" or "outputSchema")
- Key-Value Store schema (from "storages.keyValueStore")

USAGE
Expand All @@ -445,6 +445,18 @@ ARGUMENTS
validates all schemas in '.actor/actor.json'.
```

##### `apify actors doctor`

```sh
DESCRIPTION
Run local diagnostics on the Actor project in the current directory.
Checks actor.json structure, schema references, and schema validity. No
network calls are made.

USAGE
$ apify actors doctor
```

##### `apify actor`

```sh
Expand Down Expand Up @@ -680,6 +692,8 @@ SUBCOMMANDS
actors call Executes Actor remotely using your authenticated
account.
actors build Creates a new build of the Actor.
actors doctor Run local diagnostics on the Actor project in the
current directory.
```

##### `apify actors ls`
Expand Down
1 change: 1 addition & 0 deletions scripts/generate-cli-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const categories: Record<string, CommandsInCategory[]> = {
{ command: Commands.init },
{ command: Commands.run },
{ command: Commands.validateSchema },
{ command: Commands.actorsDoctor },

{ command: Commands.actor },
{ command: Commands.actorCalculateMemory },
Expand Down
2 changes: 2 additions & 0 deletions src/commands/actors/_index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
import { ActorsBuildCommand } from './build.js';
import { ActorsCallCommand } from './call.js';
import { ActorsDoctorCommand } from './doctor.js';
import { ActorsInfoCommand } from './info.js';
import { ActorsLsCommand } from './ls.js';
import { ActorsPullCommand } from './pull.js';
Expand Down Expand Up @@ -31,6 +32,7 @@ export class ActorsIndexCommand extends ApifyCommand<typeof ActorsIndexCommand>
ActorsInfoCommand,
ActorsCallCommand,
ActorsBuildCommand,
ActorsDoctorCommand,
];

async run() {
Expand Down
295 changes: 295 additions & 0 deletions src/commands/actors/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import process from 'node:process';

import chalk from 'chalk';

import { validateInputSchema } from '@apify/input_schema';
import { getActorSchemaValidator } from '@apify/json_schemas';

import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
import { CommandExitCodes, DEPRECATED_LOCAL_CONFIG_NAME, LOCAL_CONFIG_PATH } from '../../lib/consts.js';
import {
readDatasetSchemas,
readInputSchema,
readOutputSchema,
readStorageSchema,
validateDatasetSchema,
validateKvsSchema,
validateOutputSchema,
} from '../../lib/input_schema.js';
import { simpleLog } from '../../lib/outputs.js';
import { Ajv2019, validateActorName } from '../../lib/utils.js';

type DiagnosticSeverity = 'error' | 'warning' | 'pass';

interface Diagnostic {
severity: DiagnosticSeverity;
code: string;
message: string;
}

// Strip ASCII control chars (0x00–0x1F, 0x7F) from terminal output.
// This prevents escape-sequence injection from project-controlled values
// such as actor names or schema paths in actor.json.
function sanitizeForTerminal(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/[\u0000-\u001f\u007f]/g, '');
}

function renderDiagnostics(diagnostics: Diagnostic[]): void {
const lines = diagnostics.map((d) => {
const icon =
d.severity === 'pass' ? chalk.green('✓') : d.severity === 'warning' ? chalk.yellow('⚠') : chalk.red('✗');
return ` ${icon} ${sanitizeForTerminal(d.message)}`;
});

simpleLog({ message: lines.join('\n') });
}

async function gatherDiagnostics(cwd: string): Promise<Diagnostic[]> {
const diagnostics: Diagnostic[] = [];

const deprecatedConfigPath = join(cwd, DEPRECATED_LOCAL_CONFIG_NAME);
const actorJsonPath = join(cwd, LOCAL_CONFIG_PATH);

if (existsSync(deprecatedConfigPath) && !existsSync(actorJsonPath)) {
diagnostics.push({
severity: 'warning',
code: 'DEPRECATED_CONFIG',
message: `Deprecated "apify.json" detected. Run "apify actors push" to trigger automatic migration to ".actor/actor.json".`,
});
}

if (!existsSync(actorJsonPath)) {
diagnostics.push({
severity: 'error',
code: 'ACTOR_JSON_NOT_FOUND',
message: `".actor/actor.json" not found. Run "apify actors pull" or "apify create" to initialise an Actor project.`,
});
return diagnostics;
}

diagnostics.push({ severity: 'pass', code: 'ACTOR_JSON_FOUND', message: `".actor/actor.json" found.` });

let actorConfig: Record<string, unknown>;

try {
actorConfig = JSON.parse(readFileSync(actorJsonPath, { encoding: 'utf-8' }));
} catch (ex) {
diagnostics.push({
severity: 'error',
code: 'ACTOR_JSON_PARSE_FAILED',
message: `".actor/actor.json" is not valid JSON: ${(ex as Error).message}`,
});
return diagnostics;
}

const validate = getActorSchemaValidator();
if (!validate(actorConfig)) {
for (const ajvError of validate.errors ?? []) {
const path = ajvError.instancePath ? ` at ${ajvError.instancePath}` : '';
diagnostics.push({
severity: 'error',
code: 'ACTOR_JSON_SCHEMA_INVALID',
message: `".actor/actor.json" schema error${path}: ${ajvError.message}`,
});
}
// All subsequent checks require a valid actor object (access actorConfig.name, .storages, etc.).
// Return early to prevent runtime errors on non-object values such as null, [], "string", 123.
return diagnostics;
}

diagnostics.push({ severity: 'pass', code: 'ACTOR_JSON_VALID', message: `".actor/actor.json" is valid.` });

if (typeof actorConfig.name === 'string') {
try {
validateActorName(actorConfig.name);
diagnostics.push({
severity: 'pass',
code: 'ACTOR_NAME_VALID',
message: `Actor name "${actorConfig.name}" is valid.`,
});
} catch (ex) {
diagnostics.push({
severity: 'error',
code: 'ACTOR_NAME_INVALID',
message: `Actor name "${actorConfig.name}" is invalid: ${(ex as Error).message}`,
});
}
}

// Input schema — supports both `input` and `inputSchema` fields
try {
const { inputSchema } = await readInputSchema({ cwd, throwOnMissing: true });
if (inputSchema) {
try {
const ajv = new Ajv2019({ strict: false });
validateInputSchema(ajv, inputSchema);
diagnostics.push({ severity: 'pass', code: 'INPUT_SCHEMA_VALID', message: `Input schema is valid.` });
} catch (ex) {
diagnostics.push({
severity: 'error',
code: 'INPUT_SCHEMA_INVALID',
message: `Input schema is invalid: ${(ex as Error).message}`,
});
}
}
} catch (ex) {
if (ex instanceof SyntaxError) {
diagnostics.push({
severity: 'error',
code: 'INPUT_SCHEMA_PARSE_FAILED',
message: `Input schema file contains malformed JSON: ${ex.message}`,
});
} else {
diagnostics.push({
severity: 'error',
code: 'INPUT_SCHEMA_REF_MISSING',
message: (ex as Error).message,
});
}
}

// Dataset schemas — supports both `storages.dataset` and `storages.datasets`
const datasetEntries = readDatasetSchemas({ cwd });
if (datasetEntries) {
for (const entry of datasetEntries) {
const label = entry.form === 'singular' ? 'Dataset schema' : `Dataset schema "${entry.name}"`;
if (entry.errorCode === 'ref-missing') {
diagnostics.push({
severity: 'error',
code: 'DATASET_SCHEMA_REF_MISSING',
message: entry.errorMessage!,
});
} else if (entry.errorCode === 'parse-failed') {
diagnostics.push({
severity: 'error',
code: 'DATASET_SCHEMA_PARSE_FAILED',
message: entry.errorMessage!,
});
} else if (entry.schema) {
try {
validateDatasetSchema(entry.schema);
diagnostics.push({ severity: 'pass', code: 'DATASET_SCHEMA_VALID', message: `${label} is valid.` });
} catch (ex) {
diagnostics.push({
severity: 'error',
code: 'DATASET_SCHEMA_INVALID',
message: `${label} is invalid: ${(ex as Error).message}`,
});
}
}
}
}

// Output schema — supports both `output` and `outputSchema` fields
try {
const result = readOutputSchema({ cwd, throwOnMissing: true });
if (result) {
try {
validateOutputSchema(result.outputSchema);
diagnostics.push({ severity: 'pass', code: 'OUTPUT_SCHEMA_VALID', message: `Output schema is valid.` });
} catch (ex) {
diagnostics.push({
severity: 'error',
code: 'OUTPUT_SCHEMA_INVALID',
message: `Output schema is invalid: ${(ex as Error).message}`,
});
}
}
} catch (ex) {
if (ex instanceof SyntaxError) {
diagnostics.push({
severity: 'error',
code: 'OUTPUT_SCHEMA_PARSE_FAILED',
message: `Output schema file contains malformed JSON: ${ex.message}`,
});
} else {
diagnostics.push({
severity: 'error',
code: 'OUTPUT_SCHEMA_REF_MISSING',
message: (ex as Error).message,
});
}
}

// Key-Value Store schema — supports `storages.keyValueStore`
try {
const result = readStorageSchema({ cwd, key: 'keyValueStore', label: 'Key-Value Store', throwOnMissing: true });
if (result) {
try {
validateKvsSchema(result.schema);
diagnostics.push({
severity: 'pass',
code: 'KVS_SCHEMA_VALID',
message: `Key-Value Store schema is valid.`,
});
} catch (ex) {
diagnostics.push({
severity: 'error',
code: 'KVS_SCHEMA_INVALID',
message: `Key-Value Store schema is invalid: ${(ex as Error).message}`,
});
}
}
} catch (ex) {
if (ex instanceof SyntaxError) {
diagnostics.push({
severity: 'error',
code: 'KVS_SCHEMA_PARSE_FAILED',
message: `Key-Value Store schema file contains malformed JSON: ${ex.message}`,
});
} else {
diagnostics.push({
severity: 'error',
code: 'KVS_SCHEMA_REF_MISSING',
message: (ex as Error).message,
});
}
}

return diagnostics;
}

export class ActorsDoctorCommand extends ApifyCommand<typeof ActorsDoctorCommand> {
static override name = 'doctor' as const;

static override description =
`Run local diagnostics on the Actor project in the current directory.\n` +
`Checks actor.json structure, schema references, and schema validity. No network calls are made.`;

static override group = 'Apify Console';

static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-actors-doctor';

static override examples = [
{
description: 'Check the Actor project in the current directory',
command: 'apify actors doctor',
},
];

async run() {
const cwd = process.cwd();
const diagnostics = await gatherDiagnostics(cwd);

renderDiagnostics(diagnostics);

const errorCount = diagnostics.filter((d) => d.severity === 'error').length;
const warningCount = diagnostics.filter((d) => d.severity === 'warning').length;

if (errorCount === 0 && warningCount === 0) {
simpleLog({ message: chalk.green('\nNo issues found.') });
} else {
const parts: string[] = [];
if (errorCount > 0) parts.push(chalk.red(`${errorCount} error${errorCount !== 1 ? 's' : ''}`));
if (warningCount > 0) parts.push(chalk.yellow(`${warningCount} warning${warningCount !== 1 ? 's' : ''}`));
simpleLog({ message: `\n${parts.join(', ')}` });
}

if (errorCount > 0) {
process.exitCode = CommandExitCodes.InvalidActorJson;
}
}
}
Loading
Loading