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.

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