lambda-watcher 0.1.0__py3-none-any.whl

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 (38) hide show
  1. lambda_watcher/__init__.py +4 -0
  2. lambda_watcher/__main__.py +4 -0
  3. lambda_watcher/analysis/__init__.py +115 -0
  4. lambda_watcher/analysis/deps.py +291 -0
  5. lambda_watcher/analysis/envvars.py +80 -0
  6. lambda_watcher/analysis/handler.py +111 -0
  7. lambda_watcher/analysis/inventory.py +118 -0
  8. lambda_watcher/analysis/runtime.py +117 -0
  9. lambda_watcher/analysis/secrets.py +178 -0
  10. lambda_watcher/analysis/services.py +76 -0
  11. lambda_watcher/cli.py +1406 -0
  12. lambda_watcher/config.py +324 -0
  13. lambda_watcher/db.py +466 -0
  14. lambda_watcher/diffing/__init__.py +14 -0
  15. lambda_watcher/diffing/build.py +51 -0
  16. lambda_watcher/diffing/compare.py +525 -0
  17. lambda_watcher/diffing/highlight.py +312 -0
  18. lambda_watcher/diffing/icons.py +132 -0
  19. lambda_watcher/diffing/intraline.py +162 -0
  20. lambda_watcher/diffing/render_html.py +697 -0
  21. lambda_watcher/diffing/render_text.py +198 -0
  22. lambda_watcher/extract.py +227 -0
  23. lambda_watcher/gitmirror.py +151 -0
  24. lambda_watcher/identify.py +201 -0
  25. lambda_watcher/ingest.py +480 -0
  26. lambda_watcher/notify.py +59 -0
  27. lambda_watcher/reindex.py +158 -0
  28. lambda_watcher/service.py +553 -0
  29. lambda_watcher/store.py +209 -0
  30. lambda_watcher/templates.py +124 -0
  31. lambda_watcher/utils.py +314 -0
  32. lambda_watcher/watcher.py +241 -0
  33. lambda_watcher-0.1.0.dist-info/METADATA +409 -0
  34. lambda_watcher-0.1.0.dist-info/RECORD +38 -0
  35. lambda_watcher-0.1.0.dist-info/WHEEL +5 -0
  36. lambda_watcher-0.1.0.dist-info/entry_points.txt +3 -0
  37. lambda_watcher-0.1.0.dist-info/licenses/LICENSE +201 -0
  38. lambda_watcher-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,241 @@
1
+ """The Downloads-folder watcher.
2
+
3
+ Filesystem events land on the observer thread and are queued; a single worker
4
+ thread does the real work, so a slow ingest never makes us miss an event. Every
5
+ candidate file is waited on until it stops growing, because browsers rename a
6
+ ``.crdownload`` into place only at the very end — and some do not.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import queue
12
+ import threading
13
+ import time
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from collections.abc import Callable
18
+
19
+ from watchdog.events import (
20
+ DirMovedEvent,
21
+ FileCreatedEvent,
22
+ FileModifiedEvent,
23
+ FileMovedEvent,
24
+ FileSystemEvent,
25
+ FileSystemEventHandler,
26
+ )
27
+ from watchdog.observers import Observer
28
+ from watchdog.observers.polling import PollingObserver
29
+
30
+ from .config import Config
31
+ from .db import Database
32
+ from .ingest import IngestResult, Ingestor, recently_written, wait_until_stable
33
+ from .utils import LOG
34
+
35
+
36
+ #: Queue reasons that mean "this file just landed here". The startup scan is
37
+ #: deliberately absent: it sweeps files that were already sitting in the folder,
38
+ #: and cannot tell an overnight download from a zip something merely touched, so
39
+ #: what it finds is archived but never cleared out of the watched folder.
40
+ ARRIVAL_REASONS = frozenset({"created", "moved", "modified", "manual"})
41
+
42
+
43
+ @dataclass
44
+ class _Job:
45
+ path: Path
46
+ reason: str
47
+
48
+
49
+ class _Handler(FileSystemEventHandler):
50
+ """Translates watchdog events into ingest jobs."""
51
+
52
+ def __init__(
53
+ self,
54
+ enqueue: Callable[[Path, str], None],
55
+ is_candidate: Callable[[Path], bool],
56
+ arrival_max_age: float = 0.0,
57
+ ) -> None:
58
+ self.enqueue = enqueue
59
+ self.is_candidate = is_candidate
60
+ self.arrival_max_age = arrival_max_age
61
+
62
+ def _maybe(self, raw_path: str | bytes, reason: str, *, require_recent: bool = False) -> None:
63
+ path = Path(raw_path.decode() if isinstance(raw_path, bytes) else raw_path)
64
+ if not self.is_candidate(path):
65
+ return
66
+ if require_recent and not self._recently_written(path):
67
+ LOG.debug("ignoring %s event for %s: nothing was written to it", reason, path.name)
68
+ return
69
+ self.enqueue(path, reason)
70
+
71
+ def _recently_written(self, path: Path) -> bool:
72
+ try:
73
+ mtime = path.stat().st_mtime
74
+ except OSError:
75
+ return False
76
+ return recently_written(mtime, self.arrival_max_age)
77
+
78
+ def on_created(self, event: FileSystemEvent) -> None:
79
+ if isinstance(event, FileCreatedEvent):
80
+ self._maybe(event.src_path, "created")
81
+
82
+ def on_moved(self, event: FileSystemEvent) -> None:
83
+ # Chrome/Edge finish a download by renaming "foo.zip.crdownload" -> "foo.zip".
84
+ if isinstance(event, FileMovedEvent) and not isinstance(event, DirMovedEvent):
85
+ self._maybe(event.dest_path, "moved")
86
+
87
+ def on_modified(self, event: FileSystemEvent) -> None:
88
+ # Some browsers write in place without a rename, so these events matter.
89
+ # But Windows also raises them when nothing was written: watchdog asks
90
+ # ReadDirectoryChangesW for attribute, security and last-access changes
91
+ # too, so an antivirus sweep, the search indexer or OneDrive dehydrating
92
+ # a folder re-announces every zip in it at once. mtime is the filter.
93
+ if isinstance(event, FileModifiedEvent):
94
+ self._maybe(event.src_path, "modified", require_recent=True)
95
+
96
+
97
+ class Watcher:
98
+ def __init__(
99
+ self,
100
+ cfg: Config,
101
+ db: Database,
102
+ ingestor: Ingestor | None = None,
103
+ on_result: Callable[[IngestResult], None] | None = None,
104
+ ) -> None:
105
+ self.cfg = cfg
106
+ self.db = db
107
+ self.ingestor = ingestor or Ingestor(cfg, db)
108
+ self.on_result = on_result
109
+ self._queue: queue.Queue[_Job | None] = queue.Queue()
110
+ self._pending: set[str] = set()
111
+ self._pending_lock = threading.Lock()
112
+ self._stop = threading.Event()
113
+ self._observer = None
114
+ self._worker: threading.Thread | None = None
115
+
116
+ # -- queueing --------------------------------------------------------
117
+ def enqueue(self, path: Path, reason: str = "manual") -> None:
118
+ key = str(path)
119
+ with self._pending_lock:
120
+ if key in self._pending:
121
+ return # already queued; the stability wait covers late writes
122
+ self._pending.add(key)
123
+ LOG.debug("queued %s (%s)", path.name, reason)
124
+ self._queue.put(_Job(path, reason))
125
+
126
+ def _work(self) -> None:
127
+ while not self._stop.is_set():
128
+ try:
129
+ job = self._queue.get(timeout=0.5)
130
+ except queue.Empty:
131
+ continue
132
+ if job is None:
133
+ break
134
+ try:
135
+ self._process(job)
136
+ except Exception: # noqa: BLE001 - the worker must never die
137
+ LOG.exception("failed to process %s", job.path)
138
+ finally:
139
+ with self._pending_lock:
140
+ self._pending.discard(str(job.path))
141
+ self._queue.task_done()
142
+
143
+ def _process(self, job: _Job) -> None:
144
+ if not job.path.exists():
145
+ return
146
+ if not wait_until_stable(
147
+ job.path, self.cfg.watch.stable_seconds, self.cfg.watch.max_wait_seconds
148
+ ):
149
+ return
150
+ if not job.path.exists(): # moved or deleted while we waited
151
+ return
152
+ result = self.ingestor.ingest(job.path, just_downloaded=job.reason in ARRIVAL_REASONS)
153
+ if self.on_result:
154
+ try:
155
+ self.on_result(result)
156
+ except Exception: # noqa: BLE001
157
+ LOG.exception("result callback failed")
158
+
159
+ # -- startup scan ----------------------------------------------------
160
+ def initial_scan(self) -> int:
161
+ """Queue matching files that are already sitting in the watched folders."""
162
+ if not self.cfg.watch.scan_on_start:
163
+ return 0
164
+ max_age = self.cfg.watch.scan_on_start_max_age_hours
165
+ cutoff = None
166
+ if max_age > 0:
167
+ cutoff = datetime.now(timezone.utc).timestamp() - max_age * 3600
168
+
169
+ candidates: list[tuple[float, Path]] = []
170
+ for directory in self.cfg.watch_dirs():
171
+ if not directory.exists():
172
+ LOG.warning("watch directory does not exist: %s", directory)
173
+ continue
174
+ entries = directory.rglob("*") if self.cfg.watch.recursive else directory.iterdir()
175
+ for path in entries:
176
+ try:
177
+ if not path.is_file() or not self.ingestor.is_candidate(path):
178
+ continue
179
+ mtime = path.stat().st_mtime
180
+ if cutoff is not None and mtime < cutoff:
181
+ continue
182
+ except OSError:
183
+ continue
184
+ candidates.append((mtime, path))
185
+
186
+ # Oldest first, so catching up produces the same version order as
187
+ # watching live would have.
188
+ for _, path in sorted(candidates, key=lambda item: item[0]):
189
+ self.enqueue(path, "startup-scan")
190
+ if candidates:
191
+ LOG.info("startup scan queued %d existing file(s)", len(candidates))
192
+ return len(candidates)
193
+
194
+ # -- lifecycle -------------------------------------------------------
195
+ def start(self) -> None:
196
+ directories = [d for d in self.cfg.watch_dirs()]
197
+ existing = [d for d in directories if d.exists()]
198
+ if not existing:
199
+ raise FileNotFoundError(
200
+ "none of the configured watch directories exist: "
201
+ + ", ".join(str(d) for d in directories)
202
+ )
203
+
204
+ observer_cls = PollingObserver if self.cfg.watch.force_polling else Observer
205
+ kwargs = {"timeout": self.cfg.watch.polling_interval} if self.cfg.watch.force_polling else {}
206
+ self._observer = observer_cls(**kwargs) # type: ignore[operator]
207
+ handler = _Handler(
208
+ self.enqueue, self.ingestor.is_candidate, self.cfg.watch.arrival_max_age_seconds
209
+ )
210
+ for directory in existing:
211
+ self._observer.schedule(handler, str(directory), recursive=self.cfg.watch.recursive)
212
+ LOG.info("watching %s", directory)
213
+
214
+ self._worker = threading.Thread(target=self._work, name="lw-ingest", daemon=True)
215
+ self._worker.start()
216
+ self._observer.start()
217
+ self.initial_scan()
218
+
219
+ def stop(self, timeout: float = 10.0) -> None:
220
+ self._stop.set()
221
+ if self._observer is not None:
222
+ self._observer.stop()
223
+ self._observer.join(timeout=timeout)
224
+ self._queue.put(None)
225
+ if self._worker is not None:
226
+ self._worker.join(timeout=timeout)
227
+
228
+ def wait_forever(self) -> None:
229
+ try:
230
+ while not self._stop.is_set():
231
+ time.sleep(0.5)
232
+ except KeyboardInterrupt:
233
+ LOG.info("stopping on keyboard interrupt")
234
+
235
+ def drain(self, timeout: float = 60.0) -> None:
236
+ """Block until the queue is empty. Used by tests and `backfill`."""
237
+ deadline = time.monotonic() + timeout
238
+ while time.monotonic() < deadline:
239
+ if self._queue.unfinished_tasks == 0:
240
+ return
241
+ time.sleep(0.1)
@@ -0,0 +1,409 @@
1
+ Metadata-Version: 2.4
2
+ Name: lambda-watcher
3
+ Version: 0.1.0
4
+ Summary: Watch your Downloads folder for AWS Lambda deployment zips, archive them as versions, analyse them, and diff any two versions.
5
+ Author: Lambda Watcher contributors
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/utkarsh5026/lambwatch
8
+ Project-URL: Documentation, https://utkarsh5026.github.io/lambwatch/
9
+ Project-URL: Issues, https://github.com/utkarsh5026/lambwatch/issues
10
+ Keywords: aws,lambda,diff,watchdog,versioning,backup
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Version Control
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: watchdog>=4.0
20
+ Requires-Dist: typer>=0.12
21
+ Requires-Dist: rich>=13.0
22
+ Requires-Dist: PyYAML>=6.0
23
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8.0; extra == "dev"
26
+ Requires-Dist: pytest-cov; extra == "dev"
27
+ Requires-Dist: ruff>=0.15; extra == "dev"
28
+ Provides-Extra: lint
29
+ Requires-Dist: ruff>=0.15; extra == "lint"
30
+ Dynamic: license-file
31
+
32
+ # lambda-watcher
33
+
34
+ Watch your Downloads folder for AWS Lambda deployment zips, archive every
35
+ version automatically, and review what actually changed between any two of
36
+ them.
37
+
38
+ If your deploy ritual is *"download the current function zip as a backup, then
39
+ push the new one"*, you end up with a folder of near-identical archives and no
40
+ practical way to answer **"what changed between the 2nd one and the 10th?"**.
41
+ This tool turns that pile into a queryable, diffable history — with no change
42
+ to how you work. You keep downloading zips; it does the rest.
43
+
44
+ **[How it works, step by step →](https://utkarsh5026.github.io/lambwatch/)**
45
+
46
+ ```
47
+ $ lambda-watcher watch
48
+ lambda-watcher 0.1.0 — archiving into ~/.lambda-watcher
49
+ watching ~/Downloads. Press Ctrl-C to stop.
50
+ new order-processor v0001 order-processor.zip — archived a new version
51
+ new order-processor v0002 order-processor (1).zip — 2 added, 2 modified, 3 renamed, 52 vendored
52
+ +24/-5 lines · new: 1 env var, 1 AWS service, 3 secrets
53
+ report: ~/.lambda-watcher/reports/order-processor/v0001-v0002.html
54
+ unchanged order-processor v0002 order-processor (2).zip — identical to version 2
55
+ ```
56
+
57
+ That third line is the one that matters: the same code, downloaded again, is
58
+ recorded as `unchanged` rather than becoming a bogus version 3.
59
+
60
+ ---
61
+
62
+ ## What it does
63
+
64
+ Every time a `.zip` lands in your Downloads folder, lambda-watcher:
65
+
66
+ 1. **waits** until the download has actually finished (no half-written files);
67
+ 2. **works out which Lambda it is** from the filename, a sidecar
68
+ `get-function` JSON, or the archive's contents — `order-processor.zip`,
69
+ `order-processor (1).zip` and `order-processor-2026-03-01.zip` all land
70
+ under the same function;
71
+ 3. **extracts** it safely (path traversal, zip bombs and encrypted archives are
72
+ refused, not trusted);
73
+ 4. **hashes the content, not the file** — re-downloading unchanged code is
74
+ recorded as `unchanged` instead of creating a bogus new version;
75
+ 5. **analyses** it: runtime, handler, dependencies (declared *and* the versions
76
+ actually vendored in the zip), environment variables the code reads, AWS
77
+ services it calls, and hardcoded credentials;
78
+ 6. **archives** it as version *N* with a `manifest.json`, indexes it in SQLite,
79
+ and commits it to a per-function git repo tagged `v0004`.
80
+
81
+ Then you review:
82
+
83
+ ```bash
84
+ lw diff order-processor # last two versions, in the terminal
85
+ lw diff order-processor --from 2 --to 10 # any two versions
86
+ lw diff order-processor --html --open # a shareable HTML report
87
+ lw report order-processor # the whole history, browsable
88
+ lw open order-processor # the whole archive, in your editor
89
+ lw git order-processor log -p # or just use git
90
+ ```
91
+
92
+ ## Why the diffs are actually readable
93
+
94
+ A raw `diff -r` between two Lambda zips is unusable: thousands of vendored
95
+ dependency files drown three lines of real change. lambda-watcher fixes that by
96
+ answering the questions you actually have, in order:
97
+
98
+ | | |
99
+ |---|---|
100
+ | **Your code, separated from theirs** | `node_modules/`, `site-packages/` and friends are classified as vendored and hidden by default. Three changed files, not 3,000. |
101
+ | **Dependencies as versions, not files** | A `boto3` upgrade shows as `boto3 1.34.0 → 1.35.20`, parsed from the `dist-info` actually shipped in the zip — not 400 changed files. |
102
+ | **Config impact, called out** | A new `os.environ["QUEUE_URL"]` is flagged as *"this must exist in the function's environment before you deploy"*. A new `boto3.client("sqs")` is flagged as *"the execution role may need new IAM permissions"*. These are the changes that break a deploy and never show up in a file diff. |
103
+ | **Renames survive edits** | A file that moved *and* changed is shown as one rename with a diff, not an unrelated add plus delete. |
104
+ | **Secrets are diffed too** | An AWS key or Stripe token that appears between v7 and v8 gets its own section. Values are stored redacted; the secret itself never enters the index. |
105
+
106
+ Concretely — the two versions above, where a `diff -rq` reports 61 changed
107
+ files and 56 of them are `site-packages/`:
108
+
109
+ ```
110
+ $ lambda-watcher diff order-processor
111
+ ╭──────────────────────────────────────────────────────────────────────╮
112
+ │ order-processor v0001 → v0002 │
113
+ │ 2 added 2 modified 3 renamed 52 vendored (hidden) +24 / -5 lines │
114
+ ╰──────────────────────────────────────────────────────────────────────╯
115
+ size 8.1 KB → 8.9 KB (+782 B)
116
+
117
+ Dependencies
118
+ manager package from to origin
119
+ + pip pydantic — 2.9.0 installed
120
+ ~ pip boto3 1.34.0 1.35.20 installed
121
+ ~ pip botocore 1.34.0 1.35.20 installed
122
+
123
+ Env vars added: QUEUE_URL
124
+ AWS services added: sqs
125
+ ↑ these need to exist in the function's environment configuration
126
+
127
+ New findings
128
+ high aws-access-key-id config.py:6 AKIA…LE (20 chars)
129
+ high stripe-key config.py:7 sk_l…dc (32 chars)
130
+ low debug-flag config.py:8 DEBUG = True
131
+
132
+ Files
133
+ path + − size
134
+ ~ lambda_function.py 9 2 +322
135
+ ~ requirements.txt 2 1 +17
136
+ + config.py 10 +235
137
+ + helpers/__init__.py
138
+ → {db → helpers/db}.py 1 +33
139
+ → site-packages/boto3-1.{34.0 → 35.20}.dist-info/METADATA 1 1 +1
140
+ → site-packages/botocore-1.{34.0 → 35.20}.dist-info/METADATA 1 1 +1
141
+ ```
142
+
143
+ The 52 vendored files became three version numbers, `db.py` moving into a
144
+ package is one rename rather than a delete plus an add, and the new environment
145
+ variable, the new AWS service and the three secret findings are changes that a
146
+ file diff cannot express at all.
147
+
148
+ That capture is not illustrative — it is the output of
149
+ [`docs/examples/build_demo.py`](docs/examples/build_demo.py), which builds the
150
+ two zips and runs the real pipeline over them. Run it yourself:
151
+
152
+ ```bash
153
+ .venv/bin/python docs/examples/build_demo.py
154
+ ```
155
+
156
+ The same comparison as a shareable HTML page — `--html --open` on any diff, or
157
+ `report` for the whole history — is published from this repository:
158
+ **[see the generated report](https://utkarsh5026.github.io/lambwatch/examples/report/v0001-v0002.html)**.
159
+
160
+ ## Install
161
+
162
+ Python 3.10+.
163
+
164
+ ```bash
165
+ uv tool install lambda-watcher # or: pipx install lambda-watcher
166
+ ```
167
+
168
+ Either one puts `lambda-watcher` — and the shorter alias `lw`, used throughout
169
+ this README — on your `PATH` in its own environment. Plain `pip install
170
+ lambda-watcher` works too if you would rather manage the environment yourself.
171
+
172
+ <details>
173
+ <summary>From a checkout instead</summary>
174
+
175
+ ```bash
176
+ git clone https://github.com/utkarsh5026/lambwatch.git
177
+ cd lambwatch
178
+ python3 -m venv .venv
179
+ .venv/bin/pip install -e .
180
+ ```
181
+
182
+ </details>
183
+
184
+ ## Quick start
185
+
186
+ ```bash
187
+ lw setup
188
+ ```
189
+
190
+ That is the whole thing. `setup` writes a config you can edit, finds your
191
+ downloads folder, offers to import any zips already sitting in it, and starts
192
+ the watcher in the background — as a launchd agent on macOS, a systemd user
193
+ service on Linux, a scheduled task on Windows — so it comes back after a
194
+ reboot without you thinking about it.
195
+
196
+ Then just keep downloading zips. When you want to know what happened:
197
+
198
+ ```bash
199
+ lw # is it running, and what has it caught?
200
+ lw diff order-processor # what changed in the last version
201
+ ```
202
+
203
+ Every new version also writes its own comparison to
204
+ `~/.lambda-watcher/reports/<function>/latest.html` as it is archived, so the
205
+ answer is a bookmark rather than a command.
206
+
207
+ <details>
208
+ <summary>Running it by hand, or setting it up piece by piece</summary>
209
+
210
+ ```bash
211
+ lw watch # run in the foreground instead; Ctrl-C stops it
212
+ lw start # install and start the background watcher
213
+ lw stop # stop it (--remove also unregisters it)
214
+ lw restart # after editing the config
215
+ lw doctor # check the config, watch folders, store, git and disk space
216
+ ```
217
+
218
+ Already have a folder of old backups? Import them oldest-first so the version
219
+ numbers match real history:
220
+
221
+ ```bash
222
+ lw backfill ~/Downloads/lambda-backups --dry-run # check the names first
223
+ lw backfill ~/Downloads/lambda-backups
224
+ ```
225
+
226
+ If your platform's service manager is unavailable — WSL without a systemd user
227
+ session, say — `lw start` falls back to a plain background process and tells
228
+ you it will not survive a reboot. [docs/autostart.md](docs/autostart.md) has
229
+ the manual recipes.
230
+
231
+ </details>
232
+
233
+ ## Commands
234
+
235
+ | Command | What it does |
236
+ |---|---|
237
+ | `setup` | Config, background watcher and any history already on disk, in one go. `--no-service` skips the background watcher, `--yes` takes every default. |
238
+ | `status` | Is it running, and what has it archived? Also what bare `lw` prints. |
239
+ | `start` / `stop` | Register the background watcher with the OS, or stop it. `stop --remove` unregisters it too. |
240
+ | `restart` | Stop and start it — use after editing the config. |
241
+ | `watch` | Watch the download folders in the foreground. `--once` processes what is already there and exits. |
242
+ | `ingest FILE...` | Archive specific zips by hand. `--as NAME` overrides the detected function, `--label` annotates the version. |
243
+ | `backfill DIR` | Import a folder of old downloads, oldest first. `--dry-run` shows the names it would assign. |
244
+ | `ls` | Every function archived so far. |
245
+ | `versions FN` | Every archived version of one function. |
246
+ | `show FN [V]` | Runtime, handler, dependencies, env vars, services and findings for one version. `--files`, `--json`. |
247
+ | `diff FN` | Compare two versions. Defaults to the last two. `--from`/`--to`, `--html`, `--open`, `--vendor`, `--no-patch`, `--json`. |
248
+ | `report FN` | Build a browsable HTML history: an index plus a diff for every step. |
249
+ | `export FN [V]` | Get a version back out as a deployable zip (`--zip`) or a plain folder (`--tree`). |
250
+ | `open FN [V]` | Open the function's mirror in your editor — every version in one folder, with history. Name a version to open just its files. |
251
+ | `git FN ...` | Run git inside that function's mirror repo: `lw git order-processor log --oneline`. |
252
+ | `rename OLD NEW` | Fix a misidentified name. `--alias FRAGMENT` remembers the mapping for next time. |
253
+ | `merge SRC DST` | Combine two entries that are really the same Lambda, renumbering by archive time. |
254
+ | `label FN V TEXT` | Annotate a version, e.g. `label order-processor 7 "prod deploy 2026-03-01"`. |
255
+ | `search TERM` | Search filenames and dependencies across everything archived. |
256
+ | `log` | Recent activity, including downloads that were skipped and why. |
257
+ | `path FN [V]` | Print a path, for `cd "$(lw path order-processor 7)"`. |
258
+ | `rm FN` | Delete a function and everything archived for it. |
259
+ | `reindex` | Rebuild the SQLite index from the manifests on disk. |
260
+ | `doctor` | Check the config, watch folders, store, git and disk space. |
261
+
262
+ Version arguments accept `7`, `v7`, `latest`, `first`, or `-1` / `-2` counting
263
+ back from the newest.
264
+
265
+ ## Where things are kept
266
+
267
+ ```
268
+ ~/.lambda-watcher/
269
+ ├── config.yaml
270
+ ├── index.db # rebuildable index (see `reindex`)
271
+ ├── logs/watcher.log
272
+ ├── reports/ # generated HTML
273
+ ├── quarantine/ # archives that failed, with a reason file
274
+ ├── repos/
275
+ │ └── order-processor/ # git mirror: one commit per version, tagged v0001…
276
+ └── functions/
277
+ └── order-processor/
278
+ └── versions/
279
+ ├── 0001-bd9f77c8/
280
+ │ ├── code/ # the extracted tree
281
+ │ ├── manifest.json # the full analysis
282
+ │ └── package.zip # the original download
283
+ └── 0002-73d375ad/
284
+ ```
285
+
286
+ The directories are the source of truth. `index.db` is a cache you can delete
287
+ and rebuild with `lw reindex`, and the whole store is portable —
288
+ copy it to another machine and reindex.
289
+
290
+ ### The git mirror
291
+
292
+ Each function gets its own git repository at `repos/<name>/`, whose working tree
293
+ is the latest version, with one commit per archived version tagged `v0001`,
294
+ `v0002`, … The folder is named after the function on purpose: it is what an
295
+ editor shows as the workspace root, so an open window says `order-processor`
296
+ rather than something generic. Every tool you already know works on it:
297
+
298
+ ```bash
299
+ lw open order-processor # VS Code, on the whole repo
300
+
301
+ cd "$(lw path order-processor --repo)"
302
+ git diff v0002 v0010 # the diff you originally wanted
303
+ git log --oneline --stat
304
+ ```
305
+
306
+ `open` finds VS Code, Cursor, Windsurf, VSCodium, Zed or Sublime on your `PATH`
307
+ — set `editor:` in the config (or `LAMBDA_WATCHER_EDITOR`, or `--editor`) to
308
+ name a different one. What you get is a folder of real files, not a diff: the
309
+ sidebar reads `order-processor`, and the editor's own file tree, search, Source
310
+ Control panel and timeline all work, with every earlier version a tag away. Name
311
+ a version — `lw open order-processor 3` — to open that version's
312
+ files on their own instead.
313
+
314
+ One repo per function is the point: your 2nd and 10th version of *one* Lambda
315
+ sit next to each other, with no other function's history in the way.
316
+
317
+ ## Configuration
318
+
319
+ `lw init` writes an annotated `~/.lambda-watcher/config.yaml`.
320
+ Everything is optional. The settings worth knowing:
321
+
322
+ ```yaml
323
+ watch:
324
+ dirs: ["~/Downloads"] # add more if you download from several places
325
+ stable_seconds: 2.0 # how long a file must stop changing before it is read
326
+ force_polling: false # turn on for network shares, VM mounts, WSL
327
+ arrival_max_age_seconds: 300 # ignore "modified" events for files older than this
328
+
329
+ store:
330
+ on_ingest: copy # copy | move | leave
331
+ # `move` takes the zip out of Downloads once archived
332
+ strip_wrapper_dir: true # lift a lone `myrepo-1.2.3/` wrapper to the root, so a
333
+ # source archive's ref does not read as a full rewrite
334
+ max_versions_per_function: 0 # 0 keeps everything
335
+
336
+ naming:
337
+ rules: # explicit filename → function name mappings
338
+ - pattern: "^prod[-_](.+?)[-_]deploy"
339
+ name: '\1'
340
+
341
+ diff:
342
+ ignore_vendor: true # hide vendored dependency files in diffs
343
+ context_lines: 3
344
+ ```
345
+
346
+ Set `LAMBDA_WATCHER_HOME` to relocate the whole archive, or
347
+ `LAMBDA_WATCHER_CONFIG` to point at a different config file — useful for
348
+ keeping work and personal archives separate.
349
+
350
+ ### When it guesses the wrong name
351
+
352
+ The filename is a guess, and `lw log` records which strategy was
353
+ used and how confident it was. Two commands fix any mistake:
354
+
355
+ ```bash
356
+ lw rename unknown-a1b2c3d4 order-processor --alias "a1b2c3d4"
357
+ lw merge order-processor-old order-processor
358
+ ```
359
+
360
+ `--alias` teaches it permanently: any future download whose filename contains
361
+ that fragment maps straight to the right function.
362
+
363
+ ## Notes and limits
364
+
365
+ - **Only the code is archived.** A deployment package does not contain the
366
+ function's configuration — memory, timeout, environment variable *values*,
367
+ IAM role, layers or triggers. lambda-watcher infers what it can from the code
368
+ (which env vars are read, which services are called) and flags it, but if you
369
+ want the real configuration archived too, save
370
+ `aws lambda get-function --function-name X > X.json` next to the zip: it is
371
+ picked up as a naming hint, and it is a genuinely useful thing to keep.
372
+ - **Secret scanning is a tripwire, not a security tool.** It catches the
373
+ obvious cases — an AWS key, a private key block, a live Stripe token — and it
374
+ skips placeholders. Treat a finding as a prompt to look, not a verdict.
375
+ - **Layers are separate functions in AWS**, and they are downloaded separately;
376
+ they will be archived as their own entries.
377
+ - **Source archives work too.** A zip from GitHub (or npm, or `git archive`)
378
+ wraps everything in a directory named after the ref — `myrepo-1.2.3/` — and
379
+ names the file the same way. Both are handled: the wrapper is lifted to the
380
+ root so a re-download diffs as an edit rather than a total rewrite, the ref
381
+ becomes the version's label, and the ref is stripped from the name so
382
+ `myrepo-1.2.3.zip`, `myrepo-main.zip` and `myrepo-a1b2c3d.zip` all land as
383
+ versions of one `myrepo`. A trailing `-v2` is still left alone: it is part of
384
+ a name far more often than it is a tag.
385
+ - **A filesystem event is not proof that a file was written.** Windows reports
386
+ a zip as modified when an antivirus scan, the search indexer or OneDrive so
387
+ much as touches it — watchdog asks the OS for attribute and last-access
388
+ changes too — so a background sweep re-announces every zip in the folder at
389
+ once. Events claiming a write to a file nothing has written to are ignored
390
+ (`watch.arrival_max_age_seconds`), and `store.on_ingest: move` only clears
391
+ out a download the watcher saw arrive: a zip that a startup scan or a
392
+ `backfill` merely found is archived where it lies, never deleted.
393
+ - Large vendored packages make for large archives. `store.on_ingest: leave`
394
+ and `store.keep_zip: false` trade the original zips for disk space, and
395
+ `store.max_versions_per_function` caps history.
396
+
397
+ ## Development
398
+
399
+ ```bash
400
+ .venv/bin/pip install -e ".[dev]"
401
+ .venv/bin/python -m pytest
402
+ ```
403
+
404
+ The design decisions behind the analysis and diff layers are written up in
405
+ [docs/design.md](docs/design.md).
406
+
407
+ ## Licence
408
+
409
+ Apache 2.0. See [LICENSE](LICENSE).