Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ If you modified a command's flags, args, description, or added/removed a command
- Package manager: **pnpm 10** (via Corepack). Do not use npm or yarn.
- Use `.js` import specifiers for local files (e.g. `import { foo } from './foo.js'`). The `.ts` source resolves at build time.
- Commands extend `ApifyCommand` from `src/lib/command-framework/apify-command.ts`. Follow the pattern of existing commands: `static override name`, `static override description`, `static override flags/args`, and an `async run()` method.
- Repeatable flags: `Flags.string({ multiple: true })` collects repeated values into a `string[]` (flag tag `'strings'`). `choices` and `default` are type-forbidden with `multiple`, and stdin (`-`) is disabled for multi-value flags.
- New commands must be registered in `src/commands/_register.ts` (or the parent `_index.ts` for subcommands).
- Do not add docstrings, comments, or type annotations to code you did not change. Keep diffs tight.

Expand Down
11 changes: 10 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,8 @@ DESCRIPTION
USAGE
$ apify actors push [actorId] [--allow-missing-secrets]
[--apply-env-vars-to-build] [-b <value>] [--dir <value>]
[-f] [--json] [--open] [-v <value>] [-w <value>]
[--env <value>...] [-f] [--json] [--open] [-v <value>]
[-w <value>]

ARGUMENTS
actorId Name or ID of the Actor to push (e.g. "apify/hello-world" or
Expand All @@ -785,6 +786,14 @@ FLAGS
it is taken from the '.actor/actor.json' file.
--dir=<value> Directory where the
Actor is located.
--env=<value>... Set an environment
variable for the Actor, in KEY=VALUE format. Can be
used multiple times. Merged with (and overriding)
'environmentVariables' from the '.actor/actor.json'
file. Note that using this flag replaces the full
list of environment variables stored on the
platform, removing any that were set only in Apify
Console.
-f, --force Push an Actor even
when the local files are older than the Actor on
the platform.
Expand Down
16 changes: 16 additions & 0 deletions docs/vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,22 @@ You can use the CLI to manage secrets environment variables:
}
```

### Pass variables on the command line

For one-off pushes (for example in CI), pass variables directly to `apify push` with the repeatable `--env` flag:

```bash
apify push --env MYSQL_USER=my_username --env MYSQL_PASSWORD=@mySecretPassword
```

The values are merged with `environmentVariables` from `.actor/actor.json`; when a key is defined in both, the `--env` value wins. The `@` prefix references stored secrets the same way as in the file.

:::caution

Pushing with `--env` (just like pushing with `environmentVariables` in `.actor/actor.json`) replaces the full list of environment variables stored on the platform. Variables set only in Apify Console are removed — include them in `.actor/actor.json` or `--env` if you want to keep them.

:::

### Apply environment variables to the build

By default, custom environment variables are available only at runtime. To also make them available to the Actor build process (for example, as Docker build arguments), set `applyEnvVarsToBuild` in `.actor/actor.json`:
Expand Down
50 changes: 45 additions & 5 deletions src/commands/actors/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,24 @@ interface PushOutcome {
errorMessage?: string;
}

// Parses --env values in KEY=VALUE format into an env object.
export function parseEnvFlags(entries: string[]): Record<string, string> {
// null prototype so a key like __proto__ is stored instead of silently swallowed
const result: Record<string, string> = Object.create(null);

for (const entry of entries) {
const separatorIndex = entry.indexOf('=');

if (separatorIndex < 1) {
throw new Error(`Invalid --env value "${entry}", expected KEY=VALUE format.`);
}

result[entry.slice(0, separatorIndex)] = entry.slice(separatorIndex + 1);
}

return result;
}

// Maps the final build status to the overall push outcome. A still-running
// fire-and-forget build is not a failure (`ok: true`) — its pending state is
// conveyed by the build status, and it carries no exit code yet.
Expand Down Expand Up @@ -191,6 +209,11 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
required: false,
default: false,
}),
env: Flags.string({
description: `Set an environment variable for the Actor, in KEY=VALUE format. Can be used multiple times. Merged with (and overriding) 'environmentVariables' from the '${LOCAL_CONFIG_PATH}' file. Note that using this flag replaces the full list of environment variables stored on the platform, removing any that were set only in Apify Console.`,
multiple: true,
required: false,
}),
'apply-env-vars-to-build': Flags.boolean({
description: `Make the environment variables also available to the Actor build process. Use --no-apply-env-vars-to-build to turn the setting off. Overrides the 'applyEnvVarsToBuild' field in the '${LOCAL_CONFIG_PATH}' file. When both are omitted, the setting currently stored on the platform is kept.`,
required: false,
Expand All @@ -210,6 +233,16 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
// Resolving with `.` will mean stay in the cwd folder, whereas anything else in dir will be resolved. If users pass in a full path (`/home/...`, it will correctly resolve to that)
const cwd = resolve(process.cwd(), this.flags.dir ?? '.');

let cliEnvVars: Record<string, string> = {};

try {
cliEnvVars = parseEnvFlags(this.flags.env ?? []);
} catch (err) {
error({ message: (err as Error).message });
process.exitCode = CommandExitCodes.InvalidInput;
return;
}

// Validate there are files before rest of the logic
const filePathsToPush = await getActorLocalFilePaths(cwd);

Expand Down Expand Up @@ -405,11 +438,18 @@ Skipping push. Use --force to override.`,

// Update Actor version
const actorCurrentVersion = await actorClient.version(version).get();
const envVars = actorConfig!.environmentVariables
? transformEnvToEnvVars(actorConfig!.environmentVariables as Record<string, string>, undefined, {
allowMissing: this.flags.allowMissingSecrets,
})
: undefined;
const environmentVariables = {
...(actorConfig!.environmentVariables as Record<string, string> | undefined),
...cliEnvVars,
};
// Sent whenever actor.json has the field (even empty, which clears the platform vars) or --env is used;
// otherwise omitted entirely so the platform vars are preserved
const envVars =
actorConfig!.environmentVariables || this.flags.env?.length
? transformEnvToEnvVars(environmentVariables, undefined, {
allowMissing: this.flags.allowMissingSecrets,
})
: undefined;
// undefined when neither the flag nor the actor.json field is set, so the value stored on the platform is preserved
const applyEnvVarsToBuild =
this.flags.applyEnvVarsToBuild ?? (actorConfig!.applyEnvVarsToBuild as boolean | undefined);
Expand Down
101 changes: 61 additions & 40 deletions src/lib/command-framework/apify-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { registerCommandForHelpGeneration, renderHelpForCommand, selectiveRender
import { getMaxLineWidth } from './help/consts.js';

export enum StdinMode {
None = 0,
Raw = 1,
Stringified = 2,
}
Expand All @@ -35,6 +36,7 @@ interface ArgTagToTSType {

interface FlagTagToTSType {
string: string;
strings: string[];
boolean: boolean;
integer: number;
}
Expand All @@ -51,45 +53,48 @@ type InferFlagTypeFromFlag<
Builder extends TaggedFlagBuilder<FlagTag, string[] | null, unknown, unknown>,
OptionalIfHasDefault = false,
> =
Builder extends TaggedFlagBuilder<infer ReturnedType, never, infer Required, infer HasDefault> // Handle special case where there can be no choices
? If<
// If we want to mark flags as optional if they have a default
OptionalIfHasDefault,
// If the flag actually has a default value, assert on that
IfNotUnknown<
HasDefault,
FlagTagToTSType[ReturnedType] | undefined,
// Otherwise fall back to required status
// Multi-value flags always yield a string array; choices do not apply to them
Builder extends TaggedFlagBuilder<'strings', string[] | null, infer Required, unknown>
? If<Required, string[], string[] | undefined>
: Builder extends TaggedFlagBuilder<infer ReturnedType, never, infer Required, infer HasDefault> // Handle special case where there can be no choices
? If<
// If we want to mark flags as optional if they have a default
OptionalIfHasDefault,
// If the flag actually has a default value, assert on that
IfNotUnknown<
HasDefault,
FlagTagToTSType[ReturnedType] | undefined,
// Otherwise fall back to required status
If<Required, FlagTagToTSType[ReturnedType], FlagTagToTSType[ReturnedType] | undefined>
>,
// fallback to required status
If<Required, FlagTagToTSType[ReturnedType], FlagTagToTSType[ReturnedType] | undefined>
>,
// fallback to required status
If<Required, FlagTagToTSType[ReturnedType], FlagTagToTSType[ReturnedType] | undefined>
>
: // Might have choices, in which case we branch based on that
Builder extends TaggedFlagBuilder<infer ReturnedType, infer ChoiceType, infer Required, infer HasDefault>
? // If choices is a valid array
ChoiceType extends unknown[] | readonly unknown[]
? // If we want optional flags to stay as optional
If<
OptionalIfHasDefault,
ChoiceType[number] | undefined,
// fallback to required status
If<Required, ChoiceType[number], ChoiceType[number] | undefined>
>
: If<
// If we want to mark flags as optional if they have a default
OptionalIfHasDefault,
// If the flag actually has a default value, assert on that
IfNotUnknown<
HasDefault,
FlagTagToTSType[ReturnedType] | undefined,
// Fallback to required status
>
: // Might have choices, in which case we branch based on that
Builder extends TaggedFlagBuilder<infer ReturnedType, infer ChoiceType, infer Required, infer HasDefault>
? // If choices is a valid array
ChoiceType extends unknown[] | readonly unknown[]
? // If we want optional flags to stay as optional
If<
OptionalIfHasDefault,
ChoiceType[number] | undefined,
// fallback to required status
If<Required, ChoiceType[number], ChoiceType[number] | undefined>
>
: If<
// If we want to mark flags as optional if they have a default
OptionalIfHasDefault,
// If the flag actually has a default value, assert on that
IfNotUnknown<
HasDefault,
FlagTagToTSType[ReturnedType] | undefined,
// Fallback to required status
If<Required, FlagTagToTSType[ReturnedType], FlagTagToTSType[ReturnedType] | undefined>
>,
// fallback to required status
If<Required, FlagTagToTSType[ReturnedType], FlagTagToTSType[ReturnedType] | undefined>
>,
// fallback to required status
If<Required, FlagTagToTSType[ReturnedType], FlagTagToTSType[ReturnedType] | undefined>
>
: unknown;
>
: unknown;

// Adapted from https://gist.github.com/kuroski/9a7ae8e5e5c9e22985364d1ddbf3389d to support kebab-case and "string a"
type CamelCase<S extends string> = S extends
Expand Down Expand Up @@ -476,7 +481,14 @@ export abstract class ApifyCommand<T extends typeof BuiltApifyCommand = typeof B

const camelCasedName = camelCaseString(rawBaseFlagName);

const usedShortFormOfTheFlag = rawTokens.some((token) => token.kind === 'option' && token.name === baseFlagName);
// parseArgs reports the canonical long name in token.name for both forms; only rawName shows the short form
const usedShortFormOfTheFlag = rawTokens.some(
(token) =>
token.kind === 'option' &&
token.name === baseFlagName &&
token.rawName.startsWith('-') &&
!token.rawName.startsWith('--'),
);

if (builderData.exclusive?.length) {
const existingExclusiveFlags = exclusiveFlagMap.get(baseFlagName) ?? new Set();
Expand Down Expand Up @@ -527,8 +539,8 @@ export abstract class ApifyCommand<T extends typeof BuiltApifyCommand = typeof B
});
}

// If you provide --a 1 --a 2, it's <currently> not allowed
if (Array.isArray(rawFlag)) {
// If you provide --a 1 --a 2, it's not allowed unless the flag opted into multiple values
if (Array.isArray(rawFlag) && builderData.flagTag !== 'strings') {
if (rawFlag.length > 1) {
throw new CommandError({
code: CommandErrorCode.APIFY_FLAG_PROVIDED_MULTIPLE_TIMES,
Expand All @@ -545,10 +557,19 @@ export abstract class ApifyCommand<T extends typeof BuiltApifyCommand = typeof B
// -i='{"foo":"bar"}'
if (usedShortFormOfTheFlag && typeof rawFlag === 'string' && rawFlag.startsWith('=')) {
rawFlag = rawFlag.slice(1);
} else if (usedShortFormOfTheFlag && Array.isArray(rawFlag)) {
// Same strip for multi-value flags, where values arrive as an array
rawFlag = rawFlag.map((value) => (typeof value === 'string' && value.startsWith('=') ? value.slice(1) : value));
}

if (typeof rawFlag !== 'undefined') {
switch (builderData.flagTag) {
case 'strings': {
// The parser always yields arrays; scalars only come from internalRunCommand injection
this.flags[camelCasedName] = Array.isArray(rawFlag) ? rawFlag : [rawFlag];

break;
}
case 'boolean': {
this.flags[camelCasedName] = rawBaseFlagName.startsWith('no-') ? !rawFlag : rawFlag;

Expand Down
22 changes: 17 additions & 5 deletions src/lib/command-framework/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ParseArgsOptionDescriptor } from 'node:util';

import { camelCaseToKebabCase, kebabCaseString, StdinMode } from './apify-command.js';

export type FlagTag = 'string' | 'boolean' | 'integer';
export type FlagTag = 'string' | 'strings' | 'boolean' | 'integer';

export interface BaseFlagOptions {
required?: boolean;
Expand All @@ -24,6 +24,11 @@ export interface BaseFlagOptions {
export interface StringFlagOptions<Choices extends readonly string[] = readonly string[]> extends BaseFlagOptions {
choices?: Choices;
default?: string;
/**
* Whether the flag can be provided multiple times, collecting all values into an array
* @default false
*/
multiple?: boolean;
}

export interface BooleanFlagOptions extends BaseFlagOptions {
Expand Down Expand Up @@ -76,10 +81,16 @@ export function YesFlag(description = 'Automatic yes to prompts; assume "yes" as
}

function stringFlag<const Choices extends string[], const T extends StringFlagOptions<readonly string[]>>(
options: T & { choices?: Choices },
): TaggedFlagBuilder<'string', Choices, T['default'] extends string ? true : T['required'], T['default']> {
// Multi-value flags do not support choices or default (unimplemented in parsing), so forbid them at the type level
options: T & { choices?: Choices } & (T['multiple'] extends true ? { choices?: never; default?: never } : unknown),
): TaggedFlagBuilder<
T['multiple'] extends true ? 'strings' : 'string',
Choices,
T['default'] extends string ? true : T['required'],
T['default']
> {
return {
flagTag: 'string',
flagTag: (options.multiple ? 'strings' : 'string') as never,
builder: (objectName) => {
const allAliases = new Set([...(options.aliases ?? [])]);

Expand Down Expand Up @@ -115,7 +126,8 @@ function stringFlag<const Choices extends string[], const T extends StringFlagOp
choices: options.choices as Choices,
required: (options.required ?? false) as never,
hasDefault: options.default,
stdin: options.stdin ?? StdinMode.Stringified,
// Multi-value flags do not support stdin ('-' is treated as a regular value)
stdin: options.stdin ?? (options.multiple ? StdinMode.None : StdinMode.Stringified),

description: options.description,
aliases: options.aliases,
Expand Down
4 changes: 3 additions & 1 deletion src/lib/command-framework/help/CommandHelp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,10 +272,12 @@ export class CommandHelp extends BaseCommandRenderer {
stringParts.push(`--${this.kebabFlagName(flagName)}`);
break;
case 'string':
case 'strings':
case 'integer': {
const flagValues = flag.choices ? '<option>' : '<value>';
const repeatableSuffix = flag.flagTag === 'strings' ? '...' : '';

stringParts.push(`--${this.kebabFlagName(flagName)}=${chalk.underline(flagValues)}`);
stringParts.push(`--${this.kebabFlagName(flagName)}=${chalk.underline(flagValues)}${repeatableSuffix}`);
break;
}
default:
Expand Down
4 changes: 3 additions & 1 deletion src/lib/command-framework/help/_BaseCommandRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,12 @@ export abstract class BaseCommandRenderer {
}

case 'string':
case 'strings':
case 'integer': {
const flagValues = flag.choices?.length ? `${flag.choices.join('|')}` : '<value>';
const repeatableSuffix = flag.flagTag === 'strings' ? '...' : '';

return `${mainFlagPart} ${flagValues}`;
return `${mainFlagPart} ${flagValues}${repeatableSuffix}`;
}

default: {
Expand Down
Loading