zapdev 0.9.0 → 0.10.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 +85 -57
  2. package/dist/cli.js +124 -17
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,15 +1,55 @@
1
1
  # zapdev
2
2
 
3
- **zapdev** — a lightweight TypeScript CLI that makes small, repetitive git chores fast and precise.
3
+ ## Project Introduction
4
4
 
5
- ## Requirements
5
+ **zapdev** is a lightweight TypeScript CLI that makes small, repetitive Git chores fast and precise.
6
6
 
7
- Node.js >= 20 and git.
7
+ It stages changes, scans them for secrets, generates Conventional Commit messages with Ollama, and streamlines repository cleanup.
8
8
 
9
- ## Install
9
+ ## Project Architecture
10
+
11
+ ```mermaid
12
+ flowchart LR
13
+ Entry["src/index.ts"] --> CLI["src/cli.ts"]
14
+ CLI --> Commands["src/commands"]
15
+ Commands --> Lib["src/lib"]
16
+ Commands --> Types["src/types"]
17
+ Lib --> Prompts["src/prompts"]
18
+ Lib --> Types
19
+ Lib --> Tools["Git, Gitleaks, Ollama"]
20
+ ```
21
+
22
+ - `src/index.ts`: bin launcher; enables the V8 compile cache, then loads `cli.js`.
23
+ - `src/cli.ts`: CLI entry; registers subcommands and opens the interactive menu.
24
+ - `src/commands/`: command UI and orchestration.
25
+ - `src/lib/`: pure logic and isolated Git, Gitleaks, and Ollama side effects.
26
+ - `src/prompts/`: LLM prompts inlined into the bundle at build time.
27
+ - `src/types/`: shared type declarations.
28
+
29
+ ## Environment Variables
30
+
31
+ | Variable | Default | Required | Description |
32
+ | --- | --- | --- | --- |
33
+ | `OLLAMA_URL` | `http://localhost:11434` | No | Ollama base URL |
34
+ | `OLLAMA_MODEL` | `deepseek-v4-flash:cloud` | No | Model used to generate commit messages |
35
+ | `OLLAMA_BACKUP_MODEL` | - | No | Model used when generation with the primary model fails |
36
+ | `OLLAMA_EFFORT` | - | No | Ollama thinking effort (`low`, `medium`, `high`, or `max`) |
37
+
38
+ ## Setup
39
+
40
+ ### Requirements
41
+
42
+ - **Node.js >= 20 (required):** runs the CLI.
43
+ - **Git (required):** provides the repository operations.
44
+ - **Ollama (required for `commit`):** generates Conventional Commit messages.
45
+ - **Gitleaks (recommended):** scans staged changes before message generation when available on `PATH`.
46
+
47
+ ### Install
48
+
49
+ Install zapdev globally for daily use:
10
50
 
11
51
  ```bash
12
- npm install -g zapdev # installs the `zapdev` command globally
52
+ npm install -g zapdev
13
53
  ```
14
54
 
15
55
  Or run it once without installing:
@@ -18,15 +58,28 @@ Or run it once without installing:
18
58
  npx zapdev commit
19
59
  ```
20
60
 
21
- > For daily use, prefer the global install: `npx` adds resolution overhead on every run.
61
+ ### Development Setup
62
+
63
+ From a clone:
64
+
65
+ ```bash
66
+ npm install
67
+ npm run zapdev # build then run the CLI in dev
68
+ npm run typecheck # tsc --noEmit
69
+ npm run lint # eslint
70
+ npm run test # vitest
71
+ npm run build # bundle to dist/ with esbuild
72
+ ```
73
+
74
+ `npm link` exposes the local `zapdev` binary after `npm run build`.
22
75
 
23
76
  ## Usage
24
77
 
25
- Run `zapdev` with no command to pick one from an interactive menu (falls back to usage output without a TTY).
78
+ Run `zapdev` with no command to pick one from an interactive menu. Without a TTY, zapdev displays its usage instead.
26
79
 
27
80
  ### `zapdev commit`
28
81
 
29
- Stages all changes (`git add -A`), asks an Ollama model for a one-line Conventional Commits message, then lets you commit, edit or cancel, and optionally push.
82
+ Stages all changes, scans them with Gitleaks when installed, generates a Conventional Commit message, and optionally pushes the commit.
30
83
 
31
84
  ```bash
32
85
  zapdev commit
@@ -34,77 +87,52 @@ zapdev commit
34
87
 
35
88
  | Flag | Description |
36
89
  | --- | --- |
37
- | `-m, --model <model>` | Override the Ollama model |
38
- | `-t, --type <type>` | Force the Conventional Commits type (`feat`, `fix`, `chore`, …) |
90
+ | `--model <model>` | Override the Ollama model |
91
+ | `-t, --type <type>` | Force the Conventional Commit type (`feat`, `fix`, `chore`, etc.) |
39
92
  | `-p, --push` | Push after committing without asking |
93
+ | `-s, --staged` | Commit only changes that are already staged |
94
+ | `-r, --rebase` | Rebase on upstream if the push is rejected |
95
+ | `-m, --merge` | Merge upstream if the push is rejected |
40
96
  | `-y, --yes` | Skip prompts and commit directly |
41
97
 
42
98
  ```bash
43
- zapdev commit -t feat # force the type; the model still writes scope + description
99
+ zapdev commit -t feat # force the type
100
+ zapdev commit --staged # leave unstaged changes untouched
44
101
  ```
45
102
 
46
- Pushing is optimistic — no preliminary fetch, so the common case stays a single round-trip. If the push is rejected because the branch is behind its upstream, zapdev automatically runs `git pull --rebase` and retries the push once — no prompt, in every mode. A rebase conflict stops before pushing so you can resolve it, then re-run.
103
+ Before contacting Ollama, zapdev runs `gitleaks git --staged` when Gitleaks is installed. A failed scan stops the commit; when Gitleaks is absent, the scan is skipped.
104
+
105
+ 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.
47
106
 
48
- Without a TTY (piped / CI), it commits automatically and only pushes when `--push` is set.
107
+ Without a TTY, zapdev commits automatically and only pushes when `--push` is set.
49
108
 
50
109
  ### `zapdev reset`
51
110
 
52
- Operates on a git repo, or — when the path is a plain directory — on its **direct child** repos (level 1, non-recursive). For each one: `git fetch --prune`, switch to a branch (chosen interactively, or forced with `--principal` / `--target`), then permanently delete every other local branch and every linked worktree. The principal branch (from `origin/HEAD`) and the branch you switched to are always kept. Ends with an optional `pull`.
111
+ Operates on a Git repository or the direct child repositories of a directory. It fetches and prunes, switches branch, then permanently removes other local branches and linked worktrees.
53
112
 
54
113
  ```bash
55
- zapdev reset # the current repo, or the child repos of the current directory
56
- zapdev reset ~/dev # a repo, or the child repos of a directory
57
- zapdev reset -p # switch each repo to its principal branch, no prompt
58
- zapdev reset -t dev # switch each repo to `dev` (or principal if absent)
114
+ zapdev reset # reset the current repo or direct child repos
115
+ zapdev reset ~/dev # reset repos under a directory
116
+ zapdev reset -p # switch to the principal branch without prompting
117
+ zapdev reset -t dev # switch to dev or fall back to the principal branch
59
118
  ```
60
119
 
61
120
  | Flag | Description |
62
121
  | --- | --- |
63
122
  | `-p, --principal` | Switch every repo to its resolved principal branch (`origin/HEAD`) |
64
- | `-t, --target <branch>` | Switch every repo to `<branch>`, falling back to the principal one |
123
+ | `-t, --target <branch>` | Switch to a target branch, falling back to the principal branch |
65
124
  | `--pull` | Pull the checked-out branch after reset without asking |
66
- | `-y, --yes` | Non-interactive: switch and delete without confirmation |
125
+ | `-y, --yes` | Switch and delete without confirmation |
67
126
 
68
- Deletion is permanent: branches via `git branch -D` (force), worktrees via `git worktree remove --force`. Worktrees are removed first so the branches they held become deletable. Without a TTY, pass `--yes` — otherwise the destructive step is refused. `node_modules` is never scanned.
127
+ Deletion is permanent. Branches are removed with `git branch -D`; worktrees are removed with `git worktree remove --force`. Without a TTY, pass `--yes` or the destructive step is refused. `node_modules` is never scanned.
69
128
 
70
- ## Shell aliases
71
-
72
- `--yes` skips every prompt, which is exactly what you want behind a short alias. Two that pay off daily — add them to `~/.zshrc` or `~/.bashrc`:
129
+ ### Shell Aliases
73
130
 
74
131
  ```bash
75
- alias commit="zapdev commit --yes" # stage → generate message → commit, no prompts
76
- alias git-reset="zapdev reset --yes --principal --pull" # switch each repo to its principal branch, wipe the rest, then pull
132
+ alias commit="zapdev commit --yes"
133
+ alias git-reset="zapdev reset --yes --principal --pull"
77
134
  ```
78
135
 
79
- `commit` collapses the whole stage → message → commit flow into one word. `git-reset` brings a workspace back to a clean principal state in one shot — point it at a parent directory to reset every child repo at once.
80
-
81
- ## Configuration
82
-
83
- | Variable | Default | Description |
84
- | --- | --- | --- |
85
- | `OLLAMA_URL` | `http://localhost:11434` | Ollama base URL |
86
- | `OLLAMA_MODEL` | `deepseek-v4-flash:cloud` | Model used to generate messages |
87
-
88
- ## Development
89
-
90
- From a clone:
91
-
92
- ```bash
93
- npm install
94
- npm run zapdev # build then run the CLI in dev
95
- npm run typecheck # tsc --noEmit
96
- npm run lint # eslint
97
- npm run test # vitest
98
- npm run build # bundle to dist/ (esbuild)
99
- ```
100
-
101
- `npm link` (after `npm run build`) exposes the local `zapdev` binary on your PATH.
102
-
103
- ## Project structure
136
+ ## Other
104
137
 
105
- - `src/index.ts` — bin launcher; enables the V8 compile cache, then loads `cli.js`.
106
- - `src/cli.ts` — CLI entry (Citty); registers subcommands, defaults to `commit`.
107
- - `src/commands/` — one file per command.
108
- - `src/lib/` — pure logic (`config`, `commit-message`, `branches`, unit-tested) and side effects (`git`, `ollama`); shared helpers (`errors`).
109
- - `src/prompts/` — LLM prompts as `.md` files, imported as text (esbuild `.md` text loader) and inlined into the bundle at build time.
110
- - `src/types/` — shared type declarations (one file per domain) and ambient module declarations.
138
+ zapdev is available under the MIT license.
package/dist/cli.js CHANGED
@@ -1718,7 +1718,7 @@ function _getBuiltinFlags(long, short, userNames, userAliases) {
1718
1718
  // package.json
1719
1719
  var package_default = {
1720
1720
  name: "zapdev",
1721
- version: "0.9.0",
1721
+ version: "0.10.0",
1722
1722
  description: "Fast, performant and lightweight git chores and dev tools using your terminal and your own local LLM.",
1723
1723
  type: "module",
1724
1724
  bin: {
@@ -1817,9 +1817,14 @@ var DEFAULT_MODEL = "deepseek-v4-flash:cloud";
1817
1817
  function resolveConfig(env = process.env, overrides = {}) {
1818
1818
  return {
1819
1819
  ollamaUrl: normalizeBaseUrl(overrides.ollamaUrl ?? env.OLLAMA_URL ?? DEFAULT_OLLAMA_URL),
1820
- model: overrides.model ?? env.OLLAMA_MODEL ?? DEFAULT_MODEL
1820
+ model: overrides.model ?? env.OLLAMA_MODEL ?? DEFAULT_MODEL,
1821
+ backupModel: overrides.backupModel ?? optionalValue(env.OLLAMA_BACKUP_MODEL),
1822
+ effort: overrides.effort ?? optionalValue(env.OLLAMA_EFFORT)
1821
1823
  };
1822
1824
  }
1825
+ function optionalValue(value) {
1826
+ return value?.trim() || void 0;
1827
+ }
1823
1828
  function normalizeBaseUrl(raw) {
1824
1829
  const trimmed = raw.trim().replace(/\/+$/, "");
1825
1830
  return /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
@@ -2225,6 +2230,9 @@ async function pushSetUpstream(branch) {
2225
2230
  async function pullRebase() {
2226
2231
  await git(["pull", "--rebase"]);
2227
2232
  }
2233
+ async function pullMerge() {
2234
+ await git(["pull", "--no-rebase", "--no-edit"]);
2235
+ }
2228
2236
  async function fetchRemote() {
2229
2237
  await git(["fetch"]);
2230
2238
  }
@@ -2296,6 +2304,25 @@ function errorMessage(error) {
2296
2304
  return error instanceof Error ? error.message : String(error);
2297
2305
  }
2298
2306
 
2307
+ // src/lib/gitleaks.ts
2308
+ async function hasGitleaks() {
2309
+ try {
2310
+ await R2("gitleaks", ["version"], { nodePath: false });
2311
+ return true;
2312
+ } catch (error) {
2313
+ if (error instanceof Error && error.code === "ENOENT") {
2314
+ return false;
2315
+ }
2316
+ throw error;
2317
+ }
2318
+ }
2319
+ async function scanStagedChanges() {
2320
+ const result = await R2("gitleaks", ["git", "--staged"], { nodePath: false });
2321
+ if (result.exitCode === 0) return;
2322
+ const output = result.stderr.trim() || result.stdout.trim();
2323
+ throw new Error(output || "gitleaks git --staged failed");
2324
+ }
2325
+
2299
2326
  // src/prompts/commit-message.md
2300
2327
  var commit_message_default = "You are zapdev, a CLI tool that creates commits quickly and reliably.\n\nGenerate ONE commit message in Conventional Commits format.\nA single line: imperative, English, \u226472 chars.\n\nStrictly follow this format:\n\n```text\ntype(scope): description\n```\n\nReply ONLY with the message, no backticks, no quotes, no explanation.\n";
2301
2328
 
@@ -2307,13 +2334,28 @@ var REQUEST_TIMEOUT_MS = 25e3;
2307
2334
  var KEEP_ALIVE = "30m";
2308
2335
  async function generateCommitMessage(diff, config, type) {
2309
2336
  const systemPrompt = type ? applyCommitType(COMMIT_SYSTEM_PROMPT, type) : COMMIT_SYSTEM_PROMPT;
2310
- const content = await chat(config, [
2337
+ const messages = [
2311
2338
  { role: "system", content: systemPrompt },
2312
2339
  { role: "user", content: truncateDiff(diff) }
2313
- ]);
2340
+ ];
2341
+ let content;
2342
+ try {
2343
+ content = await chat(config, config.model, messages);
2344
+ } catch (error) {
2345
+ if (!config.backupModel || config.backupModel === config.model) throw error;
2346
+ try {
2347
+ content = await chat(config, config.backupModel, messages);
2348
+ } catch (backupError) {
2349
+ throw new AggregateError(
2350
+ [error, backupError],
2351
+ `Primary model failed: ${errorMessage(error)} Backup model failed: ${errorMessage(backupError)}`,
2352
+ { cause: backupError }
2353
+ );
2354
+ }
2355
+ }
2314
2356
  return sanitizeCommitMessage(content);
2315
2357
  }
2316
- async function chat(config, messages) {
2358
+ async function chat(config, model, messages) {
2317
2359
  const url = `${config.ollamaUrl}/api/chat`;
2318
2360
  let response;
2319
2361
  try {
@@ -2321,9 +2363,10 @@ async function chat(config, messages) {
2321
2363
  method: "POST",
2322
2364
  headers: { "content-type": "application/json" },
2323
2365
  body: JSON.stringify({
2324
- model: config.model,
2366
+ model,
2325
2367
  stream: false,
2326
2368
  keep_alive: KEEP_ALIVE,
2369
+ think: config.effort,
2327
2370
  options: { temperature: 0.2 },
2328
2371
  messages
2329
2372
  }),
@@ -2374,7 +2417,6 @@ var commitCommand = defineCommand({
2374
2417
  args: {
2375
2418
  model: {
2376
2419
  type: "string",
2377
- alias: "m",
2378
2420
  description: "Override the Ollama model (defaults to $OLLAMA_MODEL)."
2379
2421
  },
2380
2422
  type: {
@@ -2387,6 +2429,21 @@ var commitCommand = defineCommand({
2387
2429
  alias: "p",
2388
2430
  description: "Push after committing without asking."
2389
2431
  },
2432
+ staged: {
2433
+ type: "boolean",
2434
+ alias: "s",
2435
+ description: "Commit only changes that are already staged."
2436
+ },
2437
+ rebase: {
2438
+ type: "boolean",
2439
+ alias: "r",
2440
+ description: "Rebase on the upstream branch if the push is rejected."
2441
+ },
2442
+ merge: {
2443
+ type: "boolean",
2444
+ alias: "m",
2445
+ description: "Merge the upstream branch if the push is rejected."
2446
+ },
2390
2447
  yes: {
2391
2448
  type: "boolean",
2392
2449
  alias: "y",
@@ -2399,6 +2456,12 @@ var commitCommand = defineCommand({
2399
2456
  process.env,
2400
2457
  args.model ? { model: args.model } : {}
2401
2458
  );
2459
+ if (args.rebase && args.merge) {
2460
+ log.error("Choose either --rebase or --merge, not both.");
2461
+ process.exitCode = 1;
2462
+ return;
2463
+ }
2464
+ const syncStrategy = args.rebase ? "rebase" : args.merge ? "merge" : void 0;
2402
2465
  const type = args.type ? normalizeCommitType(args.type) : void 0;
2403
2466
  if (type === null) {
2404
2467
  log.error(
@@ -2408,13 +2471,32 @@ var commitCommand = defineCommand({
2408
2471
  return;
2409
2472
  }
2410
2473
  if (interactive) intro("zapdev commit");
2411
- await stageAll();
2474
+ if (!args.staged) await stageAll();
2412
2475
  const diff = await getStagedDiff();
2413
2476
  if (!diff.trim()) {
2414
2477
  log.warn("Nothing to commit.");
2415
2478
  if (interactive) outro("Nothing to do.");
2416
2479
  return;
2417
2480
  }
2481
+ try {
2482
+ if (await hasGitleaks()) {
2483
+ const scanLoader = interactive ? spinner() : void 0;
2484
+ scanLoader?.start("Scanning staged changes with Gitleaks");
2485
+ try {
2486
+ await scanStagedChanges();
2487
+ scanLoader?.stop("No leaks found");
2488
+ } catch (error) {
2489
+ scanLoader?.error("Gitleaks check failed");
2490
+ throw error;
2491
+ }
2492
+ } else {
2493
+ log.info("Gitleaks not found, skipping secret scan.");
2494
+ }
2495
+ } catch (error) {
2496
+ log.error(`Gitleaks check failed: ${errorMessage(error)}`);
2497
+ process.exitCode = 1;
2498
+ return;
2499
+ }
2418
2500
  const loader = interactive ? spinner() : void 0;
2419
2501
  loader?.start("Generating commit message");
2420
2502
  let message;
@@ -2477,7 +2559,11 @@ var commitCommand = defineCommand({
2477
2559
  shouldPush = answer;
2478
2560
  }
2479
2561
  if (shouldPush) {
2480
- const pushed = await pushOptimistic(interactive);
2562
+ const pushed = await pushOptimistic(
2563
+ interactive,
2564
+ interactive && !args.yes,
2565
+ syncStrategy
2566
+ );
2481
2567
  if (!pushed) {
2482
2568
  process.exitCode = 1;
2483
2569
  return;
@@ -2486,26 +2572,28 @@ var commitCommand = defineCommand({
2486
2572
  if (interactive) outro("Done.");
2487
2573
  }
2488
2574
  });
2489
- async function rebaseOnUpstream(interactive) {
2575
+ async function syncWithUpstream(strategy, interactive) {
2490
2576
  const loader = interactive ? spinner() : void 0;
2491
- loader?.start("Pulling --rebase");
2577
+ const label = strategy === "rebase" ? "Rebase" : "Merge";
2578
+ loader?.start(`Pulling --${strategy === "rebase" ? "rebase" : "no-rebase"}`);
2492
2579
  try {
2493
- await pullRebase();
2494
- loader?.stop("\u2713 Rebased on upstream");
2580
+ await (strategy === "rebase" ? pullRebase() : pullMerge());
2581
+ loader?.stop(strategy === "rebase" ? "\u2713 Rebased on upstream" : "\u2713 Merged upstream");
2495
2582
  return true;
2496
2583
  } catch (error) {
2497
- loader?.error("Rebase failed");
2498
- log.error(`Rebase failed (resolve conflicts, then push): ${errorMessage(error)}`);
2584
+ loader?.error(`${label} failed`);
2585
+ log.error(`${label} failed (resolve conflicts, then push): ${errorMessage(error)}`);
2499
2586
  return false;
2500
2587
  }
2501
2588
  }
2502
- async function pushOptimistic(interactive) {
2589
+ async function pushOptimistic(interactive, canPrompt, strategy) {
2503
2590
  const [upstream, branch] = await Promise.all([hasUpstream(), currentBranch()]);
2504
2591
  const doPush = () => upstream ? push() : pushSetUpstream(branch);
2505
2592
  const first = await tryPush(interactive, doPush);
2506
2593
  if (first.ok) return true;
2507
2594
  if (upstream && await isBehind(interactive)) {
2508
- if (!await rebaseOnUpstream(interactive)) return false;
2595
+ const syncStrategy = strategy ?? await chooseSyncStrategy(canPrompt);
2596
+ if (!syncStrategy || !await syncWithUpstream(syncStrategy, interactive)) return false;
2509
2597
  const retry = await tryPush(interactive, doPush);
2510
2598
  if (retry.ok) return true;
2511
2599
  log.error(`Push failed: ${errorMessage(retry.error)}`);
@@ -2514,6 +2602,25 @@ async function pushOptimistic(interactive) {
2514
2602
  log.error(`Push failed: ${errorMessage(first.error)}`);
2515
2603
  return false;
2516
2604
  }
2605
+ async function chooseSyncStrategy(interactive) {
2606
+ if (!interactive) {
2607
+ log.error("Branch is behind upstream. Re-run with --rebase or --merge.");
2608
+ return null;
2609
+ }
2610
+ const action = await select({
2611
+ message: "Branch is behind upstream. How should zapdev sync it?",
2612
+ options: [
2613
+ { value: "rebase", label: "Rebase" },
2614
+ { value: "merge", label: "Merge" },
2615
+ { value: "quit", label: "Quit" }
2616
+ ]
2617
+ });
2618
+ if (isCancel(action) || action === "quit") {
2619
+ log.warn("Push cancelled. Commit remains local.");
2620
+ return null;
2621
+ }
2622
+ return action;
2623
+ }
2517
2624
  async function tryPush(interactive, doPush) {
2518
2625
  const loader = interactive ? spinner() : void 0;
2519
2626
  loader?.start("Pushing");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapdev",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Fast, performant and lightweight git chores and dev tools using your terminal and your own local LLM.",
5
5
  "type": "module",
6
6
  "bin": {