zapdev 1.4.0 → 1.6.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 +9 -6
  2. package/dist/cli.js +141 -75
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -37,13 +37,14 @@ flowchart LR
37
37
 
38
38
  ## Environment Variables
39
39
 
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` |
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` |
45
46
 
46
- 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.
47
48
 
48
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`.
49
50
 
@@ -139,6 +140,8 @@ Before contacting the LLM endpoint, zapdev runs `gitleaks git --staged --verbose
139
140
 
140
141
  Failures are reported per repository while the others continue. Any failure produces a nonzero exit code.
141
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
+
142
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.
143
146
 
144
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.4.0",
557
+ version: "1.6.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,6 +1844,7 @@ 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";
1842
1850
  import { createInterface as createInterface2 } from "node:readline";
@@ -2168,10 +2176,22 @@ var R2 = (e2, t3, n4) => {
2168
2176
  };
2169
2177
  var z = R2;
2170
2178
 
2179
+ // src/lib/errors.ts
2180
+ var GitOutputError = class extends Error {
2181
+ constructor(message, hookFailureReported = false) {
2182
+ super(message);
2183
+ this.hookFailureReported = hookFailureReported;
2184
+ }
2185
+ hookFailureReported;
2186
+ };
2187
+ function errorMessage(error) {
2188
+ return error instanceof Error ? error.message : String(error);
2189
+ }
2190
+
2171
2191
  // src/lib/git-hooks.ts
2172
- function createHookReporter(onHook) {
2192
+ function createHookReporter(onHook, onTimeout, timeout) {
2173
2193
  const hooks = /* @__PURE__ */ new Map();
2174
- return (line) => {
2194
+ const report = (line) => {
2175
2195
  let event;
2176
2196
  try {
2177
2197
  const value = JSON.parse(line);
@@ -2183,13 +2203,22 @@ function createHookReporter(onHook) {
2183
2203
  if (typeof event.sid !== "string" || typeof event.child_id !== "number") return;
2184
2204
  const key = `${event.sid}:${event.child_id}`;
2185
2205
  if (event.event === "child_start" && event.child_class === "hook" && typeof event.hook_name === "string") {
2186
- hooks.set(key, event.hook_name);
2187
- onHook({ name: event.hook_name, phase: "start" });
2206
+ const name = event.hook_name;
2207
+ hooks.set(key, { name, timer: setTimeout(() => onTimeout(name), timeout) });
2208
+ onHook?.({ name, phase: "start" });
2188
2209
  } else if (event.event === "child_exit" && typeof event.code === "number") {
2189
- const name = hooks.get(key);
2190
- if (name === void 0) return;
2210
+ const hook = hooks.get(key);
2211
+ if (!hook) return;
2212
+ clearTimeout(hook.timer);
2191
2213
  hooks.delete(key);
2192
- onHook({ name, phase: "exit", exitCode: event.code });
2214
+ onHook?.({ name: hook.name, phase: "exit", exitCode: event.code });
2215
+ }
2216
+ };
2217
+ return {
2218
+ report,
2219
+ close() {
2220
+ for (const { timer } of hooks.values()) clearTimeout(timer);
2221
+ hooks.clear();
2193
2222
  }
2194
2223
  };
2195
2224
  }
@@ -2197,18 +2226,73 @@ function createHookReporter(onHook) {
2197
2226
  // src/lib/git.ts
2198
2227
  var UPSTREAM_REF = "@{upstream}";
2199
2228
  async function git(args, cwd, onHook) {
2229
+ const timeout = resolveHookTimeout();
2230
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && ["commit", "push", "pull", "fetch"].includes(args[0] ?? ""));
2231
+ const terminalState = interactive && process.platform !== "win32" ? execFileSync("stty", ["-g"], { stdio: ["inherit", "pipe", "ignore"], encoding: "utf8" }).trim() : void 0;
2200
2232
  const child = R2("git", args, {
2201
- nodeOptions: onHook ? { cwd, env: { ...process.env, GIT_TRACE2_EVENT: "3" }, stdio: ["pipe", "pipe", "pipe", "pipe"] } : { cwd }
2233
+ nodeOptions: {
2234
+ cwd,
2235
+ detached: !interactive && process.platform !== "win32",
2236
+ env: { ...process.env, GIT_TRACE2_EVENT: "3" },
2237
+ stdio: interactive ? ["inherit", "inherit", "inherit", "pipe"] : ["pipe", "pipe", "pipe", "pipe"]
2238
+ }
2202
2239
  });
2240
+ let timeoutError;
2241
+ let hookFailureReported = false;
2242
+ const { report, close } = createHookReporter((event) => {
2243
+ if (onHook && event.phase === "exit" && event.exitCode !== 0) hookFailureReported = true;
2244
+ onHook?.(event);
2245
+ }, (name) => {
2246
+ if (timeoutError || child.pid === void 0) return;
2247
+ timeoutError = new Error(`${name} hook timed out after ${timeout / 1e3} seconds`);
2248
+ try {
2249
+ if (process.platform === "win32") {
2250
+ execFileSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
2251
+ } else if (interactive) {
2252
+ killProcessTree(child.pid);
2253
+ } else {
2254
+ process.kill(-child.pid, "SIGKILL");
2255
+ }
2256
+ } catch {
2257
+ child.kill("SIGKILL");
2258
+ }
2259
+ }, timeout);
2203
2260
  const trace = child.process?.stdio[3];
2204
- if (onHook && trace instanceof Readable) {
2205
- createInterface2({ input: trace }).on("line", createHookReporter(onHook));
2206
- }
2207
- const result = await child;
2208
- if (result.exitCode !== 0) {
2209
- throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`);
2261
+ const reader = trace instanceof Readable ? createInterface2({ input: trace }).on("line", report) : void 0;
2262
+ try {
2263
+ const result = await child;
2264
+ if (timeoutError) throw timeoutError;
2265
+ if (result.exitCode !== 0) {
2266
+ const message = result.stderr.trim() || `git ${args.join(" ")} failed`;
2267
+ if (interactive) throw new GitOutputError(message, hookFailureReported);
2268
+ throw new Error(message);
2269
+ }
2270
+ return result.stdout;
2271
+ } finally {
2272
+ reader?.close();
2273
+ close();
2274
+ if (terminalState) execFileSync("stty", [terminalState], { stdio: ["inherit", "ignore", "ignore"] });
2275
+ }
2276
+ }
2277
+ function killProcessTree(pid) {
2278
+ const children = /* @__PURE__ */ new Map();
2279
+ const output = execFileSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" });
2280
+ for (const line of output.trim().split("\n")) {
2281
+ const [child, parent] = line.trim().split(/\s+/).map(Number);
2282
+ if (child === void 0 || parent === void 0) continue;
2283
+ const siblings = children.get(parent) ?? [];
2284
+ siblings.push(child);
2285
+ children.set(parent, siblings);
2286
+ }
2287
+ const pids = [pid];
2288
+ for (const parent of pids) pids.push(...children.get(parent) ?? []);
2289
+ for (const child of pids.reverse()) {
2290
+ try {
2291
+ process.kill(child, "SIGKILL");
2292
+ } catch (error) {
2293
+ if (error.code !== "ESRCH") throw error;
2294
+ }
2210
2295
  }
2211
- return result.stdout;
2212
2296
  }
2213
2297
  async function tryGit(args, cwd) {
2214
2298
  const result = await R2("git", args, { nodeOptions: { cwd } });
@@ -2221,7 +2305,7 @@ async function getStagedDiff(repo) {
2221
2305
  return git(["diff", "--cached"], repo);
2222
2306
  }
2223
2307
  async function commit(repo, message, onHook) {
2224
- await git(["commit", "-m", message], repo, onHook);
2308
+ await git(["commit", "--quiet", "-m", message], repo, onHook);
2225
2309
  }
2226
2310
  async function currentBranch(repo) {
2227
2311
  return (await git(["symbolic-ref", "--short", "HEAD"], repo)).trim();
@@ -2239,19 +2323,19 @@ async function hasUpstream(repo) {
2239
2323
  return result !== null;
2240
2324
  }
2241
2325
  async function push(repo, onHook) {
2242
- await git(["push"], repo, onHook);
2326
+ await git(["push", "--quiet"], repo, onHook);
2243
2327
  }
2244
2328
  async function pushSetUpstream(repo, branch, onHook) {
2245
- await git(["push", "-u", "origin", branch], repo, onHook);
2329
+ await git(["push", "--quiet", "-u", "origin", branch], repo, onHook);
2246
2330
  }
2247
2331
  async function pullRebase(repo, onHook) {
2248
- await git(["pull", "--rebase"], repo, onHook);
2332
+ await git(["pull", "--quiet", "--rebase"], repo, onHook);
2249
2333
  }
2250
2334
  async function pullMerge(repo, onHook) {
2251
- await git(["pull", "--no-rebase", "--no-edit"], repo, onHook);
2335
+ await git(["pull", "--quiet", "--no-rebase", "--no-edit"], repo, onHook);
2252
2336
  }
2253
2337
  async function fetchRemote(repo, onHook) {
2254
- await git(["fetch"], repo, onHook);
2338
+ await git(["fetch", "--quiet"], repo, onHook);
2255
2339
  }
2256
2340
  async function behindCount(repo) {
2257
2341
  const out = await tryGit(["rev-list", "--count", `HEAD..${UPSTREAM_REF}`], repo);
@@ -2270,11 +2354,6 @@ async function repoRoot(dir) {
2270
2354
  return (await tryGit(["rev-parse", "--show-toplevel"], dir))?.trim() ?? null;
2271
2355
  }
2272
2356
 
2273
- // src/lib/errors.ts
2274
- function errorMessage(error) {
2275
- return error instanceof Error ? error.message : String(error);
2276
- }
2277
-
2278
2357
  // src/lib/gitleaks.ts
2279
2358
  async function hasGitleaks() {
2280
2359
  try {
@@ -2438,6 +2517,7 @@ var commitCommand = defineCommand({
2438
2517
  }
2439
2518
  let config;
2440
2519
  try {
2520
+ resolveHookTimeout();
2441
2521
  config = resolveConfig(process.env, {
2442
2522
  url: args.url,
2443
2523
  model: args.model,
@@ -2503,17 +2583,13 @@ var commitCommand = defineCommand({
2503
2583
  }
2504
2584
  const committed = [];
2505
2585
  for (const draft of selected) {
2506
- const loader2 = interactive ? spinner() : void 0;
2507
- const message = `${draft.label}: committing`;
2508
- loader2?.start(message);
2586
+ log.info(`${draft.label}: committing`);
2509
2587
  try {
2510
- await commit(draft.repo, draft.message, reportHooks(draft.label, loader2, message));
2511
- loader2?.clear();
2588
+ await commit(draft.repo, draft.message, reportHooks(draft.label));
2512
2589
  committed.push(draft);
2513
2590
  log.success(`${draft.label}: committed ${draft.message}`);
2514
2591
  } catch (error) {
2515
- loader2?.clear();
2516
- log.error(`${draft.pendingLabel}: commit failed: ${errorMessage(error)}`);
2592
+ reportGitFailure(`${draft.pendingLabel}: commit failed`, error);
2517
2593
  process.exitCode = 1;
2518
2594
  }
2519
2595
  }
@@ -2535,13 +2611,12 @@ var commitCommand = defineCommand({
2535
2611
  try {
2536
2612
  const pushed = await pushOptimistic(
2537
2613
  repository,
2538
- interactive,
2539
2614
  interactive && !args.yes,
2540
2615
  syncStrategy
2541
2616
  );
2542
2617
  if (!pushed) process.exitCode = 1;
2543
2618
  } catch (error) {
2544
- log.error(`${repository.label}: push failed: ${errorMessage(error)}`);
2619
+ reportGitFailure(`${repository.label}: push failed`, error);
2545
2620
  process.exitCode = 1;
2546
2621
  }
2547
2622
  }
@@ -2549,17 +2624,19 @@ var commitCommand = defineCommand({
2549
2624
  if (interactive) outro(process.exitCode ? "Finished with errors." : "Done.");
2550
2625
  }
2551
2626
  });
2552
- function reportHooks(label, loader, message) {
2627
+ function reportGitFailure(message, error, level = "error") {
2628
+ if (error instanceof GitOutputError && error.hookFailureReported) return;
2629
+ log[level](error instanceof GitOutputError ? message : `${message}: ${errorMessage(error)}`);
2630
+ }
2631
+ function reportHooks(label) {
2632
+ const options = { secondarySymbol: "" };
2553
2633
  return (event) => {
2554
2634
  const hook = `${label}: ${event.name}`;
2555
2635
  if (event.phase === "start") {
2556
- if (loader) loader.message(`${hook} running\u2026`);
2557
- else log.info(`${hook} running\u2026`);
2636
+ log.step(styleText3("bold", hook), options);
2558
2637
  } else {
2559
- loader?.clear();
2560
- if (event.exitCode === 0) log.success(`${hook} \u2713`);
2561
- else log.error(`${hook} \u2717 (exit ${event.exitCode})`);
2562
- loader?.start(message);
2638
+ if (event.exitCode === 0) log.success(`${hook}: completed`, options);
2639
+ else log.error(`${hook}: failed (exit ${event.exitCode})`, options);
2563
2640
  }
2564
2641
  };
2565
2642
  }
@@ -2595,36 +2672,33 @@ async function reviewMessages(drafts, canPrompt) {
2595
2672
  action.draft.message = edited.trim();
2596
2673
  }
2597
2674
  }
2598
- async function syncWithUpstream({ repo, label }, strategy, interactive) {
2599
- const loader = interactive ? spinner() : void 0;
2675
+ async function syncWithUpstream({ repo, label }, strategy) {
2600
2676
  const actionLabel = strategy === "rebase" ? "Rebase" : "Merge";
2601
- const message = `${label}: pulling --${strategy === "rebase" ? "rebase" : "no-rebase"}`;
2602
- loader?.start(message);
2677
+ log.info(`${label}: pulling --${strategy === "rebase" ? "rebase" : "no-rebase"}`);
2603
2678
  try {
2604
- await (strategy === "rebase" ? pullRebase : pullMerge)(repo, reportHooks(label, loader, message));
2605
- loader?.stop(strategy === "rebase" ? "\u2713 Rebased on upstream" : "\u2713 Merged upstream");
2679
+ await (strategy === "rebase" ? pullRebase : pullMerge)(repo, reportHooks(label));
2680
+ log.success(`${label}: ${strategy === "rebase" ? "rebased on upstream" : "merged upstream"}`);
2606
2681
  return true;
2607
2682
  } catch (error) {
2608
- loader?.error(`${actionLabel} failed`);
2609
- log.error(`${label}: ${actionLabel} failed (resolve conflicts, then push): ${errorMessage(error)}`);
2683
+ reportGitFailure(`${label}: ${actionLabel} failed (resolve conflicts, then push)`, error);
2610
2684
  return false;
2611
2685
  }
2612
2686
  }
2613
- async function pushOptimistic(repository, interactive, canPrompt, strategy) {
2687
+ async function pushOptimistic(repository, canPrompt, strategy) {
2614
2688
  const { repo, label } = repository;
2615
2689
  const [upstream, branch] = await Promise.all([hasUpstream(repo), currentBranch(repo)]);
2616
2690
  const doPush = (onHook) => upstream ? push(repo, onHook) : pushSetUpstream(repo, branch, onHook);
2617
- const first = await tryPush(repository, interactive, doPush);
2691
+ const first = await tryPush(repository, doPush);
2618
2692
  if (first.ok) return true;
2619
- if (upstream && await isBehind(repository, interactive)) {
2693
+ if (upstream && await isBehind(repository)) {
2620
2694
  const syncStrategy = strategy ?? await chooseSyncStrategy(repository, canPrompt);
2621
- if (!syncStrategy || !await syncWithUpstream(repository, syncStrategy, interactive)) return false;
2622
- const retry = await tryPush(repository, interactive, doPush);
2695
+ if (!syncStrategy || !await syncWithUpstream(repository, syncStrategy)) return false;
2696
+ const retry = await tryPush(repository, doPush);
2623
2697
  if (retry.ok) return true;
2624
- log.error(`${label}: push failed: ${errorMessage(retry.error)}`);
2698
+ reportGitFailure(`${label}: push failed`, retry.error);
2625
2699
  return false;
2626
2700
  }
2627
- log.error(`${label}: push failed: ${errorMessage(first.error)}`);
2701
+ reportGitFailure(`${label}: push failed`, first.error);
2628
2702
  return false;
2629
2703
  }
2630
2704
  async function chooseSyncStrategy({ label }, interactive) {
@@ -2646,33 +2720,25 @@ async function chooseSyncStrategy({ label }, interactive) {
2646
2720
  }
2647
2721
  return action;
2648
2722
  }
2649
- async function tryPush({ label }, interactive, doPush) {
2650
- const loader = interactive ? spinner() : void 0;
2651
- const message = `${label}: pushing`;
2652
- loader?.start(message);
2723
+ async function tryPush({ label }, doPush) {
2724
+ log.info(`${label}: pushing`);
2653
2725
  try {
2654
- await doPush(reportHooks(label, loader, message));
2655
- loader?.stop("\u2713 Pushed");
2726
+ await doPush(reportHooks(label));
2727
+ log.success(`${label}: pushed`);
2656
2728
  return { ok: true };
2657
2729
  } catch (error) {
2658
- loader?.error("Push failed");
2659
2730
  return { ok: false, error };
2660
2731
  }
2661
2732
  }
2662
- async function isBehind({ repo, label }, interactive) {
2663
- const loader = interactive ? spinner() : void 0;
2664
- const message = `${label}: checking upstream`;
2665
- loader?.start(message);
2733
+ async function isBehind({ repo, label }) {
2734
+ log.info(`${label}: checking upstream`);
2666
2735
  try {
2667
- await fetchRemote(repo, reportHooks(label, loader, message));
2736
+ await fetchRemote(repo, reportHooks(label));
2668
2737
  const behind = await behindCount(repo);
2669
- loader?.stop(
2670
- behind > 0 ? `Behind upstream by ${behind} commit${behind > 1 ? "s" : ""}` : "Up to date with upstream"
2671
- );
2738
+ log.info(`${label}: ${behind > 0 ? `Behind upstream by ${behind} commit${behind > 1 ? "s" : ""}` : "Up to date with upstream"}`);
2672
2739
  return behind > 0;
2673
2740
  } catch (error) {
2674
- loader?.error("Could not check upstream");
2675
- log.warn(`${label}: could not check upstream: ${errorMessage(error)}`);
2741
+ reportGitFailure(`${label}: could not check upstream`, error, "warn");
2676
2742
  return false;
2677
2743
  }
2678
2744
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapdev",
3
- "version": "1.4.0",
3
+ "version": "1.6.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": {