diff --git a/package.json b/package.json index e678a283d..72effb6ef 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "handlebars": "~4.7.8", "ignore": "^7.0.0", "indent-string": "^5.0.0", + "intl-messageformat": "^11.2.1", "is-ci": "~4.1.0", "istextorbinary": "~9.5.0", "jju": "~1.4.0", diff --git a/src/commands/actor/calculate-memory.ts b/src/commands/actor/calculate-memory.ts index 230ec8031..83ccd2650 100644 --- a/src/commands/actor/calculate-memory.ts +++ b/src/commands/actor/calculate-memory.ts @@ -7,7 +7,6 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { CommandExitCodes } from '../../lib/consts.js'; import { useActorConfig } from '../../lib/hooks/useActorConfig.js'; -import { error, info, success } from '../../lib/outputs.js'; import { getJsonFileContent, getLocalKeyValueStorePath } from '../../lib/utils.js'; const DEFAULT_INPUT_PATH = join(getLocalKeyValueStorePath('default'), 'INPUT.json'); @@ -77,7 +76,7 @@ export class ActorCalculateMemoryCommand extends ApifyCommand const isAtHome = Boolean(process.env.APIFY_IS_AT_HOME); if (!isAtHome) { - info({ - message: `No platform detected: would charge ${count} events of type "${eventName}" with idempotency key "${idempotencyKey ?? 'not-provided'}".`, - stdout: true, - }); + this.logger.stdout.info( + `No platform detected: would charge ${count} events of type "${eventName}" with idempotency key "${idempotencyKey ?? 'not-provided'}".`, + ); return; } if (testPayPerEvent) { - info({ - message: `PPE test mode: would charge ${count} events of type "${eventName}" with idempotency key "${idempotencyKey ?? 'not-provided'}".`, - stdout: true, - }); + this.logger.stdout.info( + `PPE test mode: would charge ${count} events of type "${eventName}" with idempotency key "${idempotencyKey ?? 'not-provided'}".`, + ); return; } @@ -83,10 +80,9 @@ export class ActorChargeCommand extends ApifyCommand throw new Error('Charge command can only be used with pay-per-event pricing model.'); } - info({ - message: `Charging ${count} events of type "${eventName}" with idempotency key "${idempotencyKey ?? 'not-provided'}" (runId: ${runId}).`, - stdout: true, - }); + this.logger.stdout.info( + `Charging ${count} events of type "${eventName}" with idempotency key "${idempotencyKey ?? 'not-provided'}" (runId: ${runId}).`, + ); await apifyClient.run(runId).charge({ eventName, count, diff --git a/src/commands/actor/generate-schema-types.ts b/src/commands/actor/generate-schema-types.ts index 6450a0b66..0d23d3f77 100644 --- a/src/commands/actor/generate-schema-types.ts +++ b/src/commands/actor/generate-schema-types.ts @@ -15,7 +15,6 @@ import { readOutputSchema, readStorageSchema, } from '../../lib/input_schema.js'; -import { error, info, success, warning } from '../../lib/outputs.js'; import { clearAllRequired, makePropertiesRequired, @@ -118,7 +117,7 @@ Optionally specify custom schema path to use.`; const outputFile = path.join(outputDir, `${name}.ts`); await writeFile(outputFile, result, 'utf-8'); - success({ message: `Generated types written to ${outputFile}` }); + this.logger.stderr.success(`Generated types written to ${outputFile}`); // When no custom path is provided, also generate types from additional schemas if (!this.args.path) { @@ -134,9 +133,9 @@ Optionally specify custom schema path to use.`; for (const [i, schemaResult] of schemaResults.entries()) { if (schemaResult.status === 'rejected') { anyFailed = true; - error({ - message: `Failed to generate types for ${schemaLabels[i]} schema: ${schemaResult.reason instanceof Error ? schemaResult.reason.message : String(schemaResult.reason)}`, - }); + this.logger.stderr.error( + `Failed to generate types for ${schemaLabels[i]} schema: ${schemaResult.reason instanceof Error ? schemaResult.reason.message : String(schemaResult.reason)}`, + ); } } @@ -164,15 +163,17 @@ Optionally specify custom schema path to use.`; const { datasetSchema, datasetSchemaPath } = datasetResult; if (datasetSchemaPath) { - info({ message: `[experimental] Generating types from Dataset schema at ${datasetSchemaPath}` }); + this.logger.stderr.info(`[experimental] Generating types from Dataset schema at ${datasetSchemaPath}`); } else { - info({ message: `[experimental] Generating types from Dataset schema embedded in '${LOCAL_CONFIG_PATH}'` }); + this.logger.stderr.info( + `[experimental] Generating types from Dataset schema embedded in '${LOCAL_CONFIG_PATH}'`, + ); } const prepared = prepareFieldsSchemaForCompilation(datasetSchema); if (!prepared) { - warning({ message: 'Dataset schema has no fields defined, skipping type generation.' }); + this.logger.stderr.warning('Dataset schema has no fields defined, skipping type generation.'); return; } @@ -185,7 +186,7 @@ Optionally specify custom schema path to use.`; const outputFile = path.join(outputDir, `${datasetName}.ts`); await writeFile(outputFile, result, 'utf-8'); - success({ message: `Generated types written to ${outputFile}` }); + this.logger.stderr.success(`Generated types written to ${outputFile}`); } private async generateOutputTypes({ @@ -206,15 +207,17 @@ Optionally specify custom schema path to use.`; const { outputSchema, outputSchemaPath } = outputResult; if (outputSchemaPath) { - info({ message: `[experimental] Generating types from Output schema at ${outputSchemaPath}` }); + this.logger.stderr.info(`[experimental] Generating types from Output schema at ${outputSchemaPath}`); } else { - info({ message: `[experimental] Generating types from Output schema embedded in '${LOCAL_CONFIG_PATH}'` }); + this.logger.stderr.info( + `[experimental] Generating types from Output schema embedded in '${LOCAL_CONFIG_PATH}'`, + ); } const prepared = prepareOutputSchemaForCompilation(outputSchema); if (!prepared) { - warning({ message: 'Output schema has no properties defined, skipping type generation.' }); + this.logger.stderr.warning('Output schema has no properties defined, skipping type generation.'); return; } @@ -227,7 +230,7 @@ Optionally specify custom schema path to use.`; const outputFile = path.join(outputDir, `${outputName}.ts`); await writeFile(outputFile, result, 'utf-8'); - success({ message: `Generated types written to ${outputFile}` }); + this.logger.stderr.success(`Generated types written to ${outputFile}`); } private async generateKvsTypes({ @@ -248,19 +251,19 @@ Optionally specify custom schema path to use.`; const { schema: kvsSchema, schemaPath: kvsSchemaPath } = kvsResult; if (kvsSchemaPath) { - info({ message: `[experimental] Generating types from Key-Value Store schema at ${kvsSchemaPath}` }); + this.logger.stderr.info(`[experimental] Generating types from Key-Value Store schema at ${kvsSchemaPath}`); } else { - info({ - message: `[experimental] Generating types from Key-Value Store schema embedded in '${LOCAL_CONFIG_PATH}'`, - }); + this.logger.stderr.info( + `[experimental] Generating types from Key-Value Store schema embedded in '${LOCAL_CONFIG_PATH}'`, + ); } const collections = prepareKvsCollectionsForCompilation(kvsSchema); if (collections.length === 0) { - warning({ - message: 'Key-Value Store schema has no collections with JSON schemas, skipping type generation.', - }); + this.logger.stderr.warning( + 'Key-Value Store schema has no collections with JSON schemas, skipping type generation.', + ); return; } @@ -281,6 +284,6 @@ Optionally specify custom schema path to use.`; const outputFile = path.join(outputDir, 'key-value-store.ts'); await writeFile(outputFile, parts.join('\n'), 'utf-8'); - success({ message: `Generated types written to ${outputFile}` }); + this.logger.stderr.success(`Generated types written to ${outputFile}`); } } diff --git a/src/commands/actor/get-public-url.ts b/src/commands/actor/get-public-url.ts index 473e16964..185a6dbb9 100644 --- a/src/commands/actor/get-public-url.ts +++ b/src/commands/actor/get-public-url.ts @@ -6,7 +6,6 @@ import { getApifyStorageClient } from '../../lib/actor.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { CommandExitCodes } from '../../lib/consts.js'; -import { error } from '../../lib/outputs.js'; export class ActorGetPublicUrlCommand extends ApifyCommand { static override name = 'get-public-url' as const; @@ -24,7 +23,7 @@ export class ActorGetPublicUrlCommand extends ApifyCommand { static override name = 'push-data' as const; @@ -28,7 +27,7 @@ export class ActorPushDataCommand extends ApifyCommand { static override name = 'call' as const; @@ -80,7 +73,9 @@ export class ActorsCallCommand extends ApifyCommand { const usernameOrId = userInfo.username || (userInfo.id as string); if (this.flags.json && this.flags.outputDataset) { - error({ message: 'You cannot use both the --json and --output-dataset flags when running this command.' }); + this.logger.stderr.error( + 'You cannot use both the --json and --output-dataset flags when running this command.', + ); process.exitCode = CommandExitCodes.InvalidInput; return; @@ -196,25 +191,24 @@ export class ActorsCallCommand extends ApifyCommand { // url message.push(`${chalk.blue('View on Apify Console')}: ${url}`, ''); - simpleLog({ message: message.join('\n'), stdout: !this.flags.json }); + (!this.flags.json ? this.logger.stdout : this.logger.stderr).log(message.join('\n')); } } } if (this.flags.json) { - printJsonToStdout(run!); + this.logger.stdout.json(run!); return; } if (!this.flags.silent) { - simpleLog({ - message: [ + this.logger.stdout.log( + [ '', `${chalk.blue('Export results')}: ${datasetUrl!}`, `${chalk.blue('View on Apify Console')}: ${url!}`, ].join('\n'), - stdout: true, - }); + ); } if (this.flags.outputDataset) { diff --git a/src/commands/actors/info.ts b/src/commands/actors/info.ts index b3c3fccf0..3693e5cf7 100644 --- a/src/commands/actors/info.ts +++ b/src/commands/actors/info.ts @@ -6,8 +6,7 @@ import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { resolveActorContext } from '../../lib/commands/resolve-actor-context.js'; import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; -import { error, simpleLog } from '../../lib/outputs.js'; -import { DurationFormatter, getLoggedClientOrThrow, printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; +import { DurationFormatter, getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js'; interface HydratedActorInfo extends Omit { taggedBuilds?: Record; @@ -74,10 +73,7 @@ export class ActorsInfoCommand extends ApifyCommand { const ctx = await resolveActorContext({ providedActorNameOrId: actorId, client }); if (!ctx.valid) { - error({ - message: `${ctx.reason}. Please specify the Actor ID.`, - stdout: true, - }); + this.logger.stdout.error(`${ctx.reason}. Please specify the Actor ID.`); return; } @@ -99,7 +95,7 @@ export class ActorsInfoCommand extends ApifyCommand { } if (json) { - printJsonToStdout(actorInfo); + this.logger.stdout.json(actorInfo); return; } @@ -107,46 +103,34 @@ export class ActorsInfoCommand extends ApifyCommand { if (readme) { if (!latest) { - error({ - message: 'No README found for this Actor.', - stdout: true, - }); + this.logger.stdout.error('No README found for this Actor.'); return; } if (!latest.build?.readme) { - error({ - message: 'No README found for this Actor.', - stdout: true, - }); + this.logger.stdout.error('No README found for this Actor.'); return; } - simpleLog({ message: latest.build.readme, stdout: true }); + this.logger.stdout.log(latest.build.readme); } if (input) { if (!latest) { - error({ - message: 'No input schema found for this Actor.', - stdout: true, - }); + this.logger.stdout.error('No input schema found for this Actor.'); return; } if (!latest.build?.inputSchema) { - error({ - message: 'No input schema found for this Actor.', - stdout: true, - }); + this.logger.stdout.error('No input schema found for this Actor.'); return; } - simpleLog({ message: latest.build.inputSchema, stdout: true }); + this.logger.stdout.log(latest.build.inputSchema); } const message = [ @@ -295,6 +279,6 @@ export class ActorsInfoCommand extends ApifyCommand { } } - simpleLog({ message: message.join('\n'), stdout: true }); + this.logger.stdout.log(message.join('\n')); } } diff --git a/src/commands/actors/ls.ts b/src/commands/actors/ls.ts index 7b7f6b406..92316b980 100644 --- a/src/commands/actors/ls.ts +++ b/src/commands/actors/ls.ts @@ -8,12 +8,10 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { prettyPrintStatus } from '../../lib/commands/pretty-print-status.js'; import { CompactMode, kSkipColumn, ResponsiveTable } from '../../lib/commands/responsive-table.js'; -import { info, simpleLog } from '../../lib/outputs.js'; import { DateOnlyTimestampFormatter, getLoggedClientOrThrow, MultilineTimestampFormatter, - printJsonToStdout, ShortDurationFormatter, } from '../../lib/utils.js'; @@ -129,14 +127,11 @@ export class ActorsLsCommand extends ApifyCommand { if (rawActorList.count === 0) { if (json) { - printJsonToStdout(rawActorList); + this.logger.stdout.json(rawActorList); return; } - info({ - message: my ? "You don't have any Actors yet!" : 'There are no recent Actors used by you.', - stdout: true, - }); + this.logger.stdout.info(my ? "You don't have any Actors yet!" : 'There are no recent Actors used by you.'); return; } @@ -176,7 +171,7 @@ export class ActorsLsCommand extends ApifyCommand { actorList.items = my ? this.sortByModifiedAt(actorList.items) : this.sortByLastRun(actorList.items); if (json) { - printJsonToStdout(actorList); + this.logger.stdout.json(actorList); return; } @@ -281,10 +276,7 @@ export class ActorsLsCommand extends ApifyCommand { }); } - simpleLog({ - message: table.render(CompactMode.WebLikeCompact), - stdout: true, - }); + this.logger.stdout.log(table.render(CompactMode.WebLikeCompact)); } private sortByModifiedAt(items: HydratedListData[]) { diff --git a/src/commands/actors/pull.ts b/src/commands/actors/pull.ts index 7513d1d6a..ff2a8379b 100644 --- a/src/commands/actors/pull.ts +++ b/src/commands/actors/pull.ts @@ -13,7 +13,6 @@ import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { CommandExitCodes, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; import { useActorConfig } from '../../lib/hooks/useActorConfig.js'; -import { error, success } from '../../lib/outputs.js'; import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; const extractGitHubZip = async (url: string, directoryPath: string) => { @@ -58,7 +57,7 @@ export class ActorsPullCommand extends ApifyCommand { const actorConfigResult = await useActorConfig({ cwd }); if (actorConfigResult.isErr()) { - error({ message: actorConfigResult.unwrapErr().message }); + this.logger.stderr.error(actorConfigResult.unwrapErr().message); process.exitCode = CommandExitCodes.InvalidActorJson; return; } @@ -118,7 +117,7 @@ export class ActorsPullCommand extends ApifyCommand { mkdirSync(dirpath, { recursive: true }); if (!isActorAutomaticallyDetected && !(readdirSync(dirpath).length === 0)) { - error({ message: `Directory ${dirpath} is not empty. Please empty it or choose another directory.` }); + this.logger.stderr.error(`Directory ${dirpath} is not empty. Please empty it or choose another directory.`); return; } @@ -198,8 +197,8 @@ export class ActorsPullCommand extends ApifyCommand { throw new Error(`Unknown source type: ${sourceType}`); } - success({ - message: isActorAutomaticallyDetected ? `Actor ${name} updated at ${dirpath}/` : `Pulled to ${dirpath}/`, - }); + this.logger.stderr.success( + isActorAutomaticallyDetected ? `Actor ${name} updated at ${dirpath}/` : `Pulled to ${dirpath}/`, + ); } } diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 6e515f359..de3e79f82 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -15,7 +15,6 @@ import { Flags } from '../../lib/command-framework/flags.js'; import { CommandExitCodes, DEPRECATED_LOCAL_CONFIG_NAME, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; import { sumFilesSizeInBytes } from '../../lib/files.js'; import { useActorConfig } from '../../lib/hooks/useActorConfig.js'; -import { error, info, link, run, success, warning } from '../../lib/outputs.js'; import { transformEnvToEnvVars } from '../../lib/secrets.js'; import { createActZip, @@ -24,7 +23,6 @@ import { getLocalUserInfo, getLoggedClientOrThrow, outputJobLog, - printJsonToStdout, } from '../../lib/utils.js'; const TEMP_ZIP_FILE_NAME = 'temp_file.zip'; @@ -110,7 +108,7 @@ export class ActorsPushCommand extends ApifyCommand { const filePathsToPush = await getActorLocalFilePaths(cwd); if (!filePathsToPush.length) { - error({ message: 'You need to call this command from a folder that has an Actor in it!' }); + this.logger.stderr.error('You need to call this command from a folder that has an Actor in it!'); process.exitCode = CommandExitCodes.NoFilesToPush; return; } @@ -127,12 +125,12 @@ export class ActorsPushCommand extends ApifyCommand { '.actor', ].some((filePath) => filePathsToPush.some((fp) => fp === filePath || fp.startsWith(filePath))) ) { - error({ - message: [ + this.logger.stderr.error( + [ 'A valid Actor could not be found in the current directory. Please make sure you are in the correct directory.', 'You can also turn this directory into an Actor by running `apify init`.', ].join('\n'), - }); + ); process.exitCode = CommandExitCodes.NoFilesToPush; return; @@ -143,7 +141,7 @@ export class ActorsPushCommand extends ApifyCommand { const actorConfigResult = await useActorConfig({ cwd }); if (actorConfigResult.isErr()) { - error({ message: actorConfigResult.unwrapErr().message }); + this.logger.stderr.error(actorConfigResult.unwrapErr().message); process.exitCode = CommandExitCodes.InvalidActorJson; return; } @@ -218,13 +216,13 @@ export class ActorsPushCommand extends ApifyCommand { actor = await apifyClient.actors().create(newActor); actorId = actor.id; isActorCreatedNow = true; - info({ message: `Created Actor with name ${actorConfig!.name} on Apify.` }); + this.logger.stderr.info(`Created Actor with name ${actorConfig!.name} on Apify.`); } } const actorClient = apifyClient.actor(actorId); - info({ message: `Deploying Actor '${actorConfig!.name}' to Apify.` }); + this.logger.stderr.info(`Deploying Actor '${actorConfig!.name}' to Apify.`); const filesSize = await sumFilesSizeInBytes(filePathsToPush, cwd); @@ -263,7 +261,7 @@ Skipping push. Use --force to override.`, sourceType = ACTOR_SOURCE_TYPES.SOURCE_FILES; } else { // Create zip - run({ message: 'Zipping Actor files' }); + this.logger.stderr.run('Zipping Actor files'); await createActZip(TEMP_ZIP_FILE_NAME, filePathsToPush, cwd); // Upload it to Apify.keyValueStores @@ -310,7 +308,7 @@ Skipping push. Use --force to override.`, const actorVersionModifier = { tarballUrl, sourceFiles, buildTag, sourceType, envVars }; // TODO: fix this type too -.- await actorClient.version(version).update(actorVersionModifier as never); - run({ message: `Updated version ${version} for Actor ${actor.name}.` }); + this.logger.stderr.run(`Updated version ${version} for Actor ${actor.name}.`); } else { const actorNewVersion = { versionNumber: version, @@ -325,18 +323,18 @@ Skipping push. Use --force to override.`, ...actorNewVersion, } as never); - run({ message: `Created version ${version} for Actor ${actor.name}.` }); + this.logger.stderr.run(`Created version ${version} for Actor ${actor.name}.`); } // Sync standby mode on existing actors with actor.json if (!isActorCreatedNow && !!actorConfig!.usesStandbyMode !== !!actor.actorStandby?.isEnabled) { const isEnabled = !!actorConfig!.usesStandbyMode; await actorClient.update({ actorStandby: { isEnabled } }); - info({ message: `${isEnabled ? 'Enabled' : 'Disabled'} standby mode for Actor ${actor.name}.` }); + this.logger.stderr.info(`${isEnabled ? 'Enabled' : 'Disabled'} standby mode for Actor ${actor.name}.`); } // Build Actor on Apify and wait for build to finish - run({ message: `Building Actor ${actor.name}` }); + this.logger.stderr.run(`Building Actor ${actor.name}`); let build = await actorClient.build(version, { useCache: true, waitForFinish: 2, // NOTE: We need to wait some time to Apify open stream and we can create connection @@ -345,49 +343,46 @@ Skipping push. Use --force to override.`, try { await outputJobLog({ job: build, timeoutMillis: waitForFinishMillis, apifyClient }); } catch (err) { - warning({ message: 'Can not get log:' }); + this.logger.stderr.warning('Can not get log:'); console.error(err); } build = (await apifyClient.build(build.id).get())!; if (this.flags.json) { - printJsonToStdout(build); + this.logger.stdout.json(build); return; } - link({ - message: 'Actor build detail', - url: `https://console.apify.com${redirectUrlPart}/actors/${build.actId}#/builds/${build.buildNumber}`, - }); + this.logger.stderr.link( + 'Actor build detail', + `https://console.apify.com${redirectUrlPart}/actors/${build.actId}#/builds/${build.buildNumber}`, + ); - link({ - message: 'Actor detail', - url: `https://console.apify.com${redirectUrlPart}/actors/${build.actId}`, - }); + this.logger.stderr.link('Actor detail', `https://console.apify.com${redirectUrlPart}/actors/${build.actId}`); if (this.flags.open) { await open(`https://console.apify.com${redirectUrlPart}/actors/${build.actId}`); } if (build.status === ACTOR_JOB_STATUSES.SUCCEEDED) { - success({ message: 'Actor was deployed to Apify cloud and built there.' }); + this.logger.stderr.success('Actor was deployed to Apify cloud and built there.'); // @ts-expect-error FIX THESE TYPES 😢 } else if (build.status === ACTOR_JOB_STATUSES.READY) { - warning({ message: 'Build is waiting for allocation.' }); + this.logger.stderr.warning('Build is waiting for allocation.'); // @ts-expect-error FIX THESE TYPES 😢 } else if (build.status === ACTOR_JOB_STATUSES.RUNNING) { - warning({ message: 'Build is still running.' }); + this.logger.stderr.warning('Build is still running.'); // @ts-expect-error FIX THESE TYPES 😢 } else if (build.status === ACTOR_JOB_STATUSES.ABORTED || build.status === ACTOR_JOB_STATUSES.ABORTING) { - warning({ message: 'Build was aborted!' }); + this.logger.stderr.warning('Build was aborted!'); process.exitCode = CommandExitCodes.BuildAborted; // @ts-expect-error FIX THESE TYPES 😢 } else if (build.status === ACTOR_JOB_STATUSES.TIMED_OUT || build.status === ACTOR_JOB_STATUSES.TIMING_OUT) { - warning({ message: 'Build timed out!' }); + this.logger.stderr.warning('Build timed out!'); process.exitCode = CommandExitCodes.BuildTimedOut; } else { - error({ message: 'Build failed!' }); + this.logger.stderr.error('Build failed!'); process.exitCode = CommandExitCodes.BuildFailed; } } diff --git a/src/commands/actors/rm.ts b/src/commands/actors/rm.ts index 168b863ca..ba91041b5 100644 --- a/src/commands/actors/rm.ts +++ b/src/commands/actors/rm.ts @@ -3,7 +3,6 @@ import type { ApifyApiError } from 'apify-client'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; -import { error, info, success } from '../../lib/outputs.js'; import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class ActorsRmCommand extends ApifyCommand { @@ -26,7 +25,7 @@ export class ActorsRmCommand extends ApifyCommand { const actor = await apifyClient.actor(actorId).get(); if (!actor) { - error({ message: `Actor with ID "${actorId}" was not found on your account.` }); + this.logger.stderr.error(`Actor with ID "${actorId}" was not found on your account.`); return; } @@ -35,19 +34,17 @@ export class ActorsRmCommand extends ApifyCommand { }); if (!confirmedDelete) { - info({ - message: `Deletion of Actor "${actorId}" was canceled.`, - }); + this.logger.stderr.info(`Deletion of Actor "${actorId}" was canceled.`); return; } try { await apifyClient.actor(actorId).delete(); - success({ message: `Actor with ID "${actorId}" was deleted.` }); + this.logger.stderr.success(`Actor with ID "${actorId}" was deleted.`); } catch (err) { const casted = err as ApifyApiError; - error({ message: `Failed to delete Actor "${actorId}".\n ${casted.message || casted}` }); + this.logger.stderr.error(`Failed to delete Actor "${actorId}".\n ${casted.message || casted}`); } } } diff --git a/src/commands/actors/search.ts b/src/commands/actors/search.ts index 4fde24e2b..905490671 100644 --- a/src/commands/actors/search.ts +++ b/src/commands/actors/search.ts @@ -6,8 +6,7 @@ import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; import { CommandExitCodes } from '../../lib/consts.js'; -import { error, info, simpleLog } from '../../lib/outputs.js'; -import { getApifyClientOptions, printJsonToStdout } from '../../lib/utils.js'; +import { getApifyClientOptions } from '../../lib/utils.js'; const pricingModelLabels: Record = { FREE: 'Free', @@ -93,25 +92,24 @@ export class ActorsSearchCommand extends ApifyCommand { @@ -113,7 +106,7 @@ export class ActorsStartCommand extends ApifyCommand } if (this.flags.json) { - printJsonToStdout(run); + this.logger.stdout.json(run); return; } @@ -170,9 +163,6 @@ export class ActorsStartCommand extends ApifyCommand `${chalk.blue('View on Apify Console')}: ${url}`, ); - simpleLog({ - message: message.join('\n'), - stdout: true, - }); + this.logger.stdout.log(message.join('\n')); } } diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 90bee3c99..f3de54dd0 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -15,7 +15,7 @@ import { AUTH_FILE_PATH } from '../../lib/consts.js'; import { updateUserId } from '../../lib/hooks/telemetry/useTelemetryState.js'; import { useMaskedInput } from '../../lib/hooks/user-confirmations/useMaskedInput.js'; import { useSelectFromList } from '../../lib/hooks/user-confirmations/useSelectFromList.js'; -import { error, info, success } from '../../lib/outputs.js'; +import { logger } from '../../lib/logger.js'; import { getLocalUserInfo, getLoggedClient, tildify } from '../../lib/utils.js'; const CONSOLE_BASE_URL = 'https://console.apify.com/settings/integrations'; @@ -34,13 +34,11 @@ const tryToLogin = async (token: string) => { if (isUserLogged) { await updateUserId(userInfo.id!); - success({ - message: `You are logged in to Apify as ${userInfo.username || userInfo.id}. ${chalk.gray(`Your token is stored at ${AUTH_FILE_PATH()}.`)}`, - }); + logger.stderr.success( + `You are logged in to Apify as ${userInfo.username || userInfo.id}. ${chalk.gray(`Your token is stored at ${AUTH_FILE_PATH()}.`)}`, + ); } else { - error({ - message: 'Login to Apify failed, the provided API token is not valid.', - }); + logger.stderr.error('Login to Apify failed, the provided API token is not valid.'); } return isUserLogged; }; @@ -154,7 +152,7 @@ export class AuthLoginCommand extends ApifyCommand { res.end(); } catch (err) { const errorMessage = `Login to Apify failed with error: ${(err as Error).message}`; - error({ message: errorMessage }); + this.logger.stderr.error(errorMessage); res.status(500); res.send(errorMessage); } @@ -163,15 +161,11 @@ export class AuthLoginCommand extends ApifyCommand { apiRouter.post('/exit', (req, res) => { if (req.body.isWindowClosed) { - error({ - message: 'Login to Apify failed, the console window was closed.', - }); + this.logger.stderr.error('Login to Apify failed, the console window was closed.'); } else if (req.body.actionCanceled) { - error({ - message: 'Login to Apify failed, the action was canceled in the Apify Console.', - }); + this.logger.stderr.error('Login to Apify failed, the action was canceled in the Apify Console.'); } else { - error({ message: 'Login to Apify failed.' }); + this.logger.stderr.error('Login to Apify failed.'); } res.end(); @@ -193,7 +187,7 @@ export class AuthLoginCommand extends ApifyCommand { // Ignore errors from fetching computer name as it's not critical } - info({ message: `Opening Apify Console at "${consoleUrl.href}"...` }); + this.logger.stderr.info(`Opening Apify Console at "${consoleUrl.href}"...`); await open(consoleUrl.href); } else { console.log( diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index c8b3b47df..5364e04e1 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -2,7 +2,6 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { AUTH_FILE_PATH } from '../../lib/consts.js'; import { rimrafPromised } from '../../lib/files.js'; import { updateUserId } from '../../lib/hooks/telemetry/useTelemetryState.js'; -import { success } from '../../lib/outputs.js'; import { tildify } from '../../lib/utils.js'; export class AuthLogoutCommand extends ApifyCommand { @@ -17,6 +16,6 @@ export class AuthLogoutCommand extends ApifyCommand { await updateUserId(null); - success({ message: 'You are logged out from your Apify account.' }); + this.logger.stderr.success('You are logged out from your Apify account.'); } } diff --git a/src/commands/auth/token.ts b/src/commands/auth/token.ts index 1abb0eca4..abe7ca260 100644 --- a/src/commands/auth/token.ts +++ b/src/commands/auth/token.ts @@ -1,5 +1,4 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; -import { simpleLog } from '../../lib/outputs.js'; import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; export class AuthTokenCommand extends ApifyCommand { @@ -12,7 +11,7 @@ export class AuthTokenCommand extends ApifyCommand { const userInfo = await getLocalUserInfo(); if (userInfo.token) { - simpleLog({ message: userInfo.token, stdout: true }); + this.logger.stdout.log(userInfo.token); } } } diff --git a/src/commands/builds/add-tag.ts b/src/commands/builds/add-tag.ts index 1472f97c4..d22706ae3 100644 --- a/src/commands/builds/add-tag.ts +++ b/src/commands/builds/add-tag.ts @@ -3,7 +3,6 @@ import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Flags } from '../../lib/command-framework/flags.js'; -import { error, success, warning } from '../../lib/outputs.js'; import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class BuildsAddTagCommand extends ApifyCommand { @@ -32,22 +31,21 @@ export class BuildsAddTagCommand extends ApifyCommand { static override name = 'create' as const; @@ -50,10 +43,9 @@ export class BuildsCreateCommand extends ApifyCommand v.versionNumber === version))) { - error({ - message: `The Actor Version "${version}" does not have the tag "${tag}".`, - stdout: true, - }); + this.logger.stdout.error(`The Actor Version "${version}" does not have the tag "${tag}".`); return; } @@ -98,10 +87,9 @@ export class BuildsCreateCommand extends ApifyCommand 1) { if (!version) { - error({ - message: `Multiple Actor versions with the tag "${tag}" found. Please specify the version number using the "--version" flag.\n Available versions for this tag: ${taggedVersions.map((v) => chalk.yellow(v.versionNumber)).join(', ')}`, - stdout: true, - }); + this.logger.stdout.error( + `Multiple Actor versions with the tag "${tag}" found. Please specify the version number using the "--version" flag.\n Available versions for this tag: ${taggedVersions.map((v) => chalk.yellow(v.versionNumber)).join(', ')}`, + ); return; } @@ -111,10 +99,9 @@ export class BuildsCreateCommand extends ApifyCommand { static override name = 'info' as const; @@ -29,13 +28,13 @@ export class BuildsInfoCommand extends ApifyCommand { const build = await apifyClient.build(buildId).get(); if (!build) { - error({ message: `Build with ID "${buildId}" was not found on your account.`, stdout: true }); + this.logger.stdout.error(`Build with ID "${buildId}" was not found on your account.`); return; } // JSON output -> return the object (which is handled by oclif) if (this.flags.json) { - printJsonToStdout(build); + this.logger.stdout.json(build); return; } @@ -100,6 +99,6 @@ export class BuildsInfoCommand extends ApifyCommand { message.push(`${chalk.blue('View in Apify Console')}: ${url}`); - simpleLog({ message: message.join('\n'), stdout: true }); + this.logger.stdout.log(message.join('\n')); } } diff --git a/src/commands/builds/log.ts b/src/commands/builds/log.ts index 4b2a0c1d9..ddcab1f4d 100644 --- a/src/commands/builds/log.ts +++ b/src/commands/builds/log.ts @@ -1,6 +1,5 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; -import { error, info } from '../../lib/outputs.js'; import { getLoggedClientOrThrow, outputJobLog } from '../../lib/utils.js'; export class BuildsLogCommand extends ApifyCommand { @@ -23,20 +22,17 @@ export class BuildsLogCommand extends ApifyCommand { const build = await apifyClient.build(buildId).get(); if (!build) { - error({ message: `Build with ID "${buildId}" was not found on your account.`, stdout: true }); + this.logger.stdout.error(`Build with ID "${buildId}" was not found on your account.`); return; } - info({ message: `Log for build with ID "${buildId}":\n` }); + this.logger.stderr.info(`Log for build with ID "${buildId}":\n`); try { await outputJobLog({ job: build, apifyClient }); } catch (err) { // This should never happen... - error({ - message: `Failed to get log for build with ID "${buildId}": ${(err as Error).message}`, - stdout: true, - }); + this.logger.stdout.error(`Failed to get log for build with ID "${buildId}": ${(err as Error).message}`); } } } diff --git a/src/commands/builds/ls.ts b/src/commands/builds/ls.ts index f4ab0954b..842b09474 100644 --- a/src/commands/builds/ls.ts +++ b/src/commands/builds/ls.ts @@ -7,8 +7,7 @@ import { Flags } from '../../lib/command-framework/flags.js'; import { prettyPrintStatus } from '../../lib/commands/pretty-print-status.js'; import { resolveActorContext } from '../../lib/commands/resolve-actor-context.js'; import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; -import { error, simpleLog } from '../../lib/outputs.js'; -import { getLoggedClientOrThrow, objectGroupBy, printJsonToStdout, ShortDurationFormatter } from '../../lib/utils.js'; +import { getLoggedClientOrThrow, objectGroupBy, ShortDurationFormatter } from '../../lib/utils.js'; const tableFactory = () => new ResponsiveTable({ @@ -63,10 +62,9 @@ export class BuildsLsCommand extends ApifyCommand { const ctx = await resolveActorContext({ providedActorNameOrId: actorId, client }); if (!ctx.valid) { - error({ - message: `${ctx.reason}. Please run this command in an Actor directory, or specify the Actor ID.`, - stdout: true, - }); + this.logger.stdout.error( + `${ctx.reason}. Please run this command in an Actor directory, or specify the Actor ID.`, + ); return; } @@ -104,23 +102,19 @@ export class BuildsLsCommand extends ApifyCommand { } } - printJsonToStdout(allBuilds); + this.logger.stdout.json(allBuilds); return; } - simpleLog({ - message: `${chalk.reset('Showing')} ${chalk.yellow(allBuilds.items.length)} out of ${chalk.yellow(allBuilds.total)} builds for Actor ${chalk.yellow(ctx.userFriendlyId)} (${chalk.gray(ctx.id)})\n`, - stdout: true, - }); + this.logger.stdout.log( + `${chalk.reset('Showing')} ${chalk.yellow(allBuilds.items.length)} out of ${chalk.yellow(allBuilds.total)} builds for Actor ${chalk.yellow(ctx.userFriendlyId)} (${chalk.gray(ctx.id)})\n`, + ); const sortedActorVersions = Object.entries(buildsByActorVersion).sort((a, b) => a[0].localeCompare(b[0])); for (const [actorVersion, buildsForVersion] of sortedActorVersions) { if (!buildsForVersion?.length) { - simpleLog({ - message: `No builds for version ${actorVersion}`, - stdout: true, - }); + this.logger.stdout.log(`No builds for version ${actorVersion}`); continue; } @@ -141,10 +135,7 @@ export class BuildsLsCommand extends ApifyCommand { '', ]; - simpleLog({ - message: message.join('\n'), - stdout: true, - }); + this.logger.stdout.log(message.join('\n')); } } diff --git a/src/commands/builds/remove-tag.ts b/src/commands/builds/remove-tag.ts index 6ca252e8e..0210c2106 100644 --- a/src/commands/builds/remove-tag.ts +++ b/src/commands/builds/remove-tag.ts @@ -4,7 +4,6 @@ import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; -import { error, info, success } from '../../lib/outputs.js'; import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class BuildsRemoveTagCommand extends ApifyCommand { @@ -38,14 +37,14 @@ export class BuildsRemoveTagCommand extends ApifyCommand { @@ -27,7 +26,7 @@ export class BuildsRmCommand extends ApifyCommand { const build = await apifyClient.build(buildId).get(); if (!build) { - error({ message: `Build with ID "${buildId}" was not found on your account.`, stdout: true }); + this.logger.stdout.error(`Build with ID "${buildId}" was not found on your account.`); return; } @@ -53,10 +52,7 @@ export class BuildsRmCommand extends ApifyCommand { }); if (!confirmed) { - info({ - message: `Deletion of build "${buildId}" was canceled.`, - stdout: true, - }); + this.logger.stdout.info(`Deletion of build "${buildId}" was canceled.`); return; } @@ -64,13 +60,10 @@ export class BuildsRmCommand extends ApifyCommand { try { await apifyClient.build(buildId).delete(); - success({ - message: `Build with ID "${buildId}" was deleted.`, - stdout: true, - }); + this.logger.stdout.success(`Build with ID "${buildId}" was deleted.`); } catch (err) { const casted = err as ApifyApiError; - error({ message: `Failed to delete build "${buildId}".\n ${casted.message || casted}`, stdout: true }); + this.logger.stdout.error(`Failed to delete build "${buildId}".\n ${casted.message || casted}`); } } } diff --git a/src/commands/cli-management/install.ts b/src/commands/cli-management/install.ts index 7069b967c..4b0577770 100644 --- a/src/commands/cli-management/install.ts +++ b/src/commands/cli-management/install.ts @@ -11,7 +11,6 @@ import which from 'which'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { useCLIMetadata } from '../../lib/hooks/useCLIMetadata.js'; import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; -import { error, info, simpleLog, success, warning } from '../../lib/outputs.js'; import { detectShell, shellConfigFile, tildify } from '../../lib/utils.js'; import { cliDebugPrint } from '../../lib/utils/cliDebugPrint.js'; @@ -29,7 +28,7 @@ export class InstallCommand extends ApifyCommand { const { installMethod, installPath, version } = useCLIMetadata(); if (installMethod !== 'bundle') { - info({ message: `Apify and Actor CLI are already fully configured! 👍` }); + this.logger.stderr.info(`Apify and Actor CLI are already fully configured! 👍`); return; } @@ -38,7 +37,7 @@ export class InstallCommand extends ApifyCommand { const installMarkerPath = pathToInstallMarker(installPath); if (existsSync(installMarkerPath)) { - info({ message: `Apify and Actor CLI are already fully configured! 👍` }); + this.logger.stderr.info(`Apify and Actor CLI are already fully configured! 👍`); return; } @@ -49,18 +48,18 @@ export class InstallCommand extends ApifyCommand { try { await this.promptAddToShell(); } catch (err: any) { - error({ message: err.message || 'Failed to automatically handle shell integration' }); + this.logger.stderr.error(err.message || 'Failed to automatically handle shell integration'); } - simpleLog({ message: '' }); + this.logger.stderr.log(''); } await writeFile(installMarkerPath, version); cliDebugPrint('[install] install marker written to', installMarkerPath); - simpleLog({ - message: [ + this.logger.stderr.log( + [ '', chalk.green('Apify and Actor CLI were installed successfully!'), '', @@ -69,11 +68,11 @@ export class InstallCommand extends ApifyCommand { ` Location: ${chalk.bold.white(tildify(join(installPath, 'apify')))} and ${chalk.bold.white(tildify(join(installPath, 'actor')))}`, ), ].join('\n'), - }); + ); - simpleLog({ message: '' }); - success({ message: 'To get started, run:' }); - simpleLog({ message: chalk.white.bold(' apify --help\n actor --help') }); + this.logger.stderr.log(''); + this.logger.stderr.success('To get started, run:'); + this.logger.stderr.log(chalk.white.bold(' apify --help\n actor --help')); } private async symlinkToLocalBin(installPath: string) { @@ -84,7 +83,7 @@ export class InstallCommand extends ApifyCommand { if (!userHomeDirectory) { cliDebugPrint('[install -> symlinkToLocalBin] user home directory not found'); - warning({ message: chalk.gray(`User home directory not found, cannot symlink to ~/.local/bin`) }); + this.logger.stderr.warning(chalk.gray(`User home directory not found, cannot symlink to ~/.local/bin`)); return; } @@ -102,7 +101,7 @@ export class InstallCommand extends ApifyCommand { if (!existsSync(originalPath)) { cliDebugPrint('[install] file not found for symlinking', file, originalPath); - warning({ message: chalk.gray(`Bundle not found for symlinking: ${file}`) }); + this.logger.stderr.warning(chalk.gray(`Bundle not found for symlinking: ${file}`)); continue; } @@ -117,7 +116,7 @@ export class InstallCommand extends ApifyCommand { cliDebugPrint('[install] symlink created for item', file, symlinkPath); } - info({ message: chalk.gray(`Symlinked apify, actor, and apify-cli to ${tildify(localBinDirectory)}`) }); + this.logger.stderr.info(chalk.gray(`Symlinked apify, actor, and apify-cli to ${tildify(localBinDirectory)}`)); } /** @@ -207,7 +206,7 @@ export class InstallCommand extends ApifyCommand { ) { cliDebugPrint('[install -> promptAddToShell] already in PATH', { apifyCliPath, actorCliPath }); - info({ message: chalk.gray(`Apify and Actor CLIs are already in PATH, skipping shell integration`) }); + this.logger.stderr.info(chalk.gray(`Apify and Actor CLIs are already in PATH, skipping shell integration`)); return; } @@ -220,13 +219,13 @@ export class InstallCommand extends ApifyCommand { const installDir = process.env.PROVIDED_INSTALL_DIR ?? defaultInstallDir; if (!installDir) { - warning({ message: chalk.gray(`Install directory not found, cannot add to shell`) }); + this.logger.stderr.warning(chalk.gray(`Install directory not found, cannot add to shell`)); return; } const binDir = process.env.FINAL_BIN_DIR ?? defaultBinDir; - simpleLog({ message: '' }); + this.logger.stderr.log(''); const confirmMessage = 'Should the CLI handle adding itself to your shell automatically?'; @@ -281,7 +280,7 @@ export class InstallCommand extends ApifyCommand { } } - simpleLog({ message: '' }); + this.logger.stderr.log(''); if (allowedToAutomaticallyDo && configFile) { const oldContent = await readFile(configFile, 'utf-8').catch((err) => { @@ -312,14 +311,14 @@ export class InstallCommand extends ApifyCommand { ); } - info({ - message: [ + this.logger.stderr.info( + [ chalk.gray(`Added "${tildify(binDir)}" to your PATH in ${tildify(configFile)}.`), chalk.gray( ` You may need to run ${chalk.white.bold(`source ${tildify(configFile)}`)} to reload your shell.`, ), ].join('\n'), - }); + ); return; } @@ -329,23 +328,23 @@ export class InstallCommand extends ApifyCommand { if (showOneLiner) { const oneLiner = `echo -e '${linesToAdd.join('\\n')}' >> "${resolvedConfigFile}" && source "${resolvedConfigFile}"`; - info({ - message: [ + this.logger.stderr.info( + [ // chalk.gray(`The Apify & Actor CLIs are not in your PATH. Run:`), '', chalk.white.bold(` ${oneLiner}`), ].join('\n'), - }); + ); return; } - info({ - message: [ + this.logger.stderr.info( + [ chalk.gray(`Manually add the following lines to ${resolvedConfigFile} or similar:`), ...linesToAdd.map((line) => chalk.white.bold(` ${line}`)), ].join('\n'), - }); + ); } } diff --git a/src/commands/cli-management/upgrade.ts b/src/commands/cli-management/upgrade.ts index fe318158c..5196a0357 100644 --- a/src/commands/cli-management/upgrade.ts +++ b/src/commands/cli-management/upgrade.ts @@ -14,7 +14,6 @@ import { DEVELOPMENT_VERSION_MARKER, type InstallMethod, useCLIMetadata } from ' import type { Asset } from '../../lib/hooks/useCLIVersionAssets.js'; import { useCLIVersionAssets } from '../../lib/hooks/useCLIVersionAssets.js'; import { useCLIVersionCheck } from '../../lib/hooks/useCLIVersionCheck.js'; -import { error, info, simpleLog, success, warning } from '../../lib/outputs.js'; import { cliDebugPrint } from '../../lib/utils/cliDebugPrint.js'; const UPDATE_COMMANDS: Record string[]> = { @@ -84,7 +83,7 @@ export class UpgradeCommand extends ApifyCommand { // Always print, unless the command was called automatically by the CLI for a version check if (!this.flags.internalAutomaticCall) { - info({ message: `${this.cliName} is up to date 👍 \n` }); + this.logger.stderr.info(`${this.cliName} is up to date 👍 \n`); } return; @@ -98,22 +97,22 @@ export class UpgradeCommand extends ApifyCommand { const updateCommand = UPDATE_COMMANDS[installMethod]('latest', this.entrypoint).join(' '); - simpleLog({ message: '' }); + this.logger.stderr.log(''); const message = [ `You are using an old version of ${this.cliName}. We strongly recommend you always use the latest available version.`, ` ↪ Run ${chalk.bgWhite(chalk.black(updateCommand))} to update! 👍 \n`, ].join('\n'); - warning({ message }); + this.logger.stderr.warning(message); } async handleInstallSpecificVersion(version: string) { // Technically, we could allow downgrades to older versions, but then users would lose the upgrade command 🤷 if (version !== 'latest' && !gte(version, MINIMUM_VERSION_FOR_UPGRADE_COMMAND)) { - error({ - message: `The minimum version of the CLI you can manually downgrade/upgrade to is ${MINIMUM_VERSION_FOR_UPGRADE_COMMAND}.`, - }); + this.logger.stderr.error( + `The minimum version of the CLI you can manually downgrade/upgrade to is ${MINIMUM_VERSION_FOR_UPGRADE_COMMAND}.`, + ); return; } @@ -121,7 +120,9 @@ export class UpgradeCommand extends ApifyCommand { const versionData = await useCLIVersionAssets(version); if (!versionData) { - error({ message: `The provided version does not exist. Please check the version number and try again.` }); + this.logger.stderr.error( + `The provided version does not exist. Please check the version number and try again.`, + ); return; } @@ -129,9 +130,9 @@ export class UpgradeCommand extends ApifyCommand { // Check again, in case `latest` returns an older version for whatever reason if (!gte(versionWithoutV, MINIMUM_VERSION_FOR_UPGRADE_COMMAND)) { - error({ - message: `The minimum version of the CLI you can manually downgrade/upgrade to is ${MINIMUM_VERSION_FOR_UPGRADE_COMMAND}.`, - }); + this.logger.stderr.error( + `The minimum version of the CLI you can manually downgrade/upgrade to is ${MINIMUM_VERSION_FOR_UPGRADE_COMMAND}.`, + ); return; } @@ -139,13 +140,13 @@ export class UpgradeCommand extends ApifyCommand { if (metadata.installMethod === 'bundle') { if (!assets.length) { - error({ - message: [ + this.logger.stderr.error( + [ 'Failed to find the assets for your system and the provided version. Please open an issue on https://github.com/apify/apify-cli/issues/new and provide the following information:', `- The version you are trying to upgrade to: ${versionWithoutV}`, `- The system you are running on: ${metadata.platform} ${metadata.arch}`, ].join('\n'), - }); + ); return; } @@ -158,14 +159,14 @@ export class UpgradeCommand extends ApifyCommand { if (!directoryEntries.some((entry) => entry.startsWith('apify') || entry.startsWith('actor'))) { cliDebugPrint('[upgrade] directoryEntries', directoryEntries); - error({ - message: [ + this.logger.stderr.error( + [ `Failed to find the currently installed ${this.cliName} bundle. Please open an issue on https://github.com/apify/apify-cli/issues/new and provide the following information:`, `- The version you are trying to upgrade to: ${versionWithoutV}`, `- The system you are running on: ${metadata.platform} ${metadata.arch}`, `- The directory where the ${this.cliName} bundle is installed: ${bundleDirectory}`, ].join('\n'), - }); + ); return; } @@ -183,7 +184,7 @@ export class UpgradeCommand extends ApifyCommand { const updateCommand = UPDATE_COMMANDS[metadata.installMethod](version, this.entrypoint); if (process.env.APIFY_CLI_DEBUG) { - info({ message: `Would run command: ${updateCommand.join(' ')}` }); + this.logger.stderr.info(`Would run command: ${updateCommand.join(' ')}`); return; } @@ -192,14 +193,14 @@ export class UpgradeCommand extends ApifyCommand { this.successMessage(versionWithoutV); } catch { - error({ - message: `Failed to upgrade the CLI. Please run the following command manually: ${updateCommand.join(' ')}`, - }); + this.logger.stderr.error( + `Failed to upgrade the CLI. Please run the following command manually: ${updateCommand.join(' ')}`, + ); } } private successMessage(version: string) { - success({ message: `Successfully upgraded to ${version} 👍` }); + this.logger.stderr.success(`Successfully upgraded to ${version} 👍`); } private async startUpgradeProcess(bundleDirectory: string, version: string, assets: Asset[]) { @@ -226,7 +227,7 @@ export class UpgradeCommand extends ApifyCommand { cliDebugPrint('[upgrade] starting upgrade process with args', args); - info({ message: `Starting upgrade process...` }); + this.logger.stderr.info(`Starting upgrade process...`); const upgradeProcess = spawn('powershell.exe', args, { detached: true, @@ -245,7 +246,7 @@ export class UpgradeCommand extends ApifyCommand { }); upgradeProcess.on('error', (err) => { - error({ message: `Failed to start the upgrade process: ${err.message}` }); + this.logger.stderr.error(`Failed to start the upgrade process: ${err.message}`); }); } @@ -261,14 +262,14 @@ export class UpgradeCommand extends ApifyCommand { const res = await fetch(WINDOWS_UPGRADE_SCRIPT_URL, { headers: { 'User-Agent': USER_AGENT } }); if (!res.ok) { - error({ - message: [ + this.logger.stderr.error( + [ `Failed to fetch the upgrade script. Please open an issue on https://github.com/apify/apify-cli/issues/new and provide the following information:`, `- The system you are running on: ${metadata.platform} ${metadata.arch}`, `- The URL of the asset that failed to fetch: ${WINDOWS_UPGRADE_SCRIPT_URL}`, `- The status code of the response: ${res.status}`, ].join('\n'), - }); + ); process.exit(1); } @@ -287,7 +288,7 @@ export class UpgradeCommand extends ApifyCommand { const cliName = asset.name.split('-')[0]; const filePath = join(bundleDirectory, cliName); - info({ message: `Downloading \`${cliName}\` binary of the Apify CLI...` }); + this.logger.stderr.info(`Downloading \`${cliName}\` binary of the Apify CLI...`); const res = await fetch(asset.browser_download_url, { headers: { 'User-Agent': USER_AGENT } }); @@ -296,8 +297,8 @@ export class UpgradeCommand extends ApifyCommand { cliDebugPrint('[upgrade] failed to fetch asset', { asset, status: res.status, body }); - error({ - message: [ + this.logger.stderr.error( + [ `Failed to fetch the ${cliName} bundle. Please open an issue on https://github.com/apify/apify-cli/issues/new and provide the following information:`, `- The version you are trying to upgrade to: ${version}`, `- The system you are running on: ${metadata.platform} ${metadata.arch}`, @@ -305,18 +306,18 @@ export class UpgradeCommand extends ApifyCommand { `- The status code of the response: ${res.status}`, `- The body of the response: ${body}`, ].join('\n'), - }); + ); return; } if (process.env.APIFY_CLI_DEBUG && !process.env.APIFY_CLI_FORCE) { - info({ message: `Would write asset ${cliName} to ${filePath}` }); + this.logger.stderr.info(`Would write asset ${cliName} to ${filePath}`); continue; } - info({ message: chalk.gray(`Writing ${cliName} to ${filePath}...`) }); + this.logger.stderr.info(chalk.gray(`Writing ${cliName} to ${filePath}...`)); const buffer = await res.arrayBuffer(); @@ -336,15 +337,15 @@ export class UpgradeCommand extends ApifyCommand { } catch (err: any) { cliDebugPrint('[upgrade] failed to write asset', { error: err }); - error({ - message: [ + this.logger.stderr.error( + [ `Failed to write the ${cliName} bundle. Please open an issue on https://github.com/apify/apify-cli/issues/new and provide the following information:`, `- The version you are trying to upgrade to: ${version}`, `- The system you are running on: ${metadata.platform} ${metadata.arch}`, `- The URL of the asset that failed to fetch: ${asset.browser_download_url}`, `- The error: ${err.message}`, ].join('\n'), - }); + ); } } } diff --git a/src/commands/create.ts b/src/commands/create.ts index 524cc5187..c8283426e 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -28,7 +28,6 @@ import { usePythonRuntime } from '../lib/hooks/runtimes/python.js'; import { getInstallCommandSuggestion } from '../lib/hooks/runtimes/utils.js'; import { ProjectLanguage, useCwdProject } from '../lib/hooks/useCwdProject.js'; import { createPrefilledInputFileFromInputSchema } from '../lib/input_schema.js'; -import { error, info, simpleLog, success, warning } from '../lib/outputs.js'; import { downloadAndUnzip, getJsonFileContent, @@ -106,11 +105,10 @@ export class CreateCommand extends ApifyCommand { .catch(() => false)); if (folderExists?.isDirectory() && folderHasFiles) { - error({ - message: - `Cannot create new Actor, directory '${actorName}' already exists. Please provide a different name.` + + this.logger.stderr.error( + `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); @@ -174,18 +172,17 @@ export class CreateCommand extends ApifyCommand { if (!project.runtime) { switch (project.type) { case ProjectLanguage.JavaScript: { - warning({ - message: - `No Node.js detected! Please install Node.js ${minimumSupportedNodeVersion} or higher` + + this.logger.stderr.warning( + `No Node.js detected! Please install Node.js ${minimumSupportedNodeVersion} or higher` + ' to be able to run Node.js Actors locally.', - }); + ); break; } case ProjectLanguage.Scrapy: case ProjectLanguage.Python: { - warning({ - message: `No Python detected! Please install Python ${MINIMUM_SUPPORTED_PYTHON_VERSION} or higher to be able to run Python Actors locally.`, - }); + this.logger.stderr.warning( + `No Python detected! Please install Python ${MINIMUM_SUPPORTED_PYTHON_VERSION} or higher to be able to run Python Actors locally.`, + ); break; } default: @@ -199,11 +196,10 @@ export class CreateCommand extends ApifyCommand { switch (project.type) { case ProjectLanguage.JavaScript: { if (!isNodeVersionSupported(runtime.version)) { - warning({ - message: - `You are running Node.js version ${runtime.version}, which is no longer supported. ` + + this.logger.stderr.warning( + `You are running Node.js version ${runtime.version}, which is no longer supported. ` + `Please upgrade to Node.js version ${minimumSupportedNodeVersion} or later.`, - }); + ); } // If the Actor is a Node.js Actor (has package.json), run `npm install` @@ -251,20 +247,20 @@ export class CreateCommand extends ApifyCommand { case ProjectLanguage.Python: case ProjectLanguage.Scrapy: { if (!isPythonVersionSupported(runtime.version)) { - warning({ - message: `Python Actors require Python 3.9 or higher, but you have Python ${runtime.version}!`, - }); - warning({ - message: 'Please install Python 3.9 or higher to be able to run Python Actors locally.', - }); + this.logger.stderr.warning( + `Python Actors require Python 3.9 or higher, but you have Python ${runtime.version}!`, + ); + this.logger.stderr.warning( + 'Please install Python 3.9 or higher to be able to run Python Actors locally.', + ); return; } const venvPath = join(actFolderDir, '.venv'); - info({ message: `Python version ${runtime.version} detected.` }); - info({ - message: `Creating a virtual environment in "${venvPath}" and installing dependencies from "requirements.txt"...`, - }); + this.logger.stderr.info(`Python version ${runtime.version} detected.`); + this.logger.stderr.info( + `Creating a virtual environment in "${venvPath}" and installing dependencies from "requirements.txt"...`, + ); if (!process.env.VIRTUAL_ENV) { // If Python is not running in a virtual environment, create a new one @@ -341,22 +337,22 @@ export class CreateCommand extends ApifyCommand { : null; // Success message with extra empty line - simpleLog({ message: '' }); - success({ - message: formatCreateSuccessMessage({ + this.logger.stderr.log(''); + this.logger.stderr.success( + formatCreateSuccessMessage({ actorName, dependenciesInstalled, postCreate: messages?.postCreate ?? null, gitRepositoryInitialized: !skipGitInit && !cwdHasGit && gitInitResult.success, installCommandSuggestion, }), - }); + ); // Report git initialization result only if it failed (success already included in success message) if (!skipGitInit && !cwdHasGit && !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.' }); + this.logger.stderr.warning(`Failed to initialize git repository: ${gitInitResult.error!.message}`); + this.logger.stderr.warning('You can manually run "git init" in the Actor directory if needed.'); } } } diff --git a/src/commands/datasets/create.ts b/src/commands/datasets/create.ts index 03435debe..ccf5f7ec1 100644 --- a/src/commands/datasets/create.ts +++ b/src/commands/datasets/create.ts @@ -3,8 +3,7 @@ import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { tryToGetDataset } from '../../lib/commands/storages.js'; -import { error, success } from '../../lib/outputs.js'; -import { getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; +import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class DatasetsCreateCommand extends ApifyCommand { static override name = 'create' as const; @@ -29,7 +28,7 @@ export class DatasetsCreateCommand extends ApifyCommand = { @@ -51,7 +50,7 @@ export class DatasetsGetItems extends ApifyCommand { const maybeDataset = await this.tryToGetDataset(apifyClient, datasetId); if (!maybeDataset) { - error({ message: `Dataset with ID "${datasetId}" not found.` }); + this.logger.stderr.error(`Dataset with ID "${datasetId}" not found.`); return; } @@ -68,7 +67,7 @@ export class DatasetsGetItems extends ApifyCommand { const contentType = downloadFormatToContentType[format] ?? 'application/octet-stream'; - simpleLog({ message: contentType }); + this.logger.stderr.log(contentType); process.stdout.write(result); process.stdout.write('\n'); diff --git a/src/commands/datasets/info.ts b/src/commands/datasets/info.ts index 38d88f4b4..6620ce731 100644 --- a/src/commands/datasets/info.ts +++ b/src/commands/datasets/info.ts @@ -7,8 +7,7 @@ import { prettyPrintBytes } from '../../lib/commands/pretty-print-bytes.js'; import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; import { getUserPlanPricing } from '../../lib/commands/storage-size.js'; import { tryToGetDataset } from '../../lib/commands/storages.js'; -import { error, simpleLog } from '../../lib/outputs.js'; -import { getLoggedClientOrThrow, printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; +import { getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js'; const consoleLikeTable = new ResponsiveTable({ allColumns: ['Row1', 'Row2'], @@ -36,9 +35,7 @@ export class DatasetsInfoCommand extends ApifyCommand { const rawDatasetList = await client.datasets().list({ desc, offset, limit, unnamed }); if (json) { - printJsonToStdout(rawDatasetList); + this.logger.stdout.json(rawDatasetList); return; } if (rawDatasetList.count === 0) { - info({ - message: "You don't have any Datasets on your account", - stdout: true, - }); + this.logger.stdout.info("You don't have any Datasets on your account"); return; } @@ -80,9 +76,6 @@ export class DatasetsLsCommand extends ApifyCommand { }); } - simpleLog({ - message: table.render(CompactMode.WebLikeCompact), - stdout: true, - }); + this.logger.stdout.log(table.render(CompactMode.WebLikeCompact)); } } diff --git a/src/commands/datasets/push-items.ts b/src/commands/datasets/push-items.ts index 2593e71ce..24a5c16f8 100644 --- a/src/commands/datasets/push-items.ts +++ b/src/commands/datasets/push-items.ts @@ -5,7 +5,6 @@ import { cachedStdinInput } from '../../entrypoints/_shared.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { tryToGetDataset } from '../../lib/commands/storages.js'; -import { error, success } from '../../lib/outputs.js'; import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class DatasetsPushDataCommand extends ApifyCommand { @@ -31,9 +30,7 @@ export class DatasetsPushDataCommand extends ApifyCommand { @@ -34,14 +33,12 @@ export class DatasetsRenameCommand extends ApifyCommand { @@ -28,9 +27,7 @@ export class DatasetsRmCommand extends ApifyCommand { const existingDataset = await tryToGetDataset(client, datasetNameOrId); if (!existingDataset) { - error({ - message: `Dataset with ID or name "${datasetNameOrId}" not found.`, - }); + this.logger.stderr.error(`Dataset with ID or name "${datasetNameOrId}" not found.`); return; } @@ -40,7 +37,7 @@ export class DatasetsRmCommand extends ApifyCommand { }); if (!confirmed) { - info({ message: 'Dataset deletion has been aborted.' }); + this.logger.stderr.info('Dataset deletion has been aborted.'); return; } @@ -49,16 +46,15 @@ export class DatasetsRmCommand extends ApifyCommand { try { await existingDataset.datasetClient.delete(); - success({ - message: `Dataset with ID ${chalk.yellow(id)}${name ? ` (called ${chalk.yellow(name)})` : ''} has been deleted.`, - stdout: true, - }); + this.logger.stdout.success( + `Dataset with ID ${chalk.yellow(id)}${name ? ` (called ${chalk.yellow(name)})` : ''} has been deleted.`, + ); } catch (err) { const casted = err as ApifyApiError; - error({ - message: `Failed to delete dataset with ID ${chalk.yellow(id)}\n ${casted.message || casted}`, - }); + this.logger.stderr.error( + `Failed to delete dataset with ID ${chalk.yellow(id)}\n ${casted.message || casted}`, + ); } } } diff --git a/src/commands/edit-input-schema.ts b/src/commands/edit-input-schema.ts index 533ed1bf9..6617a74a1 100644 --- a/src/commands/edit-input-schema.ts +++ b/src/commands/edit-input-schema.ts @@ -14,7 +14,6 @@ import { ApifyCommand } from '../lib/command-framework/apify-command.js'; import { Args } from '../lib/command-framework/args.js'; import { LOCAL_CONFIG_PATH } from '../lib/consts.js'; import { readInputSchema } from '../lib/input_schema.js'; -import { error, info, success, warning } from '../lib/outputs.js'; const INPUT_SCHEMA_EDITOR_BASE_URL = 'https://apify.github.io/input-schema-editor-react/'; const INPUT_SCHEMA_EDITOR_ORIGIN = new URL(INPUT_SCHEMA_EDITOR_BASE_URL).origin; @@ -55,8 +54,10 @@ export class EditInputSchemaCommand extends ApifyCommand { try { - info({ message: 'Got input schema from editor...' }); + this.logger.stderr.info('Got input schema from editor...'); const inputSchemaObj = req.body; let inputSchemaStr = JSON.stringify(inputSchemaObj, null, jsonIndentation); if (appendFinalNewline) inputSchemaStr += '\n'; @@ -161,10 +162,10 @@ export class EditInputSchemaCommand extends ApifyCommand { if (req.body.isWindowClosed) { - info({ message: 'Editor closed, finishing...' }); + this.logger.stderr.info('Editor closed, finishing...'); } else { - info({ message: 'Editing finished, you can close the editor.' }); + this.logger.stderr.info('Editing finished, you can close the editor.'); } res.end(); - server.close(() => success({ message: 'Done.' })); + server.close(() => this.logger.stderr.success('Done.')); }); // Listening on port 0 will assign a random available port server = app.listen(0); const { port } = server.address() as AddressInfo; - info({ message: `Listening for messages from input schema editor on port ${port}...` }); + this.logger.stderr.info(`Listening for messages from input schema editor on port ${port}...`); const editorUrl = `${INPUT_SCHEMA_EDITOR_BASE_URL}?localCliPort=${port}&localCliToken=${authToken}&localCliApiVersion=${API_VERSION}`; - info({ message: `Opening input schema editor at "${editorUrl}"...` }); + this.logger.stderr.info(`Opening input schema editor at "${editorUrl}"...`); await open(editorUrl); } } diff --git a/src/commands/help.ts b/src/commands/help.ts index c0b7588b3..68cdd86d4 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -4,7 +4,6 @@ import { ApifyCommand, commandRegistry } from '../lib/command-framework/apify-co import { Args } from '../lib/command-framework/args.js'; import { renderHelpForCommand, renderMainHelpMenu } from '../lib/command-framework/help.js'; import { useCommandSuggestions } from '../lib/hooks/useCommandSuggestions.js'; -import { error } from '../lib/outputs.js'; export class HelpCommand extends ApifyCommand { static override name = 'help' as const; @@ -48,9 +47,7 @@ export class HelpCommand extends ApifyCommand { ); } - error({ - message, - }); + this.logger.stderr.error(message); return; } diff --git a/src/commands/init-wrap-scrapy.ts b/src/commands/init-wrap-scrapy.ts index b390ed493..2e59303cf 100644 --- a/src/commands/init-wrap-scrapy.ts +++ b/src/commands/init-wrap-scrapy.ts @@ -1,6 +1,5 @@ import { ApifyCommand } from '../lib/command-framework/apify-command.js'; import { Args } from '../lib/command-framework/args.js'; -import { info } from '../lib/outputs.js'; import { wrapScrapyProject } from '../lib/projects/scrapy/wrapScrapyProject.js'; export class WrapScrapyCommand extends ApifyCommand { @@ -27,6 +26,6 @@ It adds the following features: async run() { await wrapScrapyProject({ projectPath: this.args.path }); - info({ message: 'Scrapy project wrapped successfully.' }); + this.logger.stderr.info('Scrapy project wrapped successfully.'); } } diff --git a/src/commands/init.ts b/src/commands/init.ts index 347d97f50..790fab865 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -10,7 +10,6 @@ import { ProjectLanguage, useCwdProject } from '../lib/hooks/useCwdProject.js'; import { useUserInput } from '../lib/hooks/user-confirmations/useUserInput.js'; import { useYesNoConfirm } from '../lib/hooks/user-confirmations/useYesNoConfirm.js'; import { createPrefilledInputFileFromInputSchema } from '../lib/input_schema.js'; -import { error, info, success, warning } from '../lib/outputs.js'; import { wrapScrapyProject } from '../lib/projects/scrapy/wrapScrapyProject.js'; import { sanitizeActorName, setLocalConfig, setLocalEnv, validateActorName } from '../lib/utils.js'; @@ -51,7 +50,7 @@ export class InitCommand extends ApifyCommand { // TODO: use direct .unwrap() once we migrate to yargs if (projectResult.isErr()) { - error({ message: projectResult.unwrapErr().message }); + this.logger.stderr.error(projectResult.unwrapErr().message); process.exit(1); } @@ -59,7 +58,7 @@ export class InitCommand extends ApifyCommand { if (project.warnings?.length) { for (const w of project.warnings) { - warning({ message: w }); + this.logger.stderr.warning(w); } } @@ -72,14 +71,16 @@ export class InitCommand extends ApifyCommand { } if (project.type === ProjectLanguage.Scrapy) { - info({ message: 'The current directory looks like a Scrapy project. Using automatic project wrapping.' }); + this.logger.stderr.info( + 'The current directory looks like a Scrapy project. Using automatic project wrapping.', + ); this.telemetryData.actorWrapper = 'scrapy'; return wrapScrapyProject({ projectPath: cwd }); } if (!this.flags.yes && project.type === ProjectLanguage.Unknown) { - warning({ message: 'The current directory does not look like a Node.js or Python project.' }); + this.logger.stderr.warning('The current directory does not look like a Node.js or Python project.'); const confirmed = await useYesNoConfirm({ message: 'Do you want to continue?', @@ -94,12 +95,12 @@ export class InitCommand extends ApifyCommand { const actorConfig = await useActorConfig({ cwd }); if (actorConfig.isOkAnd((cfg) => cfg.exists && !cfg.migrated)) { - warning({ - message: `Skipping creation of '${LOCAL_CONFIG_PATH}', the file already exists in the current directory.`, - }); + this.logger.stderr.warning( + `Skipping creation of '${LOCAL_CONFIG_PATH}', the file already exists in the current directory.`, + ); } else { if (actorConfig.isErr()) { - error({ message: actorConfig.unwrapErr().message }); + this.logger.stderr.error(actorConfig.unwrapErr().message); process.exitCode = CommandExitCodes.InvalidActorJson; return; } @@ -121,7 +122,7 @@ export class InitCommand extends ApifyCommand { response = answer; } catch (err) { - error({ message: (err as Error).message }); + this.logger.stderr.error((err as Error).message); } } @@ -151,6 +152,6 @@ export class InitCommand extends ApifyCommand { // Create prefilled INPUT.json file from the input schema prefills await createPrefilledInputFileFromInputSchema(cwd); - success({ message: 'The Actor has been initialized in the current directory.' }); + this.logger.stderr.success('The Actor has been initialized in the current directory.'); } } diff --git a/src/commands/key-value-stores/create.ts b/src/commands/key-value-stores/create.ts index 91f4b2dd4..15ee89af6 100644 --- a/src/commands/key-value-stores/create.ts +++ b/src/commands/key-value-stores/create.ts @@ -3,8 +3,7 @@ import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { tryToGetKeyValueStore } from '../../lib/commands/storages.js'; -import { error, success } from '../../lib/outputs.js'; -import { getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; +import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class KeyValueStoresCreateCommand extends ApifyCommand { static override name = 'create' as const; @@ -29,7 +28,7 @@ export class KeyValueStoresCreateCommand extends ApifyCommand { @@ -31,9 +30,7 @@ export class KeyValueStoresDeleteValueCommand extends ApifyCommand { @@ -37,7 +36,7 @@ export class KeyValueStoresGetValueCommand extends ApifyCommand { @@ -34,14 +33,12 @@ export class KeyValueStoresRenameCommand extends ApifyCommand { @@ -28,9 +27,7 @@ export class KeyValueStoresRmCommand extends ApifyCommand { @@ -41,9 +40,7 @@ export class KeyValueStoresSetValueCommand extends ApifyCommand { if (localConfigResult.isErr()) { const { message, cause } = localConfigResult.unwrapErr(); - error({ message: `${message}${cause ? `\n ${cause.message}` : ''}` }); + this.logger.stderr.error(`${message}${cause ? `\n ${cause.message}` : ''}`); process.exitCode = CommandExitCodes.InvalidActorJson; return; } @@ -128,7 +127,7 @@ export class RunCommand extends ApifyCommand { const projectRuntimeResult = await useCwdProject({ cwd }); if (projectRuntimeResult.isErr()) { - error({ message: projectRuntimeResult.unwrapErr().message }); + this.logger.stderr.error(projectRuntimeResult.unwrapErr().message); process.exitCode = CommandExitCodes.InvalidActorJson; return; } @@ -138,7 +137,7 @@ export class RunCommand extends ApifyCommand { if (project.warnings?.length) { for (const w of project.warnings) { - warning({ message: w }); + this.logger.stderr.warning(w); } } @@ -152,20 +151,20 @@ export class RunCommand extends ApifyCommand { if (!runtime) { switch (type) { case ProjectLanguage.JavaScript: - error({ - message: `No Node.js detected! Please install Node.js ${SUPPORTED_NODEJS_VERSION} (or higher) to be able to run Node.js Actors locally.`, - }); + this.logger.stderr.error( + `No Node.js detected! Please install Node.js ${SUPPORTED_NODEJS_VERSION} (or higher) to be able to run Node.js Actors locally.`, + ); break; case ProjectLanguage.Scrapy: case ProjectLanguage.Python: - error({ - message: `No Python detected! Please install Python ${MINIMUM_SUPPORTED_PYTHON_VERSION} (or higher) to be able to run Python Actors locally.`, - }); + this.logger.stderr.error( + `No Python detected! Please install Python ${MINIMUM_SUPPORTED_PYTHON_VERSION} (or higher) to be able to run Python Actors locally.`, + ); break; default: - error({ - message: `No runtime detected! Make sure you have Python ${MINIMUM_SUPPORTED_PYTHON_VERSION} (or higher) or Node.js ${SUPPORTED_NODEJS_VERSION} (or higher) installed.`, - }); + this.logger.stderr.error( + `No runtime detected! Make sure you have Python ${MINIMUM_SUPPORTED_PYTHON_VERSION} (or higher) or Node.js ${SUPPORTED_NODEJS_VERSION} (or higher) installed.`, + ); } return; @@ -200,20 +199,19 @@ export class RunCommand extends ApifyCommand { runType = type !== ProjectLanguage.JavaScript ? RunType.Module : RunType.DirectFile; entrypoint = cwdEntrypoint.path; } else { - error({ - message: `No entrypoint detected! Please provide an entrypoint using the --entrypoint flag, or make sure your project has an entrypoint.`, - }); + this.logger.stderr.error( + `No entrypoint detected! Please provide an entrypoint using the --entrypoint flag, or make sure your project has an entrypoint.`, + ); return; } if (existsSync(LEGACY_LOCAL_STORAGE_DIR) && !existsSync(actualStoragePath)) { renameSync(LEGACY_LOCAL_STORAGE_DIR, actualStoragePath); - warning({ - message: - `The legacy 'apify_storage' directory was renamed to '${actualStoragePath}' to align it with Apify SDK v3.` + + this.logger.stderr.warning( + `The legacy 'apify_storage' directory was renamed to '${actualStoragePath}' to align it with Apify SDK v3.` + ' Contents were left intact.', - }); + ); } const crawleeVersion = await useModuleVersion({ @@ -234,7 +232,7 @@ export class RunCommand extends ApifyCommand { if (crawleeVersion.isNone()) { await Promise.all([purgeDefaultQueue(), purgeDefaultKeyValueStore(), purgeDefaultDataset()]); - info({ message: 'All default local stores were purged.' }); + this.logger.stderr.info('All default local stores were purged.'); } } @@ -242,11 +240,10 @@ export class RunCommand extends ApifyCommand { const isStorageEmpty = await checkIfStorageIsEmpty(); if (!isStorageEmpty && !this.flags.resurrect) { - warning({ - message: - 'The storage directory contains a previous state, the Actor will continue where it left off. ' + + this.logger.stderr.warning( + 'The storage directory contains a previous state, the Actor will continue where it left off. ' + 'To start from the initial state, use --purge parameter to clean the storage directory.', - }); + ); } } @@ -283,10 +280,9 @@ export class RunCommand extends ApifyCommand { const env = Object.assign(localEnvVars, process.env); if (!userId) { - warning({ - message: - 'You are not logged in with your Apify Account. Some features like Apify Proxy will not work. Call "apify login" to fix that.', - }); + this.logger.stderr.warning( + 'You are not logged in with your Apify Account. Some features like Apify Proxy will not work. Call "apify login" to fix that.', + ); } try { @@ -303,11 +299,10 @@ export class RunCommand extends ApifyCommand { ? `${env.NODE_OPTIONS} --max-http-header-size=80000` : '--max-http-header-size=80000'; } else { - warning({ - message: - `You are running Node.js version ${runtime.version}, which is no longer supported. ` + + this.logger.stderr.warning( + `You are running Node.js version ${runtime.version}, which is no longer supported. ` + `Please upgrade to Node.js version ${minimumSupportedNodeVersion} or later.`, - }); + ); } if (runType === RunType.DirectFile || runType === RunType.Module) { @@ -354,12 +349,12 @@ export class RunCommand extends ApifyCommand { case ProjectLanguage.Python: case ProjectLanguage.Scrapy: { if (!isPythonVersionSupported(runtime.version)) { - error({ - message: `Python Actors require Python 3.9 or higher, but you have Python ${runtime.version}!`, - }); - error({ - message: 'Please install Python 3.9 or higher to be able to run Python Actors locally.', - }); + this.logger.stderr.error( + `Python Actors require Python 3.9 or higher, but you have Python ${runtime.version}!`, + ); + this.logger.stderr.error( + 'Please install Python 3.9 or higher to be able to run Python Actors locally.', + ); return; } @@ -381,9 +376,9 @@ export class RunCommand extends ApifyCommand { break; } default: - error({ - message: `Failed to detect the language of your project. Please report this issue to the Apify team with your project structure over at https://github.com/apify/apify-cli/issues`, - }); + this.logger.stderr.error( + `Failed to detect the language of your project. Please report this issue to the Apify team with your project structure over at https://github.com/apify/apify-cli/issues`, + ); } } catch (err) { const { stderr } = err as ExecaError; @@ -401,9 +396,9 @@ export class RunCommand extends ApifyCommand { // If its in a 5ms range, we assume the file was modified (realistically impossible) if (mtime - storedInputResults.writtenAt >= 5) { - warning({ - message: `The "${storedInputResults.inputFilePath}" file was overwritten during the run. The CLI will not undo the setting of missing default fields from your input schema.`, - }); + this.logger.stderr.warning( + `The "${storedInputResults.inputFilePath}" file was overwritten during the run. The CLI will not undo the setting of missing default fields from your input schema.`, + ); // eslint-disable-next-line no-unsafe-finally -- we do not return anything in the commands anyways return; diff --git a/src/commands/runs/abort.ts b/src/commands/runs/abort.ts index cb2e5dc10..8da454708 100644 --- a/src/commands/runs/abort.ts +++ b/src/commands/runs/abort.ts @@ -5,8 +5,7 @@ import { ACTOR_JOB_STATUSES } from '@apify/consts'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; -import { error, success } from '../../lib/outputs.js'; -import { getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; +import { getLoggedClientOrThrow } from '../../lib/utils.js'; const runningStatuses = [ACTOR_JOB_STATUSES.READY, ACTOR_JOB_STATUSES.RUNNING]; @@ -42,15 +41,15 @@ export class RunsAbortCommand extends ApifyCommand { const run = await apifyClient.run(runId).get(); if (!run) { - error({ message: `Run with ID "${runId}" was not found on your account.`, stdout: true }); + this.logger.stdout.error(`Run with ID "${runId}" was not found on your account.`); return; } if (!runningStatuses.includes(run.status as never)) { if (abortingStatuses.includes(run.status as never)) { - error({ message: `Run with ID "${runId}" is already aborting.`, stdout: true }); + this.logger.stdout.error(`Run with ID "${runId}" is already aborting.`); } else { - error({ message: `Run with ID "${runId}" is already aborted.`, stdout: true }); + this.logger.stdout.error(`Run with ID "${runId}" is already aborted.`); } return; @@ -60,25 +59,21 @@ export class RunsAbortCommand extends ApifyCommand { const result = await apifyClient.run(runId).abort({ gracefully: !this.flags.force }); if (this.flags.json) { - printJsonToStdout(result); + this.logger.stdout.json(result); return; } if (this.flags.force) { - success({ message: `Triggered the immediate abort of run "${runId}".`, stdout: true }); + this.logger.stdout.success(`Triggered the immediate abort of run "${runId}".`); } else { - success({ - message: `Triggered the abort of run "${runId}", it should finish aborting in up to 30 seconds.`, - stdout: true, - }); + this.logger.stdout.success( + `Triggered the abort of run "${runId}", it should finish aborting in up to 30 seconds.`, + ); } } catch (err) { const casted = err as ApifyApiError; - error({ - message: `Failed to abort run "${runId}".\n ${casted.message || casted}`, - stdout: true, - }); + this.logger.stdout.error(`Failed to abort run "${runId}".\n ${casted.message || casted}`); } } } diff --git a/src/commands/runs/info.ts b/src/commands/runs/info.ts index fcf813d4a..bc2ca0097 100644 --- a/src/commands/runs/info.ts +++ b/src/commands/runs/info.ts @@ -7,13 +7,7 @@ import { Flags } from '../../lib/command-framework/flags.js'; import { prettyPrintBytes } from '../../lib/commands/pretty-print-bytes.js'; import { prettyPrintStatus } from '../../lib/commands/pretty-print-status.js'; import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; -import { error, simpleLog } from '../../lib/outputs.js'; -import { - getLoggedClientOrThrow, - printJsonToStdout, - ShortDurationFormatter, - TimestampFormatter, -} from '../../lib/utils.js'; +import { getLoggedClientOrThrow, ShortDurationFormatter, TimestampFormatter } from '../../lib/utils.js'; const usageTable = new ResponsiveTable({ allColumns: ['', 'Unit', 'USD Amount'], @@ -69,7 +63,7 @@ export class RunsInfoCommand extends ApifyCommand { const run = await apifyClient.run(runId).get(); if (!run) { - error({ message: `Run with ID "${runId}" was not found on your account.` }); + this.logger.stderr.error(`Run with ID "${runId}" was not found on your account.`); return; } @@ -83,7 +77,7 @@ export class RunsInfoCommand extends ApifyCommand { ]); if (this.flags.json) { - printJsonToStdout({ + this.logger.stdout.json({ ...run, actor, build, @@ -251,7 +245,7 @@ export class RunsInfoCommand extends ApifyCommand { message.push(`${chalk.blue('View saved items')}: ${keyValueStoreUrl}`); message.push(`${chalk.blue('View in Apify Console')}: ${url}`); - simpleLog({ message: message.join('\n'), stdout: true }); + this.logger.stdout.log(message.join('\n')); } private addDetailedUsage(run: ActorRun) { diff --git a/src/commands/runs/log.ts b/src/commands/runs/log.ts index a59a0432b..26982714b 100644 --- a/src/commands/runs/log.ts +++ b/src/commands/runs/log.ts @@ -1,6 +1,5 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; -import { error, info } from '../../lib/outputs.js'; import { getLoggedClientOrThrow, outputJobLog } from '../../lib/utils.js'; export class RunsLogCommand extends ApifyCommand { @@ -23,17 +22,17 @@ export class RunsLogCommand extends ApifyCommand { const run = await apifyClient.run(runId).get(); if (!run) { - error({ message: `Run with ID "${runId}" was not found on your account.`, stdout: true }); + this.logger.stdout.error(`Run with ID "${runId}" was not found on your account.`); return; } - info({ message: `Log for run with ID "${runId}":\n`, stdout: true }); + this.logger.stdout.info(`Log for run with ID "${runId}":\n`); try { await outputJobLog({ job: run, apifyClient }); } catch (err) { // This should never happen... - error({ message: `Failed to get log for run with ID "${runId}": ${(err as Error).message}` }); + this.logger.stderr.error(`Failed to get log for run with ID "${runId}": ${(err as Error).message}`); } } } diff --git a/src/commands/runs/ls.ts b/src/commands/runs/ls.ts index a531da255..9ebe981d6 100644 --- a/src/commands/runs/ls.ts +++ b/src/commands/runs/ls.ts @@ -6,13 +6,7 @@ import { Flags } from '../../lib/command-framework/flags.js'; import { prettyPrintStatus } from '../../lib/commands/pretty-print-status.js'; import { resolveActorContext } from '../../lib/commands/resolve-actor-context.js'; import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; -import { error, simpleLog } from '../../lib/outputs.js'; -import { - getLoggedClientOrThrow, - MultilineTimestampFormatter, - printJsonToStdout, - ShortDurationFormatter, -} from '../../lib/utils.js'; +import { getLoggedClientOrThrow, MultilineTimestampFormatter, ShortDurationFormatter } from '../../lib/utils.js'; const table = new ResponsiveTable({ allColumns: ['ID', 'Status', 'Results', 'Usage', 'Started At', 'Took', 'Build No.', 'Origin'], @@ -69,9 +63,9 @@ export class RunsLsCommand extends ApifyCommand { const ctx = await resolveActorContext({ providedActorNameOrId: actorId, client }); if (!ctx.valid) { - error({ - message: `${ctx.reason}. Please run this command in an Actor directory, or specify the Actor ID.`, - }); + this.logger.stderr.error( + `${ctx.reason}. Please run this command in an Actor directory, or specify the Actor ID.`, + ); return; } @@ -79,14 +73,12 @@ export class RunsLsCommand extends ApifyCommand { const allRuns = await client.actor(ctx.id).runs().list({ desc, limit, offset }); if (json) { - printJsonToStdout(allRuns); + this.logger.stdout.json(allRuns); return; } if (!allRuns.items.length) { - simpleLog({ - message: 'There are no recent runs found for this Actor.', - }); + this.logger.stderr.log('There are no recent runs found for this Actor.'); return; } @@ -136,9 +128,6 @@ export class RunsLsCommand extends ApifyCommand { message.push(table.render(compact ? CompactMode.VeryCompact : CompactMode.WebLikeCompact)); - simpleLog({ - message: message.join('\n'), - stdout: true, - }); + this.logger.stdout.log(message.join('\n')); } } diff --git a/src/commands/runs/resurrect.ts b/src/commands/runs/resurrect.ts index 0dbdcb05e..88b0a88fd 100644 --- a/src/commands/runs/resurrect.ts +++ b/src/commands/runs/resurrect.ts @@ -4,8 +4,7 @@ import { ACTOR_JOB_STATUSES } from '@apify/consts'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; -import { error, success } from '../../lib/outputs.js'; -import { getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; +import { getLoggedClientOrThrow } from '../../lib/utils.js'; const resurrectStatuses = [ ACTOR_JOB_STATUSES.SUCCEEDED, @@ -36,15 +35,14 @@ export class RunsResurrectCommand extends ApifyCommand { const run = await apifyClient.run(runId).get(); if (!run) { - error({ message: `Run with ID "${runId}" was not found on your account.` }); + this.logger.stderr.error(`Run with ID "${runId}" was not found on your account.`); return; } if (!deletableStatuses.includes(run.status as never)) { - error({ - message: `Run with ID "${runId}" cannot be deleted, as it is still running or in the process of aborting.`, - }); + this.logger.stderr.error( + `Run with ID "${runId}" cannot be deleted, as it is still running or in the process of aborting.`, + ); return; } @@ -52,9 +51,7 @@ export class RunsRmCommand extends ApifyCommand { }); if (!confirmedDelete) { - info({ - message: `Deletion of run "${runId}" was canceled.`, - }); + this.logger.stderr.info(`Deletion of run "${runId}" was canceled.`); return; } @@ -62,12 +59,10 @@ export class RunsRmCommand extends ApifyCommand { try { await apifyClient.run(runId).delete(); - success({ - message: `Run with ID "${runId}" was deleted.`, - }); + this.logger.stderr.success(`Run with ID "${runId}" was deleted.`); } catch (err) { const casted = err as ApifyApiError; - error({ message: `Failed to delete run "${runId}".\n ${casted.message || casted}` }); + this.logger.stderr.error(`Failed to delete run "${runId}".\n ${casted.message || casted}`); } } } diff --git a/src/commands/secrets/ls.ts b/src/commands/secrets/ls.ts index c867bebc9..a5c325f7e 100644 --- a/src/commands/secrets/ls.ts +++ b/src/commands/secrets/ls.ts @@ -2,9 +2,7 @@ import chalk from 'chalk'; 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 { getSecretsFile } from '../../lib/secrets.js'; -import { printJsonToStdout } from '../../lib/utils.js'; const table = new ResponsiveTable({ allColumns: ['Secret Name'], @@ -28,15 +26,14 @@ export class SecretsLsCommand extends ApifyCommand { const secretKeys = Object.keys(secrets); if (json) { - printJsonToStdout({ keys: secretKeys }); + this.logger.stdout.json({ keys: secretKeys }); return; } if (secretKeys.length === 0) { - info({ - message: "You don't have any secrets stored locally. Use 'apify secrets add' to add a secret.", - stdout: true, - }); + this.logger.stdout.info( + "You don't have any secrets stored locally. Use 'apify secrets add' to add a secret.", + ); return; } @@ -47,9 +44,6 @@ export class SecretsLsCommand extends ApifyCommand { }); } - simpleLog({ - message: table.render(CompactMode.WebLikeCompact), - stdout: true, - }); + this.logger.stdout.log(table.render(CompactMode.WebLikeCompact)); } } diff --git a/src/commands/task/run.ts b/src/commands/task/run.ts index 905006404..3c90c6653 100644 --- a/src/commands/task/run.ts +++ b/src/commands/task/run.ts @@ -4,7 +4,6 @@ import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands/run-on-cloud.js'; -import { simpleLog } from '../../lib/outputs.js'; import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; export class TaskRunCommand extends ApifyCommand { @@ -65,14 +64,13 @@ export class TaskRunCommand extends ApifyCommand { datasetUrl = `https://console.apify.com/storage/datasets/${yieldedRun.defaultDatasetId}`; } - simpleLog({ - message: [ + this.logger.stdout.log( + [ '', `${chalk.blue('Export results')}: ${datasetUrl!}`, `${chalk.blue('View on Apify Console')}: ${url!}`, ].join('\n'), - stdout: true, - }); + ); } private async resolveTaskId(client: ApifyClient, usernameOrId: string) { diff --git a/src/commands/telemetry/disable.ts b/src/commands/telemetry/disable.ts index f1fe66ecf..1952c32c8 100644 --- a/src/commands/telemetry/disable.ts +++ b/src/commands/telemetry/disable.ts @@ -1,6 +1,5 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { updateTelemetryEnabled, useTelemetryState } from '../../lib/hooks/telemetry/useTelemetryState.js'; -import { info, success } from '../../lib/outputs.js'; export class TelemetryDisableCommand extends ApifyCommand { static override name = 'disable' as const; @@ -13,9 +12,9 @@ export class TelemetryDisableCommand extends ApifyCommand { static override name = 'enable' as const; @@ -11,11 +10,11 @@ export class TelemetryEnableCommand extends ApifyCommand { static override name = 'validate-schema' as const; @@ -36,6 +35,6 @@ Optionally specify custom schema path to validate.`; : `Validating input schema embedded in '${LOCAL_CONFIG_PATH}'`, }); - success({ message: 'Input schema is valid.' }); + this.logger.stderr.success('Input schema is valid.'); } } diff --git a/src/entrypoints/_shared.ts b/src/entrypoints/_shared.ts index 1c5c17c6d..6bfaba477 100644 --- a/src/entrypoints/_shared.ts +++ b/src/entrypoints/_shared.ts @@ -14,7 +14,7 @@ import { SUPPORTED_NODEJS_VERSION } from '../lib/consts.js'; import { useCLIMetadata } from '../lib/hooks/useCLIMetadata.js'; import { shouldSkipVersionCheck } from '../lib/hooks/useCLIVersionCheck.js'; import { useCommandSuggestions } from '../lib/hooks/useCommandSuggestions.js'; -import { error } from '../lib/outputs.js'; +import { logger } from '../lib/logger.js'; import { cliDebugPrint } from '../lib/utils/cliDebugPrint.js'; export const cachedStdinInput = await readStdin(); @@ -29,9 +29,9 @@ export function processVersionCheck(cliName: string) { } if (!satisfies(process.version, SUPPORTED_NODEJS_VERSION)) { - error({ - message: `${cliName} CLI requires Node.js version ${SUPPORTED_NODEJS_VERSION}. Your current version is ${process.version}.`, - }); + logger.stderr.error( + `${cliName} CLI requires Node.js version ${SUPPORTED_NODEJS_VERSION}. Your current version is ${process.version}.`, + ); process.exit(1); } @@ -80,7 +80,7 @@ function handleCommandNotFound(commandName: string): never { message += chalk.gray(`Did you mean: ${closestMatches.map((cmd) => chalk.whiteBright(cmd)).join(', ')}?`); } - error({ message }); + logger.stderr.error(message); process.exit(1); } @@ -215,7 +215,7 @@ export async function runCLI(entrypoint: string) { } catch (err) { const commandError = CommandError.into(err, FinalCommand); - error({ message: commandError.getPrettyMessage() }); + logger.stderr.error(commandError.getPrettyMessage()); process.exit(1); } diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index fc32080b5..ab94e7d17 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -15,7 +15,7 @@ import { trackEvent } from '../hooks/telemetry/trackEvent.js'; import { checkAndUpdateLastCommand } from '../hooks/telemetry/useTelemetryState.js'; import { useCLIMetadata } from '../hooks/useCLIMetadata.js'; import { ProjectLanguage, useCwdProject } from '../hooks/useCwdProject.js'; -import { error } from '../outputs.js'; +import { logger } from '../logger.js'; import type { ArgTag, TaggedArgBuilder } from './args.js'; import { CommandError, CommandErrorCode } from './CommandError.js'; import type { FlagTag, TaggedFlagBuilder } from './flags.js'; @@ -205,6 +205,16 @@ export abstract class ApifyCommand(...)` for output meant to be piped / + * scripted against (raw values, JSON) and `this.logger.stderr.(...)` + * for progress, warnings, and errors the user reads but does not consume. + */ + protected get logger() { + return logger; + } + public constructor(entrypoint: string, commandString: string, aliasUsed: string, subcommandAliasUsed?: string) { this.entrypoint = entrypoint; this.commandString = commandString; @@ -318,7 +328,7 @@ export abstract class ApifyCommand(); export function registerCommandForHelpGeneration(entrypoint: string, command: typeof BuiltApifyCommand) { if (command.name.toLowerCase() !== command.name) { - error({ - message: `Command name "${command.name}" is not correctly set up internally. Make sure you fill out the "name" field in the command class extension.`, - }); + logger.stderr.error( + `Command name "${command.name}" is not correctly set up internally. Make sure you fill out the "name" field in the command class extension.`, + ); return; } diff --git a/src/lib/commands/resolve-input.ts b/src/lib/commands/resolve-input.ts index ef85bd383..bb2765013 100644 --- a/src/lib/commands/resolve-input.ts +++ b/src/lib/commands/resolve-input.ts @@ -6,7 +6,7 @@ import mime from 'mime'; import { cachedStdinInput } from '../../entrypoints/_shared.js'; import { CommandExitCodes } from '../consts.js'; -import { error } from '../outputs.js'; +import { logger } from '../logger.js'; import { getLocalInput } from '../utils.js'; export function resolveInput(cwd: string, inputOverride: Record | undefined) { @@ -52,7 +52,7 @@ export async function getInputOverride(cwd: string, inputFlag: string | undefine const parsed = JSON.parse(stdin.toString('utf8')); if (Array.isArray(parsed)) { - error({ message: 'The provided input is invalid. It should be an object, not an array.' }); + logger.stderr.error('The provided input is invalid. It should be an object, not an array.'); process.exitCode = CommandExitCodes.InvalidInput; return false; } @@ -60,7 +60,7 @@ export async function getInputOverride(cwd: string, inputFlag: string | undefine input = parsed; source = 'stdin'; } catch (err) { - error({ message: `Cannot parse JSON input from standard input.\n ${(err as Error).message}` }); + logger.stderr.error(`Cannot parse JSON input from standard input.\n ${(err as Error).message}`); process.exitCode = CommandExitCodes.InvalidInput; return false; } @@ -70,10 +70,9 @@ export async function getInputOverride(cwd: string, inputFlag: string | undefine if (inputFlag) { switch (inputFlag[0]) { case '-': { - error({ - message: - 'You need to pipe something into standard input when you specify the `-` value to `--input`.', - }); + logger.stderr.error( + 'You need to pipe something into standard input when you specify the `-` value to `--input`.', + ); process.exitCode = CommandExitCodes.InvalidInput; return false; } @@ -98,9 +97,9 @@ export async function getInputOverride(cwd: string, inputFlag: string | undefine inputFlag.startsWith('..\\'); if (fileExists || inputLooksLikePath) { - error({ - message: `Providing a JSON file path in the --input flag is not supported. Use the "--input-file=" flag instead`, - }); + logger.stderr.error( + `Providing a JSON file path in the --input flag is not supported. Use the "--input-file=" flag instead`, + ); process.exitCode = CommandExitCodes.InvalidInput; return false; } @@ -109,7 +108,7 @@ export async function getInputOverride(cwd: string, inputFlag: string | undefine const parsed = JSON.parse(inputFlag); if (Array.isArray(parsed)) { - error({ message: 'The provided input is invalid. It should be an object, not an array.' }); + logger.stderr.error('The provided input is invalid. It should be an object, not an array.'); process.exitCode = CommandExitCodes.InvalidInput; return false; } @@ -117,7 +116,7 @@ export async function getInputOverride(cwd: string, inputFlag: string | undefine input = parsed; source = 'input'; } catch (err) { - error({ message: `Cannot parse JSON input.\n ${(err as Error).message}` }); + logger.stderr.error(`Cannot parse JSON input.\n ${(err as Error).message}`); process.exitCode = CommandExitCodes.InvalidInput; return false; } @@ -126,10 +125,9 @@ export async function getInputOverride(cwd: string, inputFlag: string | undefine } else if (inputFileFlag) { switch (inputFileFlag[0]) { case '-': { - error({ - message: - 'You need to pipe something into standard input when you specify the `-` value to `--input-file`.', - }); + logger.stderr.error( + 'You need to pipe something into standard input when you specify the `-` value to `--input-file`.', + ); process.exitCode = CommandExitCodes.InvalidInput; return false; } @@ -145,7 +143,7 @@ export async function getInputOverride(cwd: string, inputFlag: string | undefine const parsed = JSON.parse(fileContent); if (Array.isArray(parsed)) { - error({ message: 'The provided input is invalid. It should be an object, not an array.' }); + logger.stderr.error('The provided input is invalid. It should be an object, not an array.'); process.exitCode = CommandExitCodes.InvalidInput; return false; } @@ -161,7 +159,7 @@ export async function getInputOverride(cwd: string, inputFlag: string | undefine const parsed = JSON.parse(inputFileFlag); if (Array.isArray(parsed)) { - error({ message: 'The provided input is invalid. It should be an object, not an array.' }); + logger.stderr.error('The provided input is invalid. It should be an object, not an array.'); process.exitCode = CommandExitCodes.InvalidInput; return false; } @@ -169,9 +167,9 @@ export async function getInputOverride(cwd: string, inputFlag: string | undefine input = parsed; source = inputFileFlag; } catch { - error({ - message: `Cannot read input file at path "${fullPath}".\n ${(fsError as Error).message}`, - }); + logger.stderr.error( + `Cannot read input file at path "${fullPath}".\n ${(fsError as Error).message}`, + ); process.exitCode = CommandExitCodes.InvalidInput; return false; } diff --git a/src/lib/commands/run-on-cloud.ts b/src/lib/commands/run-on-cloud.ts index f93372010..336abae6a 100644 --- a/src/lib/commands/run-on-cloud.ts +++ b/src/lib/commands/run-on-cloud.ts @@ -7,7 +7,7 @@ import { ACTOR_JOB_STATUSES } from '@apify/consts'; import { Flags } from '../command-framework/flags.js'; import { CommandExitCodes } from '../consts.js'; -import { error, run as runLog, success, warning } from '../outputs.js'; +import { logger } from '../logger.js'; import { outputJobLog } from '../utils.js'; import { resolveInput } from './resolve-input.js'; @@ -53,17 +53,17 @@ export async function* runActorOrTaskOnCloud(apifyClient: ApifyClient, options: if (!silent) { if (type === 'Actor') { - runLog({ - message: `Calling ${type} ${actorOrTaskData.userFriendlyId} (${chalk.gray(actorOrTaskData.id)})\n`, - }); + logger.stderr.run( + `Calling ${type} ${actorOrTaskData.userFriendlyId} (${chalk.gray(actorOrTaskData.id)})\n`, + ); } else if (actorOrTaskData.title) { - runLog({ - message: `Calling ${type} ${actorOrTaskData.title} (${actorOrTaskData.userFriendlyId}, ${chalk.gray(actorOrTaskData.id)})\n`, - }); + logger.stderr.run( + `Calling ${type} ${actorOrTaskData.title} (${actorOrTaskData.userFriendlyId}, ${chalk.gray(actorOrTaskData.id)})\n`, + ); } else { - runLog({ - message: `Calling ${type} ${actorOrTaskData.userFriendlyId} (${chalk.gray(actorOrTaskData.id)})\n`, - }); + logger.stderr.run( + `Calling ${type} ${actorOrTaskData.userFriendlyId} (${chalk.gray(actorOrTaskData.id)})\n`, + ); } } @@ -103,7 +103,7 @@ export async function* runActorOrTaskOnCloud(apifyClient: ApifyClient, options: console.error(); } } catch (err) { - warning({ message: 'Can not get log:' }); + logger.stderr.warning('Can not get log:'); console.error(err); } } @@ -127,14 +127,14 @@ export async function* runActorOrTaskOnCloud(apifyClient: ApifyClient, options: if (!silent) { if (run.status === ACTOR_JOB_STATUSES.SUCCEEDED) { - success({ message: `${type} finished.` }); + logger.stderr.success(`${type} finished.`); } else if (run.status === ACTOR_JOB_STATUSES.RUNNING) { - warning({ message: `${type} is still running!` }); + logger.stderr.warning(`${type} is still running!`); } else if (run.status === ACTOR_JOB_STATUSES.ABORTED || run.status === ACTOR_JOB_STATUSES.ABORTING) { - warning({ message: `${type} was aborted!` }); + logger.stderr.warning(`${type} was aborted!`); process.exitCode = CommandExitCodes.RunAborted; } else { - error({ message: `${type} failed!` }); + logger.stderr.error(`${type} failed!`); process.exitCode = CommandExitCodes.RunFailed; } } diff --git a/src/lib/create-utils.ts b/src/lib/create-utils.ts index 72b973b83..7656fc3c1 100644 --- a/src/lib/create-utils.ts +++ b/src/lib/create-utils.ts @@ -8,7 +8,7 @@ import type { Manifest, Template } from '@apify/actor-templates'; import type { ChoicesType } from './hooks/user-confirmations/useSelectFromList.js'; import { useSelectFromList } from './hooks/user-confirmations/useSelectFromList.js'; import { useUserInput } from './hooks/user-confirmations/useUserInput.js'; -import { warning } from './outputs.js'; +import { logger } from './logger.js'; import { httpsGet, validateActorName } from './utils.js'; const PROGRAMMING_LANGUAGES = ['JavaScript', 'TypeScript', 'Python']; @@ -59,9 +59,9 @@ export async function enhanceReadmeWithLocalSuffix(readmePath: string, manifestP readmeStream.write('\n\n'); await pipeline(suffixStream, readmeStream); } catch (err) { - warning({ - message: `Could not append local development instructions to README.md. Cause: ${(err as Error).message}`, - }); + logger.stderr.warning( + `Could not append local development instructions to README.md. Cause: ${(err as Error).message}`, + ); } } diff --git a/src/lib/exec.ts b/src/lib/exec.ts index 1ddf61289..7db5c8eb4 100644 --- a/src/lib/exec.ts +++ b/src/lib/exec.ts @@ -2,7 +2,7 @@ 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 { logger } from './logger.js'; import { cliDebugPrint } from './utils/cliDebugPrint.js'; const spawnPromised = async (cmd: string, args: string[], opts: Options) => { @@ -36,12 +36,12 @@ export interface ExecWithLogOptions { } export async function execWithLog({ cmd, args = [], opts = {}, overrideCommand }: ExecWithLogOptions) { - run({ message: `${overrideCommand || cmd} ${args.join(' ')}` }); + logger.stderr.run(`${overrideCommand || cmd} ${args.join(' ')}`); const result = await spawnPromised(cmd, args, opts); if (result.isErr()) { const err = result.unwrapErr(); - error({ message: err.message }); + logger.stderr.error(err.message); if (err.cause) { throw err.cause; diff --git a/src/lib/hooks/telemetry/useTelemetryState.ts b/src/lib/hooks/telemetry/useTelemetryState.ts index ce6ed213f..c89422226 100644 --- a/src/lib/hooks/telemetry/useTelemetryState.ts +++ b/src/lib/hooks/telemetry/useTelemetryState.ts @@ -4,7 +4,7 @@ import { dirname } from 'node:path'; import { cryptoRandomObjectId } from '@apify/utilities'; import { TELEMETRY_FILE_PATH } from '../../consts.js'; -import { info } from '../../outputs.js'; +import { logger } from '../../logger.js'; import type { AuthJSON } from '../../types.js'; import { getLocalUserInfo } from '../../utils.js'; @@ -78,7 +78,7 @@ export async function useTelemetryState(): Promise { !process.env.APIFY_CLI_DISABLE_TELEMETRY || ['false', '0'].includes(process.env.APIFY_CLI_DISABLE_TELEMETRY) ) { - info({ message: telemetryWarningText }); + logger.stderr.info(telemetryWarningText); } return useTelemetryState(); diff --git a/src/lib/hooks/useActorConfig.ts b/src/lib/hooks/useActorConfig.ts index a8efe1985..c4427141e 100644 --- a/src/lib/hooks/useActorConfig.ts +++ b/src/lib/hooks/useActorConfig.ts @@ -6,7 +6,7 @@ import { inspect } from 'node:util'; import { err, ok, type Result } from '@sapphire/result'; import { ACTOR_SPECIFICATION_VERSION, DEPRECATED_LOCAL_CONFIG_NAME } from '../consts.js'; -import { error, info, warning } from '../outputs.js'; +import { logger } from '../logger.js'; import { getJsonFileContent, getLocalConfigPath } from '../utils.js'; import { cliDebugPrint } from '../utils/cliDebugPrint.js'; import { useYesNoConfirm } from './user-confirmations/useYesNoConfirm.js'; @@ -114,10 +114,9 @@ async function handleBothConfigVersionsFound(deprecatedConfigPath: string) { // If users refuse to migrate, 🤷 if (!confirmed) { - warning({ - message: - 'The "apify.json" file present in your Actor directory will be ignored, and the new ".actor/actor.json" file will be used instead. Please, either rename or remove the old file.', - }); + logger.stderr.warning( + 'The "apify.json" file present in your Actor directory will be ignored, and the new ".actor/actor.json" file will be used instead. Please, either rename or remove the old file.', + ); return; } @@ -125,18 +124,18 @@ async function handleBothConfigVersionsFound(deprecatedConfigPath: string) { try { await rename(deprecatedConfigPath, `${deprecatedConfigPath}.deprecated`); - info({ - message: `The "apify.json" file has been renamed to "apify.json.deprecated". The deprecated file is no longer used by the CLI or Apify Console. If you do not need it for some specific purpose, it can be safely deleted.`, - }); + logger.stderr.info( + `The "apify.json" file has been renamed to "apify.json.deprecated". The deprecated file is no longer used by the CLI or Apify Console. If you do not need it for some specific purpose, it can be safely deleted.`, + ); } catch (ex) { if (ex instanceof Error) { - error({ - message: `Failed to rename the deprecated "apify.json" file to "apify.json.deprecated".\n ${ex.message || ex}`, - }); + logger.stderr.error( + `Failed to rename the deprecated "apify.json" file to "apify.json.deprecated".\n ${ex.message || ex}`, + ); } else { - error({ - message: `Failed to rename the deprecated "apify.json" file to "apify.json.deprecated".\n ${inspect(ex, { showHidden: false })}`, - }); + logger.stderr.error( + `Failed to rename the deprecated "apify.json" file to "apify.json.deprecated".\n ${inspect(ex, { showHidden: false })}`, + ); } } } @@ -202,14 +201,14 @@ async function handleMigrationFlow( } catch (ex) { const casted = ex as Error; - warning({ - message: `Failed to rename the deprecated "apify.json" file to "apify.json.deprecated".\n ${casted.message || casted}`, - }); + logger.stderr.warning( + `Failed to rename the deprecated "apify.json" file to "apify.json.deprecated".\n ${casted.message || casted}`, + ); } - info({ - message: `The "apify.json" file has been migrated to ".actor/actor.json" and the original file renamed to "apify.json.deprecated". The deprecated file is no longer used by the CLI or Apify Console. If you do not need it for some specific purpose, it can be safely deleted. Do not forget to commit the new file to your Git repository.`, - }); + logger.stderr.info( + `The "apify.json" file has been migrated to ".actor/actor.json" and the original file renamed to "apify.json.deprecated". The deprecated file is no longer used by the CLI or Apify Console. If you do not need it for some specific purpose, it can be safely deleted. Do not forget to commit the new file to your Git repository.`, + ); return ok(migratedConfig); } diff --git a/src/lib/hooks/useCLIMetadata.ts b/src/lib/hooks/useCLIMetadata.ts index c7b71ab60..537294369 100644 --- a/src/lib/hooks/useCLIMetadata.ts +++ b/src/lib/hooks/useCLIMetadata.ts @@ -1,7 +1,7 @@ import { realpathSync } from 'node:fs'; import { dirname } from 'node:path'; -import { warning } from '../outputs.js'; +import { logger } from '../logger.js'; export const DEVELOPMENT_VERSION_MARKER = '0.0.0'; export const DEVELOPMENT_HASH_MARKER = '0000000'; @@ -43,7 +43,7 @@ function detectInstallMethod(): InstallMethod { // Should be impossible, but if it is if (!entrypointFilePathRaw) { - warning({ message: `Failed to detect install method of CLI, assuming npm` }); + logger.stderr.warning(`Failed to detect install method of CLI, assuming npm`); return 'npm'; } diff --git a/src/lib/hooks/useCLIVersionCheck.ts b/src/lib/hooks/useCLIVersionCheck.ts index f5baa88ce..5314b5cda 100644 --- a/src/lib/hooks/useCLIVersionCheck.ts +++ b/src/lib/hooks/useCLIVersionCheck.ts @@ -1,7 +1,7 @@ import { gt } from 'semver'; import { CHECK_VERSION_EVERY_MILLIS } from '../consts.js'; -import { warning } from '../outputs.js'; +import { logger } from '../logger.js'; import { cliDebugPrint } from '../utils/cliDebugPrint.js'; import { useCLIMetadata } from './useCLIMetadata.js'; import { type LatestState, updateLocalState, useLocalState } from './useLocalState.js'; @@ -66,7 +66,7 @@ async function getLatestVersion(state: LatestState) { body: await res.text(), }); - warning({ message: 'Failed to fetch latest version of Apify CLI, using the cached version instead.' }); + logger.stderr.warning('Failed to fetch latest version of Apify CLI, using the cached version instead.'); return null; } diff --git a/src/lib/i18n/defineMessages.ts b/src/lib/i18n/defineMessages.ts new file mode 100644 index 000000000..fd9a34fc7 --- /dev/null +++ b/src/lib/i18n/defineMessages.ts @@ -0,0 +1,44 @@ +import type { DefineMessagesInput, DefineMessagesResult, MessageDescriptor, SupportedLocale } from './types.js'; +import { DEFAULT_LOCALE } from './types.js'; + +/** + * Force any top-level key on the input that is not in {@link SupportedLocale} + * to `never`. Generic inference on its own lets unknown keys slip through the + * `extends` constraint — intersecting with this type pins every extra key to + * an impossible value so the call fails at the use site. + */ +type RejectUnknownLocales = { + [K in Exclude]: never; +}; + +/** + * Declare a set of localized messages. + * + * Requires an `en` locale — it is used as the compile-time source for argument + * type inference and as the runtime fallback when the active locale is missing + * a translation. Every other locale key must be in {@link SUPPORTED_LOCALES}. + */ +export function defineMessages( + input: T & RejectUnknownLocales, +): DefineMessagesResult { + const base = input[DEFAULT_LOCALE]; + const result: Record> = {}; + + for (const id of Object.keys(base)) { + const translations: Partial> = {}; + for (const locale of Object.keys(input) as SupportedLocale[]) { + const message = input[locale]?.[id]; + if (typeof message === 'string') { + translations[locale] = message; + } + } + + result[id] = { + id, + source: base[id], + translations, + }; + } + + return result as DefineMessagesResult; +} diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts new file mode 100644 index 000000000..6e14a4268 --- /dev/null +++ b/src/lib/i18n/index.ts @@ -0,0 +1,12 @@ +export { defineMessages } from './defineMessages.js'; +export { getLocale, setLocale, t } from './t.js'; +export { SUPPORTED_LOCALES } from './types.js'; +export type { + ArgsOfDescriptor, + DefineMessagesInput, + DefineMessagesResult, + ExtractArgs, + MapIcuType, + MessageDescriptor, + SupportedLocale, +} from './types.js'; diff --git a/src/lib/i18n/t.ts b/src/lib/i18n/t.ts new file mode 100644 index 000000000..68934e09a --- /dev/null +++ b/src/lib/i18n/t.ts @@ -0,0 +1,58 @@ +import { IntlMessageFormat } from 'intl-messageformat'; + +import type { ArgsOfDescriptor, HasRequiredArgs, MessageDescriptor, SupportedLocale } from './types.js'; +import { DEFAULT_LOCALE } from './types.js'; + +let currentLocale: SupportedLocale = DEFAULT_LOCALE; + +export function setLocale(locale: SupportedLocale): void { + currentLocale = locale; +} + +export function getLocale(): SupportedLocale { + return currentLocale; +} + +/** + * Rewrite our convenience `{name,string}` syntax to plain `{name}` before + * handing the message to `intl-messageformat`, which rejects `string` as an + * unknown argument type. + */ +const STRING_TYPE_RE = /\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*,\s*string\s*\}/g; + +function preprocessMessage(message: string): string { + return message.replace(STRING_TYPE_RE, '{$1}'); +} + +const formatterCache = new Map(); + +function getFormatter(locale: SupportedLocale, source: string): IntlMessageFormat { + const key = `${locale}\u001F${source}`; + let formatter = formatterCache.get(key); + if (!formatter) { + formatter = new IntlMessageFormat(preprocessMessage(source), locale, undefined, { + shouldParseSkeletons: true, + }); + formatterCache.set(key, formatter); + } + return formatter; +} + +function resolveSource(message: MessageDescriptor, locale: SupportedLocale): string { + return message.translations[locale] ?? message.translations[DEFAULT_LOCALE] ?? message.source; +} + +/** + * Format a message for the current locale, strictly typed against the + * placeholders declared in the message source. + */ +export function t>( + message: M, + ...args: HasRequiredArgs> extends true ? [values: ArgsOfDescriptor] : [] +): string { + const source = resolveSource(message, currentLocale); + const formatter = getFormatter(currentLocale, source); + const values = (args as [Record?])[0]; + const result = formatter.format(values as never); + return typeof result === 'string' ? result : String(result); +} diff --git a/src/lib/i18n/types.ts b/src/lib/i18n/types.ts new file mode 100644 index 000000000..730bbabc2 --- /dev/null +++ b/src/lib/i18n/types.ts @@ -0,0 +1,154 @@ +/** + * Locales the CLI ships translations for. `en` is the source of truth and + * always required; every other entry is optional. + * + * Extend this list when you start translating a new locale — the rest of the + * i18n surface (`defineMessages`, `setLocale`, the `translations` record) will + * accept the new code automatically. + */ +export const SUPPORTED_LOCALES = ['en'] as const; + +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; + +/** + * The locale used to source the compile-time type of a message. Keeping this + * in a single place makes it trivial to swap the reference locale later. + */ +export const DEFAULT_LOCALE = 'en' satisfies SupportedLocale; + +export type DefaultLocale = typeof DEFAULT_LOCALE; + +/** + * Descriptor object returned for every message id in {@link defineMessages}. + * + * The `source` string is kept as a literal type so that {@link ExtractArgs} + * can recover the required call-site argument shape. + */ +export interface MessageDescriptor { + readonly id: string; + readonly source: Source; + readonly translations: Readonly>>; +} + +type Whitespace = ' ' | '\t' | '\n' | '\r'; + +type TrimLeft = S extends `${Whitespace}${infer R}` ? TrimLeft : S; +type TrimRight = S extends `${infer R}${Whitespace}` ? TrimRight : S; + +type Trim = TrimLeft>; + +/** + * ICU uses a single apostrophe to escape syntax characters: `'{'` → literal + * `{`, `'}'` → literal `}`, `''` → literal `'`. A lone apostrophe that is not + * followed by a syntax character is kept as a literal. + * + * Stripping these regions before running the placeholder extractor means that + * escaped braces in a source string do not get mistaken for real placeholders + * at compile time. + */ +type IcuSyntaxChar = '{' | '}' | '#' | '|'; + +type StripQuoted = S extends `${infer Head}'${infer Rest}` + ? Rest extends `${infer Ch}${infer After}` + ? Ch extends IcuSyntaxChar + ? After extends `${string}'${infer Tail}` + ? StripQuoted + : `${Acc}${Head}` + : Ch extends "'" + ? StripQuoted + : StripQuoted + : `${Acc}${Head}'` + : `${Acc}${S}`; + +/** + * Consume characters from `S` (which starts *inside* an already-opened `{`) + * until we close that brace, correctly handling nested pairs. Returns the + * suffix following the matching close brace, or `''` on EOF. + */ +type SkipToClose = S extends `${infer Ch}${infer Rest}` + ? Ch extends '{' + ? SkipToClose + : Ch extends '}' + ? Depth extends readonly [unknown, ...infer RestDepth] + ? SkipToClose + : Rest + : SkipToClose + : ''; + +type SimplePlaceholder = S extends `${infer Name}}${infer Rest}` + ? [Trim, 'argument', Rest] + : never; + +type TypedPlaceholder = Rest extends `${infer Type},${infer Tail}` + ? Type extends `${string}}${string}` + ? Rest extends `${infer Short}}${infer After}` + ? [Trim, Trim, After] + : never + : [Trim, Trim, SkipToClose] + : Rest extends `${infer Type}}${infer After}` + ? [Trim, Trim, After] + : never; + +type ParsePlaceholder = S extends `${infer Name},${infer Rest}` + ? Name extends `${string}}${string}` + ? SimplePlaceholder + : TypedPlaceholder + : SimplePlaceholder; + +/** + * Map an ICU argument type to the TypeScript type we require at the call site. + * + * `string` is a convenience extension — plain ICU only spells this as `{var}` — + * and is stripped at runtime before handing the source to `intl-messageformat`. + */ +export type MapIcuType = Type extends 'string' | 'argument' + ? string + : Type extends 'number' | 'plural' | 'selectordinal' + ? number + : Type extends 'date' | 'time' + ? Date + : Type extends 'select' + ? string + : string; + +type ExtractArgsRaw = Message extends `${string}{${infer After}` + ? ParsePlaceholder extends readonly [infer Name, infer Type, infer Rest] + ? Name extends string + ? Type extends string + ? Rest extends string + ? ExtractArgsRaw }> + : Acc & { [K in Name]: MapIcuType } + : Acc + : Acc + : Acc + : Acc; + +/** + * Walk the message and collect a single object type keyed by each placeholder + * name. ICU-quoted regions (e.g. `'{'`) are stripped first so that escaped + * braces are not mistaken for placeholders. Result is flattened so it compares + * identical to a hand-written object type under `toEqualTypeOf`. + */ +export type ExtractArgs = Simplify, EmptyArgs>>; + +export type EmptyArgs = Record; + +export type Simplify = { [K in keyof T]: T[K] } & {}; + +export type HasRequiredArgs = [keyof T] extends [never] ? false : true; + +export type ArgsOfDescriptor = M extends MessageDescriptor ? ExtractArgs : never; + +type OptionalSupportedLocaleMessages = { + readonly [K in Exclude]?: Readonly>; +}; + +export interface DefineMessagesInput extends OptionalSupportedLocaleMessages { + readonly en: Readonly>; +} + +export type DefineMessagesResult = { + readonly [K in keyof T[DefaultLocale] & string]: MessageDescriptor< + T[DefaultLocale][K] extends string ? T[DefaultLocale][K] : string + >; +}; diff --git a/src/lib/input_schema.ts b/src/lib/input_schema.ts index 9c515c54a..20c90ef38 100644 --- a/src/lib/input_schema.ts +++ b/src/lib/input_schema.ts @@ -7,7 +7,7 @@ import { KEY_VALUE_STORE_KEYS } from '@apify/consts'; import { validateInputSchema } from '@apify/input_schema'; import { ACTOR_SPECIFICATION_FOLDER, LOCAL_CONFIG_PATH } from './consts.js'; -import { info, warning } from './outputs.js'; +import { logger } from './logger.js'; import { Ajv2019, getJsonFileContent, getLocalConfig, getLocalKeyValueStorePath } from './utils.js'; const DEFAULT_INPUT_SCHEMA_PATHS = [ @@ -94,7 +94,7 @@ export const readAndValidateInputSchema = async ({ throw new Error(`Input schema has not been found at ${inputSchemaPath}.`); } - info({ message: getMessage(inputSchemaPath) }); + logger.stderr.info(getMessage(inputSchemaPath)); const validator = new Ajv2019({ strict: false }); validateInputSchema(validator, inputSchema); @@ -137,9 +137,9 @@ export const readStorageSchema = ({ const schema = getJsonFileContent(fullPath); if (!schema) { - warning({ - message: `${label} schema file not found at ${fullPath} (referenced in '${LOCAL_CONFIG_PATH}').`, - }); + logger.stderr.warning( + `${label} schema file not found at ${fullPath} (referenced in '${LOCAL_CONFIG_PATH}').`, + ); return null; } @@ -231,11 +231,11 @@ export const createPrefilledInputFileFromInputSchema = async (actorFolderDir: st ); } } catch (err) { - warning({ - message: `Could not create default input based on input schema, creating empty input instead. Cause: ${ + logger.stderr.warning( + `Could not create default input based on input schema, creating empty input instead. Cause: ${ (err as Error).message }`, - }); + ); } finally { const keyValueStorePath = getLocalKeyValueStorePath(); const inputJsonPath = join(actorFolderDir, keyValueStorePath, `${KEY_VALUE_STORE_KEYS.INPUT}.json`); diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 000000000..91e4bdcfa --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,175 @@ +/* eslint-disable max-classes-per-file */ + +import process from 'node:process'; + +import chalk from 'chalk'; + +/** + * Minimal writable-stream contract the {@link Logger} needs. Compatible with + * `process.stdout` / `process.stderr`, with any Node.js `Writable`, and with + * the in-memory capture streams used in tests. + */ +export interface LoggerStream { + write(chunk: string): unknown; +} + +export interface LoggerStreams { + stdout: LoggerStream; + stderr: LoggerStream; +} + +/** + * The full surface of a single output channel. `logger.stdout` and + * `logger.stderr` both satisfy this shape. Tests plug in custom + * implementations via {@link Logger.setOutputs}. + */ +export interface LoggerOutput { + /** Emit `message` as-is, no prefix, followed by a newline. */ + log(message: string): void; + /** Emit `Info: {message}` with a white prefix. */ + info(message: string): void; + /** Emit `Warning: {message}` with a bold yellow prefix. */ + warning(message: string): void; + /** Emit `Success: {message}` with a green prefix. */ + success(message: string): void; + /** Emit `Error: {message}` with a red prefix. */ + error(message: string): void; + /** Emit `Run: {message}` with a gray prefix. */ + run(message: string): void; + /** Emit a blue `{message}` followed by the URL. */ + link(message: string, url: string): void; + /** Emit `data` as pretty-printed JSON (replaces `printJsonToStdout`). */ + json(data: unknown): void; +} + +export interface LoggerOutputs { + stdout: LoggerOutput; + stderr: LoggerOutput; +} + +class StreamLoggerOutput implements LoggerOutput { + stream: LoggerStream; + + constructor(stream: LoggerStream) { + this.stream = stream; + } + + private emit(line: string): void { + this.stream.write(`${line}\n`); + } + + log(message: string): void { + this.emit(message); + } + + info(message: string): void { + this.emit(`${chalk.white('Info:')} ${message}`); + } + + warning(message: string): void { + this.emit(`${chalk.yellow.bold('Warning:')} ${message}`); + } + + success(message: string): void { + this.emit(`${chalk.green('Success:')} ${message}`); + } + + error(message: string): void { + this.emit(`${chalk.red('Error:')} ${message}`); + } + + run(message: string): void { + this.emit(`${chalk.gray('Run:')} ${message}`); + } + + link(message: string, url: string): void { + this.emit(`${chalk.blue(message)} ${url}`); + } + + json(data: unknown): void { + this.emit(JSON.stringify(data, null, 2)); + } +} + +/** + * Output that drops every message. Used as the default channel when the + * `APIFY_NO_LOGS_IN_TESTS` env var is set (vitest workers) so test runs stay + * quiet unless a test opts into capture via `useConsoleSpy`. + */ +/* eslint-disable @typescript-eslint/no-empty-function */ +export class NoopLoggerOutput implements LoggerOutput { + log(): void {} + info(): void {} + warning(): void {} + success(): void {} + error(): void {} + run(): void {} + link(): void {} + json(): void {} +} +/* eslint-enable @typescript-eslint/no-empty-function */ + +function createDefaultOutputs(): LoggerOutputs { + if (process.env.APIFY_NO_LOGS_IN_TESTS) { + return { + stdout: new NoopLoggerOutput(), + stderr: new NoopLoggerOutput(), + }; + } + return { + stdout: new StreamLoggerOutput(process.stdout), + stderr: new StreamLoggerOutput(process.stderr), + }; +} + +/** + * CLI logger with explicit `stdout` and `stderr` channels. Every channel + * exposes the same {@link LoggerOutput} surface — call + * `logger.stdout.info(...)` when the output is meant to be piped or scripted + * against, and `logger.stderr.info(...)` for progress/diagnostics the user + * reads but does not consume programmatically. + * + * Outputs are swappable at runtime. Production code almost never touches this; + * tests use {@link setOutputs} to install a capturing or silent implementation. + */ +export class Logger { + stdout: LoggerOutput; + stderr: LoggerOutput; + + constructor(outputs: LoggerOutputs = createDefaultOutputs()) { + this.stdout = outputs.stdout; + this.stderr = outputs.stderr; + } + + /** + * Replace the underlying outputs with arbitrary {@link LoggerOutput} + * implementations — e.g. the capturing output used by `useConsoleSpy`, a + * file-backed output, or {@link NoopLoggerOutput} to silence everything. + */ + setOutputs(outputs: LoggerOutputs): void { + this.stdout = outputs.stdout; + this.stderr = outputs.stderr; + } + + /** + * Convenience shortcut: point the logger at a pair of writable streams. + * Equivalent to `setOutputs` with fresh {@link StreamLoggerOutput}s. + */ + setStreams(streams: LoggerStreams): void { + this.stdout = new StreamLoggerOutput(streams.stdout); + this.stderr = new StreamLoggerOutput(streams.stderr); + } + + /** Restore the outputs the logger was constructed with (per env defaults). */ + reset(): void { + const defaults = createDefaultOutputs(); + this.stdout = defaults.stdout; + this.stderr = defaults.stderr; + } +} + +/** + * Process-wide logger. Commands access the same instance through + * `this.logger`; utility modules import it directly. + */ +export const logger = new Logger(); diff --git a/src/lib/outputs.ts b/src/lib/outputs.ts deleted file mode 100644 index 33e58cbbb..000000000 --- a/src/lib/outputs.ts +++ /dev/null @@ -1,67 +0,0 @@ -import chalk from 'chalk'; - -export interface LogOptions { - stdoutOutput?: unknown[]; - stderrOutput?: unknown[]; -} - -function internalLog(options: LogOptions) { - if (options.stdoutOutput) { - console.log(...options.stdoutOutput); - } - - if (options.stderrOutput) { - console.error(...options.stderrOutput); - } -} - -export interface SimpleLogOptions { - stdout?: boolean; - message: string; -} - -export function simpleLog(options: SimpleLogOptions) { - internalLog({ - [options.stdout ? 'stdoutOutput' : 'stderrOutput']: [options.message], - }); -} - -export function error(options: SimpleLogOptions) { - internalLog({ - [options.stdout ? 'stdoutOutput' : 'stderrOutput']: [chalk.red('Error:'), options.message], - }); -} - -export function warning(options: SimpleLogOptions) { - internalLog({ - [options.stdout ? 'stdoutOutput' : 'stderrOutput']: [chalk.yellow.bold('Warning:'), options.message], - }); -} - -export function success(options: SimpleLogOptions) { - internalLog({ - [options.stdout ? 'stdoutOutput' : 'stderrOutput']: [chalk.green('Success:'), options.message], - }); -} - -export function run(options: SimpleLogOptions) { - internalLog({ - [options.stdout ? 'stdoutOutput' : 'stderrOutput']: [chalk.gray('Run:'), options.message], - }); -} - -export function info(options: SimpleLogOptions) { - internalLog({ - [options.stdout ? 'stdoutOutput' : 'stderrOutput']: [chalk.white('Info:'), options.message], - }); -} - -export interface SimpleLinkOptions extends SimpleLogOptions { - url: string; -} - -export function link(options: SimpleLinkOptions) { - internalLog({ - [options.stdout ? 'stdoutOutput' : 'stderrOutput']: [chalk.blue(options.message), options.url], - }); -} diff --git a/src/lib/projects/scrapy/wrapScrapyProject.ts b/src/lib/projects/scrapy/wrapScrapyProject.ts index 3339913e6..d18b671d2 100644 --- a/src/lib/projects/scrapy/wrapScrapyProject.ts +++ b/src/lib/projects/scrapy/wrapScrapyProject.ts @@ -18,7 +18,7 @@ import Handlebars from 'handlebars'; import { fetchManifest, wrapperManifestUrl } from '@apify/actor-templates'; import { useSelectFromList } from '../../hooks/user-confirmations/useSelectFromList.js'; -import { info, success } from '../../outputs.js'; +import { logger } from '../../logger.js'; import { downloadAndUnzip, sanitizeActorName } from '../../utils.js'; import { ScrapyProjectAnalyzer } from './ScrapyProjectAnalyzer.js'; @@ -117,7 +117,7 @@ export async function wrapScrapyProject({ projectPath }: { projectPath?: string const manifest = await fetchManifest(wrapperManifestUrl); - info({ message: 'Downloading the latest Scrapy wrapper template...' }); + logger.stderr.info('Downloading the latest Scrapy wrapper template...'); const { archiveUrl } = manifest.templates.find(({ id }) => id === 'python-scrapy')!; @@ -128,7 +128,7 @@ export async function wrapScrapyProject({ projectPath }: { projectPath?: string pathTo: templatePath, }); - info({ message: 'Wrapping the Scrapy project...' }); + logger.stderr.info('Wrapping the Scrapy project...'); await merge(templatePath, projectPath, { bindings: templateBindings, @@ -149,5 +149,5 @@ export async function wrapScrapyProject({ projectPath }: { projectPath?: string }); }); - success({ message: 'The Scrapy project has been wrapped successfully.' }); + logger.stderr.success('The Scrapy project has been wrapped successfully.'); } diff --git a/src/lib/secrets.ts b/src/lib/secrets.ts index ed1a35fe6..52de70c13 100644 --- a/src/lib/secrets.ts +++ b/src/lib/secrets.ts @@ -1,7 +1,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { SECRETS_FILE_PATH } from './consts.js'; -import { warning } from './outputs.js'; +import { logger } from './logger.js'; import { ensureApifyDirectory } from './utils.js'; const SECRET_KEY_PREFIX = '@'; @@ -83,9 +83,9 @@ export const replaceSecretsValue = ( const secretsList = missingSecrets.map((s) => ` - ${s}`).join('\n'); if (allowMissing) { for (const secretKey of missingSecrets) { - warning({ - message: `Value for ${secretKey} not found in local secrets. Set it by calling "apify secrets add ${secretKey} [SECRET_VALUE]"`, - }); + logger.stderr.warning( + `Value for ${secretKey} not found in local secrets. Set it by calling "apify secrets add ${secretKey} [SECRET_VALUE]"`, + ); } } else { throw new Error( @@ -143,9 +143,9 @@ export const transformEnvToEnvVars = ( const secretsList = missingSecrets.map((s) => ` - ${s}`).join('\n'); if (allowMissing) { for (const secretKey of missingSecrets) { - warning({ - message: `Value for ${secretKey} not found in local secrets. Set it by calling "apify secrets add ${secretKey} [SECRET_VALUE]"`, - }); + logger.stderr.warning( + `Value for ${secretKey} not found in local secrets. Set it by calling "apify secrets add ${secretKey} [SECRET_VALUE]"`, + ); } } else { throw new Error( diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 432b1126a..26069ed6f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -775,10 +775,6 @@ export function mapGroupBy(items: Iterable, keySelector: (item: T, inde return map; } -export function printJsonToStdout(object: unknown) { - console.log(JSON.stringify(object, null, 2)); -} - export const tildify = (path: string) => { if (path.startsWith(homedir())) { return path.replace(homedir(), '~'); diff --git a/test/__setup__/hooks/useConsoleSpy.ts b/test/__setup__/hooks/useConsoleSpy.ts index 2c5a113cb..398a38cc8 100644 --- a/test/__setup__/hooks/useConsoleSpy.ts +++ b/test/__setup__/hooks/useConsoleSpy.ts @@ -2,6 +2,9 @@ import { appendFileSync } from 'node:fs'; import type { MockInstance } from 'vitest'; +import type { LoggerOutput } from '../../../src/lib/logger.js'; +import { logger } from '../../../src/lib/logger.js'; + interface ConsoleSpyOptions { /** * Whether the log messages should reset between tests @@ -21,38 +24,99 @@ function maybeStringify(itm: unknown): string { } } +/** + * Installs a test-friendly {@link LoggerOutput} on the global `logger` and + * wraps `console.log` / `console.error` so assertions can run against either + * path. Every write is mirrored into: + * + * - `logMessages.log` / `logMessages.error` for string-based matching + * - `logSpy()` / `errorSpy()` vitest mocks for call-count assertions + * + * Prefixes (`Info:`, `Warning:`, `Success:`, `Error:`, `Run:`) are preserved + * in the captured strings (without chalk color codes) so existing tests that + * match on those labels keep working. + */ export function useConsoleSpy(options: ConsoleSpyOptions = { resetMessagesPerTest: true }) { - let logSpy!: MockInstance<(typeof console)['log']>; - let errorSpy!: MockInstance<(typeof console)['error']>; - const logMessages = { log: [] as string[], error: [] as string[], }; + // Mutable spy container. The capture outputs read `spies.stdout` / + // `spies.stderr` at call time, so the fresh `vitest.fn()`s created in each + // `beforeEach` are visible without re-installing the outputs. + const spies = { + stdout: null as MockInstance<(message: string) => void> | null, + stderr: null as MockInstance<(message: string) => void> | null, + }; + vitest.setConfig({ restoreMocks: false }); - beforeEach(() => { - logSpy = vitest.spyOn(console, 'log').mockImplementation((...args) => { - logMessages.log.push(args.map(maybeStringify).join(' ')); - }); + function makeCaptureOutput(channel: 'stdout' | 'stderr'): LoggerOutput { + const write = (formatted: string) => { + const bucket = channel === 'stdout' ? logMessages.log : logMessages.error; + bucket.push(formatted); + const spy = channel === 'stdout' ? spies.stdout : spies.stderr; + spy?.(formatted); + }; - errorSpy = vitest.spyOn(console, 'error').mockImplementation((...args) => { - logMessages.error.push(args.map(maybeStringify).join(' ')); - }); + return { + log: (message) => write(message), + info: (message) => write(`Info: ${message}`), + warning: (message) => write(`Warning: ${message}`), + success: (message) => write(`Success: ${message}`), + error: (message) => write(`Error: ${message}`), + run: (message) => write(`Run: ${message}`), + link: (message, url) => write(`${message} ${url}`), + json: (data) => write(JSON.stringify(data, null, 2)), + }; + } + logger.setOutputs({ + stdout: makeCaptureOutput('stdout'), + stderr: makeCaptureOutput('stderr'), + }); + + beforeEach(() => { if (options.resetMessagesPerTest) { logMessages.log = []; logMessages.error = []; } + + // Fresh mocks per test so `toHaveBeenCalledTimes` assertions are scoped + // to a single test without needing explicit `mockClear()` calls. + spies.stdout = vitest.fn(); + spies.stderr = vitest.fn(); + + // Some commands still write directly via `console.log` / `console.error` + // (e.g. help rendering, raw JSON dumps). Mirror those calls into the + // same arrays/spies so tests don't need to care which path produced the + // output. + vitest.spyOn(console, 'log').mockImplementation((...args) => { + const combined = args.map(maybeStringify).join(' '); + logMessages.log.push(combined); + spies.stdout?.(combined); + }); + vitest.spyOn(console, 'error').mockImplementation((...args) => { + const combined = args.map(maybeStringify).join(' '); + logMessages.error.push(combined); + spies.stderr?.(combined); + }); + }); + + afterAll(() => { + // Return the global logger to its environment-appropriate defaults so + // subsequent suites (or post-suite teardown) don't inherit the capture + // outputs installed above. + logger.reset(); }); return { logSpy() { - return logSpy; + return spies.stdout!; }, errorSpy() { - return errorSpy; + return spies.stderr!; }, logMessages, lastLogMessage() { diff --git a/test/e2e/__helpers__/run-cli.ts b/test/e2e/__helpers__/run-cli.ts index 6d4ac825d..87094b0ac 100644 --- a/test/e2e/__helpers__/run-cli.ts +++ b/test/e2e/__helpers__/run-cli.ts @@ -45,6 +45,11 @@ export async function runCli( env: { APIFY_CLI_DISABLE_TELEMETRY: '1', APIFY_CLI_SKIP_UPDATE_CHECK: '1', + // Vitest sets `APIFY_NO_LOGS_IN_TESTS=1` for its workers, which makes + // the logger default to a noop output. We don't want that leaking + // into the CLI child process under test — e2e tests assert on real + // stdout / stderr. + APIFY_NO_LOGS_IN_TESTS: '', ...options.env, }, }); diff --git a/test/local/commands/secrets/ls.test.ts b/test/local/commands/secrets/ls.test.ts index 15dfaa96f..194acdc57 100644 --- a/test/local/commands/secrets/ls.test.ts +++ b/test/local/commands/secrets/ls.test.ts @@ -3,11 +3,14 @@ import { SecretsLsCommand } from '../../../../src/commands/secrets/ls.js'; import { SecretsRmCommand } from '../../../../src/commands/secrets/rm.js'; import { testRunCommand } from '../../../../src/lib/command-framework/apify-command.js'; import { getSecretsFile } from '../../../../src/lib/secrets.js'; +import { useConsoleSpy } from '../../../__setup__/hooks/useConsoleSpy.js'; const SECRET_KEY_1 = 'testSecret1'; const SECRET_KEY_2 = 'testSecret2'; const SECRET_VALUE = 'testSecretValue'; +const { logMessages } = useConsoleSpy(); + describe('apify secrets ls', () => { beforeAll(async () => { // Clean up any existing test secrets @@ -35,16 +38,12 @@ describe('apify secrets ls', () => { }); it('should list all secrets', async () => { - const spy = vitest.spyOn(console, 'log'); - await testRunCommand(SecretsLsCommand, {}); // Verify the command outputs our test secrets - const output = spy.mock.calls.map((call) => call.join(' ')).join('\n'); + const output = logMessages.log.join('\n'); expect(output).to.include(SECRET_KEY_1); expect(output).to.include(SECRET_KEY_2); - - spy.mockRestore(); }); afterAll(async () => { diff --git a/test/local/lib/i18n.test.ts b/test/local/lib/i18n.test.ts new file mode 100644 index 000000000..f27975df7 --- /dev/null +++ b/test/local/lib/i18n.test.ts @@ -0,0 +1,368 @@ +import type { ArgsOfDescriptor, ExtractArgs, MessageDescriptor } from '../../../src/lib/i18n/index.js'; +import { defineMessages, getLocale, setLocale, SUPPORTED_LOCALES, t } from '../../../src/lib/i18n/index.js'; + +describe('i18n', () => { + afterEach(() => { + setLocale('en'); + }); + + describe('defineMessages()', () => { + it('returns a descriptor for every id in the default locale', () => { + const messages = defineMessages({ + en: { + hello: 'Hello', + bye: 'Goodbye', + }, + }); + + expect(Object.keys(messages).sort()).toEqual(['bye', 'hello']); + expect(messages.hello.id).toBe('hello'); + expect(messages.hello.source).toBe('Hello'); + expect(messages.hello.translations).toEqual({ en: 'Hello' }); + }); + + it('collects every locale translation per id', () => { + const messages = defineMessages({ + en: { hello: 'Hello' }, + // @ts-expect-error -- Test only case + de: { hello: 'Hallo' }, + // @ts-expect-error -- Test only case + cs: { hello: 'Ahoj' }, + }); + + expect(messages.hello.translations).toEqual({ + en: 'Hello', + de: 'Hallo', + cs: 'Ahoj', + }); + }); + + it('skips locales that do not translate a given id', () => { + const messages = defineMessages({ + en: { hello: 'Hello', bye: 'Goodbye' }, + // @ts-expect-error -- Test only case + de: { hello: 'Hallo' }, + }); + + expect(messages.hello.translations).toEqual({ en: 'Hello', de: 'Hallo' }); + expect(messages.bye.translations).toEqual({ en: 'Goodbye' }); + }); + }); + + describe('t()', () => { + it('formats a message with no placeholders', () => { + const messages = defineMessages({ + en: { + noActorName: 'You need to provide an actor name', + }, + }); + + expect(t(messages.noActorName)).toBe('You need to provide an actor name'); + }); + + it('interpolates a plain {var} placeholder', () => { + const messages = defineMessages({ + en: { + actorWithNameExists: 'An actor with the name {name} already exists', + }, + }); + + expect(t(messages.actorWithNameExists, { name: 'my-actor' })).toBe( + 'An actor with the name my-actor already exists', + ); + }); + + it('accepts the {var,string} convenience syntax', () => { + const messages = defineMessages({ + en: { + actorWithNameExists: 'An actor with the name {name,string} already exists', + }, + }); + + expect(t(messages.actorWithNameExists, { name: 'John Doe' })).toBe( + 'An actor with the name John Doe already exists', + ); + }); + + it('accepts {var , string} with whitespace around the type', () => { + const messages = defineMessages({ + en: { + actorWithNameExists: 'An actor with the name { name , string } already exists', + }, + }); + + expect(t(messages.actorWithNameExists, { name: 'My Actor' })).toBe( + 'An actor with the name My Actor already exists', + ); + }); + + it('formats {var,number} with the locale number formatter', () => { + const messages = defineMessages({ + en: { + small: 'There are {count,number} actors', + big: 'You reached {count,number} runs', + }, + }); + + expect(t(messages.small, { count: 42 })).toBe('There are 42 actors'); + expect(t(messages.big, { count: 1234 })).toBe('You reached 1,234 runs'); + }); + + it('formats {var,date,::skeleton} via intl-messageformat', () => { + const messages = defineMessages({ + en: { + actorDateTime: 'Wow its {currentTime,date,::yyyy-MM-dd}', + }, + }); + + const result = t(messages.actorDateTime, { + currentTime: new Date(Date.UTC(2024, 0, 15, 12)), + }); + + // Exact output depends on the Intl runtime; assert the placeholder was + // consumed and the surrounding text preserved. + expect(result.startsWith('Wow its ')).toBe(true); + expect(result).not.toContain('{'); + expect(result).not.toContain('currentTime'); + }); + + it('formats {var,time,::skeleton} via intl-messageformat', () => { + const messages = defineMessages({ + en: { + actorTime: 'Current time: {now,time,::HH:mm:ss}', + }, + }); + + const result = t(messages.actorTime, { + now: new Date(2024, 0, 15, 9, 5, 3), + }); + + expect(result).toBe('Current time: 09:05:03'); + }); + + it('resolves {var,plural,...} categories', () => { + const messages = defineMessages({ + en: { + itemCount: 'You have {count, plural, one {# item} other {# items}}', + }, + }); + + expect(t(messages.itemCount, { count: 1 })).toBe('You have 1 item'); + expect(t(messages.itemCount, { count: 4 })).toBe('You have 4 items'); + }); + + it('resolves {var,select,...} branches', () => { + const messages = defineMessages({ + en: { + greeting: '{gender, select, male {He} female {She} other {They}} said hi', + }, + }); + + expect(t(messages.greeting, { gender: 'male' })).toBe('He said hi'); + expect(t(messages.greeting, { gender: 'female' })).toBe('She said hi'); + expect(t(messages.greeting, { gender: 'unknown' })).toBe('They said hi'); + }); + + it('supports multiple placeholders in a single message', () => { + const messages = defineMessages({ + en: { + multiParam: 'Actor {name} has {count,number} runs', + }, + }); + + expect(t(messages.multiParam, { name: 'scraper', count: 100 })).toBe('Actor scraper has 100 runs'); + }); + + it('throws when a required placeholder is missing (formatjs behaviour)', () => { + const messages = defineMessages({ + en: { + actorWithNameExists: 'An actor with the name {name} already exists', + }, + }); + + // Cast through unknown to bypass the static check — we want to verify + // the runtime error path from intl-messageformat. + expect(() => t(messages.actorWithNameExists, {} as unknown as { name: string })).toThrow(/name/); + }); + + it('returns the same output for repeated calls (formatter cache)', () => { + const messages = defineMessages({ + en: { + hello: 'Hello {name}', + }, + }); + + expect(t(messages.hello, { name: 'A' })).toBe('Hello A'); + expect(t(messages.hello, { name: 'B' })).toBe('Hello B'); + }); + }); + + describe('locale management', () => { + it('exposes getLocale / setLocale', () => { + expect(getLocale()).toBe('en'); + // @ts-expect-error -- Test only case + setLocale('de'); + expect(getLocale()).toBe('de'); + }); + + it('uses the active locale translation when present', () => { + const messages = defineMessages({ + en: { hello: 'Hello {name}' }, + // @ts-expect-error -- Test only case + de: { hello: 'Hallo {name}' }, + }); + + // @ts-expect-error -- Test only case + setLocale('de'); + expect(t(messages.hello, { name: 'Welt' })).toBe('Hallo Welt'); + }); + + it('falls back to the en source when the active locale has no translation', () => { + const messages = defineMessages({ + en: { hello: 'Hello {name}' }, + }); + + // @ts-expect-error -- Test only case + setLocale('de'); + expect(t(messages.hello, { name: 'Welt' })).toBe('Hello Welt'); + }); + }); + + describe('supported locales', () => { + it('exposes SUPPORTED_LOCALES with en included', () => { + expect(SUPPORTED_LOCALES).toContain('en'); + }); + + it('accepts any SUPPORTED_LOCALES entry as an optional key', () => { + const messages = defineMessages({ + en: { hello: 'Hello' }, + // @ts-expect-error -- Test only case + cs: { hello: 'Ahoj' }, + // @ts-expect-error -- Test only case + fr: { hello: 'Bonjour' }, + }); + + expect(messages.hello.translations).toEqual({ en: 'Hello', cs: 'Ahoj', fr: 'Bonjour' }); + }); + + it('rejects non-en-only inputs and unknown locale keys at compile time', () => { + const _assertions = () => { + // @ts-expect-error -- the `en` locale is required + defineMessages({}); + + defineMessages({ + en: { hello: 'Hello' }, + // @ts-expect-error -- 'xx' is not a supported locale + xx: { hello: 'Xx' }, + }); + + // @ts-expect-error -- setLocale only accepts a SupportedLocale + setLocale('xx'); + }; + + expect(typeof _assertions).toBe('function'); + }); + }); + + describe('ICU escape syntax', () => { + it('treats single-quoted braces as literal text', () => { + const messages = defineMessages({ + en: { + jsonHint: "Send JSON like '{'key: value'}'", + }, + }); + + expect(t(messages.jsonHint)).toBe('Send JSON like {key: value}'); + }); + + it('combines escaped literal braces with real placeholders', () => { + const messages = defineMessages({ + en: { + bothBraces: "Open '{' and insert {value}", + }, + }); + + expect(t(messages.bothBraces, { value: 'x' })).toBe('Open { and insert x'); + }); + + it('ignores escaped braces in ExtractArgs', () => { + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf<{ name: string }>(); + }); + }); + + describe('type inference (compile-time)', () => { + const messages = defineMessages({ + en: { + noArgs: 'hi', + oneString: 'An actor with the name {name,string} already exists', + plainString: 'Hello {name}!', + oneNumber: 'You have {count,number} items', + oneDate: 'Wow its {currentTime,date,::yyyy-MM-dd}', + withPlural: 'You have {count, plural, one {# item} other {# items}}', + withSelect: '{gender, select, male {He} female {She} other {They}} said hi', + multiple: '{name} pushed {count,number} commits', + }, + }); + + it('extracts required argument shapes from ICU sources', () => { + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf<{ name: string }>(); + expectTypeOf>().toEqualTypeOf<{ name: string }>(); + expectTypeOf>().toEqualTypeOf<{ count: number }>(); + expectTypeOf>().toEqualTypeOf<{ currentTime: Date }>(); + expectTypeOf>().toEqualTypeOf<{ count: number }>(); + expectTypeOf>().toEqualTypeOf<{ gender: string }>(); + expectTypeOf>().toEqualTypeOf<{ + name: string; + count: number; + }>(); + }); + + it('accepts well-typed t() call shapes', () => { + expect(t(messages.noArgs)).toBe('hi'); + expect(t(messages.oneString, { name: 'a' })).toContain('a'); + expect(t(messages.plainString, { name: 'a' })).toBe('Hello a!'); + expect(t(messages.oneNumber, { count: 1 })).toBe('You have 1 items'); + expect(t(messages.multiple, { name: 'a', count: 1 })).toBe('a pushed 1 commits'); + }); + + it('rejects mismatched t() call shapes at compile time', () => { + // The body is a function whose statements are type-checked but never + // executed. Each `@ts-expect-error` asserts a specific compile-time + // rejection; if any stops being an error, tsc will fail the build. + const _assertions = () => { + // @ts-expect-error -- noArgs takes no values + t(messages.noArgs, { name: 'a' }); + // @ts-expect-error -- oneString requires a values object + t(messages.oneString); + // @ts-expect-error -- name must be a string + t(messages.oneString, { name: 1 }); + // @ts-expect-error -- count must be a number + t(messages.oneNumber, { count: 'nope' }); + // @ts-expect-error -- currentTime must be a Date + t(messages.oneDate, { currentTime: 'nope' }); + }; + + // Reference it so `noUnusedLocals` is happy. + expect(typeof _assertions).toBe('function'); + }); + + it('ExtractArgs handles raw message literals', () => { + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf<{ name: string }>(); + expectTypeOf>().toEqualTypeOf<{ + name: string; + count: number; + }>(); + expectTypeOf>().toEqualTypeOf<{ + count: number; + user: string; + }>(); + }); + + it('MessageDescriptor carries the source literal in its generic argument', () => { + expectTypeOf(messages.plainString).toExtend>(); + }); + }); +}); diff --git a/test/local/lib/secrets.test.ts b/test/local/lib/secrets.test.ts index 55568157e..63efc4050 100644 --- a/test/local/lib/secrets.test.ts +++ b/test/local/lib/secrets.test.ts @@ -1,4 +1,7 @@ import { replaceSecretsValue, transformEnvToEnvVars } from '../../../src/lib/secrets.js'; +import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; + +const { logMessages } = useConsoleSpy(); describe('Secrets', () => { describe('replaceSecretsValue()', () => { @@ -43,8 +46,6 @@ describe('Secrets', () => { }); it('should warn instead of throwing when allowMissing is true', () => { - const spy = vitest.spyOn(console, 'error'); - const secrets = { myProdToken: 'mySecretToken', }; @@ -62,10 +63,8 @@ describe('Secrets', () => { TOKEN: secrets.myProdToken, }); - expect(spy).toHaveBeenCalled(); - expect(spy.mock.calls.flat().join(' ')).to.include('doesNotExist'); - - spy.mockRestore(); + expect(logMessages.error.length).toBeGreaterThan(0); + expect(logMessages.error.join(' ')).to.include('doesNotExist'); }); }); @@ -105,8 +104,6 @@ describe('Secrets', () => { }); it('should warn instead of throwing when allowMissing is true', () => { - const spy = vitest.spyOn(console, 'error'); - const secrets = {}; const env = { TOKEN: '@doesNotExist', @@ -119,10 +116,8 @@ describe('Secrets', () => { expect(envVars).toStrictEqual([{ name: 'USER', value: 'plain-value' }]); - expect(spy).toHaveBeenCalled(); - expect(spy.mock.calls.flat().join(' ')).to.include('doesNotExist'); - - spy.mockRestore(); + expect(logMessages.error.length).toBeGreaterThan(0); + expect(logMessages.error.join(' ')).to.include('doesNotExist'); }); }); }); diff --git a/yarn.lock b/yarn.lock index 8819a8f61..5f859d018 100644 --- a/yarn.lock +++ b/yarn.lock @@ -677,6 +677,29 @@ __metadata: languageName: node linkType: hard +"@formatjs/fast-memoize@npm:3.1.2": + version: 3.1.2 + resolution: "@formatjs/fast-memoize@npm:3.1.2" + checksum: 10c0/25af387ebb53146c8c09af34cda4ce82768f0855227ec854fb315d6dc2e7859e16724860fdea7991b3c7c5741154e3fcdd5169ef7a11ba1afb3ffd860613931c + languageName: node + linkType: hard + +"@formatjs/icu-messageformat-parser@npm:3.5.4": + version: 3.5.4 + resolution: "@formatjs/icu-messageformat-parser@npm:3.5.4" + dependencies: + "@formatjs/icu-skeleton-parser": "npm:2.1.4" + checksum: 10c0/3ff5ab209b63113d6b1779a4e8de6ee2cf941f3ad3f3ede2c8e425953a25456b6a1d880d734845244615dc9887fad10b62e75dee38858c731e45812402110efb + languageName: node + linkType: hard + +"@formatjs/icu-skeleton-parser@npm:2.1.4": + version: 2.1.4 + resolution: "@formatjs/icu-skeleton-parser@npm:2.1.4" + checksum: 10c0/9d6292443e4079c5718c50e32041a3bd192047449d181ae87e8cb6ef78c90f156f8b2d454008c9d0c2920c215daee5250c0eb214d13a105522a631f269e71b3c + languageName: node + linkType: hard + "@gar/promise-retry@npm:^1.0.0": version: 1.0.3 resolution: "@gar/promise-retry@npm:1.0.3" @@ -2211,6 +2234,7 @@ __metadata: husky: "npm:^9" ignore: "npm:^7.0.0" indent-string: "npm:^5.0.0" + intl-messageformat: "npm:^11.2.1" is-ci: "npm:~4.1.0" istextorbinary: "npm:~9.5.0" jju: "npm:~1.4.0" @@ -5187,6 +5211,16 @@ __metadata: languageName: node linkType: hard +"intl-messageformat@npm:^11.2.1": + version: 11.2.1 + resolution: "intl-messageformat@npm:11.2.1" + dependencies: + "@formatjs/fast-memoize": "npm:3.1.2" + "@formatjs/icu-messageformat-parser": "npm:3.5.4" + checksum: 10c0/c4d2e43c2e7940a8dbd75d7a3b77d722c7ac8802707f7410067097e3a923243343bd36367edb4e922b13d1a10425a2f52c544c65c9368841d481816ed04c6c2b + languageName: node + linkType: hard + "ip-address@npm:^10.0.1": version: 10.1.0 resolution: "ip-address@npm:10.1.0"