From 420860a35410664e2e7b30b44a9ebf69dfffd75a Mon Sep 17 00:00:00 2001 From: Matous Marik Date: Mon, 24 Aug 2026 16:24:11 +0200 Subject: [PATCH 1/4] feat: add --apply-env-vars-to-build flag to apify push Allows env vars from actor.json to be applied to the Actor build process (Docker build args) without flipping the switch in Console. --no-apply-env-vars-to-build explicitly turns the setting off. When the flag is omitted, the value stored on the platform is kept. The flag parser matched provided flags by truthiness, which dropped scalar false values injected by the test harness (the real CLI path always yields arrays via multiple: true and was unaffected); it now checks for presence so harness-injected negated booleans behave the same as parsed ones. --- docs/reference.md | 9 ++++- src/commands/actors/push.ts | 10 ++++- src/lib/command-framework/apify-command.ts | 6 ++- test/api/commands/push.test.ts | 46 ++++++++++++++++++++++ 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index a9f4d6b38..996f94fe4 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -760,8 +760,8 @@ DESCRIPTION USAGE $ apify actors push [actorId] [--allow-missing-secrets] - [-b ] [--dir ] [-f] [--json] [--open] - [-v ] [-w ] + [--apply-env-vars-to-build] [-b ] [--dir ] + [-f] [--json] [--open] [-v ] [-w ] ARGUMENTS actorId Name or ID of the Actor to push (e.g. "apify/hello-world" or @@ -772,6 +772,11 @@ 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. When omitted, the setting + currently stored on the platform is kept. -b, --build-tag= Build tag to be applied to the successful Actor build. By default, it is taken from the '.actor/actor.json' file. diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index d2d2c546a..c97f121bc 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -191,6 +191,11 @@ export class ActorsPushCommand extends ApifyCommand { required: false, default: 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. When omitted, the setting currently stored on the platform is kept.', + required: false, + }), }; static override args = { @@ -406,9 +411,11 @@ Skipping push. Use --force to override.`, allowMissing: this.flags.allowMissingSecrets, }) : undefined; + // true/false when --[no-]apply-env-vars-to-build is passed, undefined when omitted so the value stored on the platform is preserved + const { applyEnvVarsToBuild } = this.flags; 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}.` }); @@ -420,6 +427,7 @@ Skipping push. Use --force to override.`, buildTag, sourceType, envVars, + applyEnvVarsToBuild, }; await actorClient.versions().create({ diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index 06b024537..f4718581e 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -500,7 +500,9 @@ export abstract class ApifyCommand 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({ @@ -514,7 +516,7 @@ export abstract class ApifyCommand { TEST_TIMEOUT, ); + it( + 'should set applyEnvVarsToBuild when the flag is passed and keep it when omitted', + async () => { + const testActor = await testUserClient.actors().create(TEST_ACTOR); + actorsForCleanup.add(testActor.id); + const testActorClient = testUserClient.actor(testActor.id); + const actorJson = JSON.parse(readFileSync(joinPath(LOCAL_CONFIG_PATH), 'utf8')); + + await testRunCommand(ActorsPushCommand, { + args_actorId: testActor.id, + flags_noPrompt: true, + flags_force: true, + flags_applyEnvVarsToBuild: true, + }); + + const versionWithFlag = await testActorClient.version(actorJson.version).get(); + + await testRunCommand(ActorsPushCommand, { + args_actorId: testActor.id, + flags_noPrompt: true, + flags_force: true, + }); + + const versionWithoutFlag = await testActorClient.version(actorJson.version).get(); + + // false is what --no-apply-env-vars-to-build parses to + await testRunCommand(ActorsPushCommand, { + args_actorId: testActor.id, + flags_noPrompt: true, + flags_force: true, + flags_applyEnvVarsToBuild: false, + }); + + const versionWithNegatedFlag = await testActorClient.version(actorJson.version).get(); + + await testActorClient.delete(); + + expect(versionWithFlag!.applyEnvVarsToBuild).to.be.eql(true); + // omitting the flag must preserve the value stored on the platform + expect(versionWithoutFlag!.applyEnvVarsToBuild).to.be.eql(true); + // the negated flag must actively turn the setting off + expect(versionWithNegatedFlag!.applyEnvVarsToBuild).to.be.eql(false); + }, + TEST_TIMEOUT, + ); + it( 'should upload zip for source files larger that 3MB', async () => { From a8a1158ea971efb58bfcf8bb9756d7f28c3f4d00 Mon Sep 17 00:00:00 2001 From: Matous Marik Date: Mon, 24 Aug 2026 16:57:20 +0200 Subject: [PATCH 2/4] feat: read applyEnvVarsToBuild from actor.json in apify push The field persists the build env vars setting per repo, so it applies on every push without remembering the flag. The --apply-env-vars-to-build flag takes precedence over the field, and an explicit false in actor.json turns the setting off. When both are omitted, the value stored on the platform is kept. --- docs/reference.md | 7 +++- docs/vars.md | 19 +++++++++ src/commands/actors/push.ts | 8 ++-- test/api/commands/push.test.ts | 70 ++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 6 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 996f94fe4..cb707be19 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -775,8 +775,11 @@ FLAGS --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. When omitted, the setting - currently stored on the platform is kept. + 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= Build tag to be applied to the successful Actor build. By default, it is taken from the '.actor/actor.json' file. diff --git a/docs/vars.md b/docs/vars.md index 2ca91338e..c342c3151 100644 --- a/docs/vars.md +++ b/docs/vars.md @@ -74,3 +74,22 @@ You can use the CLI to manage secrets environment variables: ... } ``` + +### 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. diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index c97f121bc..e822d1043 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -192,8 +192,7 @@ export class ActorsPushCommand extends ApifyCommand { default: 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. When omitted, the setting currently stored on the platform is kept.', + 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, }), }; @@ -411,8 +410,9 @@ Skipping push. Use --force to override.`, allowMissing: this.flags.allowMissingSecrets, }) : undefined; - // true/false when --[no-]apply-env-vars-to-build is passed, undefined when omitted so the value stored on the platform is preserved - const { applyEnvVarsToBuild } = this.flags; + // 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, applyEnvVarsToBuild }; diff --git a/test/api/commands/push.test.ts b/test/api/commands/push.test.ts index 2eaea9814..2e781db7b 100644 --- a/test/api/commands/push.test.ts +++ b/test/api/commands/push.test.ts @@ -261,6 +261,76 @@ describe('[api] apify push', () => { TEST_TIMEOUT, ); + it( + 'should read applyEnvVarsToBuild from actor.json, with the flag taking precedence', + async () => { + const testActor = await testUserClient.actors().create(TEST_ACTOR); + actorsForCleanup.add(testActor.id); + const testActorClient = testUserClient.actor(testActor.id); + const actorJson = JSON.parse(readFileSync(joinPath(LOCAL_CONFIG_PATH), 'utf8')); + + try { + actorJson.applyEnvVarsToBuild = true; + writeFileSync(joinPath(LOCAL_CONFIG_PATH), JSON.stringify(actorJson, null, '\t'), { flag: 'w' }); + + await testRunCommand(ActorsPushCommand, { + args_actorId: testActor.id, + flags_noPrompt: true, + flags_force: true, + }); + + const versionWithFieldTrue = await testActorClient.version(actorJson.version).get(); + + actorJson.applyEnvVarsToBuild = false; + writeFileSync(joinPath(LOCAL_CONFIG_PATH), JSON.stringify(actorJson, null, '\t'), { flag: 'w' }); + // the actor config is cached per cwd, so mid-test rewrites need a reset + resetCwdCaches(); + + await testRunCommand(ActorsPushCommand, { + args_actorId: testActor.id, + flags_noPrompt: true, + flags_force: true, + }); + + const versionWithFieldFalse = await testActorClient.version(actorJson.version).get(); + + // the file still says false, but the flag must win + await testRunCommand(ActorsPushCommand, { + args_actorId: testActor.id, + flags_noPrompt: true, + flags_force: true, + flags_applyEnvVarsToBuild: true, + }); + + const versionWithFlagOverride = await testActorClient.version(actorJson.version).get(); + + // and the negated flag must also win over a true in the file + actorJson.applyEnvVarsToBuild = true; + writeFileSync(joinPath(LOCAL_CONFIG_PATH), JSON.stringify(actorJson, null, '\t'), { flag: 'w' }); + resetCwdCaches(); + + await testRunCommand(ActorsPushCommand, { + args_actorId: testActor.id, + flags_noPrompt: true, + flags_force: true, + flags_applyEnvVarsToBuild: false, + }); + + const versionWithNegatedFlagOverride = await testActorClient.version(actorJson.version).get(); + + expect(versionWithFieldTrue!.applyEnvVarsToBuild).to.be.eql(true); + expect(versionWithFieldFalse!.applyEnvVarsToBuild).to.be.eql(false); + expect(versionWithFlagOverride!.applyEnvVarsToBuild).to.be.eql(true); + expect(versionWithNegatedFlagOverride!.applyEnvVarsToBuild).to.be.eql(false); + } finally { + delete actorJson.applyEnvVarsToBuild; + writeFileSync(joinPath(LOCAL_CONFIG_PATH), JSON.stringify(actorJson, null, '\t'), { flag: 'w' }); + await testActorClient.delete(); + } + }, + TEST_TIMEOUT, + ); + it( 'should upload zip for source files larger that 3MB', async () => { From 3f4f29946851edb005a7fc6ba46aa653c360850b Mon Sep 17 00:00:00 2001 From: Matous Marik Date: Tue, 25 Aug 2026 14:40:24 +0200 Subject: [PATCH 3/4] feat: support multi-value string flags in the command framework Flags.string({ multiple: true }) collects repeated flag values into a string[] instead of rejecting the second occurrence. The parser already registered every flag with multiple: true; the value was collapsed and guarded in _parseFlags, which now keeps the array for flags tagged as multi-value. --- CLAUDE.md | 1 + src/lib/command-framework/apify-command.ts | 24 ++++++++-- src/lib/command-framework/flags.ts | 22 +++++++-- src/lib/command-framework/help/CommandHelp.ts | 4 +- .../help/_BaseCommandRenderer.ts | 4 +- test/local/lib/command-framework.test.ts | 46 +++++++++++++++++++ 6 files changed, 91 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a73bb956b..5c5153b5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index f4718581e..2746aaf61 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -25,6 +25,7 @@ import { registerCommandForHelpGeneration, renderHelpForCommand, selectiveRender import { getMaxLineWidth } from './help/consts.js'; export enum StdinMode { + None = 0, Raw = 1, Stringified = 2, } @@ -35,6 +36,7 @@ interface ArgTagToTSType { interface FlagTagToTSType { string: string; + strings: string[]; boolean: boolean; integer: number; } @@ -476,7 +478,14 @@ export abstract class ApifyCommand 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(); @@ -527,8 +536,8 @@ export abstract class ApifyCommand 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, @@ -545,10 +554,19 @@ export abstract class ApifyCommand 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 { @@ -76,10 +81,16 @@ export function YesFlag(description = 'Automatic yes to prompts; assume "yes" as } function stringFlag>( - 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 ?? [])]); @@ -115,7 +126,8 @@ function stringFlag' : ''; + const repeatableSuffix = flag.flagTag === 'strings' ? '...' : ''; - stringParts.push(`--${this.kebabFlagName(flagName)}=${chalk.underline(flagValues)}`); + stringParts.push(`--${this.kebabFlagName(flagName)}=${chalk.underline(flagValues)}${repeatableSuffix}`); break; } default: diff --git a/src/lib/command-framework/help/_BaseCommandRenderer.ts b/src/lib/command-framework/help/_BaseCommandRenderer.ts index 5efc74cc9..fab5dfa2c 100644 --- a/src/lib/command-framework/help/_BaseCommandRenderer.ts +++ b/src/lib/command-framework/help/_BaseCommandRenderer.ts @@ -174,10 +174,12 @@ export abstract class BaseCommandRenderer { } case 'string': + case 'strings': case 'integer': { const flagValues = flag.choices?.length ? `${flag.choices.join('|')}` : ''; + const repeatableSuffix = flag.flagTag === 'strings' ? '...' : ''; - return `${mainFlagPart} ${flagValues}`; + return `${mainFlagPart} ${flagValues}${repeatableSuffix}`; } default: { diff --git a/test/local/lib/command-framework.test.ts b/test/local/lib/command-framework.test.ts index b319330b8..941e602c3 100644 --- a/test/local/lib/command-framework.test.ts +++ b/test/local/lib/command-framework.test.ts @@ -1,3 +1,4 @@ +import { ActorsPushCommand } from '../../../src/commands/actors/push.js'; import { ValidateSchemaCommand } from '../../../src/commands/validate-schema.js'; import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; import { validInputSchemaPath } from '../../__setup__/input-schemas/paths.js'; @@ -8,4 +9,49 @@ describe('Command Framework', () => { args_path: validInputSchemaPath, }); }); + + describe('multi-value string flags', () => { + const parseFlags = ( + rawFlags: Record, + rawTokens: { kind: string; name: string; rawName: string }[] = [], + ) => { + const instance = new ActorsPushCommand('test-cli', 'push', 'push'); + // @ts-expect-error accessing internals to unit-test flag parsing in isolation + // eslint-disable-next-line dot-notation + instance.flags = {}; + // eslint-disable-next-line dot-notation + instance['_parseFlags'](rawFlags, rawTokens as never); + // @ts-expect-error accessing internals to unit-test flag parsing in isolation + return instance.flags; + }; + + test('collects repeated values into an array', () => { + expect(parseFlags({ env: ['A=1', 'B=2', 'C=3'] }).env).toStrictEqual(['A=1', 'B=2', 'C=3']); + expect(parseFlags({ env: ['A=1'] }).env).toStrictEqual(['A=1']); + }); + + test('wraps scalar values injected by the test harness', () => { + expect(parseFlags({ env: 'A=1' }).env).toStrictEqual(['A=1']); + }); + + test('stays undefined when not provided', () => { + expect(parseFlags({}).env).toBeUndefined(); + }); + + test('single-value flags still reject repeated values', () => { + expect(() => parseFlags({ 'build-tag': ['a', 'b'] })).toThrow(); + }); + + test('strips the leading = of every value only when the short form is used', () => { + // -e='A=1' parses the value as '=A=1'; the parser must strip it per element + const shortFormTokens = [{ kind: 'option', name: 'env', rawName: '-e' }]; + // parseArgs reports the canonical name for both forms; only rawName distinguishes them + const longFormTokens = [{ kind: 'option', name: 'env', rawName: '--env' }]; + + expect(parseFlags({ env: ['=A=1', '=B=2'] }, shortFormTokens).env).toStrictEqual(['A=1', 'B=2']); + // long-form values are kept verbatim, even when they start with = + expect(parseFlags({ env: ['=A=1'] }, longFormTokens).env).toStrictEqual(['=A=1']); + expect(parseFlags({ env: ['=A=1'] }).env).toStrictEqual(['=A=1']); + }); + }); }); From 60c5b8d3a22bef18016e3bdf5df7a9d187b8b97c Mon Sep 17 00:00:00 2001 From: Matous Marik Date: Tue, 25 Aug 2026 14:50:15 +0200 Subject: [PATCH 4/4] feat: add repeatable --env flag to apify push Passes environment variables in KEY=VALUE format directly to the Actor version, primarily for CI. The values are merged with environmentVariables from actor.json, with the CLI value winning on key conflicts. @secret references resolve the same way as in the file. --- docs/reference.md | 11 +- docs/vars.md | 16 +++ src/commands/actors/push.ts | 50 ++++++++- src/lib/command-framework/apify-command.ts | 77 ++++++------- test/api/commands/push.test.ts | 120 +++++++++++++++++++++ test/local/commands/push.test.ts | 23 +++- 6 files changed, 253 insertions(+), 44 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index cb707be19..459e9d4a2 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -761,7 +761,8 @@ DESCRIPTION USAGE $ apify actors push [actorId] [--allow-missing-secrets] [--apply-env-vars-to-build] [-b ] [--dir ] - [-f] [--json] [--open] [-v ] [-w ] + [--env ...] [-f] [--json] [--open] [-v ] + [-w ] ARGUMENTS actorId Name or ID of the Actor to push (e.g. "apify/hello-world" or @@ -785,6 +786,14 @@ FLAGS it is taken from the '.actor/actor.json' file. --dir= Directory where the Actor is located. + --env=... 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. diff --git a/docs/vars.md b/docs/vars.md index c342c3151..7795256c9 100644 --- a/docs/vars.md +++ b/docs/vars.md @@ -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`: diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index e822d1043..2d6ed5041 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -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 { + // null prototype so a key like __proto__ is stored instead of silently swallowed + const result: Record = 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. @@ -191,6 +209,11 @@ export class ActorsPushCommand extends ApifyCommand { 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, @@ -210,6 +233,16 @@ export class ActorsPushCommand extends ApifyCommand { // 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 = {}; + + 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); @@ -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, undefined, { - allowMissing: this.flags.allowMissingSecrets, - }) - : undefined; + const environmentVariables = { + ...(actorConfig!.environmentVariables as Record | 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); diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index 2746aaf61..8c7423566 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -53,45 +53,48 @@ type InferFlagTypeFromFlag< Builder extends TaggedFlagBuilder, OptionalIfHasDefault = false, > = - Builder extends TaggedFlagBuilder // 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 + : Builder extends TaggedFlagBuilder // 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 + >, + // fallback to required status If - >, - // fallback to required status - If - > - : // Might have choices, in which case we branch based on that - Builder extends TaggedFlagBuilder - ? // 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 - > - : 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 + ? // 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 + > + : 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 + >, + // fallback to required status If - >, - // fallback to required status - If - > - : unknown; + > + : unknown; // Adapted from https://gist.github.com/kuroski/9a7ae8e5e5c9e22985364d1ddbf3389d to support kebab-case and "string a" type CamelCase = S extends diff --git a/test/api/commands/push.test.ts b/test/api/commands/push.test.ts index 2e781db7b..8cc9b4c83 100644 --- a/test/api/commands/push.test.ts +++ b/test/api/commands/push.test.ts @@ -8,6 +8,7 @@ import { createHmacSignature } from '@apify/utilities'; import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; import { LOCAL_CONFIG_PATH } from '../../../src/lib/consts.js'; +import { addSecret, removeSecret } from '../../../src/lib/secrets.js'; import { createSourceFiles, getActorLocalFilePaths, getLocalUserInfo } from '../../../src/lib/utils.js'; import { testUserClient } from '../../__setup__/config.js'; import { TEST_TIMEOUT } from '../../__setup__/consts.js'; @@ -331,6 +332,125 @@ describe('[api] apify push', () => { TEST_TIMEOUT, ); + it( + 'should merge --env values over actor.json environmentVariables', + async () => { + const testActor = await testUserClient.actors().create(TEST_ACTOR); + actorsForCleanup.add(testActor.id); + const testActorClient = testUserClient.actor(testActor.id); + const actorJson = JSON.parse(readFileSync(joinPath(LOCAL_CONFIG_PATH), 'utf8')); + + try { + actorJson.environmentVariables = { FROM_FILE: 'file', SHARED: 'file' }; + writeFileSync(joinPath(LOCAL_CONFIG_PATH), JSON.stringify(actorJson, null, '\t'), { flag: 'w' }); + + await testRunCommand(ActorsPushCommand, { + args_actorId: testActor.id, + flags_noPrompt: true, + flags_force: true, + flags_env: ['SHARED=cli', 'FROM_CLI=cli'], + }); + + const version = await testActorClient.version(actorJson.version).get(); + + expect(version!.envVars).to.have.deep.members([ + { name: 'FROM_FILE', value: 'file' }, + { name: 'SHARED', value: 'cli' }, + { name: 'FROM_CLI', value: 'cli' }, + ]); + } finally { + delete actorJson.environmentVariables; + writeFileSync(joinPath(LOCAL_CONFIG_PATH), JSON.stringify(actorJson, null, '\t'), { flag: 'w' }); + await testActorClient.delete(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should resolve @secret values from both actor.json and --env as secret env vars', + async () => { + const testActor = await testUserClient.actors().create(TEST_ACTOR); + actorsForCleanup.add(testActor.id); + const testActorClient = testUserClient.actor(testActor.id); + const actorJson = JSON.parse(readFileSync(joinPath(LOCAL_CONFIG_PATH), 'utf8')); + + addSecret('pushTestSecret', 'push-test-secret-value'); + + try { + actorJson.environmentVariables = { FROM_FILE_SECRET: '@pushTestSecret', PLAIN: 'plain-value' }; + writeFileSync(joinPath(LOCAL_CONFIG_PATH), JSON.stringify(actorJson, null, '\t'), { flag: 'w' }); + + await testRunCommand(ActorsPushCommand, { + args_actorId: testActor.id, + flags_noPrompt: true, + flags_force: true, + flags_env: ['FROM_CLI_SECRET=@pushTestSecret'], + }); + + const version = await testActorClient.version(actorJson.version).get(); + const varsByName = Object.fromEntries(version!.envVars!.map((envVar) => [envVar.name, envVar])); + + expect(Object.keys(varsByName).sort()).to.be.eql(['FROM_CLI_SECRET', 'FROM_FILE_SECRET', 'PLAIN']); + expect(varsByName.PLAIN.isSecret).to.be.not.eql(true); + expect(varsByName.PLAIN.value).to.be.eql('plain-value'); + expect(varsByName.FROM_FILE_SECRET.isSecret).to.be.eql(true); + expect(varsByName.FROM_CLI_SECRET.isSecret).to.be.eql(true); + // secret values must never come back in plain text + expect(varsByName.FROM_FILE_SECRET.value).to.be.not.eql('push-test-secret-value'); + expect(varsByName.FROM_CLI_SECRET.value).to.be.not.eql('push-test-secret-value'); + } finally { + removeSecret('pushTestSecret'); + delete actorJson.environmentVariables; + writeFileSync(joinPath(LOCAL_CONFIG_PATH), JSON.stringify(actorJson, null, '\t'), { flag: 'w' }); + await testActorClient.delete(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should clear platform env vars when actor.json has an empty environmentVariables object', + async () => { + // preservation with the field absent is covered by 'should not rewrite current Actor envVars' + const testActorWithEnvVars = { ...TEST_ACTOR }; + testActorWithEnvVars.versions = [ + { + versionNumber: '0.0', + sourceType: 'SOURCE_FILES' as never, + buildTag: 'latest', + sourceFiles: [], + envVars: [{ name: 'PLATFORM_VAR', value: 'platformValue' }], + }, + ]; + const testActor = await testUserClient.actors().create(testActorWithEnvVars); + actorsForCleanup.add(testActor.id); + const testActorClient = testUserClient.actor(testActor.id); + const actorJson = JSON.parse(readFileSync(joinPath(LOCAL_CONFIG_PATH), 'utf8')); + + try { + // an empty environmentVariables object is still an explicit value and clears the platform vars + actorJson.environmentVariables = {}; + writeFileSync(joinPath(LOCAL_CONFIG_PATH), JSON.stringify(actorJson, null, '\t'), { flag: 'w' }); + + await testRunCommand(ActorsPushCommand, { + args_actorId: testActor.id, + flags_noPrompt: true, + flags_force: true, + }); + + const version = await testActorClient.version(actorJson.version).get(); + + expect(version!.envVars).to.be.eql([]); + } finally { + delete actorJson.environmentVariables; + writeFileSync(joinPath(LOCAL_CONFIG_PATH), JSON.stringify(actorJson, null, '\t'), { flag: 'w' }); + await testActorClient.delete(); + } + }, + TEST_TIMEOUT, + ); + it( 'should upload zip for source files larger that 3MB', async () => { diff --git a/test/local/commands/push.test.ts b/test/local/commands/push.test.ts index 77c9ed7c6..20ba27329 100644 --- a/test/local/commands/push.test.ts +++ b/test/local/commands/push.test.ts @@ -1,6 +1,6 @@ import { ACTOR_JOB_STATUSES } from '@apify/consts'; -import { resolvePushOutcome } from '../../../src/commands/actors/push.js'; +import { parseEnvFlags, resolvePushOutcome } from '../../../src/commands/actors/push.js'; import { CommandExitCodes } from '../../../src/lib/consts.js'; describe('resolvePushOutcome', () => { @@ -37,3 +37,24 @@ describe('resolvePushOutcome', () => { expect(resolvePushOutcome(ACTOR_JOB_STATUSES.FAILED).errorMessage).toBe('Build failed'); }); }); + +describe('parseEnvFlags', () => { + test('parses KEY=VALUE entries, later entries win, values may contain =', () => { + expect(parseEnvFlags([])).toEqual({}); + expect(parseEnvFlags(['A=1', 'B=two'])).toEqual({ A: '1', B: 'two' }); + expect(parseEnvFlags(['A=1', 'A=2'])).toEqual({ A: '2' }); + expect(parseEnvFlags(['URL=https://example.com?a=b'])).toEqual({ URL: 'https://example.com?a=b' }); + expect(parseEnvFlags(['EMPTY='])).toEqual({ EMPTY: '' }); + }); + + test.each([['NO_SEPARATOR'], ['=NO_KEY'], ['']])('rejects malformed entry %j', (entry) => { + expect(() => parseEnvFlags([entry])).toThrow('expected KEY=VALUE format'); + }); + + test('keeps a __proto__ key instead of silently swallowing it', () => { + const parsed = parseEnvFlags(['__proto__=x', 'A=1']); + + expect(Object.keys(parsed).sort()).toStrictEqual(['A', '__proto__']); + expect({ ...parsed }).toHaveProperty('A', '1'); + }); +});