heartbeads 0.4.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/.next/BUILD_ID +1 -0
- package/.next/app-build-manifest.json +49 -0
- package/.next/app-path-routes-manifest.json +1 -0
- package/.next/build-manifest.json +32 -0
- package/.next/export-marker.json +1 -0
- package/.next/images-manifest.json +1 -0
- package/.next/next-minimal-server.js.nft.json +1 -0
- package/.next/next-server.js.nft.json +1 -0
- package/.next/package.json +1 -0
- package/.next/prerender-manifest.json +1 -0
- package/.next/react-loadable-manifest.json +8 -0
- package/.next/required-server-files.json +1 -0
- package/.next/routes-manifest.json +1 -0
- package/.next/server/app/_not-found/page.js +1 -0
- package/.next/server/app/_not-found/page.js.nft.json +1 -0
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
- package/.next/server/app/_not-found.html +1 -0
- package/.next/server/app/_not-found.meta +6 -0
- package/.next/server/app/_not-found.rsc +9 -0
- package/.next/server/app/api/auth/route.js +1 -0
- package/.next/server/app/api/auth/route.js.nft.json +1 -0
- package/.next/server/app/api/beads/route.js +8 -0
- package/.next/server/app/api/beads/route.js.nft.json +1 -0
- package/.next/server/app/api/beads/stream/route.js +10 -0
- package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
- package/.next/server/app/api/beads.body +1 -0
- package/.next/server/app/api/beads.meta +1 -0
- package/.next/server/app/api/config/route.js +8 -0
- package/.next/server/app/api/config/route.js.nft.json +1 -0
- package/.next/server/app/api/docs/page.js +120 -0
- package/.next/server/app/api/docs/page.js.nft.json +1 -0
- package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
- package/.next/server/app/api/docs.html +120 -0
- package/.next/server/app/api/docs.meta +5 -0
- package/.next/server/app/api/docs.rsc +70 -0
- package/.next/server/app/api/login/route.js +1 -0
- package/.next/server/app/api/login/route.js.nft.json +1 -0
- package/.next/server/app/api/logout/route.js +1 -0
- package/.next/server/app/api/logout/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/callback/route.js +1 -0
- package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
- package/.next/server/app/api/records/route.js +1 -0
- package/.next/server/app/api/records/route.js.nft.json +1 -0
- package/.next/server/app/api/status/route.js +1 -0
- package/.next/server/app/api/status/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/graph/route.js +1 -0
- package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
- package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/ready/route.js +1 -0
- package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
- package/.next/server/app/index.html +1 -0
- package/.next/server/app/index.meta +5 -0
- package/.next/server/app/index.rsc +9 -0
- package/.next/server/app/login/page.js +1 -0
- package/.next/server/app/login/page.js.nft.json +1 -0
- package/.next/server/app/login/page_client-reference-manifest.js +1 -0
- package/.next/server/app/login.html +1 -0
- package/.next/server/app/login.meta +5 -0
- package/.next/server/app/login.rsc +9 -0
- package/.next/server/app/opengraph-image.png/route.js +1 -0
- package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
- package/.next/server/app/opengraph-image.png.body +0 -0
- package/.next/server/app/opengraph-image.png.meta +1 -0
- package/.next/server/app/page.js +24 -0
- package/.next/server/app/page.js.nft.json +1 -0
- package/.next/server/app/page_client-reference-manifest.js +1 -0
- package/.next/server/app/twitter-image.png/route.js +1 -0
- package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
- package/.next/server/app/twitter-image.png.body +0 -0
- package/.next/server/app/twitter-image.png.meta +1 -0
- package/.next/server/app-paths-manifest.json +22 -0
- package/.next/server/chunks/247.js +12 -0
- package/.next/server/chunks/29.js +1 -0
- package/.next/server/chunks/343.js +1 -0
- package/.next/server/chunks/460.js +12 -0
- package/.next/server/chunks/533.js +38 -0
- package/.next/server/chunks/542.js +27 -0
- package/.next/server/chunks/590.js +6 -0
- package/.next/server/chunks/615.js +15 -0
- package/.next/server/chunks/696.js +25 -0
- package/.next/server/chunks/719.js +2 -0
- package/.next/server/chunks/739.js +1 -0
- package/.next/server/chunks/950.js +2 -0
- package/.next/server/chunks/font-manifest.json +1 -0
- package/.next/server/edge-runtime-webpack.js +2 -0
- package/.next/server/edge-runtime-webpack.js.map +1 -0
- package/.next/server/font-manifest.json +1 -0
- package/.next/server/functions-config-manifest.json +1 -0
- package/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/.next/server/middleware-build-manifest.js +1 -0
- package/.next/server/middleware-manifest.json +32 -0
- package/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/.next/server/middleware.js +14 -0
- package/.next/server/middleware.js.map +1 -0
- package/.next/server/next-font-manifest.js +1 -0
- package/.next/server/next-font-manifest.json +1 -0
- package/.next/server/pages/404.html +1 -0
- package/.next/server/pages/500.html +1 -0
- package/.next/server/pages/_app.js +1 -0
- package/.next/server/pages/_app.js.nft.json +1 -0
- package/.next/server/pages/_document.js +1 -0
- package/.next/server/pages/_document.js.nft.json +1 -0
- package/.next/server/pages/_error.js +1 -0
- package/.next/server/pages/_error.js.nft.json +1 -0
- package/.next/server/pages-manifest.json +1 -0
- package/.next/server/server-reference-manifest.js +1 -0
- package/.next/server/server-reference-manifest.json +1 -0
- package/.next/server/webpack-runtime.js +1 -0
- package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
- package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
- package/.next/static/chunks/788-aa413085174e935a.js +1 -0
- package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
- package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
- package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
- package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
- package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
- package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
- package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
- package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
- package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
- package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
- package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
- package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
- package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
- package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
- package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
- package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
- package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
- package/README.md +389 -0
- package/app/api/auth/route.ts +103 -0
- package/app/api/beads/route.ts +27 -0
- package/app/api/beads/stream/route.ts +83 -0
- package/app/api/config/route.ts +48 -0
- package/app/api/docs/page.tsx +497 -0
- package/app/api/login/route.ts +42 -0
- package/app/api/logout/route.ts +14 -0
- package/app/api/oauth/callback/route.ts +97 -0
- package/app/api/oauth/client-metadata.json/route.ts +33 -0
- package/app/api/oauth/jwks.json/route.ts +32 -0
- package/app/api/records/route.ts +168 -0
- package/app/api/status/route.ts +25 -0
- package/app/api/v1/graph/route.ts +251 -0
- package/app/api/v1/issues/[id]/route.ts +158 -0
- package/app/api/v1/ready/route.ts +229 -0
- package/app/globals.css +230 -0
- package/app/layout.tsx +51 -0
- package/app/login/page.tsx +164 -0
- package/app/not-found.tsx +91 -0
- package/app/opengraph-image.png +0 -0
- package/app/page.tsx +2041 -0
- package/app/twitter-image.png +0 -0
- package/bin/heartbeads.mjs +225 -0
- package/components/ActivityItem.tsx +326 -0
- package/components/ActivityOverlay.tsx +125 -0
- package/components/ActivityPanel.tsx +345 -0
- package/components/AllCommentsPanel.tsx +270 -0
- package/components/AuthButton.tsx +202 -0
- package/components/BeadTooltip.tsx +246 -0
- package/components/BeadsGraph.tsx +2493 -0
- package/components/BeadsLogo.tsx +94 -0
- package/components/CommentTooltip.tsx +338 -0
- package/components/ContextMenu.tsx +272 -0
- package/components/DescriptionModal.tsx +595 -0
- package/components/GraphStats.tsx +121 -0
- package/components/HeartIcon.tsx +33 -0
- package/components/HelpPanel.tsx +339 -0
- package/components/MobileActionSheet.tsx +255 -0
- package/components/NodeDetail.tsx +793 -0
- package/components/SettingsModal.tsx +315 -0
- package/components/StatusLegend.tsx +99 -0
- package/components/TimelineBar.tsx +116 -0
- package/components/TutorialOverlay.tsx +235 -0
- package/hooks/useBeadsComments.ts +81 -0
- package/hooks/useIsMobile.ts +19 -0
- package/lib/activity.ts +377 -0
- package/lib/agent.ts +29 -0
- package/lib/api-helpers.ts +46 -0
- package/lib/auth/client.ts +221 -0
- package/lib/auth.tsx +159 -0
- package/lib/comments.ts +413 -0
- package/lib/diff-beads.ts +128 -0
- package/lib/discover.ts +228 -0
- package/lib/env.ts +33 -0
- package/lib/gate.ts +55 -0
- package/lib/parse-beads.ts +234 -0
- package/lib/session.ts +52 -0
- package/lib/settings.ts +42 -0
- package/lib/timeline.ts +138 -0
- package/lib/tts.ts +397 -0
- package/lib/types.ts +271 -0
- package/lib/utils.ts +48 -0
- package/lib/watch-beads.ts +97 -0
- package/next.config.mjs +4 -0
- package/package.json +81 -0
- package/postcss.config.mjs +9 -0
- package/public/image.png +0 -0
- package/scripts/generate-jwk.js +38 -0
- package/tailwind.config.ts +41 -0
- package/tsconfig.json +24 -0
package/lib/discover.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "fs";
|
|
2
|
+
import { join, resolve, dirname, basename, parse as pathParse } from "path";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
|
|
6
|
+
export interface BeadsDiscovery {
|
|
7
|
+
beadsDir: string; // Absolute path to the .beads/ directory
|
|
8
|
+
repoRoot: string; // Parent directory of .beads/
|
|
9
|
+
repoName: string; // Directory name of repoRoot (e.g. 'my-project')
|
|
10
|
+
issuePrefix?: string; // From config.yaml issue-prefix field, if set
|
|
11
|
+
additionalRepos: number; // Count of repos.additional entries (0 for single-repo)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read config.yaml from a .beads directory and extract metadata.
|
|
16
|
+
*/
|
|
17
|
+
function readBeadsConfig(beadsDir: string): {
|
|
18
|
+
issuePrefix?: string;
|
|
19
|
+
additionalRepos: number;
|
|
20
|
+
} {
|
|
21
|
+
const configPath = join(beadsDir, "config.yaml");
|
|
22
|
+
try {
|
|
23
|
+
if (!existsSync(configPath)) return { additionalRepos: 0 };
|
|
24
|
+
const content = readFileSync(configPath, "utf-8");
|
|
25
|
+
const config = parseYaml(content);
|
|
26
|
+
|
|
27
|
+
const issuePrefix = config?.["issue-prefix"] || undefined;
|
|
28
|
+
const additional = config?.repos?.additional;
|
|
29
|
+
const additionalRepos = Array.isArray(additional) ? additional.length : 0;
|
|
30
|
+
|
|
31
|
+
return { issuePrefix, additionalRepos };
|
|
32
|
+
} catch {
|
|
33
|
+
return { additionalRepos: 0 };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Auto-discover the .beads/ directory by walking up from a starting directory,
|
|
39
|
+
* similar to how git finds .git/ or how bd itself finds .beads/.
|
|
40
|
+
*
|
|
41
|
+
* Resolution order:
|
|
42
|
+
* 1. BEADS_DIR environment variable (if set)
|
|
43
|
+
* 2. Walk up from startDir (or process.cwd()) looking for .beads/issues.jsonl
|
|
44
|
+
*
|
|
45
|
+
* @param startDir - Directory to start searching from (defaults to process.cwd())
|
|
46
|
+
* @throws Error if no .beads/ directory is found
|
|
47
|
+
*/
|
|
48
|
+
export function discoverBeadsDir(startDir?: string): BeadsDiscovery {
|
|
49
|
+
// 1. Check BEADS_DIR env var
|
|
50
|
+
const envDir = process.env.BEADS_DIR;
|
|
51
|
+
if (envDir) {
|
|
52
|
+
const resolved = resolve(envDir);
|
|
53
|
+
|
|
54
|
+
// Accept both /path/to/.beads and /path/to/repo (auto-append .beads/)
|
|
55
|
+
let beadsDir: string;
|
|
56
|
+
if (basename(resolved) === ".beads") {
|
|
57
|
+
beadsDir = resolved;
|
|
58
|
+
} else if (existsSync(join(resolved, ".beads"))) {
|
|
59
|
+
beadsDir = join(resolved, ".beads");
|
|
60
|
+
} else {
|
|
61
|
+
// Treat as .beads dir directly even if it doesn't exist yet
|
|
62
|
+
beadsDir = resolved;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
beadsDir = realpathSync(beadsDir);
|
|
67
|
+
} catch {
|
|
68
|
+
// realpathSync fails if path doesn't exist — use as-is
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const repoRoot = dirname(beadsDir);
|
|
72
|
+
const repoName = basename(repoRoot);
|
|
73
|
+
const configData = readBeadsConfig(beadsDir);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
beadsDir,
|
|
77
|
+
repoRoot,
|
|
78
|
+
repoName,
|
|
79
|
+
...configData,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 2. Walk up directory tree from startDir or cwd
|
|
84
|
+
let current = resolve(startDir || process.cwd());
|
|
85
|
+
const { root } = pathParse(current);
|
|
86
|
+
|
|
87
|
+
while (true) {
|
|
88
|
+
const candidate = join(current, ".beads");
|
|
89
|
+
|
|
90
|
+
// Check for .beads/ directory (with or without issues.jsonl — empty projects are valid)
|
|
91
|
+
if (existsSync(candidate) && statSync(candidate).isDirectory()) {
|
|
92
|
+
let beadsDir: string;
|
|
93
|
+
try {
|
|
94
|
+
beadsDir = realpathSync(candidate);
|
|
95
|
+
} catch {
|
|
96
|
+
beadsDir = candidate;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const repoRoot = dirname(beadsDir);
|
|
100
|
+
const repoName = basename(repoRoot);
|
|
101
|
+
const configData = readBeadsConfig(beadsDir);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
beadsDir,
|
|
105
|
+
repoRoot,
|
|
106
|
+
repoName,
|
|
107
|
+
...configData,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Reached filesystem root without finding .beads/
|
|
112
|
+
if (current === root) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`No .beads/ directory found.\n` +
|
|
115
|
+
`Searched from: ${resolve(startDir || process.cwd())}\n\n` +
|
|
116
|
+
`To initialize beads in your project, run:\n` +
|
|
117
|
+
` bd init\n\n` +
|
|
118
|
+
`Or set the BEADS_DIR environment variable:\n` +
|
|
119
|
+
` BEADS_DIR=/path/to/project/.beads heartbeads`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Move up one level
|
|
124
|
+
current = dirname(current);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Convert a raw git remote URL to a browser-friendly HTTPS URL.
|
|
130
|
+
* Handles: git@github.com:Org/repo.git, https://github.com/Org/repo.git, etc.
|
|
131
|
+
*/
|
|
132
|
+
function gitUrlToHttps(rawUrl: string): string | null {
|
|
133
|
+
let url = rawUrl.trim().replace(/\.git$/, "");
|
|
134
|
+
|
|
135
|
+
// SSH format: git@github.com:Org/repo
|
|
136
|
+
const sshMatch = url.match(/^git@([^:]+):(.+)$/);
|
|
137
|
+
if (sshMatch) {
|
|
138
|
+
return `https://${sshMatch[1]}/${sshMatch[2]}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Already HTTPS
|
|
142
|
+
if (url.startsWith("https://") || url.startsWith("http://")) {
|
|
143
|
+
return url;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get the git remote origin URL for a directory, if it's a git repo.
|
|
151
|
+
*/
|
|
152
|
+
function getGitRemoteUrl(dir: string): string | null {
|
|
153
|
+
try {
|
|
154
|
+
const raw = execSync("git config --get remote.origin.url", {
|
|
155
|
+
cwd: dir,
|
|
156
|
+
encoding: "utf-8",
|
|
157
|
+
timeout: 3000,
|
|
158
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
159
|
+
});
|
|
160
|
+
return gitUrlToHttps(raw);
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build a mapping of repo prefix → GitHub HTTPS URL for all discovered repos.
|
|
168
|
+
* Uses the primary repo + any additional repos from config.yaml.
|
|
169
|
+
*/
|
|
170
|
+
export function getRepoUrls(beadsDir: string): Record<string, string> {
|
|
171
|
+
const urls: Record<string, string> = {};
|
|
172
|
+
|
|
173
|
+
// Primary repo
|
|
174
|
+
const repoRoot = dirname(beadsDir);
|
|
175
|
+
const primaryUrl = getGitRemoteUrl(repoRoot);
|
|
176
|
+
|
|
177
|
+
// Read config to get issue-prefix and additional repos
|
|
178
|
+
const configPath = join(beadsDir, "config.yaml");
|
|
179
|
+
let issuePrefix: string | undefined;
|
|
180
|
+
let additionalPaths: string[] = [];
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
if (existsSync(configPath)) {
|
|
184
|
+
const content = readFileSync(configPath, "utf-8");
|
|
185
|
+
const config = parseYaml(content);
|
|
186
|
+
issuePrefix = config?.["issue-prefix"] || undefined;
|
|
187
|
+
const additional = config?.repos?.additional;
|
|
188
|
+
if (Array.isArray(additional)) {
|
|
189
|
+
additionalPaths = additional
|
|
190
|
+
.map((p: string) => resolve(repoRoot, p))
|
|
191
|
+
.filter((p: string) => existsSync(join(p, ".beads")));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// config.yaml unreadable
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Primary repo: use issue-prefix or directory name as key
|
|
199
|
+
const primaryPrefix = issuePrefix || basename(repoRoot);
|
|
200
|
+
if (primaryUrl) {
|
|
201
|
+
urls[primaryPrefix] = primaryUrl;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Additional repos
|
|
205
|
+
for (const repoPath of additionalPaths) {
|
|
206
|
+
const url = getGitRemoteUrl(repoPath);
|
|
207
|
+
if (!url) continue;
|
|
208
|
+
|
|
209
|
+
// Read that repo's .beads/config.yaml for its issue-prefix
|
|
210
|
+
const subConfigPath = join(repoPath, ".beads", "config.yaml");
|
|
211
|
+
let prefix = basename(repoPath); // fallback: directory name
|
|
212
|
+
try {
|
|
213
|
+
if (existsSync(subConfigPath)) {
|
|
214
|
+
const content = readFileSync(subConfigPath, "utf-8");
|
|
215
|
+
const config = parseYaml(content);
|
|
216
|
+
if (config?.["issue-prefix"]) {
|
|
217
|
+
prefix = config["issue-prefix"];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// use directory name fallback
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
urls[prefix] = url;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return urls;
|
|
228
|
+
}
|
package/lib/env.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment variable validation and defaults for ATProto OAuth.
|
|
3
|
+
* Allows missing values in dev mode (defaults kick in).
|
|
4
|
+
*/
|
|
5
|
+
export const env = {
|
|
6
|
+
/** iron-session encryption key (32+ chars) */
|
|
7
|
+
get COOKIE_SECRET(): string {
|
|
8
|
+
return (
|
|
9
|
+
process.env.COOKIE_SECRET ||
|
|
10
|
+
"development-secret-at-least-32-chars!!"
|
|
11
|
+
);
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
/** App's public URL (empty = localhost dev mode with public OAuth client) */
|
|
15
|
+
get PUBLIC_URL(): string {
|
|
16
|
+
return process.env.PUBLIC_URL || "";
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
/** Dev server port */
|
|
20
|
+
get PORT(): number {
|
|
21
|
+
return parseInt(process.env.PORT || "3000", 10);
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
/** ES256 JWK private key JSON (empty = public client mode) */
|
|
25
|
+
get ATPROTO_JWK_PRIVATE(): string {
|
|
26
|
+
return process.env.ATPROTO_JWK_PRIVATE || "";
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/** Dashboard password (empty = no auth, open access) */
|
|
30
|
+
get HEARTBEADS_PASSWORD(): string {
|
|
31
|
+
return process.env.HEARTBEADS_PASSWORD || "";
|
|
32
|
+
},
|
|
33
|
+
};
|
package/lib/gate.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password gate helpers for dashboard access control.
|
|
3
|
+
*
|
|
4
|
+
* The gate cookie is an HMAC-SHA256 signature of the string "heartbeads-gate"
|
|
5
|
+
* keyed by the dashboard password. This produces a static token that can be
|
|
6
|
+
* validated by both Node.js (API routes) and Edge Runtime (middleware).
|
|
7
|
+
*
|
|
8
|
+
* Used by:
|
|
9
|
+
* - app/api/auth/route.ts — generates and validates the cookie
|
|
10
|
+
* - middleware.ts — validates cookie, Bearer token, and query param
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
14
|
+
|
|
15
|
+
export const COOKIE_NAME = "heartbeads_gate";
|
|
16
|
+
const HMAC_MESSAGE = "heartbeads-gate";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generate the gate token (HMAC-SHA256 of a fixed message, keyed by password).
|
|
20
|
+
* This is the value stored in the httpOnly cookie.
|
|
21
|
+
*/
|
|
22
|
+
export function generateGateToken(password: string): string {
|
|
23
|
+
return createHmac("sha256", password).update(HMAC_MESSAGE).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validate a gate token against the password (Node.js crypto, constant-time).
|
|
28
|
+
*/
|
|
29
|
+
export function validateGateToken(
|
|
30
|
+
token: string,
|
|
31
|
+
password: string
|
|
32
|
+
): boolean {
|
|
33
|
+
const expected = generateGateToken(password);
|
|
34
|
+
if (token.length !== expected.length) return false;
|
|
35
|
+
try {
|
|
36
|
+
return timingSafeEqual(Buffer.from(token), Buffer.from(expected));
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Constant-time password comparison (Node.js crypto).
|
|
44
|
+
*/
|
|
45
|
+
export function comparePassword(
|
|
46
|
+
provided: string,
|
|
47
|
+
expected: string
|
|
48
|
+
): boolean {
|
|
49
|
+
if (provided.length !== expected.length) return false;
|
|
50
|
+
try {
|
|
51
|
+
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { join, resolve } from "path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { discoverBeadsDir } from "./discover";
|
|
5
|
+
import type {
|
|
6
|
+
BeadIssue,
|
|
7
|
+
BeadDependency,
|
|
8
|
+
GraphNode,
|
|
9
|
+
GraphLink,
|
|
10
|
+
GraphData,
|
|
11
|
+
BeadsApiResponse,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extract the prefix from a bead ID (e.g. "my-app-5gw" -> "my-app")
|
|
16
|
+
*/
|
|
17
|
+
export function extractPrefix(id: string): string {
|
|
18
|
+
const lastDash = id.lastIndexOf("-");
|
|
19
|
+
if (lastDash === -1) return id;
|
|
20
|
+
return id.substring(0, lastDash);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read repos.additional paths from .beads/config.yaml
|
|
25
|
+
*/
|
|
26
|
+
export function getAdditionalRepoPaths(beadsDir: string): string[] {
|
|
27
|
+
const configPath = join(beadsDir, "config.yaml");
|
|
28
|
+
try {
|
|
29
|
+
const content = readFileSync(configPath, "utf-8");
|
|
30
|
+
const config = parseYaml(content);
|
|
31
|
+
const additional = config?.repos?.additional;
|
|
32
|
+
if (Array.isArray(additional)) {
|
|
33
|
+
return additional
|
|
34
|
+
.map((p: string) => resolve(beadsDir, "..", p))
|
|
35
|
+
.filter((p: string) => existsSync(join(p, ".beads", "issues.jsonl")));
|
|
36
|
+
}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error("Failed to read config.yaml:", err);
|
|
39
|
+
}
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse a single issues.jsonl file and return issues
|
|
45
|
+
*/
|
|
46
|
+
function parseJsonlFile(filePath: string): BeadIssue[] {
|
|
47
|
+
try {
|
|
48
|
+
const content = readFileSync(filePath, "utf-8");
|
|
49
|
+
const lines = content.split("\n").filter((line) => line.trim().length > 0);
|
|
50
|
+
return lines.map((line) => JSON.parse(line) as BeadIssue);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error(`Failed to parse ${filePath}:`, err);
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse issues.jsonl from the primary .beads directory and all additional repos
|
|
59
|
+
*/
|
|
60
|
+
export function parseIssuesJsonl(beadsDir?: string): BeadIssue[] {
|
|
61
|
+
const dir = beadsDir || discoverBeadsDir().beadsDir;
|
|
62
|
+
|
|
63
|
+
// Load primary repo issues
|
|
64
|
+
const primaryPath = join(dir, "issues.jsonl");
|
|
65
|
+
const issues = parseJsonlFile(primaryPath);
|
|
66
|
+
|
|
67
|
+
// Load additional repo issues from config.yaml
|
|
68
|
+
const additionalRepos = getAdditionalRepoPaths(dir);
|
|
69
|
+
for (const repoPath of additionalRepos) {
|
|
70
|
+
const repoJsonl = join(repoPath, ".beads", "issues.jsonl");
|
|
71
|
+
const repoIssues = parseJsonlFile(repoJsonl);
|
|
72
|
+
issues.push(...repoIssues);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Deduplicate by issue ID (primary repo wins) and filter out tombstones.
|
|
76
|
+
// bd delete marks issues with status "tombstone" rather than removing them
|
|
77
|
+
// from the JSONL file — we must exclude these from the graph.
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
return issues.filter((issue) => {
|
|
80
|
+
if (seen.has(issue.id)) return false;
|
|
81
|
+
seen.add(issue.id);
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
|
+
if ((issue as any).status === "tombstone") return false;
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract all dependencies from issues (they're embedded in each issue)
|
|
90
|
+
*/
|
|
91
|
+
export function extractDependencies(issues: BeadIssue[]): BeadDependency[] {
|
|
92
|
+
const deps: BeadDependency[] = [];
|
|
93
|
+
const seen = new Set<string>();
|
|
94
|
+
|
|
95
|
+
for (const issue of issues) {
|
|
96
|
+
if (issue.dependencies) {
|
|
97
|
+
for (const dep of issue.dependencies) {
|
|
98
|
+
const key = `${dep.issue_id}->${dep.depends_on_id}:${dep.type}`;
|
|
99
|
+
if (!seen.has(key)) {
|
|
100
|
+
seen.add(key);
|
|
101
|
+
deps.push(dep);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return deps;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build graph data from issues and dependencies
|
|
112
|
+
*/
|
|
113
|
+
export function buildGraphData(
|
|
114
|
+
issues: BeadIssue[],
|
|
115
|
+
dependencies: BeadDependency[]
|
|
116
|
+
): GraphData {
|
|
117
|
+
const issueMap = new Map(issues.map((i) => [i.id, i]));
|
|
118
|
+
|
|
119
|
+
// Count blockers and dependents for each issue
|
|
120
|
+
const blockerCounts = new Map<string, string[]>(); // issue -> issues it blocks
|
|
121
|
+
const dependentCounts = new Map<string, string[]>(); // issue -> issues that block it
|
|
122
|
+
|
|
123
|
+
for (const dep of dependencies) {
|
|
124
|
+
if (dep.type === "blocks" || dep.type === "parent-child") {
|
|
125
|
+
// For blocks: depends_on_id blocks issue_id
|
|
126
|
+
// For parent-child: depends_on_id is parent of issue_id
|
|
127
|
+
// Both count as connections for node sizing
|
|
128
|
+
if (!blockerCounts.has(dep.depends_on_id)) {
|
|
129
|
+
blockerCounts.set(dep.depends_on_id, []);
|
|
130
|
+
}
|
|
131
|
+
blockerCounts.get(dep.depends_on_id)!.push(dep.issue_id);
|
|
132
|
+
|
|
133
|
+
if (!dependentCounts.has(dep.issue_id)) {
|
|
134
|
+
dependentCounts.set(dep.issue_id, []);
|
|
135
|
+
}
|
|
136
|
+
dependentCounts.get(dep.issue_id)!.push(dep.depends_on_id);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const nodes: GraphNode[] = issues.map((issue) => ({
|
|
141
|
+
id: issue.id,
|
|
142
|
+
title: issue.title,
|
|
143
|
+
description: issue.description,
|
|
144
|
+
status: issue.status,
|
|
145
|
+
priority: issue.priority,
|
|
146
|
+
issueType: issue.issue_type,
|
|
147
|
+
owner: issue.owner,
|
|
148
|
+
assignee: issue.assignee,
|
|
149
|
+
createdBy: issue.created_by,
|
|
150
|
+
createdAt: issue.created_at,
|
|
151
|
+
updatedAt: issue.updated_at,
|
|
152
|
+
closedAt: issue.closed_at,
|
|
153
|
+
closeReason: issue.close_reason,
|
|
154
|
+
prefix: extractPrefix(issue.id),
|
|
155
|
+
|
|
156
|
+
blockerCount: blockerCounts.get(issue.id)?.length || 0,
|
|
157
|
+
dependentCount: dependentCounts.get(issue.id)?.length || 0,
|
|
158
|
+
blockerIds: blockerCounts.get(issue.id) || [],
|
|
159
|
+
dependentIds: dependentCounts.get(issue.id) || [],
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
// Build links: include blocking AND parent-child dependencies where both nodes exist
|
|
163
|
+
const links: GraphLink[] = dependencies
|
|
164
|
+
.filter(
|
|
165
|
+
(d) =>
|
|
166
|
+
(d.type === "blocks" || d.type === "parent-child") &&
|
|
167
|
+
issueMap.has(d.issue_id) &&
|
|
168
|
+
issueMap.has(d.depends_on_id)
|
|
169
|
+
)
|
|
170
|
+
.map((d) => ({
|
|
171
|
+
// For both types: depends_on_id is the "upstream" node (blocker or parent)
|
|
172
|
+
// blocks: blocker -> blocked
|
|
173
|
+
// parent-child: parent -> child
|
|
174
|
+
source: d.depends_on_id,
|
|
175
|
+
target: d.issue_id,
|
|
176
|
+
type: d.type,
|
|
177
|
+
createdAt: d.created_at,
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
return { nodes, links };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Compute stats from issues and dependencies
|
|
185
|
+
*/
|
|
186
|
+
export function computeStats(
|
|
187
|
+
issues: BeadIssue[],
|
|
188
|
+
dependencies: BeadDependency[],
|
|
189
|
+
graphData: GraphData
|
|
190
|
+
) {
|
|
191
|
+
const prefixes = [...new Set(issues.map((i) => extractPrefix(i.id)))];
|
|
192
|
+
|
|
193
|
+
// An issue is "actionable" if it's open and has no open blockers
|
|
194
|
+
const openIssueIds = new Set(
|
|
195
|
+
issues.filter((i) => i.status !== "closed").map((i) => i.id)
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const blockedByOpen = new Set<string>();
|
|
199
|
+
for (const dep of dependencies) {
|
|
200
|
+
if (dep.type === "blocks" && openIssueIds.has(dep.depends_on_id)) {
|
|
201
|
+
// issue_id is blocked by depends_on_id which is still open
|
|
202
|
+
blockedByOpen.add(dep.issue_id);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const actionable = issues.filter(
|
|
207
|
+
(i) =>
|
|
208
|
+
(i.status === "open" || i.status === "in_progress") &&
|
|
209
|
+
!blockedByOpen.has(i.id)
|
|
210
|
+
).length;
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
total: issues.length,
|
|
214
|
+
open: issues.filter((i) => i.status === "open").length,
|
|
215
|
+
inProgress: issues.filter((i) => i.status === "in_progress").length,
|
|
216
|
+
blocked: issues.filter((i) => i.status === "blocked").length,
|
|
217
|
+
closed: issues.filter((i) => i.status === "closed").length,
|
|
218
|
+
actionable,
|
|
219
|
+
edges: graphData.links.length,
|
|
220
|
+
prefixes,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Full pipeline: parse, extract, build, compute
|
|
226
|
+
*/
|
|
227
|
+
export function loadBeadsData(beadsDir?: string): BeadsApiResponse {
|
|
228
|
+
const issues = parseIssuesJsonl(beadsDir);
|
|
229
|
+
const dependencies = extractDependencies(issues);
|
|
230
|
+
const graphData = buildGraphData(issues, dependencies);
|
|
231
|
+
const stats = computeStats(issues, dependencies, graphData);
|
|
232
|
+
|
|
233
|
+
return { issues, dependencies, graphData, stats };
|
|
234
|
+
}
|
package/lib/session.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { env } from "./env";
|
|
2
|
+
import { getIronSession, type SessionOptions } from "iron-session";
|
|
3
|
+
import { cookies } from "next/headers";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Session data stored in the encrypted cookie.
|
|
7
|
+
*/
|
|
8
|
+
export interface Session {
|
|
9
|
+
did?: string;
|
|
10
|
+
handle?: string;
|
|
11
|
+
displayName?: string;
|
|
12
|
+
avatar?: string;
|
|
13
|
+
returnTo?: string;
|
|
14
|
+
// OAuth session data (serialized) - persisted across serverless invocations
|
|
15
|
+
oauthSession?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
19
|
+
|
|
20
|
+
const sessionOptions: SessionOptions = {
|
|
21
|
+
cookieName: "beads_map_sid",
|
|
22
|
+
password: env.COOKIE_SECRET,
|
|
23
|
+
cookieOptions: {
|
|
24
|
+
secure: isProduction,
|
|
25
|
+
maxAge: 60 * 60 * 24 * 30, // 30 days
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the current user's session from their encrypted cookie.
|
|
31
|
+
*/
|
|
32
|
+
export async function getSession(): Promise<Session> {
|
|
33
|
+
const cookieStore = await cookies();
|
|
34
|
+
const session = await getIronSession<Session>(cookieStore, sessionOptions);
|
|
35
|
+
return session;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get the raw iron-session object for direct manipulation (save/destroy).
|
|
40
|
+
*/
|
|
41
|
+
export async function getRawSession() {
|
|
42
|
+
const cookieStore = await cookies();
|
|
43
|
+
return await getIronSession<Session>(cookieStore, sessionOptions);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Clear the current user's session.
|
|
48
|
+
*/
|
|
49
|
+
export async function clearSession(): Promise<void> {
|
|
50
|
+
const session = await getRawSession();
|
|
51
|
+
session.destroy();
|
|
52
|
+
}
|
package/lib/settings.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// User settings — persisted in localStorage
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
export interface BeadsMapSettings {
|
|
6
|
+
elevenLabsApiKey?: string;
|
|
7
|
+
elevenLabsVoiceId: string;
|
|
8
|
+
elevenLabsModel: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const STORAGE_KEY = "heartbeads-settings";
|
|
12
|
+
|
|
13
|
+
const DEFAULTS: BeadsMapSettings = {
|
|
14
|
+
elevenLabsVoiceId: "UgBBYS2sOqTuMpoF3BR0", // Mark - Natural Conversations
|
|
15
|
+
elevenLabsModel: "eleven_flash_v2_5",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Read settings from localStorage, merged with defaults */
|
|
19
|
+
export function getSettings(): BeadsMapSettings {
|
|
20
|
+
if (typeof window === "undefined") return { ...DEFAULTS };
|
|
21
|
+
try {
|
|
22
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
23
|
+
if (!raw) return { ...DEFAULTS };
|
|
24
|
+
return { ...DEFAULTS, ...JSON.parse(raw) };
|
|
25
|
+
} catch {
|
|
26
|
+
return { ...DEFAULTS };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Write settings to localStorage */
|
|
31
|
+
export function saveSettings(settings: Partial<BeadsMapSettings>): void {
|
|
32
|
+
if (typeof window === "undefined") return;
|
|
33
|
+
const current = getSettings();
|
|
34
|
+
const merged = { ...current, ...settings };
|
|
35
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Check if ElevenLabs API key is configured */
|
|
39
|
+
export function hasApiKey(): boolean {
|
|
40
|
+
const s = getSettings();
|
|
41
|
+
return !!s.elevenLabsApiKey && s.elevenLabsApiKey.trim().length > 0;
|
|
42
|
+
}
|