Skip to content

Commit e620ca9

Browse files
authored
feat: Lifecycle hooks (#762)
1 parent fb8f1a2 commit e620ca9

15 files changed

Lines changed: 406 additions & 86 deletions

File tree

‎.changeset/few-flies-notice.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"jsrepo": minor
3+
---
4+
5+
feat: Lifecycle hooks
6+

‎apps/docs/content/docs/jsrepo-config.mdx‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,3 +239,56 @@ Available warning types:
239239
- `UnresolvableDynamicImportWarning`: Triggered when a dynamic import cannot be resolved due to unresolvable syntax
240240

241241
All warnings extend the base `Warning` class which has a `message` property. Each specific warning type includes additional properties relevant to that warning type.
242+
243+
### hooks
244+
245+
Hooks allow you to run custom logic before and after CLI commands.
246+
247+
- `before` hooks run before the command executes.
248+
- `after` hooks run after the command completes.
249+
250+
Hooks can be a function, a shell command string, or an array of either.
251+
252+
```ts title="jsrepo.config.ts"
253+
import { defineConfig } from "jsrepo";
254+
255+
export default defineConfig({
256+
hooks: {
257+
before: ({ command }) => {
258+
console.log(`Running ${command}...`);
259+
},
260+
after: "echo done",
261+
},
262+
});
263+
```
264+
265+
Function hooks receive typed arguments with a `command` discriminant. Use it to narrow and access command-specific data:
266+
267+
```ts title="jsrepo.config.ts"
268+
import { defineConfig } from "jsrepo";
269+
270+
export default defineConfig({
271+
hooks: {
272+
after: (args) => {
273+
if (args.command === "add") {
274+
console.log(`Added ${args.result.items.length} items`);
275+
}
276+
},
277+
},
278+
});
279+
```
280+
281+
You can also use an array of hooks:
282+
283+
```ts title="jsrepo.config.ts"
284+
import { defineConfig } from "jsrepo";
285+
286+
export default defineConfig({
287+
hooks: {
288+
before: [
289+
({ command }) => console.log(`Starting ${command}...`),
290+
"echo 'before hook done'",
291+
],
292+
},
293+
});
294+
```

‎packages/jsrepo/src/api/config.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,12 @@ export {
1212
type TransformOptions,
1313
} from '@/utils/config';
1414
export { loadConfigSearch } from '@/utils/config/utils';
15+
export type {
16+
AfterArgs,
17+
AfterHook,
18+
BeforeArgs,
19+
BeforeHook,
20+
Hook,
21+
HookFn,
22+
InferHookArgs,
23+
} from '@/utils/hooks';

‎packages/jsrepo/src/commands/add.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { updateConfigPaths } from '@/utils/config/mods/update-paths';
3535
import { loadConfigSearch } from '@/utils/config/utils';
3636
import { type CLIError, InvalidRegistryError, RegistryNotProvidedError } from '@/utils/errors';
3737
import { readFileSync, writeFileSync } from '@/utils/fs';
38+
import { runAfterHooks, runBeforeHooks } from '@/utils/hooks';
3839
import {
3940
initLogging,
4041
intro,
@@ -82,26 +83,37 @@ export const add = new Command('add')
8283
.action(async (blockNames, rawOptions) => {
8384
const options = parseOptions(schema, rawOptions);
8485

85-
intro();
86-
87-
const config = await loadConfigSearch({
86+
const configResult = await loadConfigSearch({
8887
cwd: options.cwd,
8988
promptForContinueIfNull: !options.yes,
9089
});
9190

91+
const config = configResult?.config ?? {};
92+
const cwd = configResult ? (path.dirname(configResult.path) as AbsolutePath) : options.cwd;
93+
94+
await runBeforeHooks(
95+
config as Config,
96+
{ command: 'add', options: { ...options, cwd } },
97+
{ cwd, yes: options.yes }
98+
);
99+
100+
intro();
101+
92102
const result = await tryCommand(
93103
runAdd(
94104
blockNames,
95105
// this way if the config is found in a higher directory we base everything off of that directory
96106
{
97107
...options,
98-
cwd: config ? (path.dirname(config.path) as AbsolutePath) : options.cwd,
108+
cwd,
99109
},
100-
config
110+
configResult
101111
)
102112
);
103113

104114
outro(formatResult(result));
115+
116+
await runAfterHooks(config as Config, { command: 'add', options: { ...options, cwd }, result }, { cwd });
105117
});
106118

107119
export type AddCommandResult = {

‎packages/jsrepo/src/commands/auth.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '@clack/prompts';
1313
import { Command } from 'commander';
1414
import { err, ok, type Result } from 'nevereverthrow';
15+
import path from 'pathe';
1516
import pc from 'picocolors';
1617
import { z } from 'zod';
1718
import {
@@ -24,6 +25,7 @@ import { DEFAULT_PROVIDERS, type ProviderFactory } from '@/providers';
2425
import type { Config } from '@/utils/config';
2526
import { loadConfigSearch } from '@/utils/config/utils';
2627
import { type CLIError, NoProviderFoundError } from '@/utils/errors';
28+
import { runAfterHooks, runBeforeHooks } from '@/utils/hooks';
2729
import { initLogging, intro, outro } from '@/utils/prompts';
2830
import { TokenManager } from '@/utils/token-manager';
2931

@@ -45,16 +47,29 @@ export const auth = new Command('auth')
4547
.action(async (provider, rawOptions) => {
4648
const options = parseOptions(schema, rawOptions);
4749

48-
intro();
49-
50-
const config = await loadConfigSearch({
50+
const configResult = await loadConfigSearch({
5151
cwd: options.cwd,
5252
promptForContinueIfNull: false,
5353
});
5454

55-
const result = await tryCommand(runAuth(provider, options, config?.config));
55+
const config = configResult?.config ?? ({} as Config);
56+
const cwd = configResult
57+
? (path.dirname(configResult.path) as import('@/utils/types').AbsolutePath)
58+
: options.cwd;
59+
60+
await runBeforeHooks(
61+
config,
62+
{ command: 'auth', options: { ...options, cwd } },
63+
{ cwd, yes: false }
64+
);
65+
66+
intro();
67+
68+
const result = await tryCommand(runAuth(provider, options, configResult?.config));
5669

5770
outro(formatResult(result));
71+
72+
await runAfterHooks(config, { command: 'auth', options: { ...options, cwd }, result }, { cwd });
5873
});
5974

6075
export type AuthCommandResult = {

‎packages/jsrepo/src/commands/build.ts‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
NoRegistriesError,
2525
} from '@/utils/errors';
2626
import { existsSync } from '@/utils/fs';
27+
import { runAfterHooks, runBeforeHooks } from '@/utils/hooks';
2728
import { joinAbsolute } from '@/utils/path';
2829
import { intro, isTTY, outro } from '@/utils/prompts';
2930
import type { AbsolutePath } from '@/utils/types';
@@ -58,25 +59,26 @@ export const build = new Command('build')
5859
if (options.watch) {
5960
await runWatch(options, configResult);
6061
} else {
62+
const config = configResult.config;
63+
const cwd = path.dirname(configResult.path) as AbsolutePath;
64+
const buildOptions = { ...options, cwd };
65+
66+
await runBeforeHooks(
67+
config,
68+
{ command: 'build', options: buildOptions },
69+
{ cwd, yes: false }
70+
);
71+
6172
intro();
6273

63-
const result = await tryCommand(
64-
runBuild(
65-
// this way if the config is found in a higher directory we base everything off of that directory
66-
{
67-
...options,
68-
cwd: configResult
69-
? (path.dirname(configResult.path) as AbsolutePath)
70-
: options.cwd,
71-
},
72-
configResult.config
73-
)
74-
);
74+
const result = await tryCommand(runBuild(buildOptions, config));
7575

7676
outro(formatResult(result, { type: 'build' }));
7777

78+
await runAfterHooks(config, { command: 'build', options: buildOptions, result }, { cwd });
79+
7880
// if any of the registries failed to build, exit with an error
79-
if (result.results.some((result) => result.isErr())) {
81+
if (result.results.some((r) => r.isErr())) {
8082
process.exit(1);
8183
}
8284
}

‎packages/jsrepo/src/commands/config/language.ts‎

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { addPluginsToConfig, parsePlugins } from '@/utils/config/mods/add-plugin
1515
import { loadConfigSearch } from '@/utils/config/utils';
1616
import { type CLIError, ConfigNotFoundError } from '@/utils/errors';
1717
import { readFileSync, writeFileSync } from '@/utils/fs';
18+
import { runAfterHooks, runBeforeHooks } from '@/utils/hooks';
1819
import { intro, outro, promptInstallDependencies } from '@/utils/prompts';
1920
import type { AbsolutePath } from '@/utils/types';
2021

@@ -35,29 +36,29 @@ export const language = new Command('language')
3536
.action(async (languages, rawOptions) => {
3637
const options = parseOptions(schema, rawOptions);
3738

38-
intro();
39-
4039
const configResult = await loadConfigSearch({
4140
cwd: options.cwd,
4241
promptForContinueIfNull: !options.yes,
4342
});
4443
if (!configResult) error(new ConfigNotFoundError(options.cwd));
4544

46-
const result = await tryCommand(
47-
runLanguage(
48-
languages,
49-
// this way if the config is found in a higher directory we base everything off of that directory
50-
{
51-
...options,
52-
cwd: configResult
53-
? (path.dirname(configResult.path) as AbsolutePath)
54-
: options.cwd,
55-
},
56-
configResult
57-
)
45+
const config = configResult.config;
46+
const cwd = path.dirname(configResult.path) as AbsolutePath;
47+
const languageOptions = { ...options, cwd };
48+
49+
await runBeforeHooks(
50+
config,
51+
{ command: 'config.language', options: languageOptions },
52+
{ cwd, yes: options.yes }
5853
);
5954

55+
intro();
56+
57+
const result = await tryCommand(runLanguage(languages, languageOptions, configResult));
58+
6059
outro(formatResult(result));
60+
61+
await runAfterHooks(config, { command: 'config.language', options: languageOptions, result }, { cwd });
6162
});
6263

6364
export type ConfigAddLanguageCommandResult = {

‎packages/jsrepo/src/commands/config/mcp.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@ import { err, ok, type Result } from 'nevereverthrow';
55
import path from 'pathe';
66
import pc from 'picocolors';
77
import { z } from 'zod';
8-
import type { AbsolutePath } from '@/api';
8+
import type { AbsolutePath, Config } from '@/api';
99
import {
1010
commonOptions,
1111
defaultCommandOptionsSchema,
1212
parseOptions,
1313
tryCommand,
1414
} from '@/commands/utils';
15+
import { loadConfigSearch } from '@/utils/config/utils';
1516
import { type CLIError, JsrepoError } from '@/utils/errors';
1617
import { existsSync, readFileSync, writeFileSync } from '@/utils/fs';
18+
import { runAfterHooks, runBeforeHooks } from '@/utils/hooks';
1719
import { stringify } from '@/utils/json';
1820
import { joinAbsolute } from '@/utils/path';
1921
import { intro, outro } from '@/utils/prompts';
@@ -42,11 +44,22 @@ export const mcp = new Command('mcp')
4244
.action(async (rawOptions) => {
4345
const options = parseOptions(schema, rawOptions);
4446

47+
const configResult = await loadConfigSearch({
48+
cwd: options.cwd,
49+
promptForContinueIfNull: false,
50+
});
51+
const config = (configResult?.config ?? {}) as Config;
52+
const cwd = options.cwd;
53+
54+
await runBeforeHooks(config, { command: 'config.mcp', options }, { cwd, yes: false });
55+
4556
intro();
4657

4758
const result = await tryCommand(runMcp(options));
4859

4960
outro(formatResult({ ...result, cwd: options.cwd }));
61+
62+
await runAfterHooks(config, { command: 'config.mcp', options, result }, { cwd });
5063
});
5164

5265
export type ClientConfigResult = {

‎packages/jsrepo/src/commands/config/provider.ts‎

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { addPluginsToConfig, parsePlugins } from '@/utils/config/mods/add-plugin
1515
import { loadConfigSearch } from '@/utils/config/utils';
1616
import { type CLIError, ConfigNotFoundError } from '@/utils/errors';
1717
import { readFileSync, writeFileSync } from '@/utils/fs';
18+
import { runAfterHooks, runBeforeHooks } from '@/utils/hooks';
1819
import { intro, outro, promptInstallDependencies } from '@/utils/prompts';
1920
import type { AbsolutePath } from '@/utils/types';
2021

@@ -35,30 +36,30 @@ export const provider = new Command('provider')
3536
.action(async (providers, rawOptions) => {
3637
const options = parseOptions(schema, rawOptions);
3738

38-
intro();
39-
4039
const configResult = await loadConfigSearch({
4140
cwd: options.cwd,
4241
promptForContinueIfNull: !options.yes,
4342
});
4443

4544
if (!configResult) error(new ConfigNotFoundError(options.cwd));
4645

47-
const result = await tryCommand(
48-
runProvider(
49-
providers,
50-
// this way if the config is found in a higher directory we base everything off of that directory
51-
{
52-
...options,
53-
cwd: configResult
54-
? (path.dirname(configResult.path) as AbsolutePath)
55-
: options.cwd,
56-
},
57-
configResult
58-
)
46+
const config = configResult.config;
47+
const cwd = path.dirname(configResult.path) as AbsolutePath;
48+
const providerOptions = { ...options, cwd };
49+
50+
await runBeforeHooks(
51+
config,
52+
{ command: 'config.provider', options: providerOptions },
53+
{ cwd, yes: options.yes }
5954
);
6055

56+
intro();
57+
58+
const result = await tryCommand(runProvider(providers, providerOptions, configResult));
59+
6160
outro(formatResult(result));
61+
62+
await runAfterHooks(config, { command: 'config.provider', options: providerOptions, result }, { cwd });
6263
});
6364

6465
export type ConfigAddProviderCommandResult = {

0 commit comments

Comments
 (0)