Execution Lifecycle
Demonstrates the full execution lifecycle of a cli-forge CLI, including the order in which builders, parsing, middleware, init hooks, validation, coerce, defaults, conflicts, implies, and handlers execute.
When forge() is called, execution proceeds in two phases:
Phase 1 — Discovery loop (per command level): builder → parse (best-effort) → merge → middleware → init hooks → find next subcommand → repeat
Phase 2 — Final parse + execution: parse (with validation) → help/version check → handler
This example uses --format json with the deploy subcommand to walk
through every stage. The console output shows the exact order.
1 FILESTry in Playground
All Example Files
FILE EXPLORER
execution-lifecycle.ts
1import cli from 'cli-forge';
2
3const app = cli('lifecycle', {
4 builder: (argv) => {
5 // Phase 1, step 1 — The root builder runs first. In this example,
6 // the root options are registered inline (above) rather than in a
7 // builder callback, but the effect is the same. The builder is where
8 // options, middleware, init hooks, and subcommands are registered.
9 console.log('[1. root builder]');
10
11 // ──────────────────────────────────────────────────────────
12 // ROOT LEVEL — options, middleware, init hook
13 // ──────────────────────────────────────────────────────────
14
15 return (
16 argv
17 .option('format', {
18 type: 'string',
19 description: 'Output format',
20 // Default: applied during normalization (before validation)
21 default: 'text',
22 // Coerce: runs after the value is parsed, transforms the raw value
23 coerce: (v: string) => v.toLowerCase(),
24 })
25
26 .option('verbose', {
27 type: 'boolean',
28 description: 'Enable verbose output',
29 default: false,
30 })
31
32 // Phase 1, step 3 — Middleware runs BEFORE init hooks at each command
33 // level. This lets init hooks depend on middleware-computed values.
34 .middleware((args) => {
35 console.log('[2. root middleware] format =', args.format);
36 return {
37 ...args,
38 // Middleware can add derived values that later stages can use
39 isJson: args.format === 'json',
40 };
41 })
42
43 // Phase 1, step 4 — Init hooks run after middleware, with access to
44 // middleware-transformed args. They can dynamically register commands.
45 .init((app, args) => {
46 console.log(
47 '[3. root init hook] isJson =',
48 args.isJson,
49 '(set by middleware)'
50 );
51
52 // Dynamically register the "deploy" command based on parsed args.
53 // In real CLIs this is useful for plugin loading: read a config,
54 // then register commands based on what plugins are configured.
55 app.command('deploy', {
56 // Phase 1 (next iteration) step 1 — Subcommand builder runs
57 // lazily when "deploy" is discovered in unmatched tokens.
58 builder: (cmd) => {
59 console.log('[4. deploy builder]');
60 return (
61 cmd
62 .option('target', {
63 type: 'string',
64 description: 'Deployment target',
65 required: true,
66 })
67 .option('dry-run', {
68 type: 'boolean',
69 description: 'Simulate without making changes',
70 })
71 .option('force', {
72 type: 'boolean',
73 description: 'Skip safety checks',
74 })
75 .option('backup-path', {
76 type: 'string',
77 description:
78 'Backup location (required when --force is used)',
79 })
80 // Conflicts: --dry-run and --force are mutually exclusive
81 // (validated during Phase 2 final parse)
82 .conflicts('dry-run', 'force')
83 // Implies: --force requires --backup-path
84 // (validated during Phase 2 final parse)
85 .implies('force', 'backup-path')
86 // Subcommand-level middleware runs in Phase 1 for this level,
87 // before the subcommand's init hooks.
88 .middleware((a) => {
89 console.log('[5. deploy middleware] target =', a.target);
90 return a;
91 })
92 // Subcommand-level init hook
93 .init((subcli, a) => {
94 console.log('[6. deploy init hook] verbose =', a.verbose);
95 // Could register nested subcommands here, modify options, or other stuff.
96 // Most typically not needed for registering commands or options, but could
97 // make sense in some cases (e.g. options that's registration depends on another
98 // option flag. Things like a --config path that includes plugins which register
99 // commands are one of the very few cases that it would make sense.)
100 })
101 );
102 },
103
104 // Phase 2 step 3 — Handler runs after final validation passes.
105 // At this point all options have been through:
106 // defaults → env → config → coerce → required → choices →
107 // conflicts → implies → custom validate
108 handler: (args) => {
109 console.log('[7. handler] deploying to', args.target);
110 if (args.verbose) {
111 console.log(' format:', args.format);
112 console.log(' dry-run:', args['dry-run']);
113 }
114 },
115 });
116 })
117 );
118 },
119});
120
121export default cli;
122
123if (require.main === module) {
124 (async () => {
125 await app.forge();
126 })();
127}
128