From 3f4f29946851edb005a7fc6ba46aa653c360850b Mon Sep 17 00:00:00 2001 From: Matous Marik Date: Tue, 25 Aug 2026 14:40:24 +0200 Subject: [PATCH 1/2] 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 2/2] 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'); + }); +});