heyiam 0.3.0 → 0.3.2
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/dist/auth.js +29 -3
- package/dist/db.js +1 -1
- package/dist/export.js +84 -2
- package/dist/github.js +381 -0
- package/dist/parsers/index.js +22 -3
- package/dist/public/assets/index-Coilyhtr.css +1 -0
- package/dist/public/assets/index-D0noVMFu.js +44 -0
- package/dist/public/index.html +2 -2
- package/dist/render/templates/aurora/portfolio.liquid +10 -22
- package/dist/render/templates/aurora/project.liquid +1 -1
- package/dist/render/templates/aurora/styles.css +6 -0
- package/dist/render/templates/bauhaus/portfolio.liquid +9 -19
- package/dist/render/templates/bauhaus/styles.css +4 -0
- package/dist/render/templates/blueprint/portfolio.liquid +10 -24
- package/dist/render/templates/blueprint/styles.css +4 -0
- package/dist/render/templates/canvas/portfolio.liquid +17 -29
- package/dist/render/templates/canvas/styles.css +4 -0
- package/dist/render/templates/carbon/portfolio.liquid +9 -19
- package/dist/render/templates/carbon/styles.css +6 -0
- package/dist/render/templates/chalk/portfolio.liquid +9 -19
- package/dist/render/templates/chalk/styles.css +4 -0
- package/dist/render/templates/circuit/portfolio.liquid +10 -20
- package/dist/render/templates/circuit/project.liquid +1 -1
- package/dist/render/templates/circuit/styles.css +6 -0
- package/dist/render/templates/cosmos/portfolio.liquid +10 -20
- package/dist/render/templates/cosmos/project.liquid +1 -1
- package/dist/render/templates/cosmos/styles.css +6 -0
- package/dist/render/templates/daylight/portfolio.liquid +10 -20
- package/dist/render/templates/daylight/project.liquid +1 -1
- package/dist/render/templates/daylight/styles.css +4 -0
- package/dist/render/templates/editorial/portfolio.liquid +11 -27
- package/dist/render/templates/editorial/styles.css +4 -0
- package/dist/render/templates/ember/portfolio.liquid +11 -23
- package/dist/render/templates/ember/project.liquid +1 -1
- package/dist/render/templates/ember/styles.css +6 -0
- package/dist/render/templates/glacier/portfolio.liquid +10 -20
- package/dist/render/templates/glacier/project.liquid +1 -1
- package/dist/render/templates/glacier/styles.css +4 -0
- package/dist/render/templates/grid/portfolio.liquid +9 -19
- package/dist/render/templates/grid/styles.css +4 -0
- package/dist/render/templates/kinetic/portfolio.liquid +10 -22
- package/dist/render/templates/kinetic/project.liquid +1 -1
- package/dist/render/templates/kinetic/styles.css +4 -0
- package/dist/render/templates/meridian/portfolio.liquid +11 -23
- package/dist/render/templates/meridian/styles.css +6 -0
- package/dist/render/templates/minimal/portfolio.liquid +10 -10
- package/dist/render/templates/minimal/styles.css +4 -0
- package/dist/render/templates/mono/portfolio.liquid +9 -19
- package/dist/render/templates/mono/styles.css +6 -0
- package/dist/render/templates/neon/portfolio.liquid +10 -20
- package/dist/render/templates/neon/project.liquid +1 -1
- package/dist/render/templates/neon/styles.css +6 -0
- package/dist/render/templates/noir/portfolio.liquid +5 -5
- package/dist/render/templates/noir/styles.css +6 -0
- package/dist/render/templates/obsidian/portfolio.liquid +9 -19
- package/dist/render/templates/obsidian/styles.css +6 -0
- package/dist/render/templates/paper/portfolio.liquid +9 -19
- package/dist/render/templates/paper/styles.css +4 -0
- package/dist/render/templates/parallax/portfolio.liquid +9 -19
- package/dist/render/templates/parallax/styles.css +6 -0
- package/dist/render/templates/parchment/portfolio.liquid +9 -19
- package/dist/render/templates/parchment/styles.css +4 -0
- package/dist/render/templates/radar/portfolio.liquid +9 -19
- package/dist/render/templates/radar/styles.css +6 -0
- package/dist/render/templates/showcase/portfolio.liquid +9 -19
- package/dist/render/templates/showcase/styles.css +5 -0
- package/dist/render/templates/signal/portfolio.liquid +9 -19
- package/dist/render/templates/signal/styles.css +6 -0
- package/dist/render/templates/strata/portfolio.liquid +10 -22
- package/dist/render/templates/strata/styles.css +4 -0
- package/dist/render/templates/terminal/portfolio.liquid +10 -26
- package/dist/render/templates/terminal/styles.css +5 -0
- package/dist/render/templates/verdant/portfolio.liquid +11 -23
- package/dist/render/templates/verdant/project.liquid +1 -1
- package/dist/render/templates/verdant/styles.css +4 -0
- package/dist/render/templates/zen/portfolio.liquid +10 -22
- package/dist/render/templates/zen/styles.css +4 -0
- package/dist/routes/auth.js +7 -3
- package/dist/routes/context.js +2 -0
- package/dist/routes/delete.js +195 -0
- package/dist/routes/enhance.js +40 -0
- package/dist/routes/github.js +254 -0
- package/dist/routes/index.js +2 -0
- package/dist/routes/portfolio-render-data.js +160 -0
- package/dist/routes/preview.js +85 -10
- package/dist/routes/projects.js +50 -5
- package/dist/routes/publish.js +306 -15
- package/dist/routes/settings.js +102 -2
- package/dist/search.js +6 -0
- package/dist/server.js +3 -1
- package/dist/settings.js +95 -0
- package/package.json +2 -1
- package/dist/public/assets/index-BZ65TU_Y.js +0 -40
- package/dist/public/assets/index-CqCaW2cb.css +0 -1
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Express router for the GitHub Pages publish target (Phase 5).
|
|
2
|
+
//
|
|
3
|
+
// All routes are authenticated via the existing `getAuthToken` Bearer
|
|
4
|
+
// pattern — the GitHub access token itself lives in the OS keychain via
|
|
5
|
+
// `cli/src/github.ts` and is NEVER returned to the client.
|
|
6
|
+
import { Router } from 'express';
|
|
7
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { getAuthToken } from '../auth.js';
|
|
11
|
+
import { requestDeviceCode, pollForTokenOnce, storeToken, loadToken, deleteToken, listRepos, getAuthenticatedUser, pushSiteToRepo, enablePages, pollPagesBuild, GitHubError, } from '../github.js';
|
|
12
|
+
import { getDefaultTemplate, getPortfolioProfile, hashPortfolioProfile, updatePortfolioPublishTarget, } from '../settings.js';
|
|
13
|
+
import { generatePortfolioSite } from '../export.js';
|
|
14
|
+
import { buildPortfolioRenderData } from './portfolio-render-data.js';
|
|
15
|
+
import { buildProjectDetail } from './context.js';
|
|
16
|
+
import { invalidatePortfolioPreviewCache } from './preview.js';
|
|
17
|
+
const GITHUB_TARGET = 'github';
|
|
18
|
+
function authError(res) {
|
|
19
|
+
res.status(401).json({
|
|
20
|
+
error: { code: 'UNAUTHENTICATED', message: 'Authentication required' },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function handleGitHubError(res, err, fallbackCode) {
|
|
24
|
+
if (err instanceof GitHubError) {
|
|
25
|
+
const status = err.code === 'KEYCHAIN_UNAVAILABLE' ? 503
|
|
26
|
+
: err.code === 'NO_TOKEN' ? 401
|
|
27
|
+
: err.status && err.status >= 400 && err.status < 600 ? err.status
|
|
28
|
+
: 500;
|
|
29
|
+
res.status(status).json({
|
|
30
|
+
error: { code: err.code, message: err.message },
|
|
31
|
+
});
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
res.status(500).json({
|
|
35
|
+
error: { code: fallbackCode, message: err.message },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export function createGithubRouter(ctx) {
|
|
39
|
+
const router = Router();
|
|
40
|
+
// ── Device-flow kickoff ────────────────────────────────────────────
|
|
41
|
+
router.post('/api/github/device-code', async (_req, res) => {
|
|
42
|
+
if (!getAuthToken()) {
|
|
43
|
+
authError(res);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
// Scope rationale: public_repo is the minimum needed to push portfolio
|
|
48
|
+
// sites to user-owned public repos and enable Pages on them. We do NOT
|
|
49
|
+
// request the broader 'repo' scope — portfolios are inherently public,
|
|
50
|
+
// and narrowing the scope shrinks the blast radius if a user is ever
|
|
51
|
+
// phished using this app's client_id (RFC 8628 client_ids are public).
|
|
52
|
+
// If users ever request private-repo support, expand to ['repo'] then.
|
|
53
|
+
const body = await requestDeviceCode(['public_repo']);
|
|
54
|
+
res.json({
|
|
55
|
+
device_code: body.device_code,
|
|
56
|
+
user_code: body.user_code,
|
|
57
|
+
verification_uri: body.verification_uri,
|
|
58
|
+
expires_in: body.expires_in,
|
|
59
|
+
interval: body.interval,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
handleGitHubError(res, err, 'DEVICE_CODE_FAILED');
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
// ── Device-flow single-poll + keychain write ─────────────────────
|
|
67
|
+
// Returns immediately in ALL cases — no blocking loop.
|
|
68
|
+
// The frontend is responsible for calling this on an interval.
|
|
69
|
+
router.post('/api/github/poll-token', async (req, res) => {
|
|
70
|
+
if (!getAuthToken()) {
|
|
71
|
+
authError(res);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const { device_code: deviceCode } = (req.body ?? {});
|
|
75
|
+
if (typeof deviceCode !== 'string' || deviceCode.length === 0) {
|
|
76
|
+
res.status(400).json({
|
|
77
|
+
error: { code: 'INVALID_DEVICE_CODE', message: 'device_code is required' },
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const result = await pollForTokenOnce(deviceCode);
|
|
83
|
+
if (result.status === 'success') {
|
|
84
|
+
await storeToken(result.access_token);
|
|
85
|
+
const user = await getAuthenticatedUser(result.access_token);
|
|
86
|
+
res.json({
|
|
87
|
+
status: 'success',
|
|
88
|
+
account: { login: user.login, name: user.name, avatarUrl: user.avatar_url },
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// pending / expired / denied — return status directly, no token in response.
|
|
93
|
+
res.json({ status: result.status });
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
handleGitHubError(res, err, 'TOKEN_POLL_FAILED');
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
// ── Connected account (GET + DELETE) ──────────────────────────────
|
|
100
|
+
router.get('/api/github/account', async (_req, res) => {
|
|
101
|
+
if (!getAuthToken()) {
|
|
102
|
+
authError(res);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const token = await loadToken();
|
|
107
|
+
if (!token) {
|
|
108
|
+
res.json({ account: null });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const user = await getAuthenticatedUser(token);
|
|
112
|
+
res.json({
|
|
113
|
+
account: { login: user.login, name: user.name, avatarUrl: user.avatar_url },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
handleGitHubError(res, err, 'GITHUB_API_FAILED');
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
router.delete('/api/github/account', async (_req, res) => {
|
|
121
|
+
if (!getAuthToken()) {
|
|
122
|
+
authError(res);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
await deleteToken();
|
|
127
|
+
res.json({ ok: true });
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
handleGitHubError(res, err, 'KEYCHAIN_UNAVAILABLE');
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
// ── Repo list ─────────────────────────────────────────────────────
|
|
134
|
+
router.get('/api/github/repos', async (_req, res) => {
|
|
135
|
+
if (!getAuthToken()) {
|
|
136
|
+
authError(res);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const token = await loadToken();
|
|
141
|
+
if (!token) {
|
|
142
|
+
res.status(401).json({
|
|
143
|
+
error: { code: 'NO_GITHUB_TOKEN', message: 'GitHub account not connected' },
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const repos = await listRepos(token);
|
|
148
|
+
res.json({ repos });
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
handleGitHubError(res, err, 'GITHUB_API_FAILED');
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
// ── Publish: render portfolio -> push to repo -> enable Pages ─────
|
|
155
|
+
router.post('/api/github/publish', async (req, res) => {
|
|
156
|
+
const auth = getAuthToken();
|
|
157
|
+
if (!auth) {
|
|
158
|
+
authError(res);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const { owner, repo, branch } = (req.body ?? {});
|
|
162
|
+
if (typeof owner !== 'string' || owner.length === 0
|
|
163
|
+
|| typeof repo !== 'string' || repo.length === 0) {
|
|
164
|
+
res.status(400).json({
|
|
165
|
+
error: { code: 'INVALID_TARGET', message: 'owner and repo are required strings' },
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const branchName = typeof branch === 'string' && branch.length > 0 ? branch : 'gh-pages';
|
|
170
|
+
let tempDir = null;
|
|
171
|
+
try {
|
|
172
|
+
const token = await loadToken();
|
|
173
|
+
if (!token) {
|
|
174
|
+
res.status(401).json({
|
|
175
|
+
error: { code: 'NO_GITHUB_TOKEN', message: 'GitHub account not connected' },
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// Build portfolio site into a temp directory.
|
|
180
|
+
const templateName = getDefaultTemplate() || 'editorial';
|
|
181
|
+
const { renderData } = await buildPortfolioRenderData(ctx, auth);
|
|
182
|
+
const rawProjects = await ctx.getProjects();
|
|
183
|
+
const projectInputs = [];
|
|
184
|
+
for (const rawProj of rawProjects) {
|
|
185
|
+
try {
|
|
186
|
+
const detail = buildProjectDetail(ctx.db, rawProj);
|
|
187
|
+
const cache = detail.enhanceCache ?? {
|
|
188
|
+
fingerprint: 'gh-publish',
|
|
189
|
+
enhancedAt: new Date().toISOString(),
|
|
190
|
+
selectedSessionIds: detail.sessions.map((s) => s.id),
|
|
191
|
+
result: { narrative: '', arc: [], skills: [], timeline: [], questions: [] },
|
|
192
|
+
};
|
|
193
|
+
const proj = detail.project;
|
|
194
|
+
projectInputs.push({
|
|
195
|
+
dirName: rawProj.dirName,
|
|
196
|
+
cache,
|
|
197
|
+
sessions: detail.sessions,
|
|
198
|
+
opts: {
|
|
199
|
+
totalFilesChanged: proj.totalFiles,
|
|
200
|
+
totalAgentDurationMinutes: proj.totalAgentDuration,
|
|
201
|
+
totalInputTokens: proj.totalInputTokens,
|
|
202
|
+
totalOutputTokens: proj.totalOutputTokens,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (projErr) {
|
|
207
|
+
console.warn(`[github-publish] skipping project ${rawProj.dirName}:`, projErr.message);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
tempDir = mkdtempSync(join(tmpdir(), 'heyiam-gh-'));
|
|
211
|
+
await generatePortfolioSite(renderData, projectInputs, tempDir, templateName);
|
|
212
|
+
await pushSiteToRepo({
|
|
213
|
+
token, owner, repo, branch: branchName, sourceDir: tempDir,
|
|
214
|
+
});
|
|
215
|
+
await enablePages({ token, owner, repo, branch: branchName });
|
|
216
|
+
await pollPagesBuild({ token, owner, repo });
|
|
217
|
+
const url = `https://${owner}.github.io/${repo}/`;
|
|
218
|
+
const publishedAt = new Date().toISOString();
|
|
219
|
+
const profile = getPortfolioProfile();
|
|
220
|
+
const hash = hashPortfolioProfile(profile);
|
|
221
|
+
updatePortfolioPublishTarget(GITHUB_TARGET, {
|
|
222
|
+
lastPublishedAt: publishedAt,
|
|
223
|
+
lastPublishedProfileHash: hash,
|
|
224
|
+
lastPublishedProfile: profile,
|
|
225
|
+
config: { owner, repo, branch: branchName },
|
|
226
|
+
url,
|
|
227
|
+
lastError: undefined,
|
|
228
|
+
lastErrorAt: undefined,
|
|
229
|
+
});
|
|
230
|
+
invalidatePortfolioPreviewCache();
|
|
231
|
+
res.json({ ok: true, url, publishedAt, hash });
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
235
|
+
try {
|
|
236
|
+
updatePortfolioPublishTarget(GITHUB_TARGET, {
|
|
237
|
+
lastError: message,
|
|
238
|
+
lastErrorAt: new Date().toISOString(),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch { /* do not mask original error */ }
|
|
242
|
+
handleGitHubError(res, err, 'GITHUB_PUBLISH_FAILED');
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
if (tempDir) {
|
|
246
|
+
try {
|
|
247
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
catch { /* best effort */ }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
return router;
|
|
254
|
+
}
|
package/dist/routes/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export { createRouteContext } from './context.js';
|
|
|
2
2
|
export { createProjectsRouter } from './projects.js';
|
|
3
3
|
export { createEnhanceRouter } from './enhance.js';
|
|
4
4
|
export { createPublishRouter } from './publish.js';
|
|
5
|
+
export { createDeleteRouter } from './delete.js';
|
|
5
6
|
export { createSearchRouter } from './search.js';
|
|
6
7
|
export { createSessionsRouter } from './sessions.js';
|
|
7
8
|
export { createArchiveRouter } from './archive.js';
|
|
@@ -10,3 +11,4 @@ export { createSettingsRouter } from './settings.js';
|
|
|
10
11
|
export { createExportRouter } from './export.js';
|
|
11
12
|
export { createPreviewRouter } from './preview.js';
|
|
12
13
|
export { createDashboardRouter } from './dashboard.js';
|
|
14
|
+
export { createGithubRouter } from './github.js';
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { getPortfolioProfile, loadProjectEnhanceResult } from '../settings.js';
|
|
2
|
+
import { getSessionsByProject, getAllProjectStats } from '../db.js';
|
|
3
|
+
import { displayNameFromDir } from '../sync.js';
|
|
4
|
+
import { toSlug } from '../format-utils.js';
|
|
5
|
+
/**
|
|
6
|
+
* Assemble the `PortfolioRenderData` payload from local project data.
|
|
7
|
+
*
|
|
8
|
+
* Shared between:
|
|
9
|
+
* - `POST /api/portfolio/upload` (Phase 2, hosted heyi.am publish)
|
|
10
|
+
* - `POST /api/portfolio/export` (Phase 4, static folder export)
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the preview route's assembly logic. Projects that fail to load are
|
|
13
|
+
* silently skipped — the portfolio still publishes with whatever succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export async function buildPortfolioRenderData(ctx, auth) {
|
|
16
|
+
const profile = getPortfolioProfile();
|
|
17
|
+
const allRawProjects = await ctx.getProjects();
|
|
18
|
+
// Build a recency map from DB stats so the default-when-empty branch of
|
|
19
|
+
// applyPortfolioProjectFilter can rank projects by "user's most recent
|
|
20
|
+
// work" (latest non-subagent session start_time).
|
|
21
|
+
const recencyByDir = new Map();
|
|
22
|
+
try {
|
|
23
|
+
for (const s of getAllProjectStats(ctx.db)) {
|
|
24
|
+
recencyByDir.set(s.projectDir, s.latestDate || '');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch { /* DB may be empty on first run; default branch will fall back */ }
|
|
28
|
+
const rawProjects = applyPortfolioProjectFilter(allRawProjects, profile.projectsOnPortfolio, { getRecency: (p) => recencyByDir.get(p.dirName) });
|
|
29
|
+
const portfolioProjects = [];
|
|
30
|
+
const projectCaches = new Map();
|
|
31
|
+
let totalDuration = 0;
|
|
32
|
+
let totalAgentDuration = 0;
|
|
33
|
+
let totalLoc = 0;
|
|
34
|
+
let totalSessions = 0;
|
|
35
|
+
for (const rawProj of rawProjects) {
|
|
36
|
+
try {
|
|
37
|
+
const proj = await ctx.getProjectWithStats(rawProj);
|
|
38
|
+
const cached = loadProjectEnhanceResult(rawProj.dirName);
|
|
39
|
+
const projDuration = proj.totalDuration || 0;
|
|
40
|
+
const projAgentDuration = proj.totalAgentDuration || 0;
|
|
41
|
+
const projLoc = proj.totalLoc || 0;
|
|
42
|
+
const projSessions = proj.sessionCount || 0;
|
|
43
|
+
totalDuration += projDuration;
|
|
44
|
+
totalAgentDuration += projAgentDuration;
|
|
45
|
+
totalLoc += projLoc;
|
|
46
|
+
totalSessions += projSessions;
|
|
47
|
+
const title = cached?.title
|
|
48
|
+
|| proj.name || displayNameFromDir(rawProj.dirName);
|
|
49
|
+
const dbSessions = getSessionsByProject(ctx.db, rawProj.dirName);
|
|
50
|
+
const sessionActivity = dbSessions
|
|
51
|
+
.filter((s) => !s.is_subagent)
|
|
52
|
+
.map((s) => ({
|
|
53
|
+
date: s.start_time || '',
|
|
54
|
+
loc: (s.loc_added || 0) + (s.loc_removed || 0),
|
|
55
|
+
durationMinutes: s.duration_minutes || 0,
|
|
56
|
+
}));
|
|
57
|
+
portfolioProjects.push({
|
|
58
|
+
slug: toSlug(title),
|
|
59
|
+
title,
|
|
60
|
+
narrative: cached?.result?.narrative || proj.description || '',
|
|
61
|
+
totalSessions: projSessions,
|
|
62
|
+
totalLoc: projLoc,
|
|
63
|
+
totalDurationMinutes: projDuration,
|
|
64
|
+
totalAgentDurationMinutes: projAgentDuration,
|
|
65
|
+
totalFilesChanged: proj.totalFiles || 0,
|
|
66
|
+
skills: cached?.result?.skills || proj.skills || [],
|
|
67
|
+
publishedCount: 0,
|
|
68
|
+
sessions: sessionActivity,
|
|
69
|
+
});
|
|
70
|
+
projectCaches.set(rawProj.dirName, { dirName: rawProj.dirName, cache: cached });
|
|
71
|
+
}
|
|
72
|
+
catch { /* skip projects that fail */ }
|
|
73
|
+
}
|
|
74
|
+
const renderData = {
|
|
75
|
+
user: {
|
|
76
|
+
username: auth.username,
|
|
77
|
+
accent: profile.accent || '#084471',
|
|
78
|
+
displayName: profile.displayName || '',
|
|
79
|
+
bio: profile.bio || '',
|
|
80
|
+
location: profile.location || '',
|
|
81
|
+
status: 'active',
|
|
82
|
+
email: profile.email,
|
|
83
|
+
phone: profile.phone,
|
|
84
|
+
photoUrl: profile.photoBase64 || undefined,
|
|
85
|
+
linkedinUrl: profile.linkedinUrl,
|
|
86
|
+
githubUrl: profile.githubUrl,
|
|
87
|
+
twitterHandle: profile.twitterHandle,
|
|
88
|
+
websiteUrl: profile.websiteUrl,
|
|
89
|
+
resumeUrl: profile.resumeBase64 ? '#' : undefined,
|
|
90
|
+
},
|
|
91
|
+
projects: portfolioProjects,
|
|
92
|
+
totalDurationMinutes: totalDuration,
|
|
93
|
+
totalAgentDurationMinutes: totalAgentDuration || undefined,
|
|
94
|
+
totalLoc,
|
|
95
|
+
totalSessions,
|
|
96
|
+
};
|
|
97
|
+
return { renderData, projectCaches, filteredProjects: rawProjects };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Apply the user-curated `projectsOnPortfolio` list to a raw project list.
|
|
101
|
+
*
|
|
102
|
+
* Pure function — no I/O. Lives here so it can be unit-tested without
|
|
103
|
+
* standing up a RouteContext.
|
|
104
|
+
*
|
|
105
|
+
* Semantics:
|
|
106
|
+
* - Empty/missing list: default to the `defaultLimit` (3) most recently
|
|
107
|
+
* active projects, ranked by `getRecency` (descending). If fewer than
|
|
108
|
+
* `defaultLimit` projects exist, return all of them. If `getRecency` is
|
|
109
|
+
* not provided, falls back to reverse-alphabetic order on `dirName`
|
|
110
|
+
* (an unfortunate fallback — callers should provide a real recency
|
|
111
|
+
* accessor).
|
|
112
|
+
* - Non-empty list:
|
|
113
|
+
* - Filter out projects whose entry has `included === false`.
|
|
114
|
+
* - Sort the remaining matched projects by `order` ascending.
|
|
115
|
+
* - Projects present in the source list but missing from the curated
|
|
116
|
+
* list (e.g. newly imported since the user last edited) are appended
|
|
117
|
+
* at the end in source order, treated as `included: true`.
|
|
118
|
+
*
|
|
119
|
+
* NOTE: The default-when-empty branch is duplicated in
|
|
120
|
+
* `cli/app/src/components/PortfolioWorkspace.tsx` (HydratePortfolioStore)
|
|
121
|
+
* because the frontend bundler does not reach into `cli/src/`. Keep the
|
|
122
|
+
* two implementations in sync.
|
|
123
|
+
*/
|
|
124
|
+
export const PORTFOLIO_DEFAULT_PROJECT_LIMIT = 3;
|
|
125
|
+
export function applyPortfolioProjectFilter(projects, curated, options = {}) {
|
|
126
|
+
if (!curated || curated.length === 0) {
|
|
127
|
+
const limit = options.defaultLimit ?? PORTFOLIO_DEFAULT_PROJECT_LIMIT;
|
|
128
|
+
if (projects.length <= limit)
|
|
129
|
+
return projects;
|
|
130
|
+
const getRecency = options.getRecency;
|
|
131
|
+
const ranked = projects.slice().sort((a, b) => {
|
|
132
|
+
if (getRecency) {
|
|
133
|
+
const ra = getRecency(a) || '';
|
|
134
|
+
const rb = getRecency(b) || '';
|
|
135
|
+
if (ra !== rb)
|
|
136
|
+
return rb.localeCompare(ra); // descending
|
|
137
|
+
}
|
|
138
|
+
// Fallback / tiebreaker: reverse-alphabetic on dirName.
|
|
139
|
+
return b.dirName.localeCompare(a.dirName);
|
|
140
|
+
});
|
|
141
|
+
return ranked.slice(0, limit);
|
|
142
|
+
}
|
|
143
|
+
const byId = new Map();
|
|
144
|
+
for (const entry of curated)
|
|
145
|
+
byId.set(entry.projectId, entry);
|
|
146
|
+
const matched = [];
|
|
147
|
+
const unmatched = [];
|
|
148
|
+
for (const proj of projects) {
|
|
149
|
+
const entry = byId.get(proj.dirName);
|
|
150
|
+
if (!entry) {
|
|
151
|
+
unmatched.push(proj);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (entry.included === false)
|
|
155
|
+
continue;
|
|
156
|
+
matched.push({ proj, order: entry.order });
|
|
157
|
+
}
|
|
158
|
+
matched.sort((a, b) => a.order - b.order);
|
|
159
|
+
return [...matched.map((m) => m.proj), ...unmatched];
|
|
160
|
+
}
|
package/dist/routes/preview.js
CHANGED
|
@@ -12,7 +12,8 @@ import { buildSessionRenderData, buildProjectRenderData } from '../render/build-
|
|
|
12
12
|
import { buildAgentSummary } from './context.js';
|
|
13
13
|
import { displayNameFromDir } from '../sync.js';
|
|
14
14
|
import { toSlug } from '../format-utils.js';
|
|
15
|
-
import { getSessionsByProject } from '../db.js';
|
|
15
|
+
import { getSessionsByProject, getAllProjectStats } from '../db.js';
|
|
16
|
+
import { applyPortfolioProjectFilter } from './portfolio-render-data.js';
|
|
16
17
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
/**
|
|
18
19
|
* In-memory cache for expensive buildProjectPreviewData calls.
|
|
@@ -25,6 +26,36 @@ const PREVIEW_CACHE_TTL = 30_000;
|
|
|
25
26
|
/** Clear the preview data cache. Exported for testing. */
|
|
26
27
|
export function clearPreviewCache() {
|
|
27
28
|
previewDataCache.clear();
|
|
29
|
+
portfolioPreviewCache.clear();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* In-memory cache for the rendered /preview/portfolio HTML response.
|
|
33
|
+
* Keyed by the literal string 'portfolio' (CLI is single-user). Cached value
|
|
34
|
+
* is the full HTML body that the route would otherwise re-render on every
|
|
35
|
+
* iframe reload from the React PreviewPane.
|
|
36
|
+
*
|
|
37
|
+
* TTL: 30 seconds. Invalidated explicitly on profile save, portfolio
|
|
38
|
+
* publish, and project mutations via invalidatePortfolioPreviewCache().
|
|
39
|
+
*/
|
|
40
|
+
// Cache key is `portfolio:<templateName>` so previewing the user's data
|
|
41
|
+
// through alternate templates (template browser "My data" toggle) does not
|
|
42
|
+
// poison the default-template entry.
|
|
43
|
+
const portfolioPreviewCache = new Map();
|
|
44
|
+
const PORTFOLIO_PREVIEW_CACHE_TTL = 30_000;
|
|
45
|
+
function portfolioCacheKey(templateName) {
|
|
46
|
+
return `portfolio:${templateName}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Invalidate the cached /preview/portfolio HTML. Call this from any route
|
|
50
|
+
* that mutates state visible in the portfolio render: profile save,
|
|
51
|
+
* portfolio publish, project enhance-save, screenshot capture/delete, etc.
|
|
52
|
+
*/
|
|
53
|
+
export function invalidatePortfolioPreviewCache() {
|
|
54
|
+
portfolioPreviewCache.clear();
|
|
55
|
+
}
|
|
56
|
+
/** Test helper: read current cache entry without mutating. */
|
|
57
|
+
export function _getPortfolioPreviewCacheEntry(templateName = 'editorial') {
|
|
58
|
+
return portfolioPreviewCache.get(portfolioCacheKey(templateName));
|
|
28
59
|
}
|
|
29
60
|
/**
|
|
30
61
|
* Build project render data and enhance result from a project parameter.
|
|
@@ -132,7 +163,7 @@ async function buildProjectPreviewData(ctx, projectParam, queryOverrides) {
|
|
|
132
163
|
const projAny = proj;
|
|
133
164
|
const rawName = projAny.name || displayNameFromDir(projAny.dirName);
|
|
134
165
|
const title = cached?.title || rawName;
|
|
135
|
-
const slug = toSlug(
|
|
166
|
+
const slug = toSlug(title);
|
|
136
167
|
// Metadata from enhance cache (set in sidebar), with query overrides taking priority
|
|
137
168
|
const cachedAny = cached;
|
|
138
169
|
const metaRepoUrl = queryOverrides?.repoUrl || cachedAny?.repoUrl;
|
|
@@ -183,7 +214,21 @@ export function createPreviewRouter(ctx) {
|
|
|
183
214
|
res.status(404).send('Template not found');
|
|
184
215
|
return;
|
|
185
216
|
}
|
|
186
|
-
|
|
217
|
+
// Allowlist page to prevent path traversal via ?page=../../etc/passwd
|
|
218
|
+
// in the path.resolve() call below.
|
|
219
|
+
const VALID_PREVIEW_PAGES = ['portfolio', 'project', 'session'];
|
|
220
|
+
const rawPage = req.query.page === undefined ? 'project' : req.query.page;
|
|
221
|
+
if (typeof rawPage !== 'string' ||
|
|
222
|
+
!VALID_PREVIEW_PAGES.includes(rawPage)) {
|
|
223
|
+
res.status(400).json({
|
|
224
|
+
error: {
|
|
225
|
+
code: 'INVALID_PAGE',
|
|
226
|
+
message: 'page must be one of: portfolio, project, session',
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const page = rawPage;
|
|
187
232
|
// 1. Try static mockup HTML (instant, from docs/mockups/)
|
|
188
233
|
const mockupPath = path.resolve(__dirname, '..', '..', '..', 'docs', 'mockups', name, `${page}.html`);
|
|
189
234
|
if (existsSync(mockupPath)) {
|
|
@@ -283,6 +328,8 @@ body { overflow: auto !important; min-height: auto !important; }
|
|
|
283
328
|
const { unlinkSync } = require('node:fs');
|
|
284
329
|
unlinkSync(filePath);
|
|
285
330
|
}
|
|
331
|
+
// Portfolio listing shows project screenshots — bust the cache.
|
|
332
|
+
invalidatePortfolioPreviewCache();
|
|
286
333
|
res.json({ ok: true });
|
|
287
334
|
}
|
|
288
335
|
catch {
|
|
@@ -465,14 +512,37 @@ body { overflow: auto !important; min-height: auto !important; }
|
|
|
465
512
|
res.status(500).json({ error: 'Session render failed' });
|
|
466
513
|
}
|
|
467
514
|
});
|
|
468
|
-
// Portfolio preview -- serves full standalone HTML page with real user data
|
|
469
|
-
|
|
515
|
+
// Portfolio preview -- serves full standalone HTML page with real user data.
|
|
516
|
+
// Cached for PORTFOLIO_PREVIEW_CACHE_TTL ms; the React PreviewPane reloads
|
|
517
|
+
// the iframe on every keystroke (post-debounce), and re-rendering every
|
|
518
|
+
// project's Liquid template on each hit is expensive.
|
|
519
|
+
router.get('/preview/portfolio', async (req, res) => {
|
|
520
|
+
// Optional template override (?template=:name) lets the template browser
|
|
521
|
+
// preview real user data through any template without changing the saved
|
|
522
|
+
// default. Falls back to the user's default when missing or invalid.
|
|
523
|
+
const templateOverride = typeof req.query.template === 'string' ? req.query.template : undefined;
|
|
524
|
+
const templateName = (templateOverride && isValidTemplate(templateOverride))
|
|
525
|
+
? templateOverride
|
|
526
|
+
: (getDefaultTemplate() || 'editorial');
|
|
527
|
+
const cacheKey = portfolioCacheKey(templateName);
|
|
528
|
+
const cached = portfolioPreviewCache.get(cacheKey);
|
|
529
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
530
|
+
res.type('html').send(cached.html);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
470
533
|
try {
|
|
471
534
|
const profile = getPortfolioProfile();
|
|
472
535
|
const auth = getAuthToken();
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const
|
|
536
|
+
// Build portfolio projects from real project data, filtered by user curation
|
|
537
|
+
const allRawProjects = await ctx.getProjects();
|
|
538
|
+
const recencyByDir = new Map();
|
|
539
|
+
try {
|
|
540
|
+
for (const s of getAllProjectStats(ctx.db)) {
|
|
541
|
+
recencyByDir.set(s.projectDir, s.latestDate || '');
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
catch { /* DB may be empty on first run */ }
|
|
545
|
+
const rawProjects = applyPortfolioProjectFilter(allRawProjects, profile.projectsOnPortfolio, { getRecency: (p) => recencyByDir.get(p.dirName) });
|
|
476
546
|
const portfolioProjects = [];
|
|
477
547
|
let totalDuration = 0;
|
|
478
548
|
let totalAgentDuration = 0;
|
|
@@ -502,7 +572,7 @@ body { overflow: auto !important; min-height: auto !important; }
|
|
|
502
572
|
durationMinutes: s.duration_minutes || 0,
|
|
503
573
|
}));
|
|
504
574
|
portfolioProjects.push({
|
|
505
|
-
slug: toSlug(
|
|
575
|
+
slug: toSlug(title),
|
|
506
576
|
title,
|
|
507
577
|
narrative: cached?.result?.narrative || proj.description || '',
|
|
508
578
|
totalSessions: projSessions,
|
|
@@ -543,7 +613,12 @@ body { overflow: auto !important; min-height: auto !important; }
|
|
|
543
613
|
totalSessions,
|
|
544
614
|
};
|
|
545
615
|
const bodyHtml = renderPortfolioHtml(renderData, templateName);
|
|
546
|
-
|
|
616
|
+
const fullHtml = ctx.buildPreviewPage(renderData.user.displayName ? `${renderData.user.displayName}'s Portfolio` : 'Portfolio Preview', bodyHtml, undefined, templateName);
|
|
617
|
+
portfolioPreviewCache.set(cacheKey, {
|
|
618
|
+
html: fullHtml,
|
|
619
|
+
expiresAt: Date.now() + PORTFOLIO_PREVIEW_CACHE_TTL,
|
|
620
|
+
});
|
|
621
|
+
res.type('html').send(fullHtml);
|
|
547
622
|
}
|
|
548
623
|
catch (err) {
|
|
549
624
|
console.error('[portfolio-preview] Error:', err.message);
|
package/dist/routes/projects.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
2
|
import { statSync } from 'node:fs';
|
|
3
|
+
import { loadProjectEnhanceResult, saveProjectEnhanceResult } from '../settings.js';
|
|
3
4
|
import { requireProject, buildSessionList, buildProjectDetail } from './context.js';
|
|
5
|
+
import { invalidatePortfolioPreviewCache } from './preview.js';
|
|
4
6
|
export function createProjectsRouter(ctx) {
|
|
5
7
|
const router = Router();
|
|
6
8
|
router.get('/api/projects', async (_req, res) => {
|
|
@@ -173,12 +175,55 @@ export function createProjectsRouter(ctx) {
|
|
|
173
175
|
res.status(500).json({ error: { code: 'GIT_REMOTE_FAILED', message: err.message } });
|
|
174
176
|
}
|
|
175
177
|
});
|
|
176
|
-
// ── Boundaries
|
|
177
|
-
router.get('/api/projects/:project/boundaries', (
|
|
178
|
-
|
|
178
|
+
// ── Boundaries — manage which sessions belong to a project ───
|
|
179
|
+
router.get('/api/projects/:project/boundaries', async (req, res) => {
|
|
180
|
+
try {
|
|
181
|
+
const project = String(req.params.project);
|
|
182
|
+
const proj = await requireProject(ctx, project, res);
|
|
183
|
+
if (!proj)
|
|
184
|
+
return;
|
|
185
|
+
const cache = loadProjectEnhanceResult(proj.dirName);
|
|
186
|
+
const allSessionIds = proj.sessions
|
|
187
|
+
.filter((s) => !s.isSubagent)
|
|
188
|
+
.map((s) => s.sessionId);
|
|
189
|
+
res.json({
|
|
190
|
+
selectedSessionIds: cache?.selectedSessionIds ?? [],
|
|
191
|
+
allSessionIds,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
res.status(500).json({ error: { code: 'BOUNDARIES_FAILED', message: err.message } });
|
|
196
|
+
}
|
|
179
197
|
});
|
|
180
|
-
router.put('/api/projects/:project/boundaries', (
|
|
181
|
-
|
|
198
|
+
router.put('/api/projects/:project/boundaries', async (req, res) => {
|
|
199
|
+
try {
|
|
200
|
+
const project = String(req.params.project);
|
|
201
|
+
const proj = await requireProject(ctx, project, res);
|
|
202
|
+
if (!proj)
|
|
203
|
+
return;
|
|
204
|
+
const { selectedSessionIds } = req.body;
|
|
205
|
+
if (!Array.isArray(selectedSessionIds) || selectedSessionIds.length === 0) {
|
|
206
|
+
res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'selectedSessionIds must be a non-empty array' } });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const allIds = new Set(proj.sessions.map((s) => s.sessionId));
|
|
210
|
+
const invalid = selectedSessionIds.filter((id) => !allIds.has(id));
|
|
211
|
+
if (invalid.length > 0) {
|
|
212
|
+
res.status(400).json({ error: { code: 'INVALID_SESSION_IDS', message: `Unknown session IDs: ${invalid.join(', ')}` } });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const cache = loadProjectEnhanceResult(proj.dirName);
|
|
216
|
+
if (!cache) {
|
|
217
|
+
res.status(400).json({ error: { code: 'NO_CACHE', message: 'Project must be enhanced before managing sessions' } });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
saveProjectEnhanceResult(proj.dirName, selectedSessionIds, cache.result, undefined, { title: cache.title, repoUrl: cache.repoUrl, projectUrl: cache.projectUrl, screenshotBase64: cache.screenshotBase64 });
|
|
221
|
+
invalidatePortfolioPreviewCache();
|
|
222
|
+
res.json({ ok: true, selectedSessionIds });
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
res.status(500).json({ error: { code: 'BOUNDARIES_FAILED', message: err.message } });
|
|
226
|
+
}
|
|
182
227
|
});
|
|
183
228
|
return router;
|
|
184
229
|
}
|