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/docs/reference.md b/docs/reference.md index a9f4d6b38..459e9d4a2 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -760,8 +760,9 @@ DESCRIPTION USAGE $ apify actors push [actorId] [--allow-missing-secrets] - [-b ] [--dir ] [-f] [--json] [--open] - [-v ] [-w ] + [--apply-env-vars-to-build] [-b ] [--dir ] + [--env ...] [-f] [--json] [--open] [-v ] + [-w ] ARGUMENTS actorId Name or ID of the Actor to push (e.g. "apify/hello-world" or @@ -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= Build tag to be applied to the successful Actor build. By default, 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 2ca91338e..7795256c9 100644 --- a/docs/vars.md +++ b/docs/vars.md @@ -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. diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index d2d2c546a..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,15 @@ 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, + }), }; static override args = { @@ -206,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); @@ -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, 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); 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 +467,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..8c7423566 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; } @@ -51,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 @@ -476,7 +481,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(); @@ -500,7 +512,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 +528,7 @@ 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, @@ -543,10 +557,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/api/commands/push.test.ts b/test/api/commands/push.test.ts index 5a4dc194b..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'; @@ -215,6 +216,241 @@ describe('[api] apify push', () => { 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 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 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'); + }); +}); 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']); + }); + }); });