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
lambda_watcher/cli.py ADDED
@@ -0,0 +1,1406 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shlex
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import webbrowser
12
+ import zipfile
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import NoReturn, Optional
16
+
17
+ import typer
18
+ import yaml
19
+ from rich.console import Console
20
+ from rich.table import Table
21
+
22
+ from . import __version__
23
+ from .config import Config, default_config_path, load_config
24
+ from .db import Database
25
+ from .diffing import code_dir, diff_from_index
26
+ from .diffing.render_html import render_timeline, write_html
27
+ from .diffing.render_text import render as render_diff
28
+ from .gitmirror import git_available
29
+ from .ingest import Ingestor
30
+ from .service import ServiceError, ServiceStatus, current_status, get_manager
31
+ from .store import Store
32
+ from .utils import format_ts, human_size, relative_ts, rmtree, setup_logging, slugify
33
+
34
+ # Commands open the index freely and the process normally exits straight
35
+ # after, so nothing closed it. Windows disagrees: an open SQLite handle makes
36
+ # the file undeletable, so `reindex` (which replaces index.db) fails whenever
37
+ # another command ran first in the same process. Closing on command completion
38
+ # keeps that deterministic instead of waiting for the garbage collector.
39
+ _OPEN_DBS: list[Database] = []
40
+
41
+
42
+ def _close_open_dbs(*_args: object, **_kwargs: object) -> None:
43
+ while _OPEN_DBS:
44
+ _OPEN_DBS.pop().close()
45
+
46
+
47
+ app = typer.Typer(
48
+ add_completion=True,
49
+ # Bare `lambda-watcher` answers "is it on, and what has it seen?" rather
50
+ # than printing a wall of twenty commands. Someone who has just installed
51
+ # the tool learns more from their own status than from the command list,
52
+ # and `--help` is still one flag away.
53
+ no_args_is_help=False,
54
+ help="Watch your Downloads folder for Lambda deployment zips, archive every "
55
+ "version, and diff any two of them.",
56
+ result_callback=_close_open_dbs,
57
+ )
58
+ console = Console()
59
+ err_console = Console(stderr=True)
60
+
61
+ _CONFIG_PATH: Path | None = None
62
+
63
+
64
+ # ---------------------------------------------------------------- helpers
65
+ def _cfg() -> Config:
66
+ """Load the config, or explain why it could not be loaded.
67
+
68
+ Bare `lw` reads the config now, so a stray tab in the YAML would otherwise
69
+ greet the reader with a traceback on the command they type most. The file is
70
+ hand-edited and the fix is always in it, so name it.
71
+ """
72
+ try:
73
+ cfg = load_config(_CONFIG_PATH)
74
+ except (OSError, ValueError, yaml.YAMLError) as exc:
75
+ path = _CONFIG_PATH or default_config_path()
76
+ _fail(f"could not read {path}:\n {exc}\n Fix it, or delete it to fall back to defaults.")
77
+ cfg.ensure_dirs()
78
+ setup_logging(cfg.log_level, cfg.log_dir / "watcher.log")
79
+ return cfg
80
+
81
+
82
+ def _open_db(cfg: Config) -> Database:
83
+ db = Database(cfg.db_path)
84
+ _OPEN_DBS.append(db)
85
+ return db
86
+
87
+
88
+ def _fail(message: str, code: int = 1) -> NoReturn:
89
+ err_console.print(f"[red]error:[/red] {message}")
90
+ raise typer.Exit(code)
91
+
92
+
93
+ def _complete_function(incomplete: str) -> list[str]:
94
+ """Tab-completion for a function argument.
95
+
96
+ Runs inside the user's shell on every TAB, so it opens its own short-lived
97
+ connection, touches nothing, and swallows everything: a completion that
98
+ raises prints a traceback into the middle of the command line being typed.
99
+ """
100
+ try:
101
+ cfg = load_config(_CONFIG_PATH)
102
+ if not cfg.db_path.exists():
103
+ return []
104
+ with Database(cfg.db_path) as db:
105
+ return [
106
+ row["name"] for row in db.list_functions()
107
+ if row["name"].lower().startswith(incomplete.lower())
108
+ ]
109
+ except Exception: # noqa: BLE001 - never break a prompt
110
+ return []
111
+
112
+
113
+ def _resolve_function(db: Database, ident: str):
114
+ row = db.get_function(ident)
115
+ if row is None:
116
+ names = [r["name"] for r in db.list_functions()]
117
+ hint = f" Known functions: {', '.join(names)}" if names else " Nothing has been archived yet."
118
+ _fail(f"no function matching {ident!r}.{hint}")
119
+ return row
120
+
121
+
122
+ def _resolve_seq(db: Database, function_id: int, spec: str | int | None, default_offset: int = 0) -> int:
123
+ """Turn 'latest', '-2', '7' into a concrete version number."""
124
+ versions = db.list_versions(function_id) # newest first
125
+ if not versions:
126
+ _fail("this function has no archived versions")
127
+ seqs = [int(v["seq"]) for v in versions]
128
+
129
+ if spec is None:
130
+ index = min(default_offset, len(seqs) - 1)
131
+ return seqs[index]
132
+
133
+ text = str(spec).strip().lower()
134
+ if text in {"latest", "last", "head"}:
135
+ return seqs[0]
136
+ if text in {"first", "oldest"}:
137
+ return seqs[-1]
138
+ if text.startswith("v"):
139
+ text = text[1:]
140
+ try:
141
+ value = int(text)
142
+ except ValueError:
143
+ _fail(f"cannot understand version {spec!r}; use a number, 'latest', 'first' or -1")
144
+ if value < 0: # -1 = latest, -2 = the one before it
145
+ index = -value - 1
146
+ if index >= len(seqs):
147
+ _fail(f"only {len(seqs)} version(s) archived")
148
+ return seqs[index]
149
+ if value not in seqs:
150
+ _fail(f"version {value} not found (have: {', '.join(str(s) for s in seqs)})")
151
+ return value
152
+
153
+
154
+ def _version_or_fail(db: Database, function_id: int, seq: int):
155
+ row = db.get_version(function_id, seq)
156
+ if row is None:
157
+ _fail(f"version {seq} not found")
158
+ return row
159
+
160
+
161
+ def _build_diff(db: Database, store: Store, cfg: Config, function_row, a_seq: int, b_seq: int,
162
+ include_vendor: bool | None, compute_diffs: bool = True):
163
+ a = _version_or_fail(db, function_row["id"], a_seq)
164
+ b = _version_or_fail(db, function_row["id"], b_seq)
165
+ for row, seq in ((a, a_seq), (b, b_seq)):
166
+ path = code_dir(store, row)
167
+ if not path.exists():
168
+ err_console.print(
169
+ f"[yellow]warning:[/yellow] code for v{seq:04d} is missing at {path}; "
170
+ "line diffs for it will be empty"
171
+ )
172
+ return diff_from_index(db, store, cfg.diff, function_row["name"], a, b,
173
+ include_vendor=include_vendor, compute_diffs=compute_diffs)
174
+
175
+
176
+ def _open_path(path: Path) -> None:
177
+ try:
178
+ if sys.platform == "darwin":
179
+ subprocess.run(["open", str(path)], check=False)
180
+ elif sys.platform.startswith("win"):
181
+ os.startfile(str(path)) # type: ignore[attr-defined]
182
+ else:
183
+ subprocess.run(["xdg-open", str(path)], check=False)
184
+ except OSError as exc:
185
+ err_console.print(f"[yellow]could not open {path}: {exc}[/yellow]")
186
+
187
+
188
+ #: Editors that take a folder as their argument, in the order they are tried.
189
+ #: Everything here is VS Code or a fork of it except the last two, so `--reuse`
190
+ #: (VS Code's `-r`) applies to all but those.
191
+ _EDITORS = ("code", "cursor", "windsurf", "code-insiders", "codium", "vscodium", "zed", "subl")
192
+ _REUSE_SUPPORTED = {"code", "cursor", "windsurf", "code-insiders", "codium", "vscodium"}
193
+
194
+
195
+ def _resolve_editor(cfg: Config, override: str | None) -> list[str]:
196
+ """The command to launch on a folder, as argv.
197
+
198
+ An explicit choice — the flag, then ``editor`` in the config (which
199
+ ``LAMBDA_WATCHER_EDITOR`` overrides) — is used as given and is an error when
200
+ it is not installed, because silently opening a different editor than the
201
+ one you asked for is worse than the error. With no choice made, the first
202
+ of ``_EDITORS`` on PATH wins.
203
+ """
204
+ chosen = (override or cfg.editor).strip()
205
+ if chosen:
206
+ # A command that resolves as it stands is taken as it stands: on Windows
207
+ # the natural thing to write is a full path, and shlex would turn
208
+ # `C:\Program Files\...\code.cmd` into four broken tokens. Splitting is
209
+ # only for a command that carries arguments.
210
+ if shutil.which(chosen):
211
+ return [chosen]
212
+ argv = shlex.split(chosen)
213
+ if not argv:
214
+ _fail("the editor command is empty")
215
+ if not shutil.which(argv[0]):
216
+ _fail(f"{argv[0]!r} is not on PATH")
217
+ return argv
218
+ for candidate in _EDITORS:
219
+ found = shutil.which(candidate)
220
+ if found:
221
+ return [found]
222
+ _fail(
223
+ "no editor found on PATH (looked for " + ", ".join(_EDITORS) + "). "
224
+ "Pass --editor CMD, set `editor:` in the config, or use `lw path` "
225
+ "and open the folder yourself."
226
+ )
227
+ return [] # unreachable; _fail exits
228
+
229
+
230
+ def _launch_editor(argv: list[str], target: Path, reuse: bool) -> None:
231
+ name = Path(argv[0]).stem
232
+ if reuse:
233
+ if name in _REUSE_SUPPORTED:
234
+ argv = [*argv, "-r"]
235
+ else:
236
+ err_console.print(f"[yellow]--reuse means nothing to {name}; ignoring it[/yellow]")
237
+ try:
238
+ proc = subprocess.run([*argv, str(target)], check=False)
239
+ except OSError as exc:
240
+ _fail(f"could not launch {name}: {exc}")
241
+ return
242
+ if proc.returncode != 0:
243
+ _fail(f"{name} exited with status {proc.returncode}", proc.returncode)
244
+
245
+
246
+ def _version_callback(value: bool) -> None:
247
+ if value:
248
+ console.print(f"lambda-watcher {__version__}")
249
+ raise typer.Exit()
250
+
251
+
252
+ @app.callback(invoke_without_command=True)
253
+ def _main(
254
+ ctx: typer.Context,
255
+ config: Optional[Path] = typer.Option(
256
+ None, "--config", "-c", help="Path to config.yaml (default: ~/.lambda-watcher/config.yaml)."
257
+ ),
258
+ version: bool = typer.Option(
259
+ False, "--version", help="Print the version and exit.",
260
+ callback=_version_callback, is_eager=True,
261
+ ),
262
+ ) -> None:
263
+ global _CONFIG_PATH
264
+ _CONFIG_PATH = config
265
+ if ctx.invoked_subcommand is None:
266
+ _print_status()
267
+
268
+
269
+ # --------------------------------------------------------------- dashboard
270
+ def _home_relative(path: Path) -> str:
271
+ """``~/Downloads`` rather than ``/Users/someone/Downloads``, where it applies."""
272
+ try:
273
+ return "~/" + str(path.relative_to(Path.home()))
274
+ except ValueError:
275
+ return str(path)
276
+
277
+
278
+ def _print_status() -> None:
279
+ """What bare `lw` prints: is it on, what has it seen, what to do next.
280
+
281
+ This is the first thing most people will ever see from the tool, so it
282
+ answers the two questions a newcomer actually has — *is it running* and
283
+ *did it catch anything* — and then names the one or two commands worth
284
+ typing next. Nothing here fails: an archive that does not exist yet is a
285
+ normal state to be in, not an error.
286
+ """
287
+ cfg = _cfg()
288
+ state = current_status(cfg, _CONFIG_PATH)
289
+ watched = ", ".join(_home_relative(d) for d in cfg.watch_dirs())
290
+
291
+ console.print(f"[bold]lambda-watcher[/bold] {__version__}")
292
+ if state.running:
293
+ console.print(f"\n [green]●[/green] watching {watched} [dim]{state.manager}[/dim]")
294
+ elif state.installed:
295
+ console.print(f"\n [yellow]●[/yellow] installed but not running [dim]{state.manager}[/dim]")
296
+ else:
297
+ console.print(f"\n [dim]○[/dim] not watching [dim]{watched} is not being archived[/dim]")
298
+
299
+ db = _open_db(cfg)
300
+ functions, versions_count, total_bytes = db.archive_totals()
301
+ if functions:
302
+ console.print(
303
+ f" [dim]{functions} function{'s' if functions != 1 else ''} · "
304
+ f"{versions_count} version{'s' if versions_count != 1 else ''} · "
305
+ f"{human_size(total_bytes)} in {_home_relative(cfg.root)}[/dim]"
306
+ )
307
+ else:
308
+ console.print(f" [dim]nothing archived yet · {_home_relative(cfg.root)}[/dim]")
309
+
310
+ rows = db.list_functions()[:5]
311
+ if rows:
312
+ table = Table(box=None, show_header=False, padding=(0, 2, 0, 2))
313
+ table.add_column("function")
314
+ table.add_column("latest", justify="right")
315
+ table.add_column("when", style="dim")
316
+ table.add_column("runtime", style="dim")
317
+ for row in rows:
318
+ latest = db.latest_version(int(row["id"]))
319
+ table.add_row(
320
+ row["name"],
321
+ f"v{int(row['latest_seq']):04d}" if row["latest_seq"] else "-",
322
+ relative_ts(row["last_seen"]),
323
+ (latest["runtime"] if latest else "") or "",
324
+ )
325
+ console.print()
326
+ console.print(table)
327
+
328
+ console.print()
329
+ for command, blurb in _next_steps(state, rows[0]["name"] if rows else None):
330
+ console.print(f" [bold]{command}[/bold] [dim]{blurb}[/dim]")
331
+
332
+
333
+ def _next_steps(state: ServiceStatus, newest: str | None) -> list[tuple[str, str]]:
334
+ """The two or three commands most worth typing from where the user is now.
335
+
336
+ Not watching is always the first thing to fix — an archive that has stopped
337
+ growing is the failure this tool has to be loud about — but someone with
338
+ history already deserves to be told how to read it in the same breath.
339
+ """
340
+ steps: list[tuple[str, str]] = []
341
+ if not state.running:
342
+ steps.append(
343
+ ("lw start", "start watching again") if state.installed
344
+ else ("lw setup", "watch your downloads folder from now on")
345
+ )
346
+ if newest is None:
347
+ if state.running:
348
+ steps.append(("lw doctor", "check the watch folder is the right one"))
349
+ return steps
350
+ steps.append((f'lw diff "{newest}"', "what changed in the last version"))
351
+ if state.running:
352
+ steps.append((f'lw report "{newest}"', "the whole history, in your browser"))
353
+ return steps
354
+
355
+
356
+ @app.command(rich_help_panel="Everyday")
357
+ def status() -> None:
358
+ """Is the watcher running, and what has it archived? (Also plain `lw`.)"""
359
+ _print_status()
360
+
361
+
362
+ # ----------------------------------------------------------- getting started
363
+ @app.command(rich_help_panel="Everyday")
364
+ def setup(
365
+ yes: bool = typer.Option(False, "--yes", "-y", help="Take the default answer to every prompt."),
366
+ no_service: bool = typer.Option(
367
+ False, "--no-service", help="Set up the archive but do not run in the background."
368
+ ),
369
+ ) -> None:
370
+ """Set everything up: config, background watcher, and any history already on disk."""
371
+ config_path = _CONFIG_PATH or default_config_path()
372
+ console.print(f"[bold]lambda-watcher[/bold] {__version__} — setting up\n")
373
+
374
+ # The config is written before anything reads one, so that every step below
375
+ # — which folders to watch, above all — runs against the file the user will
376
+ # be editing rather than against defaults that happen to match it today.
377
+ if not config_path.exists():
378
+ from .templates import DEFAULT_CONFIG_YAML
379
+ config_path.parent.mkdir(parents=True, exist_ok=True)
380
+ config_path.write_text(DEFAULT_CONFIG_YAML, encoding="utf-8")
381
+ console.print(f" [green]✓[/green] wrote {_home_relative(config_path)}")
382
+ else:
383
+ console.print(f" [green]✓[/green] using {_home_relative(config_path)}")
384
+
385
+ cfg = _cfg()
386
+ console.print(f" [green]✓[/green] archive at {_home_relative(cfg.root)}")
387
+
388
+ missing = [d for d in cfg.watch_dirs() if not d.exists()]
389
+ for directory in cfg.watch_dirs():
390
+ mark = "[green]✓[/green]" if directory.exists() else "[red]✗[/red]"
391
+ console.print(f" {mark} watching {_home_relative(directory)}")
392
+ if missing:
393
+ console.print(
394
+ f"\n[yellow]note:[/yellow] {len(missing)} watch folder(s) do not exist. "
395
+ f"Set [bold]watch.dirs[/bold] in {_home_relative(config_path)} and run setup again."
396
+ )
397
+
398
+ _offer_backfill(cfg, yes)
399
+
400
+ if no_service:
401
+ console.print("\n[dim]Skipping the background watcher. Run [bold]lw watch[/bold] "
402
+ "when you want it, or [bold]lw start[/bold] to install it later.[/dim]")
403
+ else:
404
+ console.print()
405
+ _start_service(cfg)
406
+
407
+ console.print("\n[dim]That is the whole setup. Download a Lambda zip as you normally would; "
408
+ "run [bold]lw[/bold] to see what it caught.[/dim]")
409
+
410
+
411
+ def _offer_backfill(cfg: Config, yes: bool) -> None:
412
+ """Archive zips already sitting in the watch folders, if the user wants them.
413
+
414
+ The watcher's own start-up scan only reaches back ``scan_on_start_max_age_hours``,
415
+ so anything older is invisible unless it is imported deliberately. Importing
416
+ it is not the obvious default — a Downloads folder is full of zips that have
417
+ nothing to do with Lambda — so this asks, and only assumes yes when told to.
418
+ """
419
+ candidates: list[Path] = []
420
+ ingestor = Ingestor(cfg, _open_db(cfg))
421
+ for directory in cfg.watch_dirs():
422
+ if not directory.exists():
423
+ continue
424
+ for extension in cfg.watch.extensions:
425
+ candidates += [
426
+ p for p in sorted(directory.glob(f"*{extension}")) if ingestor.is_candidate(p)
427
+ ]
428
+ if not candidates:
429
+ return
430
+
431
+ console.print(f"\n found {len(candidates)} zip(s) already in your download folder(s)")
432
+ if not yes:
433
+ if not sys.stdin.isatty():
434
+ console.print(" [dim]run [bold]lw backfill <folder>[/bold] to archive them[/dim]")
435
+ return
436
+ if not typer.confirm(" archive them now as history?", default=False):
437
+ console.print(" [dim]skipped — [bold]lw backfill <folder>[/bold] does it later[/dim]")
438
+ return
439
+
440
+ candidates.sort(key=lambda p: p.stat().st_mtime) # oldest first, so seq matches history
441
+ stats: dict[str, int] = {}
442
+ for path in candidates:
443
+ result = ingestor.ingest(path, just_downloaded=False)
444
+ stats[result.status] = stats.get(result.status, 0) + 1
445
+ console.print(" " + " ".join(f"[bold]{k}[/bold]: {v}" for k, v in sorted(stats.items())))
446
+
447
+
448
+ # ------------------------------------------------------------------ service
449
+ def _start_service(cfg: Config) -> None:
450
+ """Install and start the background watcher, explaining whatever happens."""
451
+ manager = get_manager(cfg, _CONFIG_PATH)
452
+ try:
453
+ state = manager.install()
454
+ except ServiceError as exc:
455
+ _fail(
456
+ f"{exc}\n"
457
+ " Run [bold]lw watch[/bold] in a terminal instead, or see docs/autostart.md "
458
+ "for the manual recipe."
459
+ )
460
+ if state.running:
461
+ console.print(f" [green]●[/green] watching in the background [dim]{state.manager}[/dim]")
462
+ else:
463
+ console.print(
464
+ f" [yellow]●[/yellow] registered with {state.manager} but not running yet"
465
+ f"{' — ' + state.detail if state.detail else ''}"
466
+ )
467
+ if state.manager == "pidfile":
468
+ console.print(
469
+ " [dim]no systemd user session here, so this will not come back after a reboot; "
470
+ "run [bold]lw start[/bold] again, or see docs/autostart.md[/dim]"
471
+ )
472
+
473
+
474
+ @app.command(rich_help_panel="Watching")
475
+ def start() -> None:
476
+ """Watch in the background, now and after every reboot."""
477
+ cfg = _cfg()
478
+ _start_service(cfg)
479
+
480
+
481
+ @app.command(rich_help_panel="Watching")
482
+ def stop(
483
+ remove: bool = typer.Option(
484
+ False, "--remove", help="Also unregister it, so it does not come back at login."
485
+ ),
486
+ ) -> None:
487
+ """Stop the background watcher."""
488
+ cfg = _cfg()
489
+ manager = get_manager(cfg, _CONFIG_PATH)
490
+ try:
491
+ if remove:
492
+ manager.uninstall()
493
+ else:
494
+ manager.stop()
495
+ except ServiceError as exc:
496
+ _fail(str(exc))
497
+ console.print(
498
+ "[dim]unregistered[/dim]" if remove else
499
+ "[dim]stopped (it will start again at login; --remove prevents that)[/dim]"
500
+ )
501
+
502
+
503
+ @app.command(rich_help_panel="Watching")
504
+ def restart() -> None:
505
+ """Stop and start the background watcher — use it after editing the config."""
506
+ cfg = _cfg()
507
+ manager = get_manager(cfg, _CONFIG_PATH)
508
+ try:
509
+ state = manager.restart()
510
+ except ServiceError as exc:
511
+ _fail(str(exc))
512
+ console.print(f"[dim]{state.summary} ({state.manager})[/dim]")
513
+
514
+
515
+ # ----------------------------------------------------------------- checkup
516
+ @app.command(rich_help_panel="Everyday")
517
+ def doctor() -> None:
518
+ """Check that everything the tool needs is in place."""
519
+ cfg = _cfg()
520
+ rows: list[tuple[str, str, str]] = []
521
+
522
+ config_path = _CONFIG_PATH or default_config_path()
523
+ rows.append(("config file", "ok" if config_path.exists() else "using defaults", str(config_path)))
524
+ rows.append(("archive root", "ok" if cfg.root.exists() else "missing", str(cfg.root)))
525
+
526
+ for directory in cfg.watch_dirs():
527
+ rows.append((
528
+ "watch dir",
529
+ "ok" if directory.exists() else "MISSING",
530
+ str(directory),
531
+ ))
532
+ rows.append((
533
+ "git mirror",
534
+ "ok" if git_available() else "git not found",
535
+ "enabled" if cfg.git_mirror.enabled else "disabled in config",
536
+ ))
537
+
538
+ try:
539
+ db = _open_db(cfg)
540
+ functions = db.list_functions()
541
+ total_versions = sum(int(f["version_count"] or 0) for f in functions)
542
+ rows.append(("index", "ok", f"{len(functions)} function(s), {total_versions} version(s)"))
543
+ db.close()
544
+ except Exception as exc: # noqa: BLE001
545
+ rows.append(("index", "FAILED", str(exc)))
546
+
547
+ try:
548
+ usage = shutil.disk_usage(cfg.root)
549
+ rows.append(("disk free", "ok", human_size(usage.free)))
550
+ except OSError:
551
+ pass
552
+
553
+ table = Table(box=None, header_style="bold", padding=(0, 2, 0, 0))
554
+ table.add_column("check")
555
+ table.add_column("status")
556
+ table.add_column("detail", style="dim")
557
+ for name, status, detail in rows:
558
+ style = "green" if status == "ok" else ("red" if status.isupper() else "yellow")
559
+ table.add_row(name, f"[{style}]{status}[/{style}]", detail)
560
+ console.print(table)
561
+
562
+
563
+ # ------------------------------------------------------------------ watch
564
+ @app.command(rich_help_panel="Watching")
565
+ def watch(
566
+ once: bool = typer.Option(False, "--once", help="Process what is already there, then exit."),
567
+ dir: Optional[list[Path]] = typer.Option(
568
+ None, "--dir", "-d", help="Watch this directory instead of the configured ones."
569
+ ),
570
+ ) -> None:
571
+ """Watch the downloads folder and archive every Lambda zip that lands in it."""
572
+ from .watcher import Watcher
573
+
574
+ cfg = _cfg()
575
+ if dir:
576
+ cfg.watch.dirs = [str(Path(d).expanduser()) for d in dir]
577
+
578
+ db = _open_db(cfg)
579
+ ingestor = Ingestor(cfg, db)
580
+
581
+ def report(result) -> None:
582
+ colours = {
583
+ "new": "green", "unchanged": "cyan", "duplicate-download": "dim", "failed": "red",
584
+ }
585
+ colour = colours.get(result.status, "white")
586
+ label = f"{result.function_name or '?'}"
587
+ if result.seq:
588
+ label += f" v{result.seq:04d}"
589
+ console.print(
590
+ f"[{colour}]{result.status:>18}[/{colour}] {label} "
591
+ f"[dim]{result.source.name} — {result.change_summary or result.message}[/dim]"
592
+ )
593
+ if result.change_impact:
594
+ console.print(f"[dim]{'':>18} {result.change_impact}[/dim]")
595
+ # The comparison is rendered during ingest, so what is offered here is a
596
+ # file that already exists rather than a command to go and produce it.
597
+ if result.report_path is not None:
598
+ console.print(f"[dim]{'':>18} report: {_home_relative(result.report_path)}[/dim]")
599
+ elif result.status == "new" and result.changed_from:
600
+ console.print(
601
+ f"[dim]{'':>18} review: lw diff "
602
+ f'"{result.function_name}" --html --open[/dim]'
603
+ )
604
+
605
+ watcher = Watcher(cfg, db, ingestor, on_result=report)
606
+ try:
607
+ watcher.start()
608
+ except FileNotFoundError as exc:
609
+ _fail(str(exc))
610
+
611
+ console.print(
612
+ f"[bold]lambda-watcher[/bold] {__version__} — archiving into {cfg.root}\n"
613
+ f"[dim]watching {', '.join(str(d) for d in cfg.watch_dirs())}. Press Ctrl-C to stop.[/dim]"
614
+ )
615
+ if once:
616
+ watcher.drain(timeout=cfg.watch.max_wait_seconds + 60)
617
+ watcher.stop()
618
+ console.print("[dim]done[/dim]")
619
+ return
620
+ watcher.wait_forever()
621
+ watcher.stop()
622
+ console.print("[dim]stopped[/dim]")
623
+
624
+
625
+ @app.command(rich_help_panel="Watching")
626
+ def ingest(
627
+ paths: list[Path] = typer.Argument(..., help="Zip file(s) to archive."),
628
+ function: Optional[str] = typer.Option(
629
+ None, "--as", "-a", help="Force the function name instead of guessing it."
630
+ ),
631
+ force: bool = typer.Option(False, "--force", help="Archive even if the content is unchanged."),
632
+ label: Optional[str] = typer.Option(None, "--label", "-l", help="Note to attach to this version."),
633
+ ) -> None:
634
+ """Archive one or more zip files by hand."""
635
+ cfg = _cfg()
636
+ db = _open_db(cfg)
637
+ ingestor = Ingestor(cfg, db)
638
+ failures = 0
639
+ for path in paths:
640
+ result = ingestor.ingest(Path(path).expanduser(), function, force, label)
641
+ colour = {"new": "green", "unchanged": "cyan", "duplicate-download": "dim"}.get(
642
+ result.status, "red"
643
+ )
644
+ suffix = f" v{result.seq:04d}" if result.seq else ""
645
+ console.print(
646
+ f"[{colour}]{result.status}[/{colour}] {result.function_name or '?'}{suffix} "
647
+ f"[dim]({result.message})[/dim]"
648
+ )
649
+ if result.status == "failed":
650
+ failures += 1
651
+ if failures:
652
+ raise typer.Exit(1)
653
+
654
+
655
+ @app.command(rich_help_panel="Watching")
656
+ def backfill(
657
+ directory: Path = typer.Argument(..., help="Folder full of previously downloaded zips."),
658
+ pattern: str = typer.Option("*.zip", "--pattern", "-p", help="Glob to match."),
659
+ recursive: bool = typer.Option(False, "--recursive", "-r", help="Descend into subfolders."),
660
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be archived."),
661
+ ) -> None:
662
+ """Import a folder of old backups, oldest first, so version order matches history."""
663
+ cfg = _cfg()
664
+ directory = Path(directory).expanduser()
665
+ if not directory.is_dir():
666
+ _fail(f"{directory} is not a directory")
667
+
668
+ files = sorted(
669
+ (directory.rglob(pattern) if recursive else directory.glob(pattern)),
670
+ key=lambda p: p.stat().st_mtime,
671
+ )
672
+ files = [f for f in files if f.is_file()]
673
+ if not files:
674
+ console.print("[yellow]nothing to import[/yellow]")
675
+ return
676
+
677
+ if dry_run:
678
+ from .identify import identify
679
+
680
+ table = Table(box=None, header_style="bold", padding=(0, 2, 0, 0))
681
+ table.add_column("file")
682
+ table.add_column("modified", style="dim")
683
+ table.add_column("would become")
684
+ table.add_column("via", style="dim")
685
+ for path in files:
686
+ ident = identify(path, cfg.naming, None)
687
+ modified = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
688
+ table.add_row(
689
+ path.name,
690
+ format_ts(modified.isoformat()),
691
+ ident.name,
692
+ f"{ident.strategy}/{ident.confidence}",
693
+ )
694
+ console.print(table)
695
+ console.print(f"[dim]{len(files)} file(s). Re-run without --dry-run to import.[/dim]")
696
+ return
697
+
698
+ db = _open_db(cfg)
699
+ ingestor = Ingestor(cfg, db)
700
+ stats: dict[str, int] = {}
701
+ for path in files:
702
+ # Someone else's backup folder: archive from it, never delete out of it.
703
+ result = ingestor.ingest(path, just_downloaded=False)
704
+ stats[result.status] = stats.get(result.status, 0) + 1
705
+ suffix = f" v{result.seq:04d}" if result.seq else ""
706
+ console.print(f" {result.status:>18} {result.function_name or '?'}{suffix} [dim]{path.name}[/dim]")
707
+ console.print("\n" + " ".join(f"[bold]{k}[/bold]: {v}" for k, v in sorted(stats.items())))
708
+
709
+
710
+ # ------------------------------------------------------------- inspection
711
+ @app.command("ls", rich_help_panel="Everyday")
712
+ def list_functions() -> None:
713
+ """List every Lambda function that has been archived."""
714
+ cfg = _cfg()
715
+ db = _open_db(cfg)
716
+ rows = db.list_functions()
717
+ if not rows:
718
+ console.print(
719
+ "[dim]Nothing archived yet. "
720
+ "Run [bold]lw start[/bold] and download a zip.[/dim]"
721
+ )
722
+ return
723
+ table = Table(box=None, header_style="bold", padding=(0, 2, 0, 0))
724
+ table.add_column("function")
725
+ table.add_column("versions", justify="right")
726
+ table.add_column("latest", justify="right")
727
+ table.add_column("last seen", style="dim")
728
+ table.add_column("runtime", style="dim")
729
+ for row in rows:
730
+ latest = db.latest_version(int(row["id"]))
731
+ table.add_row(
732
+ row["name"],
733
+ str(row["version_count"]),
734
+ f"v{int(row['latest_seq']):04d}" if row["latest_seq"] else "-",
735
+ format_ts(row["last_seen"]),
736
+ (latest["runtime"] if latest else "") or "",
737
+ )
738
+ console.print(table)
739
+
740
+
741
+ @app.command(rich_help_panel="Reading the archive")
742
+ def versions(
743
+ function: str = typer.Argument(
744
+ ..., help="Function name (a unique substring works).",
745
+ autocompletion=_complete_function,
746
+ ),
747
+ limit: int = typer.Option(30, "--limit", "-n", help="How many to show."),
748
+ ) -> None:
749
+ """List the archived versions of one function."""
750
+ cfg = _cfg()
751
+ db = _open_db(cfg)
752
+ row = _resolve_function(db, function)
753
+ rows = db.list_versions(int(row["id"]), limit)
754
+ if not rows:
755
+ console.print("[dim]no versions archived[/dim]")
756
+ return
757
+
758
+ table = Table(box=None, header_style="bold", padding=(0, 2, 0, 0),
759
+ title=row["name"], title_justify="left")
760
+ table.add_column("version")
761
+ table.add_column("archived", style="dim")
762
+ table.add_column("files", justify="right")
763
+ table.add_column("size", justify="right")
764
+ table.add_column("handler")
765
+ table.add_column("downloaded as", style="dim")
766
+ table.add_column("label", style="cyan")
767
+ for version in rows:
768
+ table.add_row(
769
+ f"v{int(version['seq']):04d}",
770
+ format_ts(version["ingested_at"]),
771
+ f"{version['file_count']:,}",
772
+ human_size(version["total_size"]),
773
+ version["handler"] or "-",
774
+ version["source_name"] or "-",
775
+ version["label"] or "",
776
+ )
777
+ console.print(table)
778
+ console.print(
779
+ f"\n[dim]Compare the last two: [bold]lw diff \"{row['name']}\"[/bold][/dim]"
780
+ )
781
+
782
+
783
+ @app.command(rich_help_panel="Reading the archive")
784
+ def show(
785
+ function: str = typer.Argument(..., autocompletion=_complete_function),
786
+ version: Optional[str] = typer.Argument(None, help="Version number, or 'latest' (default)."),
787
+ files: bool = typer.Option(False, "--files", help="List every file in the package."),
788
+ json_out: bool = typer.Option(False, "--json", help="Print the raw manifest."),
789
+ ) -> None:
790
+ """Show what one archived version contains."""
791
+ cfg = _cfg()
792
+ db = _open_db(cfg)
793
+ store = Store(cfg)
794
+ row = _resolve_function(db, function)
795
+ seq = _resolve_seq(db, int(row["id"]), version)
796
+ version_row = _version_or_fail(db, int(row["id"]), seq)
797
+ version_dir = store.resolve_version_dir(version_row["dir"])
798
+
799
+ if json_out:
800
+ manifest = store.read_manifest(version_dir)
801
+ console.print_json(json.dumps(manifest or dict(version_row)))
802
+ return
803
+
804
+ console.print(f"[bold]{row['name']}[/bold] [cyan]v{seq:04d}[/cyan]")
805
+ console.print(f" archived {format_ts(version_row['ingested_at'])}")
806
+ console.print(f" source {version_row['source_name']}")
807
+ console.print(
808
+ f" runtime {version_row['runtime']} "
809
+ f"[dim]({version_row['runtime_confidence']} confidence)[/dim]"
810
+ )
811
+ console.print(f" handler {version_row['handler'] or '-'}")
812
+ console.print(
813
+ f" contents {version_row['file_count']:,} files, {human_size(version_row['total_size'])} "
814
+ f"[dim]({version_row['code_file_count']:,} first-party, {version_row['code_lines']:,} lines)[/dim]"
815
+ )
816
+ console.print(f" tree hash [dim]{version_row['tree_hash'][:16]}[/dim]")
817
+ console.print(f" location [dim]{version_dir}[/dim]")
818
+
819
+ deps = db.deps_for(int(version_row["id"]))
820
+ if deps:
821
+ installed = [d for d in deps if not d["is_declared"]]
822
+ declared = [d for d in deps if d["is_declared"]]
823
+ console.print(
824
+ f"\n[bold]Dependencies[/bold] [dim]{len(declared)} declared, {len(installed)} installed[/dim]"
825
+ )
826
+ for dep in (installed or declared)[:25]:
827
+ console.print(f" {dep['name']} [dim]{dep['version'] or ''}[/dim]")
828
+ if len(installed or declared) > 25:
829
+ console.print(f" [dim]… {len(installed or declared) - 25} more[/dim]")
830
+
831
+ env = db.env_for(int(version_row["id"]))
832
+ if env:
833
+ console.print("\n[bold]Environment variables read[/bold]")
834
+ console.print(" " + ", ".join(sorted({e["name"] for e in env})))
835
+
836
+ services = db.services_for(int(version_row["id"]))
837
+ if services:
838
+ console.print("\n[bold]AWS services used[/bold]")
839
+ console.print(" " + ", ".join(sorted({s["service"] for s in services})))
840
+
841
+ findings = db.findings_for(int(version_row["id"]))
842
+ if findings:
843
+ console.print("\n[bold]Findings[/bold]")
844
+ for finding in findings[:20]:
845
+ colour = {"high": "red", "medium": "yellow"}.get(finding["severity"], "dim")
846
+ console.print(
847
+ f" [{colour}]{finding['severity']:>6}[/{colour}] {finding['kind']} "
848
+ f"[dim]{finding['path']}:{finding['line']} {finding['detail']}[/dim]"
849
+ )
850
+
851
+ if files:
852
+ console.print("\n[bold]Files[/bold]")
853
+ for entry in db.files_for(int(version_row["id"])):
854
+ marker = "[dim]v[/dim]" if entry["is_vendor"] else " "
855
+ console.print(f" {marker} {entry['path']} [dim]{human_size(entry['size'])}[/dim]")
856
+
857
+
858
+ # ------------------------------------------------------------------- diff
859
+ @app.command(rich_help_panel="Everyday")
860
+ def diff(
861
+ function: str = typer.Argument(..., autocompletion=_complete_function),
862
+ from_: Optional[str] = typer.Option(
863
+ None, "--from", "-f", help="Older version (default: the one before --to)."
864
+ ),
865
+ to: Optional[str] = typer.Option(None, "--to", "-t", help="Newer version (default: latest)."),
866
+ html: bool = typer.Option(False, "--html", help="Write an HTML report instead of terminal output."),
867
+ open_report: bool = typer.Option(False, "--open", help="Open the HTML report in your browser."),
868
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Where to write the HTML report."),
869
+ vendor: bool = typer.Option(False, "--vendor", help="Include vendored dependency files."),
870
+ no_patch: bool = typer.Option(False, "--no-patch", help="Summary only, no line diffs."),
871
+ json_out: bool = typer.Option(False, "--json", help="Emit the diff as JSON."),
872
+ ) -> None:
873
+ """Compare two versions of a function. Defaults to the last two."""
874
+ cfg = _cfg()
875
+ db = _open_db(cfg)
876
+ store = Store(cfg)
877
+ row = _resolve_function(db, function)
878
+ function_id = int(row["id"])
879
+
880
+ b_seq = _resolve_seq(db, function_id, to, default_offset=0)
881
+ if from_ is None:
882
+ available = [int(v["seq"]) for v in db.list_versions(function_id) if int(v["seq"]) < b_seq]
883
+ if not available:
884
+ _fail(f"v{b_seq:04d} is the oldest archived version; nothing to compare it against")
885
+ a_seq = max(available)
886
+ else:
887
+ a_seq = _resolve_seq(db, function_id, from_, default_offset=1)
888
+
889
+ if a_seq == b_seq:
890
+ _fail("--from and --to are the same version")
891
+ if a_seq > b_seq:
892
+ a_seq, b_seq = b_seq, a_seq
893
+
894
+ include_vendor = True if vendor else None
895
+ result = _build_diff(db, store, cfg, row, a_seq, b_seq, include_vendor,
896
+ compute_diffs=not no_patch)
897
+
898
+ if json_out:
899
+ console.print_json(json.dumps(result.as_dict()))
900
+ return
901
+
902
+ if html or open_report or output:
903
+ target = Path(output).expanduser() if output else (
904
+ cfg.reports_dir / f"{slugify(row['name'])}-v{a_seq:04d}-v{b_seq:04d}.html"
905
+ )
906
+ write_html(result, target)
907
+ console.print(f"[green]wrote[/green] {target}")
908
+ if open_report:
909
+ webbrowser.open(target.resolve().as_uri())
910
+ return
911
+
912
+ render_diff(console, result, show_diffs=not no_patch)
913
+
914
+
915
+ @app.command(rich_help_panel="Everyday")
916
+ def report(
917
+ function: str = typer.Argument(..., autocompletion=_complete_function),
918
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Directory for the report."),
919
+ open_report: bool = typer.Option(False, "--open", help="Open the index in your browser."),
920
+ limit: int = typer.Option(25, "--limit", "-n", help="How many recent versions to include."),
921
+ vendor: bool = typer.Option(False, "--vendor", help="Include vendored files in the diffs."),
922
+ ) -> None:
923
+ """Build a browsable HTML history: every version plus a diff for each step."""
924
+ cfg = _cfg()
925
+ db = _open_db(cfg)
926
+ store = Store(cfg)
927
+ row = _resolve_function(db, function)
928
+ function_id = int(row["id"])
929
+ all_versions = db.list_versions(function_id)
930
+ if not all_versions:
931
+ _fail("nothing archived for this function yet")
932
+
933
+ selected = all_versions[:limit]
934
+ target_dir = Path(output).expanduser() if output else cfg.reports_dir / slugify(row["name"])
935
+ target_dir.mkdir(parents=True, exist_ok=True)
936
+
937
+ entries: list[dict] = []
938
+ seqs = [int(v["seq"]) for v in selected]
939
+ include_vendor = True if vendor else None
940
+
941
+ for version in selected:
942
+ seq = int(version["seq"])
943
+ entry = {
944
+ "seq": seq,
945
+ "ingested_at": version["ingested_at"],
946
+ "runtime": version["runtime"],
947
+ "handler": version["handler"],
948
+ "file_count": version["file_count"],
949
+ "total_size": version["total_size"],
950
+ "source_name": version["source_name"],
951
+ "label": version["label"],
952
+ }
953
+ previous = [s for s in seqs if s < seq]
954
+ if previous:
955
+ a_seq = max(previous)
956
+ pair = _build_diff(db, store, cfg, row, a_seq, seq, include_vendor)
957
+ filename = f"v{a_seq:04d}-v{seq:04d}.html"
958
+ write_html(pair, target_dir / filename)
959
+ entry["diff_href"] = filename
960
+ entry["diff_summary"] = pair.headline()
961
+ entries.append(entry)
962
+
963
+ index = target_dir / "index.html"
964
+ index.write_text(render_timeline(row["name"], entries), encoding="utf-8")
965
+ console.print(f"[green]wrote[/green] {index} [dim]({len(entries)} versions)[/dim]")
966
+ if open_report:
967
+ webbrowser.open(index.resolve().as_uri())
968
+
969
+
970
+ # ---------------------------------------------------------------- editing
971
+ @app.command(rich_help_panel="Housekeeping")
972
+ def rename(
973
+ current: str = typer.Argument(
974
+ ..., help="The function as it is recorded now.", autocompletion=_complete_function
975
+ ),
976
+ new_name: str = typer.Argument(..., help="What it should be called."),
977
+ alias: Optional[str] = typer.Option(
978
+ None, "--alias", help="Also remember this filename fragment as belonging to the function."
979
+ ),
980
+ ) -> None:
981
+ """Fix a misidentified function name (and optionally remember the mapping)."""
982
+ cfg = _cfg()
983
+ db = _open_db(cfg)
984
+ row = _resolve_function(db, current)
985
+ existing = db.get_function_by_name(new_name)
986
+ if existing and int(existing["id"]) != int(row["id"]):
987
+ _fail(
988
+ f"{new_name!r} already exists. Use `lw merge {current!r} {new_name!r}` "
989
+ "to combine them."
990
+ )
991
+
992
+ store = Store(cfg)
993
+ old_slug = row["slug"]
994
+ new_slug = slugify(new_name)
995
+ old_dir = store.function_dir(old_slug)
996
+ new_dir = store.function_dir(new_slug)
997
+ if old_slug != new_slug and old_dir.exists():
998
+ if new_dir.exists():
999
+ _fail(f"{new_dir} already exists on disk; move it aside first")
1000
+ old_dir.rename(new_dir)
1001
+ for version in db.list_versions(int(row["id"])):
1002
+ updated = version["dir"].replace(f"functions/{old_slug}/", f"functions/{new_slug}/", 1)
1003
+ db.conn.execute("UPDATE versions SET dir = ? WHERE id = ?", (updated, version["id"]))
1004
+ if old_slug != new_slug:
1005
+ # The mirror lives outside the function directory, so it does not move
1006
+ # with it — and its folder name is what an editor puts in the sidebar.
1007
+ old_repo = store.repo_dir(old_slug)
1008
+ new_repo = cfg.repos_dir / new_slug
1009
+ if old_repo.exists() and not new_repo.exists():
1010
+ try:
1011
+ old_repo.rename(new_repo)
1012
+ except OSError as exc:
1013
+ err_console.print(f"[yellow]could not move {old_repo} to {new_repo}: {exc}[/yellow]")
1014
+
1015
+ db.rename_function(int(row["id"]), new_name, new_slug)
1016
+ if alias:
1017
+ db.add_alias(int(row["id"]), alias)
1018
+ console.print(f"[green]renamed[/green] {row['name']} → {new_name}")
1019
+ if alias:
1020
+ console.print(f"[dim]future downloads containing {alias!r} will map here automatically[/dim]")
1021
+
1022
+
1023
+ @app.command(rich_help_panel="Housekeeping")
1024
+ def merge(
1025
+ source: str = typer.Argument(
1026
+ ..., help="Function whose versions should move.", autocompletion=_complete_function
1027
+ ),
1028
+ target: str = typer.Argument(
1029
+ ..., help="Function they should move into.", autocompletion=_complete_function
1030
+ ),
1031
+ ) -> None:
1032
+ """Combine two entries that are really the same Lambda, renumbering by time."""
1033
+ cfg = _cfg()
1034
+ db = _open_db(cfg)
1035
+ store = Store(cfg)
1036
+ src = _resolve_function(db, source)
1037
+ dst = _resolve_function(db, target)
1038
+ if int(src["id"]) == int(dst["id"]):
1039
+ _fail("source and target are the same function")
1040
+
1041
+ moving = db.list_versions(int(src["id"]))
1042
+ if not moving:
1043
+ db.delete_function(int(src["id"]))
1044
+ console.print("[green]merged[/green] (the source had no versions)")
1045
+ return
1046
+
1047
+ everything = list(db.list_versions(int(dst["id"]))) + list(moving)
1048
+ everything.sort(key=lambda v: (v["ingested_at"], v["seq"]))
1049
+
1050
+ with db.transaction():
1051
+ # Park every version on a temporary sequence to dodge the UNIQUE index.
1052
+ for offset, version in enumerate(everything, start=1):
1053
+ db.conn.execute(
1054
+ "UPDATE versions SET function_id = ?, seq = ? WHERE id = ?",
1055
+ (int(dst["id"]), -offset, version["id"]),
1056
+ )
1057
+ for new_seq, version in enumerate(everything, start=1):
1058
+ db.conn.execute("UPDATE versions SET seq = ? WHERE id = ?", (new_seq, version["id"]))
1059
+ db.conn.execute("UPDATE aliases SET function_id = ? WHERE function_id = ?",
1060
+ (int(dst["id"]), int(src["id"])))
1061
+ db.conn.execute("DELETE FROM functions WHERE id = ?", (int(src["id"]),))
1062
+
1063
+ src_dir = store.function_dir(src["slug"])
1064
+ dst_versions = store.versions_dir(dst["slug"])
1065
+ dst_versions.mkdir(parents=True, exist_ok=True)
1066
+ if src_dir.exists():
1067
+ for version_dir in (src_dir / "versions").glob("*"):
1068
+ if version_dir.is_dir():
1069
+ destination = dst_versions / version_dir.name
1070
+ if not destination.exists():
1071
+ shutil.move(str(version_dir), str(destination))
1072
+ rmtree(src_dir)
1073
+ # The target's mirror no longer matches the renumbered versions, but the
1074
+ # source's belongs to a function that no longer exists at all.
1075
+ rmtree(store.repo_dir(src["slug"]))
1076
+
1077
+ # Directory names still carry the old sequence numbers; re-point the index.
1078
+ for version in db.list_versions(int(dst["id"])):
1079
+ stored = store.resolve_version_dir(version["dir"])
1080
+ if stored.exists():
1081
+ continue
1082
+ candidate = dst_versions / Path(version["dir"]).name
1083
+ if candidate.exists():
1084
+ db.conn.execute(
1085
+ "UPDATE versions SET dir = ? WHERE id = ?",
1086
+ (store.relative(candidate), version["id"]),
1087
+ )
1088
+
1089
+ console.print(
1090
+ f"[green]merged[/green] {src['name']} into {dst['name']} "
1091
+ f"({len(everything)} versions, renumbered by archive time)"
1092
+ )
1093
+ console.print("[dim]run `lw reindex` if any diffs look wrong[/dim]")
1094
+
1095
+
1096
+ @app.command(rich_help_panel="Housekeeping")
1097
+ def label(
1098
+ function: str = typer.Argument(..., autocompletion=_complete_function),
1099
+ version: str = typer.Argument(..., help="Version number, or 'latest'."),
1100
+ text: str = typer.Argument(..., help="Note to attach, e.g. 'prod deploy 2026-03-01'."),
1101
+ ) -> None:
1102
+ """Annotate a version so you can recognise it later."""
1103
+ cfg = _cfg()
1104
+ db = _open_db(cfg)
1105
+ row = _resolve_function(db, function)
1106
+ seq = _resolve_seq(db, int(row["id"]), version)
1107
+ version_row = _version_or_fail(db, int(row["id"]), seq)
1108
+ db.set_version_label(int(version_row["id"]), text or None)
1109
+ console.print(f"[green]labelled[/green] {row['name']} v{seq:04d}: {text}")
1110
+
1111
+
1112
+ @app.command("rm", rich_help_panel="Housekeeping")
1113
+ def remove(
1114
+ function: str = typer.Argument(..., autocompletion=_complete_function),
1115
+ yes: bool = typer.Option(False, "--yes", "-y", help="Do not ask for confirmation."),
1116
+ ) -> None:
1117
+ """Delete a function and everything archived for it."""
1118
+ cfg = _cfg()
1119
+ db = _open_db(cfg)
1120
+ store = Store(cfg)
1121
+ row = _resolve_function(db, function)
1122
+ count = len(db.list_versions(int(row["id"])))
1123
+ if not yes:
1124
+ confirm = typer.confirm(
1125
+ f"Delete {row['name']} and all {count} archived version(s)? This cannot be undone"
1126
+ )
1127
+ if not confirm:
1128
+ raise typer.Abort()
1129
+ rmtree(store.function_dir(row["slug"]))
1130
+ # The mirror is a second full copy of the code; leaving it behind would make
1131
+ # "deleted" a lie. It needs the read-only-tolerant rmtree more than anything
1132
+ # else in the store does: git's object files are read-only by design, and on
1133
+ # Windows shutil.rmtree walks straight past them and reports success.
1134
+ rmtree(store.repo_dir(row["slug"]))
1135
+ db.delete_function(int(row["id"]))
1136
+ console.print(f"[green]deleted[/green] {row['name']}")
1137
+
1138
+
1139
+ # --------------------------------------------------------------- plumbing
1140
+ @app.command(rich_help_panel="Reading the archive")
1141
+ def export(
1142
+ function: str = typer.Argument(..., autocompletion=_complete_function),
1143
+ version: Optional[str] = typer.Argument(None, help="Version number, or 'latest' (default)."),
1144
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Destination path."),
1145
+ as_zip: bool = typer.Option(True, "--zip/--tree", help="Write a zip, or copy the folder."),
1146
+ ) -> None:
1147
+ """Get a version back out — a deployable zip or a plain folder."""
1148
+ cfg = _cfg()
1149
+ db = _open_db(cfg)
1150
+ store = Store(cfg)
1151
+ row = _resolve_function(db, function)
1152
+ seq = _resolve_seq(db, int(row["id"]), version)
1153
+ version_row = _version_or_fail(db, int(row["id"]), seq)
1154
+ code_dir = store.resolve_version_dir(version_row["dir"]) / "code"
1155
+ if not code_dir.exists():
1156
+ _fail(f"the extracted code for v{seq:04d} is missing at {code_dir}")
1157
+
1158
+ if as_zip:
1159
+ default_name = f"{slugify(row['name'])}-v{seq:04d}.zip"
1160
+ target = Path(output).expanduser() if output else Path.cwd() / default_name
1161
+ target.parent.mkdir(parents=True, exist_ok=True)
1162
+ with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as zf:
1163
+ for path in sorted(code_dir.rglob("*")):
1164
+ if path.is_file():
1165
+ zf.write(path, path.relative_to(code_dir).as_posix())
1166
+ console.print(f"[green]wrote[/green] {target} [dim]({human_size(target.stat().st_size)})[/dim]")
1167
+ else:
1168
+ target = Path(output).expanduser() if output else Path.cwd() / f"{slugify(row['name'])}-v{seq:04d}"
1169
+ if target.exists():
1170
+ _fail(f"{target} already exists")
1171
+ shutil.copytree(code_dir, target)
1172
+ console.print(f"[green]copied[/green] {target}")
1173
+
1174
+
1175
+ @app.command("open", rich_help_panel="Reading the archive")
1176
+ def open_in_editor(
1177
+ function: str = typer.Argument(..., help="Function name, slug or id.", autocompletion=_complete_function),
1178
+ version: Optional[str] = typer.Argument(
1179
+ None, help="Open this version's files alone instead of the whole repo."
1180
+ ),
1181
+ editor: Optional[str] = typer.Option(
1182
+ None, "--editor", "-e",
1183
+ help="Editor command to launch (default: `editor` in the config, else VS Code and friends on PATH).",
1184
+ ),
1185
+ reuse: bool = typer.Option(
1186
+ False, "--reuse", "-r", help="Reuse the editor's current window instead of opening a new one."
1187
+ ),
1188
+ print_only: bool = typer.Option(
1189
+ False, "--print", help="Print the folder that would be opened and launch nothing."
1190
+ ),
1191
+ ) -> None:
1192
+ """Open a function's archived code in your editor.
1193
+
1194
+ With no version, this opens the git mirror: a real working tree holding the
1195
+ latest version, with every earlier one a commit tagged `v0001`, `v0002`, …
1196
+ so the editor's own history, blame and diff views cover the whole archive.
1197
+ Name a version and you get that version's files on their own instead.
1198
+ """
1199
+ cfg = _cfg()
1200
+ db = _open_db(cfg)
1201
+ store = Store(cfg)
1202
+ row = _resolve_function(db, function)
1203
+
1204
+ note = ""
1205
+ if version is not None:
1206
+ seq = _resolve_seq(db, int(row["id"]), version)
1207
+ target = store.resolve_version_dir(_version_or_fail(db, int(row["id"]), seq)["dir"]) / "code"
1208
+ if not target.exists():
1209
+ _fail(f"the extracted code for v{seq:04d} is missing at {target}")
1210
+ subtitle = f"v{seq:04d} only — no history, just the files"
1211
+ else:
1212
+ target = store.repo_dir(row["slug"])
1213
+ if (target / ".git").is_dir():
1214
+ seqs = [int(v["seq"]) for v in db.list_versions(int(row["id"]))]
1215
+ subtitle = (
1216
+ f"{len(seqs)} version(s), tagged v{min(seqs):04d}…v{max(seqs):04d}; "
1217
+ f"the working tree is v{max(seqs):04d}"
1218
+ if seqs else "no versions archived yet"
1219
+ )
1220
+ else:
1221
+ # No mirror to open, but the request was to look at the code, and
1222
+ # the newest version is the closest thing to what was asked for.
1223
+ seq = _resolve_seq(db, int(row["id"]), "latest")
1224
+ target = store.resolve_version_dir(_version_or_fail(db, int(row["id"]), seq)["dir"]) / "code"
1225
+ subtitle = f"v{seq:04d} only — no history, just the files"
1226
+ note = (
1227
+ f"no git mirror for {row['name']} yet. Set `git_mirror.enabled: true` in "
1228
+ f"{_CONFIG_PATH or default_config_path()} and re-ingest to get one."
1229
+ )
1230
+
1231
+ if note:
1232
+ err_console.print(f"[yellow]{note}[/yellow]")
1233
+ if print_only:
1234
+ print(target)
1235
+ return
1236
+
1237
+ argv = _resolve_editor(cfg, editor)
1238
+ _launch_editor(argv, target, reuse)
1239
+ console.print(f"[green]opened[/green] {row['name']} in {Path(argv[0]).stem} [dim]({subtitle})[/dim]")
1240
+ console.print(f"[dim]{target}[/dim]")
1241
+
1242
+
1243
+ @app.command(rich_help_panel="Housekeeping")
1244
+ def path(
1245
+ function: str = typer.Argument(..., autocompletion=_complete_function),
1246
+ version: Optional[str] = typer.Argument(None),
1247
+ repo: bool = typer.Option(
1248
+ False, "--repo", "--git", help="Print the git mirror path instead."
1249
+ ),
1250
+ open_it: bool = typer.Option(False, "--open", help="Open it in the file manager."),
1251
+ ) -> None:
1252
+ """Print where something lives on disk (handy for `cd $(...)`)."""
1253
+ cfg = _cfg()
1254
+ db = _open_db(cfg)
1255
+ store = Store(cfg)
1256
+ row = _resolve_function(db, function)
1257
+ if repo:
1258
+ target = store.repo_dir(row["slug"])
1259
+ elif version is None:
1260
+ target = store.function_dir(row["slug"])
1261
+ else:
1262
+ seq = _resolve_seq(db, int(row["id"]), version)
1263
+ version_row = _version_or_fail(db, int(row["id"]), seq)
1264
+ target = store.resolve_version_dir(version_row["dir"]) / "code"
1265
+ print(target)
1266
+ if open_it:
1267
+ _open_path(target)
1268
+
1269
+
1270
+ @app.command(
1271
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
1272
+ help="Run a git command inside a function's mirror repo, e.g. `lw git my-fn log --oneline`.",
1273
+ rich_help_panel="Reading the archive",
1274
+ )
1275
+ def git(ctx: typer.Context, function: str = typer.Argument(...)) -> None:
1276
+ """Run git against the per-function mirror repository."""
1277
+ cfg = _cfg()
1278
+ db = _open_db(cfg)
1279
+ store = Store(cfg)
1280
+ row = _resolve_function(db, function)
1281
+ repo = store.repo_dir(row["slug"])
1282
+ if not (repo / ".git").exists():
1283
+ _fail(
1284
+ f"no git mirror for {row['name']} at {repo}. "
1285
+ "Enable git_mirror in the config and re-ingest, or use `lw diff`."
1286
+ )
1287
+ args = list(ctx.args) or ["log", "--oneline", "--decorate", "-20"]
1288
+ proc = subprocess.run(["git", "-C", str(repo), *args])
1289
+ raise typer.Exit(proc.returncode)
1290
+
1291
+
1292
+ @app.command(rich_help_panel="Reading the archive")
1293
+ def search(
1294
+ term: str = typer.Argument(..., help="Filename fragment or package name."),
1295
+ kind: str = typer.Option("all", "--kind", "-k", help="all | files | deps"),
1296
+ ) -> None:
1297
+ """Search across everything archived."""
1298
+ cfg = _cfg()
1299
+ db = _open_db(cfg)
1300
+ if kind in {"all", "files"}:
1301
+ rows = db.search_files(term)
1302
+ if rows:
1303
+ table = Table(title="Files", title_justify="left", box=None, header_style="bold",
1304
+ padding=(0, 2, 0, 0))
1305
+ table.add_column("function")
1306
+ table.add_column("version")
1307
+ table.add_column("path")
1308
+ table.add_column("size", justify="right", style="dim")
1309
+ for row in rows[:60]:
1310
+ table.add_row(row["function_name"], f"v{int(row['seq']):04d}", row["path"],
1311
+ human_size(row["size"]))
1312
+ console.print(table)
1313
+ if kind in {"all", "deps"}:
1314
+ rows = db.search_deps(term)
1315
+ if rows:
1316
+ table = Table(title="Dependencies", title_justify="left", box=None, header_style="bold",
1317
+ padding=(0, 2, 0, 0))
1318
+ table.add_column("function")
1319
+ table.add_column("version")
1320
+ table.add_column("package")
1321
+ table.add_column("version", style="dim")
1322
+ for row in rows[:60]:
1323
+ table.add_row(row["function_name"], f"v{int(row['seq']):04d}", row["name"],
1324
+ row["version"] or "-")
1325
+ console.print(table)
1326
+
1327
+
1328
+ @app.command("log", rich_help_panel="Housekeeping")
1329
+ def show_log(limit: int = typer.Option(25, "--limit", "-n")) -> None:
1330
+ """Recent activity, including downloads that were skipped and why."""
1331
+ cfg = _cfg()
1332
+ db = _open_db(cfg)
1333
+ rows = db.recent_events(limit)
1334
+ if not rows:
1335
+ console.print("[dim]no activity recorded yet[/dim]")
1336
+ return
1337
+ table = Table(box=None, header_style="bold", padding=(0, 2, 0, 0))
1338
+ table.add_column("when", style="dim")
1339
+ table.add_column("event")
1340
+ table.add_column("function")
1341
+ table.add_column("detail", style="dim")
1342
+ colours = {"new-version": "green", "unchanged": "cyan", "failed": "red",
1343
+ "duplicate-download": "dim"}
1344
+ for row in rows:
1345
+ detail = row["detail"] or ""
1346
+ if row["source_path"]:
1347
+ detail = f"{Path(row['source_path']).name} {detail}"
1348
+ colour = colours.get(row["kind"], "white")
1349
+ table.add_row(
1350
+ format_ts(row["ts"]),
1351
+ f"[{colour}]{row['kind']}[/{colour}]",
1352
+ row["function_name"] or "-",
1353
+ detail[:110],
1354
+ )
1355
+ console.print(table)
1356
+
1357
+
1358
+ @app.command(rich_help_panel="Housekeeping")
1359
+ def init(
1360
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing config file."),
1361
+ ) -> None:
1362
+ """Write a commented config file you can edit."""
1363
+ from .templates import DEFAULT_CONFIG_YAML
1364
+
1365
+ path = _CONFIG_PATH or default_config_path()
1366
+ path.parent.mkdir(parents=True, exist_ok=True)
1367
+ if path.exists() and not force:
1368
+ _fail(f"{path} already exists (use --force to overwrite)")
1369
+ path.write_text(DEFAULT_CONFIG_YAML, encoding="utf-8")
1370
+ console.print(f"[green]wrote[/green] {path}")
1371
+ cfg = load_config(path)
1372
+ cfg.ensure_dirs()
1373
+ console.print(f"archive root: {cfg.root}")
1374
+ console.print(f"watching: {', '.join(str(d) for d in cfg.watch_dirs())}")
1375
+ console.print("\nNext: [bold]lw start[/bold]")
1376
+
1377
+
1378
+ @app.command(rich_help_panel="Housekeeping")
1379
+ def reindex(
1380
+ yes: bool = typer.Option(False, "--yes", "-y", help="Do not ask for confirmation."),
1381
+ ) -> None:
1382
+ """Rebuild the index from the manifests on disk (the archive is the source of truth)."""
1383
+ cfg = _cfg()
1384
+ if not yes and not typer.confirm(f"Rebuild {cfg.db_path} from {cfg.functions_dir}?"):
1385
+ raise typer.Abort()
1386
+
1387
+ from .reindex import rebuild
1388
+
1389
+ stats = rebuild(cfg)
1390
+ console.print(
1391
+ f"[green]reindexed[/green] {stats['functions']} function(s), "
1392
+ f"{stats['versions']} version(s)"
1393
+ + (f", [yellow]{stats['skipped']} skipped[/yellow]" if stats["skipped"] else "")
1394
+ )
1395
+
1396
+
1397
+ def main() -> None:
1398
+ try:
1399
+ app()
1400
+ except KeyboardInterrupt: # pragma: no cover
1401
+ err_console.print("\n[dim]interrupted[/dim]")
1402
+ sys.exit(130)
1403
+
1404
+
1405
+ if __name__ == "__main__": # pragma: no cover
1406
+ main()