heyiam 0.3.3 → 0.3.6

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.
Files changed (45) hide show
  1. package/dist/export.js +19 -5
  2. package/dist/mount.js +9 -7
  3. package/dist/public/assets/{index-qyd5sXR0.js → index-7mUuxgqY.js} +3 -3
  4. package/dist/public/index.html +1 -1
  5. package/dist/render/build-render-data.js +1 -0
  6. package/dist/render/liquid.js +12 -2
  7. package/dist/render/templates/aurora/project.liquid +1 -1
  8. package/dist/render/templates/bauhaus/project.liquid +1 -1
  9. package/dist/render/templates/blueprint/project.liquid +1 -1
  10. package/dist/render/templates/canvas/project.liquid +1 -1
  11. package/dist/render/templates/carbon/project.liquid +1 -1
  12. package/dist/render/templates/chalk/project.liquid +1 -1
  13. package/dist/render/templates/circuit/project.liquid +1 -1
  14. package/dist/render/templates/cosmos/project.liquid +1 -1
  15. package/dist/render/templates/daylight/project.liquid +1 -1
  16. package/dist/render/templates/editorial/project.liquid +1 -1
  17. package/dist/render/templates/ember/project.liquid +1 -1
  18. package/dist/render/templates/glacier/project.liquid +1 -1
  19. package/dist/render/templates/grid/project.liquid +1 -1
  20. package/dist/render/templates/kinetic/portfolio.liquid +1 -1
  21. package/dist/render/templates/kinetic/project.liquid +1 -1
  22. package/dist/render/templates/kinetic/styles.css +15 -9
  23. package/dist/render/templates/meridian/project.liquid +1 -1
  24. package/dist/render/templates/minimal/project.liquid +1 -1
  25. package/dist/render/templates/mono/project.liquid +1 -1
  26. package/dist/render/templates/neon/project.liquid +1 -1
  27. package/dist/render/templates/noir/project.liquid +1 -1
  28. package/dist/render/templates/obsidian/project.liquid +1 -1
  29. package/dist/render/templates/paper/project.liquid +1 -1
  30. package/dist/render/templates/parallax/project.liquid +1 -1
  31. package/dist/render/templates/parchment/project.liquid +1 -1
  32. package/dist/render/templates/project.liquid +1 -1
  33. package/dist/render/templates/radar/project.liquid +1 -1
  34. package/dist/render/templates/showcase/project.liquid +1 -1
  35. package/dist/render/templates/signal/project.liquid +1 -1
  36. package/dist/render/templates/strata/project.liquid +1 -1
  37. package/dist/render/templates/terminal/project.liquid +1 -1
  38. package/dist/render/templates/verdant/project.liquid +1 -1
  39. package/dist/render/templates/zen/project.liquid +1 -1
  40. package/dist/routes/portfolio-render-data.js +2 -2
  41. package/dist/routes/preview.js +18 -2
  42. package/dist/routes/project-session-upload.js +224 -0
  43. package/dist/routes/publish.js +270 -260
  44. package/dist/settings.js +14 -1
  45. package/package.json +1 -1
@@ -5,21 +5,51 @@ import path from 'node:path';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { getAuthToken } from '../auth.js';
7
7
  import { API_URL, PUBLIC_URL, warnIfNonDefaultApiUrl } from '../config.js';
8
- import { loadEnhancedData, saveEnhancedData, saveUploadedState, getDefaultTemplate, getPortfolioProfile, hashPortfolioProfile, updatePortfolioPublishTarget, getPortfolioPublishState, isTranscriptIncluded, DEFAULT_PORTFOLIO_TARGET, } from '../settings.js';
8
+ import { loadEnhancedData, saveUploadedState, getDefaultTemplate, getPortfolioProfile, hashPortfolioProfile, updatePortfolioPublishTarget, getPortfolioPublishState, listUploadedProjects, DEFAULT_PORTFOLIO_TARGET, } from '../settings.js';
9
9
  import { generatePortfolioHtmlFragment, generateProjectHtmlFragment, generatePortfolioSite, createZipBuffer } from '../export.js';
10
10
  import { buildPortfolioRenderData } from './portfolio-render-data.js';
11
11
  import { buildProjectDetail } from './context.js';
12
12
  import { captureScreenshot } from '../screenshot.js';
13
- import { redactSession, redactText, scanTextSync, formatFindings, stripHomePathsInText } from '../redact.js';
14
- import { renderProjectHtml, renderSessionHtml } from '../render/index.js';
15
- import { buildSessionRenderData, buildSessionCard, buildProjectRenderData } from '../render/build-render-data.js';
16
- import { buildAgentSummary } from './context.js';
13
+ import { renderProjectHtml } from '../render/index.js';
14
+ import { buildProjectRenderData } from '../render/build-render-data.js';
15
+ import { uploadSelectedSessions } from './project-session-upload.js';
17
16
  import { invalidatePortfolioPreviewCache } from './preview.js';
18
17
  import { startSSE } from './sse.js';
19
18
  import { displayNameFromDir } from '../sync.js';
20
19
  import { toSlug } from '../format-utils.js';
21
- import { getProjectUuid, getFileCountWithChildren } from '../db.js';
20
+ import { getProjectUuid } from '../db.js';
22
21
  const IMAGE_KEY_PREFIX = 'images/';
22
+ /**
23
+ * Upload the user's base64-encoded profile photo to S3 via the Phoenix
24
+ * presign endpoints, then save the resulting key on the user record.
25
+ * Returns the public `/_img/:uuid` URL suitable for og:image / <img src>,
26
+ * or `null` if the upload failed (publish still proceeds without a photo).
27
+ */
28
+ /**
29
+ * POSTs the user's base64 profile photo to Phoenix, which resizes it into
30
+ * a full (max 1200px) and small (max 600px) variant, uploads both to R2,
31
+ * and persists the two keys on the user record. Returns the full-size
32
+ * URL (for inline <img src>) or `null` on any failure.
33
+ */
34
+ async function uploadProfilePhoto(photoBase64, auth) {
35
+ try {
36
+ const res = await fetch(`${API_URL}/api/portfolio/profile-photo`, {
37
+ method: 'POST',
38
+ headers: {
39
+ 'Content-Type': 'application/json',
40
+ Authorization: `Bearer ${auth.token}`,
41
+ },
42
+ body: JSON.stringify({ photo: photoBase64 }),
43
+ });
44
+ if (!res.ok)
45
+ return null;
46
+ const { full_url } = await res.json();
47
+ return full_url ?? null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
23
53
  export function createPublishRouter(ctx) {
24
54
  const router = Router();
25
55
  // Render project preview HTML
@@ -210,220 +240,17 @@ export function createPublishRouter(ctx) {
210
240
  const proj = projects.find((p) => p.dirName === project);
211
241
  let uploadedCount = 0;
212
242
  const failedSessions = [];
213
- const uploadedSessionCards = [];
243
+ let uploadedSessionCards = [];
214
244
  if (proj) {
215
- const selectedTemplate = getDefaultTemplate() || 'editorial';
216
- for (const sessionId of selectedSessionIds) {
217
- const meta = proj.sessions.find((s) => s.sessionId === sessionId);
218
- if (!meta)
219
- continue;
220
- send({ type: 'session', sessionId, status: 'uploading' });
221
- try {
222
- const session = await ctx.loadSession(meta.path, proj.name, sessionId);
223
- const enhanced = loadEnhancedData(sessionId);
224
- const sessionSlug = toSlug(enhanced?.title ?? session.title ?? sessionId, 80);
225
- const includeTranscript = isTranscriptIncluded(sessionId);
226
- const agentSummary = await buildAgentSummary(meta.children ?? [], (c) => ctx.getSessionStats(c, proj.name), { deduplicate: true });
227
- const devTake = (enhanced?.developerTake ?? session.developerTake ?? '').slice(0, 2000);
228
- const sessionNarrative = enhanced?.narrative ?? '';
229
- const sessionTitle = enhanced?.title ?? session.title;
230
- const sessionSkills = enhanced?.skills ?? session.skills ?? [];
231
- const sessionSourceTool = session.source ?? meta.source ?? 'claude';
232
- const sessionRecordedAt = session.date ? new Date(session.date).toISOString() : new Date().toISOString();
233
- const renderOpts = {
234
- sessionId,
235
- session,
236
- enhanced,
237
- username: auth.username,
238
- projectSlug: projectData.slug,
239
- sessionSlug,
240
- sourceTool: sessionSourceTool,
241
- agentSummary,
242
- template: selectedTemplate,
243
- };
244
- let sessionRenderedHtml = null;
245
- try {
246
- const sessionRenderData = buildSessionRenderData(renderOpts);
247
- sessionRenderedHtml = renderSessionHtml(sessionRenderData, selectedTemplate);
248
- }
249
- catch (renderErr) {
250
- console.error(`[upload] Session render failed for ${sessionId}:`, renderErr.message);
251
- }
252
- uploadedSessionCards.push(buildSessionCard(renderOpts));
253
- const childLoc = agentSummary?.agents?.reduce((s, a) => s + (a.loc_changed ?? 0), 0) ?? 0;
254
- const totalLocChanged = (session.linesOfCode ?? 0) + childLoc;
255
- const totalFilesChanged = getFileCountWithChildren(ctx.db, sessionId) || session.filesChanged?.length || 0;
256
- const sessionPayload = {
257
- session: {
258
- title: sessionTitle,
259
- dev_take: devTake,
260
- context: enhanced?.context ?? '',
261
- duration_minutes: session.durationMinutes ?? 0,
262
- turns: session.turns ?? 0,
263
- files_changed: totalFilesChanged,
264
- loc_changed: totalLocChanged,
265
- recorded_at: sessionRecordedAt,
266
- end_time: session.endTime ? new Date(session.endTime).toISOString() : null,
267
- cwd: session.cwd ?? null,
268
- wall_clock_minutes: session.wallClockMinutes ?? null,
269
- template: selectedTemplate,
270
- language: null,
271
- tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
272
- skills: sessionSkills,
273
- narrative: sessionNarrative,
274
- project_name: proj.name,
275
- project_id: projectData.project_id,
276
- slug: sessionSlug,
277
- status: 'unlisted',
278
- source_tool: sessionSourceTool,
279
- agent_summary: agentSummary,
280
- rendered_html: sessionRenderedHtml,
281
- },
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
- });
299
- const sessionData = {
300
- version: 1,
301
- id: sessionId,
302
- title: sessionTitle,
303
- dev_take: devTake,
304
- context: enhanced?.context ?? '',
305
- duration_minutes: session.durationMinutes ?? 0,
306
- turns: session.turns ?? 0,
307
- files_changed: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
308
- loc_changed: totalLocChanged,
309
- date: sessionRecordedAt,
310
- end_time: (() => {
311
- if (!session.endTime || !session.date)
312
- return null;
313
- const wallMs = new Date(session.endTime).getTime() - new Date(session.date).getTime();
314
- const activeMs = (session.durationMinutes ?? 0) * 60_000;
315
- return wallMs <= activeMs * 3 ? new Date(session.endTime).toISOString() : null;
316
- })(),
317
- cwd: session.cwd ?? null,
318
- wall_clock_minutes: session.wallClockMinutes ?? null,
319
- template: selectedTemplate,
320
- skills: sessionSkills,
321
- tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
322
- source: sessionSourceTool,
323
- slug: sessionSlug,
324
- project_name: proj.name,
325
- narrative: sessionNarrative,
326
- status: 'unlisted',
327
- raw_log: [],
328
- execution_path: (enhanced?.executionSteps ?? session.executionPath ?? []).map((s, i) => ({
329
- label: s.title ?? `Step ${i + 1}`,
330
- description: s.description ?? s.body ?? '',
331
- })),
332
- qa_pairs: enhanced?.qaPairs ?? session.qaPairs ?? [],
333
- highlights: [],
334
- tool_breakdown: (session.toolBreakdown ?? []).map((t) => ({ tool: t.tool, count: t.count })),
335
- top_files: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
336
- ...(includeTranscript ? { turn_timeline: turnTimeline } : {}),
337
- ...(includeTranscript ? { transcript_excerpt: transcriptExcerpt } : {}),
338
- agent_summary: agentSummary,
339
- children: agentSummary?.agents?.map((a) => ({
340
- sessionId: a.role,
341
- role: a.role,
342
- durationMinutes: a.duration_minutes,
343
- linesOfCode: a.loc_changed,
344
- })) ?? [],
345
- };
346
- const sessionCwd = session.cwd ?? undefined;
347
- const redactedPayload = redactSession(sessionPayload, 'high', sessionCwd);
348
- const redactedData = redactSession(sessionData, 'high', sessionCwd);
349
- const payloadFindings = scanTextSync(JSON.stringify(sessionPayload));
350
- if (payloadFindings.length > 0) {
351
- const summary = formatFindings(payloadFindings);
352
- send({ type: 'redaction', sessionId, message: summary });
353
- }
354
- const sessionRes = await fetch(`${API_URL}/api/sessions`, {
355
- method: 'POST',
356
- headers: {
357
- 'Content-Type': 'application/json',
358
- Authorization: `Bearer ${auth.token}`,
359
- },
360
- body: JSON.stringify(redactedPayload),
361
- });
362
- if (sessionRes.ok) {
363
- uploadedCount++;
364
- try {
365
- const sesData = await sessionRes.json();
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.
372
- const { raw: rawUrl, log: logUrl } = sesData.upload_urls;
373
- if (rawUrl && meta.path && !meta.path.startsWith('cursor://')) {
374
- try {
375
- const rawText = readFileSync(meta.path, 'utf-8');
376
- let redactedRaw = redactText(rawText);
377
- redactedRaw = stripHomePathsInText(redactedRaw, sessionCwd);
378
- await fetch(rawUrl, { method: 'PUT', body: Buffer.from(redactedRaw, 'utf-8'), headers: { 'Content-Type': 'application/octet-stream' } });
379
- }
380
- catch { /* S3 upload is best-effort */ }
381
- }
382
- if (logUrl && session.rawLog && session.rawLog.length > 0) {
383
- try {
384
- const redactedLog = session.rawLog.map((line) => {
385
- let cleaned = redactText(line);
386
- cleaned = stripHomePathsInText(cleaned, sessionCwd);
387
- return cleaned;
388
- });
389
- await fetch(logUrl, { method: 'PUT', body: JSON.stringify(redactedLog), headers: { 'Content-Type': 'application/json' } });
390
- }
391
- catch { /* S3 upload is best-effort */ }
392
- }
393
- if (sesData.upload_urls.session) {
394
- try {
395
- await fetch(sesData.upload_urls.session, {
396
- method: 'PUT',
397
- body: JSON.stringify(redactedData),
398
- headers: { 'Content-Type': 'application/json' },
399
- });
400
- }
401
- catch { /* S3 upload is best-effort */ }
402
- }
403
- }
404
- }
405
- catch { /* Response already consumed or no upload_urls -- not fatal */ }
406
- if (enhanced) {
407
- saveEnhancedData(sessionId, { ...enhanced, uploaded: true });
408
- }
409
- send({ type: 'session', sessionId, status: 'uploaded' });
410
- }
411
- else {
412
- const sesErrBody = await sessionRes.json().catch(() => null);
413
- const rawSesErr = sesErrBody && typeof sesErrBody === 'object' ? sesErrBody.error : null;
414
- const errMsg = typeof rawSesErr === 'string' ? rawSesErr
415
- : (rawSesErr && typeof rawSesErr === 'object' && 'message' in rawSesErr) ? rawSesErr.message
416
- : `HTTP ${sessionRes.status}`;
417
- failedSessions.push({ sessionId, error: errMsg });
418
- send({ type: 'session', sessionId, status: 'failed', error: errMsg });
419
- }
420
- }
421
- catch (err) {
422
- const errMsg = err.message;
423
- failedSessions.push({ sessionId, error: errMsg });
424
- send({ type: 'session', sessionId, status: 'failed', error: errMsg });
425
- }
426
- }
245
+ const sessionResult = await uploadSelectedSessions(ctx, auth, {
246
+ proj,
247
+ projectData,
248
+ selectedSessionIds,
249
+ send,
250
+ });
251
+ uploadedCount = sessionResult.uploadedCount;
252
+ failedSessions.push(...sessionResult.failedSessions);
253
+ uploadedSessionCards = sessionResult.uploadedSessionCards;
427
254
  }
428
255
  // Step 3: Render project HTML using the same path as HTML export
429
256
  if (uploadedSessionCards.length > 0) {
@@ -555,61 +382,245 @@ export function createPublishRouter(ctx) {
555
382
  });
556
383
  return;
557
384
  }
385
+ const send = startSSE(res);
558
386
  try {
387
+ send({ type: 'progress', message: 'Preparing portfolio…' });
559
388
  const profile = getPortfolioProfile();
560
389
  const templateName = getDefaultTemplate() || 'editorial';
561
- const { renderData, filteredProjects } = await buildPortfolioRenderData(ctx, auth);
390
+ // If the user has a profile photo, upload it to Phoenix first so the
391
+ // rendered HTML can reference the hosted URL instead of inlining 1.5MB
392
+ // of base64. Phoenix resizes into a full + small variant and stores
393
+ // both in R2. Removals are handled by the upload endpoint below based
394
+ // on the `has_photo` signal — no explicit DELETE needed.
395
+ let photoUrlOverride;
396
+ if (profile.photoBase64) {
397
+ send({ type: 'progress', message: 'Uploading profile photo…' });
398
+ const uploadedUrl = await uploadProfilePhoto(profile.photoBase64, auth);
399
+ if (uploadedUrl)
400
+ photoUrlOverride = uploadedUrl;
401
+ }
402
+ const { renderData, filteredProjects } = await buildPortfolioRenderData(ctx, auth, { photoUrlOverride });
403
+ send({ type: 'progress', message: 'Rendering portfolio HTML…' });
562
404
  const renderedHtml = generatePortfolioHtmlFragment(renderData, templateName);
563
405
  // Upload individual project pages for every project included in the
564
406
  // portfolio. This ensures project detail pages exist on heyi.am even
565
407
  // if the user never published them individually.
408
+ const MAX_SLUG_RETRIES = 10;
409
+ const slugMap = new Map();
410
+ send({ type: 'progress', message: `Publishing ${filteredProjects.length} project${filteredProjects.length === 1 ? '' : 's'}…` });
411
+ let projectIndex = 0;
566
412
  for (const rawProj of filteredProjects) {
413
+ projectIndex++;
567
414
  try {
415
+ const allProjectsList = await ctx.getProjects();
416
+ const projInfo = allProjectsList.find((p) => p.dirName === rawProj.dirName);
417
+ if (!projInfo) {
418
+ console.warn(`[portfolio-upload] project not found: ${rawProj.dirName}`);
419
+ continue;
420
+ }
568
421
  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: [] } };
422
+ const enhance = detail.enhanceCache;
423
+ const cache = enhance ?? {
424
+ fingerprint: 'portfolio-upload',
425
+ enhancedAt: new Date().toISOString(),
426
+ selectedSessionIds: detail.sessions.map((s) => s.id),
427
+ result: { narrative: '', arc: [], skills: [], timeline: [], questions: [] },
428
+ };
429
+ const selectedSessionIds = enhance !== null && enhance.selectedSessionIds !== undefined
430
+ ? enhance.selectedSessionIds
431
+ : detail.sessions.map((s) => s.id);
432
+ const projRecord = detail.project;
572
433
  const title = cache.title
573
- || proj.name || displayNameFromDir(rawProj.dirName);
574
- const slug = toSlug(title);
434
+ || projRecord.name || displayNameFromDir(rawProj.dirName);
435
+ const baseSlug = toSlug(title);
575
436
  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,
437
+ send({ type: 'project', project: title, index: projectIndex, total: filteredProjects.length, status: 'creating' });
438
+ const projectHtmlPreview = generateProjectHtmlFragment(rawProj.dirName, cache, detail.sessions, auth.username, {
439
+ totalFilesChanged: projRecord.totalFiles,
440
+ totalAgentDurationMinutes: projRecord.totalAgentDuration,
441
+ totalInputTokens: projRecord.totalInputTokens,
442
+ totalOutputTokens: projRecord.totalOutputTokens,
581
443
  });
582
- await fetch(`${API_URL}/api/projects`, {
583
- method: 'POST',
444
+ const projectBodyBase = {
445
+ client_project_id: clientProjectId,
446
+ title,
447
+ narrative: cache.result?.narrative ?? '',
448
+ repo_url: cache.repoUrl || null,
449
+ project_url: cache.projectUrl || null,
450
+ timeline: cache.result?.timeline ?? [],
451
+ skills: cache.result?.skills ?? [],
452
+ total_sessions: projRecord.sessionCount,
453
+ total_loc: projRecord.totalLoc,
454
+ total_duration_minutes: projRecord.totalDuration,
455
+ total_agent_duration_minutes: projRecord.totalAgentDuration || null,
456
+ total_files_changed: projRecord.totalFiles,
457
+ total_input_tokens: projRecord.totalInputTokens,
458
+ total_output_tokens: projRecord.totalOutputTokens,
459
+ skipped_sessions: [],
460
+ };
461
+ let slug = baseSlug;
462
+ let projectRes = null;
463
+ if (selectedSessionIds.length === 0) {
464
+ for (let attempt = 0; attempt <= MAX_SLUG_RETRIES; attempt++) {
465
+ projectRes = await fetch(`${API_URL}/api/projects`, {
466
+ method: 'POST',
467
+ headers: {
468
+ 'Content-Type': 'application/json',
469
+ Authorization: `Bearer ${auth.token}`,
470
+ },
471
+ body: JSON.stringify({
472
+ project: {
473
+ ...projectBodyBase,
474
+ slug,
475
+ rendered_html: projectHtmlPreview,
476
+ },
477
+ }),
478
+ });
479
+ if (projectRes.status === 409) {
480
+ slug = `${baseSlug}-${attempt + 1}`;
481
+ continue;
482
+ }
483
+ break;
484
+ }
485
+ if (projectRes?.ok) {
486
+ const data = await projectRes.json().catch(() => null);
487
+ if (data?.slug && data.slug !== baseSlug) {
488
+ slugMap.set(baseSlug, data.slug);
489
+ }
490
+ }
491
+ else {
492
+ const errText = await projectRes?.text().catch(() => '');
493
+ console.warn(`[portfolio-upload] project ${rawProj.dirName} create failed:`, projectRes?.status, errText);
494
+ }
495
+ continue;
496
+ }
497
+ for (let attempt = 0; attempt <= MAX_SLUG_RETRIES; attempt++) {
498
+ projectRes = await fetch(`${API_URL}/api/projects`, {
499
+ method: 'POST',
500
+ headers: {
501
+ 'Content-Type': 'application/json',
502
+ Authorization: `Bearer ${auth.token}`,
503
+ },
504
+ body: JSON.stringify({
505
+ project: {
506
+ ...projectBodyBase,
507
+ slug,
508
+ },
509
+ }),
510
+ });
511
+ if (projectRes.status === 409) {
512
+ slug = `${baseSlug}-${attempt + 1}`;
513
+ continue;
514
+ }
515
+ break;
516
+ }
517
+ if (!projectRes || !projectRes.ok) {
518
+ const errText = await projectRes?.text().catch(() => '') ?? '';
519
+ console.warn(`[portfolio-upload] project ${rawProj.dirName} create failed:`, projectRes?.status, errText);
520
+ continue;
521
+ }
522
+ const projectData = await projectRes.json();
523
+ if (projectData.slug !== baseSlug) {
524
+ slugMap.set(baseSlug, projectData.slug);
525
+ }
526
+ send({ type: 'project', project: title, index: projectIndex, total: filteredProjects.length, status: 'created' });
527
+ send({ type: 'progress', message: `Uploading ${selectedSessionIds.length} session${selectedSessionIds.length === 1 ? '' : 's'} for ${title}…` });
528
+ const { uploadedSessionCards } = await uploadSelectedSessions(ctx, auth, {
529
+ proj: projInfo,
530
+ projectData,
531
+ selectedSessionIds,
532
+ sessionStatus: 'listed',
533
+ send: (evt) => send({ ...evt, project: title }),
534
+ });
535
+ // Ensure all existing sessions for this project are listed
536
+ await fetch(`${API_URL}/api/sessions/bulk-status`, {
537
+ method: 'PATCH',
584
538
  headers: {
585
539
  'Content-Type': 'application/json',
586
540
  Authorization: `Bearer ${auth.token}`,
587
541
  },
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,
542
+ body: JSON.stringify({ project_id: projectData.project_id, status: 'listed' }),
543
+ }).catch((e) => console.warn(`[portfolio-upload] bulk-status failed for ${title}:`, e.message));
544
+ if (uploadedSessionCards.length === 0) {
545
+ continue;
546
+ }
547
+ try {
548
+ const detailAfter = buildProjectDetail(ctx.db, rawProj);
549
+ const cacheAfter = detailAfter.enhanceCache ?? cache;
550
+ const totalFiles = detailAfter.project.totalFiles;
551
+ const projectHtmlFinal = generateProjectHtmlFragment(rawProj.dirName, cacheAfter, detailAfter.sessions, auth.username, {
552
+ totalFilesChanged: totalFiles,
553
+ totalInputTokens: detailAfter.project.totalInputTokens,
554
+ totalOutputTokens: detailAfter.project.totalOutputTokens,
555
+ });
556
+ const renderRes = await fetch(`${API_URL}/api/projects`, {
557
+ method: 'POST',
558
+ headers: {
559
+ 'Content-Type': 'application/json',
560
+ Authorization: `Bearer ${auth.token}`,
602
561
  },
603
- }),
562
+ body: JSON.stringify({
563
+ project: {
564
+ ...projectBodyBase,
565
+ slug: projectData.slug,
566
+ rendered_html: projectHtmlFinal,
567
+ },
568
+ }),
569
+ });
570
+ if (!renderRes.ok) {
571
+ console.warn(`[portfolio-upload] project ${rawProj.dirName} rendered_html update failed:`, renderRes.status);
572
+ }
573
+ }
574
+ catch (renderErr) {
575
+ console.warn(`[portfolio-upload] project render update ${rawProj.dirName}:`, renderErr.message);
576
+ }
577
+ const uploadedSessionIds = selectedSessionIds.filter((sid) => {
578
+ const enhanced = loadEnhancedData(sid);
579
+ return enhanced?.uploaded;
580
+ });
581
+ saveUploadedState(rawProj.dirName, {
582
+ slug: projectData.slug,
583
+ projectId: projectData.project_id,
584
+ uploadedSessions: uploadedSessionIds,
604
585
  });
605
586
  }
606
587
  catch (projErr) {
607
588
  console.warn(`[portfolio-upload] skipping project ${rawProj.dirName}:`, projErr.message);
608
589
  }
609
590
  }
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.
591
+ // Demote sessions on projects that were previously published but are
592
+ // no longer included in the portfolio.
593
+ const includedDirNames = new Set(filteredProjects.map((p) => p.dirName));
594
+ const previouslyUploaded = listUploadedProjects();
595
+ for (const { dirName, state } of previouslyUploaded) {
596
+ if (includedDirNames.has(dirName))
597
+ continue;
598
+ try {
599
+ send({ type: 'progress', message: `Demoting sessions for removed project "${dirName}"…` });
600
+ await fetch(`${API_URL}/api/sessions/bulk-status`, {
601
+ method: 'PATCH',
602
+ headers: {
603
+ 'Content-Type': 'application/json',
604
+ Authorization: `Bearer ${auth.token}`,
605
+ },
606
+ body: JSON.stringify({ project_id: state.projectId, status: 'unlisted' }),
607
+ });
608
+ }
609
+ catch (demoteErr) {
610
+ console.warn(`[portfolio-upload] failed to demote sessions for ${dirName}:`, demoteErr.message);
611
+ }
612
+ }
613
+ // Rewrite project links in the portfolio HTML to match the actual
614
+ // Phoenix-assigned slugs (which may differ due to conflict retries).
615
+ let finalHtml = renderedHtml;
616
+ for (const [originalSlug, actualSlug] of slugMap) {
617
+ const pattern = `/${auth.username}/${originalSlug}"`;
618
+ const replacement = `/${auth.username}/${actualSlug}"`;
619
+ while (finalHtml.includes(pattern)) {
620
+ finalHtml = finalHtml.replace(pattern, replacement);
621
+ }
622
+ }
623
+ send({ type: 'progress', message: 'Publishing landing page…' });
613
624
  const phoenixRes = await fetch(`${API_URL}/api/portfolio/upload`, {
614
625
  method: 'POST',
615
626
  headers: {
@@ -617,8 +628,9 @@ export function createPublishRouter(ctx) {
617
628
  Authorization: `Bearer ${auth.token}`,
618
629
  },
619
630
  body: JSON.stringify({
620
- html: renderedHtml,
631
+ html: finalHtml,
621
632
  profile,
633
+ has_photo: Boolean(profile.photoBase64),
622
634
  }),
623
635
  });
624
636
  if (!phoenixRes.ok) {
@@ -635,9 +647,8 @@ export function createPublishRouter(ctx) {
635
647
  lastError: errMsg,
636
648
  lastErrorAt: new Date().toISOString(),
637
649
  });
638
- res.status(phoenixRes.status >= 500 ? 502 : phoenixRes.status).json({
639
- error: { code: 'PORTFOLIO_UPLOAD_FAILED', message: errMsg },
640
- });
650
+ send({ type: 'error', code: 'PORTFOLIO_UPLOAD_FAILED', message: errMsg });
651
+ res.end();
641
652
  return;
642
653
  }
643
654
  const okBody = await phoenixRes.json().catch(() => ({}));
@@ -653,15 +664,15 @@ export function createPublishRouter(ctx) {
653
664
  lastError: undefined,
654
665
  lastErrorAt: undefined,
655
666
  });
656
- // Published version of the portfolio just changed — drop any cached
657
- // /preview/portfolio HTML so the next preview reflects reality.
658
667
  invalidatePortfolioPreviewCache();
659
- res.json({
668
+ send({
669
+ type: 'done',
660
670
  ok: true,
661
671
  url: publishedUrl ?? `${PUBLIC_URL}/${auth.username}`,
662
672
  publishedAt,
663
673
  hash,
664
674
  });
675
+ res.end();
665
676
  }
666
677
  catch (err) {
667
678
  const errMsg = err.message;
@@ -673,9 +684,8 @@ export function createPublishRouter(ctx) {
673
684
  });
674
685
  }
675
686
  catch { /* don't mask the original error */ }
676
- res.status(500).json({
677
- error: { code: 'PORTFOLIO_UPLOAD_FAILED', message: errMsg },
678
- });
687
+ send({ type: 'error', code: 'PORTFOLIO_UPLOAD_FAILED', message: errMsg });
688
+ res.end();
679
689
  }
680
690
  });
681
691
  // Export the portfolio as a downloadable .zip file.
package/dist/settings.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { createHash } from 'node:crypto';
@@ -235,6 +235,19 @@ export function clearUploadedState(projectDirName, configDir) {
235
235
  if (existsSync(path))
236
236
  unlinkSync(path);
237
237
  }
238
+ export function listUploadedProjects(configDir) {
239
+ const dir = uploadedDir(configDir);
240
+ if (!existsSync(dir))
241
+ return [];
242
+ return readdirSync(dir)
243
+ .filter((f) => f.endsWith('.json'))
244
+ .map((f) => {
245
+ const dirName = f.replace(/\.json$/, '');
246
+ const state = getUploadedState(dirName, configDir);
247
+ return state ? { dirName, state } : null;
248
+ })
249
+ .filter((x) => x !== null);
250
+ }
238
251
  const PORTFOLIO_PUBLISH_FILE = 'portfolio-publish.json';
239
252
  const DEFAULT_PORTFOLIO_TARGET = 'heyi.am';
240
253
  function portfolioPublishPath(configDir = getDataDir()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyiam",
3
- "version": "0.3.3",
3
+ "version": "0.3.6",
4
4
  "description": "Turn AI coding sessions into portfolio case studies",
5
5
  "type": "module",
6
6
  "license": "MIT",