@bli-cockpit/cli 0.1.2 → 0.1.5

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,203 @@
1
+ import { execFile } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ const SKIPPED_DIR_NAMES = new Set([
8
+ ".cache",
9
+ ".git",
10
+ ".next",
11
+ ".turbo",
12
+ "build",
13
+ "coverage",
14
+ "dist",
15
+ "node_modules",
16
+ "out",
17
+ ]);
18
+ export async function resolveRepoWorktreeIdentity(repoRoot) {
19
+ const requestedPath = path.resolve(repoRoot);
20
+ const gitRoot = await runGit(["rev-parse", "--show-toplevel"], requestedPath);
21
+ const resolvedRoot = path.resolve(gitRoot.trim() || requestedPath);
22
+ const branch = await resolveGitBranchWithGit(resolvedRoot);
23
+ const headSha = await runGit(["rev-parse", "HEAD"], resolvedRoot).then((value) => value.trim() || null, () => null);
24
+ const absoluteGitDir = await runGit(["rev-parse", "--absolute-git-dir"], resolvedRoot)
25
+ .then((value) => path.resolve(resolvedRoot, value.trim()))
26
+ .catch(() => null);
27
+ const commonGitDir = await runGit(["rev-parse", "--git-common-dir"], resolvedRoot)
28
+ .then((value) => path.resolve(resolvedRoot, value.trim()))
29
+ .catch(() => absoluteGitDir ?? resolvedRoot);
30
+ const rawOrigin = await runGit(["remote", "get-url", "origin"], resolvedRoot).then((value) => value.trim() || null, () => null);
31
+ const repoOriginUrl = rawOrigin ? normalizeGitOrigin(rawOrigin) : null;
32
+ const repoLabel = repoOriginUrl
33
+ ? repoLabelFromOrigin(repoOriginUrl)
34
+ : path.basename(resolvedRoot);
35
+ const repoMaterial = repoOriginUrl
36
+ ? `origin:${repoOriginUrl}`
37
+ : `local:${sha256(commonGitDir ?? resolvedRoot)}`;
38
+ const repoFingerprint = `repo-${sha256(repoMaterial).slice(0, 24)}`;
39
+ const worktreeMaterial = [
40
+ repoFingerprint,
41
+ resolvedRoot,
42
+ commonGitDir ?? "",
43
+ ].join("\n");
44
+ const gitFilePath = path.join(resolvedRoot, ".git");
45
+ const worktreeIsPrimary = await fs.stat(gitFilePath).then((stat) => stat.isDirectory(), () => false);
46
+ return {
47
+ requested_path: requestedPath,
48
+ repo_root: resolvedRoot,
49
+ repo_label: repoLabel,
50
+ repo_fingerprint: repoFingerprint,
51
+ repo_origin_url: repoOriginUrl,
52
+ branch,
53
+ head_sha: headSha,
54
+ worktree_label: path.basename(resolvedRoot),
55
+ worktree_fingerprint: `wt-${sha256(worktreeMaterial).slice(0, 24)}`,
56
+ worktree_is_primary: worktreeIsPrimary,
57
+ };
58
+ }
59
+ export async function discoverGitWorktrees(root, options = {}) {
60
+ const resolvedRoot = path.resolve(root);
61
+ const direct = await resolveRepoWorktreeIdentity(resolvedRoot).catch(() => null);
62
+ if (direct)
63
+ return [direct];
64
+ if (await hasGitMarker(resolvedRoot)) {
65
+ return [await fallbackFilesystemIdentity(resolvedRoot)];
66
+ }
67
+ const maxDepth = options.maxDepth ?? 2;
68
+ const maxWorktrees = options.maxWorktrees ?? 50;
69
+ const discovered = new Map();
70
+ const stack = [{ dir: resolvedRoot, depth: 0 }];
71
+ const visited = new Set();
72
+ while (stack.length > 0 && discovered.size < maxWorktrees) {
73
+ const current = stack.shift();
74
+ if (!current)
75
+ continue;
76
+ const real = await fs.realpath(current.dir).catch(() => current.dir);
77
+ if (visited.has(real))
78
+ continue;
79
+ visited.add(real);
80
+ if (await hasGitMarker(current.dir)) {
81
+ const identity = (await resolveRepoWorktreeIdentity(current.dir).catch(() => null)) ??
82
+ (await fallbackFilesystemIdentity(current.dir));
83
+ discovered.set(identity.worktree_fingerprint, identity);
84
+ continue;
85
+ }
86
+ if (current.depth >= maxDepth)
87
+ continue;
88
+ const entries = await fs.readdir(current.dir, { withFileTypes: true }).catch(() => []);
89
+ for (const entry of entries) {
90
+ if (!entry.isDirectory() || shouldSkipDirectory(entry.name))
91
+ continue;
92
+ stack.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
93
+ }
94
+ }
95
+ return [...discovered.values()].sort(compareIdentity);
96
+ }
97
+ async function hasGitMarker(dir) {
98
+ return fs.stat(path.join(dir, ".git")).then((stat) => stat.isDirectory() || stat.isFile(), () => false);
99
+ }
100
+ async function fallbackFilesystemIdentity(repoRoot) {
101
+ const resolvedRoot = path.resolve(repoRoot);
102
+ const repoLabel = path.basename(resolvedRoot) || "repo";
103
+ const repoFingerprint = `repo-${sha256(`local:${resolvedRoot}`).slice(0, 24)}`;
104
+ const worktreeFingerprint = `wt-${sha256(`${repoFingerprint}:${resolvedRoot}`).slice(0, 24)}`;
105
+ return {
106
+ requested_path: resolvedRoot,
107
+ repo_root: resolvedRoot,
108
+ repo_label: repoLabel,
109
+ repo_fingerprint: repoFingerprint,
110
+ repo_origin_url: null,
111
+ branch: await resolveBranchFromHead(resolvedRoot),
112
+ head_sha: null,
113
+ worktree_label: repoLabel,
114
+ worktree_fingerprint: worktreeFingerprint,
115
+ worktree_is_primary: true,
116
+ };
117
+ }
118
+ async function resolveBranchFromHead(repoRoot) {
119
+ try {
120
+ const gitPath = path.join(repoRoot, ".git");
121
+ const stat = await fs.stat(gitPath);
122
+ const headPath = stat.isFile()
123
+ ? path.join(await resolveLinkedGitDir(gitPath), "HEAD")
124
+ : path.join(gitPath, "HEAD");
125
+ const head = (await fs.readFile(headPath, "utf8")).trim();
126
+ if (head.startsWith("ref: refs/heads/")) {
127
+ return head.slice("ref: refs/heads/".length);
128
+ }
129
+ return head ? `detached:${head.slice(0, 12)}` : "unknown";
130
+ }
131
+ catch {
132
+ return "unknown";
133
+ }
134
+ }
135
+ async function resolveLinkedGitDir(gitFile) {
136
+ const raw = await fs.readFile(gitFile, "utf8");
137
+ const match = raw.match(/^gitdir:\s*(.+)$/m);
138
+ if (!match)
139
+ return path.dirname(gitFile);
140
+ const gitDir = match[1].trim();
141
+ return path.isAbsolute(gitDir) ? gitDir : path.resolve(path.dirname(gitFile), gitDir);
142
+ }
143
+ export function normalizeGitOrigin(rawOrigin) {
144
+ const trimmed = rawOrigin.trim();
145
+ if (!trimmed)
146
+ return "";
147
+ const scpLike = trimmed.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
148
+ if (scpLike && !trimmed.includes("://")) {
149
+ return normalizeOriginParts(scpLike[1] ?? "", scpLike[2] ?? "");
150
+ }
151
+ try {
152
+ const url = new URL(trimmed);
153
+ return normalizeOriginParts(url.hostname, url.pathname);
154
+ }
155
+ catch {
156
+ return trimmed
157
+ .replace(/\.git$/i, "")
158
+ .replace(/^\/+|\/+$/g, "")
159
+ .toLowerCase();
160
+ }
161
+ }
162
+ export function repoLabelFromOrigin(origin) {
163
+ const segments = origin.split("/").filter(Boolean);
164
+ return segments.at(-1) ?? origin;
165
+ }
166
+ function normalizeOriginParts(host, repoPath) {
167
+ return [
168
+ host.trim().toLowerCase(),
169
+ repoPath
170
+ .trim()
171
+ .replace(/\.git$/i, "")
172
+ .replace(/^\/+|\/+$/g, "")
173
+ .toLowerCase(),
174
+ ]
175
+ .filter(Boolean)
176
+ .join("/");
177
+ }
178
+ function shouldSkipDirectory(name) {
179
+ return name.startsWith(".") || SKIPPED_DIR_NAMES.has(name);
180
+ }
181
+ function compareIdentity(a, b) {
182
+ return (a.repo_label.localeCompare(b.repo_label) ||
183
+ Number(b.worktree_is_primary) - Number(a.worktree_is_primary) ||
184
+ a.worktree_label.localeCompare(b.worktree_label));
185
+ }
186
+ async function resolveGitBranchWithGit(repoRoot) {
187
+ const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot).then((value) => value.trim(), () => "");
188
+ if (branch && branch !== "HEAD")
189
+ return branch;
190
+ const head = await runGit(["rev-parse", "--short=12", "HEAD"], repoRoot).then((value) => value.trim(), () => "");
191
+ return head ? `detached:${head}` : "unknown";
192
+ }
193
+ async function runGit(args, cwd) {
194
+ const { stdout } = await execFileAsync("git", args, {
195
+ cwd,
196
+ timeout: 2_000,
197
+ maxBuffer: 1024 * 1024,
198
+ });
199
+ return stdout;
200
+ }
201
+ function sha256(value) {
202
+ return crypto.createHash("sha256").update(value, "utf8").digest("hex");
203
+ }
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getCollectorRuntimePaths, inspectLocalCollectorStatus, readLocalSessionReference, readLocalWorkContext, } from "./local-state.js";
1
+ import { getCollectorRuntimePaths, inspectLocalCollectorStatus, readLocalSessionReference, readLocalWorkContext, readLocalWorkContextForRepo, } from "./local-state.js";
2
2
  import { runLocalSourceCollectors } from "./adapters/local-sources.js";
3
3
  import http from "node:http";
4
4
  export function createCollectorServer(options = {}) {
@@ -55,7 +55,9 @@ export function isLoopbackHostHeader(hostHeader) {
55
55
  }
56
56
  async function contextPayload(options) {
57
57
  const paths = getCollectorRuntimePaths(options.homeDir);
58
- const context = await readLocalWorkContext(paths).catch(() => null);
58
+ const context = options.repoRoot
59
+ ? await readLocalWorkContextForRepo(paths, options.repoRoot).catch(() => null)
60
+ : await readLocalWorkContext(paths).catch(() => null);
59
61
  return { context };
60
62
  }
61
63
  async function sourcesPayload(options) {
@@ -73,7 +75,9 @@ async function flagsPayload(options) {
73
75
  async function collectLocalSources(options) {
74
76
  const status = await inspectLocalCollectorStatus(options);
75
77
  const paths = getCollectorRuntimePaths(options.homeDir);
76
- const activeContext = await readLocalWorkContext(paths).catch(() => null);
78
+ const activeContext = options.repoRoot
79
+ ? await readLocalWorkContextForRepo(paths, options.repoRoot).catch(() => null)
80
+ : await readLocalWorkContext(paths).catch(() => null);
77
81
  const session = await readLocalSessionReference(paths);
78
82
  const workContextId = activeContext?.work_context_id ?? status.work_context_id ?? "status-only";
79
83
  const sources = await runLocalSourceCollectors({