@xuhaojun/githunk 0.1.3 → 0.3.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/README.md CHANGED
@@ -5,19 +5,40 @@ A review-first Git TUI that mixes [lazygit](https://github.com/jesseduffield/laz
5
5
 
6
6
  ## Install
7
7
 
8
+ No Node.js required — githunk ships as a standalone binary:
9
+
10
+ ```sh
11
+ curl -fsSL https://raw.githubusercontent.com/XuHaoJun/githunk/main/install.sh | sh
12
+ ```
13
+
14
+ This installs the newest release into `~/.local/bin` (or `$XDG_BIN_HOME`, or `$GITHUNK_INSTALL_DIR`), verifying its checksum first. Pin a version with `sh -s -- 0.2.0`, or pass `--no-modify-path` to skip shell-startup PATH wiring. macOS and Linux only.
15
+
16
+ Alternative via npm (needs Node.js 26.1.0 or newer just to install):
17
+
8
18
  ```sh
9
19
  npm install --global @xuhaojun/githunk
10
20
  ```
11
21
 
12
22
  ## Requirements
13
23
 
14
- - Node.js 26.1.0 or newer
15
24
  - Git
16
25
  - A terminal with interactive TUI support
17
26
 
18
27
  `gh` is optional. When it is installed and authenticated, githunk can show GitHub pull-request status; local Git review works without it.
19
28
 
20
- The installed launcher enables Node's experimental FFI support automatically for OpenTUI.
29
+ The launcher prefers the prebuilt binary. On platforms without one it falls back to the Node.js bundle, which needs Node.js 26.1.0 or newer; the launcher enables Node's experimental FFI support automatically for OpenTUI.
30
+
31
+ ## Update
32
+
33
+ ```sh
34
+ githunk update # newest release
35
+ githunk update 0.3.0 # a specific version
36
+ githunk update --check # report without installing
37
+ ```
38
+
39
+ Standalone installs replace their own binary after checksum verification. npm installs update through npm instead: `npm update --global @xuhaojun/githunk`.
40
+
41
+ Installer knobs: `GITHUNK_VERSION` pins the version, `GITHUNK_INSTALL_DIR` overrides the install directory, `GITHUNK_NO_MODIFY_PATH=1` skips shell-startup PATH wiring.
21
42
 
22
43
  ## Usage
23
44
 
package/bin/githunk.js CHANGED
@@ -1,23 +1,95 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // githunk launcher: prefer the prebuilt standalone binary installed as an
4
+ // optional platform package, and fall back to the Node bundle otherwise.
5
+ //
6
+ // npm installs only the optional dependency whose `os`/`cpu` matches this
7
+ // machine, so the binary usually lives at
8
+ // `<install-root>/node_modules/@xuhaojun/githunk-<os>-<arch>/bin/githunk`.
9
+ // The lookup walks up from this file (the hunk `findInstalledBinary` shape in
10
+ // `learn-projects/hunk/bin/hunk.cjs`), which keeps working no matter which
11
+ // Node version owns the invoking shell: the prebuilt binary embeds its own
12
+ // Bun runtime and takes no `--experimental-ffi` flag. `$GITHUNK_BIN_PATH`
13
+ // overrides everything for debugging and custom installs.
14
+
3
15
  import { spawn } from "node:child_process"
16
+ import { existsSync, realpathSync } from "node:fs"
4
17
  import { constants } from "node:os"
18
+ import { dirname, join } from "node:path"
5
19
  import { fileURLToPath } from "node:url"
6
20
 
7
- const cliPath = fileURLToPath(new URL("../dist/githunk.js", import.meta.url))
8
- const child = spawn(process.execPath, ["--experimental-ffi", cliPath, ...process.argv.slice(2)], {
9
- stdio: "inherit",
10
- })
21
+ const PLATFORM_PACKAGES = [
22
+ { packageName: "@xuhaojun/githunk-darwin-arm64", binary: "bin/githunk" },
23
+ { packageName: "@xuhaojun/githunk-darwin-x64", binary: "bin/githunk" },
24
+ { packageName: "@xuhaojun/githunk-linux-arm64", binary: "bin/githunk" },
25
+ { packageName: "@xuhaojun/githunk-linux-x64", binary: "bin/githunk" },
26
+ { packageName: "@xuhaojun/githunk-windows-x64", binary: "bin/githunk.exe" },
27
+ ]
28
+
29
+ function hostCandidates() {
30
+ const platformMap = {
31
+ darwin: "darwin",
32
+ linux: "linux",
33
+ win32: "windows",
34
+ }
35
+ const archMap = {
36
+ x64: "x64",
37
+ arm64: "arm64",
38
+ }
39
+ const platform = platformMap[process.platform]
40
+ const arch = archMap[process.arch]
41
+ if (platform === undefined || arch === undefined) return []
42
+ return PLATFORM_PACKAGES.filter((candidate) => candidate.packageName === `@xuhaojun/githunk-${platform}-${arch}`)
43
+ }
44
+
45
+ /** Find the prebuilt binary installed next to this package, walking up past nested installs. */
46
+ function findInstalledBinary(startDir) {
47
+ let current = startDir
48
+ for (;;) {
49
+ const modulesDir = join(current, "node_modules")
50
+ for (const candidate of hostCandidates()) {
51
+ const resolved = join(modulesDir, ...candidate.packageName.split("/"), candidate.binary)
52
+ if (existsSync(resolved)) return resolved
53
+ }
54
+ const parent = dirname(current)
55
+ if (parent === current) return null
56
+ current = parent
57
+ }
58
+ }
59
+
60
+ function launch(target, args) {
61
+ const child = spawn(target, args, { stdio: "inherit" })
11
62
 
12
- for (const signal of ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"]) {
13
- process.once(signal, () => { child.kill(signal) })
63
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"]) {
64
+ process.once(signal, () => { child.kill(signal) })
65
+ }
66
+
67
+ child.once("error", (error) => {
68
+ process.stderr.write(`githunk: failed to start: ${error instanceof Error ? error.message : String(error)}\n`)
69
+ process.exitCode = 1
70
+ })
71
+ child.once("close", (exitCode, signal) => {
72
+ const signalNumber = signal === null ? undefined : constants.signals[signal]
73
+ process.exitCode = exitCode ?? (signalNumber === undefined ? 1 : 128 + signalNumber)
74
+ })
14
75
  }
15
76
 
16
- child.once("error", (error) => {
17
- process.stderr.write(`githunk: failed to start: ${error instanceof Error ? error.message : String(error)}\n`)
18
- process.exitCode = 1
19
- })
20
- child.once("close", (exitCode, signal) => {
21
- const signalNumber = signal === null ? undefined : constants.signals[signal]
22
- process.exitCode = exitCode ?? (signalNumber === undefined ? 1 : 128 + signalNumber)
23
- })
77
+ const forwardedArgs = process.argv.slice(2)
78
+ const overrideBinary = process.env.GITHUNK_BIN_PATH
79
+ if (typeof overrideBinary === "string" && overrideBinary !== "") {
80
+ launch(overrideBinary, forwardedArgs)
81
+ } else {
82
+ let scriptDir = dirname(fileURLToPath(import.meta.url))
83
+ try {
84
+ scriptDir = realpathSync(scriptDir)
85
+ } catch {
86
+ // Unresolved symlinks just start the walk from the literal directory.
87
+ }
88
+ const prebuiltBinary = findInstalledBinary(scriptDir)
89
+ if (prebuiltBinary !== null) {
90
+ launch(prebuiltBinary, forwardedArgs)
91
+ } else {
92
+ const cliPath = fileURLToPath(new URL("../dist/githunk.js", import.meta.url))
93
+ launch(process.execPath, ["--experimental-ffi", cliPath, ...forwardedArgs])
94
+ }
95
+ }
package/dist/githunk.js CHANGED
@@ -1,36 +1,95 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/cli.ts
4
+ import { spawnSync } from "node:child_process";
5
+ import { chmodSync, cpSync, mkdtempSync, renameSync, rmSync } from "node:fs";
6
+ import { writeFile } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join as join6 } from "node:path";
9
+
3
10
  // src/cli/args.ts
4
11
  import { Command, CommanderError } from "commander";
5
- import { readFileSync } from "node:fs";
6
- import { dirname, join } from "node:path";
7
- import { fileURLToPath } from "node:url";
8
- var cachedVersion;
9
- function getCliVersion() {
10
- if (cachedVersion !== undefined)
11
- return cachedVersion;
12
- let directory = dirname(fileURLToPath(import.meta.url));
13
- for (let level = 0;level < 4; level += 1) {
14
- try {
15
- const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
16
- if (manifest.name === "@xuhaojun/githunk" && typeof manifest.version === "string") {
17
- cachedVersion = manifest.version;
18
- return cachedVersion;
19
- }
20
- } catch {}
21
- const parent = dirname(directory);
22
- if (parent === directory)
23
- break;
24
- directory = parent;
12
+ // package.json
13
+ var package_default = {
14
+ name: "@xuhaojun/githunk",
15
+ version: "0.3.0",
16
+ description: "A review-first Git TUI combining lazygit's everyday Git workflow with focused hunk review.",
17
+ type: "module",
18
+ bin: {
19
+ githunk: "bin/githunk.js"
20
+ },
21
+ files: [
22
+ "bin",
23
+ "dist/githunk.js",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ scripts: {
28
+ start: "bun run src/main.ts",
29
+ dev: "bun --watch src/main.ts",
30
+ build: "bun build src/cli.ts --target=node --format=esm --packages=external --outfile=dist/githunk.js --banner '#!/usr/bin/env node'",
31
+ "build:bin": "bun run ./scripts/build-bin.ts",
32
+ "build:prebuilt:artifact": "bun run ./scripts/build-prebuilt-artifact.ts",
33
+ "stage:prebuilt:release": "bun run ./scripts/stage-prebuilt-npm.ts --artifact-root ./dist/release/artifacts",
34
+ "check:release-version": "bun run ./scripts/check-release-version.ts",
35
+ "check:prebuilt-pack": "bun run ./scripts/check-prebuilt-pack.ts",
36
+ "publish:prebuilt:npm": "bun run ./scripts/publish-prebuilt-npm.ts",
37
+ "smoke:prebuilt-install": "bun run ./scripts/smoke-prebuilt-install.ts",
38
+ prepack: "bun run build",
39
+ "pack:check": "npm pack --dry-run",
40
+ "spike:selection": "bun run spikes/selection/src/main.ts",
41
+ test: "bun test --path-ignore-patterns='learn-projects/**'",
42
+ typecheck: "tsc --noEmit",
43
+ check: "bun run typecheck && bun run test",
44
+ "bench:review-load": "bun run benchmarks/review-document-load.ts",
45
+ "bench:review-rows": "bun run benchmarks/review-row-plan.ts",
46
+ "bench:review-reconcile": "bun run benchmarks/review-reconcile.ts"
47
+ },
48
+ keywords: [
49
+ "git",
50
+ "tui",
51
+ "code-review",
52
+ "cli"
53
+ ],
54
+ homepage: "https://github.com/XuHaoJun/githunk#readme",
55
+ bugs: {
56
+ url: "https://github.com/XuHaoJun/githunk/issues"
57
+ },
58
+ repository: {
59
+ type: "git",
60
+ url: "git+https://github.com/XuHaoJun/githunk.git"
61
+ },
62
+ license: "MIT",
63
+ engines: {
64
+ node: ">=26.1.0"
65
+ },
66
+ packageManager: "bun@1.4.0",
67
+ publishConfig: {
68
+ access: "public"
69
+ },
70
+ dependencies: {
71
+ "@opentui/core": "0.5.10",
72
+ "@opentui/react": "0.5.10",
73
+ "@pierre/diffs": "1.3.5",
74
+ commander: "^15.0.0",
75
+ react: "19.2.8",
76
+ zod: "^4.4.3"
77
+ },
78
+ devDependencies: {
79
+ "@types/bun": "1.4.0",
80
+ "@types/react": "19.2.8",
81
+ typescript: "5.9.2"
25
82
  }
26
- cachedVersion = "0.0.0-dev";
27
- return cachedVersion;
28
- }
83
+ };
84
+
85
+ // src/cli/args.ts
86
+ var cliVersion = typeof package_default.version === "string" && package_default.version !== "" ? package_default.version : "0.0.0-dev";
29
87
  function parseCliArgs(argv) {
30
88
  let stdout = "";
31
89
  let stderr = "";
90
+ let update;
32
91
  const program = new Command;
33
- program.name("githunk").description("A review-first Git TUI combining lazygit's everyday Git workflow with focused hunk review.").version(getCliVersion(), "-V, --version", "output the version number").option("-p, --path <dir>", "path to the Git repository to open").argument("[path]", "path to the Git repository to open").exitOverride().configureOutput({
92
+ program.name("githunk").description("A review-first Git TUI combining lazygit's everyday Git workflow with focused hunk review.").version(cliVersion, "-V, --version", "output the version number").option("-p, --path <dir>", "path to the Git repository to open").argument("[path]", "path to the Git repository to open").exitOverride().action(() => {}).configureOutput({
34
93
  writeOut: (text) => {
35
94
  stdout += text;
36
95
  },
@@ -38,6 +97,12 @@ function parseCliArgs(argv) {
38
97
  stderr += text;
39
98
  }
40
99
  });
100
+ program.command("update").description("update githunk to the newest (or a given) release").argument("[version]", "version to install; the newest release when omitted").option("--check", "report the installed and available versions without installing").action((version, options2) => {
101
+ update = {
102
+ ...version === undefined ? {} : { version },
103
+ check: options2.check ?? false
104
+ };
105
+ });
41
106
  try {
42
107
  program.parse([...argv], { from: "user" });
43
108
  } catch (error) {
@@ -51,12 +116,91 @@ function parseCliArgs(argv) {
51
116
  }
52
117
  throw error;
53
118
  }
119
+ if (update !== undefined)
120
+ return { kind: "update", ...update };
54
121
  const options = program.opts();
55
122
  const positional = program.args[0];
56
123
  const startDirectory = options.path ?? positional;
57
124
  return startDirectory === undefined ? { kind: "start" } : { kind: "start", startDirectory };
58
125
  }
59
126
 
127
+ // src/cli/update.ts
128
+ import { createHash } from "node:crypto";
129
+ import { basename, join } from "node:path";
130
+ function assetNameFor(platform, arch) {
131
+ const osToken = platform === "linux" ? "linux" : platform === "darwin" ? "darwin" : platform === "win32" ? "windows" : null;
132
+ const archToken = arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : null;
133
+ if (osToken === null || archToken === null)
134
+ return null;
135
+ if (osToken === "windows" && archToken !== "x64")
136
+ return null;
137
+ return `githunk-${osToken}-${archToken}.tar.gz`;
138
+ }
139
+ function normalizeVersion(version) {
140
+ return version.trim().replace(/^v/, "");
141
+ }
142
+ function compareVersions(left, right) {
143
+ const parts = (version) => {
144
+ const [major = "0", minor = "0", patch = "0"] = normalizeVersion(version).split(".");
145
+ return [Number(major) || 0, Number(minor) || 0, Number(patch) || 0];
146
+ };
147
+ const [aMajor, aMinor, aPatch] = parts(left);
148
+ const [bMajor, bMinor, bPatch] = parts(right);
149
+ return aMajor - bMajor || aMinor - bMinor || aPatch - bPatch;
150
+ }
151
+ function isSelfManagedBinary(executablePath) {
152
+ const base = basename(executablePath);
153
+ return base === "githunk" || base === "githunk.exe";
154
+ }
155
+ function verifyChecksum(tarball, checksums, asset) {
156
+ const actual = createHash("sha256").update(tarball).digest("hex");
157
+ const line = checksums.split(`
158
+ `).map((entry) => entry.trim().split(/\s+/)).find((fields) => fields[fields.length - 1] === asset);
159
+ const expected = line?.[0];
160
+ if (expected === undefined || expected === "" || expected.toLowerCase() !== actual) {
161
+ throw new Error(`checksum mismatch for ${asset}`);
162
+ }
163
+ }
164
+ async function applyUpdate(target, asset, env) {
165
+ await env.withTempDir(async (dir) => {
166
+ const { tarball, checksums } = await env.fetchAsset(`v${target}`, asset);
167
+ verifyChecksum(tarball, checksums, asset);
168
+ const archivePath = join(dir, asset);
169
+ await env.writeFile(archivePath, tarball);
170
+ await env.extractTarball(archivePath, dir);
171
+ await env.writeBinary(env.stagedBinary(dir), env.executablePath);
172
+ });
173
+ }
174
+ async function runUpdate(request, env) {
175
+ try {
176
+ if (!isSelfManagedBinary(env.executablePath)) {
177
+ return {
178
+ exitCode: 1,
179
+ message: "githunk was installed via npm — update it with `npm update --global @xuhaojun/githunk`"
180
+ };
181
+ }
182
+ const asset = assetNameFor(env.platform, env.arch);
183
+ if (asset === null) {
184
+ return {
185
+ exitCode: 1,
186
+ message: `no prebuilt githunk binary ships for ${env.platform}-${env.arch} — install with \`npm install -g @xuhaojun/githunk\``
187
+ };
188
+ }
189
+ const current = normalizeVersion(env.installedVersion());
190
+ const target = normalizeVersion(request.version ?? normalizeVersion(await env.fetchReleaseTag()));
191
+ if (compareVersions(target, current) === 0) {
192
+ return { exitCode: 0, message: `githunk ${current} is already up to date` };
193
+ }
194
+ if (request.check) {
195
+ return { exitCode: 0, message: `update available: ${current} -> ${target}` };
196
+ }
197
+ await applyUpdate(target, asset, env);
198
+ return { exitCode: 0, message: `updated githunk ${current} -> ${target}` };
199
+ } catch (error) {
200
+ return { exitCode: 1, message: error instanceof Error ? error.message : String(error) };
201
+ }
202
+ }
203
+
60
204
  // src/main.ts
61
205
  import { resolve as resolve4 } from "node:path";
62
206
  import { createCliRenderer } from "@opentui/core";
@@ -940,12 +1084,12 @@ function reviewStateFor(record, currentFingerprint) {
940
1084
  }
941
1085
 
942
1086
  // src/review/working-tree-fingerprint.ts
943
- import { createHash } from "node:crypto";
1087
+ import { createHash as createHash2 } from "node:crypto";
944
1088
  function utf8(value) {
945
1089
  return new TextEncoder().encode(value);
946
1090
  }
947
1091
  function sha256Tuple(parts) {
948
- const hash = createHash("sha256");
1092
+ const hash = createHash2("sha256");
949
1093
  for (const part of parts) {
950
1094
  const bytes = utf8(part);
951
1095
  const length = Buffer.allocUnsafe(4);
@@ -978,7 +1122,7 @@ function fingerprintWorkingTreeFile(target, filePatch) {
978
1122
 
979
1123
  // src/storage/local-state-file.ts
980
1124
  import { mkdir, open, rename, stat, unlink, lstat, link, readFile } from "node:fs/promises";
981
- import { dirname as dirname2, isAbsolute, join as join2, resolve } from "node:path";
1125
+ import { dirname, isAbsolute, join as join2, resolve } from "node:path";
982
1126
  import { randomUUID } from "node:crypto";
983
1127
  async function assertNoSymlinkInPath(path, pathKind) {
984
1128
  const absolute = resolve(path);
@@ -1033,7 +1177,7 @@ class LocalStateFile {
1033
1177
  async writeText(text) {
1034
1178
  const path = await this.resolvePath();
1035
1179
  await assertNoSymlinkInPath(path, this.pathKind);
1036
- await mkdir(dirname2(path), { recursive: true, mode: 448 });
1180
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
1037
1181
  const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
1038
1182
  await assertNoSymlinkInPath(temporary, this.pathKind);
1039
1183
  const handle = await open(temporary, "wx", 384);
@@ -1046,7 +1190,7 @@ class LocalStateFile {
1046
1190
  }
1047
1191
  await rename(temporary, path);
1048
1192
  try {
1049
- const directory = await open(dirname2(path), "r");
1193
+ const directory = await open(dirname(path), "r");
1050
1194
  try {
1051
1195
  await directory.sync();
1052
1196
  } finally {
@@ -1070,7 +1214,7 @@ class LocalStateFile {
1070
1214
  async createTextExclusive(text) {
1071
1215
  const path = await this.resolvePath();
1072
1216
  await assertNoSymlinkInPath(path, this.pathKind);
1073
- await mkdir(dirname2(path), { recursive: true, mode: 448 });
1217
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
1074
1218
  const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
1075
1219
  await assertNoSymlinkInPath(temporary, this.pathKind);
1076
1220
  const handle = await open(temporary, "wx", 384);
@@ -1099,7 +1243,7 @@ class LocalStateFile {
1099
1243
  return;
1100
1244
  });
1101
1245
  try {
1102
- const directory = await open(dirname2(path), "r");
1246
+ const directory = await open(dirname(path), "r");
1103
1247
  try {
1104
1248
  await directory.sync();
1105
1249
  } finally {
@@ -1925,7 +2069,7 @@ function validateUpstream(upstream) {
1925
2069
  throw new Error("invalid upstream choice");
1926
2070
  }
1927
2071
  }
1928
- async function fetch(runner, remote, options = {}) {
2072
+ async function fetch2(runner, remote, options = {}) {
1929
2073
  await runner.run(remote === undefined ? ["fetch"] : ["fetch", remote], options.background === true ? { dontLog: true } : { streamOutput: true });
1930
2074
  }
1931
2075
  async function pull(runner, options = {}) {
@@ -2134,7 +2278,7 @@ async function listReflog(runner, options = {}) {
2134
2278
 
2135
2279
  // src/git/worktrees.ts
2136
2280
  import { readFile as readFile2, stat as stat2 } from "node:fs/promises";
2137
- import { dirname as dirname3, join as join3 } from "node:path";
2281
+ import { dirname as dirname2, join as join3 } from "node:path";
2138
2282
  function finalizeEntry(entry) {
2139
2283
  return {
2140
2284
  path: entry.path,
@@ -2236,7 +2380,7 @@ async function resolveRepositoryPaths(runner) {
2236
2380
  const worktreeGitDirPath = lines[1] ?? "";
2237
2381
  const repoGitDirPath = lines[2] ?? "";
2238
2382
  const isSubmodule = (lines[3] ?? "").length > 0;
2239
- const repoPath = worktreeGitDirPath === repoGitDirPath || isSubmodule ? worktreePath : dirname3(repoGitDirPath);
2383
+ const repoPath = worktreeGitDirPath === repoGitDirPath || isSubmodule ? worktreePath : dirname2(repoGitDirPath);
2240
2384
  return { worktreePath, worktreeGitDirPath, repoPath, repoGitDirPath };
2241
2385
  }
2242
2386
  async function isPathMissing(path) {
@@ -2890,7 +3034,7 @@ class AppController {
2890
3034
  return;
2891
3035
  if (options.background !== true)
2892
3036
  this.logAction(LOG_ACTIONS.fetch);
2893
- await this.runMutation(() => this.requireRunnerOperation((runner) => fetch(runner, remote, options)));
3037
+ await this.runMutation(() => this.requireRunnerOperation((runner) => fetch2(runner, remote, options)));
2894
3038
  }
2895
3039
  async pull(options = {}) {
2896
3040
  if (!this.ensureWorkingTreeMutation())
@@ -5884,7 +6028,7 @@ function commitGraphRows(commits, getColor) {
5884
6028
  }
5885
6029
 
5886
6030
  // src/ui/author-style.ts
5887
- import { createHash as createHash2 } from "node:crypto";
6031
+ import { createHash as createHash3 } from "node:crypto";
5888
6032
  var initialsCache = new Map;
5889
6033
  var colorCache = new Map;
5890
6034
  function randInt(bytes, max) {
@@ -5925,7 +6069,7 @@ function authorColor(authorName) {
5925
6069
  const cached = colorCache.get(authorName);
5926
6070
  if (cached !== undefined)
5927
6071
  return cached;
5928
- const hash = new Uint8Array(createHash2("md5").update(authorName).digest());
6072
+ const hash = new Uint8Array(createHash3("md5").update(authorName).digest());
5929
6073
  const color = hslToHex(randFloat(hash.slice(0, 4)) * 360, 0.6 + 0.4 * randFloat(hash.slice(4, 8)), 0.4 + 0.2 * randFloat(hash.slice(8, 12)));
5930
6074
  colorCache.set(authorName, color);
5931
6075
  return color;
@@ -15418,7 +15562,7 @@ async function loadRefsSnapshot(runner) {
15418
15562
  }
15419
15563
 
15420
15564
  // src/git/editor.ts
15421
- import { join as join5, basename } from "node:path";
15565
+ import { join as join5, basename as basename2 } from "node:path";
15422
15566
  function standardTerminalPreset(editor) {
15423
15567
  return {
15424
15568
  edit: `${editor} -- {{filename}}`,
@@ -15511,13 +15655,13 @@ function resolvePlaceholders(template, values) {
15511
15655
  }
15512
15656
  function guessEditorBase(env, gitEditor) {
15513
15657
  if (env.GITHUNK_EDITOR !== undefined && env.GITHUNK_EDITOR.trim().length > 0)
15514
- return basename(env.GITHUNK_EDITOR.split(" ")[0].trim());
15658
+ return basename2(env.GITHUNK_EDITOR.split(" ")[0].trim());
15515
15659
  if (gitEditor !== undefined && gitEditor.trim().length > 0)
15516
- return basename(gitEditor.split(" ")[0].trim());
15660
+ return basename2(gitEditor.split(" ")[0].trim());
15517
15661
  for (const key of ["GIT_EDITOR", "VISUAL", "EDITOR"]) {
15518
15662
  const value = env[key];
15519
15663
  if (value !== undefined && value.trim().length > 0)
15520
- return basename(value.split(" ")[0].trim());
15664
+ return basename2(value.split(" ")[0].trim());
15521
15665
  }
15522
15666
  return "vi";
15523
15667
  }
@@ -15564,7 +15708,7 @@ import { isAbsolute as isAbsolute2, resolve as resolve3 } from "node:path";
15564
15708
 
15565
15709
  // src/app/index-watcher.ts
15566
15710
  import { watch, statSync } from "node:fs";
15567
- import { basename as basename2, dirname as dirname4 } from "node:path";
15711
+ import { basename as basename3, dirname as dirname3 } from "node:path";
15568
15712
  var DEFAULT_INDEX_EVENT_DEBOUNCE_MS = 50;
15569
15713
  var BUSY_RETRY_MS = 50;
15570
15714
  function fingerprint(path) {
@@ -15594,7 +15738,7 @@ class IndexWatcher {
15594
15738
  stopped = true;
15595
15739
  constructor(options) {
15596
15740
  this.options = options;
15597
- this.indexName = basename2(options.indexPath);
15741
+ this.indexName = basename3(options.indexPath);
15598
15742
  this.lockName = `${this.indexName}.lock`;
15599
15743
  this.debounceMs = options.debounceMs ?? DEFAULT_INDEX_EVENT_DEBOUNCE_MS;
15600
15744
  this.baseline = undefined;
@@ -15605,7 +15749,7 @@ class IndexWatcher {
15605
15749
  this.stopped = false;
15606
15750
  this.baseline = fingerprint(this.options.indexPath);
15607
15751
  try {
15608
- const watcher = watch(dirname4(this.options.indexPath), { persistent: false }, (_eventType, filename) => {
15752
+ const watcher = watch(dirname3(this.options.indexPath), { persistent: false }, (_eventType, filename) => {
15609
15753
  if (this.stopped)
15610
15754
  return;
15611
15755
  const name = eventFileName(filename);
@@ -16271,7 +16415,7 @@ function reviewHelp(focus, state) {
16271
16415
  }
16272
16416
 
16273
16417
  // src/ui/review-workspace/review-sidebar.ts
16274
- import { basename as basename3, dirname as dirname5 } from "node:path/posix";
16418
+ import { basename as basename4, dirname as dirname4 } from "node:path/posix";
16275
16419
  function normalizeDiffPath(p) {
16276
16420
  return p?.replace(/[\r\n]+$/u, "");
16277
16421
  }
@@ -16300,10 +16444,10 @@ function sidebarFileName(file) {
16300
16444
  const path = formatTerminalPath(normalizeDiffPath(file.path) ?? file.path);
16301
16445
  const previousPath = file.previousPath ? formatTerminalPath(normalizeDiffPath(file.previousPath) ?? file.previousPath) : undefined;
16302
16446
  if (previousPath === undefined || previousPath === path) {
16303
- return basename3(path);
16447
+ return basename4(path);
16304
16448
  }
16305
- const previousName = basename3(previousPath);
16306
- const nextName = basename3(path);
16449
+ const previousName = basename4(previousPath);
16450
+ const nextName = basename4(path);
16307
16451
  return previousName === nextName ? nextName : `${previousName} -> ${nextName}`;
16308
16452
  }
16309
16453
  function formatSidebarStat(prefix, value, truncated = false) {
@@ -16357,7 +16501,7 @@ function buildReviewSidebarEntries(state) {
16357
16501
  let activeGroup;
16358
16502
  visible.forEach((file, index) => {
16359
16503
  const path = formatTerminalPath(normalizeDiffPath(file.path) ?? file.path);
16360
- const group = dirname5(path);
16504
+ const group = dirname4(path);
16361
16505
  if (group !== activeGroup) {
16362
16506
  activeGroup = group;
16363
16507
  entries.push({
@@ -17549,9 +17693,9 @@ function syntaxThemeForAppearance(appearance) {
17549
17693
  import { parsePatchFiles as parsePatchFiles2 } from "@pierre/diffs";
17550
17694
 
17551
17695
  // src/review/core/identity.ts
17552
- import { createHash as createHash3 } from "node:crypto";
17696
+ import { createHash as createHash4 } from "node:crypto";
17553
17697
  function sha256Tuple2(parts) {
17554
- const hash = createHash3("sha256");
17698
+ const hash = createHash4("sha256");
17555
17699
  for (const part of parts) {
17556
17700
  const bytes = new TextEncoder().encode(part);
17557
17701
  const length = Buffer.alloc(4);
@@ -21873,7 +22017,7 @@ class AppScreenController {
21873
22017
  }
21874
22018
 
21875
22019
  // src/ui/review-workspace/controller.ts
21876
- import { createHash as createHash5 } from "node:crypto";
22020
+ import { createHash as createHash6 } from "node:crypto";
21877
22021
 
21878
22022
  // src/review/core/state.ts
21879
22023
  function createInitialReviewState(document) {
@@ -23303,12 +23447,12 @@ function persistedFromReviewState(state) {
23303
23447
  }
23304
23448
 
23305
23449
  // src/review/storage/review-artifact-store.ts
23306
- import { createHash as createHash4 } from "node:crypto";
23450
+ import { createHash as createHash5 } from "node:crypto";
23307
23451
  function artifactRelativePath(reviewId, artifactId) {
23308
23452
  return `githunk/reviews/${reviewId}/${artifactId}.json`;
23309
23453
  }
23310
23454
  function artifactDigest(text) {
23311
- return createHash4("sha256").update(text, "utf8").digest("hex");
23455
+ return createHash5("sha256").update(text, "utf8").digest("hex");
23312
23456
  }
23313
23457
  function artifactText(artifact) {
23314
23458
  return serializeReviewArtifactV1(artifact) + `
@@ -23817,7 +23961,7 @@ class ReviewWorkspaceController {
23817
23961
  artifactIdFromMarker = marker.artifactId;
23818
23962
  const raw = await this.artifactStore.readRaw(reviewId, marker.artifactId);
23819
23963
  if (raw !== undefined) {
23820
- const digest = createHash5("sha256").update(raw, "utf8").digest("hex");
23964
+ const digest = createHash6("sha256").update(raw, "utf8").digest("hex");
23821
23965
  if (digest === marker.digest) {
23822
23966
  const parsed = JSON.parse(raw);
23823
23967
  const res = parseReviewArtifactV1(parsed);
@@ -24841,6 +24985,64 @@ ${detail}
24841
24985
  if (false) {}
24842
24986
 
24843
24987
  // src/cli.ts
24988
+ var RELEASES_API = "https://api.github.com/repos/XuHaoJun/githunk/releases/latest";
24989
+ var DOWNLOAD_BASE = "https://github.com/XuHaoJun/githunk/releases/download";
24990
+ async function fetchText(url) {
24991
+ const response = await fetch(url);
24992
+ if (!response.ok)
24993
+ throw new Error(`request failed: ${url} (${response.status})`);
24994
+ return response.text();
24995
+ }
24996
+ async function fetchBytes(url) {
24997
+ const response = await fetch(url);
24998
+ if (!response.ok)
24999
+ throw new Error(`request failed: ${url} (${response.status})`);
25000
+ return new Uint8Array(await response.arrayBuffer());
25001
+ }
25002
+ function productionUpdateEnv() {
25003
+ return {
25004
+ executablePath: process.execPath,
25005
+ platform: process.platform,
25006
+ arch: process.arch,
25007
+ installedVersion: () => {
25008
+ const proc = spawnSync(process.execPath, ["--version"], { encoding: "utf8" });
25009
+ if (proc.status !== 0)
25010
+ throw new Error("could not read the installed version");
25011
+ return proc.stdout.trim();
25012
+ },
25013
+ fetchReleaseTag: async () => {
25014
+ const payload = JSON.parse(await fetchText(RELEASES_API));
25015
+ const tag = typeof payload === "object" && payload !== null && "tag_name" in payload ? payload.tag_name : undefined;
25016
+ if (typeof tag !== "string" || tag === "")
25017
+ throw new Error("could not read the newest release");
25018
+ return tag;
25019
+ },
25020
+ fetchAsset: async (tag, asset) => ({
25021
+ tarball: await fetchBytes(`${DOWNLOAD_BASE}/${tag}/${asset}`),
25022
+ checksums: await fetchText(`${DOWNLOAD_BASE}/${tag}/SHA256SUMS`)
25023
+ }),
25024
+ withTempDir: async (run) => {
25025
+ const dir = mkdtempSync(join6(tmpdir(), "githunk-update-"));
25026
+ try {
25027
+ return await run(dir);
25028
+ } finally {
25029
+ rmSync(dir, { recursive: true, force: true });
25030
+ }
25031
+ },
25032
+ writeFile: (path, data) => writeFile(path, data),
25033
+ extractTarball: async (archivePath, destDir) => {
25034
+ const proc = spawnSync("tar", ["-xzf", archivePath, "-C", destDir]);
25035
+ if (proc.status !== 0)
25036
+ throw new Error("could not extract the release archive (need tar on PATH)");
25037
+ },
25038
+ stagedBinary: (dir) => join6(dir, `githunk-${process.platform === "win32" ? "windows" : process.platform}-${process.arch === "arm64" ? "arm64" : "x64"}`, process.platform === "win32" ? "githunk.exe" : "githunk"),
25039
+ writeBinary: async (stagedPath, destPath) => {
25040
+ cpSync(stagedPath, `${destPath}.new`);
25041
+ chmodSync(`${destPath}.new`, 493);
25042
+ renameSync(`${destPath}.new`, destPath);
25043
+ }
25044
+ };
25045
+ }
24844
25046
  var result = parseCliArgs(process.argv.slice(2));
24845
25047
  if (result.kind === "help" || result.kind === "version") {
24846
25048
  process.stdout.write(result.text.endsWith(`
@@ -24852,6 +25054,13 @@ if (result.kind === "help" || result.kind === "version") {
24852
25054
  `) ? result.message : `${result.message}
24853
25055
  `);
24854
25056
  process.exitCode = result.exitCode;
25057
+ } else if (result.kind === "update") {
25058
+ const outcome = await runUpdate({ ...result.version === undefined ? {} : { version: result.version }, check: result.check }, productionUpdateEnv());
25059
+ const stream = outcome.exitCode === 0 ? process.stdout : process.stderr;
25060
+ stream.write(outcome.message.endsWith(`
25061
+ `) ? outcome.message : `${outcome.message}
25062
+ `);
25063
+ process.exitCode = outcome.exitCode;
24855
25064
  } else {
24856
25065
  process.exitCode = await startApp(result.startDirectory === undefined ? {} : { startDirectory: result.startDirectory });
24857
25066
  }
package/package.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "name": "@xuhaojun/githunk",
3
- "version": "0.1.3",
3
+ "version": "0.3.0",
4
4
  "description": "A review-first Git TUI combining lazygit's everyday Git workflow with focused hunk review.",
5
- "type": "module",
6
5
  "bin": {
7
6
  "githunk": "bin/githunk.js"
8
7
  },
@@ -12,42 +11,24 @@
12
11
  "README.md",
13
12
  "LICENSE"
14
13
  ],
15
- "scripts": {
16
- "start": "bun run src/main.ts",
17
- "dev": "bun --watch src/main.ts",
18
- "build": "bun build src/cli.ts --target=node --format=esm --packages=external --outfile=dist/githunk.js --banner '#!/usr/bin/env node'",
19
- "prepack": "bun run build",
20
- "pack:check": "npm pack --dry-run",
21
- "spike:selection": "bun run spikes/selection/src/main.ts",
22
- "test": "bun test --path-ignore-patterns='learn-projects/**'",
23
- "typecheck": "tsc --noEmit",
24
- "check": "bun run typecheck && bun run test",
25
- "bench:review-load": "bun run benchmarks/review-document-load.ts",
26
- "bench:review-rows": "bun run benchmarks/review-row-plan.ts",
27
- "bench:review-reconcile": "bun run benchmarks/review-reconcile.ts"
28
- },
14
+ "type": "module",
29
15
  "keywords": [
30
16
  "git",
31
17
  "tui",
32
18
  "code-review",
33
19
  "cli"
34
20
  ],
35
- "homepage": "https://github.com/XuHaoJun/githunk#readme",
36
- "bugs": {
37
- "url": "https://github.com/XuHaoJun/githunk/issues"
38
- },
39
21
  "repository": {
40
22
  "type": "git",
41
23
  "url": "git+https://github.com/XuHaoJun/githunk.git"
42
24
  },
43
- "license": "MIT",
25
+ "homepage": "https://github.com/XuHaoJun/githunk#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/XuHaoJun/githunk/issues"
28
+ },
44
29
  "engines": {
45
30
  "node": ">=26.1.0"
46
31
  },
47
- "packageManager": "bun@1.4.0",
48
- "publishConfig": {
49
- "access": "public"
50
- },
51
32
  "dependencies": {
52
33
  "@opentui/core": "0.5.10",
53
34
  "@opentui/react": "0.5.10",
@@ -56,9 +37,15 @@
56
37
  "react": "19.2.8",
57
38
  "zod": "^4.4.3"
58
39
  },
59
- "devDependencies": {
60
- "@types/bun": "1.4.0",
61
- "@types/react": "19.2.8",
62
- "typescript": "5.9.2"
40
+ "optionalDependencies": {
41
+ "@xuhaojun/githunk-darwin-arm64": "0.3.0",
42
+ "@xuhaojun/githunk-darwin-x64": "0.3.0",
43
+ "@xuhaojun/githunk-linux-arm64": "0.3.0",
44
+ "@xuhaojun/githunk-linux-x64": "0.3.0",
45
+ "@xuhaojun/githunk-windows-x64": "0.3.0"
46
+ },
47
+ "license": "MIT",
48
+ "publishConfig": {
49
+ "access": "public"
63
50
  }
64
51
  }