From 0a94cf24633c983a4de56602ced0e67b9c5253d0 Mon Sep 17 00:00:00 2001 From: JuanGalilea Date: Mon, 21 Sep 2026 17:04:42 +0200 Subject: [PATCH 1/4] feat(mode): add grouped config mode --- bin/utils/config/parser.ts | 2 + bin/utils/config/structures/base.ts | 1 + bin/utils/config/structures/grouped.ts | 38 ++++++++ test/unit/bin/utils/config/parser.test.ts | 24 ++++- .../utils/config/structures/grouped.test.ts | 88 +++++++++++++++++++ 5 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 bin/utils/config/structures/grouped.ts create mode 100644 test/unit/bin/utils/config/structures/grouped.test.ts diff --git a/bin/utils/config/parser.ts b/bin/utils/config/parser.ts index 7ec1c1a..8f41484 100644 --- a/bin/utils/config/parser.ts +++ b/bin/utils/config/parser.ts @@ -1,6 +1,7 @@ import z from 'zod'; import { CONFIG_FILE_STRATEGY, type ResolvedActorConfig, type StrategyParser } from './structures/base.js'; +import { GROUPED_PARSER } from './structures/grouped.js'; import { LEGACY_PARSER } from './structures/legacy.js'; const CONFIG_FILE_STRATEGIES: { @@ -8,6 +9,7 @@ const CONFIG_FILE_STRATEGIES: { [key in CONFIG_FILE_STRATEGY]: StrategyParser & { mode: key }; } = { [CONFIG_FILE_STRATEGY.LEGACY]: LEGACY_PARSER, + [CONFIG_FILE_STRATEGY.GROUPED]: GROUPED_PARSER, } as const; const ModeSelectionSchema = z.enum(CONFIG_FILE_STRATEGY).default(CONFIG_FILE_STRATEGY.LEGACY); diff --git a/bin/utils/config/structures/base.ts b/bin/utils/config/structures/base.ts index afe8264..0e10fc8 100644 --- a/bin/utils/config/structures/base.ts +++ b/bin/utils/config/structures/base.ts @@ -20,6 +20,7 @@ export interface ResolvedActorConfig { export enum CONFIG_FILE_STRATEGY { LEGACY = 'legacy', + GROUPED = 'grouped', } export type StrategyParser = { diff --git a/bin/utils/config/structures/grouped.ts b/bin/utils/config/structures/grouped.ts new file mode 100644 index 0000000..4d9529d --- /dev/null +++ b/bin/utils/config/structures/grouped.ts @@ -0,0 +1,38 @@ +import z from 'zod'; + +import { ACTOR_FULL_NAME_REGEX, CONFIG_FILE_STRATEGY, defineStrategy } from './base.js'; + +const schema = z + .record( + z.string(), + z.object({ + actors: z + .array( + z.object({ + folder: z.string(), + actorFullName: z.string().regex(ACTOR_FULL_NAME_REGEX), + // these allow overrides from stuff set at the group level + tokenEnvVar: z.string().optional(), + overrideActorContext: z.array(z.string()).optional(), + }), + ) + .min(1), + tokenEnvVar: z.string(), + overrideActorContext: z.array(z.string()).optional(), + }), + ) + .refine((data) => Object.keys(data).length > 0, 'Invalid input: At least one configuration group is required.'); + +type GroupedConfig = z.infer; + +export const GROUPED_PARSER = defineStrategy(CONFIG_FILE_STRATEGY.GROUPED, schema, (body: GroupedConfig) => { + const entries = Object.entries(body); + return entries.flatMap(([_folder, entry]) => + entry.actors.map((actor) => ({ + tokenEnvVar: entry.tokenEnvVar, + overrideActorContext: entry.overrideActorContext, + // last so actor can override stuff from the group + ...actor, + })), + ); +}); diff --git a/test/unit/bin/utils/config/parser.test.ts b/test/unit/bin/utils/config/parser.test.ts index 5daa7bf..517ab84 100644 --- a/test/unit/bin/utils/config/parser.test.ts +++ b/test/unit/bin/utils/config/parser.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { _privates, parseConfigFile } from '../../../../../bin/utils/config/parser.js'; import { CONFIG_FILE_STRATEGY } from '../../../../../bin/utils/config/structures/base.js'; +import { GROUPED_PARSER } from '../../../../../bin/utils/config/structures/grouped.js'; import { LEGACY_PARSER } from '../../../../../bin/utils/config/structures/legacy.js'; const { selectStrategy, verifyConfiguration } = _privates; @@ -18,12 +19,15 @@ describe('selectStrategy', () => { expect(selectStrategy(undefined)).toBe(LEGACY_PARSER); }); - it('honours an explicit mode', () => { - expect(selectStrategy(CONFIG_FILE_STRATEGY.LEGACY)).toBe(LEGACY_PARSER); + it.each([ + [CONFIG_FILE_STRATEGY.LEGACY, LEGACY_PARSER], + [CONFIG_FILE_STRATEGY.GROUPED, GROUPED_PARSER], + ])('honours an explicit "%s" mode', (mode, expected) => { + expect(selectStrategy(mode)).toBe(expected); }); it('throws on a mode no strategy is registered for', () => { - expect(() => selectStrategy('some-nonexistent-mode')).toThrow(/Invalid input/); + expect(() => selectStrategy('some-nonexistent-mode')).toThrow(); }); }); @@ -124,6 +128,20 @@ describe('parseConfigFile', () => { ]); }); + it('parses a grouped config file, which needs mode to be stripped', () => { + expect( + parseConfigFile({ + mode: CONFIG_FILE_STRATEGY.GROUPED, + myteam: { + actors: [{ folder: 'actors/shopify/', actorFullName: 'myteam/shopify' }], + tokenEnvVar: 'APIFY_TOKEN', + }, + }), + ).toEqual([ + actor({ folder: 'actors/shopify', actorFullName: 'myteam/shopify', overrideActorContext: undefined }), + ]); + }); + it('surfaces cross-entry violations from verifyConfiguration', () => { expect(() => parseConfigFile({ diff --git a/test/unit/bin/utils/config/structures/grouped.test.ts b/test/unit/bin/utils/config/structures/grouped.test.ts new file mode 100644 index 0000000..56e7d6f --- /dev/null +++ b/test/unit/bin/utils/config/structures/grouped.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { GROUPED_PARSER } from '../../../../../../bin/utils/config/structures/grouped.js'; + +const actor = (fields: Record = {}) => ({ + folder: 'actors/shopify', + actorFullName: 'myteam/shopify', + ...fields, +}); + +const group = (fields: Record = {}) => ({ + actors: [actor()], + tokenEnvVar: 'APIFY_TOKEN', + ...fields, +}); + +describe('GROUPED_PARSER', () => { + it('flattens every group into one list, handing each actor its group’s settings', () => { + expect( + GROUPED_PARSER.parse({ + myteam: { + actors: [actor(), actor({ folder: 'actors/email', actorFullName: 'myteam/email' })], + tokenEnvVar: 'APIFY_TOKEN_MYTEAM', + overrideActorContext: ['packages'], + }, + other: { actors: [actor({ folder: 'actors/x', actorFullName: 'other/x' })], tokenEnvVar: 'TOKEN' }, + }), + ).toEqual([ + { + folder: 'actors/shopify', + actorFullName: 'myteam/shopify', + tokenEnvVar: 'APIFY_TOKEN_MYTEAM', + overrideActorContext: ['packages'], + }, + { + folder: 'actors/email', + actorFullName: 'myteam/email', + tokenEnvVar: 'APIFY_TOKEN_MYTEAM', + overrideActorContext: ['packages'], + }, + { folder: 'actors/x', actorFullName: 'other/x', tokenEnvVar: 'TOKEN', overrideActorContext: undefined }, + ]); + }); + + it('ignores the group key entirely', () => { + expect(GROUPED_PARSER.parse({ 'not-a-folder-at-all': group() })).toEqual([ + { folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'APIFY_TOKEN' }, + ]); + }); + + it('allows actors to override some fields', () => { + expect(GROUPED_PARSER.parse({ myteam: group({ actors: [actor({ tokenEnvVar: 'FROM_ACTOR' })] }) })).toEqual([ + { folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'FROM_ACTOR' }, + ]); + }); + + it("returns the entries as written — normalizing is not this layer's job", () => { + const raw = actor({ folder: 'actors/shopify/' }); + + expect(GROUPED_PARSER.parse({ myteam: group({ actors: [raw], overrideActorContext: ['packages/'] }) })).toEqual( + [{ ...raw, tokenEnvVar: 'APIFY_TOKEN', overrideActorContext: ['packages/'] }], + ); + }); + + // Unlike the legacy schema, which requires at least one actor, a record with no groups is valid and + // resolves to no actors at all. Every command downstream then runs against an empty set. + it('accepts a config with no groups and resolves it to no actors', () => { + expect(() => GROUPED_PARSER.parse({})).toThrow(); + }); + + it('reports problems from every group at once instead of only the first', () => { + const message = (() => { + try { + GROUPED_PARSER.parse({ + myteam: { actors: [actor({ folder: 123 })], tokenEnvVar: 'APIFY_TOKEN' }, + other: { actors: [actor({ actorFullName: 'nope' })] }, + }); + throw new Error('Function should have thrown'); + } catch (err) { + return (err as Error).message; + } + })(); + + expect(message).toContain('myteam.actors[0].folder'); + expect(message).toContain('other.actors[0].actorFullName'); + expect(message).toContain('other.tokenEnvVar'); + }); +}); From e83ab95c6d66fbb6080384c2269cd4e1f6a5e9b5 Mon Sep 17 00:00:00 2001 From: JuanGalilea Date: Tue, 22 Sep 2026 15:04:04 +0200 Subject: [PATCH 2/4] fix nit about test not matching name or comment --- test/unit/bin/utils/config/structures/grouped.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/unit/bin/utils/config/structures/grouped.test.ts b/test/unit/bin/utils/config/structures/grouped.test.ts index 56e7d6f..e097c85 100644 --- a/test/unit/bin/utils/config/structures/grouped.test.ts +++ b/test/unit/bin/utils/config/structures/grouped.test.ts @@ -62,9 +62,7 @@ describe('GROUPED_PARSER', () => { ); }); - // Unlike the legacy schema, which requires at least one actor, a record with no groups is valid and - // resolves to no actors at all. Every command downstream then runs against an empty set. - it('accepts a config with no groups and resolves it to no actors', () => { + it('rejects a config with no groups (and thus no actors)', () => { expect(() => GROUPED_PARSER.parse({})).toThrow(); }); From dd1bda03bd757375dafb844722fc70c83ececf74 Mon Sep 17 00:00:00 2001 From: JuanGalilea Date: Wed, 23 Sep 2026 10:00:27 +0200 Subject: [PATCH 3/4] nest groups on grouped mode for to have no collision --- bin/utils/config/parser.ts | 9 ++-- bin/utils/config/structures/grouped.ts | 44 ++++++++++--------- test/unit/bin/utils/config/parser.test.ts | 12 ++--- .../utils/config/structures/grouped.test.ts | 32 ++++++++------ 4 files changed, 52 insertions(+), 45 deletions(-) diff --git a/bin/utils/config/parser.ts b/bin/utils/config/parser.ts index 8f41484..4af06a1 100644 --- a/bin/utils/config/parser.ts +++ b/bin/utils/config/parser.ts @@ -12,7 +12,7 @@ const CONFIG_FILE_STRATEGIES: { [CONFIG_FILE_STRATEGY.GROUPED]: GROUPED_PARSER, } as const; -const ModeSelectionSchema = z.enum(CONFIG_FILE_STRATEGY).default(CONFIG_FILE_STRATEGY.LEGACY); +const ModeSelectionSchema = z.object({ mode: z.enum(CONFIG_FILE_STRATEGY).default(CONFIG_FILE_STRATEGY.LEGACY) }); function selectStrategy(mode: unknown): StrategyParser { const parsed = ModeSelectionSchema.safeParse(mode); @@ -20,7 +20,7 @@ function selectStrategy(mode: unknown): StrategyParser { throw new Error(z.prettifyError(parsed.error)); } - return CONFIG_FILE_STRATEGIES[parsed.data]; + return CONFIG_FILE_STRATEGIES[parsed.data.mode]; } // Strips a trailing slash so config-declared paths ("actors/shopify/" vs "actors/shopify") compare equal. @@ -81,10 +81,9 @@ export const _privates = { }; export function parseConfigFile(body: Record): ResolvedActorConfig[] { - const { mode, ...rest } = body; - const strategy = selectStrategy(mode); + const strategy = selectStrategy(body); - const resolved = strategy.parse(rest); + const resolved = strategy.parse(body); const validated = verifyConfiguration(resolved); return validated; } diff --git a/bin/utils/config/structures/grouped.ts b/bin/utils/config/structures/grouped.ts index 4d9529d..c518909 100644 --- a/bin/utils/config/structures/grouped.ts +++ b/bin/utils/config/structures/grouped.ts @@ -2,31 +2,33 @@ import z from 'zod'; import { ACTOR_FULL_NAME_REGEX, CONFIG_FILE_STRATEGY, defineStrategy } from './base.js'; -const schema = z - .record( - z.string(), - z.object({ - actors: z - .array( - z.object({ - folder: z.string(), - actorFullName: z.string().regex(ACTOR_FULL_NAME_REGEX), - // these allow overrides from stuff set at the group level - tokenEnvVar: z.string().optional(), - overrideActorContext: z.array(z.string()).optional(), - }), - ) - .min(1), - tokenEnvVar: z.string(), - overrideActorContext: z.array(z.string()).optional(), - }), - ) - .refine((data) => Object.keys(data).length > 0, 'Invalid input: At least one configuration group is required.'); +const schema = z.object({ + groups: z + .record( + z.string(), + z.object({ + actors: z + .array( + z.object({ + folder: z.string(), + actorFullName: z.string().regex(ACTOR_FULL_NAME_REGEX), + // these allow overrides from stuff set at the group level + tokenEnvVar: z.string().optional(), + overrideActorContext: z.array(z.string()).optional(), + }), + ) + .min(1), + tokenEnvVar: z.string(), + overrideActorContext: z.array(z.string()).optional(), + }), + ) + .refine((data) => Object.keys(data).length > 0, 'Invalid input: At least one configuration group is required.'), +}); type GroupedConfig = z.infer; export const GROUPED_PARSER = defineStrategy(CONFIG_FILE_STRATEGY.GROUPED, schema, (body: GroupedConfig) => { - const entries = Object.entries(body); + const entries = Object.entries(body.groups); return entries.flatMap(([_folder, entry]) => entry.actors.map((actor) => ({ tokenEnvVar: entry.tokenEnvVar, diff --git a/test/unit/bin/utils/config/parser.test.ts b/test/unit/bin/utils/config/parser.test.ts index 517ab84..b1e48fb 100644 --- a/test/unit/bin/utils/config/parser.test.ts +++ b/test/unit/bin/utils/config/parser.test.ts @@ -16,14 +16,14 @@ const actor = (fields: Record = {}) => ({ describe('selectStrategy', () => { it('falls back to the legacy strategy when no mode is declared', () => { - expect(selectStrategy(undefined)).toBe(LEGACY_PARSER); + expect(selectStrategy({})).toBe(LEGACY_PARSER); }); it.each([ [CONFIG_FILE_STRATEGY.LEGACY, LEGACY_PARSER], [CONFIG_FILE_STRATEGY.GROUPED, GROUPED_PARSER], ])('honours an explicit "%s" mode', (mode, expected) => { - expect(selectStrategy(mode)).toBe(expected); + expect(selectStrategy({ mode })).toBe(expected); }); it('throws on a mode no strategy is registered for', () => { @@ -132,9 +132,11 @@ describe('parseConfigFile', () => { expect( parseConfigFile({ mode: CONFIG_FILE_STRATEGY.GROUPED, - myteam: { - actors: [{ folder: 'actors/shopify/', actorFullName: 'myteam/shopify' }], - tokenEnvVar: 'APIFY_TOKEN', + groups: { + myteam: { + actors: [{ folder: 'actors/shopify/', actorFullName: 'myteam/shopify' }], + tokenEnvVar: 'APIFY_TOKEN', + }, }, }), ).toEqual([ diff --git a/test/unit/bin/utils/config/structures/grouped.test.ts b/test/unit/bin/utils/config/structures/grouped.test.ts index e097c85..5a2767c 100644 --- a/test/unit/bin/utils/config/structures/grouped.test.ts +++ b/test/unit/bin/utils/config/structures/grouped.test.ts @@ -18,12 +18,14 @@ describe('GROUPED_PARSER', () => { it('flattens every group into one list, handing each actor its group’s settings', () => { expect( GROUPED_PARSER.parse({ - myteam: { - actors: [actor(), actor({ folder: 'actors/email', actorFullName: 'myteam/email' })], - tokenEnvVar: 'APIFY_TOKEN_MYTEAM', - overrideActorContext: ['packages'], + groups: { + myteam: { + actors: [actor(), actor({ folder: 'actors/email', actorFullName: 'myteam/email' })], + tokenEnvVar: 'APIFY_TOKEN_MYTEAM', + overrideActorContext: ['packages'], + }, + other: { actors: [actor({ folder: 'actors/x', actorFullName: 'other/x' })], tokenEnvVar: 'TOKEN' }, }, - other: { actors: [actor({ folder: 'actors/x', actorFullName: 'other/x' })], tokenEnvVar: 'TOKEN' }, }), ).toEqual([ { @@ -43,23 +45,23 @@ describe('GROUPED_PARSER', () => { }); it('ignores the group key entirely', () => { - expect(GROUPED_PARSER.parse({ 'not-a-folder-at-all': group() })).toEqual([ + expect(GROUPED_PARSER.parse({ groups: { 'not-a-folder-at-all': group() } })).toEqual([ { folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'APIFY_TOKEN' }, ]); }); it('allows actors to override some fields', () => { - expect(GROUPED_PARSER.parse({ myteam: group({ actors: [actor({ tokenEnvVar: 'FROM_ACTOR' })] }) })).toEqual([ - { folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'FROM_ACTOR' }, - ]); + expect( + GROUPED_PARSER.parse({ groups: { myteam: group({ actors: [actor({ tokenEnvVar: 'FROM_ACTOR' })] }) } }), + ).toEqual([{ folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'FROM_ACTOR' }]); }); it("returns the entries as written — normalizing is not this layer's job", () => { const raw = actor({ folder: 'actors/shopify/' }); - expect(GROUPED_PARSER.parse({ myteam: group({ actors: [raw], overrideActorContext: ['packages/'] }) })).toEqual( - [{ ...raw, tokenEnvVar: 'APIFY_TOKEN', overrideActorContext: ['packages/'] }], - ); + expect( + GROUPED_PARSER.parse({ groups: { myteam: group({ actors: [raw], overrideActorContext: ['packages/'] }) } }), + ).toEqual([{ ...raw, tokenEnvVar: 'APIFY_TOKEN', overrideActorContext: ['packages/'] }]); }); it('rejects a config with no groups (and thus no actors)', () => { @@ -70,8 +72,10 @@ describe('GROUPED_PARSER', () => { const message = (() => { try { GROUPED_PARSER.parse({ - myteam: { actors: [actor({ folder: 123 })], tokenEnvVar: 'APIFY_TOKEN' }, - other: { actors: [actor({ actorFullName: 'nope' })] }, + groups: { + myteam: { actors: [actor({ folder: 123 })], tokenEnvVar: 'APIFY_TOKEN' }, + other: { actors: [actor({ actorFullName: 'nope' })] }, + }, }); throw new Error('Function should have thrown'); } catch (err) { From 6d424baa5f8a2c25120c64c9a4859dcc8ac52284 Mon Sep 17 00:00:00 2001 From: Luigi Ruocco Date: Wed, 23 Sep 2026 12:26:24 +0100 Subject: [PATCH 4/4] feat(modes): add globs support --- bin/utils/config/parser.ts | 2 + bin/utils/config/structures/base.ts | 1 + bin/utils/config/structures/globs.ts | 97 +++++++++++ package-lock.json | 154 +++++------------- package.json | 1 + test/unit/bin/utils/config/parser.test.ts | 2 + .../bin/utils/config/structures/globs.test.ts | 99 +++++++++++ 7 files changed, 239 insertions(+), 117 deletions(-) create mode 100644 bin/utils/config/structures/globs.ts create mode 100644 test/unit/bin/utils/config/structures/globs.test.ts diff --git a/bin/utils/config/parser.ts b/bin/utils/config/parser.ts index 4af06a1..73ff14c 100644 --- a/bin/utils/config/parser.ts +++ b/bin/utils/config/parser.ts @@ -1,6 +1,7 @@ import z from 'zod'; import { CONFIG_FILE_STRATEGY, type ResolvedActorConfig, type StrategyParser } from './structures/base.js'; +import { GLOBS_PARSER } from './structures/globs.js'; import { GROUPED_PARSER } from './structures/grouped.js'; import { LEGACY_PARSER } from './structures/legacy.js'; @@ -10,6 +11,7 @@ const CONFIG_FILE_STRATEGIES: { } = { [CONFIG_FILE_STRATEGY.LEGACY]: LEGACY_PARSER, [CONFIG_FILE_STRATEGY.GROUPED]: GROUPED_PARSER, + [CONFIG_FILE_STRATEGY.GLOBS]: GLOBS_PARSER, } as const; const ModeSelectionSchema = z.object({ mode: z.enum(CONFIG_FILE_STRATEGY).default(CONFIG_FILE_STRATEGY.LEGACY) }); diff --git a/bin/utils/config/structures/base.ts b/bin/utils/config/structures/base.ts index 0e10fc8..d0f6ada 100644 --- a/bin/utils/config/structures/base.ts +++ b/bin/utils/config/structures/base.ts @@ -21,6 +21,7 @@ export interface ResolvedActorConfig { export enum CONFIG_FILE_STRATEGY { LEGACY = 'legacy', GROUPED = 'grouped', + GLOBS = 'globs', } export type StrategyParser = { diff --git a/bin/utils/config/structures/globs.ts b/bin/utils/config/structures/globs.ts new file mode 100644 index 0000000..d37e3f7 --- /dev/null +++ b/bin/utils/config/structures/globs.ts @@ -0,0 +1,97 @@ +import { minimatch } from 'minimatch'; +import z from 'zod'; + +import { ACTOR_FULL_NAME_REGEX, CONFIG_FILE_STRATEGY, defineStrategy, type ResolvedActorConfig } from './base.js'; + +const SETTING_KEYS = ['tokenEnvVar', 'overrideActorContext'] as const; + +const schema = z.object({ + actors: z + .array( + z.object({ + folder: z.string(), + actorFullName: z.string().regex(ACTOR_FULL_NAME_REGEX), + tokenEnvVar: z.string().optional(), + overrideActorContext: z.array(z.string()).optional(), + }), + ) + .min(1), + configs: z + .array( + z.object({ + match: z + .object({ + folderGlob: z.string().optional(), + actorFullNameGlob: z.string().optional(), + }) + .refine((data) => Object.keys(data).length > 0, 'Invalid input: At least one glob is required.'), + + set: z + .object({ + tokenEnvVar: z.string().optional(), + overrideActorContext: z.array(z.string()).optional(), + }) + .refine((data) => Object.keys(data).length > 0, 'Invalid input: At least one setting is required.'), + }), + ) + .min(1), +}); + +type GlobsConfig = z.infer; +type Actor = GlobsConfig['actors'][number]; +type Match = GlobsConfig['configs'][number]['match']; + +// folders are matched as written (no normalization) and dotfolders are skipped +const matches = (actor: Actor, { folderGlob, actorFullNameGlob }: Match) => + (folderGlob === undefined || minimatch(actor.folder, folderGlob)) && + (actorFullNameGlob === undefined || minimatch(actor.actorFullName, actorFullNameGlob)); + +export const GLOBS_PARSER = defineStrategy(CONFIG_FILE_STRATEGY.GLOBS, schema, (body: GlobsConfig) => { + const errors: string[] = []; + + const resolved = body.actors.flatMap((actor): ResolvedActorConfig[] => { + const result = { ...actor }; + // keep tracks of who set the key, the actor definition or the index of the config + const owners = new Map<(typeof SETTING_KEYS)[number], 'actor' | number>(); + for (const key of SETTING_KEYS) { + if (actor[key] !== undefined) owners.set(key, 'actor'); + } + + for (const [index, config] of body.configs.entries()) { + if (!matches(actor, config.match)) continue; + + for (const key of SETTING_KEYS) { + if (config.set[key] === undefined) continue; + + const owner = owners.get(key); + if (owner === undefined) { + owners.set(key, index); + Object.assign(result, { [key]: config.set[key] }); + } else if (owner === 'actor') { + errors.push( + `Actor "${actor.actorFullName}": "${key}" is set on the actor and by configs[${index}].`, + ); + } else { + errors.push( + `Actor "${actor.actorFullName}": "${key}" is set by both configs[${owner}] and configs[${index}].`, + ); + } + } + } + + const { tokenEnvVar } = result; + if (tokenEnvVar === undefined) { + errors.push( + `Actor "${actor.actorFullName}" has no tokenEnvVar: set it on the actor or through a matching config.`, + ); + return []; + } + return [{ ...result, tokenEnvVar }]; + }); + + if (errors.length > 0) { + throw new Error(errors.join('\n')); + } + + return resolved; +}); diff --git a/package-lock.json b/package-lock.json index 7e9c4eb..801d5b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@slack/web-api": "^7.9.2", "apify-client": "^2.22.2", "ignore": "^7.0.5", + "minimatch": "^10.2.6", "yargs": "^18.0.0", "zod": "^4.6.5" }, @@ -1824,29 +1825,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", @@ -1860,22 +1838,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/type-utils/node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -1991,29 +1953,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/utils/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", @@ -2027,22 +1966,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@typescript-eslint/utils/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils/node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2512,6 +2435,27 @@ "node": ">=10.0.0" } }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -5210,6 +5154,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -7006,29 +6965,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/typescript-eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/typescript-eslint/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", @@ -7042,22 +6978,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/typescript-eslint/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/typescript-eslint/node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", diff --git a/package.json b/package.json index 84f1293..2468d74 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@slack/web-api": "^7.9.2", "apify-client": "^2.22.2", "ignore": "^7.0.5", + "minimatch": "^10.2.6", "yargs": "^18.0.0", "zod": "^4.6.5" }, diff --git a/test/unit/bin/utils/config/parser.test.ts b/test/unit/bin/utils/config/parser.test.ts index b1e48fb..e0ca011 100644 --- a/test/unit/bin/utils/config/parser.test.ts +++ b/test/unit/bin/utils/config/parser.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { _privates, parseConfigFile } from '../../../../../bin/utils/config/parser.js'; import { CONFIG_FILE_STRATEGY } from '../../../../../bin/utils/config/structures/base.js'; +import { GLOBS_PARSER } from '../../../../../bin/utils/config/structures/globs.js'; import { GROUPED_PARSER } from '../../../../../bin/utils/config/structures/grouped.js'; import { LEGACY_PARSER } from '../../../../../bin/utils/config/structures/legacy.js'; @@ -22,6 +23,7 @@ describe('selectStrategy', () => { it.each([ [CONFIG_FILE_STRATEGY.LEGACY, LEGACY_PARSER], [CONFIG_FILE_STRATEGY.GROUPED, GROUPED_PARSER], + [CONFIG_FILE_STRATEGY.GLOBS, GLOBS_PARSER], ])('honours an explicit "%s" mode', (mode, expected) => { expect(selectStrategy({ mode })).toBe(expected); }); diff --git a/test/unit/bin/utils/config/structures/globs.test.ts b/test/unit/bin/utils/config/structures/globs.test.ts new file mode 100644 index 0000000..5685930 --- /dev/null +++ b/test/unit/bin/utils/config/structures/globs.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { GLOBS_PARSER } from '../../../../../../bin/utils/config/structures/globs.js'; + +const actor = (fields: Record = {}) => ({ + folder: 'actors/shopify', + actorFullName: 'myteam/shopify', + ...fields, +}); + +const config = (fields: Record = {}) => ({ + match: { folderGlob: 'actors/*' }, + set: { tokenEnvVar: 'APIFY_TOKEN' }, + ...fields, +}); + +const errorMessage = (body: Record) => { + try { + GLOBS_PARSER.parse(body); + throw new Error('Function should have thrown'); + } catch (err) { + return (err as Error).message; + } +}; + +describe('GLOBS_PARSER', () => { + it('applies the settings of every matching config, leaving other actors untouched', () => { + expect( + GLOBS_PARSER.parse({ + actors: [actor(), actor({ folder: 'other/email', actorFullName: 'other/email', tokenEnvVar: 'TOKEN' })], + configs: [config(), config({ set: { overrideActorContext: ['packages'] } })], + }), + ).toEqual([ + { + folder: 'actors/shopify', + actorFullName: 'myteam/shopify', + tokenEnvVar: 'APIFY_TOKEN', + overrideActorContext: ['packages'], + }, + { folder: 'other/email', actorFullName: 'other/email', tokenEnvVar: 'TOKEN' }, + ]); + }); + + it('requires both globs to match if both are specified', () => { + expect( + GLOBS_PARSER.parse({ + actors: [ + actor({ tokenEnvVar: 'TOKEN' }), + actor({ folder: 'actors/email', actorFullName: 'other/email' }), + ], + configs: [config({ match: { folderGlob: 'actors/*', actorFullNameGlob: 'other/*' } })], + }), + ).toEqual([ + { folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'TOKEN' }, + { folder: 'actors/email', actorFullName: 'other/email', tokenEnvVar: 'APIFY_TOKEN' }, + ]); + }); + + it('rejects two configs setting the same key, even to the same value', () => { + expect(errorMessage({ actors: [actor()], configs: [config(), config(), config()] }).split('\n')).toEqual([ + 'Actor "myteam/shopify": "tokenEnvVar" is set by both configs[0] and configs[1].', + 'Actor "myteam/shopify": "tokenEnvVar" is set by both configs[0] and configs[2].', + ]); + }); + + it('rejects a config setting a key the actor already defines', () => { + expect(() => + GLOBS_PARSER.parse({ actors: [actor({ tokenEnvVar: 'APIFY_TOKEN' })], configs: [config()] }), + ).toThrow('Actor "myteam/shopify": "tokenEnvVar" is set on the actor and by configs[0].'); + }); + + it('rejects an actor left without a tokenEnvVar', () => { + expect(() => + GLOBS_PARSER.parse({ actors: [actor()], configs: [config({ set: { overrideActorContext: ['shared'] } })] }), + ).toThrow('Actor "myteam/shopify" has no tokenEnvVar: set it on the actor or through a matching config.'); + }); + + it('reports problems from every actor at once instead of only the first', () => { + expect( + errorMessage({ + actors: [ + actor({ overrideActorContext: ['packages'] }), + actor({ folder: 'other/email', actorFullName: 'other/email' }), + ], + configs: [config({ set: { tokenEnvVar: 'APIFY_TOKEN', overrideActorContext: ['packages'] } })], + }).split('\n'), + ).toEqual([ + 'Actor "myteam/shopify": "overrideActorContext" is set on the actor and by configs[0].', + 'Actor "other/email" has no tokenEnvVar: set it on the actor or through a matching config.', + ]); + }); + + it.each([ + ['match', { match: {} }], + ['set', { set: {} }], + ])('rejects a config with an empty %s', (_name, fields) => { + expect(() => GLOBS_PARSER.parse({ actors: [actor()], configs: [config(fields)] })).toThrow(); + }); +});