@litterthanlit/hypher-cli 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.
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Hypher git helpers. Node built-ins only, synchronous, read-only against the
3
+ * real repository. Git failures return null or empty values; nothing throws.
4
+ *
5
+ * Canonical source: packages/hypher-git/index.mjs.
6
+ * Vendored copy: extensions/cursor/scripts/vendor/hypher-git.mjs
7
+ * (refresh with `node script/sync-hypher-git.mjs`; do not edit the copy).
8
+ */
9
+ import { execFileSync } from "node:child_process";
10
+ import { copyFileSync, mkdtempSync, rmSync, statSync, utimesSync } from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ export const GIT_TIMEOUT_MS = 10_000;
15
+ export const TREE_TIMEOUT_MS = 60_000;
16
+
17
+ /**
18
+ * Run git and return stdout (trimmed unless `trim` is false), or null when git fails or times out.
19
+ * @param {string | undefined} cwd
20
+ * @param {string[]} args
21
+ * @param {{ env?: Record<string, string>, timeoutMs?: number, trim?: boolean }} [options]
22
+ * @returns {string | null}
23
+ */
24
+ export function git(cwd, args, { env, timeoutMs = GIT_TIMEOUT_MS, trim = true } = {}) {
25
+ try {
26
+ const out = execFileSync("git", args, {
27
+ encoding: "utf8",
28
+ timeout: timeoutMs,
29
+ stdio: ["ignore", "pipe", "ignore"],
30
+ ...(cwd ? { cwd } : {}),
31
+ ...(env ? { env: { ...process.env, ...env } } : {}),
32
+ });
33
+ return trim ? out.trim() : out;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * `owner/repo` from a GitHub remote URL or `owner/repo`, else null.
41
+ * Mirrors hypher-web/shared/githubRepo.ts exactly (a parity test enforces it).
42
+ * @param {string | undefined | null} input
43
+ * @returns {string | null}
44
+ */
45
+ export function normalizeGitHubRepo(input) {
46
+ const raw = (input ?? "").trim();
47
+ if (!raw) return null;
48
+
49
+ let value = raw.replace(/\.git$/i, "");
50
+ const sshMatch = value.match(/^git@github\.com:(.+)$/i);
51
+ if (sshMatch?.[1]) {
52
+ value = sshMatch[1];
53
+ } else if (/github\.com/i.test(value)) {
54
+ try {
55
+ const url = new URL(value.startsWith("http") ? value : `https://${value.replace(/^git\+/, "")}`);
56
+ const parts = url.pathname.split("/").filter(Boolean);
57
+ if (parts.length >= 2) {
58
+ value = `${parts[0]}/${parts[1]}`;
59
+ }
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ value = value.replace(/^\/+/, "").replace(/\.git$/i, "");
66
+ if (!/^[\w.-]+\/[\w.-]+$/.test(value)) return null;
67
+ return value;
68
+ }
69
+
70
+ /**
71
+ * URL of `origin`, else of the first remote, else null.
72
+ * @param {string | undefined} cwd
73
+ * @param {{ timeoutMs?: number }} [options]
74
+ * @returns {string | null}
75
+ */
76
+ export function remoteUrl(cwd, { timeoutMs } = {}) {
77
+ const origin = git(cwd, ["remote", "get-url", "origin"], { timeoutMs });
78
+ if (origin) return origin;
79
+ const names = (git(cwd, ["remote"], { timeoutMs }) ?? "")
80
+ .split("\n").map((line) => line.trim()).filter(Boolean);
81
+ if (names.length === 0) return null;
82
+ const name = names.includes("origin") ? "origin" : names[0];
83
+ return git(cwd, ["remote", "get-url", name], { timeoutMs }) || null;
84
+ }
85
+
86
+ /**
87
+ * @typedef {object} RepoIdentity
88
+ * @property {string | null} root Work tree top level.
89
+ * @property {string | null} remote Origin URL, else first remote.
90
+ * @property {string | null} repository `owner/repo` for a GitHub remote.
91
+ * @property {string | null} branch Current branch (also on an unborn branch); "HEAD" when detached.
92
+ * @property {string | null} commit HEAD commit.
93
+ * @property {boolean} dirty
94
+ * @property {string[]} changedFiles `git status --porcelain` lines.
95
+ */
96
+
97
+ /**
98
+ * Identity of the repository containing cwd. Outside git every field is null.
99
+ * @param {string} [cwd]
100
+ * @param {{ timeoutMs?: number }} [options]
101
+ * @returns {RepoIdentity}
102
+ */
103
+ export function repoIdentity(cwd, { timeoutMs } = {}) {
104
+ const opts = { timeoutMs };
105
+ const root = git(cwd, ["rev-parse", "--show-toplevel"], opts) || null;
106
+ const remote = remoteUrl(cwd, opts);
107
+ const commit = git(cwd, ["rev-parse", "HEAD"], opts) || null;
108
+ const ref = git(cwd, ["symbolic-ref", "-q", "HEAD"], opts);
109
+ const branch = ref ? ref.replace(/^refs\/heads\//, "") : commit ? "HEAD" : null;
110
+ const status = git(cwd, ["status", "--porcelain"], { ...opts, trim: false });
111
+ const changedFiles = (status ?? "").split("\n").map((line) => line.trimEnd()).filter(Boolean);
112
+ return {
113
+ root,
114
+ remote,
115
+ repository: normalizeGitHubRepo(remote),
116
+ branch,
117
+ commit,
118
+ dirty: changedFiles.length > 0,
119
+ changedFiles,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Tree hash of the working tree (tracked edits plus untracked, non-ignored files),
125
+ * built in a throwaway index so the real index is never touched. Null on failure.
126
+ *
127
+ * The throwaway index starts as a copy of the real one, and `read-tree -m HEAD`
128
+ * resets it to HEAD while keeping the cached stat data of unchanged entries, so
129
+ * `add -A` rehashes only files that changed. The result is the same tree as
130
+ * `read-tree HEAD` in an empty index (HANDOFF_TREE_FINGERPRINT_COMMAND), which
131
+ * `worktreeTreeHashCold` keeps for the parity test.
132
+ * @param {string} [cwd]
133
+ * @returns {string | null}
134
+ */
135
+ export function worktreeTreeHash(cwd) {
136
+ return treeHashInThrowawayIndex(cwd, { seed: true });
137
+ }
138
+
139
+ /** The original method: an empty throwaway index, every file rehashed. */
140
+ export function worktreeTreeHashCold(cwd) {
141
+ return treeHashInThrowawayIndex(cwd, { seed: false });
142
+ }
143
+
144
+ function treeHashInThrowawayIndex(cwd, { seed }) {
145
+ const root = git(cwd, ["rev-parse", "--show-toplevel"]);
146
+ if (!root) return null;
147
+ let dir;
148
+ try {
149
+ dir = mkdtempSync(path.join(os.tmpdir(), "hypher-index-"));
150
+ const index = path.join(dir, "index");
151
+ const opts = { env: { GIT_INDEX_FILE: index }, timeoutMs: TREE_TIMEOUT_MS };
152
+ let seeded = false;
153
+ if (seed) {
154
+ const real = git(root, ["rev-parse", "--path-format=absolute", "--git-path", "index"]);
155
+ try {
156
+ if (real) {
157
+ copyFileSync(real, index);
158
+ // Keep the real index's timestamp. Git trusts a cached entry only when the
159
+ // entry is older than the index file itself; a fresh mtime on the copy
160
+ // would make a same-size edit made in that same second look unchanged.
161
+ const { atime, mtime } = statSync(real);
162
+ utimesSync(index, atime, mtime);
163
+ seeded = git(root, ["read-tree", "-m", "HEAD"], opts) !== null;
164
+ }
165
+ } catch {
166
+ seeded = false;
167
+ }
168
+ if (!seeded) rmSync(index, { force: true });
169
+ }
170
+ if (!seeded && git(root, ["read-tree", "HEAD"], opts) === null) return null;
171
+ if (git(root, ["add", "-A"], opts) === null) return null;
172
+ const tree = git(root, ["write-tree"], opts);
173
+ return tree && /^[0-9a-f]{40,64}$/.test(tree) ? tree : null;
174
+ } catch {
175
+ return null;
176
+ } finally {
177
+ if (dir) rmSync(dir, { recursive: true, force: true });
178
+ }
179
+ }
180
+
181
+ const HANDOFF_GITHUB_REMOTE = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?\/?$/;
182
+
183
+ /**
184
+ * @typedef {object} HandoffRepoSnapshot
185
+ * @property {string} [repository]
186
+ * @property {string} branch
187
+ * @property {string} commit
188
+ * @property {boolean} dirty
189
+ * @property {string} [worktreePath]
190
+ * @property {string} [dirtyFingerprint] `tree:<sha>` when dirty.
191
+ */
192
+
193
+ /**
194
+ * Repository snapshot for a structured handoff (`currentRepo` / `proposal.repo`).
195
+ * Reads only `origin`. Null outside a checkout with a commit.
196
+ * @param {string} [cwd]
197
+ * @returns {HandoffRepoSnapshot | null}
198
+ */
199
+ export function handoffRepoSnapshot(cwd) {
200
+ const remote = git(cwd, ["config", "--get", "remote.origin.url"]);
201
+ const repository = remote?.match(HANDOFF_GITHUB_REMOTE)?.[1] ?? null;
202
+ const worktreePath = git(cwd, ["rev-parse", "--show-toplevel"]);
203
+ const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
204
+ const commit = git(cwd, ["rev-parse", "HEAD"]);
205
+ const status = git(cwd, ["status", "--porcelain"]);
206
+ if (!branch || !commit) return null;
207
+ const dirty = Boolean(status);
208
+ const tree = dirty ? worktreeTreeHash(cwd) : null;
209
+ return {
210
+ ...(repository ? { repository } : {}),
211
+ branch,
212
+ commit,
213
+ dirty,
214
+ ...(worktreePath ? { worktreePath } : {}),
215
+ ...(tree ? { dirtyFingerprint: `tree:${tree}` } : {}),
216
+ };
217
+ }
218
+
219
+ function commitSha(cwd, rev) {
220
+ if (typeof rev !== "string" || !rev.trim() || rev.startsWith("-")) return null;
221
+ return git(cwd, ["rev-parse", "--verify", "-q", `${rev.trim()}^{commit}`]) || null;
222
+ }
223
+
224
+ /**
225
+ * Whether commit a is an ancestor of (or equal to) b. Null when unknown.
226
+ * @param {string | undefined} cwd
227
+ * @param {string} a
228
+ * @param {string} b
229
+ * @returns {boolean | null}
230
+ */
231
+ export function isAncestor(cwd, a, b) {
232
+ const from = commitSha(cwd, a);
233
+ const to = commitSha(cwd, b);
234
+ if (!from || !to) return null;
235
+ try {
236
+ execFileSync("git", ["merge-base", "--is-ancestor", from, to], {
237
+ timeout: GIT_TIMEOUT_MS,
238
+ stdio: "ignore",
239
+ ...(cwd ? { cwd } : {}),
240
+ });
241
+ return true;
242
+ } catch (error) {
243
+ return error?.status === 1 ? false : null;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Number of commits reachable from `to` but not from `from` (`from..to`). Null when unknown.
249
+ * @param {string | undefined} cwd
250
+ * @param {string} from
251
+ * @param {string} to
252
+ * @returns {number | null}
253
+ */
254
+ export function commitsBetween(cwd, from, to) {
255
+ const a = commitSha(cwd, from);
256
+ const b = commitSha(cwd, to);
257
+ if (!a || !b) return null;
258
+ const count = Number(git(cwd, ["rev-list", "--count", `${a}..${b}`]) ?? NaN);
259
+ return Number.isInteger(count) ? count : null;
260
+ }
261
+
262
+ /**
263
+ * @typedef {"same" | "ahead" | "behind" | "diverged" | "unknown"} CommitRelationKind
264
+ * @typedef {{ relation: CommitRelationKind, ahead: number | null, behind: number | null }} CommitRelation
265
+ */
266
+
267
+ /**
268
+ * How `current` relates to `saved`: ahead = commits only in current, behind = commits only in saved.
269
+ * @param {string | undefined} cwd
270
+ * @param {string} saved
271
+ * @param {string} current
272
+ * @returns {CommitRelation}
273
+ */
274
+ export function commitRelation(cwd, saved, current) {
275
+ const unknown = { relation: /** @type {const} */ ("unknown"), ahead: null, behind: null };
276
+ const a = commitSha(cwd, saved);
277
+ const b = commitSha(cwd, current);
278
+ if (!a || !b) return unknown;
279
+ if (a === b) return { relation: "same", ahead: 0, behind: 0 };
280
+ const counts = git(cwd, ["rev-list", "--left-right", "--count", `${a}...${b}`])?.split(/\s+/).map(Number);
281
+ if (!counts || counts.length !== 2 || !counts.every(Number.isInteger)) return unknown;
282
+ const [behind, ahead] = counts;
283
+ const relation = ahead && behind ? "diverged" : ahead ? "ahead" : behind ? "behind" : "same";
284
+ return { relation, ahead, behind };
285
+ }
286
+
287
+ /** Tree of a commit, or null. */
288
+ export function commitTree(cwd, rev) {
289
+ const sha = commitSha(cwd, rev);
290
+ return sha ? git(cwd, ["rev-parse", `${sha}^{tree}`]) || null : null;
291
+ }
292
+
293
+ /** True when the object exists in this repository. */
294
+ export function hasObject(cwd, sha) {
295
+ return typeof sha === "string" && /^[0-9a-f]{40,64}$/.test(sha) && git(cwd, ["cat-file", "-e", sha]) !== null;
296
+ }
297
+
298
+ /**
299
+ * Paths that differ between two trees, or null when either tree is missing.
300
+ * @returns {string[] | null}
301
+ */
302
+ export function changedPathsBetweenTrees(cwd, fromTree, toTree) {
303
+ if (!hasObject(cwd, fromTree) || !hasObject(cwd, toTree)) return null;
304
+ if (fromTree === toTree) return [];
305
+ const out = git(cwd, ["diff-tree", "-r", "--no-renames", "--name-only", fromTree, toTree]);
306
+ return out === null ? null : out.split("\n").filter(Boolean);
307
+ }
308
+
309
+ /** A ref-safe name for a branch: readable, plus a short hash so two branches never collide. */
310
+ export function hypherRefBranch(branch) {
311
+ const text = String(branch ?? "HEAD");
312
+ const readable = text.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "branch";
313
+ let hash = 0;
314
+ for (const char of text) hash = (Math.imul(hash, 31) + char.codePointAt(0)) >>> 0;
315
+ return `${readable}-${hash.toString(16).padStart(8, "0")}`;
316
+ }
317
+
318
+ const HYPHER_REF_IDENTITY = {
319
+ GIT_AUTHOR_NAME: "Hypher",
320
+ GIT_AUTHOR_EMAIL: "hypher@localhost",
321
+ GIT_COMMITTER_NAME: "Hypher",
322
+ GIT_COMMITTER_EMAIL: "hypher@localhost",
323
+ };
324
+
325
+ /**
326
+ * Keep a handoff tree reachable under refs/hypher/<branch>/<tree>, so "edited
327
+ * since" can name files later. The tree holds untracked files, which can include
328
+ * secrets, so only the latest ref per branch is kept, the rest are pruned, and
329
+ * refs/hypher is never pushed. Returns the ref, or null.
330
+ * @param {string} cwd
331
+ * @param {{ branch: string, tree: string, commit?: string }} handoff
332
+ */
333
+ export function keepHandoffTree(cwd, { branch, tree, commit }) {
334
+ if (!hasObject(cwd, tree)) return null;
335
+ const prefix = `refs/hypher/${hypherRefBranch(branch)}/`;
336
+ const ref = `${prefix}${tree.slice(0, 12)}`;
337
+ const parent = commit ? commitSha(cwd, commit) : null;
338
+ const wrapped = git(cwd, ["commit-tree", tree, ...(parent ? ["-p", parent] : []), "-m", "Hypher handoff tree (local only; never push refs/hypher)"],
339
+ { env: HYPHER_REF_IDENTITY });
340
+ if (!wrapped || git(cwd, ["update-ref", ref, wrapped]) === null) return null;
341
+ pruneHypherRefs(cwd, { keep: ref, prefix });
342
+ return ref;
343
+ }
344
+
345
+ /** Delete refs/hypher refs under `prefix` except `keep`. Returns the deleted refs. */
346
+ export function pruneHypherRefs(cwd, { keep, prefix = "refs/hypher/" }) {
347
+ const refs = (git(cwd, ["for-each-ref", "--format=%(refname)", prefix]) ?? "").split("\n").filter(Boolean);
348
+ const deleted = [];
349
+ for (const ref of refs) {
350
+ if (ref === keep) continue;
351
+ if (git(cwd, ["update-ref", "-d", ref]) !== null) deleted.push(ref);
352
+ }
353
+ return deleted;
354
+ }
355
+
356
+ /**
357
+ * Remotes whose configuration would push refs/hypher (a mirror, or a push
358
+ * refspec that covers it). Reads config only; no network.
359
+ * @returns {string[]}
360
+ */
361
+ export function remotesExposingHypherRefs(cwd) {
362
+ const names = (git(cwd, ["remote"]) ?? "").split("\n").map((name) => name.trim()).filter(Boolean);
363
+ const exposed = [];
364
+ for (const name of names) {
365
+ const mirror = git(cwd, ["config", "--bool", `remote.${name}.mirror`]) === "true";
366
+ const pushes = (git(cwd, ["config", "--get-all", `remote.${name}.push`]) ?? "").split("\n").filter(Boolean);
367
+ const covers = pushes.some((spec) => /^\+?(refs\/\*|refs\/hypher\/|\*)/.test(spec.replace(/:.*$/, "")) || spec === ":");
368
+ if (mirror || covers) exposed.push(name);
369
+ }
370
+ return exposed;
371
+ }
372
+
373
+ /**
374
+ * @typedef {"identical" | "committed-since" | "ahead" | "edited-since" | "behind" | "diverged" | "wrong-repository"} VerdictKind
375
+ * @typedef {object} TruthVerdict
376
+ * @property {VerdictKind} kind
377
+ * @property {boolean} stop Only behind, diverged, and wrong-repository stop the agent.
378
+ * @property {number} [ahead]
379
+ * @property {number} [behind]
380
+ * @property {string[]} [files] Paths changed since the handoff tree.
381
+ * @property {boolean} [filesUnavailable] The handoff tree is not on this machine.
382
+ * @property {boolean} [commitMissing] The handoff commit is not in this clone.
383
+ * @property {string} [branchNote] The current branch differs from the handoff's.
384
+ * @property {{ repository: string | null, branch: string | null, commit: string | null, tree: string | null }} current
385
+ */
386
+
387
+ /**
388
+ * Compare the working tree with a handoff, locally, with git.
389
+ * @param {string} cwd
390
+ * @param {{ repository?: string, branch: string, commit: string, dirty?: boolean, dirtyFingerprint?: string }} saved
391
+ * @returns {TruthVerdict}
392
+ */
393
+ export function truthCheck(cwd, saved) {
394
+ const repository = normalizeGitHubRepo(remoteUrl(cwd));
395
+ const ref = git(cwd, ["symbolic-ref", "-q", "HEAD"]);
396
+ const commit = git(cwd, ["rev-parse", "HEAD"]) || null;
397
+ const branch = ref ? ref.replace(/^refs\/heads\//, "") : commit ? "HEAD" : null;
398
+ const tree = worktreeTreeHash(cwd);
399
+ const current = { repository, branch, commit, tree };
400
+ const branchNote = saved.branch && branch && saved.branch !== branch ? `on branch ${branch}; the handoff was on ${saved.branch}` : undefined;
401
+ const base = { current, ...(branchNote ? { branchNote } : {}) };
402
+
403
+ const savedRepo = normalizeGitHubRepo(saved.repository ?? "");
404
+ if (!repository || !savedRepo || savedRepo.toLowerCase() !== repository.toLowerCase()) {
405
+ return { kind: "wrong-repository", stop: true, ...base };
406
+ }
407
+ const fingerprint = typeof saved.dirtyFingerprint === "string" && saved.dirtyFingerprint.startsWith("tree:")
408
+ ? saved.dirtyFingerprint.slice(5) : null;
409
+ // A dirty handoff without a fingerprint cannot say which edits it had.
410
+ const savedTree = fingerprint ?? (saved.dirty ? null : commitTree(cwd, saved.commit));
411
+ const relation = commitRelation(cwd, saved.commit, commit ?? "");
412
+ if (relation.relation === "unknown") {
413
+ return { kind: "behind", stop: true, behind: 0, commitMissing: true, ...base };
414
+ }
415
+ if (relation.relation === "behind") return { kind: "behind", stop: true, behind: relation.behind ?? 0, ...base };
416
+ if (relation.relation === "diverged") {
417
+ return { kind: "diverged", stop: true, ahead: relation.ahead ?? 0, behind: relation.behind ?? 0, ...base };
418
+ }
419
+ const files = savedTree && tree ? changedPathsBetweenTrees(cwd, savedTree, tree) : null;
420
+ const detail = files === null ? { filesUnavailable: true } : { files };
421
+ if (relation.relation === "same") {
422
+ if (savedTree && tree && savedTree === tree) return { kind: "identical", stop: false, ...base };
423
+ return { kind: "edited-since", stop: false, ...detail, ...base };
424
+ }
425
+ if (savedTree && tree && savedTree === tree) {
426
+ return { kind: "committed-since", stop: false, ahead: relation.ahead ?? 0, ...base };
427
+ }
428
+ return { kind: "ahead", stop: false, ahead: relation.ahead ?? 0, ...detail, ...base };
429
+ }
@@ -0,0 +1,47 @@
1
+ // Generated from hypher-web/shared/secretScan.ts by script/sync-hypher-shared.mjs. Do not edit.
2
+ /**
3
+ * Common secret shapes, refused in agent-written handoff text. The CLI scans
4
+ * before upload and the server scans again on every structured save, because
5
+ * saved text reaches every later session. The match itself is never echoed.
6
+ */
7
+ export const SECRET_SHAPES = [
8
+ { name: "a Hypher API key (hyp_…)", pattern: /\bhyp_[A-Za-z0-9]{16,}/ },
9
+ { name: "an API secret key (sk-…)", pattern: /\bsk-(?:[A-Za-z]+-)?[A-Za-z0-9_-]{16,}/ },
10
+ { name: "a GitHub token (ghp_…)", pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}/ },
11
+ { name: "an AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
12
+ { name: "a private key (PEM)", pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
13
+ ];
14
+ /** The first secret shape in text, or null. */
15
+ export function findSecret(text) {
16
+ for (const shape of SECRET_SHAPES) {
17
+ if (shape.pattern.test(text))
18
+ return shape;
19
+ }
20
+ return null;
21
+ }
22
+ /**
23
+ * Walk any JSON-like value and report the first string that holds a secret,
24
+ * with its field path (for example "decisions[1].reason").
25
+ */
26
+ export function findSecretInValue(value, field = "text") {
27
+ if (typeof value === "string") {
28
+ const shape = findSecret(value);
29
+ return shape ? { field, shape: shape.name } : null;
30
+ }
31
+ if (Array.isArray(value)) {
32
+ for (let index = 0; index < value.length; index += 1) {
33
+ const found = findSecretInValue(value[index], `${field}[${index}]`);
34
+ if (found)
35
+ return found;
36
+ }
37
+ return null;
38
+ }
39
+ if (value && typeof value === "object") {
40
+ for (const [key, item] of Object.entries(value)) {
41
+ const found = findSecretInValue(item, field === "text" ? key : `${field}.${key}`);
42
+ if (found)
43
+ return found;
44
+ }
45
+ }
46
+ return null;
47
+ }