@nemus-cli/nemus 0.2.8 → 0.2.10

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/CHANGELOG.md CHANGED
@@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.10] - 2026-08-27
11
+
12
+ ### Security
13
+
14
+ - **Eliminated the shell-injection surface in git/shell operations.** Every
15
+ `exec`/`execSync` call that interpolated a value into a shell string was
16
+ migrated to `execFile`/argv (no shell). Most notably, the MCP `branch_create`
17
+ and `switch_branch` tools built `git checkout -b <name>` by unquoted
18
+ interpolation with no branch-name validation, so a crafted (but valid) git ref
19
+ could run arbitrary commands; branch names are now passed as argv elements and
20
+ can never be interpreted by a shell. Also migrated `ghq`/clone, workspace
21
+ cleanup (`du`, `git clean`), disk-usage health checks, agent-availability
22
+ probing, and the shell-integration/MCP installers.
23
+ - **`w delete` no longer deletes arbitrary directories.** The `--workspace` flag
24
+ path validated names through `safeWorkspacePath()` (allowlist + containment)
25
+ and skips unknown/invalid names instead of `fs.rm`-ing a guessed path; the
26
+ interactive path is guarded the same way.
27
+
28
+ ### Added
29
+
30
+ - **Monorepo (npm workspaces).** The repo is now an npm-workspaces monorepo:
31
+ the published CLI stays at the root (`@nemus-cli/nemus`, unchanged) and new
32
+ packages live under `packages/*`.
33
+ - **`@nemus-cli/cloud` (private, P1).** An optional, vendor-neutral cloud/IaC
34
+ package — not required for local Nemus. Two seams so far: the **forge-auth**
35
+ seam (`ForgeTokenSource`) with `pat` and a dependency-free `github-app` source
36
+ that mints least-privilege, auto-refreshing installation tokens; and the
37
+ **execution** seam (`Runner`/`Provisioner` + neutral `TaskSpec`/
38
+ `TargetDescriptor`/`Capabilities` + a name registry) with an in-box
39
+ **Docker runner** (needs no cloud account); a **`SecretSource`** seam
40
+ (`env`/`dotenv`/`gh` + `resolveSecretsToEnv`); and a **`GitForge`** seam
41
+ (`openPR`/`getChecks`/`comment`) with a dependency-free GitHub implementation;
42
+ and the **in-image agent orchestrator** (runner-image env contract, versioned
43
+ `result.json` schema, and `runAgentTask`: clone all → run the agent once over
44
+ the workspace → open a PR per changed repo, with per-repo error isolation);
45
+ the **OCI image + `nemus-cloud-agent` entrypoint** (a Dockerfile with node +
46
+ git + gh + pi/claude); and the **provisioning** seam (P2, in progress): a
47
+ generic `OpenTofuProvisioner` (delegates to `tofu`/`terraform`, maps a module's
48
+ `target` output → `TargetDescriptor`) + registry, plus a first **AWS Fargate**
49
+ OpenTofu module (`iac/fargate/`, validated with real `tofu validate`); and the
50
+ matching **`aws-fargate` Runner** (dependency-free, shells the `aws` CLI:
51
+ `register-task-definition` → `run-task`, `describe-tasks` → status, CloudWatch
52
+ `logs tail` streaming, `stop-task`); and the **`nemus-cloud` CLI** (own bin,
53
+ dependency-free) — `up`/`down` drive the provisioner, `run` launches the agent
54
+ image on a target (`--follow` logs, `--wait` for the exit code). This
55
+ completes P2. **P3 (in progress):** a bounded **CI-loop** (`runCiLoop`) that
56
+ drives a PR to green over `GitForge` — poll checks, run a fix pass on failure,
57
+ commit/push, repeat; anti-runaway guards (max iterations, no-change → stuck,
58
+ poll-budget → timeout) + a best-effort "needs a human" give-up comment; and
59
+ (P4) a vendor-neutral **notification seam** — `Notifier` with Slack (incoming
60
+ webhook) + generic webhook sinks (`notifierFromEnv`), wired into the CI-loop as
61
+ optional out-of-band report-back.
62
+ Design: `docs/plans/2026-08-26-cloud-iac.md`.
63
+
64
+ ## [0.2.9] - 2026-08-26
65
+
66
+ ### Fixed
67
+
68
+ - Synced `package-lock.json` to the current version (it had drifted to 0.2.7
69
+ while package.json was 0.2.8, because the ink/React upgrade regenerated the
70
+ lockfile before the version bump).
71
+
72
+ ### Added
73
+
74
+ - CI guard `scripts/check-lock-version.js` — fails PR CI (and the release verify
75
+ step) if `package-lock.json`'s `version` / `packages[""]` drift from
76
+ package.json, so a stale lockfile can't reach `npm ci` at release time.
77
+
10
78
  ## [0.2.8] - 2026-08-26
11
79
 
12
80
  ### Changed
@@ -181,7 +249,8 @@ Initial open-source release of **Nemus**.
181
249
  - Automatic retries with backoff on flaky network operations.
182
250
  - Optional shell integration (auto-cd on create + quick-navigate helper).
183
251
 
184
- [Unreleased]: https://github.com/me-public/nemus/compare/v0.2.8...HEAD
252
+ [Unreleased]: https://github.com/me-public/nemus/compare/v0.2.9...HEAD
253
+ [0.2.9]: https://github.com/me-public/nemus/compare/v0.2.8...v0.2.9
185
254
  [0.2.8]: https://github.com/me-public/nemus/compare/v0.2.7...v0.2.8
186
255
  [0.2.7]: https://github.com/me-public/nemus/compare/v0.2.6...v0.2.7
187
256
  [0.2.6]: https://github.com/me-public/nemus/compare/v0.2.5...v0.2.6
package/README.md CHANGED
@@ -3,46 +3,82 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <b>Multi-repo workspaces for the AI-agent era.</b><br/>
7
- Create, sync, and operate across dozens of Git repositories with a single command
8
- and wire them up to your favorite coding agent.
6
+ <b>Work on many Git repos as if they were one project.</b><br/>
7
+ Nemus pulls the repos you're working on into a single folder, runs commands across
8
+ all of them at once, and gives your AI coding agent the whole picture.
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
+ <a href="https://me-public.github.io/nemus/"><img src="https://img.shields.io/badge/website-nemus-3FB950.svg" alt="Website" /></a>
12
13
  <a href="https://github.com/me-public/nemus/actions/workflows/ci.yml"><img src="https://github.com/me-public/nemus/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
13
14
  <a href="https://www.npmjs.com/package/@nemus-cli/nemus"><img src="https://img.shields.io/npm/v/@nemus-cli/nemus.svg" alt="npm" /></a>
14
15
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT" /></a>
15
16
  <a href="https://www.npmjs.com/package/@nemus-cli/nemus"><img src="https://img.shields.io/npm/dm/@nemus-cli/nemus.svg" alt="npm downloads" /></a>
16
17
  <img src="https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg" alt="Node >= 22" />
18
+ <a href="https://x.com/nemus_cli"><img src="https://img.shields.io/badge/follow-%40nemus__cli-000000.svg?logo=x&logoColor=white" alt="Follow @nemus_cli on X" /></a>
17
19
  </p>
18
20
 
19
21
  <p align="center">
20
- 📦 <b><a href="https://www.npmjs.com/package/@nemus-cli/nemus">@nemus-cli/nemus</a></b> on npm &nbsp;—&nbsp; <code>npm install -g @nemus-cli/nemus</code>
22
+ 🌐 <b><a href="https://me-public.github.io/nemus/">me-public.github.io/nemus</a></b> &nbsp;—&nbsp; 📦 <b><a href="https://www.npmjs.com/package/@nemus-cli/nemus">@nemus-cli/nemus</a></b> on npm &nbsp;—&nbsp; 𝕏 <b><a href="https://x.com/nemus_cli">@nemus_cli</a></b> &nbsp;—&nbsp; <code>npm install -g @nemus-cli/nemus</code>
21
23
  </p>
22
24
 
25
+ <p align="center">
26
+ <a href="https://raw.githubusercontent.com/me-public/nemus/main/docs/assets/nemus-cli-demo.mp4"><img src="https://raw.githubusercontent.com/me-public/nemus/main/docs/assets/nemus-cli-demo.gif" alt="Nemus terminal demo — describe a workspace in plain English, Nemus clones the repos and writes the agent context, then run across every repo at once" width="820" /></a>
27
+ </p>
28
+ <p align="center"><sub>A real recording — <code>nemus -- "…"</code> turns a sentence into a ready workspace. <a href="https://raw.githubusercontent.com/me-public/nemus/main/docs/assets/nemus-cli-demo.mp4">▶ watch as MP4</a></sub></p>
29
+
23
30
  ---
24
31
 
25
- ## The name
32
+ ## What is Nemus?
26
33
 
27
- **Nemus** (_NEH-mus_) is Latin for a **grove** a small wood of trees sharing
28
- soil and roots. It's a fitting picture of what the tool manages: a cluster of
29
- repositories, each its own tree, growing together in one workspace. It also nods
30
- to the branch-and-commit shape of Git itself.
34
+ Your product's code is probably split across **many separate Git repos** frontend,
35
+ backend, auth, payments, shared libraries. Working on one feature means cloning,
36
+ pulling, branching, and testing each repo by hand, one at a time and your AI coding
37
+ agent only ever sees the single repo you happened to open.
38
+
39
+ **Nemus lets you pull the repos you're working on into one folder and treat them as a
40
+ single project:**
41
+
42
+ - **One command clones them all** — and keeps them in sync.
43
+ - **Run any git or shell command across every repo at once** — `status`, `pull`,
44
+ `branch`, `npm test`, anything.
45
+ - **Your AI agent sees the whole set at once** — so a prompt like *"add idempotency
46
+ keys to the payments flow"* works even when that flow spans five repos, not one.
47
+
48
+ > **In short:** a **monorepo-style workflow across your many repos — without merging them.**
49
+
50
+ ### Before vs. after
31
51
 
32
- ## Why Nemus?
52
+ **Without Nemus** — clone each repo by hand, `cd` into every one, pull, branch, run
53
+ tests, and repeat. Your agent only sees whichever repo you opened.
33
54
 
34
- Modern products span many repositories. Nemus keeps a **workspace** — a named folder
35
- of related repos — in sync and lets you act across all of them at once: clone, pull,
36
- branch, run commands, check status, and diff. Then it connects that workspace to a
37
- coding agent (Claude Code, pi, OpenCode, Codex, Gemini) with generated context files,
38
- skills, and an MCP server, so "work on the payments stack" becomes a single prompt.
55
+ **With Nemus:**
39
56
 
40
- - 🌳 **Workspaces** — group repos, clone them all, jump in.
57
+ ```bash
58
+ nemus create -w payments -r api,web,ledger,gateway,workers # clone all 5 into one workspace
59
+ nemus run payments "npm test" # run the tests in all 5 at once
60
+ nemus -- "add idempotency keys to the payments flow" # let an agent work across all 5
61
+ ```
62
+
63
+ > Prefer to point-and-pick? Just run `nemus create` for an interactive repo picker with
64
+ > fuzzy search, or `nemus -- "create a workspace with the payments repos"` in plain English.
65
+
66
+ ## What you get
67
+
68
+ - 🌳 **Workspaces** — group repos, clone them all in parallel, jump in.
41
69
  - 🔁 **Operate in bulk** — status, sync, diff, run, branch across every repo.
42
70
  - 📦 **Suites & snapshots** — save reusable repo collections and exact states.
43
- - 🤖 **Agent-native** — first-class integration with multiple coding agents.
71
+ - 🤖 **Agent-native** — first-class integration with multiple coding agents
72
+ (Claude Code, pi, OpenCode, Codex, Gemini) via context files, skills, and an MCP server.
44
73
  - 🩺 **Healthy by default** — health checks, dependency analysis, retries.
45
74
 
75
+ ## The name
76
+
77
+ **Nemus** (_NEH-mus_) is Latin for a **grove** — a small wood of trees sharing
78
+ soil and roots. It's a fitting picture of what the tool manages: a cluster of
79
+ repositories, each its own tree, growing together in one workspace. It also nods
80
+ to the branch-and-commit shape of Git itself.
81
+
46
82
  ## A full clone per workspace — on purpose
47
83
 
48
84
  This is the core design decision, so it's worth being explicit: **every workspace
@@ -50,7 +50,7 @@ const logger_1 = require("../utils/logger");
50
50
  const colors_1 = require("../utils/colors");
51
51
  const agent_config_1 = require("../utils/agent-config");
52
52
  const validation_1 = require("../utils/validation");
53
- const execAsync = (0, util_1.promisify)(child_process_1.exec);
53
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
54
54
  exports.AI_PROMPT_FILE = path.join(os.homedir(), '.workspace-ai-prompt');
55
55
  const EXTRACT_SCHEMA = JSON.stringify({
56
56
  type: 'object',
@@ -129,7 +129,7 @@ function buildInvestigationPreamble(workspaceName, task, opts = {}) {
129
129
  async function isPrimaryAgentAvailable() {
130
130
  const agent = (0, agent_config_1.getPrimaryAgent)();
131
131
  try {
132
- await execAsync(`which ${agent.launchCommand}`);
132
+ await execFileAsync('which', [agent.launchCommand]);
133
133
  return true;
134
134
  }
135
135
  catch {
@@ -148,7 +148,7 @@ async function handleConfigure() {
148
148
  (0, logger_1.logInfo)('Installing MCP server...');
149
149
  try {
150
150
  const mcpInstallScript = path.join(__dirname, '..', '..', 'dist', 'mcp', 'install.js');
151
- (0, child_process_1.execSync)(`node "${mcpInstallScript}" install`, { stdio: 'inherit' });
151
+ (0, child_process_1.execFileSync)('node', [mcpInstallScript, 'install'], { stdio: 'inherit' });
152
152
  mcpInstalled = true;
153
153
  }
154
154
  catch {
@@ -198,7 +198,7 @@ function installShellIntegration() {
198
198
  return;
199
199
  }
200
200
  try {
201
- (0, child_process_1.execSync)(`bash "${scriptPath}" ${shellType}`, { stdio: 'inherit' });
201
+ (0, child_process_1.execFileSync)('bash', [scriptPath, shellType], { stdio: 'inherit' });
202
202
  }
203
203
  catch {
204
204
  (0, logger_1.logWarning)('Shell integration install failed — you can run it manually:');
@@ -39,8 +39,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.registerDeleteCommand = registerDeleteCommand;
40
40
  exports.main = main;
41
41
  const fs = __importStar(require("fs/promises"));
42
- const config_1 = require("../utils/config");
43
- const path = __importStar(require("path"));
42
+ const validation_1 = require("../utils/validation");
44
43
  const workspace_meta_1 = require("../utils/workspace-meta");
45
44
  const prompts_1 = require("../utils/prompts");
46
45
  const logger_1 = require("../utils/logger");
@@ -67,9 +66,33 @@ async function handleDelete(opts) {
67
66
  if (opts.workspace) {
68
67
  const selectedNames = (0, command_helpers_1.parseList)(opts.workspace);
69
68
  const workspaces = await (0, workspace_meta_1.listWorkspaces)();
69
+ const known = new Map(workspaces.map(ws => [ws.name, ws]));
70
+ // Resolve to validated, existing targets. safeWorkspacePath() both
71
+ // enforces the name allowlist and pins the path inside WORKSPACES_DIR, so
72
+ // a name like "../../etc" can never reach fs.rm; unknown names are skipped
73
+ // rather than deleted at a guessed path.
74
+ const targets = [];
70
75
  for (const name of selectedNames) {
71
- const workspace = workspaces.find(ws => ws.name === name);
72
- const workspacePath = path.join(config_1.WORKSPACES_DIR, name);
76
+ const workspace = known.get(name);
77
+ if (!workspace) {
78
+ (0, logger_1.logError)(`Workspace "${name}" not found — skipping`);
79
+ continue;
80
+ }
81
+ let workspacePath;
82
+ try {
83
+ workspacePath = (0, validation_1.safeWorkspacePath)(name);
84
+ }
85
+ catch (error) {
86
+ (0, logger_1.logError)(error instanceof Error ? error.message : `Invalid workspace name "${name}"`);
87
+ continue;
88
+ }
89
+ targets.push({ name, path: workspacePath, workspace });
90
+ }
91
+ if (targets.length === 0) {
92
+ (0, logger_1.logInfo)('Nothing to delete');
93
+ return;
94
+ }
95
+ for (const { name, path: workspacePath, workspace } of targets) {
73
96
  if (workspace?.metadata) {
74
97
  console.log(`${(0, colors_1.colorize)(name, 'cyan')}`);
75
98
  console.log(` Repositories: ${workspace.metadata.repositories.length}`);
@@ -86,9 +109,9 @@ async function handleDelete(opts) {
86
109
  {
87
110
  type: 'confirm',
88
111
  name: 'confirmed',
89
- message: selectedNames.length === 1
90
- ? `Delete workspace ${selectedNames[0]}?`
91
- : `Delete these ${selectedNames.length} workspaces?`,
112
+ message: targets.length === 1
113
+ ? `Delete workspace ${targets[0].name}?`
114
+ : `Delete these ${targets.length} workspaces?`,
92
115
  default: true,
93
116
  },
94
117
  ]);
@@ -97,8 +120,7 @@ async function handleDelete(opts) {
97
120
  process.exit(0);
98
121
  }
99
122
  }
100
- for (const name of selectedNames) {
101
- const workspacePath = path.join(config_1.WORKSPACES_DIR, name);
123
+ for (const { name, path: workspacePath } of targets) {
102
124
  try {
103
125
  await fs.rm(workspacePath, { recursive: true, force: true });
104
126
  (0, logger_1.logSuccess)(`Deleted "${(0, colors_1.colorize)(name, 'cyan')}"`);
@@ -119,44 +141,55 @@ async function handleDelete(opts) {
119
141
  break;
120
142
  }
121
143
  const selectedNames = await (0, prompts_1.promptMultiWorkspaceSelection)(workspaces);
144
+ // Resolve + validate paths once. Names come from disk, but safeWorkspacePath
145
+ // must not throw mid-flow and crash the interactive session, so skip any
146
+ // name that fails the allowlist rather than aborting.
147
+ const resolved = [];
122
148
  for (const name of selectedNames) {
123
- const workspace = workspaces.find(ws => ws.name === name);
124
- const workspacePath = path.join(config_1.WORKSPACES_DIR, name);
125
- if (workspace?.metadata) {
126
- console.log(`${(0, colors_1.colorize)(name, 'cyan')}`);
127
- console.log(` Repositories: ${workspace.metadata.repositories.length}`);
128
- console.log(` Created: ${new Date(workspace.metadata.createdAt).toLocaleString()}`);
129
- console.log(` Path: ${workspacePath}`);
149
+ try {
150
+ resolved.push({ name, path: (0, validation_1.safeWorkspacePath)(name), workspace: workspaces.find(ws => ws.name === name) });
130
151
  }
131
- else {
132
- console.log(`${(0, colors_1.colorize)(name, 'cyan')}`);
133
- console.log(` Path: ${workspacePath}`);
152
+ catch (error) {
153
+ (0, logger_1.logError)(error instanceof Error ? error.message : `Invalid workspace name "${name}"`);
134
154
  }
135
155
  }
136
- console.log('');
137
- (0, logger_1.logWarning)('This will permanently delete all cloned repositories in the selected workspaces!');
138
- const confirmMessage = selectedNames.length === 1
139
- ? `Delete workspace ${selectedNames[0]}?`
140
- : `Delete these ${selectedNames.length} workspaces?`;
141
- const { confirmed } = await inquirer_1.default.prompt([
142
- {
143
- type: 'confirm',
144
- name: 'confirmed',
145
- message: confirmMessage,
146
- default: true,
147
- },
148
- ]);
149
- if (confirmed) {
150
- for (const name of selectedNames) {
151
- const workspacePath = path.join(config_1.WORKSPACES_DIR, name);
152
- try {
153
- await fs.rm(workspacePath, { recursive: true, force: true });
154
- (0, logger_1.logSuccess)(`Deleted "${(0, colors_1.colorize)(name, 'cyan')}"`);
156
+ if (resolved.length > 0) {
157
+ for (const { name, path: workspacePath, workspace } of resolved) {
158
+ if (workspace?.metadata) {
159
+ console.log(`${(0, colors_1.colorize)(name, 'cyan')}`);
160
+ console.log(` Repositories: ${workspace.metadata.repositories.length}`);
161
+ console.log(` Created: ${new Date(workspace.metadata.createdAt).toLocaleString()}`);
162
+ console.log(` Path: ${workspacePath}`);
163
+ }
164
+ else {
165
+ console.log(`${(0, colors_1.colorize)(name, 'cyan')}`);
166
+ console.log(` Path: ${workspacePath}`);
155
167
  }
156
- catch (error) {
157
- (0, logger_1.logError)(`Failed to delete "${name}"`);
158
- if (error instanceof Error)
159
- (0, logger_1.logError)(error.message);
168
+ }
169
+ console.log('');
170
+ (0, logger_1.logWarning)('This will permanently delete all cloned repositories in the selected workspaces!');
171
+ const confirmMessage = resolved.length === 1
172
+ ? `Delete workspace ${resolved[0].name}?`
173
+ : `Delete these ${resolved.length} workspaces?`;
174
+ const { confirmed } = await inquirer_1.default.prompt([
175
+ {
176
+ type: 'confirm',
177
+ name: 'confirmed',
178
+ message: confirmMessage,
179
+ default: true,
180
+ },
181
+ ]);
182
+ if (confirmed) {
183
+ for (const { name, path: workspacePath } of resolved) {
184
+ try {
185
+ await fs.rm(workspacePath, { recursive: true, force: true });
186
+ (0, logger_1.logSuccess)(`Deleted "${(0, colors_1.colorize)(name, 'cyan')}"`);
187
+ }
188
+ catch (error) {
189
+ (0, logger_1.logError)(`Failed to delete "${name}"`);
190
+ if (error instanceof Error)
191
+ (0, logger_1.logError)(error.message);
192
+ }
160
193
  }
161
194
  }
162
195
  }
@@ -70,7 +70,7 @@ function installShellIntegration() {
70
70
  return;
71
71
  }
72
72
  try {
73
- (0, child_process_1.execSync)(`bash "${scriptPath}" ${shellType}`, { stdio: 'inherit' });
73
+ (0, child_process_1.execFileSync)('bash', [scriptPath, shellType], { stdio: 'inherit' });
74
74
  (0, logger_1.logSuccess)('Shell integration installed (auto-CD for w/workspace commands)');
75
75
  }
76
76
  catch {
@@ -294,7 +294,7 @@ async function install() {
294
294
  const serverPath = getMcpServerPath();
295
295
  (0, logger_1.logInfo)(`MCP server path: ${(0, colors_1.colorize)(serverPath, 'cyan')}`);
296
296
  try {
297
- (0, child_process_1.execSync)(`claude mcp add nemus -s user -- node "${serverPath}"`, { stdio: 'pipe' });
297
+ (0, child_process_1.execFileSync)('claude', ['mcp', 'add', 'nemus', '-s', 'user', '--', 'node', serverPath], { stdio: 'pipe' });
298
298
  (0, logger_1.logSuccess)('MCP server registered globally with Claude Code');
299
299
  console.log('\nYou can now use nemus tools in any Claude Code session.');
300
300
  console.log('Try asking: "list my workspaces" or "what\'s the status of my-workspace"');
@@ -38,7 +38,10 @@ const child_process_1 = require("child_process");
38
38
  const util_1 = require("util");
39
39
  const fs = __importStar(require("fs/promises"));
40
40
  const git_status_1 = require("./git-status");
41
- const execAsync = (0, util_1.promisify)(child_process_1.exec);
41
+ // execFile (no shell): git args are passed as an argv array, so a branch name
42
+ // containing shell metacharacters can never be interpreted as a command.
43
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
44
+ const git = (args, opts) => execFileAsync('git', args, opts);
42
45
  const GIT_TIMEOUT = 30000;
43
46
  const switchBranch = async (repoPath, repoName, targetBranch) => {
44
47
  try {
@@ -55,7 +58,7 @@ const switchBranch = async (repoPath, repoName, targetBranch) => {
55
58
  }
56
59
  // Check if it's a git repository
57
60
  try {
58
- await execAsync('git rev-parse --git-dir', { cwd: repoPath });
61
+ await git(['rev-parse', '--git-dir'], { cwd: repoPath });
59
62
  }
60
63
  catch {
61
64
  return {
@@ -65,7 +68,7 @@ const switchBranch = async (repoPath, repoName, targetBranch) => {
65
68
  };
66
69
  }
67
70
  // Get current branch
68
- const { stdout: currentBranchOutput } = await execAsync('git branch --show-current', { cwd: repoPath });
71
+ const { stdout: currentBranchOutput } = await git(['branch', '--show-current'], { cwd: repoPath });
69
72
  const currentBranch = currentBranchOutput.trim();
70
73
  if (currentBranch === targetBranch) {
71
74
  return {
@@ -76,7 +79,7 @@ const switchBranch = async (repoPath, repoName, targetBranch) => {
76
79
  };
77
80
  }
78
81
  // Check for uncommitted changes
79
- const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: repoPath });
82
+ const { stdout: statusOutput } = await git(['status', '--porcelain'], { cwd: repoPath });
80
83
  if (statusOutput.trim().length > 0) {
81
84
  return {
82
85
  repo: repoName,
@@ -86,17 +89,17 @@ const switchBranch = async (repoPath, repoName, targetBranch) => {
86
89
  };
87
90
  }
88
91
  // Fetch to ensure we have latest branches
89
- await execAsync('git fetch', { cwd: repoPath, timeout: GIT_TIMEOUT });
92
+ await git(['fetch'], { cwd: repoPath, timeout: GIT_TIMEOUT });
90
93
  // Check if branch exists locally
91
94
  try {
92
- await execAsync(`git rev-parse --verify ${targetBranch}`, { cwd: repoPath });
95
+ await git(['rev-parse', '--verify', targetBranch], { cwd: repoPath });
93
96
  }
94
97
  catch {
95
98
  // Branch doesn't exist locally, check remote
96
99
  try {
97
- await execAsync(`git rev-parse --verify origin/${targetBranch}`, { cwd: repoPath });
100
+ await git(['rev-parse', '--verify', `origin/${targetBranch}`], { cwd: repoPath });
98
101
  // Branch exists on remote, create local tracking branch
99
- await execAsync(`git checkout -b ${targetBranch} origin/${targetBranch}`, { cwd: repoPath });
102
+ await git(['checkout', '-b', targetBranch, `origin/${targetBranch}`], { cwd: repoPath });
100
103
  return {
101
104
  repo: repoName,
102
105
  status: 'success',
@@ -114,7 +117,7 @@ const switchBranch = async (repoPath, repoName, targetBranch) => {
114
117
  }
115
118
  }
116
119
  // Switch to existing local branch
117
- await execAsync(`git checkout ${targetBranch}`, { cwd: repoPath });
120
+ await git(['checkout', targetBranch], { cwd: repoPath });
118
121
  return {
119
122
  repo: repoName,
120
123
  status: 'success',
@@ -147,16 +150,10 @@ const createBranch = async (repoPath, repoName, branchName, baseBranch, force) =
147
150
  }
148
151
  // Checkout base branch if specified
149
152
  if (baseBranch) {
150
- await execAsync(`git checkout ${baseBranch}`, {
151
- cwd: repoPath,
152
- timeout: GIT_TIMEOUT,
153
- });
153
+ await git(['checkout', baseBranch], { cwd: repoPath, timeout: GIT_TIMEOUT });
154
154
  }
155
155
  // Create and checkout new branch
156
- await execAsync(`git checkout -b ${branchName}`, {
157
- cwd: repoPath,
158
- timeout: GIT_TIMEOUT,
159
- });
156
+ await git(['checkout', '-b', branchName], { cwd: repoPath, timeout: GIT_TIMEOUT });
160
157
  return {
161
158
  repo: repoName,
162
159
  success: true,
@@ -176,22 +173,16 @@ exports.createBranch = createBranch;
176
173
  const mergeBranch = async (repoPath, repoName, sourceBranch, targetBranch, options) => {
177
174
  try {
178
175
  // Checkout target branch
179
- await execAsync(`git checkout ${targetBranch}`, {
180
- cwd: repoPath,
181
- timeout: GIT_TIMEOUT,
182
- });
183
- // Build merge command
184
- let mergeCmd = `git merge ${sourceBranch}`;
176
+ await git(['checkout', targetBranch], { cwd: repoPath, timeout: GIT_TIMEOUT });
177
+ // Build merge argv
178
+ const mergeArgs = ['merge', sourceBranch];
185
179
  if (options?.noFf)
186
- mergeCmd += ' --no-ff';
180
+ mergeArgs.push('--no-ff');
187
181
  if (options?.ffOnly)
188
- mergeCmd += ' --ff-only';
182
+ mergeArgs.push('--ff-only');
189
183
  if (options?.squash)
190
- mergeCmd += ' --squash';
191
- await execAsync(mergeCmd, {
192
- cwd: repoPath,
193
- timeout: GIT_TIMEOUT,
194
- });
184
+ mergeArgs.push('--squash');
185
+ await git(mergeArgs, { cwd: repoPath, timeout: GIT_TIMEOUT });
195
186
  return {
196
187
  repo: repoName,
197
188
  success: true,
@@ -210,10 +201,7 @@ const mergeBranch = async (repoPath, repoName, sourceBranch, targetBranch, optio
210
201
  exports.mergeBranch = mergeBranch;
211
202
  const rebaseBranch = async (repoPath, repoName, targetBranch) => {
212
203
  try {
213
- await execAsync(`git rebase ${targetBranch}`, {
214
- cwd: repoPath,
215
- timeout: GIT_TIMEOUT,
216
- });
204
+ await git(['rebase', targetBranch], { cwd: repoPath, timeout: GIT_TIMEOUT });
217
205
  return {
218
206
  repo: repoName,
219
207
  success: true,
@@ -38,10 +38,10 @@ const child_process_1 = require("child_process");
38
38
  const util_1 = require("util");
39
39
  const fs = __importStar(require("fs/promises"));
40
40
  const path = __importStar(require("path"));
41
- const execAsync = (0, util_1.promisify)(child_process_1.exec);
41
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
42
42
  const calculateDirSize = async (dirPath) => {
43
43
  try {
44
- const { stdout } = await execAsync(`du -sk "${dirPath}"`);
44
+ const { stdout } = await execFileAsync('du', ['-sk', dirPath]);
45
45
  const sizeInKB = parseInt(stdout.split('\t')[0], 10);
46
46
  return sizeInKB;
47
47
  }
@@ -98,9 +98,9 @@ const removeBuildArtifacts = async (repoPath) => {
98
98
  exports.removeBuildArtifacts = removeBuildArtifacts;
99
99
  const gitClean = async (repoPath, dryRun = false) => {
100
100
  try {
101
- const cmd = dryRun ? 'git clean -fdxn' : 'git clean -fdx';
102
- const { stdout } = await execAsync(cmd, { cwd: repoPath });
103
- const lines = stdout.split('\n').filter(l => l.trim());
101
+ const args = dryRun ? ['clean', '-fdxn'] : ['clean', '-fdx'];
102
+ const { stdout } = await execFileAsync('git', args, { cwd: repoPath });
103
+ const lines = stdout.split('\n').filter((l) => l.trim());
104
104
  return {
105
105
  operation: 'Git clean',
106
106
  filesRemoved: lines.length,