@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.
- package/README.md +13 -0
- package/bin/hypher.mjs +4 -0
- package/package.json +25 -0
- package/src/agentsBlock.mjs +60 -0
- package/src/api.mjs +103 -0
- package/src/claude.mjs +51 -0
- package/src/cli.mjs +101 -0
- package/src/codex.mjs +305 -0
- package/src/commands/ack.mjs +38 -0
- package/src/commands/doctor.mjs +130 -0
- package/src/commands/hook.mjs +57 -0
- package/src/commands/init.mjs +252 -0
- package/src/commands/link.mjs +65 -0
- package/src/commands/mcp.mjs +304 -0
- package/src/commands/resume.mjs +25 -0
- package/src/commands/seal.mjs +85 -0
- package/src/commands/status.mjs +38 -0
- package/src/commands/uninstall.mjs +131 -0
- package/src/config.mjs +48 -0
- package/src/credentials.mjs +134 -0
- package/src/device.mjs +62 -0
- package/src/env.mjs +75 -0
- package/src/fsutil.mjs +70 -0
- package/src/hookConfig.mjs +86 -0
- package/src/hooks/index.mjs +134 -0
- package/src/manifest.mjs +126 -0
- package/src/outbox.mjs +103 -0
- package/src/repoBlock.mjs +28 -0
- package/src/secrets.mjs +2 -0
- package/src/session.mjs +211 -0
- package/src/snapshot.mjs +56 -0
- package/src/state.mjs +53 -0
- package/src/tools.mjs +60 -0
- package/vendor/handoffNote.mjs +142 -0
- package/vendor/handoffSeal.mjs +149 -0
- package/vendor/hypher-git.mjs +429 -0
- package/vendor/secretScan.mjs +47 -0
- package/vendor/structuredHandoff.mjs +796 -0
|
@@ -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
|
+
}
|