@engramresearch/srun 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # srun
2
+
3
+ `srun` is a Universal Smart Project Runner. It translates a developer intent into the concrete command for the current project.
4
+
5
+ ```bash
6
+ srun dev
7
+ srun build
8
+ srun installer
9
+ srun test
10
+ srun lint
11
+ srun format
12
+ srun info
13
+ ```
14
+
15
+ The goal is to reduce cognitive load when switching between projects, package managers, frameworks, and custom script names.
16
+
17
+ ## Install locally
18
+
19
+ With Cargo:
20
+
21
+ ```bash
22
+ cargo install --path .
23
+ ```
24
+
25
+ With npm from the project directory:
26
+
27
+ ```bash
28
+ npm install -g .
29
+ ```
30
+
31
+ After publishing:
32
+
33
+ ```bash
34
+ npm install -g @engramresearch/srun
35
+ ```
36
+
37
+ Note: the npm package currently builds the Rust binary during install, so Rust/Cargo must be available on the target machine.
38
+
39
+ Or run during development:
40
+
41
+ ```bash
42
+ cargo run -- info
43
+ cargo run -- dev --dry-run
44
+ ```
45
+
46
+ ## What it detects
47
+
48
+ Package managers:
49
+
50
+ - `pnpm-lock.yaml` -> `pnpm`
51
+ - `bun.lockb` or `bun.lock` -> `bun`
52
+ - `yarn.lock` -> `yarn`
53
+ - `package-lock.json` -> `npm`
54
+ - `package.json` without lockfile -> `npm` with warning
55
+
56
+ If multiple lockfiles exist, priority is:
57
+
58
+ ```text
59
+ pnpm > bun > yarn > npm
60
+ ```
61
+
62
+ Project markers:
63
+
64
+ - Electron: `electron`, `electron-builder`, `electron-vite`, `electron/`, `electron.vite.config.*`
65
+ - Tauri: `src-tauri/`, `tauri.conf.json`, tauri scripts/dependencies
66
+ - Next.js: `next.config.*` or `next`
67
+ - Vite: `vite.config.*` or `vite`
68
+ - TurboRepo: `turbo.json`
69
+ - NX: `nx.json`
70
+ - Monorepo: `apps/`, `packages/`, TurboRepo or NX markers
71
+ - Cargo-only: `Cargo.toml` without `package.json`
72
+
73
+ ## Resolution examples
74
+
75
+ Next.js:
76
+
77
+ ```json
78
+ {
79
+ "scripts": {
80
+ "dev": "next dev"
81
+ }
82
+ }
83
+ ```
84
+
85
+ ```bash
86
+ srun dev --dry-run
87
+ # pnpm run dev
88
+ ```
89
+
90
+ Electron:
91
+
92
+ ```json
93
+ {
94
+ "scripts": {
95
+ "dev": "vite",
96
+ "dev:electron": "electron-vite dev"
97
+ }
98
+ }
99
+ ```
100
+
101
+ ```bash
102
+ srun dev --dry-run
103
+ # pnpm run dev:electron
104
+ ```
105
+
106
+ Tauri:
107
+
108
+ ```json
109
+ {
110
+ "scripts": {
111
+ "tauri:dev": "tauri dev"
112
+ }
113
+ }
114
+ ```
115
+
116
+ ```bash
117
+ srun dev --dry-run
118
+ # pnpm run tauri:dev
119
+ ```
120
+
121
+ Cargo-only:
122
+
123
+ ```bash
124
+ srun dev --dry-run
125
+ # cargo run
126
+ ```
127
+
128
+ ## Info and verbose mode
129
+
130
+ ```bash
131
+ srun info
132
+ ```
133
+
134
+ Prints project type, package manager, warnings, and resolved commands.
135
+
136
+ ```bash
137
+ srun dev --verbose --dry-run
138
+ ```
139
+
140
+ Shows detection and resolution phases before printing the command.
141
+
142
+ ## Current limitations
143
+
144
+ - Monorepo scopes such as `srun dev web` are detected as a future extension but not fully implemented yet.
145
+ - Interactive fallback for custom scripts is not implemented; `srun` reports candidates instead of guessing.
146
+ - Colors and shell integration are intentionally omitted from the MVP.
@@ -0,0 +1,18 @@
1
+ const { spawnSync } = require("node:child_process");
2
+ const { resolve } = require("node:path");
3
+
4
+ const root = resolve(__dirname, "..");
5
+
6
+ const result = spawnSync("cargo", ["build", "--release"], {
7
+ cwd: root,
8
+ stdio: "inherit",
9
+ shell: process.platform === "win32",
10
+ });
11
+
12
+ if (result.error) {
13
+ console.error("srun: cargo is required to build the npm package binary.");
14
+ console.error(`srun: ${result.error.message}`);
15
+ process.exit(1);
16
+ }
17
+
18
+ process.exit(result.status ?? 1);
package/npm/srun.js ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ const { spawnSync } = require("node:child_process");
3
+ const { existsSync } = require("node:fs");
4
+ const { join, resolve } = require("node:path");
5
+
6
+ const root = resolve(__dirname, "..");
7
+ const binary = join(root, "target", "release", process.platform === "win32" ? "srun.exe" : "srun");
8
+
9
+ if (!existsSync(binary)) {
10
+ const result = spawnSync("cargo", ["build", "--release"], {
11
+ cwd: root,
12
+ stdio: "inherit",
13
+ shell: process.platform === "win32",
14
+ });
15
+
16
+ if (result.error) {
17
+ console.error(`srun: failed to build Rust binary: ${result.error.message}`);
18
+ process.exit(1);
19
+ }
20
+
21
+ if (result.status !== 0) {
22
+ process.exit(result.status ?? 1);
23
+ }
24
+ }
25
+
26
+ const result = spawnSync(binary, process.argv.slice(2), {
27
+ cwd: process.cwd(),
28
+ stdio: "inherit",
29
+ });
30
+
31
+ if (result.error) {
32
+ console.error(`srun: failed to execute binary: ${result.error.message}`);
33
+ process.exit(1);
34
+ }
35
+
36
+ process.exit(result.status ?? 1);
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@engramresearch/srun",
3
+ "version": "0.1.0",
4
+ "description": "Universal Smart Project Runner",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "srun": "npm/srun.js"
8
+ },
9
+ "scripts": {
10
+ "postinstall": "node npm/postinstall.js",
11
+ "build:binary": "cargo build --release",
12
+ "dev": "cargo run",
13
+ "build": "cargo build",
14
+ "test": "cargo test",
15
+ "lint": "cargo clippy",
16
+ "format": "cargo fmt"
17
+ },
18
+ "files": [
19
+ "Cargo.toml",
20
+ "Cargo.lock",
21
+ "src",
22
+ "npm",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "keywords": [
27
+ "cli",
28
+ "runner",
29
+ "devtools",
30
+ "rust",
31
+ "npm",
32
+ "pnpm",
33
+ "electron",
34
+ "tauri"
35
+ ],
36
+ "engines": {
37
+ "node": ">=18"
38
+ }
39
+ }
package/src/cli.rs ADDED
@@ -0,0 +1,135 @@
1
+ use crate::detect::detect_project;
2
+ use crate::exec::run_command;
3
+ use crate::model::{Intent, ProjectInfo, ResolvedCommand, SrunError};
4
+ use crate::resolve::resolve_intent;
5
+ use clap::{Args, Parser, Subcommand};
6
+ use std::path::PathBuf;
7
+
8
+ #[derive(Debug, Parser)]
9
+ #[command(name = "srun", about = "Universal Smart Project Runner")]
10
+ pub struct Cli {
11
+ #[arg(long, global = true, default_value = ".")]
12
+ pub root: PathBuf,
13
+
14
+ #[command(subcommand)]
15
+ pub command: Command,
16
+ }
17
+
18
+ #[derive(Debug, Subcommand)]
19
+ pub enum Command {
20
+ Dev(RunArgs),
21
+ Build(RunArgs),
22
+ Installer(RunArgs),
23
+ Test(RunArgs),
24
+ Lint(RunArgs),
25
+ Format(RunArgs),
26
+ Info,
27
+ }
28
+
29
+ #[derive(Debug, Args, Clone, Copy)]
30
+ pub struct RunArgs {
31
+ #[arg(long)]
32
+ pub dry_run: bool,
33
+
34
+ #[arg(long)]
35
+ pub verbose: bool,
36
+ }
37
+
38
+ pub fn run(cli: Cli) -> Result<i32, SrunError> {
39
+ let project = detect_project(&cli.root)?;
40
+
41
+ match cli.command {
42
+ Command::Info => {
43
+ print_info(&project);
44
+ Ok(0)
45
+ }
46
+ Command::Dev(args) => run_intent(&project, Intent::Dev, args),
47
+ Command::Build(args) => run_intent(&project, Intent::Build, args),
48
+ Command::Installer(args) => run_intent(&project, Intent::Installer, args),
49
+ Command::Test(args) => run_intent(&project, Intent::Test, args),
50
+ Command::Lint(args) => run_intent(&project, Intent::Lint, args),
51
+ Command::Format(args) => run_intent(&project, Intent::Format, args),
52
+ }
53
+ }
54
+
55
+ fn run_intent(project: &ProjectInfo, intent: Intent, args: RunArgs) -> Result<i32, SrunError> {
56
+ if args.verbose {
57
+ print_detect(project);
58
+ }
59
+
60
+ let resolved = resolve_intent(project, intent)?;
61
+
62
+ if args.verbose {
63
+ println!("[resolve]");
64
+ println!("selected: {}", resolved.command.display());
65
+ println!("reason: {}", resolved.reason);
66
+ println!();
67
+ }
68
+
69
+ if args.dry_run {
70
+ println!("{}", resolved.command.display());
71
+ return Ok(0);
72
+ }
73
+
74
+ if args.verbose {
75
+ println!("[exec]");
76
+ println!("{}", resolved.command.display());
77
+ println!();
78
+ }
79
+
80
+ let status = run_command(&resolved.command, &project.root)?;
81
+ Ok(status.code().unwrap_or(1))
82
+ }
83
+
84
+ pub fn print_info(project: &ProjectInfo) {
85
+ println!("Project Type: {}", project.project_type());
86
+ println!(
87
+ "Package Manager: {}",
88
+ project
89
+ .package_manager
90
+ .map(|package_manager| package_manager.label())
91
+ .unwrap_or("none")
92
+ );
93
+
94
+ if !project.warnings.is_empty() {
95
+ println!();
96
+ println!("Warnings:");
97
+ for warning in &project.warnings {
98
+ println!(" - {}", warning);
99
+ }
100
+ }
101
+
102
+ println!();
103
+ println!("Resolved Commands:");
104
+ for intent in Intent::EXECUTABLE {
105
+ print_resolved_line(project, intent);
106
+ }
107
+ }
108
+
109
+ fn print_resolved_line(project: &ProjectInfo, intent: Intent) {
110
+ match resolve_intent(project, intent) {
111
+ Ok(resolved) => println!("{}:\n {}", intent.label(), resolved.command.display()),
112
+ Err(error) => println!("{}:\n unavailable ({})", intent.label(), error),
113
+ }
114
+ }
115
+
116
+ fn print_detect(project: &ProjectInfo) {
117
+ println!("[detect]");
118
+ for trace in &project.traces {
119
+ println!("{}", trace);
120
+ }
121
+ for warning in &project.warnings {
122
+ println!("warning: {}", warning);
123
+ }
124
+ println!("project type: {}", project.project_type());
125
+ println!();
126
+ }
127
+
128
+ #[allow(dead_code)]
129
+ fn _format_resolved(resolved: &ResolvedCommand) -> String {
130
+ format!(
131
+ "{} -> {}",
132
+ resolved.intent.label(),
133
+ resolved.command.display()
134
+ )
135
+ }
package/src/detect.rs ADDED
@@ -0,0 +1,232 @@
1
+ use crate::manifest::read_package_json;
2
+ use crate::model::{Framework, PackageManager, ProjectInfo, SrunError};
3
+ use std::collections::BTreeSet;
4
+ use std::path::Path;
5
+
6
+ pub fn detect_project(root: &Path) -> Result<ProjectInfo, SrunError> {
7
+ let package_json = read_package_json(root)?;
8
+ let has_package_json = package_json.is_some();
9
+ let has_cargo_toml = root.join("Cargo.toml").exists();
10
+ let (package_manager, mut warnings, mut traces) =
11
+ detect_package_manager(root, has_package_json);
12
+ let mut frameworks = BTreeSet::new();
13
+
14
+ if let Some(package_json) = &package_json {
15
+ let deps = &package_json.dependencies;
16
+ let scripts = package_json
17
+ .scripts
18
+ .keys()
19
+ .map(String::as_str)
20
+ .collect::<BTreeSet<_>>();
21
+
22
+ if deps.contains("electron")
23
+ || deps.contains("electron-builder")
24
+ || deps.contains("electron-vite")
25
+ || root.join("electron").is_dir()
26
+ || has_file_prefix(root, "electron.vite.config.")
27
+ {
28
+ frameworks.insert(Framework::Electron);
29
+ traces.push("found electron markers".to_string());
30
+ }
31
+
32
+ if root.join("src-tauri").is_dir()
33
+ || root.join("tauri.conf.json").exists()
34
+ || deps.iter().any(|dep| dep.contains("tauri"))
35
+ || scripts.iter().any(|script| script.contains("tauri"))
36
+ {
37
+ frameworks.insert(Framework::Tauri);
38
+ traces.push("found tauri markers".to_string());
39
+ }
40
+
41
+ if deps.contains("next") || has_file_prefix(root, "next.config.") {
42
+ frameworks.insert(Framework::Next);
43
+ traces.push("found next markers".to_string());
44
+ }
45
+
46
+ if deps.contains("vite") || has_file_prefix(root, "vite.config.") {
47
+ frameworks.insert(Framework::Vite);
48
+ traces.push("found vite markers".to_string());
49
+ }
50
+ } else {
51
+ if root.join("src-tauri").is_dir() || root.join("tauri.conf.json").exists() {
52
+ frameworks.insert(Framework::Tauri);
53
+ traces.push("found tauri markers".to_string());
54
+ }
55
+ }
56
+
57
+ if root.join("turbo.json").exists() {
58
+ frameworks.insert(Framework::Turbo);
59
+ traces.push("found turbo.json".to_string());
60
+ }
61
+
62
+ if root.join("nx.json").exists() {
63
+ frameworks.insert(Framework::Nx);
64
+ traces.push("found nx.json".to_string());
65
+ }
66
+
67
+ let is_monorepo = root.join("apps").is_dir()
68
+ || root.join("packages").is_dir()
69
+ || frameworks.contains(&Framework::Turbo)
70
+ || frameworks.contains(&Framework::Nx);
71
+
72
+ if is_monorepo {
73
+ traces.push("found monorepo markers".to_string());
74
+ }
75
+
76
+ warnings.retain(|warning| !warning.is_empty());
77
+
78
+ Ok(ProjectInfo {
79
+ root: root.to_path_buf(),
80
+ package_manager,
81
+ package_json,
82
+ frameworks,
83
+ has_cargo_toml,
84
+ has_package_json,
85
+ is_monorepo,
86
+ warnings,
87
+ traces,
88
+ })
89
+ }
90
+
91
+ fn detect_package_manager(
92
+ root: &Path,
93
+ has_package_json: bool,
94
+ ) -> (Option<PackageManager>, Vec<String>, Vec<String>) {
95
+ let mut found = Vec::new();
96
+ let mut traces = Vec::new();
97
+
98
+ if root.join("pnpm-lock.yaml").exists() {
99
+ found.push(PackageManager::Pnpm);
100
+ traces.push("found pnpm-lock.yaml".to_string());
101
+ }
102
+ if root.join("bun.lockb").exists() || root.join("bun.lock").exists() {
103
+ found.push(PackageManager::Bun);
104
+ traces.push("found bun lockfile".to_string());
105
+ }
106
+ if root.join("yarn.lock").exists() {
107
+ found.push(PackageManager::Yarn);
108
+ traces.push("found yarn.lock".to_string());
109
+ }
110
+ if root.join("package-lock.json").exists() {
111
+ found.push(PackageManager::Npm);
112
+ traces.push("found package-lock.json".to_string());
113
+ }
114
+
115
+ let selected = [
116
+ PackageManager::Pnpm,
117
+ PackageManager::Bun,
118
+ PackageManager::Yarn,
119
+ PackageManager::Npm,
120
+ ]
121
+ .into_iter()
122
+ .find(|package_manager| found.contains(package_manager))
123
+ .or_else(|| has_package_json.then_some(PackageManager::Npm));
124
+
125
+ let mut warnings = Vec::new();
126
+ if found.len() > 1 {
127
+ if let Some(selected) = selected {
128
+ warnings.push(format!(
129
+ "Multiple package managers detected. Using {}.",
130
+ selected.label()
131
+ ));
132
+ }
133
+ } else if found.is_empty() && has_package_json {
134
+ warnings.push("No lockfile found. Using npm.".to_string());
135
+ }
136
+
137
+ (selected, warnings, traces)
138
+ }
139
+
140
+ fn has_file_prefix(root: &Path, prefix: &str) -> bool {
141
+ let Ok(entries) = std::fs::read_dir(root) else {
142
+ return false;
143
+ };
144
+
145
+ entries.filter_map(Result::ok).any(|entry| {
146
+ entry
147
+ .file_name()
148
+ .to_str()
149
+ .map(|file_name| file_name.starts_with(prefix))
150
+ .unwrap_or(false)
151
+ })
152
+ }
153
+
154
+ #[cfg(test)]
155
+ mod tests {
156
+ use super::detect_project;
157
+ use crate::model::{Framework, PackageManager};
158
+ use std::fs;
159
+ use tempfile::tempdir;
160
+
161
+ #[test]
162
+ fn selects_pnpm_over_other_lockfiles() {
163
+ let dir = tempdir().expect("tempdir");
164
+ fs::write(dir.path().join("package.json"), "{}").expect("package");
165
+ fs::write(dir.path().join("pnpm-lock.yaml"), "").expect("pnpm");
166
+ fs::write(dir.path().join("package-lock.json"), "").expect("npm");
167
+
168
+ let project = detect_project(dir.path()).expect("project");
169
+
170
+ assert_eq!(project.package_manager, Some(PackageManager::Pnpm));
171
+ assert!(project
172
+ .warnings
173
+ .iter()
174
+ .any(|warning| warning.contains("Multiple")));
175
+ }
176
+
177
+ #[test]
178
+ fn falls_back_to_npm_for_package_json_without_lock() {
179
+ let dir = tempdir().expect("tempdir");
180
+ fs::write(dir.path().join("package.json"), "{}").expect("package");
181
+
182
+ let project = detect_project(dir.path()).expect("project");
183
+
184
+ assert_eq!(project.package_manager, Some(PackageManager::Npm));
185
+ assert!(project
186
+ .warnings
187
+ .iter()
188
+ .any(|warning| warning.contains("No lockfile")));
189
+ }
190
+
191
+ #[test]
192
+ fn detects_electron_and_vite() {
193
+ let dir = tempdir().expect("tempdir");
194
+ fs::write(
195
+ dir.path().join("package.json"),
196
+ r#"{"dependencies":{"electron":"latest","vite":"latest"}}"#,
197
+ )
198
+ .expect("package");
199
+
200
+ let project = detect_project(dir.path()).expect("project");
201
+
202
+ assert!(project.frameworks.contains(&Framework::Electron));
203
+ assert!(project.frameworks.contains(&Framework::Vite));
204
+ }
205
+
206
+ #[test]
207
+ fn detects_tauri_next_and_monorepo() {
208
+ let dir = tempdir().expect("tempdir");
209
+ fs::write(
210
+ dir.path().join("package.json"),
211
+ r#"{"dependencies":{"next":"latest"},"scripts":{"tauri:dev":"tauri dev"}}"#,
212
+ )
213
+ .expect("package");
214
+ fs::create_dir(dir.path().join("apps")).expect("apps");
215
+
216
+ let project = detect_project(dir.path()).expect("project");
217
+
218
+ assert!(project.frameworks.contains(&Framework::Tauri));
219
+ assert!(project.frameworks.contains(&Framework::Next));
220
+ assert!(project.is_monorepo);
221
+ }
222
+
223
+ #[test]
224
+ fn detects_cargo_only() {
225
+ let dir = tempdir().expect("tempdir");
226
+ fs::write(dir.path().join("Cargo.toml"), "[package]\nname='x'").expect("cargo");
227
+
228
+ let project = detect_project(dir.path()).expect("project");
229
+
230
+ assert!(project.is_cargo_only());
231
+ }
232
+ }
package/src/exec.rs ADDED
@@ -0,0 +1,80 @@
1
+ use crate::model::{CommandSpec, SrunError};
2
+ use std::io;
3
+ use std::path::Path;
4
+ use std::process::{Command, ExitStatus, Stdio};
5
+
6
+ pub fn run_command(command: &CommandSpec, cwd: &Path) -> Result<ExitStatus, SrunError> {
7
+ spawn_command(command, cwd).map_err(|source| SrunError::ProcessSpawn {
8
+ command: command.display(),
9
+ source,
10
+ })
11
+ }
12
+
13
+ fn spawn_command(command: &CommandSpec, cwd: &Path) -> io::Result<ExitStatus> {
14
+ match spawn_program(&command.program, &command.args, cwd) {
15
+ Ok(status) => Ok(status),
16
+ Err(error) if should_try_windows_cmd_shim(&command.program, &error) => {
17
+ spawn_program(&format!("{}.cmd", command.program), &command.args, cwd)
18
+ }
19
+ Err(error) => Err(error),
20
+ }
21
+ }
22
+
23
+ fn spawn_program(program: &str, args: &[String], cwd: &Path) -> io::Result<ExitStatus> {
24
+ Command::new(program)
25
+ .args(args)
26
+ .current_dir(cwd)
27
+ .stdin(Stdio::inherit())
28
+ .stdout(Stdio::inherit())
29
+ .stderr(Stdio::inherit())
30
+ .status()
31
+ }
32
+
33
+ fn should_try_windows_cmd_shim(program: &str, error: &io::Error) -> bool {
34
+ cfg!(windows)
35
+ && error.kind() == io::ErrorKind::NotFound
36
+ && matches!(program, "npm" | "pnpm" | "yarn" | "bun" | "npx")
37
+ }
38
+
39
+ #[cfg(test)]
40
+ mod tests {
41
+ use super::should_try_windows_cmd_shim;
42
+ use crate::model::{CommandSpec, PackageManager};
43
+ use std::io;
44
+
45
+ #[test]
46
+ fn builds_package_manager_commands_without_shell() {
47
+ assert_eq!(
48
+ PackageManager::Pnpm.script_command("dev").display(),
49
+ "pnpm run dev"
50
+ );
51
+ assert_eq!(
52
+ PackageManager::Npm.script_command("dev").display(),
53
+ "npm run dev"
54
+ );
55
+ assert_eq!(
56
+ PackageManager::Bun.script_command("dev").display(),
57
+ "bun run dev"
58
+ );
59
+ assert_eq!(
60
+ PackageManager::Yarn.script_command("dev").display(),
61
+ "yarn dev"
62
+ );
63
+ }
64
+
65
+ #[test]
66
+ fn displays_program_without_args() {
67
+ assert_eq!(
68
+ CommandSpec::new("srun", std::iter::empty::<&str>()).display(),
69
+ "srun"
70
+ );
71
+ }
72
+
73
+ #[test]
74
+ fn windows_shim_fallback_only_applies_to_package_managers() {
75
+ let error = io::Error::new(io::ErrorKind::NotFound, "missing");
76
+
77
+ assert_eq!(should_try_windows_cmd_shim("npm", &error), cfg!(windows));
78
+ assert!(!should_try_windows_cmd_shim("cargo", &error));
79
+ }
80
+ }
package/src/lib.rs ADDED
@@ -0,0 +1,10 @@
1
+ pub mod cli;
2
+ pub mod detect;
3
+ pub mod exec;
4
+ pub mod manifest;
5
+ pub mod model;
6
+ pub mod resolve;
7
+
8
+ pub use detect::detect_project;
9
+ pub use model::{Intent, ProjectInfo, ResolvedCommand, SrunError};
10
+ pub use resolve::resolve_intent;