@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 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
+ [![CI](https://github.com/STRATO-DAN/dan-oss-commit/actions/workflows/ci.yml/badge.svg)](https://github.com/STRATO-DAN/dan-oss-commit/actions/workflows/ci.yml)
10
+ [![npm version](https://img.shields.io/npm/v/@strato-dan/commit.svg)](https://www.npmjs.com/package/@strato-dan/commit)
11
+ [![runtime deps](https://img.shields.io/badge/runtime%20deps-0-2e9e56.svg)](#dependencies)
12
+ [![docs](https://img.shields.io/badge/docs-README-blue.svg)](#use)
13
+ [![license](https://img.shields.io/badge/license-MIT-informational.svg)](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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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
@@ -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.
@@ -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>
@@ -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
+ }