@h5l0/codelens 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 codelens contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,137 @@
1
+ # codelens
2
+
3
+ A local dashboard for any git repository: commit history and lines of code.
4
+
5
+ English | [简体中文](./README.md)
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ npx @h5l0/codelens # analyze the current directory and open the browser
11
+ npx @h5l0/codelens ../my-repo # analyze another repository
12
+ npx @h5l0/codelens --profile web # split the calendar into frontend / backend
13
+ ```
14
+
15
+ Or install it globally:
16
+
17
+ ```bash
18
+ npm install -g @h5l0/codelens
19
+ codelens
20
+ ```
21
+
22
+ No configuration is required. Data is generated on the fly at startup, served to your local browser only, and never written into the analyzed repository.
23
+
24
+ ## The two views
25
+
26
+ ### Change calendar
27
+
28
+ A week-based heatmap: the top bar of each cell is inserted lines, the bottom bar is deleted lines, and bar width uses a square-root scale. Hover a day to see that day's summary and commits, and click it to pin the day (click again to unpin); you can also Tab into the calendar and move between days with the arrow keys. The header switch filters by group, and both the stats cards and the commit list then follow that group. The calendar shows as many weeks as the window fits and never scrolls sideways, sitting flush right with today at the far right; when a repository has been quiet for more than 28 days the right edge stops at the last commit instead. Days before the first commit and after the last one stay in the grid with a paler background. The top row shows the visible date range on the left and the inserted/deleted line legend on the right; the strip along the bottom (it appears only once the history is longer than one screen) has one cell per week, coloured grey to green by that week's inserted lines — the frame sits in the middle and you drag the strip itself to pan (drag right for older weeks), with a fixed opacity gradient at both ends. Stat cards read "Total" on the range and show both the daily average and the peak in their note.
29
+
30
+ ![Change calendar](docs/screenshots/en/calendar.png)
31
+
32
+ ### Lines of code
33
+
34
+ A treemap where rectangle area is proportional to line count, color is the category, and shade is the directory depth. Click a rectangle to zoom into that directory and use the breadcrumb or Esc to go back; directories you can zoom into in the current view are reachable with Tab and Enter. The switches control the counting mode and expansion depth, and the legend toggles categories.
35
+
36
+ ![Lines of code](docs/screenshots/en/loc.png)
37
+
38
+ ## Command line
39
+
40
+ ```
41
+ codelens [directory] [options]
42
+
43
+ --profile <name|file> Profile, see below; built in: all, web
44
+ --config <file> Config file, defaults to <directory>/codelens.config.json
45
+ --days <days> Calendar time span, 0 means full history (default 120)
46
+ --exclude <glob> Extra paths to ignore, repeatable
47
+ --port <port> Listen port, default 5178, tries the next ports when busy
48
+ --host <address> Listen address, default 127.0.0.1; other addresses expose the page to your network
49
+ --no-open Do not open the browser automatically
50
+ --no-gitignore Ignore .gitignore; only built-in heavy directories are skipped
51
+ --dump <dir> Write data.json and loc.json, then exit without starting a server
52
+ --dev Dev mode with Vite hot reload
53
+ -h, --help Show help
54
+ -v, --version Show version
55
+ ```
56
+
57
+ ## Profiles
58
+
59
+ `--profile` decides how the repository is split. Two ways to use it:
60
+
61
+ 1. Point it at a JSON file: `--profile ./my-profile.json`;
62
+ 2. Or use a name under `profiles` in `codelens.config.json` (relocate it with `--config`): `--profile web`.
63
+
64
+ Two profiles are built in:
65
+
66
+ | Name | What it does |
67
+ | --- | --- |
68
+ | `all` | Default; no grouping, the whole repository is counted together |
69
+ | `web` | Common frontend/backend layout: `frontend/`, `web/`, `client/`, `ui/` and friends count as frontend, everything else as backend |
70
+
71
+ ### Config file format
72
+
73
+ ```jsonc
74
+ {
75
+ "profiles": {
76
+ "modules": {
77
+ "label": "By module",
78
+ // Calendar groups: a file goes to the first group whose patterns match.
79
+ // End the list with ["**"] to get an "A / everything else" split.
80
+ "groups": [
81
+ { "id": "core", "label": "Core", "hue": 214, "sat": 58, "match": ["src/core/**"] },
82
+ { "id": "web", "label": "UI", "hue": 152, "sat": 46, "match": ["src/web/**"] },
83
+ { "id": "other", "label": "Other", "hue": 32, "sat": 62, "match": ["**"] }
84
+ ],
85
+ // Line-count categories: drive the legend and colors; the entry without a match is the fallback.
86
+ "categories": [
87
+ { "id": "core", "label": "Core code", "hue": 214, "sat": 58, "match": ["src/core/**"] },
88
+ { "id": "test", "label": "Tests", "hue": 152, "sat": 46, "defaultOn": false, "match": ["**/*.test.ts"] },
89
+ { "id": "app", "label": "Other code", "hue": 220, "sat": 20 }
90
+ ],
91
+ // Extra paths to ignore on top of .gitignore
92
+ "ignore": ["data/**", "**/*.snap"]
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ Field notes:
99
+
100
+ - `groups[].match`, `categories[].match` and `ignore` use globs relative to the repository root: `**` crosses directories, `*` does not, `?` matches one character, `{a,b}` matches either; a pattern without `/` matches items of that name at any depth, `/foo` is anchored to the root, and `foo/` means the directory and everything inside it.
101
+ - The config file may contain `//` and `/* */` comments and trailing commas.
102
+ - `groups[].id` must not be the reserved `all` and must be unique within a profile.
103
+ - `hue` and `sat` are HSL color components for group and category colors; they default to 214 and 50.
104
+ - `categories[].defaultOn: false` means the category starts switched off in the legend (of the built-in categories, "generated code", "docs" and "config" start off).
105
+
106
+ Without `categories`, six built-in ones are used: application code, tests, scripts, docs, config, generated code.
107
+
108
+ A profile name the config file does not define falls back to the built-in profile: a repository shipping a `codelens.config.json` with only custom profiles still supports `codelens --profile all` and `--profile web`. Only a name missing from both places is an error.
109
+
110
+ ## Counting rules
111
+
112
+ - `.gitignore` is honored by default; when the directory is not a git repository, equivalent ignore rules are applied instead. `--no-gitignore` turns this off.
113
+ - Dependency and build directories such as `node_modules`, `dist`, `build`, `coverage`, `.venv`, `__pycache__` and `target` are always skipped.
114
+ - `--exclude` and the config's `ignore` apply to both views: excluded directories are neither counted nor attributed in the change calendar.
115
+ - Binary files, files larger than 3MB and empty files are excluded; a file that cannot be read is skipped and reported as a count in the startup log. Line counts are physical lines (a trailing newline does not count as an extra line).
116
+ - Calendar entries are grouped by the day of the commit time (committer date), matching how `--days` filters. "Changed lines = insertions + deletions".
117
+ - Merge commits, empty commits and commits that only change file modes or binaries have no line counts, but they still appear in the commit list and count as commits.
118
+ - When you run it on a subdirectory of a repository, both views count that subdirectory only and resolve paths relative to it.
119
+
120
+ ## Known limits
121
+
122
+ - The change calendar needs git: a repository without commits, or without git at all, leaves the calendar empty while the lines view still works.
123
+ - Line counts say nothing about code complexity.
124
+ - The treemap draws at most 6000 rectangles; the rest are not shown.
125
+ - The calendar is padded to whole weeks, so a window that does not start or end on a week boundary shows a few extra days without data (with a paler background).
126
+
127
+ ## Languages
128
+
129
+ The page follows your browser language. Simplified Chinese, English, Japanese and Korean are built in; other languages fall back to English. Append `?lang={langCode}` to the URL to override it for this session, for example `?lang=zh` or `?lang=ko`.
130
+
131
+ ## Development
132
+
133
+ See [DEV.md](./DEV.md) for the development setup, project layout and release process.
134
+
135
+ ## License
136
+
137
+ [MIT](./LICENSE)
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # codelens
2
+
3
+ 把任意 git 仓库的改动历史与代码行数,做成一个本地看板。
4
+
5
+ [English](./README.en.md) | 简体中文
6
+
7
+ ## 快速开始
8
+
9
+ ```bash
10
+ npx @h5l0/codelens # 统计当前目录并在浏览器打开
11
+ npx @h5l0/codelens ../my-repo # 统计指定仓库
12
+ npx @h5l0/codelens --profile web # 按前后端拆分改动日历
13
+ ```
14
+
15
+ 也可以全局安装后随时使用:
16
+
17
+ ```bash
18
+ npm install -g @h5l0/codelens
19
+ codelens
20
+ ```
21
+
22
+ 无需任何配置即可运行。统计数据在启动时现场生成,只提供给本机浏览器,不写入被统计的仓库。
23
+
24
+ ## 两个视图
25
+
26
+ ### 改动日历
27
+
28
+ 按周排布的热力格,每格上条为新增行、下条为删除行,条宽是行数的平方根刻度。鼠标移到某天即可查看当天的统计与提交列表,点一下可以钉住这天(再点取消);也可以用 Tab 进入日历、用方向键切换日期。顶部开关可按分组过滤,此时统计卡与提交列表都只算该分组。日历一屏铺得下几周就排几周、不横向滚动,整体靠右,最右边是今天;仓库超过 28 天没有提交时,最右边停在最后一次提交那天。第一次提交之前与最后一次提交之后的日子也留在网格里,底色比普通格子更白。顶部一行左边是当前显示的时间范围,右边是新增行与删除行的图例;底部是一条自绘的拖动条(提交多到一屏放不下时才出现),一格一周、颜色从灰到绿表示那一周的新增行数,包裹框固定在条中间,直接拖格子条即可平移窗口(往右拖看更早的周),条的两端固定是一段透明度渐变。统计卡在区间口径下显示「累计」,备注里给出日均与峰值。
29
+
30
+ ![改动日历](docs/screenshots/zh/calendar.png)
31
+
32
+ ### 代码行数
33
+
34
+ 面积树形图,方块面积正比于行数,颜色代表分类,颜色深浅代表目录层级。点击方块放大到该目录,面包屑或 Esc 返回;当前视图里可以直接放大的目录能用 Tab 聚焦、回车放大。右侧开关可切换统计口径与展开层级,图例可切换类别是否计入。
35
+
36
+ ![代码行数](docs/screenshots/zh/loc.png)
37
+
38
+ ## 命令行参数
39
+
40
+ ```
41
+ codelens [目录] [选项]
42
+
43
+ --profile <名称|文件> 配置档,见下一节,内置 all、web
44
+ --config <文件> 配置文件,默认 <目录>/codelens.config.json
45
+ --days <天数> 改动日历的时间跨度,0 表示全部历史(默认 120)
46
+ --exclude <glob> 额外忽略的路径,可重复
47
+ --port <端口> 监听端口,默认 5178,被占用时向后尝试
48
+ --host <地址> 监听地址,默认 127.0.0.1;监听别的地址时页面对同网段可见
49
+ --no-open 不自动打开浏览器
50
+ --no-gitignore 不按 .gitignore 过滤,只跳过内置的重目录
51
+ --dump <目录> 只写出 data.json 与 loc.json 后退出,不启动服务
52
+ --dev 开发模式,用 Vite 托管前端源码并热更新
53
+ -h, --help 显示帮助
54
+ -v, --version 显示版本
55
+ ```
56
+
57
+ ## 配置档
58
+
59
+ `--profile` 决定用什么维度切分这个仓库,两种用法:
60
+
61
+ 1. 指向一个 json 文件:`--profile ./my-profile.json`;
62
+ 2. 取 `codelens.config.json`(可用 `--config` 换位置)里 `profiles` 下的名字:`--profile web`。
63
+
64
+ 内置两档:
65
+
66
+ | 名称 | 作用 |
67
+ | --- | --- |
68
+ | `all` | 默认档,不做任何分组,整个仓库一起统计 |
69
+ | `web` | 常见前后端目录:`frontend/`、`web/`、`client/`、`ui/` 等算前端,其余算后端 |
70
+
71
+ ### 配置文件格式
72
+
73
+ ```jsonc
74
+ {
75
+ "profiles": {
76
+ "modules": {
77
+ "label": "按模块",
78
+ // 改动日历的分组:命中的文件算进该组,按数组顺序取第一个命中的。
79
+ // 最后一个用 ["**"] 兜底,就能得到「A / 其余」这种两分效果。
80
+ "groups": [
81
+ { "id": "core", "label": "核心", "hue": 214, "sat": 58, "match": ["src/core/**"] },
82
+ { "id": "web", "label": "界面", "hue": 152, "sat": 46, "match": ["src/web/**"] },
83
+ { "id": "other", "label": "其他", "hue": 32, "sat": 62, "match": ["**"] }
84
+ ],
85
+ // 行数视图的分类:决定图例与配色,省略 match 的那一项是兜底类。
86
+ "categories": [
87
+ { "id": "core", "label": "核心代码", "hue": 214, "sat": 58, "match": ["src/core/**"] },
88
+ { "id": "test", "label": "测试", "hue": 152, "sat": 46, "defaultOn": false, "match": ["**/*.test.ts"] },
89
+ { "id": "app", "label": "其他代码", "hue": 220, "sat": 20 }
90
+ ],
91
+ // 在 .gitignore 之外额外忽略的路径
92
+ "ignore": ["data/**", "**/*.snap"]
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ 字段说明:
99
+
100
+ - `groups[].match`、`categories[].match`、`ignore` 都使用仓库相对路径的 glob:`**` 跨目录,`*` 不跨目录,`?` 匹配单个字符,`{a,b}` 择一;不含 `/` 的模式匹配任意层级的同名项,`/foo` 表示从仓库根起算,`foo/` 表示目录及其全部内容。
101
+ - 配置文件允许写 `//`、`/* */` 注释与尾随逗号。
102
+ - `groups[].id` 不能是保留的 `all`,同一档里不能重复。
103
+ - `hue`、`sat` 为 HSL 颜色分量,用于分组色与分类色,缺省分别为 214、50。
104
+ - `categories[].defaultOn` 为 `false` 表示该分类在页面图例里默认关闭(内置分类中「生成代码」「文档」「配置」默认关闭)。
105
+
106
+ 不写 `categories` 时使用内置的六类:应用代码、测试、脚本、文档、配置、生成代码。
107
+
108
+ 配置文件里没有取到的档名会回退到内置档:仓库里放了一份只定义自定义档的 `codelens.config.json`,`codelens --profile all`、`--profile web` 依然可用;两个地方都没有的档名才会报错。
109
+
110
+ ## 统计口径
111
+
112
+ - 默认完全遵守仓库的 `.gitignore`;目录不是 git 仓库时改用等价的忽略规则自行过滤。`--no-gitignore` 可关闭该过滤。
113
+ - 任何情况下都跳过 `node_modules`、`dist`、`build`、`coverage`、`.venv`、`__pycache__`、`target` 等依赖与构建目录。
114
+ - `--exclude`、配置里的 `ignore` 对两个视图同时生效:被排除的目录既不进行数统计,改动日历里也不计入。
115
+ - 二进制文件、超过 3MB 的文件、空文件不计入;个别文件读不出来只跳过它,并在启动日志里给出数量。行数为物理行数(文件末尾换行不计一行)。
116
+ - 改动日历按提交时间(committer date)归入所在天,与 `--days` 的过滤口径一致;「改动行数 = 新增 + 删除」。
117
+ - 合并提交、空提交、只改文件权限或只动二进制的提交没有具体行数,但仍会出现在提交列表里并计入提交数。
118
+ - 对仓库的某个子目录运行时,两个视图都只统计该子目录,路径也相对它计算。
119
+
120
+ ## 已知边界
121
+
122
+ - 改动日历依赖 git,仓库没有提交或缺少 git 时日历为空,行数视图仍可用。
123
+ - 行数视图按文本文件的行数统计,无法反映代码复杂度。
124
+ - 树形图一次最多绘制 6000 个方块,超出的部分不显示。
125
+ - 日历按整周补齐,窗口首尾不足一周时会多画出几天没有数据的格子(底色更白)。
126
+
127
+ ## 多语言
128
+
129
+ 页面语言跟随浏览器,目前内置简体中文、English、日本語、한국어,其余语言回落到英文。地址后加 `?lang={langCode}` 可临时覆盖,例如 `?lang=en`、`?lang=ja`。
130
+
131
+ ## 开发
132
+
133
+ 开发环境、项目结构与发布流程见 [DEV.md](./DEV.md)。
134
+
135
+ ## 许可
136
+
137
+ [MIT](./LICENSE)
@@ -0,0 +1,126 @@
1
+ // ---------------------------------------------------------------------------
2
+ // 命令行参数
3
+ // ---------------------------------------------------------------------------
4
+ import { builtinProfileNames } from '../core/profile.js';
5
+ const STRING_FLAGS = {
6
+ '--profile': 'profile',
7
+ '--config': 'config',
8
+ '--dump': 'dump',
9
+ '--host': 'host',
10
+ };
11
+ const NUMBER_FLAGS = {
12
+ '--days': 'days',
13
+ '--port': 'port',
14
+ };
15
+ const BOOL_FLAGS = {
16
+ '--dev': ['dev', true],
17
+ '--open': ['open', true],
18
+ '--no-open': ['open', false],
19
+ '--gitignore': ['useGitignore', true],
20
+ '--no-gitignore': ['useGitignore', false],
21
+ '-h': ['help', true],
22
+ '--help': ['help', true],
23
+ '-v': ['version', true],
24
+ '--version': ['version', true],
25
+ };
26
+ /** 主机名、IPv4、IPv6(含方括号与 `%` 作用域)都用得到的字符。 */
27
+ const HOST_RE = /^[A-Za-z0-9._:[\]%-]+$/;
28
+ export function parseArgs(argv) {
29
+ const args = {
30
+ dir: '',
31
+ profile: 'all',
32
+ config: undefined,
33
+ days: 120,
34
+ exclude: [],
35
+ port: Number(process.env.PORT ?? 5178),
36
+ host: '127.0.0.1',
37
+ open: true,
38
+ useGitignore: true,
39
+ dump: undefined,
40
+ dev: false,
41
+ help: false,
42
+ version: false,
43
+ };
44
+ for (let i = 0; i < argv.length; i += 1) {
45
+ const raw = argv[i];
46
+ const eq = raw.indexOf('=');
47
+ const flag = eq > 0 ? raw.slice(0, eq) : raw;
48
+ const inline = eq > 0 ? raw.slice(eq + 1) : undefined;
49
+ if (BOOL_FLAGS[flag]) {
50
+ const [key, value] = BOOL_FLAGS[flag];
51
+ args[key] = inline === undefined ? value : inline !== 'false';
52
+ continue;
53
+ }
54
+ if (flag === '--exclude') {
55
+ const value = inline ?? argv[++i];
56
+ if (value === undefined || value === '') {
57
+ throw new Error('--exclude requires a value');
58
+ }
59
+ args.exclude.push(value);
60
+ continue;
61
+ }
62
+ if (STRING_FLAGS[flag]) {
63
+ const value = inline ?? argv[++i];
64
+ if (value === undefined || value === '') {
65
+ throw new Error(`${flag} requires a value`);
66
+ }
67
+ args[STRING_FLAGS[flag]] = value;
68
+ continue;
69
+ }
70
+ if (NUMBER_FLAGS[flag]) {
71
+ const value = inline ?? argv[++i];
72
+ const num = Number(value);
73
+ if (value === undefined || value === '' || !Number.isInteger(num) || num < 0) {
74
+ throw new Error(`${flag} requires a whole number >= 0, got: ${value ?? '(empty)'}`);
75
+ }
76
+ args[NUMBER_FLAGS[flag]] = num;
77
+ continue;
78
+ }
79
+ if (raw.startsWith('-') && raw !== '-') {
80
+ throw new Error(`unknown option: ${raw} (see --help)`);
81
+ }
82
+ if (args.dir !== '') {
83
+ throw new Error(`only one directory argument is accepted, extra: ${raw}`);
84
+ }
85
+ args.dir = raw;
86
+ }
87
+ if (args.port > 65535) {
88
+ throw new Error(`--port must be between 0 and 65535, got: ${args.port}`);
89
+ }
90
+ // 地址只允许主机名与 IP 里常见的字符,避免把奇怪的值一路带进 URL 与启动命令
91
+ if (!HOST_RE.test(args.host)) {
92
+ throw new Error(`--host must be an address or hostname, got: ${args.host}`);
93
+ }
94
+ return args;
95
+ }
96
+ /** 选项与说明分两列对齐,说明统一从这里开始的列。 */
97
+ const FLAG_COL = 23;
98
+ /** 示例比选项长,用更宽的一列。 */
99
+ const EXAMPLE_COL = 35;
100
+ const row = (left, text, width = FLAG_COL) => ` ${left.padEnd(width)}${text}`;
101
+ export function helpText() {
102
+ return `codelens: visualize a git repository's change history and lines of code in a local dashboard
103
+
104
+ Usage
105
+ codelens [directory] [options]
106
+
107
+ Options
108
+ ${row('--profile <name|file>', `Profile: built in ${builtinProfileNames().join(', ')}, or a custom json file`)}
109
+ ${row('--config <file>', 'Config file, defaults to <directory>/codelens.config.json')}
110
+ ${row('--days <days>', 'Calendar span, 0 for full history (default 120)')}
111
+ ${row('--exclude <glob>', 'Extra ignored paths, repeatable')}
112
+ ${row('--port <port>', 'Listen port, default 5178, tries the next ports when busy')}
113
+ ${row('--host <address>', 'Listen address, default 127.0.0.1')}
114
+ ${row('--no-open', 'Do not open the browser automatically')}
115
+ ${row('--no-gitignore', 'Ignore .gitignore; only built-in heavy directories are skipped')}
116
+ ${row('--dump <dir>', 'Write data.json and loc.json to <dir>, then exit')}
117
+ ${row('--dev', 'Dev mode with Vite hot reload')}
118
+ ${row('-h, --help', 'Show help')}
119
+ ${row('-v, --version', 'Show version')}
120
+
121
+ Examples
122
+ ${row('npx @h5l0/codelens', 'analyze the current directory', EXAMPLE_COL)}
123
+ ${row('npx @h5l0/codelens ../my-repo', 'analyze another repository', EXAMPLE_COL)}
124
+ ${row('npx @h5l0/codelens --profile web', 'split the calendar into frontend / backend', EXAMPLE_COL)}
125
+ ${row('npx @h5l0/codelens --profile ./p.json', 'use a custom profile file', EXAMPLE_COL)}`;
126
+ }
@@ -0,0 +1,33 @@
1
+ // ---------------------------------------------------------------------------
2
+ // 开发服务器
3
+ // 用 Vite 托管前端源码并复用同一套数据接口,改动前端代码即时生效。
4
+ // ---------------------------------------------------------------------------
5
+ import { resolve } from 'node:path';
6
+ import { packageRoot } from './paths.js';
7
+ import { createApiMiddleware } from './server.js';
8
+ export async function startDevServer(payloads, opts) {
9
+ let vite;
10
+ try {
11
+ vite = await import('vite');
12
+ }
13
+ catch {
14
+ throw new Error('dev mode requires vite; run npm install inside the package directory and retry');
15
+ }
16
+ const server = await vite.createServer({
17
+ configFile: resolve(packageRoot, 'vite.config.ts'),
18
+ server: { host: opts.host, port: opts.port, strictPort: false, open: false },
19
+ plugins: [
20
+ {
21
+ name: 'codelens-api',
22
+ configureServer(viteServer) {
23
+ viteServer.middlewares.use(createApiMiddleware(payloads));
24
+ },
25
+ },
26
+ ],
27
+ });
28
+ await server.listen();
29
+ return {
30
+ url: server.resolvedUrls?.local?.[0] ?? `http://${opts.host}:${opts.port}/`,
31
+ close: () => server.close(),
32
+ };
33
+ }
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // codelens 命令行入口
4
+ // 解析参数 -> 按 .gitignore 列出仓库文件并统计行数 -> 读取 git 历史 -> 启动本地服务。
5
+ // ---------------------------------------------------------------------------
6
+ import { spawn } from 'node:child_process';
7
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
8
+ import { join, resolve } from 'node:path';
9
+ import { buildCalendar, emptyCalendar, isGitRepo } from '../core/calendar.js';
10
+ import { buildLoc, emptyLoc } from '../core/loc.js';
11
+ import { loadProfile } from '../core/profile.js';
12
+ import { helpText, parseArgs } from './args.js';
13
+ import { startDevServer } from './dev.js';
14
+ import { packageRoot, webDir } from './paths.js';
15
+ import { compose, createApiMiddleware, createStaticMiddleware, isLoopbackHost, startServer } from './server.js';
16
+ const f = (n) => n.toLocaleString();
17
+ const message = (err) => (err instanceof Error ? err.message : String(err));
18
+ function version() {
19
+ const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
20
+ return pkg.version ?? '0.0.0';
21
+ }
22
+ function isDirectory(path) {
23
+ try {
24
+ return statSync(path).isDirectory();
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ function exists(path) {
31
+ try {
32
+ statSync(path);
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ /** 左侧标签统一宽度,让多行输出对齐。 */
40
+ const LABEL_COL = 10;
41
+ const tag = (name) => name.padEnd(LABEL_COL);
42
+ function log(label, text) {
43
+ console.log(`${tag(label)}${text}`);
44
+ }
45
+ /** git 不可用或目录不是仓库时保留空日历,让行数视图仍能打开。 */
46
+ async function readCalendar(root, profile, days) {
47
+ if (!isGitRepo(root)) {
48
+ console.warn(`${tag('warn')}directory is not inside a git repository, the calendar will be empty`);
49
+ return emptyCalendar(root, profile);
50
+ }
51
+ try {
52
+ return await buildCalendar(root, profile, { days });
53
+ }
54
+ catch (err) {
55
+ console.warn(`${tag('warn')}failed to read git history, the calendar will be empty: ${message(err)}`);
56
+ return emptyCalendar(root, profile);
57
+ }
58
+ }
59
+ /** 行数统计失败时保留空清单,日历视图仍能打开。 */
60
+ async function readLoc(root, profile, useGitignore) {
61
+ try {
62
+ return await buildLoc(root, profile, { useGitignore });
63
+ }
64
+ catch (err) {
65
+ console.warn(`${tag('warn')}failed to count lines, the line view will be empty: ${message(err)}`);
66
+ return { data: emptyLoc(root, profile), scan: { entries: [], mode: 'walk' } };
67
+ }
68
+ }
69
+ function launchBrowser(url) {
70
+ const cmd = process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] :
71
+ process.platform === 'darwin' ? ['open', [url]] :
72
+ ['xdg-open', [url]];
73
+ try {
74
+ const child = spawn(cmd[0], cmd[1], { stdio: 'ignore', detached: true });
75
+ // 没有可用的打开方式(例如 linux 上缺 xdg-open)时不该抛未捕获异常
76
+ child.on('error', () => { });
77
+ child.unref();
78
+ }
79
+ catch {
80
+ // 打不开浏览器不影响服务本身
81
+ }
82
+ }
83
+ async function main() {
84
+ const args = parseArgs(process.argv.slice(2));
85
+ if (args.help) {
86
+ console.log(helpText());
87
+ return;
88
+ }
89
+ if (args.version) {
90
+ console.log(version());
91
+ return;
92
+ }
93
+ const root = resolve(args.dir === '' ? process.cwd() : args.dir);
94
+ if (!isDirectory(root)) {
95
+ throw new Error(`directory does not exist: ${root}`);
96
+ }
97
+ const profile = loadProfile({
98
+ root,
99
+ name: args.profile,
100
+ configPath: args.config,
101
+ exclude: args.exclude,
102
+ });
103
+ console.log(`codelens ${version()}`);
104
+ log('repo', root);
105
+ log('profile', `${profile.name} (${profile.label})${profile.configPath ? ` · ${profile.configPath}` : ''}`);
106
+ const loc = await readLoc(root, profile, args.useGitignore);
107
+ const scanLabel = loc.scan.mode === 'git' ? 'git index, filtered by .gitignore' : 'directory walk';
108
+ log('files', `${f(loc.data.totals.files)} files · ${f(loc.data.totals.lines)} lines (${scanLabel})`);
109
+ if (loc.data.skipped.unreadable > 0) {
110
+ log('warn', `${f(loc.data.skipped.unreadable)} files could not be read and were skipped`);
111
+ }
112
+ if (profile.groups.length > 0) {
113
+ log('groups', profile.groups.map((group) => `${group.label}=${group.match.join(' ')}`).join(' '));
114
+ }
115
+ const calendar = await readCalendar(root, profile, args.days);
116
+ log('calendar', calendar.range.min
117
+ ? `${calendar.range.min} ~ ${calendar.range.max} · ${calendar.totals.days} days with commits`
118
+ : 'no commits in this range');
119
+ if (args.dump) {
120
+ const outDir = resolve(args.dump);
121
+ mkdirSync(outDir, { recursive: true });
122
+ writeFileSync(join(outDir, 'data.json'), JSON.stringify(calendar));
123
+ writeFileSync(join(outDir, 'loc.json'), JSON.stringify(loc.data));
124
+ log('export', join(outDir, 'data.json'));
125
+ log('', join(outDir, 'loc.json'));
126
+ return;
127
+ }
128
+ const payloads = { data: JSON.stringify(calendar), loc: JSON.stringify(loc.data) };
129
+ if (!isLoopbackHost(args.host)) {
130
+ console.warn(`${tag('warn')}listening on ${args.host}, the dashboard is reachable from other machines`);
131
+ }
132
+ if (args.dev) {
133
+ const dev = await startDevServer(payloads, { host: args.host, port: args.port });
134
+ log('server', `${dev.url} (Vite dev mode, front-end changes apply instantly)`);
135
+ if (args.open) {
136
+ launchBrowser(dev.url);
137
+ }
138
+ process.on('SIGINT', () => {
139
+ void dev.close().then(() => process.exit(0));
140
+ });
141
+ return;
142
+ }
143
+ const dir = webDir();
144
+ if (!exists(join(dir, 'index.html'))) {
145
+ throw new Error(`front-end build not found at ${dir}; run npm run build or use --dev`);
146
+ }
147
+ const server = await startServer(compose([createApiMiddleware(payloads), createStaticMiddleware(dir)]), {
148
+ host: args.host,
149
+ port: args.port,
150
+ });
151
+ log('server', server.url);
152
+ if (args.open) {
153
+ launchBrowser(server.url);
154
+ }
155
+ process.on('SIGINT', () => {
156
+ server.close();
157
+ process.exit(0);
158
+ });
159
+ }
160
+ main().catch((err) => {
161
+ console.error(`${tag('error')}${message(err)}`);
162
+ process.exit(1);
163
+ });
@@ -0,0 +1,11 @@
1
+ // ---------------------------------------------------------------------------
2
+ // 包内路径
3
+ // 源码运行时是 src/cli/*.ts,构建后是 dist/cli/*.js,上两级都是包根目录。
4
+ // ---------------------------------------------------------------------------
5
+ import { dirname, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ export const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
8
+ /** 前端资源目录:构建产物所在位置。 */
9
+ export function webDir() {
10
+ return resolve(packageRoot, 'dist', 'web');
11
+ }