@strato-dan/commit 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 +21 -0
- package/README.md +232 -0
- package/TRADEMARK.md +18 -0
- package/bin/dan-oss-commit.js +28 -0
- package/examples/read-diff-and-generate.mjs +38 -0
- package/package.json +41 -0
- package/public/app.js +169 -0
- package/public/fonts/Doto-latin.woff2 +0 -0
- package/public/fonts/Inter-latin.woff2 +0 -0
- package/public/fonts/JetBrainsMono-latin.woff2 +0 -0
- package/public/fonts/LICENSES.md +24 -0
- package/public/icons.js +55 -0
- package/public/index.html +87 -0
- package/public/style.css +242 -0
- package/src/git.js +86 -0
- package/src/llm.js +94 -0
- package/src/server.js +167 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DAN Systems
|
|
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.md
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="assets/dan-mark.svg" alt="[DAN] COMMIT" width="84" height="84">
|
|
4
|
+
|
|
5
|
+
# [DAN] COMMIT
|
|
6
|
+
|
|
7
|
+
**Reads your real diff, writes a real commit message. One command, on your own machine.**
|
|
8
|
+
|
|
9
|
+
[](https://github.com/STRATO-DAN/dan-oss-commit/actions/workflows/ci.yml)
|
|
10
|
+
[](https://www.npmjs.com/package/@strato-dan/commit)
|
|
11
|
+
[](#dependencies)
|
|
12
|
+
[](#use)
|
|
13
|
+
[](LICENSE)
|
|
14
|
+
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
> **⚡ Zero install · zero runtime dependencies.** No `npm install`, no build step —
|
|
18
|
+
> `npx @strato-dan/commit` runs it and `npm test` tests it. Pure Node standard library (Node ≥ 18).
|
|
19
|
+
> The **Generate** button uses an API key you already have (env var, not an install); everything
|
|
20
|
+
> else works with no key. Full breakdown under [Dependencies](#dependencies).
|
|
21
|
+
|
|
22
|
+
Analyses your real staged (or unstaged) changes and writes a real commit message from the real
|
|
23
|
+
diff — not a template, not a guess. One command, no backend to deploy, runs entirely on your own
|
|
24
|
+
machine against your own git repo.
|
|
25
|
+
|
|
26
|
+
## Use
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx @strato-dan/commit
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Run it inside any real git repository. It opens `http://127.0.0.1:4870` (loopback only — never
|
|
33
|
+
reachable from another machine) showing:
|
|
34
|
+
|
|
35
|
+
- the real repo/branch you're in
|
|
36
|
+
- the real diff about to be committed (staged changes if any exist, otherwise unstaged)
|
|
37
|
+
- a **Generate** button that sends that real diff to a real LLM and writes a real message
|
|
38
|
+
- a **Commit** button that runs the real `git commit` with whatever message is in the box —
|
|
39
|
+
generated, edited, or written from scratch
|
|
40
|
+
|
|
41
|
+
## Examples
|
|
42
|
+
|
|
43
|
+
[`examples/read-diff-and-generate.mjs`](examples/read-diff-and-generate.mjs) uses this package's
|
|
44
|
+
own library functions directly (no UI, no server) to read a real diff and generate a real commit
|
|
45
|
+
message from it:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
node examples/read-diff-and-generate.mjs # against the current directory
|
|
49
|
+
node examples/read-diff-and-generate.mjs /path/to/repo # against any other real git repo
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Worked example (no API key needed to follow along)
|
|
53
|
+
|
|
54
|
+
Create a throwaway repo, make a change, and run the example with **no API key set**. It prints the
|
|
55
|
+
real diff, then tells you plainly that it's skipping generation because no key is configured — it
|
|
56
|
+
never invents a message:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# a throwaway repo you can delete afterwards
|
|
60
|
+
mkdir /tmp/dan-commit-demo && cd /tmp/dan-commit-demo
|
|
61
|
+
git init -q -b main
|
|
62
|
+
git config user.email dev@example.com && git config user.name Dev
|
|
63
|
+
printf 'export function greet(name) {\n return "Hello, " + name;\n}\n' > greet.js
|
|
64
|
+
git add -A && git commit -q -m "Add greet function"
|
|
65
|
+
|
|
66
|
+
# make a real change and stage it
|
|
67
|
+
printf 'export function greet(name) {\n if (!name) return "Hello there";\n return "Hello, " + name + "!";\n}\n' > greet.js
|
|
68
|
+
git add -A
|
|
69
|
+
|
|
70
|
+
# from a checkout of this repo, point the example at that repo
|
|
71
|
+
node examples/read-diff-and-generate.mjs /tmp/dan-commit-demo
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Real output from exactly the steps above:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Repo: /tmp/dan-commit-demo
|
|
78
|
+
Branch: main
|
|
79
|
+
|
|
80
|
+
Real diff (staged), 241 chars:
|
|
81
|
+
|
|
82
|
+
diff --git a/greet.js b/greet.js
|
|
83
|
+
index 34bc9a3..226d3e8 100644
|
|
84
|
+
--- a/greet.js
|
|
85
|
+
+++ b/greet.js
|
|
86
|
+
@@ -1,3 +1,4 @@
|
|
87
|
+
export function greet(name) {
|
|
88
|
+
- return "Hello, " + name;
|
|
89
|
+
+ if (!name) return "Hello there";
|
|
90
|
+
+ return "Hello, " + name + "!";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
No ANTHROPIC_API_KEY or OPENAI_API_KEY set — skipping message generation.
|
|
95
|
+
This is the same honest message the real UI shows; nothing is fabricated here.
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Tests
|
|
99
|
+
|
|
100
|
+
Real unit + integration tests, run against a real temporary git repo (they shell out to the real
|
|
101
|
+
`git` binary rather than mocking it), on Node's own built-in test runner — no install step and no
|
|
102
|
+
dependencies to add:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm test
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Observed on the current tree: **12 tests, all passing**.
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
# tests 12
|
|
112
|
+
# pass 12
|
|
113
|
+
# fail 0
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Requirements
|
|
117
|
+
|
|
118
|
+
- Node.js 18+
|
|
119
|
+
- A real `git` repository
|
|
120
|
+
- One of `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` set in your environment, for the Generate
|
|
121
|
+
button. Everything else (reading the diff, committing) works without either key.
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
125
|
+
# or
|
|
126
|
+
export OPENAI_API_KEY=sk-...
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Configuration
|
|
130
|
+
|
|
131
|
+
| Env var | Default | What it does |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| `DAN_OSS_COMMIT_PORT` | `4870` | Local port |
|
|
134
|
+
| `DAN_OSS_COMMIT_MODEL` | provider default | Model name passed to the API |
|
|
135
|
+
|
|
136
|
+
## What it never does
|
|
137
|
+
|
|
138
|
+
- Never sends your diff anywhere except the LLM API you've configured, with your own key.
|
|
139
|
+
- Never stages files you didn't ask it to (only stages everything if you tick the box, and only
|
|
140
|
+
when the diff shown was unstaged to begin with).
|
|
141
|
+
- Never fabricates a commit message when no API key is set — it tells you plainly instead.
|
|
142
|
+
- Never listens on anything but `127.0.0.1`.
|
|
143
|
+
|
|
144
|
+
## When to use this
|
|
145
|
+
|
|
146
|
+
- **Best fit**: you want a real, fast, drafted commit message from your actual diff, without
|
|
147
|
+
standing up a backend or sending anything anywhere except the LLM API you already have a key
|
|
148
|
+
for. Reading the diff and committing work with zero setup even without a key.
|
|
149
|
+
- **Best fit**: you already have an Anthropic or OpenAI key for other work and want your commit
|
|
150
|
+
messages written from real content instead of typed from memory or left as `wip`.
|
|
151
|
+
|
|
152
|
+
**Honest flip side**: if you want commit-message generation with *no* external API call at all —
|
|
153
|
+
not even opt-in — this isn't the right fit today; there's no local/offline model option. If you
|
|
154
|
+
need multi-provider comparison (generate from both Anthropic and OpenAI and pick), that's also not
|
|
155
|
+
built — `configuredProvider()` picks one deterministically, it doesn't call both.
|
|
156
|
+
|
|
157
|
+
## Dependencies
|
|
158
|
+
|
|
159
|
+
**Runtime dependencies: none.** Pure Node standard library — nothing to install to run it or test it.
|
|
160
|
+
|
|
161
|
+
| | |
|
|
162
|
+
|---|---|
|
|
163
|
+
| **Runtime dependencies** | **0** — Node standard library only |
|
|
164
|
+
| **Install to run** | none — `npx @strato-dan/commit` |
|
|
165
|
+
| **Install to test** | none — `npm test` uses Node's built-in test runner |
|
|
166
|
+
| **Node** | ≥ 18 |
|
|
167
|
+
| **API key** | `ANTHROPIC_API_KEY` **or** `OPENAI_API_KEY` — only for the **Generate** button, and it's your own existing key as an env var, not an installed package. Reading the diff and committing need no key. |
|
|
168
|
+
| **Dev-only** | `husky` — pulled in only if you clone to contribute; never needed to use the tool |
|
|
169
|
+
|
|
170
|
+
## Project contents
|
|
171
|
+
|
|
172
|
+
| Path | What it is |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `bin/dan-oss-commit.js` | The real CLI entry point — starts the server, opens your browser. |
|
|
175
|
+
| `src/server.js` | The loopback-only HTTP server and its routes. |
|
|
176
|
+
| `src/git.js` | Every real `git` call this tool makes (`execFile`, array args, never shell). |
|
|
177
|
+
| `src/llm.js` | The real Anthropic/OpenAI API call that generates a commit message. |
|
|
178
|
+
| `public/` | The plain HTML/CSS/vanilla-JS frontend served at `127.0.0.1`. |
|
|
179
|
+
| `examples/` | Runnable example code using the library functions directly, no UI. |
|
|
180
|
+
| `test/` | Real unit + integration tests (`npm test`, Node's own built-in test runner) — against a real temporary git repo, not a mocked one. |
|
|
181
|
+
|
|
182
|
+
## FAQ
|
|
183
|
+
|
|
184
|
+
**Does it work without an API key?** Yes — reading the real diff and committing both work with no
|
|
185
|
+
key at all. Only the **Generate** button needs one.
|
|
186
|
+
|
|
187
|
+
**Which LLM does it use?** Whichever of `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` you have set. If
|
|
188
|
+
both are set, `configuredProvider()` picks one deterministically (see `src/llm.js`) — it doesn't
|
|
189
|
+
call both or let you choose per-request yet.
|
|
190
|
+
|
|
191
|
+
**Can I use a local/self-hosted model instead?** Not yet. This is a real, current limitation, not
|
|
192
|
+
a design decision to exclude it forever — a local-model provider is a reasonable future addition
|
|
193
|
+
if there's real demand for it.
|
|
194
|
+
|
|
195
|
+
**Does it work on Windows?** The server and git calls are cross-platform (Node stdlib + `git`
|
|
196
|
+
itself). The browser auto-open now uses the standard-correct Windows invocation — `cmd /c start ""
|
|
197
|
+
<url>` run through `execFile` with no shell (`start` is a `cmd` builtin, not an executable, so it
|
|
198
|
+
has to run via `cmd`; the empty `""` is the window-title argument `start` expects). Honest caveat:
|
|
199
|
+
this path hasn't yet been runtime-tested on a real Windows machine — it follows the
|
|
200
|
+
documented-correct approach, but it's flagged as unverified rather than claimed as tested. The tool
|
|
201
|
+
itself still starts and works regardless, since the browser open is best-effort and never the
|
|
202
|
+
reason it fails to launch.
|
|
203
|
+
|
|
204
|
+
**What happens with a merge conflict or a repo with zero commits yet?** It runs without crashing
|
|
205
|
+
and surfaces git's own real output either way. On a repo with zero commits yet (`git init` run,
|
|
206
|
+
nothing committed — an unborn HEAD), `currentBranch()` still reads the real branch name where
|
|
207
|
+
`rev-parse --abbrev-ref HEAD` would fail — a real bug found and fixed during this tool's own build,
|
|
208
|
+
and covered by a passing test, not an assumption. During a merge conflict, `git diff --staged`
|
|
209
|
+
reports the unmerged path exactly as git itself does, so the tool shows git's real state rather
|
|
210
|
+
than erroring.
|
|
211
|
+
|
|
212
|
+
## Contributing
|
|
213
|
+
|
|
214
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
215
|
+
for how to file an issue or submit a PR. Maintainers may use AI tools to help review
|
|
216
|
+
contributions — please don't include personal information in an issue, PR, or commit beyond
|
|
217
|
+
what's needed to describe the change.
|
|
218
|
+
|
|
219
|
+
## Releasing
|
|
220
|
+
|
|
221
|
+
See [RELEASING.md](RELEASING.md) —
|
|
222
|
+
the same version-bump/tag/publish process applies to every DAN-OSS tool, this one included.
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT (code) — see `LICENSE`. The "DAN" name and logo are trademarked and not covered by the MIT
|
|
227
|
+
grant — see `TRADEMARK.md`.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
**[DAN] MEMORY SMASH** — the full codebase-memory engine this tool's commit-analysis capability
|
|
232
|
+
also lives inside — is coming soon.
|
package/TRADEMARK.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Trademark & Branding Notice
|
|
2
|
+
|
|
3
|
+
The MIT license in this repository (see `LICENSE`) covers the **source code only**.
|
|
4
|
+
|
|
5
|
+
**"DAN"**, the **"[DAN]"** wordmark, and the DAN logo are trademarks of DAN Systems and are
|
|
6
|
+
**not** part of the MIT grant.
|
|
7
|
+
|
|
8
|
+
In practice:
|
|
9
|
+
|
|
10
|
+
- ✅ You may fork this code, modify it, redistribute it, and use it in your own projects.
|
|
11
|
+
- ❌ You may **not** strip the DAN name/logo and present the result as your own.
|
|
12
|
+
- ❌ You may **not** use the DAN name or logo to market a fork or a derivative product without
|
|
13
|
+
written permission.
|
|
14
|
+
- ✅ The code is free. The brand is not.
|
|
15
|
+
|
|
16
|
+
This applies at every scale — from a single install onward, no threshold.
|
|
17
|
+
|
|
18
|
+
If you're not sure whether your use is covered, ask before shipping it.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// [DAN] COMMIT — real CLI entry. Starts the local server (loopback only), opens the browser,
|
|
3
|
+
// and exits cleanly on Ctrl-C. No global install required — `npx dan-oss-commit` in any real
|
|
4
|
+
// git repo.
|
|
5
|
+
import { listen } from "../src/server.js";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const cwd = process.cwd();
|
|
9
|
+
const port = Number(process.env.DAN_OSS_COMMIT_PORT) || 4870;
|
|
10
|
+
|
|
11
|
+
const server = await listen(port, cwd);
|
|
12
|
+
const url = `http://127.0.0.1:${port}`;
|
|
13
|
+
|
|
14
|
+
console.log(`[DAN] COMMIT running at ${url}`);
|
|
15
|
+
console.log(`Reading real changes in: ${cwd}`);
|
|
16
|
+
console.log("Ctrl-C to stop.\n");
|
|
17
|
+
|
|
18
|
+
// Best-effort browser open — never the reason the tool fails to start. execFile (no shell); on Windows
|
|
19
|
+
// `start` is a cmd builtin, so it must run via cmd.exe rather than be exec'd as if it were a binary.
|
|
20
|
+
const [openerCmd, openerArgs] =
|
|
21
|
+
process.platform === "darwin" ? ["open", [url]]
|
|
22
|
+
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
|
|
23
|
+
: ["xdg-open", [url]];
|
|
24
|
+
execFile(openerCmd, openerArgs, () => {});
|
|
25
|
+
|
|
26
|
+
process.on("SIGINT", () => {
|
|
27
|
+
server.close(() => process.exit(0));
|
|
28
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Real, runnable example — uses this package's own library functions directly, not the CLI/UI.
|
|
2
|
+
// Run it from inside any real git repo (including this one):
|
|
3
|
+
//
|
|
4
|
+
// node examples/read-diff-and-generate.mjs
|
|
5
|
+
// node examples/read-diff-and-generate.mjs /path/to/some/other/repo
|
|
6
|
+
//
|
|
7
|
+
// Shows the real diff dan-oss-commit would show you, and generates a real commit message from
|
|
8
|
+
// it if you have ANTHROPIC_API_KEY or OPENAI_API_KEY set — same two functions the UI itself
|
|
9
|
+
// calls, `realDiff` and `generateCommitMessage`, nothing re-implemented for the example.
|
|
10
|
+
import { realDiff, currentBranch } from "../src/git.js";
|
|
11
|
+
import { generateCommitMessage, configuredProvider } from "../src/llm.js";
|
|
12
|
+
|
|
13
|
+
const cwd = process.argv[2] || process.cwd();
|
|
14
|
+
|
|
15
|
+
const branch = await currentBranch(cwd);
|
|
16
|
+
console.log(`Repo: ${cwd}`);
|
|
17
|
+
console.log(`Branch: ${branch ?? "(detached HEAD)"}`);
|
|
18
|
+
|
|
19
|
+
const { diff, source } = await realDiff(cwd);
|
|
20
|
+
|
|
21
|
+
if (source === "none") {
|
|
22
|
+
console.log("No staged or unstaged changes — nothing to show or generate from.");
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
console.log(`\nReal diff (${source}), ${diff.length} chars:\n`);
|
|
27
|
+
console.log(diff.slice(0, 2000) + (diff.length > 2000 ? "\n... (truncated for this example)" : ""));
|
|
28
|
+
|
|
29
|
+
const provider = configuredProvider();
|
|
30
|
+
if (!provider) {
|
|
31
|
+
console.log("\nNo ANTHROPIC_API_KEY or OPENAI_API_KEY set — skipping message generation.");
|
|
32
|
+
console.log("This is the same honest message the real UI shows; nothing is fabricated here.");
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(`\nGenerating a real commit message via ${provider}...`);
|
|
37
|
+
const { message } = await generateCommitMessage(diff);
|
|
38
|
+
console.log(`\nGenerated message:\n\n${message}`);
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strato-dan/commit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "[DAN] COMMIT — analyses your staged changes and writes a real commit message from the real diff. One command, no backend to deploy.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"dan-oss-commit": "bin/dan-oss-commit.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"prepare": "husky || true",
|
|
12
|
+
"test": "node --test test/*.test.mjs"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"husky": "^9.1.7"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin",
|
|
22
|
+
"src",
|
|
23
|
+
"public",
|
|
24
|
+
"examples",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"TRADEMARK.md"
|
|
27
|
+
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"git",
|
|
30
|
+
"commit-message",
|
|
31
|
+
"ai",
|
|
32
|
+
"cli",
|
|
33
|
+
"developer-tools"
|
|
34
|
+
],
|
|
35
|
+
"author": "DAN Systems",
|
|
36
|
+
"homepage": "https://github.com/STRATO-DAN/dan-oss-commit",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/STRATO-DAN/dan-oss-commit.git"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/public/app.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// [DAN] COMMIT — real client logic. No framework, plain fetch + DOM, matching the "plain
|
|
2
|
+
// HTML/CSS/JS" spec this tool is built to.
|
|
3
|
+
|
|
4
|
+
const $ = (id) => document.getElementById(id);
|
|
5
|
+
let currentSource = "none";
|
|
6
|
+
|
|
7
|
+
function escapeHtml(s) {
|
|
8
|
+
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Light diff highlighting — colour only, per real unified-diff line prefix. Never changes the text.
|
|
12
|
+
function renderDiff(text) {
|
|
13
|
+
return text.split("\n").map((line) => {
|
|
14
|
+
let cls = "";
|
|
15
|
+
if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("diff ") || line.startsWith("index ")) cls = "meta";
|
|
16
|
+
else if (line.startsWith("@@")) cls = "hunk";
|
|
17
|
+
else if (line.startsWith("+")) cls = "add";
|
|
18
|
+
else if (line.startsWith("-")) cls = "del";
|
|
19
|
+
const safe = escapeHtml(line);
|
|
20
|
+
return cls ? `<span class="${cls}">${safe}</span>` : safe;
|
|
21
|
+
}).join("\n");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Summary-line char count (git convention: <=50 ideal, >72 too long) + copy-button enablement.
|
|
25
|
+
function updateMsgMeta() {
|
|
26
|
+
const v = $("message").value;
|
|
27
|
+
const n = v.split("\n")[0].length;
|
|
28
|
+
const el = $("charCount");
|
|
29
|
+
el.textContent = `${n} character${n === 1 ? "" : "s"} in the summary line` + (n > 72 ? " — over 72, consider shortening" : "");
|
|
30
|
+
el.style.color = n > 72 ? "var(--dan-red)" : "";
|
|
31
|
+
$("copyMsg").disabled = !v.trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function loadStatus() {
|
|
35
|
+
const res = await fetch("/api/status");
|
|
36
|
+
const data = await res.json();
|
|
37
|
+
if (!data.isRepo) {
|
|
38
|
+
$("status").textContent = `${data.cwd} is not a real git repository.`;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const branch = data.branch ? escapeHtml(data.branch) : "(no branch yet)";
|
|
42
|
+
const commitsNote = data.hasCommits ? "" : " — no commits yet";
|
|
43
|
+
// Real local absolute path (carries the OS username on most machines) never rendered — the
|
|
44
|
+
// repo's own folder name is enough context, matching this product's own "privacy is the
|
|
45
|
+
// architecture" line rather than leaking a filesystem detail nobody asked to see.
|
|
46
|
+
const rawRepoName = data.cwd.split("/").filter(Boolean).pop() || "this repo";
|
|
47
|
+
// Real trademark rule, not cosmetic: "DAN" never appears bare, always bracketed (TRADEMARK.md).
|
|
48
|
+
// A folder literally named "DAN-something" would otherwise print the one bare "DAN" on the
|
|
49
|
+
// whole page — render it as the real product-name form instead of the raw folder slug.
|
|
50
|
+
const danMatch = rawRepoName.match(/^DAN[-_](.+)$/i);
|
|
51
|
+
const repoName = danMatch
|
|
52
|
+
? `<span class="dan-br">[</span>DAN<span class="dan-br">]</span> ${escapeHtml(danMatch[1].replace(/[-_]/g, " "))}`
|
|
53
|
+
: escapeHtml(rawRepoName);
|
|
54
|
+
$("status").innerHTML = `<strong>${branch}</strong>${commitsNote} — ${repoName}` +
|
|
55
|
+
(data.provider ? "" : ` <span style="color: var(--dan-red)">— no ANTHROPIC_API_KEY / OPENAI_API_KEY set, Generate will fail honestly until one is</span>`);
|
|
56
|
+
|
|
57
|
+
// Real disclosure, not a placeholder: names the ACTUAL configured provider, since "on-device" /
|
|
58
|
+
// "privacy is the architecture" sits right above this action in the sidebar and Generate
|
|
59
|
+
// genuinely does send your diff to a real third-party cloud API with your own key — say so
|
|
60
|
+
// plainly rather than let that read as a contradiction.
|
|
61
|
+
const providerName = data.provider === "anthropic" ? "Anthropic" : data.provider === "openai" ? "OpenAI" : null;
|
|
62
|
+
$("providerDisclosure").textContent = providerName
|
|
63
|
+
? `Generate sends your real diff to ${providerName}'s API, using your own key. Nothing else reads it.`
|
|
64
|
+
: "";
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function loadDiff() {
|
|
69
|
+
const res = await fetch("/api/diff");
|
|
70
|
+
const data = await res.json();
|
|
71
|
+
if (!data.ok) {
|
|
72
|
+
$("diff").textContent = data.reason;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
currentSource = data.source;
|
|
76
|
+
if (data.source === "none") {
|
|
77
|
+
$("diff").textContent = "No real changes — nothing staged, nothing unstaged.";
|
|
78
|
+
$("files").innerHTML = "";
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
$("diff").innerHTML = renderDiff(data.diff);
|
|
82
|
+
$("files").innerHTML = data.files
|
|
83
|
+
.map((f) => `<li><span class="file-status ${f.status[0]}">${f.status[0]}</span><span>${escapeHtml(f.path)}</span></li>`)
|
|
84
|
+
.join("") + `<li class="hint" style="margin-top:6px;">source: ${data.source}</li>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function generate() {
|
|
88
|
+
const btn = $("generate");
|
|
89
|
+
const status = $("genStatus");
|
|
90
|
+
btn.disabled = true;
|
|
91
|
+
status.innerHTML = `<span class="dan-seal"></span> Reading the real diff and asking the real model…`;
|
|
92
|
+
try {
|
|
93
|
+
const diffRes = await fetch("/api/diff");
|
|
94
|
+
const diffData = await diffRes.json();
|
|
95
|
+
if (!diffData.ok || diffData.source === "none") {
|
|
96
|
+
status.textContent = "No real changes to describe.";
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const res = await fetch("/api/generate", {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "content-type": "application/json" },
|
|
102
|
+
body: JSON.stringify({ diff: diffData.diff }),
|
|
103
|
+
});
|
|
104
|
+
const data = await res.json();
|
|
105
|
+
if (!data.ok) {
|
|
106
|
+
status.textContent = data.reason;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
$("message").value = data.message;
|
|
110
|
+
$("commit").disabled = false;
|
|
111
|
+
updateMsgMeta();
|
|
112
|
+
status.textContent = `Generated via ${data.provider}${data.truncated ? " (diff truncated for length)" : ""}.`;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
status.textContent = `Failed: ${err.message}`;
|
|
115
|
+
} finally {
|
|
116
|
+
btn.disabled = false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function commit() {
|
|
121
|
+
const status = $("commitStatus");
|
|
122
|
+
const message = $("message").value.trim();
|
|
123
|
+
if (!message) {
|
|
124
|
+
status.textContent = "Write or generate a message first.";
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const stageAll = $("stageAll").checked && currentSource === "unstaged";
|
|
128
|
+
$("commit").disabled = true;
|
|
129
|
+
status.textContent = "Committing…";
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch("/api/commit", {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: { "content-type": "application/json" },
|
|
134
|
+
body: JSON.stringify({ message, stageAll }),
|
|
135
|
+
});
|
|
136
|
+
const data = await res.json();
|
|
137
|
+
if (!data.ok) {
|
|
138
|
+
status.textContent = data.reason;
|
|
139
|
+
$("commit").disabled = false;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
status.innerHTML = `Committed — <strong>${data.sha}</strong>`;
|
|
143
|
+
$("message").value = "";
|
|
144
|
+
updateMsgMeta();
|
|
145
|
+
await loadDiff();
|
|
146
|
+
} catch (err) {
|
|
147
|
+
status.textContent = `Failed: ${err.message}`;
|
|
148
|
+
$("commit").disabled = false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
$("generate").addEventListener("click", generate);
|
|
153
|
+
$("commit").addEventListener("click", commit);
|
|
154
|
+
$("message").addEventListener("input", () => { $("commit").disabled = !$("message").value.trim(); updateMsgMeta(); });
|
|
155
|
+
$("copyMsg").addEventListener("click", async () => {
|
|
156
|
+
const btn = $("copyMsg");
|
|
157
|
+
try {
|
|
158
|
+
await navigator.clipboard.writeText($("message").value);
|
|
159
|
+
btn.textContent = "Copied ✓";
|
|
160
|
+
} catch {
|
|
161
|
+
btn.textContent = "Copy failed";
|
|
162
|
+
}
|
|
163
|
+
setTimeout(() => { btn.textContent = "Copy"; }, 1400);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
(async () => {
|
|
167
|
+
const ok = await loadStatus();
|
|
168
|
+
if (ok) await loadDiff();
|
|
169
|
+
})();
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# [DAN] BRAND PACK v1 — font licences
|
|
2
|
+
|
|
3
|
+
All three bundled fonts are **SIL Open Font License 1.1**, which permits redistribution
|
|
4
|
+
and bundling, including in commercial products, provided the fonts are not sold on their
|
|
5
|
+
own and the licence travels with them.
|
|
6
|
+
|
|
7
|
+
| font | licence | upstream |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| JetBrains Mono | SIL OFL 1.1 | JetBrains |
|
|
10
|
+
| Doto | SIL OFL 1.1 | Google Fonts |
|
|
11
|
+
| Inter | SIL OFL 1.1 | Rasmus Andersson |
|
|
12
|
+
|
|
13
|
+
Latin subset only, retrieved 2026-07-24, verified `wOF2` magic on every file.
|
|
14
|
+
|
|
15
|
+
## 🔴 NOT BUNDLED, AND MUST NEVER BE
|
|
16
|
+
|
|
17
|
+
**SF Pro / SF Pro Display / SF Mono — Apple.** Licensed for use *on Apple systems*, not for
|
|
18
|
+
redistribution. We reference them through the system font stack, where the OS supplies them,
|
|
19
|
+
which is free and correct. **Putting an SF Pro file in this folder would be a licence
|
|
20
|
+
breach.** Same for Segoe UI (Microsoft) — referenced, never shipped.
|
|
21
|
+
|
|
22
|
+
The rule generalises: **bundle only OFL/Apache/MIT-licensed faces; reference everything else
|
|
23
|
+
through the system stack.** Any new font added here needs its licence checked and recorded
|
|
24
|
+
in this table first.
|
package/public/icons.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// [DAN] COMMIT — dot-matrix icons, copied deliberately (same "copy, don't cross-repo-depend"
|
|
2
|
+
// discipline as style.css's own fonts/tokens) from DAN's own
|
|
3
|
+
// internal brand-pack source of truth, not cross-repo-linked. Only the four concepts this page
|
|
4
|
+
// actually uses are ported, not the whole matched-icon-set library — this is a static site with
|
|
5
|
+
// four fixed section headers, not a product that needs the full icon vocabulary.
|
|
6
|
+
//
|
|
7
|
+
// Real convention this preserves: "line" form (SVG) for buttons/menus/nav, "dot" form
|
|
8
|
+
// (bitmap-grid) for status/brand/hero moments — this page uses dot form for its section
|
|
9
|
+
// headers, matching what a brand-pack "hero" label calls for over a plain SVG glyph.
|
|
10
|
+
(function () {
|
|
11
|
+
var RED = "#f25b4f", OK = "#2e9e56";
|
|
12
|
+
var ACCENT = { on: "currentColor", ok: OK, red: RED };
|
|
13
|
+
|
|
14
|
+
// [name, 7x7 dot bitmap, accent] — exact bitmaps copied from the canonical source, not redrawn.
|
|
15
|
+
var ICONS = {
|
|
16
|
+
folder: { rows: ["0000000","1110000","1111110","1000010","1000010","1111110","0000000"], accent: "on" },
|
|
17
|
+
doc: { rows: ["1111100","1000110","1000101","1000001","1000001","1000001","1111111"], accent: "on" },
|
|
18
|
+
code: { rows: ["0000000","0010010","0100100","1001000","0100100","0010010","0000000"], accent: "on" },
|
|
19
|
+
check: { rows: ["0000000","0000010","0000110","1001100","1011000","0110000","0000000"], accent: "ok" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function dot(name, px) {
|
|
23
|
+
var e = ICONS[name];
|
|
24
|
+
if (!e) return "";
|
|
25
|
+
px = px || 12;
|
|
26
|
+
var acc = ACCENT[e.accent] || "currentColor";
|
|
27
|
+
var cols = e.rows[0].length;
|
|
28
|
+
var gap = Math.max(1, Math.round(px * 0.42));
|
|
29
|
+
var out = '<span class="dan-idg" style="grid-template-columns:repeat(' + cols + ',' + px + 'px);gap:' + gap + 'px">';
|
|
30
|
+
e.rows.join("").split("").forEach(function (c) {
|
|
31
|
+
var fill = c === "1" ? acc : null;
|
|
32
|
+
out += '<i class="dan-idp" style="width:' + px + 'px;height:' + px + 'px;' +
|
|
33
|
+
(fill ? "background:" + fill : "box-shadow:inset 0 0 0 1px rgba(128,128,128,.30)") + '"></i>';
|
|
34
|
+
});
|
|
35
|
+
return out + "</span>";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
window.DANIcons = { dot: dot };
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
// [DAN] brand favicon — red brackets · white DAN · charcoal ground. Injected from this one
|
|
42
|
+
// shared script so every page carries the mark without a per-file <link> to forget.
|
|
43
|
+
(function () {
|
|
44
|
+
if (document.querySelector('link[rel="icon"]')) return;
|
|
45
|
+
var svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">' +
|
|
46
|
+
'<rect width="64" height="64" rx="13" fill="#1e1e22"/>' +
|
|
47
|
+
'<text x="32" y="41" text-anchor="middle" font-family="ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="800" font-size="21" letter-spacing="-1">' +
|
|
48
|
+
'<tspan fill="#f25b4f">[</tspan><tspan fill="#ffffff">DAN</tspan><tspan fill="#f25b4f">]</tspan>' +
|
|
49
|
+
'</text></svg>';
|
|
50
|
+
var link = document.createElement("link");
|
|
51
|
+
link.rel = "icon"; link.type = "image/svg+xml";
|
|
52
|
+
link.href = "data:image/svg+xml," + encodeURIComponent(svg);
|
|
53
|
+
document.head.appendChild(link);
|
|
54
|
+
})();
|
|
55
|
+
})();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>[DAN] COMMIT</title>
|
|
7
|
+
<link rel="stylesheet" href="/style.css">
|
|
8
|
+
<script src="/icons.js"></script>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header>
|
|
12
|
+
<a class="dan-logo" href="/"><span class="dan-br">[</span>DAN<span class="dan-br">]</span> COMMIT</a>
|
|
13
|
+
<span class="tagline">Real diff in. Real commit message out.</span>
|
|
14
|
+
</header>
|
|
15
|
+
|
|
16
|
+
<aside class="intro-box">
|
|
17
|
+
<p class="intro-heading dan-dot">Sovereign <span class="dan-br">[</span>AI<span class="dan-br">]</span> system of <span class="dan-br">[</span>Systems<span class="dan-br">]</span></p>
|
|
18
|
+
<p><span class="dan-br">[</span>DAN<span class="dan-br">]</span> is a sovereign AI system of
|
|
19
|
+
systems — the Digital Architecture Network. Built edge-first, on-device, in the UAE.</p>
|
|
20
|
+
<p class="intro-strong">Privacy is the architecture, not the afterthought.</p>
|
|
21
|
+
<p class="intro-mono dan-dot">
|
|
22
|
+
<span class="dan-br">[</span>DAN<span class="dan-br">]</span> — DIGITAL ARCHITECTURE NETWORK, LET'S GO <span class="dan-br">[</span>BEYOND<span class="dan-br">]</span>
|
|
23
|
+
</p>
|
|
24
|
+
<p class="intro-mono">
|
|
25
|
+
STRATO MESH NETWORK<br>
|
|
26
|
+
<span class="site-text">www.thedubai.ai</span>
|
|
27
|
+
</p>
|
|
28
|
+
</aside>
|
|
29
|
+
|
|
30
|
+
<div class="layout">
|
|
31
|
+
<main>
|
|
32
|
+
<div class="dan-card">
|
|
33
|
+
<h2><span id="icon-repo"></span>Repo</h2>
|
|
34
|
+
<div id="status">Reading real repo state…</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div class="dan-card">
|
|
38
|
+
<h2><span id="icon-files"></span>Changed files</h2>
|
|
39
|
+
<ul class="file-list" id="files"></ul>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div class="dan-card">
|
|
43
|
+
<h2><span id="icon-diff"></span>Real diff</h2>
|
|
44
|
+
<pre class="diff" id="diff">Reading the real diff…</pre>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div class="dan-card">
|
|
48
|
+
<h2><span id="icon-commit"></span>Commit message</h2>
|
|
49
|
+
<p class="hint" id="providerDisclosure"></p>
|
|
50
|
+
<div class="actions">
|
|
51
|
+
<button class="dan-btn" id="generate">Generate from the real diff</button>
|
|
52
|
+
<span class="hint" id="genStatus"></span>
|
|
53
|
+
</div>
|
|
54
|
+
<div style="margin-top: 14px;">
|
|
55
|
+
<textarea id="message" placeholder="Write your own, or click Generate above."></textarea>
|
|
56
|
+
<div class="msg-meta">
|
|
57
|
+
<span class="hint" id="charCount">0 characters in the summary line</span>
|
|
58
|
+
<button class="dan-btn-ghost" id="copyMsg" disabled>Copy</button>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="actions">
|
|
62
|
+
<button class="dan-btn" id="commit" disabled>Commit</button>
|
|
63
|
+
<label class="hint"><input type="checkbox" id="stageAll"> stage all changes first (unstaged diff only)</label>
|
|
64
|
+
<span class="hint" id="commitStatus"></span>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</main>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<footer>
|
|
71
|
+
<strong><span class="dan-br">[</span>DAN<span class="dan-br">]</span> MEMORY SMASH</strong> —
|
|
72
|
+
the full codebase-memory engine this tool's commit-analysis capability also lives inside —
|
|
73
|
+
is coming soon.
|
|
74
|
+
</footer>
|
|
75
|
+
|
|
76
|
+
<script>
|
|
77
|
+
// px=3 — a small inline glyph beside an h2 label, not the sidebar's own headline-scale use
|
|
78
|
+
// of the dot font. At the library's default (12px/cell) a 7-wide icon renders ~100px, far
|
|
79
|
+
// bigger than the text it's meant to sit beside — this stacked it above the heading instead.
|
|
80
|
+
document.getElementById("icon-repo").innerHTML = DANIcons.dot("folder", 3);
|
|
81
|
+
document.getElementById("icon-files").innerHTML = DANIcons.dot("doc", 3);
|
|
82
|
+
document.getElementById("icon-diff").innerHTML = DANIcons.dot("code", 3);
|
|
83
|
+
document.getElementById("icon-commit").innerHTML = DANIcons.dot("check", 3);
|
|
84
|
+
</script>
|
|
85
|
+
<script type="module" src="/app.js"></script>
|
|
86
|
+
</body>
|
|
87
|
+
</html>
|
package/public/style.css
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/* [DAN] COMMIT — brand tokens + fonts copied deliberately from DAN's own brand source of
|
|
2
|
+
truth, same "copy, verify, don't cross-repo-depend" discipline. Fonts are the real,
|
|
3
|
+
self-hosted OFL files (public/fonts/, LICENSES.md alongside) — no external font fetch, same
|
|
4
|
+
"no third-party beacon on a page whose pitch is privacy" reasoning the source file states.
|
|
5
|
+
|
|
6
|
+
🔴 NO GREY TEXT — every foreground is full ink (--dan-text), never --dan-text-2/--dan-text-3.
|
|
7
|
+
De-emphasis comes from size/weight, not colour. Red is the one signal colour, theme-invariant. */
|
|
8
|
+
@font-face {
|
|
9
|
+
font-family: "JetBrains Mono";
|
|
10
|
+
src: url("fonts/JetBrainsMono-latin.woff2") format("woff2");
|
|
11
|
+
font-weight: 100 800;
|
|
12
|
+
font-style: normal;
|
|
13
|
+
font-display: swap;
|
|
14
|
+
}
|
|
15
|
+
@font-face {
|
|
16
|
+
font-family: "Doto";
|
|
17
|
+
src: url("fonts/Doto-latin.woff2") format("woff2");
|
|
18
|
+
font-weight: 100 900;
|
|
19
|
+
font-style: normal;
|
|
20
|
+
font-display: swap;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
:root {
|
|
24
|
+
/* native controls (number spinners, <select> arrow, scrollbars) follow the theme */
|
|
25
|
+
color-scheme: light dark;
|
|
26
|
+
--dan-red: #f25b4f;
|
|
27
|
+
--dan-font: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI Variable Text",
|
|
28
|
+
"Segoe UI", Roboto, Ubuntu, Cantarell, "Noto Sans", system-ui, sans-serif;
|
|
29
|
+
--dan-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", "Cascadia Mono",
|
|
30
|
+
Consolas, "DejaVu Sans Mono", "Liberation Mono", monospace;
|
|
31
|
+
--dan-dot: "Doto", "JetBrains Mono", ui-monospace, "Cascadia Mono", Consolas,
|
|
32
|
+
"DejaVu Sans Mono", monospace;
|
|
33
|
+
|
|
34
|
+
--dan-bg: #f5f5f3;
|
|
35
|
+
--dan-bg-2: #ffffff;
|
|
36
|
+
--dan-text: #16161a;
|
|
37
|
+
--dan-border: rgba(0, 0, 0, 0.12);
|
|
38
|
+
--dan-border-2: rgba(0, 0, 0, 0.2);
|
|
39
|
+
--dan-btn-text: #ffffff;
|
|
40
|
+
--dan-ok: #2e9e56;
|
|
41
|
+
}
|
|
42
|
+
@media (prefers-color-scheme: dark) {
|
|
43
|
+
:root {
|
|
44
|
+
--dan-bg: #1b1b1f;
|
|
45
|
+
--dan-bg-2: #232329;
|
|
46
|
+
--dan-text: #f5f5f7;
|
|
47
|
+
--dan-border: rgba(255, 255, 255, 0.12);
|
|
48
|
+
--dan-border-2: rgba(255, 255, 255, 0.2);
|
|
49
|
+
--dan-btn-text: #0e0e11;
|
|
50
|
+
--dan-ok: #3ecb72;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
* { box-sizing: border-box; }
|
|
55
|
+
html, body { margin: 0; }
|
|
56
|
+
/* 🔴 REAL BUG FOUND + FIXED: `height: 100%` + `display: flex; flex-direction: column` clips a
|
|
57
|
+
flex child to the viewport's own height instead of letting it grow — once real page content
|
|
58
|
+
(cards + diff + footer) got taller than one screen, the footer stopped following it in normal
|
|
59
|
+
flow and rendered floating mid-page, overlapping the Commit-message card. `min-height` lets the
|
|
60
|
+
body grow past the viewport when real content needs more room, same as a plain (non-flex) page
|
|
61
|
+
already would. */
|
|
62
|
+
html, body { min-height: 100%; }
|
|
63
|
+
/* Real scrollbar ink, same "no grey, full ink" rule as everything else — the default OS
|
|
64
|
+
scrollbar (dark thumb on a light track, or invisible-in-dark-mode in some browsers) doesn't
|
|
65
|
+
follow the page's own theme reliably, so this states it explicitly for both engines. */
|
|
66
|
+
* { scrollbar-color: var(--dan-text) var(--dan-bg-2); scrollbar-width: thin; }
|
|
67
|
+
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
68
|
+
*::-webkit-scrollbar-track { background: var(--dan-bg-2); }
|
|
69
|
+
*::-webkit-scrollbar-thumb { background: var(--dan-text); border-radius: 6px; border: 2px solid var(--dan-bg-2); }
|
|
70
|
+
/* 🔴 REAL BUG FOUND + FIXED: `.layout { flex: 1 }` inside a flex column body grows to fill
|
|
71
|
+
leftover vertical space below short page content — real space, so selecting text and dragging
|
|
72
|
+
(or a keyboard select-all) paints the selection highlight across that empty box too, all the
|
|
73
|
+
way down to the footer/viewport edge, not just the text. Grid rows (auto content / 1fr / auto)
|
|
74
|
+
give the exact same "push the footer down" behavior without a flex item that grows past its own
|
|
75
|
+
content, so there's no leftover box for the browser to paint a selection into. */
|
|
76
|
+
body {
|
|
77
|
+
background: var(--dan-bg);
|
|
78
|
+
color: var(--dan-text);
|
|
79
|
+
font-family: var(--dan-font);
|
|
80
|
+
line-height: 1.45;
|
|
81
|
+
-webkit-font-smoothing: antialiased;
|
|
82
|
+
display: grid;
|
|
83
|
+
grid-template-rows: auto 1fr auto;
|
|
84
|
+
min-height: 100vh;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.dan-br { color: var(--dan-red); }
|
|
88
|
+
.dan-logo { font-family: var(--dan-mono); font-weight: 800; font-size: 22px; letter-spacing: 0.02em; color: var(--dan-text); text-decoration: none; }
|
|
89
|
+
.dan-logo .dan-br { margin: 0 0.1em; }
|
|
90
|
+
|
|
91
|
+
header {
|
|
92
|
+
height: 54px; box-sizing: border-box; display: flex; align-items: center;
|
|
93
|
+
justify-content: space-between; padding: 0 20px; border-bottom: 1px solid var(--dan-border);
|
|
94
|
+
flex-shrink: 0;
|
|
95
|
+
}
|
|
96
|
+
header .tagline { font-size: 12.5px; font-weight: 600; }
|
|
97
|
+
|
|
98
|
+
.layout {
|
|
99
|
+
padding: 16px 20px; min-height: 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* 🔴 the red box — real company intro, pinned to the TRUE viewport edge (not the centered
|
|
103
|
+
content column's own edge) and wide enough to hold the tagline on one real line. Fixed, not
|
|
104
|
+
in-flow, so it never fights the centered `.layout` column for width; `.layout` gets real
|
|
105
|
+
left padding on the same breakpoint so its own content never sits under it. Hidden below that
|
|
106
|
+
breakpoint so mobile stays one column, same as before. */
|
|
107
|
+
.intro-box {
|
|
108
|
+
/* 🔴 REAL BUG FOUND + FIXED: the sidebar sat flush under the header (top: 54px) while the first
|
|
109
|
+
real card sat 16px lower (`.layout`'s own top padding) — their top EDGES never lined up,
|
|
110
|
+
verified via getBoundingClientRect (70 vs 54). top: 86px matches that same offset now that the
|
|
111
|
+
box floats 16px in from the edges instead of sitting flush against them (see below). */
|
|
112
|
+
position: fixed; left: 16px; top: 86px; bottom: 16px; width: 352px; overflow-y: auto;
|
|
113
|
+
border: 1px solid var(--dan-red); border-radius: 14px;
|
|
114
|
+
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.07);
|
|
115
|
+
padding: 22px; background: color-mix(in srgb, var(--dan-red) 7%, var(--dan-bg-2));
|
|
116
|
+
font-size: 12.5px; line-height: 1.55;
|
|
117
|
+
}
|
|
118
|
+
.intro-box p { margin: 0 0 12px; }
|
|
119
|
+
.intro-box p:last-child { margin-bottom: 0; }
|
|
120
|
+
.intro-heading { font-weight: 700; letter-spacing: 0.015em; font-size: 11.5px; white-space: nowrap; }
|
|
121
|
+
.intro-strong { font-weight: 700; }
|
|
122
|
+
.intro-mono { font-family: var(--dan-mono); font-size: 11px; letter-spacing: 0.02em; }
|
|
123
|
+
.intro-mono.dan-dot { font-size: 9.5px; letter-spacing: 0.01em; white-space: nowrap; }
|
|
124
|
+
.site-text { color: var(--dan-text); } /* real text, not a hyperlink — never navigates */
|
|
125
|
+
@media (max-width: 940px) { .intro-box { display: none; } }
|
|
126
|
+
|
|
127
|
+
/* [DAN] dot-matrix accent — the brand pack's own `.dan-dot` label/tagline treatment, real font
|
|
128
|
+
(self-hosted Doto, public/fonts/), sized big per spec rather than the pack's own small-label
|
|
129
|
+
default, since this is the page's own headline moment, not a small inline tag. */
|
|
130
|
+
.dan-dot { font-family: var(--dan-dot); font-weight: 900; text-transform: uppercase; letter-spacing: 0.05em; color: var(--dan-text); }
|
|
131
|
+
.dan-dot .dan-br { margin: 0 0.05em; }
|
|
132
|
+
|
|
133
|
+
main { max-width: 720px; margin: 0 auto; }
|
|
134
|
+
|
|
135
|
+
.dan-card {
|
|
136
|
+
background: var(--dan-bg-2); border: 1px solid var(--dan-border); border-radius: 14px;
|
|
137
|
+
padding: 14px 16px; margin-bottom: 12px;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.dan-btn {
|
|
141
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
|
142
|
+
background: var(--dan-red); color: #1b1b20; font-family: var(--dan-font);
|
|
143
|
+
font-size: 14px; font-weight: 600; padding: 12px 20px; border-radius: 10px;
|
|
144
|
+
text-decoration: none; border: 0; cursor: pointer; transition: opacity 0.2s, transform 0.2s;
|
|
145
|
+
}
|
|
146
|
+
.dan-btn:hover { opacity: 0.9; transform: translateY(-1px); }
|
|
147
|
+
.dan-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
|
148
|
+
|
|
149
|
+
.dan-btn-ghost {
|
|
150
|
+
display: inline-flex; align-items: center; gap: 8px; font-family: var(--dan-mono);
|
|
151
|
+
font-size: 12px; font-weight: 600; letter-spacing: 0.05em; color: var(--dan-text);
|
|
152
|
+
border: 1px solid var(--dan-border-2); border-radius: 8px; padding: 9px 16px;
|
|
153
|
+
background: transparent; cursor: pointer;
|
|
154
|
+
}
|
|
155
|
+
.dan-btn-ghost:hover { border-color: var(--dan-red); color: var(--dan-red); }
|
|
156
|
+
|
|
157
|
+
h1 { font-size: 20px; margin: 0 0 4px; }
|
|
158
|
+
h2 {
|
|
159
|
+
font-size: 14px; font-weight: 700; margin: 0 0 10px; text-transform: uppercase; letter-spacing: 0.04em;
|
|
160
|
+
display: flex; align-items: center; gap: 8px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* Dot-matrix icon grid — matches the brand pack's own `.dan-idg`/`.dan-idp` render shape
|
|
164
|
+
(dan-icons.js's `dot()`), same pixel-cell technique, ported into public/icons.js above. */
|
|
165
|
+
.dan-idg { display: inline-grid; vertical-align: middle; }
|
|
166
|
+
.dan-idp { border-radius: 50%; display: block; }
|
|
167
|
+
|
|
168
|
+
.file-list { list-style: none; margin: 0; padding: 0; font-family: var(--dan-mono); font-size: 12.5px; }
|
|
169
|
+
.file-list li { display: flex; gap: 10px; padding: 3px 0; }
|
|
170
|
+
.file-status { width: 14px; text-align: center; font-weight: 700; }
|
|
171
|
+
.file-status.M { color: var(--dan-text); }
|
|
172
|
+
.file-status.A { color: var(--dan-ok); }
|
|
173
|
+
.file-status.D { color: var(--dan-red); }
|
|
174
|
+
|
|
175
|
+
pre.diff {
|
|
176
|
+
background: var(--dan-bg); border: 1px solid var(--dan-border); border-radius: 10px;
|
|
177
|
+
padding: 10px 12px; font-family: var(--dan-mono); font-size: 11.5px; overflow-x: auto;
|
|
178
|
+
max-height: 160px; overflow-y: auto; white-space: pre-wrap; word-break: break-word;
|
|
179
|
+
}
|
|
180
|
+
/* light syntax highlighting on the real diff — added/removed/hunk lines, colour only. */
|
|
181
|
+
pre.diff .add { color: #2e9e56; }
|
|
182
|
+
pre.diff .del { color: var(--dan-red); }
|
|
183
|
+
pre.diff .hunk { color: #2563eb; font-weight: 600; }
|
|
184
|
+
pre.diff .meta { opacity: 0.65; }
|
|
185
|
+
@media (prefers-color-scheme: dark) {
|
|
186
|
+
pre.diff .add { color: #3ecb72; }
|
|
187
|
+
pre.diff .hunk { color: #5b8cff; }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* summary char-count + copy button under the message box */
|
|
191
|
+
.msg-meta { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 8px; }
|
|
192
|
+
.msg-meta .hint { font-variant-numeric: tabular-nums; }
|
|
193
|
+
|
|
194
|
+
textarea#message {
|
|
195
|
+
width: 100%; min-height: 70px; font-family: var(--dan-mono); font-size: 13px;
|
|
196
|
+
background: var(--dan-bg); color: var(--dan-text); border: 1px solid var(--dan-border);
|
|
197
|
+
border-radius: 10px; padding: 10px 12px; resize: vertical;
|
|
198
|
+
}
|
|
199
|
+
textarea#message:focus { outline: none; border-color: var(--dan-red); }
|
|
200
|
+
|
|
201
|
+
.actions { display: flex; gap: 10px; align-items: center; margin-top: 12px; flex-wrap: wrap; }
|
|
202
|
+
.hint { font-size: 13.5px; font-weight: 500; }
|
|
203
|
+
#providerDisclosure { margin: 0 0 10px; }
|
|
204
|
+
#providerDisclosure:empty { display: none; }
|
|
205
|
+
.actions label.hint { display: inline-flex; align-items: center; gap: 7px; }
|
|
206
|
+
.actions label.hint input[type="checkbox"] { width: 17px; height: 17px; accent-color: var(--dan-red); }
|
|
207
|
+
|
|
208
|
+
.dan-seal { --dan-sz: 16px; display: inline-block; width: var(--dan-sz); height: var(--dan-sz);
|
|
209
|
+
border-radius: 50%; vertical-align: middle;
|
|
210
|
+
background: conic-gradient(from 0deg, transparent 0 62%, var(--dan-red) 85%, transparent 92%);
|
|
211
|
+
-webkit-mask: radial-gradient(closest-side, transparent 62%, #000 63%);
|
|
212
|
+
mask: radial-gradient(closest-side, transparent 62%, #000 63%);
|
|
213
|
+
animation: dan-seal-spin 0.9s linear infinite;
|
|
214
|
+
}
|
|
215
|
+
@keyframes dan-seal-spin { to { transform: rotate(1turn); } }
|
|
216
|
+
.hidden { display: none !important; }
|
|
217
|
+
|
|
218
|
+
footer {
|
|
219
|
+
padding: 20px 20px 40px; border-top: 1px solid var(--dan-border);
|
|
220
|
+
font-size: 12.5px; font-weight: 500;
|
|
221
|
+
}
|
|
222
|
+
footer a { color: var(--dan-red); text-decoration: none; }
|
|
223
|
+
footer a:hover { text-decoration: underline; }
|
|
224
|
+
footer p { margin: 0 0 12px; }
|
|
225
|
+
footer .footer-brand { font-family: var(--dan-mono); font-size: 11.5px; letter-spacing: 0.02em; }
|
|
226
|
+
|
|
227
|
+
/* 🔴 REAL BUG FOUND + FIXED: this override must come AFTER the base `.layout`/`footer` rules in
|
|
228
|
+
source order — CSS media queries carry no special cascade priority of their own, so an earlier
|
|
229
|
+
media-query rule loses to a later plain rule of equal specificity regardless of which one is
|
|
230
|
+
"supposed" to win. footer's own shorthand `padding` (declared later, in the base rules above)
|
|
231
|
+
was silently resetting this block's `padding-left` back to 20px every time. Real fix: put
|
|
232
|
+
width-dependent overrides at the END of the file, where the cascade actually respects them. */
|
|
233
|
+
@media (min-width: 941px) {
|
|
234
|
+
/* 🔴 REAL BUG FOUND + FIXED: this used to be `padding-left: 400px` on TOP of `.layout`'s own
|
|
235
|
+
`margin: 0 auto` centering in the FULL viewport width — the two never agreed, leaving a real,
|
|
236
|
+
unexplained ~180px dead gap between the sidebar's own right edge (380px) and where the cards
|
|
237
|
+
actually started (560px on a 1280px screen). `margin-left` on the outer `.layout`/`footer`
|
|
238
|
+
containers offsets them by exactly the sidebar's real width; `main`'s own `margin: 0 auto`
|
|
239
|
+
then centers the cards in whatever space genuinely remains, not in the full viewport. */
|
|
240
|
+
.layout { margin-left: 392px; }
|
|
241
|
+
footer { margin-left: 392px; }
|
|
242
|
+
}
|
package/src/git.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Real git access only — every function here shells out to the user's own `git`, never
|
|
2
|
+
// re-implements git's own diff/status logic. execFile, not exec: arguments are passed as a
|
|
3
|
+
// real array, never interpolated into a shell string, so a branch/file name can never be
|
|
4
|
+
// read as a shell command.
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
async function git(cwd, args) {
|
|
11
|
+
try {
|
|
12
|
+
const { stdout } = await run("git", args, { cwd, maxBuffer: 1024 * 1024 * 32 });
|
|
13
|
+
return stdout;
|
|
14
|
+
} catch (err) {
|
|
15
|
+
// A real git failure (not a repo, git missing, bad ref) carries useful stderr — surface
|
|
16
|
+
// it rather than swallowing it into a generic "something went wrong".
|
|
17
|
+
const detail = err.stderr?.trim() || err.message;
|
|
18
|
+
throw new Error(`git ${args.join(" ")} failed: ${detail}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function isRepo(cwd) {
|
|
23
|
+
try {
|
|
24
|
+
await git(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The real diff to commit — staged changes if any exist, otherwise all unstaged changes.
|
|
32
|
+
* Never both silently combined: the caller is told which one it got, so "nothing staged, used
|
|
33
|
+
* unstaged instead" is a visible fact, not a guess. */
|
|
34
|
+
export async function realDiff(cwd) {
|
|
35
|
+
const staged = await git(cwd, ["diff", "--staged"]);
|
|
36
|
+
if (staged.trim()) {
|
|
37
|
+
return { diff: staged, source: "staged" };
|
|
38
|
+
}
|
|
39
|
+
const unstaged = await git(cwd, ["diff"]);
|
|
40
|
+
return { diff: unstaged, source: unstaged.trim() ? "unstaged" : "none" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function changedFiles(cwd, source) {
|
|
44
|
+
const args = source === "staged" ? ["diff", "--staged", "--name-status"] : ["diff", "--name-status"];
|
|
45
|
+
const out = await git(cwd, args);
|
|
46
|
+
return out
|
|
47
|
+
.split("\n")
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.map((line) => {
|
|
50
|
+
const [status, ...rest] = line.split("\t");
|
|
51
|
+
return { status, path: rest.join("\t") };
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Real commit — stages everything first only if the caller explicitly asked for "unstaged"
|
|
56
|
+
* source (so the message that was generated from those changes is what actually gets
|
|
57
|
+
* committed); never silently stages files the diff shown to the user didn't cover. */
|
|
58
|
+
export async function realCommit(cwd, message, { stageAll } = {}) {
|
|
59
|
+
if (stageAll) {
|
|
60
|
+
await git(cwd, ["add", "-A"]);
|
|
61
|
+
}
|
|
62
|
+
await git(cwd, ["commit", "-m", message]);
|
|
63
|
+
const sha = (await git(cwd, ["rev-parse", "--short", "HEAD"])).trim();
|
|
64
|
+
return { sha };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `symbolic-ref`, not `rev-parse --abbrev-ref HEAD` — the latter needs HEAD to resolve to a
|
|
68
|
+
* real commit and fails on a genuinely valid state this tool must still handle: a fresh repo
|
|
69
|
+
* with `git init` run and nothing committed yet (HEAD is unborn, but the branch name is real and
|
|
70
|
+
* readable straight from .git/HEAD). */
|
|
71
|
+
export async function currentBranch(cwd) {
|
|
72
|
+
try {
|
|
73
|
+
return (await git(cwd, ["symbolic-ref", "--short", "HEAD"])).trim();
|
|
74
|
+
} catch {
|
|
75
|
+
return null; // detached HEAD or something symbolic-ref genuinely can't name — honest null, not a guess
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function hasCommits(cwd) {
|
|
80
|
+
try {
|
|
81
|
+
await git(cwd, ["rev-parse", "--verify", "HEAD"]);
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/llm.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Real LLM call, provider-agnostic on purpose — this is a standalone open-source tool with NO
|
|
2
|
+
// tie-in to DAN's own infrastructure (per the product's own locked strategy), so it must work
|
|
3
|
+
// with whatever real API key the end user already has, never a DAN-hosted key or endpoint.
|
|
4
|
+
//
|
|
5
|
+
// Real, honest failure: no key configured is a clear, actionable error, never a silent fallback
|
|
6
|
+
// to a fabricated commit message.
|
|
7
|
+
|
|
8
|
+
const SYSTEM_PROMPT = `You write git commit messages from a real diff. Rules:
|
|
9
|
+
- First line: a real, specific summary in the imperative mood ("Fix", "Add", "Remove"), under 72 chars.
|
|
10
|
+
- Blank line, then body only if the diff needs more than the summary to explain WHY, not WHAT (the diff already shows what changed).
|
|
11
|
+
- Never invent a reason not visible in the diff. If the "why" isn't clear from the code, describe the change plainly instead of guessing motive.
|
|
12
|
+
- No markdown, no trailing period on the summary line, no AI/tool attribution of any kind.`;
|
|
13
|
+
|
|
14
|
+
async function callAnthropic(apiKey, diff) {
|
|
15
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: {
|
|
18
|
+
"content-type": "application/json",
|
|
19
|
+
"x-api-key": apiKey,
|
|
20
|
+
"anthropic-version": "2023-06-01",
|
|
21
|
+
},
|
|
22
|
+
body: JSON.stringify({
|
|
23
|
+
model: process.env.DAN_OSS_COMMIT_MODEL || "claude-sonnet-5",
|
|
24
|
+
max_tokens: 400,
|
|
25
|
+
system: SYSTEM_PROMPT,
|
|
26
|
+
messages: [{ role: "user", content: `Real diff:\n\n${diff}` }],
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
throw new Error(`Anthropic API error ${res.status}: ${await res.text()}`);
|
|
31
|
+
}
|
|
32
|
+
const data = await res.json();
|
|
33
|
+
return data.content?.[0]?.text?.trim() ?? "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function callOpenAI(apiKey, diff) {
|
|
37
|
+
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
"content-type": "application/json",
|
|
41
|
+
authorization: `Bearer ${apiKey}`,
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
model: process.env.DAN_OSS_COMMIT_MODEL || "gpt-4o-mini",
|
|
45
|
+
max_tokens: 400,
|
|
46
|
+
messages: [
|
|
47
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
48
|
+
{ role: "user", content: `Real diff:\n\n${diff}` },
|
|
49
|
+
],
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
throw new Error(`OpenAI API error ${res.status}: ${await res.text()}`);
|
|
54
|
+
}
|
|
55
|
+
const data = await res.json();
|
|
56
|
+
return data.choices?.[0]?.message?.content?.trim() ?? "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Which provider to use is decided by which real key is actually set — no default provider
|
|
60
|
+
* baked in, since defaulting to one vendor for everyone would be a real, unwanted tie-in. */
|
|
61
|
+
export function configuredProvider() {
|
|
62
|
+
if (process.env.ANTHROPIC_API_KEY) return "anthropic";
|
|
63
|
+
if (process.env.OPENAI_API_KEY) return "openai";
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function generateCommitMessage(diff) {
|
|
68
|
+
const provider = configuredProvider();
|
|
69
|
+
if (!provider) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"No API key configured. Set ANTHROPIC_API_KEY or OPENAI_API_KEY in your environment " +
|
|
72
|
+
"before generating a message — this tool never guesses at a key or falls back to a " +
|
|
73
|
+
"fabricated one."
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (!diff.trim()) {
|
|
77
|
+
throw new Error("No real changes to describe — the diff is empty.");
|
|
78
|
+
}
|
|
79
|
+
// A huge diff is truncated with an honest marker, never silently dropped without saying so —
|
|
80
|
+
// the model still gets to see the real start of the change, not nothing.
|
|
81
|
+
const MAX_CHARS = 60000;
|
|
82
|
+
const truncated = diff.length > MAX_CHARS;
|
|
83
|
+
const body = truncated
|
|
84
|
+
? diff.slice(0, MAX_CHARS) + "\n\n[... diff truncated at 60,000 characters ...]"
|
|
85
|
+
: diff;
|
|
86
|
+
|
|
87
|
+
const text = provider === "anthropic" ? await callAnthropic(process.env.ANTHROPIC_API_KEY, body)
|
|
88
|
+
: await callOpenAI(process.env.OPENAI_API_KEY, body);
|
|
89
|
+
|
|
90
|
+
if (!text) {
|
|
91
|
+
throw new Error(`${provider} returned an empty response — try again.`);
|
|
92
|
+
}
|
|
93
|
+
return { message: text, provider, truncated };
|
|
94
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Real local HTTP server, stdlib only — zero runtime dependencies is a deliberate choice for a
|
|
2
|
+
// one-command install tool (nothing to audit, nothing to go stale, nothing supply-chain-risky
|
|
3
|
+
// pulled in just to serve a handful of local routes).
|
|
4
|
+
//
|
|
5
|
+
// 🔴 LOOPBACK ONLY. Binds to 127.0.0.1, never 0.0.0.0 — this reads the user's own git diff and
|
|
6
|
+
// (on /api/generate) sends it to a real third-party LLM API using the user's own key. Nothing
|
|
7
|
+
// about that should ever be reachable from another machine on the network.
|
|
8
|
+
import http from "node:http";
|
|
9
|
+
import fs from "node:fs/promises";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { isRepo, realDiff, changedFiles, realCommit, currentBranch, hasCommits } from "./git.js";
|
|
13
|
+
import { generateCommitMessage, configuredProvider } from "./llm.js";
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const PUBLIC_DIR = path.join(__dirname, "..", "public");
|
|
17
|
+
|
|
18
|
+
const MIME = {
|
|
19
|
+
".html": "text/html; charset=utf-8",
|
|
20
|
+
".css": "text/css; charset=utf-8",
|
|
21
|
+
".js": "text/javascript; charset=utf-8",
|
|
22
|
+
".json": "application/json; charset=utf-8",
|
|
23
|
+
".svg": "image/svg+xml",
|
|
24
|
+
".ico": "image/x-icon",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// 🔴 DNS-rebinding guard. This loopback server reads the user's git diff and can create commits, so it
|
|
28
|
+
// must only answer requests whose Host is loopback — a web page the user visits cannot rebind a hostname
|
|
29
|
+
// to 127.0.0.1 and reach these APIs. HTTP/1.1 requires a Host header, so a missing one is refused.
|
|
30
|
+
function isLoopbackHost(hostHeader) {
|
|
31
|
+
if (!hostHeader) return false;
|
|
32
|
+
let host = String(hostHeader).trim().toLowerCase();
|
|
33
|
+
if (host.startsWith("[")) {
|
|
34
|
+
host = host.slice(1, host.indexOf("]")); // [::1]:port -> ::1
|
|
35
|
+
} else {
|
|
36
|
+
host = host.replace(/:\d+$/, ""); // 127.0.0.1:port / localhost:port -> strip port
|
|
37
|
+
}
|
|
38
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sendJson(res, status, body) {
|
|
42
|
+
const data = JSON.stringify(body);
|
|
43
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
44
|
+
res.end(data);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readBody(req) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
let chunks = [];
|
|
50
|
+
let size = 0;
|
|
51
|
+
req.on("data", (c) => {
|
|
52
|
+
size += c.length;
|
|
53
|
+
// Real cap — a runaway/malicious client body must not be read into memory unbounded.
|
|
54
|
+
if (size > 40 * 1024 * 1024) {
|
|
55
|
+
reject(new Error("request body too large"));
|
|
56
|
+
req.destroy();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
chunks.push(c);
|
|
60
|
+
});
|
|
61
|
+
req.on("end", () => {
|
|
62
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
63
|
+
if (!raw) return resolve({});
|
|
64
|
+
try {
|
|
65
|
+
resolve(JSON.parse(raw));
|
|
66
|
+
} catch {
|
|
67
|
+
reject(new Error("invalid JSON body"));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
req.on("error", reject);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function serveStatic(req, res, urlPath) {
|
|
75
|
+
// Real path-traversal guard: resolve against PUBLIC_DIR and refuse anything that escapes it,
|
|
76
|
+
// rather than trusting the URL's own `..` segments to be well-formed.
|
|
77
|
+
const rel = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, "");
|
|
78
|
+
const resolved = path.resolve(PUBLIC_DIR, rel);
|
|
79
|
+
if (resolved !== PUBLIC_DIR && !resolved.startsWith(PUBLIC_DIR + path.sep)) {
|
|
80
|
+
res.writeHead(403).end("forbidden");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const data = await fs.readFile(resolved);
|
|
85
|
+
const ext = path.extname(resolved);
|
|
86
|
+
res.writeHead(200, { "content-type": MIME[ext] || "application/octet-stream" });
|
|
87
|
+
res.end(data);
|
|
88
|
+
} catch {
|
|
89
|
+
res.writeHead(404).end("not found");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createServer({ cwd = process.cwd() } = {}) {
|
|
94
|
+
return http.createServer(async (req, res) => {
|
|
95
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
96
|
+
|
|
97
|
+
// Refuse any request whose Host isn't loopback (DNS-rebinding guard — see isLoopbackHost).
|
|
98
|
+
if (!isLoopbackHost(req.headers.host)) {
|
|
99
|
+
res.writeHead(403).end("forbidden");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
if (url.pathname === "/api/status" && req.method === "GET") {
|
|
105
|
+
const repo = await isRepo(cwd);
|
|
106
|
+
return sendJson(res, 200, {
|
|
107
|
+
ok: true,
|
|
108
|
+
isRepo: repo,
|
|
109
|
+
cwd,
|
|
110
|
+
branch: repo ? await currentBranch(cwd) : null,
|
|
111
|
+
hasCommits: repo ? await hasCommits(cwd) : false,
|
|
112
|
+
provider: configuredProvider(),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (url.pathname === "/api/diff" && req.method === "GET") {
|
|
117
|
+
if (!(await isRepo(cwd))) {
|
|
118
|
+
return sendJson(res, 200, { ok: false, reason: `${cwd} is not a real git repository.` });
|
|
119
|
+
}
|
|
120
|
+
const { diff, source } = await realDiff(cwd);
|
|
121
|
+
const files = source === "none" ? [] : await changedFiles(cwd, source);
|
|
122
|
+
return sendJson(res, 200, { ok: true, diff, source, files });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (url.pathname === "/api/generate" && req.method === "POST") {
|
|
126
|
+
const body = await readBody(req);
|
|
127
|
+
if (typeof body.diff !== "string") {
|
|
128
|
+
return sendJson(res, 400, { ok: false, reason: "diff (string) is required" });
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const result = await generateCommitMessage(body.diff);
|
|
132
|
+
return sendJson(res, 200, { ok: true, ...result });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return sendJson(res, 200, { ok: false, reason: err.message });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (url.pathname === "/api/commit" && req.method === "POST") {
|
|
139
|
+
const body = await readBody(req);
|
|
140
|
+
if (typeof body.message !== "string" || !body.message.trim()) {
|
|
141
|
+
return sendJson(res, 400, { ok: false, reason: "message (non-empty string) is required" });
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const result = await realCommit(cwd, body.message, { stageAll: body.stageAll === true });
|
|
145
|
+
return sendJson(res, 200, { ok: true, ...result });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return sendJson(res, 200, { ok: false, reason: err.message });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (req.method === "GET") {
|
|
152
|
+
return serveStatic(req, res, url.pathname);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
res.writeHead(404).end("not found");
|
|
156
|
+
} catch (err) {
|
|
157
|
+
sendJson(res, 500, { ok: false, reason: err.message });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function listen(port, cwd) {
|
|
163
|
+
const server = createServer({ cwd });
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
server.listen(port, "127.0.0.1", () => resolve(server));
|
|
166
|
+
});
|
|
167
|
+
}
|