Skip to content

Commit 1344aef

Browse files
committed
feat: enhance lifecycle command execution with improved argument handling
- Updated the LifecycleExecuteCommand to require a source argument with a flag for better CLI usability. - Introduced a new function to build console command previews, improving command generation for cron tasks. - Enhanced the lifecycle hooks service to validate cron rules before execution, returning execution status. - Updated the configuration DTO to include the rule file basename for better rule management. - Improved tests to cover new argument handling and command preview functionality.
1 parent 7e8b126 commit 1344aef

10 files changed

Lines changed: 197 additions & 49 deletions

File tree

apps/api/src/_common/decorators/cron-console-handler.decorator.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ export const CRON_CONSOLE_HANDLER_METADATA = 'sesame:cron-console-handler'
55
export type CronConsoleHandlerArgumentType = 'string' | 'number' | 'boolean'
66

77
export interface CronConsoleHandlerArgument {
8-
/** Nom de l'argument (`limit` → `--limit`, ou argument positionnel si `positional: true`). */
8+
/** Nom de l'argument dans options cron / options CLI. */
99
name: string
1010
label?: string
1111
description?: string
1212
type?: CronConsoleHandlerArgumentType
1313
default?: string | number | boolean
1414
required?: boolean
15+
/** Flag CLI (ex. `--source`). Par défaut : `--${name}`. */
16+
flag?: string
1517
/** Passe la valeur comme argument positionnel après la commande console. */
1618
positional?: boolean
1719
}

apps/api/src/core/cron/_functions/cron-command-options.function.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@ function getHandlerDescriptor(handler: string) {
88
return getCronConsoleHandlers().find((entry) => entry.handler === handler)
99
}
1010

11+
export function resolveCronConsoleArgumentFlag(argument: CronConsoleHandlerArgument): string {
12+
return argument.flag || `--${argument.name}`
13+
}
14+
15+
export function buildConsoleCommandPreview(
16+
handler: string,
17+
options?: Record<string, unknown> | null,
18+
): string {
19+
const descriptor = getHandlerDescriptor(handler)
20+
if (!descriptor) {
21+
return ''
22+
}
23+
24+
const commandWords = descriptor.command.split(/\s+/).filter(Boolean)
25+
const { positionalArgs, flagArgs } = buildCronCommandArgs(handler, options)
26+
return ['yarn', 'run', 'console', ...commandWords, ...positionalArgs, ...flagArgs].join(' ')
27+
}
28+
1129
function assertArgumentType(argument: CronConsoleHandlerArgument, value: unknown, handler: string): void {
1230
if (value === undefined || value === null || value === '') {
1331
return
@@ -107,9 +125,12 @@ export function buildCronCommandArgs(
107125
: Object.entries(options).filter(([key]) => !positionalSchema.some((argument) => argument.name === key))
108126

109127
for (const [key, value] of flagEntries) {
128+
const argument = flagSchema.find((entry) => entry.name === key)
129+
const flag = argument ? resolveCronConsoleArgumentFlag(argument) : `--${key}`
130+
110131
if (typeof value === 'boolean') {
111132
if (value) {
112-
flagArgs.push(`--${key}`)
133+
flagArgs.push(flag)
113134
}
114135
continue
115136
}
@@ -119,11 +140,11 @@ export function buildCronCommandArgs(
119140
}
120141

121142
if (typeof value === 'object') {
122-
flagArgs.push(`--${key}='${JSON.stringify(value)}'`)
143+
flagArgs.push(`${flag}='${JSON.stringify(value)}'`)
123144
continue
124145
}
125146

126-
flagArgs.push(`--${key}='${String(value)}'`)
147+
flagArgs.push(`${flag}='${String(value)}'`)
127148
}
128149

129150
return { positionalArgs, flagArgs }

apps/api/src/management/lifecycle/_dto/config-rules.dto.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,4 +329,9 @@ export class ConfigRulesObjectSchemaDTO {
329329
@ValidateNested({ each: true })
330330
@Type(() => ConfigRulesObjectIdentitiesDTO)
331331
public identities: ConfigRulesObjectIdentitiesDTO[]
332+
333+
/** Nom du fichier YAML sans extension (ex. `01-etd`). Renseigné au chargement. */
334+
@IsOptional()
335+
@IsString()
336+
public ruleFileBasename?: string
332337
}

apps/api/src/management/lifecycle/_functions/load-lifecycle-rules.function.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export async function loadLifecycleRules(): Promise<ConfigRulesObjectSchemaDTO[]
119119
}
120120

121121
lifecycleRules.push(schema)
122+
schema.ruleFileBasename = file.replace(/\.(ya?ml)$/i, '')
122123
logger.debug(`Lifecycle activated from file: ${file}`)
123124
}
124125

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { ConfigRulesObjectIdentitiesDTO } from '../_dto/config-rules.dto'
2+
3+
export interface LifecycleCronRulesValidationResult {
4+
executable: boolean
5+
rules: ConfigRulesObjectIdentitiesDTO[]
6+
warnings: string[]
7+
}
8+
9+
export function validateLifecycleCronRules(
10+
ruleFileBasename: string,
11+
identities: ConfigRulesObjectIdentitiesDTO[] | undefined,
12+
): LifecycleCronRulesValidationResult {
13+
if (!identities?.length) {
14+
return {
15+
executable: false,
16+
rules: [],
17+
warnings: [`Le fichier de règles <${ruleFileBasename}.yml> ne contient aucune règle.`],
18+
}
19+
}
20+
21+
const cronExecutableRules = identities.filter((rule) => rule.trigger === -1)
22+
if (cronExecutableRules.length > 0) {
23+
return {
24+
executable: true,
25+
rules: cronExecutableRules,
26+
warnings: [],
27+
}
28+
}
29+
30+
const warnings = [
31+
`Le fichier de règles <${ruleFileBasename}.yml> ne contient aucune règle exécutable en cron (trigger=-1 requis).`,
32+
]
33+
34+
for (const rule of identities) {
35+
const triggerLabel = rule.trigger === undefined || rule.trigger === null ? 'non défini' : String(rule.trigger)
36+
warnings.push(` • sources [${rule.sources.join(', ')}] → trigger=${triggerLabel}`)
37+
}
38+
39+
return {
40+
executable: false,
41+
rules: [],
42+
warnings,
43+
}
44+
}

apps/api/src/management/lifecycle/lifecycle-hooks.service.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AbstractLifecycleService } from './_abstracts/abstract.lifecycle.servic
1010
import { ConfigRulesObjectSchemaDTO } from './_dto/config-rules.dto'
1111
import { loadCustomStates } from './_functions/load-custom-states.function'
1212
import { loadLifecycleRules } from './_functions/load-lifecycle-rules.function'
13+
import { validateLifecycleCronRules } from './_functions/validate-lifecycle-cron-rules.function'
1314
import { resolveConfigVariables } from '~/_common/functions/resolve-config-variables.function'
1415

1516
@Injectable()
@@ -214,21 +215,32 @@ export class LifecycleHooksService extends AbstractLifecycleService {
214215
}
215216
}
216217

217-
public async executeCronForSource(source: string): Promise<void> {
218+
public async executeCronForSource(source: string): Promise<boolean> {
218219
this.logger.log(`Executing lifecycle rules for source <${source}>...`)
219220

220221
const lifecycleRules = await this.ensureLifecycleCacheFresh()
222+
const ruleSet = lifecycleRules.find((rules) => rules.ruleFileBasename === source)
221223

222-
for (const lfr of lifecycleRules) {
223-
for (const idRule of lfr.identities) {
224-
console.log('Checking identity rule sources:', idRule)
225-
if (idRule.sources.includes(source as any) && idRule.trigger === -1) {
226-
await this.handleCron({ lifecycleRules: [lfr], ignoreTrigger: true })
227-
}
228-
}
224+
if (!ruleSet) {
225+
this.logger.warn(`Fichier de règles lifecycle introuvable pour <${source}> (configs/lifecycle/rules/${source}.yml).`)
226+
return false
229227
}
230228

231-
this.logger.log(`Execution of lifecycle rules for source <${source}> completed.`)
229+
const validation = validateLifecycleCronRules(source, ruleSet.identities)
230+
if (validation.executable) {
231+
await this.handleCron({
232+
lifecycleRules: [{ ...ruleSet, identities: validation.rules }],
233+
ignoreTrigger: true,
234+
})
235+
236+
this.logger.log(`Execution of lifecycle rules for source <${source}> completed.`)
237+
return true
238+
}
239+
240+
for (const warning of validation.warnings) {
241+
this.logger.warn(warning)
242+
}
243+
return false
232244
}
233245

234246
public async executeCronForAllSources(): Promise<void> {

apps/api/src/management/lifecycle/lifecycle.command.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Logger } from '@nestjs/common'
22
import { ModuleRef } from '@nestjs/core'
3-
import { Command, CommandRunner, InquirerService, SubCommand } from 'nest-commander'
3+
import { Command, CommandRunner, InquirerService, Option, SubCommand } from 'nest-commander'
44
import { CronConsoleHandler } from '~/_common/decorators/cron-console-handler.decorator'
55
import { LifecycleCrudService } from './lifecycle-crud.service'
66
import { LifecycleHooksService } from './lifecycle-hooks.service'
@@ -83,9 +83,10 @@ export class LifecycleListCommand extends CommandRunner {
8383
{
8484
name: 'source',
8585
label: 'Source lifecycle',
86-
description: 'Identifiant de source (ex. 01-etd). Laisser vide pour exécuter toutes les sources.',
86+
description: 'Nom du fichier de règles sans extension (ex. 01-etd → configs/lifecycle/rules/01-etd.yml).',
8787
type: 'string',
88-
positional: true,
88+
flag: '--source',
89+
required: true,
8990
},
9091
],
9192
})
@@ -102,29 +103,39 @@ export class LifecycleExecuteCommand extends CommandRunner {
102103
super()
103104
}
104105

105-
async run(inputs: string[], options: any): Promise<void> {
106+
async run(_inputs: string[], options: LifecycleExecuteOptions): Promise<void> {
106107
this.logger.log('Démarrage de la commande d\'exécution du cycle de vie...')
107108

108-
const source = inputs[0]
109+
const source = `${options?.source || ''}`.trim()
109110
if (!source) {
110-
this.logger.log('Aucune source de cycle de vie spécifiée. Exécution pour toutes les sources...')
111-
112-
try {
113-
await this.lifecycleHooksService.executeCronForAllSources()
114-
this.logger.log(`Exécution du cycle de vie pour toutes les sources terminée avec succès.`)
115-
} catch (error) {
116-
this.logger.error(`Erreur lors de l'exécution du cycle de vie pour toutes les sources: ${error.message}`)
117-
}
111+
console.error('Usage: yarn run console lifecycle execute --source=<source>')
118112
return
119113
}
120114

121115
try {
122-
await this.lifecycleHooksService.executeCronForSource(source)
116+
const executed = await this.lifecycleHooksService.executeCronForSource(source)
117+
if (!executed) {
118+
process.exitCode = 1
119+
return
120+
}
123121
this.logger.log(`Exécution du cycle de vie pour la source '${source}' terminée avec succès.`)
124122
} catch (error) {
125123
this.logger.error(`Erreur lors de l'exécution du cycle de vie pour la source '${source}': ${error.message}`)
126124
}
127125
}
126+
127+
@Option({
128+
flags: '--source <source>',
129+
description: 'Nom du fichier de règles lifecycle sans extension (ex. 01-etd).',
130+
required: true,
131+
})
132+
parseSource(val: string): string {
133+
return `${val || ''}`.trim()
134+
}
135+
}
136+
137+
type LifecycleExecuteOptions = {
138+
source: string
128139
}
129140

130141
/**
@@ -144,7 +155,7 @@ export class LifecycleExecuteCommand extends CommandRunner {
144155
* @example
145156
* // Utilisation en ligne de commande
146157
* yarn run console lifecycle list
147-
* yarn run console lifecycle <autre-sous-commande>
158+
* yarn run console lifecycle execute --source=01-etd
148159
*/
149160
@Command({ name: 'lifecycle', arguments: '<task>', subCommands: [LifecycleListCommand, LifecycleExecuteCommand] })
150161
export class LifecycleCommand extends CommandRunner {

apps/api/tests/unit/core/cron/cron-command-options.function.spec.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { BadRequestException } from '@nestjs/common'
22
import {
3+
buildConsoleCommandPreview,
34
buildCronCommandArgs,
45
validateCronTaskOptions,
56
} from '~/core/cron/_functions/cron-command-options.function'
@@ -19,7 +20,7 @@ jest.mock('~/_common/decorators/cron-console-handler.decorator', () => ({
1920
command: 'lifecycle execute',
2021
label: 'Exécution du cycle de vie',
2122
arguments: [
22-
{ name: 'source', type: 'string', positional: true },
23+
{ name: 'source', type: 'string', flag: '--source', required: true },
2324
],
2425
},
2526
{
@@ -45,15 +46,28 @@ describe('cron-command-options', () => {
4546
expect(() => validateCronTaskOptions('agents-list', { limit: 500 })).toThrow(BadRequestException)
4647
})
4748

49+
it('should reject missing required options', () => {
50+
expect(() => validateCronTaskOptions('lifecycle-execute', {})).toThrow(BadRequestException)
51+
})
52+
4853
it('should build flag and positional CLI args from handler schema', () => {
4954
expect(buildCronCommandArgs('identities-pwned-recheck', { limit: 500 })).toEqual({
5055
positionalArgs: [],
5156
flagArgs: ["--limit='500'"],
5257
})
5358

5459
expect(buildCronCommandArgs('lifecycle-execute', { source: '01-etd' })).toEqual({
55-
positionalArgs: ['01-etd'],
56-
flagArgs: [],
60+
positionalArgs: [],
61+
flagArgs: ["--source='01-etd'"],
5762
})
5863
})
64+
65+
it('should build console command preview from handler command and flags', () => {
66+
expect(buildConsoleCommandPreview('lifecycle-execute', { source: '01-etd' })).toBe(
67+
"yarn run console lifecycle execute --source='01-etd'",
68+
)
69+
expect(buildConsoleCommandPreview('identities-pwned-recheck', { limit: 1 })).toBe(
70+
"yarn run console identities pwned recheck --limit='1'",
71+
)
72+
})
5973
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { validateLifecycleCronRules } from '~/management/lifecycle/_functions/validate-lifecycle-cron-rules.function'
2+
3+
describe('validateLifecycleCronRules', () => {
4+
it('should accept rules with trigger=-1', () => {
5+
const result = validateLifecycleCronRules('01-etd', [
6+
{
7+
sources: ['D'],
8+
trigger: -1,
9+
target: 'I',
10+
} as any,
11+
])
12+
13+
expect(result.executable).toBe(true)
14+
if (result.executable) {
15+
expect(result.rules).toHaveLength(1)
16+
}
17+
})
18+
19+
it('should reject rules without trigger=-1', () => {
20+
const result = validateLifecycleCronRules('01-etd', [
21+
{
22+
sources: ['I', 'W'],
23+
trigger: 5,
24+
target: 'D',
25+
} as any,
26+
{
27+
sources: ['D'],
28+
target: 'I',
29+
} as any,
30+
])
31+
32+
expect(result).toEqual({
33+
executable: false,
34+
rules: [],
35+
warnings: [
36+
'Le fichier de règles <01-etd.yml> ne contient aucune règle exécutable en cron (trigger=-1 requis).',
37+
' • sources [I, W] → trigger=5',
38+
' • sources [D] → trigger=non défini',
39+
],
40+
})
41+
})
42+
})

0 commit comments

Comments
 (0)