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
21 changes: 19 additions & 2 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -760,8 +760,9 @@ DESCRIPTION

USAGE
$ apify actors push [actorId] [--allow-missing-secrets]
[-b <value>] [--dir <value>] [-f] [--json] [--open]
[-v <value>] [-w <value>]
[--apply-env-vars-to-build] [-b <value>] [--dir <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 @@ -772,11 +773,27 @@ FLAGS
--allow-missing-secrets Allow the command to
continue even when secret values are not found in
the local secrets storage.
--apply-env-vars-to-build 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
'.actor/actor.json' file. When both are omitted,
the setting currently stored on the platform is
kept.
-b, --build-tag=<value> Build tag to be
applied to the successful Actor build. By default,
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
35 changes: 35 additions & 0 deletions docs/vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,38 @@ 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`:

```json
{
"actorSpecification": 1,
"name": "dataset-to-mysql",
"version": "0.1",
"buildTag": "latest",
"applyEnvVarsToBuild": true,
"environmentVariables": {
"MYSQL_PASSWORD": "@mySecretPassword"
}
}
```

Alternatively, pass the `--apply-env-vars-to-build` flag to `apify push` for a one-off push, or `--no-apply-env-vars-to-build` to turn the setting off. The flag overrides the `applyEnvVarsToBuild` field. When both are omitted, the setting currently stored on the Apify platform is kept.
60 changes: 54 additions & 6 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,15 @@ 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,
}),
};

static override args = {
Expand All @@ -206,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 @@ -401,14 +438,24 @@ 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);

if (actorCurrentVersion) {
const actorVersionModifier = { tarballUrl, sourceFiles, buildTag, sourceType, envVars };
const actorVersionModifier = { tarballUrl, sourceFiles, buildTag, sourceType, envVars, applyEnvVarsToBuild };
// TODO: fix this type too -.-
await actorClient.version(version).update(actorVersionModifier as never);
run({ message: `Updated version ${version} for Actor ${actor.name}.` });
Expand All @@ -420,6 +467,7 @@ Skipping push. Use --force to override.`,
buildTag,
sourceType,
envVars,
applyEnvVarsToBuild,
};

await actorClient.versions().create({
Expand Down
107 changes: 65 additions & 42 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 All @@ -500,7 +512,9 @@ export abstract class ApifyCommand<T extends typeof BuiltApifyCommand = typeof B
}

// If you have a flag a, with alias b, and you pass --a and --b, it's not allowed
const matchingFlags = allMatchers.filter((matcher) => rawFlags[matcher]);
// Check for presence, not truthiness: the real CLI path always yields arrays (`multiple: true`), but
// internalRunCommand/testRunCommand inject scalar values, where an explicit `false` must match too
const matchingFlags = allMatchers.filter((matcher) => typeof rawFlags[matcher] !== 'undefined');

if (matchingFlags.length > 1) {
throw new CommandError({
Expand All @@ -514,7 +528,7 @@ export abstract class ApifyCommand<T extends typeof BuiltApifyCommand = typeof B

let rawFlag = rawFlags[matchingFlags[0]];

if (!rawFlag && builderData.required) {
if (typeof rawFlag === 'undefined' && builderData.required) {
throw new CommandError({
code: CommandErrorCode.APIFY_MISSING_FLAG,
command: this.ctor,
Expand All @@ -525,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 @@ -543,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
Loading