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
package/dist/routes/publish.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { readFileSync, mkdtempSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
3
5
|
import { randomUUID } from 'node:crypto';
|
|
4
6
|
import { getAuthToken } from '../auth.js';
|
|
5
7
|
import { API_URL, PUBLIC_URL, warnIfNonDefaultApiUrl } from '../config.js';
|
|
6
|
-
import { loadEnhancedData, saveEnhancedData, saveUploadedState, getDefaultTemplate } from '../settings.js';
|
|
8
|
+
import { loadEnhancedData, saveEnhancedData, saveUploadedState, getDefaultTemplate, getPortfolioProfile, hashPortfolioProfile, updatePortfolioPublishTarget, getPortfolioPublishState, isTranscriptIncluded, DEFAULT_PORTFOLIO_TARGET, } from '../settings.js';
|
|
9
|
+
import { generatePortfolioHtmlFragment, generateProjectHtmlFragment, generatePortfolioSite, createZipBuffer } from '../export.js';
|
|
10
|
+
import { buildPortfolioRenderData } from './portfolio-render-data.js';
|
|
11
|
+
import { buildProjectDetail } from './context.js';
|
|
7
12
|
import { captureScreenshot } from '../screenshot.js';
|
|
8
13
|
import { redactSession, redactText, scanTextSync, formatFindings, stripHomePathsInText } from '../redact.js';
|
|
9
14
|
import { renderProjectHtml, renderSessionHtml } from '../render/index.js';
|
|
10
15
|
import { buildSessionRenderData, buildSessionCard, buildProjectRenderData } from '../render/build-render-data.js';
|
|
11
16
|
import { buildAgentSummary } from './context.js';
|
|
17
|
+
import { invalidatePortfolioPreviewCache } from './preview.js';
|
|
12
18
|
import { startSSE } from './sse.js';
|
|
13
19
|
import { displayNameFromDir } from '../sync.js';
|
|
14
20
|
import { toSlug } from '../format-utils.js';
|
|
@@ -58,6 +64,8 @@ export function createPublishRouter(ctx) {
|
|
|
58
64
|
}
|
|
59
65
|
const imageData = readFileSync(screenshotPath);
|
|
60
66
|
const base64 = imageData.toString('base64');
|
|
67
|
+
// Portfolio listing shows project screenshots — bust the cache.
|
|
68
|
+
invalidatePortfolioPreviewCache();
|
|
61
69
|
res.json({ ok: true, preview: `data:image/png;base64,${base64}` });
|
|
62
70
|
}
|
|
63
71
|
catch (err) {
|
|
@@ -214,6 +222,7 @@ export function createPublishRouter(ctx) {
|
|
|
214
222
|
const session = await ctx.loadSession(meta.path, proj.name, sessionId);
|
|
215
223
|
const enhanced = loadEnhancedData(sessionId);
|
|
216
224
|
const sessionSlug = toSlug(enhanced?.title ?? session.title ?? sessionId, 80);
|
|
225
|
+
const includeTranscript = isTranscriptIncluded(sessionId);
|
|
217
226
|
const agentSummary = await buildAgentSummary(meta.children ?? [], (c) => ctx.getSessionStats(c, proj.name), { deduplicate: true });
|
|
218
227
|
const devTake = (enhanced?.developerTake ?? session.developerTake ?? '').slice(0, 2000);
|
|
219
228
|
const sessionNarrative = enhanced?.narrative ?? '';
|
|
@@ -271,6 +280,22 @@ export function createPublishRouter(ctx) {
|
|
|
271
280
|
rendered_html: sessionRenderedHtml,
|
|
272
281
|
},
|
|
273
282
|
};
|
|
283
|
+
// Transcript-derived fields. When the user has toggled the
|
|
284
|
+
// transcript OFF for this session we omit them from the
|
|
285
|
+
// uploaded JSON entirely — see the spread below. The
|
|
286
|
+
// session's main payload (dev take, skills, Q&A, etc.) is
|
|
287
|
+
// unaffected.
|
|
288
|
+
const turnTimeline = (session.turnTimeline ?? []).map((t) => ({
|
|
289
|
+
timestamp: t.timestamp,
|
|
290
|
+
type: t.type,
|
|
291
|
+
content: (t.content ?? '').slice(0, 200),
|
|
292
|
+
tools: t.tools ?? [],
|
|
293
|
+
}));
|
|
294
|
+
const transcriptExcerpt = (session.rawLog ?? []).slice(0, 10).map((line, i) => {
|
|
295
|
+
const role = line.startsWith('> ') ? 'dev' : 'ai';
|
|
296
|
+
const text = role === 'dev' ? line.slice(2) : line;
|
|
297
|
+
return { role, id: `Turn ${i + 1}`, text, timestamp: null };
|
|
298
|
+
});
|
|
274
299
|
const sessionData = {
|
|
275
300
|
version: 1,
|
|
276
301
|
id: sessionId,
|
|
@@ -308,17 +333,8 @@ export function createPublishRouter(ctx) {
|
|
|
308
333
|
highlights: [],
|
|
309
334
|
tool_breakdown: (session.toolBreakdown ?? []).map((t) => ({ tool: t.tool, count: t.count })),
|
|
310
335
|
top_files: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
|
|
311
|
-
turn_timeline:
|
|
312
|
-
|
|
313
|
-
type: t.type,
|
|
314
|
-
content: (t.content ?? '').slice(0, 200),
|
|
315
|
-
tools: t.tools ?? [],
|
|
316
|
-
})),
|
|
317
|
-
transcript_excerpt: (session.rawLog ?? []).slice(0, 10).map((line, i) => {
|
|
318
|
-
const role = line.startsWith('> ') ? 'dev' : 'ai';
|
|
319
|
-
const text = role === 'dev' ? line.slice(2) : line;
|
|
320
|
-
return { role, id: `Turn ${i + 1}`, text, timestamp: null };
|
|
321
|
-
}),
|
|
336
|
+
...(includeTranscript ? { turn_timeline: turnTimeline } : {}),
|
|
337
|
+
...(includeTranscript ? { transcript_excerpt: transcriptExcerpt } : {}),
|
|
322
338
|
agent_summary: agentSummary,
|
|
323
339
|
children: agentSummary?.agents?.map((a) => ({
|
|
324
340
|
sessionId: a.role,
|
|
@@ -347,7 +363,12 @@ export function createPublishRouter(ctx) {
|
|
|
347
363
|
uploadedCount++;
|
|
348
364
|
try {
|
|
349
365
|
const sesData = await sessionRes.json();
|
|
350
|
-
if (sesData.upload_urls) {
|
|
366
|
+
if (sesData.upload_urls && includeTranscript) {
|
|
367
|
+
// Transcript-bearing S3 uploads. Skipped entirely when
|
|
368
|
+
// the user has toggled the transcript OFF for this
|
|
369
|
+
// session — the server never sees raw_log, turn
|
|
370
|
+
// timeline, or the bundled session-data JSON in that
|
|
371
|
+
// case.
|
|
351
372
|
const { raw: rawUrl, log: logUrl } = sesData.upload_urls;
|
|
352
373
|
if (rawUrl && meta.path && !meta.path.startsWith('cursor://')) {
|
|
353
374
|
try {
|
|
@@ -423,7 +444,14 @@ export function createPublishRouter(ctx) {
|
|
|
423
444
|
const screenshotUrl = uploadedImageKey
|
|
424
445
|
? `${PUBLIC_URL}/_img/${uploadedImageKey.replace(IMAGE_KEY_PREFIX, '')}`
|
|
425
446
|
: undefined;
|
|
426
|
-
const
|
|
447
|
+
const detailProj = detail.project;
|
|
448
|
+
const projectHtml = generateProjectHtmlFragment(String(project), enrichedCache, detail.sessions, auth.username, {
|
|
449
|
+
totalFilesChanged: totalFiles,
|
|
450
|
+
title,
|
|
451
|
+
screenshotUrl,
|
|
452
|
+
totalInputTokens: detailProj.totalInputTokens,
|
|
453
|
+
totalOutputTokens: detailProj.totalOutputTokens,
|
|
454
|
+
});
|
|
427
455
|
const renderRes = await fetch(`${API_URL}/api/projects`, {
|
|
428
456
|
method: 'POST',
|
|
429
457
|
headers: {
|
|
@@ -490,5 +518,268 @@ export function createPublishRouter(ctx) {
|
|
|
490
518
|
res.end();
|
|
491
519
|
}
|
|
492
520
|
});
|
|
521
|
+
// Read the current portfolio publish state (per-target snapshots, hashes,
|
|
522
|
+
// last errors, visibility). Authenticated via the same Bearer pattern as
|
|
523
|
+
// the upload route — the state file lives in the user's local config dir
|
|
524
|
+
// but it still references the signed-in identity, so don't leak it to
|
|
525
|
+
// unauthenticated callers.
|
|
526
|
+
router.get('/api/portfolio/state', async (_req, res) => {
|
|
527
|
+
const auth = getAuthToken();
|
|
528
|
+
if (!auth) {
|
|
529
|
+
res.status(401).json({
|
|
530
|
+
error: { code: 'UNAUTHENTICATED', message: 'Authentication required' },
|
|
531
|
+
});
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
const state = getPortfolioPublishState();
|
|
536
|
+
res.json(state);
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
const message = err.message;
|
|
540
|
+
console.error('[portfolio-state] Error:', message);
|
|
541
|
+
res.status(500).json({
|
|
542
|
+
error: { code: 'PORTFOLIO_STATE_READ_FAILED', message },
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
// Publish portfolio landing page to heyi.am
|
|
547
|
+
// Renders the portfolio HTML fragment locally, POSTs it to Phoenix,
|
|
548
|
+
// and persists the published snapshot + hash to settings on success.
|
|
549
|
+
router.post('/api/portfolio/upload', async (req, res) => {
|
|
550
|
+
const auth = getAuthToken();
|
|
551
|
+
warnIfNonDefaultApiUrl();
|
|
552
|
+
if (!auth) {
|
|
553
|
+
res.status(401).json({
|
|
554
|
+
error: { code: 'UNAUTHENTICATED', message: 'Authentication required' },
|
|
555
|
+
});
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
const profile = getPortfolioProfile();
|
|
560
|
+
const templateName = getDefaultTemplate() || 'editorial';
|
|
561
|
+
const { renderData, filteredProjects } = await buildPortfolioRenderData(ctx, auth);
|
|
562
|
+
const renderedHtml = generatePortfolioHtmlFragment(renderData, templateName);
|
|
563
|
+
// Upload individual project pages for every project included in the
|
|
564
|
+
// portfolio. This ensures project detail pages exist on heyi.am even
|
|
565
|
+
// if the user never published them individually.
|
|
566
|
+
for (const rawProj of filteredProjects) {
|
|
567
|
+
try {
|
|
568
|
+
const detail = buildProjectDetail(ctx.db, rawProj);
|
|
569
|
+
const proj = detail.project;
|
|
570
|
+
const cache = detail.enhanceCache
|
|
571
|
+
?? { fingerprint: 'portfolio-upload', enhancedAt: new Date().toISOString(), selectedSessionIds: detail.sessions.map((s) => s.id), result: { narrative: '', arc: [], skills: [], timeline: [], questions: [] } };
|
|
572
|
+
const title = cache.title
|
|
573
|
+
|| proj.name || displayNameFromDir(rawProj.dirName);
|
|
574
|
+
const slug = toSlug(title);
|
|
575
|
+
const clientProjectId = getProjectUuid(ctx.db, rawProj.dirName);
|
|
576
|
+
const projectHtml = generateProjectHtmlFragment(rawProj.dirName, cache, detail.sessions, auth.username, {
|
|
577
|
+
totalFilesChanged: proj.totalFiles,
|
|
578
|
+
totalAgentDurationMinutes: proj.totalAgentDuration,
|
|
579
|
+
totalInputTokens: proj.totalInputTokens,
|
|
580
|
+
totalOutputTokens: proj.totalOutputTokens,
|
|
581
|
+
});
|
|
582
|
+
await fetch(`${API_URL}/api/projects`, {
|
|
583
|
+
method: 'POST',
|
|
584
|
+
headers: {
|
|
585
|
+
'Content-Type': 'application/json',
|
|
586
|
+
Authorization: `Bearer ${auth.token}`,
|
|
587
|
+
},
|
|
588
|
+
body: JSON.stringify({
|
|
589
|
+
project: {
|
|
590
|
+
client_project_id: clientProjectId,
|
|
591
|
+
title, slug,
|
|
592
|
+
narrative: cache.result?.narrative ?? '',
|
|
593
|
+
skills: cache.result?.skills ?? [],
|
|
594
|
+
total_sessions: proj.sessionCount,
|
|
595
|
+
total_loc: proj.totalLoc,
|
|
596
|
+
total_duration_minutes: proj.totalDuration,
|
|
597
|
+
total_agent_duration_minutes: proj.totalAgentDuration || null,
|
|
598
|
+
total_files_changed: proj.totalFiles,
|
|
599
|
+
total_input_tokens: proj.totalInputTokens,
|
|
600
|
+
total_output_tokens: proj.totalOutputTokens,
|
|
601
|
+
rendered_html: projectHtml,
|
|
602
|
+
},
|
|
603
|
+
}),
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
catch (projErr) {
|
|
607
|
+
console.warn(`[portfolio-upload] skipping project ${rawProj.dirName}:`, projErr.message);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// POST portfolio landing page to Phoenix.
|
|
611
|
+
// Phoenix sanitizes the HTML, persists to users.rendered_portfolio_html,
|
|
612
|
+
// and applies the optional profile snapshot via Accounts.update_user_profile.
|
|
613
|
+
const phoenixRes = await fetch(`${API_URL}/api/portfolio/upload`, {
|
|
614
|
+
method: 'POST',
|
|
615
|
+
headers: {
|
|
616
|
+
'Content-Type': 'application/json',
|
|
617
|
+
Authorization: `Bearer ${auth.token}`,
|
|
618
|
+
},
|
|
619
|
+
body: JSON.stringify({
|
|
620
|
+
html: renderedHtml,
|
|
621
|
+
profile,
|
|
622
|
+
}),
|
|
623
|
+
});
|
|
624
|
+
if (!phoenixRes.ok) {
|
|
625
|
+
const errBody = await phoenixRes.json().catch(() => null);
|
|
626
|
+
const rawErr = errBody && typeof errBody === 'object'
|
|
627
|
+
? errBody.error
|
|
628
|
+
: null;
|
|
629
|
+
const errMsg = typeof rawErr === 'string'
|
|
630
|
+
? rawErr
|
|
631
|
+
: (rawErr && typeof rawErr === 'object' && 'message' in rawErr)
|
|
632
|
+
? rawErr.message
|
|
633
|
+
: `HTTP ${phoenixRes.status}`;
|
|
634
|
+
updatePortfolioPublishTarget(DEFAULT_PORTFOLIO_TARGET, {
|
|
635
|
+
lastError: errMsg,
|
|
636
|
+
lastErrorAt: new Date().toISOString(),
|
|
637
|
+
});
|
|
638
|
+
res.status(phoenixRes.status >= 500 ? 502 : phoenixRes.status).json({
|
|
639
|
+
error: { code: 'PORTFOLIO_UPLOAD_FAILED', message: errMsg },
|
|
640
|
+
});
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const okBody = await phoenixRes.json().catch(() => ({}));
|
|
644
|
+
const publishedUrl = okBody.username ? `${PUBLIC_URL}/${okBody.username}` : undefined;
|
|
645
|
+
const publishedAt = new Date().toISOString();
|
|
646
|
+
const hash = hashPortfolioProfile(profile);
|
|
647
|
+
updatePortfolioPublishTarget(DEFAULT_PORTFOLIO_TARGET, {
|
|
648
|
+
lastPublishedAt: publishedAt,
|
|
649
|
+
lastPublishedProfileHash: hash,
|
|
650
|
+
lastPublishedProfile: profile,
|
|
651
|
+
config: {},
|
|
652
|
+
url: publishedUrl,
|
|
653
|
+
lastError: undefined,
|
|
654
|
+
lastErrorAt: undefined,
|
|
655
|
+
});
|
|
656
|
+
// Published version of the portfolio just changed — drop any cached
|
|
657
|
+
// /preview/portfolio HTML so the next preview reflects reality.
|
|
658
|
+
invalidatePortfolioPreviewCache();
|
|
659
|
+
res.json({
|
|
660
|
+
ok: true,
|
|
661
|
+
url: publishedUrl ?? `${PUBLIC_URL}/${auth.username}`,
|
|
662
|
+
publishedAt,
|
|
663
|
+
hash,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
const errMsg = err.message;
|
|
668
|
+
console.error('[portfolio-upload] Error:', errMsg);
|
|
669
|
+
try {
|
|
670
|
+
updatePortfolioPublishTarget(DEFAULT_PORTFOLIO_TARGET, {
|
|
671
|
+
lastError: errMsg,
|
|
672
|
+
lastErrorAt: new Date().toISOString(),
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
catch { /* don't mask the original error */ }
|
|
676
|
+
res.status(500).json({
|
|
677
|
+
error: { code: 'PORTFOLIO_UPLOAD_FAILED', message: errMsg },
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
// Export the portfolio as a downloadable .zip file.
|
|
682
|
+
//
|
|
683
|
+
// No path arg from the client. We render the portfolio site into a temp
|
|
684
|
+
// directory, zip it via createZipBuffer, stream the zip back as an
|
|
685
|
+
// attachment, then clean up the temp dir. Mirrors the existing
|
|
686
|
+
// single-project HTML download pattern (cli/src/routes/export.ts).
|
|
687
|
+
router.post('/api/portfolio/export', async (_req, res) => {
|
|
688
|
+
const auth = getAuthToken();
|
|
689
|
+
if (!auth) {
|
|
690
|
+
res.status(401).json({
|
|
691
|
+
error: { code: 'UNAUTHENTICATED', message: 'Authentication required' },
|
|
692
|
+
});
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
let tmpDir = null;
|
|
696
|
+
try {
|
|
697
|
+
const templateName = getDefaultTemplate() || 'editorial';
|
|
698
|
+
const { renderData } = await buildPortfolioRenderData(ctx, auth);
|
|
699
|
+
// Build per-project inputs for generatePortfolioSite. Each project
|
|
700
|
+
// needs its full session list + enhance cache — use the same
|
|
701
|
+
// buildProjectDetail path the single-project HTML export uses so
|
|
702
|
+
// there's exactly one "load everything about a project" helper.
|
|
703
|
+
const rawProjects = await ctx.getProjects();
|
|
704
|
+
const projectInputs = [];
|
|
705
|
+
for (const rawProj of rawProjects) {
|
|
706
|
+
try {
|
|
707
|
+
const detail = buildProjectDetail(ctx.db, rawProj);
|
|
708
|
+
const cache = detail.enhanceCache ?? {
|
|
709
|
+
fingerprint: 'export',
|
|
710
|
+
enhancedAt: new Date().toISOString(),
|
|
711
|
+
selectedSessionIds: detail.sessions.map((s) => s.id),
|
|
712
|
+
result: {
|
|
713
|
+
narrative: '',
|
|
714
|
+
arc: [],
|
|
715
|
+
skills: [],
|
|
716
|
+
timeline: [],
|
|
717
|
+
questions: [],
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
const proj = detail.project;
|
|
721
|
+
projectInputs.push({
|
|
722
|
+
dirName: rawProj.dirName,
|
|
723
|
+
cache,
|
|
724
|
+
sessions: detail.sessions,
|
|
725
|
+
opts: {
|
|
726
|
+
totalFilesChanged: proj.totalFiles,
|
|
727
|
+
totalAgentDurationMinutes: proj.totalAgentDuration,
|
|
728
|
+
totalInputTokens: proj.totalInputTokens,
|
|
729
|
+
totalOutputTokens: proj.totalOutputTokens,
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
catch (projErr) {
|
|
734
|
+
console.warn(`[portfolio-export] skipping project ${rawProj.dirName}:`, projErr.message);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
tmpDir = mkdtempSync(path.join(tmpdir(), 'heyiam-portfolio-'));
|
|
738
|
+
const result = await generatePortfolioSite(renderData, projectInputs, tmpDir, templateName);
|
|
739
|
+
// Read every file generated into memory keyed by its path relative
|
|
740
|
+
// to the temp dir, then zip. Skip non-files defensively (the result
|
|
741
|
+
// list is files-only, but stat lets us catch directories that snuck
|
|
742
|
+
// in via a future code path).
|
|
743
|
+
const entries = [];
|
|
744
|
+
for (const filePath of result.files) {
|
|
745
|
+
try {
|
|
746
|
+
if (!statSync(filePath).isFile())
|
|
747
|
+
continue;
|
|
748
|
+
entries.push({
|
|
749
|
+
path: path.relative(tmpDir, filePath),
|
|
750
|
+
content: readFileSync(filePath, 'utf-8'),
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
catch (readErr) {
|
|
754
|
+
console.warn(`[portfolio-export] skipping unreadable file ${filePath}:`, readErr.message);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
const zipBuffer = createZipBuffer(entries);
|
|
758
|
+
const datestamp = new Date().toISOString().slice(0, 10);
|
|
759
|
+
const safeUsername = auth.username.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
760
|
+
const filename = `portfolio-${safeUsername}-${datestamp}.zip`;
|
|
761
|
+
res.setHeader('Content-Type', 'application/zip');
|
|
762
|
+
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
|
763
|
+
res.setHeader('Content-Length', zipBuffer.length);
|
|
764
|
+
res.send(zipBuffer);
|
|
765
|
+
}
|
|
766
|
+
catch (err) {
|
|
767
|
+
const errMsg = err.message;
|
|
768
|
+
console.error('[portfolio-export] Error:', errMsg);
|
|
769
|
+
if (!res.headersSent) {
|
|
770
|
+
res.status(500).json({
|
|
771
|
+
error: { code: 'PORTFOLIO_EXPORT_FAILED', message: errMsg },
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
finally {
|
|
776
|
+
if (tmpDir) {
|
|
777
|
+
try {
|
|
778
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
779
|
+
}
|
|
780
|
+
catch { /* best effort */ }
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
});
|
|
493
784
|
return router;
|
|
494
785
|
}
|
package/dist/routes/settings.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { saveAnthropicApiKey, clearAnthropicApiKey, getAnthropicApiKey, getSettings, setDefaultTemplate, getPortfolioProfile, savePortfolioProfile, isTranscriptIncluded, setTranscriptIncluded } from '../settings.js';
|
|
4
|
+
import { invalidatePortfolioPreviewCache } from './preview.js';
|
|
3
5
|
import { hasApiKey } from '../llm/index.js';
|
|
4
6
|
import { isValidTemplate, DEFAULT_TEMPLATE, BUILT_IN_TEMPLATES } from '../render/templates.js';
|
|
7
|
+
import { getDbPath } from '../db.js';
|
|
8
|
+
import { getDaemonBinaryPath } from '../daemon-install.js';
|
|
5
9
|
export function createSettingsRouter(_ctx) {
|
|
6
10
|
const router = Router();
|
|
7
11
|
// Save or clear the Anthropic API key
|
|
@@ -52,9 +56,42 @@ export function createSettingsRouter(_ctx) {
|
|
|
52
56
|
return;
|
|
53
57
|
}
|
|
54
58
|
setDefaultTemplate(template);
|
|
59
|
+
invalidatePortfolioPreviewCache();
|
|
55
60
|
console.log(`[settings] Portfolio theme set to: ${template}`);
|
|
56
61
|
res.json({ ok: true, template });
|
|
57
62
|
});
|
|
63
|
+
// ── Per-session transcript toggle (publish-time, CLI-only) ───
|
|
64
|
+
//
|
|
65
|
+
// GET /api/sessions/:sessionId/transcript-setting — returns whether the
|
|
66
|
+
// session transcript will be included in the next publish. Defaults to
|
|
67
|
+
// true.
|
|
68
|
+
//
|
|
69
|
+
// PUT /api/sessions/:sessionId/transcript-setting { included: boolean }
|
|
70
|
+
// — flips the flag. When `false`, publish.ts skips all S3 transcript
|
|
71
|
+
// uploads and strips transcript-derived fields from the uploaded
|
|
72
|
+
// session JSON.
|
|
73
|
+
router.get('/api/sessions/:sessionId/transcript-setting', (req, res) => {
|
|
74
|
+
const sessionId = typeof req.params.sessionId === 'string' ? req.params.sessionId.trim() : '';
|
|
75
|
+
if (!sessionId || sessionId.length > 200) {
|
|
76
|
+
res.status(400).json({ error: { code: 'INVALID_PARAM', message: 'sessionId is required' } });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
res.json({ sessionId, included: isTranscriptIncluded(sessionId) });
|
|
80
|
+
});
|
|
81
|
+
router.put('/api/sessions/:sessionId/transcript-setting', (req, res) => {
|
|
82
|
+
const sessionId = typeof req.params.sessionId === 'string' ? req.params.sessionId.trim() : '';
|
|
83
|
+
if (!sessionId || sessionId.length > 200) {
|
|
84
|
+
res.status(400).json({ error: { code: 'INVALID_PARAM', message: 'sessionId is required' } });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const body = req.body;
|
|
88
|
+
if (typeof body?.included !== 'boolean') {
|
|
89
|
+
res.status(400).json({ error: { code: 'INVALID_BODY', message: 'included must be a boolean' } });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
setTranscriptIncluded(sessionId, body.included);
|
|
93
|
+
res.json({ ok: true, sessionId, included: body.included });
|
|
94
|
+
});
|
|
58
95
|
// Get portfolio profile data
|
|
59
96
|
router.get('/api/portfolio', (_req, res) => {
|
|
60
97
|
res.json(getPortfolioProfile());
|
|
@@ -69,7 +106,7 @@ export function createSettingsRouter(_ctx) {
|
|
|
69
106
|
const ALLOWED_FIELDS = [
|
|
70
107
|
'displayName', 'bio', 'photoBase64', 'location', 'email', 'phone',
|
|
71
108
|
'linkedinUrl', 'githubUrl', 'twitterHandle', 'websiteUrl',
|
|
72
|
-
'resumeBase64', 'resumeFilename',
|
|
109
|
+
'resumeBase64', 'resumeFilename', 'accent',
|
|
73
110
|
];
|
|
74
111
|
const errors = [];
|
|
75
112
|
// Structural validation: only allow known string fields
|
|
@@ -103,6 +140,9 @@ export function createSettingsRouter(_ctx) {
|
|
|
103
140
|
if (cleaned.websiteUrl && !cleaned.websiteUrl.startsWith('http')) {
|
|
104
141
|
errors.push({ field: 'websiteUrl', message: 'Website URL must start with http' });
|
|
105
142
|
}
|
|
143
|
+
if (cleaned.accent && !/^#[0-9a-fA-F]{6}$/.test(cleaned.accent)) {
|
|
144
|
+
errors.push({ field: 'accent', message: 'Accent must be a 6-digit hex color (e.g. #084471)' });
|
|
145
|
+
}
|
|
106
146
|
// File size limits (base64 ~1.37x raw; cap photo at ~5MB, resume at ~10MB)
|
|
107
147
|
if (cleaned.photoBase64 && cleaned.photoBase64.length > 7_000_000) {
|
|
108
148
|
errors.push({ field: 'photoBase64', message: 'Photo must be under 5MB' });
|
|
@@ -110,13 +150,73 @@ export function createSettingsRouter(_ctx) {
|
|
|
110
150
|
if (cleaned.resumeBase64 && cleaned.resumeBase64.length > 14_000_000) {
|
|
111
151
|
errors.push({ field: 'resumeBase64', message: 'Resume must be under 10MB' });
|
|
112
152
|
}
|
|
153
|
+
// projectsOnPortfolio: array of {projectId, included, order}. Validated
|
|
154
|
+
// structurally; unknown entries are dropped silently. Empty array allowed
|
|
155
|
+
// (means "default ordering, include everything" at render time).
|
|
156
|
+
if (body.projectsOnPortfolio !== undefined) {
|
|
157
|
+
if (!Array.isArray(body.projectsOnPortfolio)) {
|
|
158
|
+
errors.push({ field: 'projectsOnPortfolio', message: 'projectsOnPortfolio must be an array' });
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
const cleanedProjects = [];
|
|
162
|
+
for (let i = 0; i < body.projectsOnPortfolio.length; i++) {
|
|
163
|
+
const raw = body.projectsOnPortfolio[i];
|
|
164
|
+
if (!raw || typeof raw !== 'object') {
|
|
165
|
+
errors.push({ field: `projectsOnPortfolio[${i}]`, message: 'must be an object' });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (typeof raw.projectId !== 'string' || raw.projectId.length === 0) {
|
|
169
|
+
errors.push({ field: `projectsOnPortfolio[${i}].projectId`, message: 'must be a non-empty string' });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (typeof raw.included !== 'boolean') {
|
|
173
|
+
errors.push({ field: `projectsOnPortfolio[${i}].included`, message: 'must be a boolean' });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (typeof raw.order !== 'number' || !Number.isFinite(raw.order)) {
|
|
177
|
+
errors.push({ field: `projectsOnPortfolio[${i}].order`, message: 'must be a finite number' });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
cleanedProjects.push({
|
|
181
|
+
projectId: raw.projectId,
|
|
182
|
+
included: raw.included,
|
|
183
|
+
order: raw.order,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
cleaned.projectsOnPortfolio = cleanedProjects;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
113
189
|
if (errors.length > 0) {
|
|
114
190
|
res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Validation failed', fields: errors } });
|
|
115
191
|
return;
|
|
116
192
|
}
|
|
117
193
|
savePortfolioProfile(cleaned);
|
|
194
|
+
invalidatePortfolioPreviewCache();
|
|
118
195
|
console.log('[settings] Portfolio profile saved');
|
|
119
196
|
res.json({ ok: true });
|
|
120
197
|
});
|
|
198
|
+
// Local data summary: read-only diagnostic info displayed in Settings
|
|
199
|
+
// (DB path, daemon install state). Archive count + last sync are served
|
|
200
|
+
// by /api/archive/stats so the frontend composes both responses.
|
|
201
|
+
router.get('/api/local-data', (_req, res) => {
|
|
202
|
+
try {
|
|
203
|
+
const dbPath = getDbPath();
|
|
204
|
+
const daemonBinaryPath = getDaemonBinaryPath();
|
|
205
|
+
const daemonInstalled = existsSync(daemonBinaryPath);
|
|
206
|
+
res.json({
|
|
207
|
+
dbPath,
|
|
208
|
+
daemon: {
|
|
209
|
+
installed: daemonInstalled,
|
|
210
|
+
binaryPath: daemonBinaryPath,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
console.error('[local-data]', err.message);
|
|
216
|
+
res.status(500).json({
|
|
217
|
+
error: { code: 'LOCAL_DATA_FAILED', message: 'Failed to read local data summary' },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
});
|
|
121
221
|
return router;
|
|
122
222
|
}
|
package/dist/search.js
CHANGED
|
@@ -7,6 +7,12 @@ import { escapeLikeWildcards } from './format-utils.js';
|
|
|
7
7
|
* e.g. "-Users-test-Dev-myapp" → "Users/test/Dev/myapp"
|
|
8
8
|
*/
|
|
9
9
|
export function decodeProjectName(projectDir) {
|
|
10
|
+
// Windows: "C-Users-ben-Dev-myapp" → "C:/Users/ben/Dev/myapp"
|
|
11
|
+
const winMatch = projectDir.match(/^([A-Z])-(.+)$/);
|
|
12
|
+
if (winMatch) {
|
|
13
|
+
return `${winMatch[1]}:/${winMatch[2].replace(/-/g, "/")}`;
|
|
14
|
+
}
|
|
15
|
+
// Unix: "-Users-ben-Dev-myapp" → "Users/ben/Dev/myapp"
|
|
10
16
|
return projectDir.replace(/^-/, '').replace(/-/g, '/');
|
|
11
17
|
}
|
|
12
18
|
function rowToResult(row, snippet, score) {
|
package/dist/server.js
CHANGED
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
8
|
import { getDatabase, closeDatabase } from './db.js';
|
|
9
9
|
import { syncWithTracking, startFileWatcher, startCursorPolling, markSyncPending } from './sync.js';
|
|
10
|
-
import { createRouteContext, createProjectsRouter, createEnhanceRouter, createPublishRouter, createSearchRouter, createSessionsRouter, createArchiveRouter, createAuthRouter, createSettingsRouter, createExportRouter, createPreviewRouter, createDashboardRouter, } from './routes/index.js';
|
|
10
|
+
import { createRouteContext, createProjectsRouter, createEnhanceRouter, createPublishRouter, createDeleteRouter, createSearchRouter, createSessionsRouter, createArchiveRouter, createAuthRouter, createSettingsRouter, createExportRouter, createPreviewRouter, createDashboardRouter, createGithubRouter, } from './routes/index.js';
|
|
11
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
function getPackageVersion() {
|
|
13
13
|
try {
|
|
@@ -97,6 +97,7 @@ export function createApp(sessionsBasePath, dbPath) {
|
|
|
97
97
|
app.use(createProjectsRouter(ctx));
|
|
98
98
|
app.use(createEnhanceRouter(ctx));
|
|
99
99
|
app.use(createPublishRouter(ctx));
|
|
100
|
+
app.use(createDeleteRouter(ctx));
|
|
100
101
|
app.use(createSearchRouter(ctx));
|
|
101
102
|
app.use(createSessionsRouter(ctx));
|
|
102
103
|
app.use(createArchiveRouter(ctx));
|
|
@@ -105,6 +106,7 @@ export function createApp(sessionsBasePath, dbPath) {
|
|
|
105
106
|
app.use(createExportRouter(ctx));
|
|
106
107
|
app.use(createPreviewRouter(ctx));
|
|
107
108
|
app.use(createDashboardRouter(ctx));
|
|
109
|
+
app.use(createGithubRouter(ctx));
|
|
108
110
|
// ── Version endpoint (used by `heyiam open` to detect stale instances) ──
|
|
109
111
|
app.get('/api/version', (_req, res) => {
|
|
110
112
|
res.json({ server: 'heyiam', version: SERVER_VERSION });
|