From ef2d62b12cbab98ae6fdcf93fd3bc1dd3ea7b6ba Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Fri, 23 May 2025 17:09:46 +0300 Subject: [PATCH 1/2] chore: hidden help command --- eslint.config.mjs | 6 + features/help-command.feature.md | 74 ++++++++++++ features/test-implementations/0.world.ts | 16 +-- features/test-implementations/1.setup.ts | 3 +- features/test-implementations/2.execution.ts | 47 +++++++- features/test-implementations/3.results.ts | 28 +++-- package.json | 8 +- src/commands/_register.ts | 19 ++- src/commands/help.ts | 68 +++++++++++ src/entrypoints/_shared.ts | 13 ++- src/lib/command-framework/apify-command.ts | 116 ++++++++++++++++++- src/lib/command-framework/args.ts | 7 ++ tsconfig.eslint.json | 1 + yarn.lock | 8 ++ 14 files changed, 385 insertions(+), 29 deletions(-) create mode 100644 features/help-command.feature.md create mode 100644 src/commands/help.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 6e7d01aad..5da613825 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -86,4 +86,10 @@ export default [ ], }, }, + { + files: ['features/**/*'], + rules: { + 'import/no-extraneous-dependencies': 'off', + }, + }, ]; diff --git a/features/help-command.feature.md b/features/help-command.feature.md new file mode 100644 index 000000000..86e5f7258 --- /dev/null +++ b/features/help-command.feature.md @@ -0,0 +1,74 @@ +# Feature: Help command (`apify help`) + +- As a CLI user +- I want to get easy help messages + +## Rule: `apify help` prints the whole help message + +### Example: call `apify help` with no commands + +- When I run anywhere: + ``` + $ apify help + ``` +- Then I can read text on stdout: + ``` + https://apify.com/contact + ``` + +### Example: call `actor help` with no commands + +- When I run anywhere: + ``` + $ actor help + ``` +- Then I can read text on stdout: + ``` + https://apify.com/contact + ``` + +### Example: call `apify help help` prints the main message + +- When I run anywhere: + ``` + $ apify help help + ``` +- Then I can read text on stdout: + ``` + https://apify.com/contact + ``` + +## Rule: `apify help ` prints a command's help message + +### Example: `apify help -h` + +- When I run anywhere: + ``` + $ apify help -h + ``` +- Then I can read text on stdout: + ``` + Prints out help about a command, or all available commands. + ``` + +### Example: `apify help run` + +- When I run anywhere: + ``` + $ apify help run + ``` +- Then I can read text on stdout: + ``` + Executes Actor locally with simulated Apify environment variables. + ``` + +### Example: `apify run --help` returns the same message as `apify help run` + +- When I run anywhere: + ``` + $ apify run --help + ``` +- Then I can read text on stdout: + ``` + Executes Actor locally with simulated Apify environment variables. + ``` diff --git a/features/test-implementations/0.world.ts b/features/test-implementations/0.world.ts index 74c3f3fbf..f28167148 100644 --- a/features/test-implementations/0.world.ts +++ b/features/test-implementations/0.world.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'; import type { IWorld } from '@cucumber/cucumber'; import { Result } from '@sapphire/result'; import type { ApifyClient } from 'apify-client'; -import { type Options, type ExecaError, type Result as ExecaResult, execaNode } from 'execa'; +import { type ExecaError, execaNode, type Options, type Result as ExecaResult } from 'execa'; type DynamicOptions = { -readonly [P in keyof Options]: Options[P]; @@ -49,8 +49,8 @@ export interface TestWorld extends IWorld { */ export const ProjectRoot = new URL('../../', import.meta.url); -export const DevRunFile = new URL('./src/entrypoints/apify.ts', ProjectRoot); - +export const ApifyDevRunFile = new URL('./src/entrypoints/apify.ts', ProjectRoot); +export const ActorDevRunFile = new URL('./src/entrypoints/actor.ts', ProjectRoot); export const TestTmpRoot = new URL('./test/tmp/', ProjectRoot); await mkdir(TestTmpRoot, { recursive: true }); @@ -80,7 +80,7 @@ export async function executeCommand({ // step 1: get the first element, and make sure it starts with `apify` const [command] = commandToRun; - if (!command.startsWith('apify')) { + if (!command.startsWith('apify') && !command.startsWith('actor')) { // TODO: maybe try to parse these commands out and provide stdin that way, but for now, its better to get the writer to use the existing rules if (command.startsWith('echo') || command.startsWith('jo')) { throw new RangeError( @@ -88,10 +88,12 @@ export async function executeCommand({ ); } - throw new RangeError(`Command must start with 'apify', received: ${command}`); + throw new RangeError(`Command must start with 'apify' or 'actor', received: ${command}`); } - const cleanCommand = command.replace(/^apify/, '').trim(); + const devRunFile = command.startsWith('apify') ? ApifyDevRunFile : ActorDevRunFile; + + const cleanCommand = command.replace(/^apify|actor/, '').trim(); const options: DynamicOptions = { cwd, @@ -138,7 +140,7 @@ export async function executeCommand({ >(async () => { const process = execaNode( tsxCli, - [fileURLToPath(DevRunFile), ...commandArguments], + [fileURLToPath(devRunFile), ...commandArguments], options as { cwd: typeof cwd; input: typeof stdin }, ); diff --git a/features/test-implementations/1.setup.ts b/features/test-implementations/1.setup.ts index e214556b6..d769fbfa0 100644 --- a/features/test-implementations/1.setup.ts +++ b/features/test-implementations/1.setup.ts @@ -4,6 +4,7 @@ import { readFile, rm, writeFile } from 'node:fs/promises'; import { AfterAll, Given, setDefaultTimeout } from '@cucumber/cucumber'; import { ApifyClient } from 'apify-client'; +import { getApifyClientOptions } from '../../src/lib/utils.js'; import { assertWorldIsLoggedIn, assertWorldIsValid, @@ -12,7 +13,6 @@ import { TestTmpRoot, type TestWorld, } from './0.world'; -import { getApifyClientOptions } from '../../src/lib/utils'; setDefaultTimeout(20_000); @@ -230,7 +230,6 @@ Given(/the local actor is pushed to the Apify platform/i, { timeout: const extraEnv: Record = {}; if (this.authStatePath) { - // eslint-disable-next-line no-underscore-dangle extraEnv.__APIFY_INTERNAL_TEST_AUTH_PATH__ = this.authStatePath; } diff --git a/features/test-implementations/2.execution.ts b/features/test-implementations/2.execution.ts index 859a9ddf8..65f7b4e52 100644 --- a/features/test-implementations/2.execution.ts +++ b/features/test-implementations/2.execution.ts @@ -10,6 +10,51 @@ import { type TestWorld, } from './0.world'; +When(/i run anywhere:?$/i, async function (commandBlock: string) { + if (typeof commandBlock !== 'string') { + throw new TypeError('When using the `I run anywhere` step, you must provide a text block containing a command'); + } + + const extraEnv: Record = {}; + + if (this.authStatePath) { + extraEnv.__APIFY_INTERNAL_TEST_AUTH_PATH__ = this.authStatePath; + } + + const result = await executeCommand({ + rawCommand: commandBlock, + env: extraEnv, + }); + + if (result.isOk()) { + const value = result.unwrap(); + + if (this.testResults) { + console.error(`\n Warning: Overwriting existing test results: ${JSON.stringify(this.testResults)}`); + } + + this.testResults = { + exitCode: value.exitCode!, + stderr: value.stderr, + stdout: value.stdout, + runResults: null, + }; + } else { + const error = result.unwrapErr(); + + if (this.testResults) { + console.error(`\n Warning: Overwriting existing test results: ${JSON.stringify(this.testResults)}`); + } + + this.testResults = { + exitCode: error.exitCode!, + stderr: error.stderr, + stdout: error.stdout, + runResults: null, + }; + } +}); + When(/i run:?$/i, async function (commandBlock: string) { assertWorldIsValid(this); @@ -20,7 +65,6 @@ When(/i run:?$/i, async function (commandBlock: string) { const extraEnv: Record = {}; if (this.authStatePath) { - // eslint-disable-next-line no-underscore-dangle extraEnv.__APIFY_INTERNAL_TEST_AUTH_PATH__ = this.authStatePath; } @@ -103,7 +147,6 @@ When(/i run with captured data/i, async function (commandBlock: strin const extraEnv: Record = {}; if (this.authStatePath) { - // eslint-disable-next-line no-underscore-dangle extraEnv.__APIFY_INTERNAL_TEST_AUTH_PATH__ = this.authStatePath; } diff --git a/features/test-implementations/3.results.ts b/features/test-implementations/3.results.ts index 143d16244..8ae41d3cd 100644 --- a/features/test-implementations/3.results.ts +++ b/features/test-implementations/3.results.ts @@ -77,7 +77,6 @@ Then(/i don't see any node\.js exception/i, function () { }); Then(/i can read text on stderr/i, function (expectedStdout: string) { - assertWorldIsValid(this); assertWorldHasRanCommand(this); if (typeof expectedStdout !== 'string') { @@ -87,9 +86,16 @@ Then(/i can read text on stderr/i, function (expectedStdout: string) } const lowercasedResult = this.testResults.stderr.toLowerCase(); - const lowercasedExpected = replaceMatchersInString(expectedStdout, { - testActorName: this.testActor.name, - }).toLowerCase(); + + let lowercasedExpected = expectedStdout; + + if (this.testActor) { + lowercasedExpected = replaceMatchersInString(lowercasedExpected, { + testActorName: this.testActor.name, + }); + } + + lowercasedExpected = lowercasedExpected.toLowerCase(); strictEqual( lowercasedResult.includes(lowercasedExpected), @@ -99,7 +105,6 @@ Then(/i can read text on stderr/i, function (expectedStdout: string) }); Then(/i can read text on stdout/i, function (expectedStdout: string) { - assertWorldIsValid(this); assertWorldHasRanCommand(this); if (typeof expectedStdout !== 'string') { @@ -109,9 +114,16 @@ Then(/i can read text on stdout/i, function (expectedStdout: string) } const lowercasedResult = this.testResults.stdout.toLowerCase(); - const lowercasedExpected = replaceMatchersInString(expectedStdout, { - testActorName: this.testActor.name, - }).toLowerCase(); + + let lowercasedExpected = expectedStdout; + + if (this.testActor) { + lowercasedExpected = replaceMatchersInString(lowercasedExpected, { + testActorName: this.testActor.name, + }); + } + + lowercasedExpected = lowercasedExpected.toLowerCase(); strictEqual( lowercasedResult.includes(lowercasedExpected), diff --git a/package.json b/package.json index 77e468035..a0c24d896 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,13 @@ "types": "./dist/index.d.ts", "type": "module", "scripts": { - "dev": "tsx ./src/entrypoints/apify.ts", + "dev:apify": "tsx ./src/entrypoints/apify.ts", + "dev:actor": "tsx ./src/entrypoints/actor.ts", "test": "vitest run", "test-python": "vitest run -t '.*\\[python\\]'", "test:cucumber": "cross-env NODE_OPTIONS=\"--import tsx\" cucumber-js", - "lint": "eslint src test scripts --ext .ts,.cjs,.mjs", - "lint:fix": "eslint src test scripts --fix --ext .ts,.cjs,.mjs", + "lint": "eslint src test scripts features --ext .ts,.cjs,.mjs", + "lint:fix": "eslint src test scripts features --fix --ext .ts,.cjs,.mjs", "format": "biome format . && prettier --check \"**/*.{md,yml,yaml}\"", "format:fix": "biome format --write . && prettier --write \"**/*.{md,yml,yaml}\"", "clean": "rimraf dist", @@ -69,6 +70,7 @@ "@sapphire/duration": "^1.1.2", "@sapphire/result": "^2.7.2", "@sapphire/timestamp": "^1.0.3", + "@skyra/jaro-winkler": "^1.1.1", "adm-zip": "~0.5.15", "ajv": "~8.17.1", "apify-client": "^2.11.0", diff --git a/src/commands/_register.ts b/src/commands/_register.ts index ea8bbad93..836b5f608 100644 --- a/src/commands/_register.ts +++ b/src/commands/_register.ts @@ -1,5 +1,11 @@ import type { BuiltApifyCommand } from '../lib/command-framework/apify-command.js'; import { ActorIndexCommand } from './actor/_index.js'; +import { ActorChargeCommand } from './actor/charge.js'; +import { ActorGetInputCommand } from './actor/get-input.js'; +import { ActorGetPublicUrlCommand } from './actor/get-public-url.js'; +import { ActorGetValueCommand } from './actor/get-value.js'; +import { ActorPushDataCommand } from './actor/push-data.js'; +import { ActorSetValueCommand } from './actor/set-value.js'; import { ActorsIndexCommand } from './actors/_index.js'; import { BuildsIndexCommand } from './builds/_index.js'; import { TopLevelCallCommand } from './call.js'; @@ -7,6 +13,7 @@ import { CheckVersionCommand } from './check-version.js'; import { CreateCommand } from './create.js'; import { DatasetsIndexCommand } from './datasets/_index.js'; import { EditInputSchemaCommand } from './edit-input-schema.js'; +import { HelpCommand } from './help.js'; import { InfoCommand } from './info.js'; import { InitCommand } from './init.js'; import { WrapScrapyCommand } from './init-wrap-scrapy.js'; @@ -47,8 +54,16 @@ export const apifyCommands = [ ToplevelPushCommand, RunCommand, ValidateInputSchemaCommand, + HelpCommand, ] as const satisfies (typeof BuiltApifyCommand)[]; -export const actorCommands: (typeof BuiltApifyCommand)[] = [ +export const actorCommands = [ // -]; + ActorSetValueCommand, + ActorPushDataCommand, + ActorGetValueCommand, + ActorGetPublicUrlCommand, + ActorGetInputCommand, + ActorChargeCommand, + HelpCommand, +] as const satisfies (typeof BuiltApifyCommand)[]; diff --git a/src/commands/help.ts b/src/commands/help.ts new file mode 100644 index 000000000..e4c45c4e3 --- /dev/null +++ b/src/commands/help.ts @@ -0,0 +1,68 @@ +import { jaroWinkler } from '@skyra/jaro-winkler'; +import chalk from 'chalk'; + +import { ApifyCommand, commandRegistry } from '../lib/command-framework/apify-command.js'; +import { Args } from '../lib/command-framework/args.js'; +import { renderHelpForCommand, renderMainHelpMenu } from '../lib/command-framework/help.js'; +import { error } from '../lib/outputs.js'; + +export class HelpCommand extends ApifyCommand { + static override name = 'help' as const; + + static override description = 'Prints out help about a command, or all available commands.'; + + static override hidden = true; + + static override args = { + commandString: Args.string({ + required: false, + description: 'The command to get help for.', + catchAll: true, + }), + }; + + async run() { + const { commandString } = this.args; + + if (!commandString || commandString.toLowerCase().startsWith('help')) { + const helpMenu = renderMainHelpMenu(this.entrypoint); + + console.log(helpMenu); + + return; + } + + const lowercasedCommandString = commandString.toLowerCase(); + + const command = commandRegistry.get(lowercasedCommandString); + + if (!command) { + const allCommands = [...commandRegistry.keys()]; + + const closestMatches = allCommands.filter((cmd) => { + const lowercased = cmd.toLowerCase(); + + return jaroWinkler(lowercasedCommandString, lowercased) >= 0.95; + }); + + let message = chalk.gray(`Command ${chalk.whiteBright(commandString)} not found`); + + if (closestMatches.length) { + message += '\n '; + message += chalk.gray( + `Did you mean: ${closestMatches.map((cmd) => chalk.whiteBright(cmd)).join(', ')}?`, + ); + } + + error({ + message, + }); + + return; + } + + const helpMenu = renderHelpForCommand(command); + + console.log(helpMenu); + } +} diff --git a/src/entrypoints/_shared.ts b/src/entrypoints/_shared.ts index fcc537090..eff6e5f7b 100644 --- a/src/entrypoints/_shared.ts +++ b/src/entrypoints/_shared.ts @@ -171,7 +171,18 @@ export async function runCLI(entrypoint: string) { case 'UNKNOWN_ARGUMENT_INPUT': case 'UNKNOWN_ARGUMENTS_INPUT': { - const nonexistentType = commandFlags.length ? 'flag' : 'subcommand'; + const nonexistentType = (() => { + if (commandFlags.length) { + return 'flag'; + } + + if (command.subcommands?.length) { + return 'subcommand'; + } + + return 'argument'; + })(); + const nonexistentRepresentation = (() => { // Rudimentary as heck, we cannot infer if the flag is provided as `-f` or `-ff` or `--flag`, etc. if (nonexistentType === 'flag') { diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index 06b20f18c..2f62e6079 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -159,6 +159,12 @@ export abstract class ApifyCommand; + protected entrypoint: string; + + public constructor(entrypoint: string) { + this.entrypoint = entrypoint; + } + abstract run(): Awaitable; protected get ctor(): typeof BuiltApifyCommand { @@ -217,6 +223,10 @@ export abstract class ApifyCommand