zapdev 1.3.0 → 1.5.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.
Files changed (3) hide show
  1. package/README.md +26 -8
  2. package/dist/cli.js +151 -46
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,3 +1,12 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/mvagnon/zapdev/main/.github/assets/preview.gif" alt="zapdev CLI demonstration">
3
+ </p>
4
+
5
+ ---
6
+
7
+ [![portfolio](https://img.shields.io/website?url=https%3A%2F%2Fmvagnon.dev&up_message=Visit&label=Portfolio&color=%23007fff)](https://mvagnon.dev)
8
+ [![bymeacoffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://buymeacoffee.com/mvagnon)
9
+
1
10
  # zapdev
2
11
 
3
12
  ## Project Introduction
@@ -28,13 +37,14 @@ flowchart LR
28
37
 
29
38
  ## Environment Variables
30
39
 
31
- | Variable | Required | Description |
32
- | -------- | -------- | ----------- |
33
- | `ZD_URL` | For `commit` | Complete HTTP(S) Chat Completions endpoint, including its path |
34
- | `ZD_MODEL` | For `commit` | Model identifier supported by the endpoint |
35
- | `ZD_EFFORT` | For `commit` | Sent as `reasoning_effort`; use a value supported by your model, such as `low`, `medium`, or `high` |
40
+ | Variable | Required | Description |
41
+ | ----------------- | ------------ | --------------------------------------------------------------------------------------------------- |
42
+ | `ZD_URL` | For `commit` | Complete HTTP(S) Chat Completions endpoint, including its path |
43
+ | `ZD_MODEL` | For `commit` | Model identifier supported by the endpoint |
44
+ | `ZD_EFFORT` | For `commit` | Sent as `reasoning_effort`; use a value supported by your model, such as `low`, `medium`, or `high` |
45
+ | `ZD_HOOK_TIMEOUT` | No | Deadline per Git hook in seconds (default: `60`); positive numbers from `0.001` to `2147483.647` |
36
46
 
37
- There are no defaults, automatic provider detection, or backup models. CLI flags override these variables. Legacy `OLLAMA_*` variables are no longer read.
47
+ The LLM settings have no defaults, automatic provider detection, or backup models. CLI flags override these settings. Legacy `OLLAMA_*` variables are no longer read.
38
48
 
39
49
  Requests use the OpenAI Chat Completions format over plain `fetch`, without a provider SDK. No authentication headers are sent; use an endpoint that does not require them. The endpoint and model must support `reasoning_effort`.
40
50
 
@@ -55,6 +65,12 @@ Install zapdev globally for daily use:
55
65
  npm install -g zapdev
56
66
  ```
57
67
 
68
+ or
69
+
70
+ ```bash
71
+ bun install -g zapdev
72
+ ```
73
+
58
74
  Configure your endpoint and model before running `commit` (replace these example values):
59
75
 
60
76
  ```bash
@@ -106,8 +122,8 @@ zapdev commit
106
122
  | Flag | Description |
107
123
  | ------------------- | ----------------------------------------------------------------- |
108
124
  | `--url <url>` | Override the complete Chat Completions endpoint |
109
- | `--model <model>` | Override the model |
110
- | `--effort <effort>` | Override the reasoning effort |
125
+ | `--model <model>` | Override the model |
126
+ | `--effort <effort>` | Override the reasoning effort |
111
127
  | `-t, --type <type>` | Force the Conventional Commit type (`feat`, `fix`, `chore`, etc.) |
112
128
  | `-p, --push` | Push after committing without asking |
113
129
  | `-s, --staged` | Commit only changes that are already staged |
@@ -124,6 +140,8 @@ Before contacting the LLM endpoint, zapdev runs `gitleaks git --staged --verbose
124
140
 
125
141
  Failures are reported per repository while the others continue. Any failure produces a nonzero exit code.
126
142
 
143
+ In a terminal, Git and its hooks display live logs and support native prompts, even with `--yes` (which only skips zapdev prompts). Git controls hook stdin; interactive hooks should read from the terminal, for example `/dev/tty`. Each hook has a 60-second deadline, including time spent answering prompts; override it with `ZD_HOOK_TIMEOUT=120 zapdev commit`.
144
+
127
145
  Pushing is optimistic, with no preliminary fetch. If the branch is behind upstream, `--rebase` runs `git pull --rebase`, while `--merge` runs `git pull --no-rebase --no-edit`; zapdev then retries once. Without either flag, interactive runs ask whether to rebase, merge, or quit. Runs using `--yes` or without a TTY must provide one of the flags.
128
146
 
129
147
  Without a TTY, zapdev commits all prepared repositories automatically and only pushes when `--push` is set.
package/dist/cli.js CHANGED
@@ -554,7 +554,7 @@ function _getBuiltinFlags(long, short, userNames, userAliases) {
554
554
  // package.json
555
555
  var package_default = {
556
556
  name: "zapdev",
557
- version: "1.3.0",
557
+ version: "1.5.0",
558
558
  description: "Fast, performant and lightweight git chores and dev tools using your terminal and an OpenAI-compatible LLM endpoint.",
559
559
  type: "module",
560
560
  bin: {
@@ -1815,6 +1815,13 @@ function sanitizeCommitMessage(raw) {
1815
1815
  }
1816
1816
 
1817
1817
  // src/lib/config.ts
1818
+ function resolveHookTimeout(env = process.env) {
1819
+ const timeout = Number(env.ZD_HOOK_TIMEOUT ?? 60) * 1e3;
1820
+ if (!Number.isFinite(timeout) || timeout < 1 || timeout > 2147483647) {
1821
+ throw new Error("ZD_HOOK_TIMEOUT must be a number of seconds between 0.001 and 2147483.647.");
1822
+ }
1823
+ return timeout;
1824
+ }
1818
1825
  function resolveConfig(env = process.env, overrides = {}) {
1819
1826
  const config = {
1820
1827
  url: (overrides.url ?? env.ZD_URL)?.trim() ?? "",
@@ -1837,8 +1844,11 @@ function resolveConfig(env = process.env, overrides = {}) {
1837
1844
  }
1838
1845
 
1839
1846
  // src/lib/git.ts
1847
+ import { execFileSync } from "node:child_process";
1840
1848
  import { readdir } from "node:fs/promises";
1841
1849
  import { join } from "node:path";
1850
+ import { createInterface as createInterface2 } from "node:readline";
1851
+ import { Readable } from "node:stream";
1842
1852
 
1843
1853
  // node_modules/tinyexec/dist/main.mjs
1844
1854
  import { spawn as e, spawnSync as t2 } from "node:child_process";
@@ -2166,14 +2176,105 @@ var R2 = (e2, t3, n4) => {
2166
2176
  };
2167
2177
  var z = R2;
2168
2178
 
2179
+ // src/lib/git-hooks.ts
2180
+ function createHookReporter(onHook, onTimeout, timeout) {
2181
+ const hooks = /* @__PURE__ */ new Map();
2182
+ const report = (line) => {
2183
+ let event;
2184
+ try {
2185
+ const value = JSON.parse(line);
2186
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
2187
+ event = value;
2188
+ } catch {
2189
+ return;
2190
+ }
2191
+ if (typeof event.sid !== "string" || typeof event.child_id !== "number") return;
2192
+ const key = `${event.sid}:${event.child_id}`;
2193
+ if (event.event === "child_start" && event.child_class === "hook" && typeof event.hook_name === "string") {
2194
+ const name = event.hook_name;
2195
+ hooks.set(key, { name, timer: setTimeout(() => onTimeout(name), timeout) });
2196
+ onHook?.({ name, phase: "start" });
2197
+ } else if (event.event === "child_exit" && typeof event.code === "number") {
2198
+ const hook = hooks.get(key);
2199
+ if (!hook) return;
2200
+ clearTimeout(hook.timer);
2201
+ hooks.delete(key);
2202
+ onHook?.({ name: hook.name, phase: "exit", exitCode: event.code });
2203
+ }
2204
+ };
2205
+ return {
2206
+ report,
2207
+ close() {
2208
+ for (const { timer } of hooks.values()) clearTimeout(timer);
2209
+ hooks.clear();
2210
+ }
2211
+ };
2212
+ }
2213
+
2169
2214
  // src/lib/git.ts
2170
2215
  var UPSTREAM_REF = "@{upstream}";
2171
- async function git(args, cwd) {
2172
- const result = await R2("git", args, { nodeOptions: { cwd } });
2173
- if (result.exitCode !== 0) {
2174
- throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`);
2216
+ async function git(args, cwd, onHook) {
2217
+ const timeout = resolveHookTimeout();
2218
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && ["commit", "push", "pull", "fetch"].includes(args[0] ?? ""));
2219
+ const terminalState = interactive && process.platform !== "win32" ? execFileSync("stty", ["-g"], { stdio: ["inherit", "pipe", "ignore"], encoding: "utf8" }).trim() : void 0;
2220
+ const child = R2("git", args, {
2221
+ nodeOptions: {
2222
+ cwd,
2223
+ detached: !interactive && process.platform !== "win32",
2224
+ env: { ...process.env, GIT_TRACE2_EVENT: "3" },
2225
+ stdio: interactive ? ["inherit", "inherit", "inherit", "pipe"] : ["pipe", "pipe", "pipe", "pipe"]
2226
+ }
2227
+ });
2228
+ let timeoutError;
2229
+ const { report, close } = createHookReporter(onHook, (name) => {
2230
+ if (timeoutError || child.pid === void 0) return;
2231
+ timeoutError = new Error(`${name} hook timed out after ${timeout / 1e3} seconds`);
2232
+ try {
2233
+ if (process.platform === "win32") {
2234
+ execFileSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
2235
+ } else if (interactive) {
2236
+ killProcessTree(child.pid);
2237
+ } else {
2238
+ process.kill(-child.pid, "SIGKILL");
2239
+ }
2240
+ } catch {
2241
+ child.kill("SIGKILL");
2242
+ }
2243
+ }, timeout);
2244
+ const trace = child.process?.stdio[3];
2245
+ const reader = trace instanceof Readable ? createInterface2({ input: trace }).on("line", report) : void 0;
2246
+ try {
2247
+ const result = await child;
2248
+ if (timeoutError) throw timeoutError;
2249
+ if (result.exitCode !== 0) {
2250
+ throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`);
2251
+ }
2252
+ return result.stdout;
2253
+ } finally {
2254
+ reader?.close();
2255
+ close();
2256
+ if (terminalState) execFileSync("stty", [terminalState], { stdio: ["inherit", "ignore", "ignore"] });
2257
+ }
2258
+ }
2259
+ function killProcessTree(pid) {
2260
+ const children = /* @__PURE__ */ new Map();
2261
+ const output = execFileSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" });
2262
+ for (const line of output.trim().split("\n")) {
2263
+ const [child, parent] = line.trim().split(/\s+/).map(Number);
2264
+ if (child === void 0 || parent === void 0) continue;
2265
+ const siblings = children.get(parent) ?? [];
2266
+ siblings.push(child);
2267
+ children.set(parent, siblings);
2268
+ }
2269
+ const pids = [pid];
2270
+ for (const parent of pids) pids.push(...children.get(parent) ?? []);
2271
+ for (const child of pids.reverse()) {
2272
+ try {
2273
+ process.kill(child, "SIGKILL");
2274
+ } catch (error) {
2275
+ if (error.code !== "ESRCH") throw error;
2276
+ }
2175
2277
  }
2176
- return result.stdout;
2177
2278
  }
2178
2279
  async function tryGit(args, cwd) {
2179
2280
  const result = await R2("git", args, { nodeOptions: { cwd } });
@@ -2185,8 +2286,8 @@ async function stageAll(repo) {
2185
2286
  async function getStagedDiff(repo) {
2186
2287
  return git(["diff", "--cached"], repo);
2187
2288
  }
2188
- async function commit(repo, message) {
2189
- await git(["commit", "-m", message], repo);
2289
+ async function commit(repo, message, onHook) {
2290
+ await git(["commit", "-m", message], repo, onHook);
2190
2291
  }
2191
2292
  async function currentBranch(repo) {
2192
2293
  return (await git(["symbolic-ref", "--short", "HEAD"], repo)).trim();
@@ -2203,20 +2304,20 @@ async function hasUpstream(repo) {
2203
2304
  const result = await tryGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", UPSTREAM_REF], repo);
2204
2305
  return result !== null;
2205
2306
  }
2206
- async function push(repo) {
2207
- await git(["push"], repo);
2307
+ async function push(repo, onHook) {
2308
+ await git(["push"], repo, onHook);
2208
2309
  }
2209
- async function pushSetUpstream(repo, branch) {
2210
- await git(["push", "-u", "origin", branch], repo);
2310
+ async function pushSetUpstream(repo, branch, onHook) {
2311
+ await git(["push", "-u", "origin", branch], repo, onHook);
2211
2312
  }
2212
- async function pullRebase(repo) {
2213
- await git(["pull", "--rebase"], repo);
2313
+ async function pullRebase(repo, onHook) {
2314
+ await git(["pull", "--rebase"], repo, onHook);
2214
2315
  }
2215
- async function pullMerge(repo) {
2216
- await git(["pull", "--no-rebase", "--no-edit"], repo);
2316
+ async function pullMerge(repo, onHook) {
2317
+ await git(["pull", "--no-rebase", "--no-edit"], repo, onHook);
2217
2318
  }
2218
- async function fetchRemote(repo) {
2219
- await git(["fetch"], repo);
2319
+ async function fetchRemote(repo, onHook) {
2320
+ await git(["fetch"], repo, onHook);
2220
2321
  }
2221
2322
  async function behindCount(repo) {
2222
2323
  const out = await tryGit(["rev-list", "--count", `HEAD..${UPSTREAM_REF}`], repo);
@@ -2403,6 +2504,7 @@ var commitCommand = defineCommand({
2403
2504
  }
2404
2505
  let config;
2405
2506
  try {
2507
+ resolveHookTimeout();
2406
2508
  config = resolveConfig(process.env, {
2407
2509
  url: args.url,
2408
2510
  model: args.model,
@@ -2468,8 +2570,9 @@ var commitCommand = defineCommand({
2468
2570
  }
2469
2571
  const committed = [];
2470
2572
  for (const draft of selected) {
2573
+ log.info(`${draft.label}: committing`);
2471
2574
  try {
2472
- await commit(draft.repo, draft.message);
2575
+ await commit(draft.repo, draft.message, reportHooks(draft.label));
2473
2576
  committed.push(draft);
2474
2577
  log.success(`${draft.label}: committed ${draft.message}`);
2475
2578
  } catch (error) {
@@ -2495,7 +2598,6 @@ var commitCommand = defineCommand({
2495
2598
  try {
2496
2599
  const pushed = await pushOptimistic(
2497
2600
  repository,
2498
- interactive,
2499
2601
  interactive && !args.yes,
2500
2602
  syncStrategy
2501
2603
  );
@@ -2509,6 +2611,17 @@ var commitCommand = defineCommand({
2509
2611
  if (interactive) outro(process.exitCode ? "Finished with errors." : "Done.");
2510
2612
  }
2511
2613
  });
2614
+ function reportHooks(label) {
2615
+ return (event) => {
2616
+ const hook = `${label}: ${event.name}`;
2617
+ if (event.phase === "start") {
2618
+ log.info(`${hook} running`);
2619
+ } else {
2620
+ if (event.exitCode === 0) log.success(`${hook} \u2713`);
2621
+ else log.error(`${hook} \u2717 (exit ${event.exitCode})`);
2622
+ }
2623
+ };
2624
+ }
2512
2625
  async function reviewMessages(drafts, canPrompt) {
2513
2626
  while (true) {
2514
2627
  for (const draft of drafts) log.message(`${draft.pendingLabel}: ${draft.message}`);
@@ -2541,30 +2654,28 @@ async function reviewMessages(drafts, canPrompt) {
2541
2654
  action.draft.message = edited.trim();
2542
2655
  }
2543
2656
  }
2544
- async function syncWithUpstream({ repo, label }, strategy, interactive) {
2545
- const loader = interactive ? spinner() : void 0;
2657
+ async function syncWithUpstream({ repo, label }, strategy) {
2546
2658
  const actionLabel = strategy === "rebase" ? "Rebase" : "Merge";
2547
- loader?.start(`${label}: pulling --${strategy === "rebase" ? "rebase" : "no-rebase"}`);
2659
+ log.info(`${label}: pulling --${strategy === "rebase" ? "rebase" : "no-rebase"}`);
2548
2660
  try {
2549
- await (strategy === "rebase" ? pullRebase(repo) : pullMerge(repo));
2550
- loader?.stop(strategy === "rebase" ? "\u2713 Rebased on upstream" : "\u2713 Merged upstream");
2661
+ await (strategy === "rebase" ? pullRebase : pullMerge)(repo, reportHooks(label));
2662
+ log.success(`${label}: ${strategy === "rebase" ? "rebased on upstream" : "merged upstream"}`);
2551
2663
  return true;
2552
2664
  } catch (error) {
2553
- loader?.error(`${actionLabel} failed`);
2554
2665
  log.error(`${label}: ${actionLabel} failed (resolve conflicts, then push): ${errorMessage(error)}`);
2555
2666
  return false;
2556
2667
  }
2557
2668
  }
2558
- async function pushOptimistic(repository, interactive, canPrompt, strategy) {
2669
+ async function pushOptimistic(repository, canPrompt, strategy) {
2559
2670
  const { repo, label } = repository;
2560
2671
  const [upstream, branch] = await Promise.all([hasUpstream(repo), currentBranch(repo)]);
2561
- const doPush = () => upstream ? push(repo) : pushSetUpstream(repo, branch);
2562
- const first = await tryPush(repository, interactive, doPush);
2672
+ const doPush = (onHook) => upstream ? push(repo, onHook) : pushSetUpstream(repo, branch, onHook);
2673
+ const first = await tryPush(repository, doPush);
2563
2674
  if (first.ok) return true;
2564
- if (upstream && await isBehind(repository, interactive)) {
2675
+ if (upstream && await isBehind(repository)) {
2565
2676
  const syncStrategy = strategy ?? await chooseSyncStrategy(repository, canPrompt);
2566
- if (!syncStrategy || !await syncWithUpstream(repository, syncStrategy, interactive)) return false;
2567
- const retry = await tryPush(repository, interactive, doPush);
2677
+ if (!syncStrategy || !await syncWithUpstream(repository, syncStrategy)) return false;
2678
+ const retry = await tryPush(repository, doPush);
2568
2679
  if (retry.ok) return true;
2569
2680
  log.error(`${label}: push failed: ${errorMessage(retry.error)}`);
2570
2681
  return false;
@@ -2591,30 +2702,24 @@ async function chooseSyncStrategy({ label }, interactive) {
2591
2702
  }
2592
2703
  return action;
2593
2704
  }
2594
- async function tryPush({ label }, interactive, doPush) {
2595
- const loader = interactive ? spinner() : void 0;
2596
- loader?.start(`${label}: pushing`);
2705
+ async function tryPush({ label }, doPush) {
2706
+ log.info(`${label}: pushing`);
2597
2707
  try {
2598
- await doPush();
2599
- loader?.stop("\u2713 Pushed");
2708
+ await doPush(reportHooks(label));
2709
+ log.success(`${label}: pushed`);
2600
2710
  return { ok: true };
2601
2711
  } catch (error) {
2602
- loader?.error("Push failed");
2603
2712
  return { ok: false, error };
2604
2713
  }
2605
2714
  }
2606
- async function isBehind({ repo, label }, interactive) {
2607
- const loader = interactive ? spinner() : void 0;
2608
- loader?.start(`${label}: checking upstream`);
2715
+ async function isBehind({ repo, label }) {
2716
+ log.info(`${label}: checking upstream`);
2609
2717
  try {
2610
- await fetchRemote(repo);
2718
+ await fetchRemote(repo, reportHooks(label));
2611
2719
  const behind = await behindCount(repo);
2612
- loader?.stop(
2613
- behind > 0 ? `Behind upstream by ${behind} commit${behind > 1 ? "s" : ""}` : "Up to date with upstream"
2614
- );
2720
+ log.info(`${label}: ${behind > 0 ? `Behind upstream by ${behind} commit${behind > 1 ? "s" : ""}` : "Up to date with upstream"}`);
2615
2721
  return behind > 0;
2616
2722
  } catch (error) {
2617
- loader?.error("Could not check upstream");
2618
2723
  log.warn(`${label}: could not check upstream: ${errorMessage(error)}`);
2619
2724
  return false;
2620
2725
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapdev",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Fast, performant and lightweight git chores and dev tools using your terminal and an OpenAI-compatible LLM endpoint.",
5
5
  "type": "module",
6
6
  "bin": {