Skip to content

Commit 47cee2c

Browse files
branchseerclaude
andauthored
feat: add pre/post lifecycle hooks for package.json scripts (#231)
## Summary Adds npm-style pre/post lifecycle hooks for package.json scripts. If you have a script named `test`, you can now define `pretest` and `posttest` scripts that automatically run before and after it — no extra configuration needed: ```json { "scripts": { "pretest": "docker compose up -d", "test": "vitest run", "posttest": "docker compose down" } } ``` Running `vt run test` will execute all three in order. ### Behavior - Hooks apply only to package.json scripts, not tasks defined in `vite-task.json` - Extra CLI args (e.g. `vt run test --coverage`) are passed to the main script only, not the hooks - Hooks are one level deep: when `test` runs `pretest` as a hook, `pretest` does not also run `prepretest` — matching npm's behavior - Opt out workspace-wide with `enablePrePostScripts: false` in the root `vite-task.json` ## Test plan - [ ] `script-hooks`: pre/post hooks run in correct order; extra args not forwarded to hooks; running `pretest` directly does expand `prepretest`, but `prepretest` is not included when `pretest` runs as a hook of `test` - [ ] `script-hooks-disabled`: `enablePrePostScripts: false` suppresses hooks - [ ] `script-hooks-task-no-hook`: task configs are not expanded with hooks 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7e28617 commit 47cee2c

25 files changed

+1354
-2
lines changed

crates/vite_task_graph/run-config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,15 @@ export type RunConfig = {
9292
* Task definitions
9393
*/
9494
tasks?: { [key in string]?: Task };
95+
/**
96+
* Whether to automatically run `preX`/`postX` package.json scripts as
97+
* lifecycle hooks when script `X` is executed.
98+
*
99+
* When `true` (the default), running script `test` will automatically
100+
* run `pretest` before and `posttest` after, if they exist.
101+
*
102+
* This option can only be set in the workspace root's config file.
103+
* Setting it in a package's config will result in an error.
104+
*/
105+
enablePrePostScripts?: boolean;
95106
};

crates/vite_task_graph/src/config/user.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,16 @@ pub struct UserRunConfig {
224224

225225
/// Task definitions
226226
pub tasks: Option<FxHashMap<Str, UserTaskConfig>>,
227+
228+
/// Whether to automatically run `preX`/`postX` package.json scripts as
229+
/// lifecycle hooks when script `X` is executed.
230+
///
231+
/// When `true` (the default), running script `test` will automatically
232+
/// run `pretest` before and `posttest` after, if they exist.
233+
///
234+
/// This option can only be set in the workspace root's config file.
235+
/// Setting it in a package's config will result in an error.
236+
pub enable_pre_post_scripts: Option<bool>,
227237
}
228238

229239
impl UserRunConfig {

crates/vite_task_graph/src/lib.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ pub enum TaskGraphLoadError {
118118

119119
#[error("`cache` can only be set in the workspace root config, but found in {package_path}")]
120120
CacheInNonRootPackage { package_path: Arc<AbsolutePath> },
121+
122+
#[error(
123+
"`enablePrePostScripts` can only be set in the workspace root config, but found in {package_path}"
124+
)]
125+
PrePostScriptsInNonRootPackage { package_path: Arc<AbsolutePath> },
121126
}
122127

123128
/// Error when looking up a task by its specifier.
@@ -185,8 +190,14 @@ pub struct IndexedTaskGraph {
185190
/// task indices by task id for quick lookup
186191
pub(crate) node_indices_by_task_id: FxHashMap<TaskId, TaskNodeIndex>,
187192

193+
/// Reverse map: task node index → task id (for hook lookup)
194+
task_ids_by_node_index: FxHashMap<TaskNodeIndex, TaskId>,
195+
188196
/// Global cache configuration resolved from the workspace root config.
189197
resolved_global_cache: ResolvedGlobalCacheConfig,
198+
199+
/// Whether pre/post script hooks are enabled (from `enablePrePostScripts` in workspace root config).
200+
pre_post_scripts_enabled: bool,
190201
}
191202

192203
pub type TaskGraph = DiGraph<TaskNode, TaskDependencyType, TaskIx>;
@@ -222,9 +233,12 @@ impl IndexedTaskGraph {
222233
// index tasks by ids
223234
let mut node_indices_by_task_id: FxHashMap<TaskId, TaskNodeIndex> =
224235
FxHashMap::with_capacity_and_hasher(task_graph.node_count(), FxBuildHasher);
236+
let mut task_ids_by_node_index: FxHashMap<TaskNodeIndex, TaskId> =
237+
FxHashMap::with_capacity_and_hasher(task_graph.node_count(), FxBuildHasher);
225238

226239
// First pass: load all configs, extract root cache config, validate
227240
let mut root_cache = None;
241+
let mut root_pre_post_scripts_enabled = None;
228242
let mut package_configs: Vec<(PackageNodeIndex, Arc<AbsolutePath>, UserRunConfig)> =
229243
Vec::with_capacity(package_graph.node_count());
230244

@@ -252,6 +266,16 @@ impl IndexedTaskGraph {
252266
}
253267
}
254268

269+
if let Some(val) = user_config.enable_pre_post_scripts {
270+
if is_workspace_root {
271+
root_pre_post_scripts_enabled = Some(val);
272+
} else {
273+
return Err(TaskGraphLoadError::PrePostScriptsInNonRootPackage {
274+
package_path: package_dir.clone(),
275+
});
276+
}
277+
}
278+
255279
package_configs.push((package_index, package_dir, user_config));
256280
}
257281

@@ -312,6 +336,7 @@ impl IndexedTaskGraph {
312336

313337
let node_index = task_graph.add_node(task_node);
314338
task_ids_with_dependency_specifiers.push((task_id.clone(), dependency_specifiers));
339+
task_ids_by_node_index.insert(node_index, task_id.clone());
315340
node_indices_by_task_id.insert(task_id, node_index);
316341
}
317342

@@ -340,6 +365,7 @@ impl IndexedTaskGraph {
340365
resolved_config,
341366
source: TaskSource::PackageJsonScript,
342367
});
368+
task_ids_by_node_index.insert(node_index, task_id.clone());
343369
node_indices_by_task_id.insert(task_id, node_index);
344370
}
345371
}
@@ -349,7 +375,9 @@ impl IndexedTaskGraph {
349375
task_graph,
350376
indexed_package_graph: IndexedPackageGraph::index(package_graph),
351377
node_indices_by_task_id,
378+
task_ids_by_node_index,
352379
resolved_global_cache,
380+
pre_post_scripts_enabled: root_pre_post_scripts_enabled.unwrap_or(true),
353381
};
354382

355383
// Add explicit dependencies
@@ -459,4 +487,32 @@ impl IndexedTaskGraph {
459487
pub const fn global_cache_config(&self) -> &ResolvedGlobalCacheConfig {
460488
&self.resolved_global_cache
461489
}
490+
491+
/// Whether pre/post script hooks are enabled workspace-wide.
492+
#[must_use]
493+
pub const fn pre_post_scripts_enabled(&self) -> bool {
494+
self.pre_post_scripts_enabled
495+
}
496+
497+
/// Returns the `TaskNodeIndex` of the pre/post hook for a `PackageJsonScript` task.
498+
///
499+
/// Given a task named `X` and `prefix = "pre"`, looks up `preX` in the same package.
500+
/// Given a task named `X` and `prefix = "post"`, looks up `postX` in the same package.
501+
///
502+
/// Returns `None` if:
503+
/// - The task is not a `PackageJsonScript`
504+
/// - No `{prefix}{name}` script exists in the same package
505+
/// - The hook is not itself a `PackageJsonScript`
506+
#[must_use]
507+
pub fn get_script_hook(&self, task_idx: TaskNodeIndex, prefix: &str) -> Option<TaskNodeIndex> {
508+
let task_node = &self.task_graph[task_idx];
509+
if task_node.source != TaskSource::PackageJsonScript {
510+
return None;
511+
}
512+
let task_id = self.task_ids_by_node_index.get(&task_idx)?;
513+
let hook_name = vite_str::format!("{prefix}{}", task_node.task_display.task_name);
514+
let hook_id = TaskId { package_index: task_id.package_index, task_name: hook_name };
515+
let &hook_idx = self.node_indices_by_task_id.get(&hook_id)?;
516+
(self.task_graph[hook_idx].source == TaskSource::PackageJsonScript).then_some(hook_idx)
517+
}
462518
}

crates/vite_task_plan/src/plan.rs

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,15 @@ fn effective_cache_config(
7979
if enabled { task_cache_config.cloned() } else { None }
8080
}
8181

82+
/// - `with_hooks`: whether to look up `preX`/`postX` lifecycle hooks for this task.
83+
/// `false` when the task itself is being executed as a hook, so that hooks are
84+
/// never expanded more than one level deep (matching npm behavior).
8285
#[expect(clippy::too_many_lines, reason = "sequential planning steps are clearer in one function")]
8386
#[expect(clippy::future_not_send, reason = "PlanContext contains !Send dyn PlanRequestParser")]
8487
async fn plan_task_as_execution_node(
8588
task_node_index: TaskNodeIndex,
8689
mut context: PlanContext<'_>,
90+
with_hooks: bool,
8791
) -> Result<TaskExecution, Error> {
8892
// Check for recursions in the task call stack.
8993
context.check_recursion(task_node_index)?;
@@ -100,6 +104,26 @@ async fn plan_task_as_execution_node(
100104

101105
let mut items = Vec::<ExecutionItem>::new();
102106

107+
// Expand pre/post hooks (`preX`/`postX`) for package.json scripts.
108+
// Hooks are never expanded more than one level deep (matching npm behavior): when planning a
109+
// hook script, `with_hooks` is false so it won't look for its own pre/post hooks.
110+
// Resolve the flag once before any mutable borrow of `context` (duplicate() needs &mut).
111+
let pre_post_scripts_enabled =
112+
with_hooks && context.indexed_task_graph().pre_post_scripts_enabled();
113+
let pre_hook_idx = if pre_post_scripts_enabled {
114+
context.indexed_task_graph().get_script_hook(task_node_index, "pre")
115+
} else {
116+
None
117+
};
118+
if let Some(pre_hook_idx) = pre_hook_idx {
119+
let mut pre_context = context.duplicate();
120+
// Extra args (e.g. `vt run test --coverage`) must not be forwarded to hooks.
121+
pre_context.set_extra_args(Arc::new([]));
122+
let pre_execution =
123+
Box::pin(plan_task_as_execution_node(pre_hook_idx, pre_context, false)).await?;
124+
items.extend(pre_execution.items);
125+
}
126+
103127
// Use task's resolved cwd for display (from task config's cwd option)
104128
let mut cwd = Arc::clone(&task_node.resolved_config.resolved_options.cwd);
105129

@@ -357,6 +381,21 @@ async fn plan_task_as_execution_node(
357381
});
358382
}
359383

384+
// Expand post-hook (`postX`) for package.json scripts.
385+
let post_hook_idx = if pre_post_scripts_enabled {
386+
context.indexed_task_graph().get_script_hook(task_node_index, "post")
387+
} else {
388+
None
389+
};
390+
if let Some(post_hook_idx) = post_hook_idx {
391+
let mut post_context = context.duplicate();
392+
// Extra args must not be forwarded to hooks.
393+
post_context.set_extra_args(Arc::new([]));
394+
let post_execution =
395+
Box::pin(plan_task_as_execution_node(post_hook_idx, post_context, false)).await?;
396+
items.extend(post_execution.items);
397+
}
398+
360399
Ok(TaskExecution { task_display: task_node.task_display.clone(), items })
361400
}
362401

@@ -648,8 +687,9 @@ pub async fn plan_query_request(
648687
if Some(task_index) == pruned_task {
649688
continue;
650689
}
651-
let task_execution =
652-
plan_task_as_execution_node(task_index, context.duplicate()).boxed_local().await?;
690+
let task_execution = plan_task_as_execution_node(task_index, context.duplicate(), true)
691+
.boxed_local()
692+
.await?;
653693
execution_node_indices_by_task_index
654694
.insert(task_index, inner_graph.add_node(task_execution));
655695
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "@test/script-hooks-disabled",
3+
"scripts": {
4+
"pretest": "echo pretest",
5+
"test": "echo test",
6+
"posttest": "echo posttest"
7+
}
8+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Tests that pre/post hooks are NOT expanded when enablePrePostScripts is false.
2+
3+
[[plan]]
4+
name = "test runs without hooks when disabled"
5+
args = ["run", "test"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
source: crates/vite_task_plan/tests/plan_snapshots/main.rs
3+
expression: "&plan_json"
4+
info:
5+
args:
6+
- run
7+
- test
8+
input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/script-hooks-disabled
9+
---
10+
[
11+
{
12+
"key": [
13+
"<workspace>/",
14+
"test"
15+
],
16+
"node": {
17+
"task_display": {
18+
"package_name": "@test/script-hooks-disabled",
19+
"task_name": "test",
20+
"package_path": "<workspace>/"
21+
},
22+
"items": [
23+
{
24+
"execution_item_display": {
25+
"task_display": {
26+
"package_name": "@test/script-hooks-disabled",
27+
"task_name": "test",
28+
"package_path": "<workspace>/"
29+
},
30+
"command": "echo test",
31+
"and_item_index": null,
32+
"cwd": "<workspace>/"
33+
},
34+
"kind": {
35+
"Leaf": {
36+
"InProcess": {
37+
"kind": {
38+
"Echo": {
39+
"strings": [
40+
"test"
41+
],
42+
"trailing_newline": true
43+
}
44+
}
45+
}
46+
}
47+
}
48+
}
49+
]
50+
},
51+
"neighbors": []
52+
}
53+
]

0 commit comments

Comments
 (0)