heyiam 0.2.29 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +45 -0
  2. package/dist/config.js +10 -1
  3. package/dist/db.js +1 -2
  4. package/dist/export.js +40 -25
  5. package/dist/format-utils.js +5 -0
  6. package/dist/index.js +168 -0
  7. package/dist/mount.js +300 -102
  8. package/dist/parsers/claude.js +2 -28
  9. package/dist/parsers/codex.js +2 -26
  10. package/dist/parsers/cursor.js +2 -26
  11. package/dist/parsers/duration.js +35 -0
  12. package/dist/parsers/gemini.js +2 -20
  13. package/dist/parsers/types.js +0 -1
  14. package/dist/public/assets/index-BZ65TU_Y.js +40 -0
  15. package/dist/public/assets/index-CqCaW2cb.css +1 -0
  16. package/dist/public/index.html +2 -2
  17. package/dist/redact.js +4 -104
  18. package/dist/render/build-render-data.js +9 -2
  19. package/dist/render/index.js +32 -5
  20. package/dist/render/liquid.js +147 -7
  21. package/dist/render/mock-data.js +303 -0
  22. package/dist/render/templates/aurora/portfolio.liquid +204 -0
  23. package/dist/render/templates/aurora/project.liquid +260 -0
  24. package/dist/render/templates/aurora/session.liquid +223 -0
  25. package/dist/render/templates/aurora/styles.css +1178 -0
  26. package/dist/render/templates/bauhaus/portfolio.liquid +179 -0
  27. package/dist/render/templates/bauhaus/project.liquid +300 -0
  28. package/dist/render/templates/bauhaus/session.liquid +333 -0
  29. package/dist/render/templates/bauhaus/styles.css +1641 -0
  30. package/dist/render/templates/blueprint/portfolio.liquid +167 -0
  31. package/dist/render/templates/blueprint/project.liquid +286 -0
  32. package/dist/render/templates/blueprint/session.liquid +248 -0
  33. package/dist/render/templates/blueprint/styles.css +1285 -0
  34. package/dist/render/templates/canvas/portfolio.liquid +215 -0
  35. package/dist/render/templates/canvas/project.liquid +235 -0
  36. package/dist/render/templates/canvas/session.liquid +223 -0
  37. package/dist/render/templates/canvas/styles.css +1436 -0
  38. package/dist/render/templates/carbon/portfolio.liquid +170 -0
  39. package/dist/render/templates/carbon/project.liquid +249 -0
  40. package/dist/render/templates/carbon/session.liquid +190 -0
  41. package/dist/render/templates/carbon/styles.css +1091 -0
  42. package/dist/render/templates/chalk/portfolio.liquid +199 -0
  43. package/dist/render/templates/chalk/project.liquid +245 -0
  44. package/dist/render/templates/chalk/session.liquid +215 -0
  45. package/dist/render/templates/chalk/styles.css +1157 -0
  46. package/dist/render/templates/circuit/portfolio.liquid +162 -0
  47. package/dist/render/templates/circuit/project.liquid +247 -0
  48. package/dist/render/templates/circuit/session.liquid +205 -0
  49. package/dist/render/templates/circuit/styles.css +1403 -0
  50. package/dist/render/templates/cosmos/portfolio.liquid +232 -0
  51. package/dist/render/templates/cosmos/project.liquid +327 -0
  52. package/dist/render/templates/cosmos/session.liquid +239 -0
  53. package/dist/render/templates/cosmos/styles.css +1151 -0
  54. package/dist/render/templates/daylight/portfolio.liquid +217 -0
  55. package/dist/render/templates/daylight/project.liquid +229 -0
  56. package/dist/render/templates/daylight/session.liquid +219 -0
  57. package/dist/render/templates/daylight/styles.css +1311 -0
  58. package/dist/render/templates/editorial/portfolio.liquid +126 -0
  59. package/dist/render/templates/editorial/project.liquid +202 -0
  60. package/dist/render/templates/editorial/session.liquid +171 -0
  61. package/dist/render/templates/editorial/styles.css +822 -0
  62. package/dist/render/templates/ember/portfolio.liquid +318 -0
  63. package/dist/render/templates/ember/project.liquid +232 -0
  64. package/dist/render/templates/ember/session.liquid +202 -0
  65. package/dist/render/templates/ember/styles.css +1283 -0
  66. package/dist/render/templates/glacier/portfolio.liquid +271 -0
  67. package/dist/render/templates/glacier/project.liquid +288 -0
  68. package/dist/render/templates/glacier/session.liquid +217 -0
  69. package/dist/render/templates/glacier/styles.css +1200 -0
  70. package/dist/render/templates/grid/portfolio.liquid +265 -0
  71. package/dist/render/templates/grid/project.liquid +306 -0
  72. package/dist/render/templates/grid/session.liquid +260 -0
  73. package/dist/render/templates/grid/styles.css +1441 -0
  74. package/dist/render/templates/kinetic/portfolio.liquid +170 -0
  75. package/dist/render/templates/kinetic/project.liquid +242 -0
  76. package/dist/render/templates/kinetic/session.liquid +228 -0
  77. package/dist/render/templates/kinetic/styles.css +944 -0
  78. package/dist/render/templates/meridian/portfolio.liquid +255 -0
  79. package/dist/render/templates/meridian/project.liquid +376 -0
  80. package/dist/render/templates/meridian/session.liquid +298 -0
  81. package/dist/render/templates/meridian/styles.css +1369 -0
  82. package/dist/render/templates/minimal/portfolio.liquid +71 -0
  83. package/dist/render/templates/minimal/project.liquid +154 -0
  84. package/dist/render/templates/minimal/session.liquid +140 -0
  85. package/dist/render/templates/minimal/styles.css +525 -0
  86. package/dist/render/templates/mono/portfolio.liquid +291 -0
  87. package/dist/render/templates/mono/project.liquid +275 -0
  88. package/dist/render/templates/mono/session.liquid +276 -0
  89. package/dist/render/templates/mono/styles.css +1016 -0
  90. package/dist/render/templates/neon/portfolio.liquid +217 -0
  91. package/dist/render/templates/neon/project.liquid +225 -0
  92. package/dist/render/templates/neon/session.liquid +195 -0
  93. package/dist/render/templates/neon/styles.css +1265 -0
  94. package/dist/render/templates/noir/portfolio.liquid +137 -0
  95. package/dist/render/templates/noir/project.liquid +220 -0
  96. package/dist/render/templates/noir/session.liquid +241 -0
  97. package/dist/render/templates/noir/styles.css +1223 -0
  98. package/dist/render/templates/obsidian/portfolio.liquid +257 -0
  99. package/dist/render/templates/obsidian/project.liquid +280 -0
  100. package/dist/render/templates/obsidian/session.liquid +241 -0
  101. package/dist/render/templates/obsidian/styles.css +1401 -0
  102. package/dist/render/templates/paper/portfolio.liquid +267 -0
  103. package/dist/render/templates/paper/project.liquid +235 -0
  104. package/dist/render/templates/paper/session.liquid +271 -0
  105. package/dist/render/templates/paper/styles.css +1509 -0
  106. package/dist/render/templates/parallax/portfolio.liquid +305 -0
  107. package/dist/render/templates/parallax/project.liquid +275 -0
  108. package/dist/render/templates/parallax/session.liquid +295 -0
  109. package/dist/render/templates/parallax/styles.css +1874 -0
  110. package/dist/render/templates/parchment/portfolio.liquid +290 -0
  111. package/dist/render/templates/parchment/project.liquid +289 -0
  112. package/dist/render/templates/parchment/session.liquid +346 -0
  113. package/dist/render/templates/parchment/styles.css +1397 -0
  114. package/dist/render/templates/partials/_beats.liquid +16 -0
  115. package/dist/render/templates/partials/_breadcrumb.liquid +9 -0
  116. package/dist/render/templates/partials/_footer.liquid +7 -0
  117. package/dist/render/templates/partials/_growth-chart.liquid +7 -0
  118. package/dist/render/templates/partials/_key-decisions.liquid +20 -0
  119. package/dist/render/templates/partials/_links.liquid +16 -0
  120. package/dist/render/templates/partials/_narrative.liquid +8 -0
  121. package/dist/render/templates/partials/_phases.liquid +20 -0
  122. package/dist/render/templates/partials/_portfolio-header.liquid +20 -0
  123. package/dist/render/templates/partials/_portfolio-projects.liquid +16 -0
  124. package/dist/render/templates/partials/_portfolio-stats.liquid +19 -0
  125. package/dist/render/templates/partials/_qa.liquid +13 -0
  126. package/dist/render/templates/partials/_screenshot.liquid +15 -0
  127. package/dist/render/templates/partials/_session-cards.liquid +30 -0
  128. package/dist/render/templates/partials/_session-header.liquid +39 -0
  129. package/dist/render/templates/partials/_session-sidebar.liquid +30 -0
  130. package/dist/render/templates/partials/_skills.liquid +12 -0
  131. package/dist/render/templates/partials/_source-breakdown.liquid +22 -0
  132. package/dist/render/templates/partials/_stats.liquid +38 -0
  133. package/dist/render/templates/partials/_work-timeline.liquid +7 -0
  134. package/dist/render/templates/project.liquid +7 -4
  135. package/dist/render/templates/radar/portfolio.liquid +233 -0
  136. package/dist/render/templates/radar/project.liquid +278 -0
  137. package/dist/render/templates/radar/session.liquid +300 -0
  138. package/dist/render/templates/radar/styles.css +1049 -0
  139. package/dist/render/templates/showcase/portfolio.liquid +231 -0
  140. package/dist/render/templates/showcase/project.liquid +237 -0
  141. package/dist/render/templates/showcase/session.liquid +210 -0
  142. package/dist/render/templates/showcase/styles.css +1279 -0
  143. package/dist/render/templates/signal/portfolio.liquid +227 -0
  144. package/dist/render/templates/signal/project.liquid +278 -0
  145. package/dist/render/templates/signal/session.liquid +282 -0
  146. package/dist/render/templates/signal/styles.css +1395 -0
  147. package/dist/render/templates/strata/portfolio.liquid +192 -0
  148. package/dist/render/templates/strata/project.liquid +282 -0
  149. package/dist/render/templates/strata/session.liquid +261 -0
  150. package/dist/render/templates/strata/styles.css +1350 -0
  151. package/dist/render/templates/styles.css +1190 -0
  152. package/dist/render/templates/terminal/portfolio.liquid +118 -0
  153. package/dist/render/templates/terminal/project.liquid +161 -0
  154. package/dist/render/templates/terminal/session.liquid +145 -0
  155. package/dist/render/templates/terminal/styles.css +492 -0
  156. package/dist/render/templates/verdant/portfolio.liquid +333 -0
  157. package/dist/render/templates/verdant/project.liquid +309 -0
  158. package/dist/render/templates/verdant/session.liquid +237 -0
  159. package/dist/render/templates/verdant/styles.css +1257 -0
  160. package/dist/render/templates/zen/portfolio.liquid +136 -0
  161. package/dist/render/templates/zen/project.liquid +187 -0
  162. package/dist/render/templates/zen/session.liquid +203 -0
  163. package/dist/render/templates/zen/styles.css +1207 -0
  164. package/dist/render/templates.js +90 -0
  165. package/dist/routes/context.js +15 -10
  166. package/dist/routes/enhance.js +17 -40
  167. package/dist/routes/export.js +14 -4
  168. package/dist/routes/preview.js +480 -108
  169. package/dist/routes/projects.js +11 -19
  170. package/dist/routes/publish.js +15 -17
  171. package/dist/routes/settings.js +94 -1
  172. package/dist/routes/sse.js +9 -0
  173. package/dist/server.js +8 -2
  174. package/dist/settings.js +17 -9
  175. package/package.json +2 -4
  176. package/dist/public/assets/index-CC9G8EF1.js +0 -21
  177. package/dist/public/assets/index-Dalqz2mC.css +0 -1
@@ -1,6 +1,6 @@
1
1
  import { Router } from 'express';
2
2
  import { statSync } from 'node:fs';
3
- import { buildSessionList, buildProjectDetail } from './context.js';
3
+ import { requireProject, buildSessionList, buildProjectDetail } from './context.js';
4
4
  export function createProjectsRouter(ctx) {
5
5
  const router = Router();
6
6
  router.get('/api/projects', async (_req, res) => {
@@ -18,13 +18,10 @@ export function createProjectsRouter(ctx) {
18
18
  // Aggregated project detail for the hub screen
19
19
  router.get('/api/projects/:project/detail', async (req, res) => {
20
20
  try {
21
- const { project } = req.params;
22
- const projects = await ctx.getProjects();
23
- const proj = projects.find((p) => p.name === project || p.dirName === project);
24
- if (!proj) {
25
- res.status(404).json({ error: 'Project not found' });
21
+ const project = String(req.params.project);
22
+ const proj = await requireProject(ctx, project, res);
23
+ if (!proj)
26
24
  return;
27
- }
28
25
  res.json(buildProjectDetail(ctx.db, proj));
29
26
  }
30
27
  catch (err) {
@@ -95,13 +92,11 @@ export function createProjectsRouter(ctx) {
95
92
  });
96
93
  router.get('/api/projects/:project/sessions/:id', async (req, res) => {
97
94
  try {
98
- const { project, id } = req.params;
99
- const projects = await ctx.getProjects();
100
- const proj = projects.find((p) => p.name === project || p.dirName === project);
101
- if (!proj) {
102
- res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
95
+ const project = String(req.params.project);
96
+ const id = String(req.params.id);
97
+ const proj = await requireProject(ctx, project, res);
98
+ if (!proj)
103
99
  return;
104
- }
105
100
  const meta = proj.sessions.find((s) => s.sessionId === id);
106
101
  if (!meta) {
107
102
  res.status(404).json({ error: { code: 'SESSION_NOT_FOUND', message: 'Session not found' } });
@@ -140,13 +135,10 @@ export function createProjectsRouter(ctx) {
140
135
  router.get('/api/projects/:project/git-remote', async (req, res) => {
141
136
  const { execFileSync } = await import('node:child_process');
142
137
  try {
143
- const { project } = req.params;
144
- const projects = await ctx.getProjects();
145
- const proj = projects.find((p) => p.name === project || p.dirName === project);
146
- if (!proj) {
147
- res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
138
+ const project = String(req.params.project);
139
+ const proj = await requireProject(ctx, project, res);
140
+ if (!proj)
148
141
  return;
149
- }
150
142
  let projectPath = null;
151
143
  for (const meta of proj.sessions) {
152
144
  try {
@@ -2,14 +2,16 @@ import { Router } from 'express';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { getAuthToken } from '../auth.js';
5
- import { API_URL, PUBLIC_URL } from '../config.js';
6
- import { loadEnhancedData, saveEnhancedData, saveUploadedState } from '../settings.js';
5
+ import { API_URL, PUBLIC_URL, warnIfNonDefaultApiUrl } from '../config.js';
6
+ import { loadEnhancedData, saveEnhancedData, saveUploadedState, getDefaultTemplate } from '../settings.js';
7
7
  import { captureScreenshot } from '../screenshot.js';
8
8
  import { redactSession, redactText, scanTextSync, formatFindings, stripHomePathsInText } from '../redact.js';
9
9
  import { renderProjectHtml, renderSessionHtml } from '../render/index.js';
10
10
  import { buildSessionRenderData, buildSessionCard, buildProjectRenderData } from '../render/build-render-data.js';
11
11
  import { buildAgentSummary } from './context.js';
12
+ import { startSSE } from './sse.js';
12
13
  import { displayNameFromDir } from '../sync.js';
14
+ import { toSlug } from '../format-utils.js';
13
15
  import { getProjectUuid, getFileCountWithChildren } from '../db.js';
14
16
  const IMAGE_KEY_PREFIX = 'images/';
15
17
  export function createPublishRouter(ctx) {
@@ -32,7 +34,8 @@ export function createPublishRouter(ctx) {
32
34
  totalTokens,
33
35
  sessionCards: sessionCards || [],
34
36
  });
35
- const html = renderProjectHtml(renderData);
37
+ const templateName = getDefaultTemplate() || 'editorial';
38
+ const html = renderProjectHtml(renderData, undefined, templateName);
36
39
  res.json({ html });
37
40
  }
38
41
  catch (err) {
@@ -65,6 +68,7 @@ export function createPublishRouter(ctx) {
65
68
  router.post('/api/projects/:project/upload', async (req, res) => {
66
69
  const { project } = req.params;
67
70
  const auth = getAuthToken();
71
+ warnIfNonDefaultApiUrl();
68
72
  if (!auth) {
69
73
  res.status(401).json({ error: { message: 'Authentication required' } });
70
74
  return;
@@ -72,18 +76,11 @@ export function createPublishRouter(ctx) {
72
76
  const { title: rawTitle, slug: rawSlug, narrative, repoUrl, projectUrl, timeline, skills, totalSessions, totalLoc, totalDurationMinutes, totalAgentDurationMinutes, totalFilesChanged, skippedSessions, selectedSessionIds, screenshotBase64, } = req.body;
73
77
  // Ensure slug is the short project name, not the full encoded directory path
74
78
  const shortName = displayNameFromDir(String(project));
75
- const baseSlug = shortName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
79
+ const baseSlug = toSlug(shortName);
76
80
  const title = rawTitle === rawSlug ? shortName : rawTitle;
77
81
  // Get stable project UUID from CLI database
78
82
  const clientProjectId = getProjectUuid(ctx.db, String(project));
79
- res.writeHead(200, {
80
- 'Content-Type': 'text/event-stream',
81
- 'Cache-Control': 'no-cache',
82
- Connection: 'keep-alive',
83
- });
84
- const send = (data) => {
85
- res.write(`data: ${JSON.stringify(data)}\n\n`);
86
- };
83
+ const send = startSSE(res);
87
84
  try {
88
85
  // Step 1: Upsert project on Phoenix (with slug conflict retry)
89
86
  send({ type: 'project', status: 'creating' });
@@ -207,6 +204,7 @@ export function createPublishRouter(ctx) {
207
204
  const failedSessions = [];
208
205
  const uploadedSessionCards = [];
209
206
  if (proj) {
207
+ const selectedTemplate = getDefaultTemplate() || 'editorial';
210
208
  for (const sessionId of selectedSessionIds) {
211
209
  const meta = proj.sessions.find((s) => s.sessionId === sessionId);
212
210
  if (!meta)
@@ -215,8 +213,7 @@ export function createPublishRouter(ctx) {
215
213
  try {
216
214
  const session = await ctx.loadSession(meta.path, proj.name, sessionId);
217
215
  const enhanced = loadEnhancedData(sessionId);
218
- const sessionSlug = (enhanced?.title ?? session.title ?? sessionId)
219
- .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80);
216
+ const sessionSlug = toSlug(enhanced?.title ?? session.title ?? sessionId, 80);
220
217
  const agentSummary = await buildAgentSummary(meta.children ?? [], (c) => ctx.getSessionStats(c, proj.name), { deduplicate: true });
221
218
  const devTake = (enhanced?.developerTake ?? session.developerTake ?? '').slice(0, 2000);
222
219
  const sessionNarrative = enhanced?.narrative ?? '';
@@ -233,11 +230,12 @@ export function createPublishRouter(ctx) {
233
230
  sessionSlug,
234
231
  sourceTool: sessionSourceTool,
235
232
  agentSummary,
233
+ template: selectedTemplate,
236
234
  };
237
235
  let sessionRenderedHtml = null;
238
236
  try {
239
237
  const sessionRenderData = buildSessionRenderData(renderOpts);
240
- sessionRenderedHtml = renderSessionHtml(sessionRenderData);
238
+ sessionRenderedHtml = renderSessionHtml(sessionRenderData, selectedTemplate);
241
239
  }
242
240
  catch (renderErr) {
243
241
  console.error(`[upload] Session render failed for ${sessionId}:`, renderErr.message);
@@ -259,7 +257,7 @@ export function createPublishRouter(ctx) {
259
257
  end_time: session.endTime ? new Date(session.endTime).toISOString() : null,
260
258
  cwd: session.cwd ?? null,
261
259
  wall_clock_minutes: session.wallClockMinutes ?? null,
262
- template: 'editorial',
260
+ template: selectedTemplate,
263
261
  language: null,
264
262
  tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
265
263
  skills: sessionSkills,
@@ -293,7 +291,7 @@ export function createPublishRouter(ctx) {
293
291
  })(),
294
292
  cwd: session.cwd ?? null,
295
293
  wall_clock_minutes: session.wallClockMinutes ?? null,
296
- template: 'editorial',
294
+ template: selectedTemplate,
297
295
  skills: sessionSkills,
298
296
  tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
299
297
  source: sessionSourceTool,
@@ -1,6 +1,7 @@
1
1
  import { Router } from 'express';
2
- import { saveAnthropicApiKey, clearAnthropicApiKey, getAnthropicApiKey } from '../settings.js';
2
+ import { saveAnthropicApiKey, clearAnthropicApiKey, getAnthropicApiKey, getSettings, setDefaultTemplate, getPortfolioProfile, savePortfolioProfile } from '../settings.js';
3
3
  import { hasApiKey } from '../llm/index.js';
4
+ import { isValidTemplate, DEFAULT_TEMPLATE, BUILT_IN_TEMPLATES } from '../render/templates.js';
4
5
  export function createSettingsRouter(_ctx) {
5
6
  const router = Router();
6
7
  // Save or clear the Anthropic API key
@@ -25,5 +26,97 @@ export function createSettingsRouter(_ctx) {
25
26
  maskedKey: key ? `...${key.slice(-4)}` : null,
26
27
  });
27
28
  });
29
+ // List available templates
30
+ router.get('/api/templates', (_req, res) => {
31
+ const templates = BUILT_IN_TEMPLATES.map((t) => ({
32
+ name: t.name,
33
+ label: t.label,
34
+ description: t.description,
35
+ accent: t.accent,
36
+ mode: t.mode,
37
+ tags: t.tags,
38
+ builtIn: true,
39
+ }));
40
+ res.json({ templates });
41
+ });
42
+ // Get current portfolio theme
43
+ router.get('/api/settings/theme', (_req, res) => {
44
+ const settings = getSettings();
45
+ res.json({ template: settings.defaultTemplate ?? DEFAULT_TEMPLATE });
46
+ });
47
+ // Set portfolio theme
48
+ router.post('/api/settings/theme', (req, res) => {
49
+ const { template } = req.body;
50
+ if (!template || !isValidTemplate(template)) {
51
+ res.status(400).json({ error: 'Invalid template name' });
52
+ return;
53
+ }
54
+ setDefaultTemplate(template);
55
+ console.log(`[settings] Portfolio theme set to: ${template}`);
56
+ res.json({ ok: true, template });
57
+ });
58
+ // Get portfolio profile data
59
+ router.get('/api/portfolio', (_req, res) => {
60
+ res.json(getPortfolioProfile());
61
+ });
62
+ // Save portfolio profile data
63
+ router.post('/api/portfolio', (req, res) => {
64
+ const body = req.body;
65
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
66
+ res.status(400).json({ error: { code: 'INVALID_BODY', message: 'Request body must be a JSON object' } });
67
+ return;
68
+ }
69
+ const ALLOWED_FIELDS = [
70
+ 'displayName', 'bio', 'photoBase64', 'location', 'email', 'phone',
71
+ 'linkedinUrl', 'githubUrl', 'twitterHandle', 'websiteUrl',
72
+ 'resumeBase64', 'resumeFilename',
73
+ ];
74
+ const errors = [];
75
+ // Structural validation: only allow known string fields
76
+ const cleaned = {};
77
+ for (const key of ALLOWED_FIELDS) {
78
+ const val = body[key];
79
+ if (val === undefined || val === null || val === '')
80
+ continue;
81
+ if (typeof val !== 'string') {
82
+ errors.push({ field: key, message: `${key} must be a string` });
83
+ continue;
84
+ }
85
+ cleaned[key] = val;
86
+ }
87
+ // Length limits
88
+ if (cleaned.displayName && cleaned.displayName.length > 200) {
89
+ errors.push({ field: 'displayName', message: 'Display name must be under 200 characters' });
90
+ }
91
+ if (cleaned.bio && cleaned.bio.length > 2000) {
92
+ errors.push({ field: 'bio', message: 'Bio must be under 2000 characters' });
93
+ }
94
+ if (cleaned.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleaned.email)) {
95
+ errors.push({ field: 'email', message: 'Invalid email format' });
96
+ }
97
+ if (cleaned.linkedinUrl && !cleaned.linkedinUrl.startsWith('http')) {
98
+ errors.push({ field: 'linkedinUrl', message: 'LinkedIn URL must start with http' });
99
+ }
100
+ if (cleaned.githubUrl && !cleaned.githubUrl.startsWith('http')) {
101
+ errors.push({ field: 'githubUrl', message: 'GitHub URL must start with http' });
102
+ }
103
+ if (cleaned.websiteUrl && !cleaned.websiteUrl.startsWith('http')) {
104
+ errors.push({ field: 'websiteUrl', message: 'Website URL must start with http' });
105
+ }
106
+ // File size limits (base64 ~1.37x raw; cap photo at ~5MB, resume at ~10MB)
107
+ if (cleaned.photoBase64 && cleaned.photoBase64.length > 7_000_000) {
108
+ errors.push({ field: 'photoBase64', message: 'Photo must be under 5MB' });
109
+ }
110
+ if (cleaned.resumeBase64 && cleaned.resumeBase64.length > 14_000_000) {
111
+ errors.push({ field: 'resumeBase64', message: 'Resume must be under 10MB' });
112
+ }
113
+ if (errors.length > 0) {
114
+ res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Validation failed', fields: errors } });
115
+ return;
116
+ }
117
+ savePortfolioProfile(cleaned);
118
+ console.log('[settings] Portfolio profile saved');
119
+ res.json({ ok: true });
120
+ });
28
121
  return router;
29
122
  }
@@ -0,0 +1,9 @@
1
+ /** Set up an SSE response and return a typed send helper. */
2
+ export function startSSE(res) {
3
+ res.writeHead(200, {
4
+ 'Content-Type': 'text/event-stream',
5
+ 'Cache-Control': 'no-cache',
6
+ Connection: 'keep-alive',
7
+ });
8
+ return (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
9
+ }
package/dist/server.js CHANGED
@@ -81,9 +81,15 @@ export function createApp(sessionsBasePath, dbPath) {
81
81
  if (process.env.NODE_ENV !== 'production')
82
82
  corsOrigins.push('http://localhost:5173');
83
83
  app.use(cors({ origin: corsOrigins }));
84
- app.use((_req, res, next) => {
84
+ app.use((req, res, next) => {
85
85
  res.setHeader('X-Content-Type-Options', 'nosniff');
86
- res.setHeader('X-Frame-Options', 'DENY');
86
+ // Allow same-origin framing for preview pages (used by template browser iframes)
87
+ if (req.path.startsWith('/preview/')) {
88
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
89
+ }
90
+ else {
91
+ res.setHeader('X-Frame-Options', 'DENY');
92
+ }
87
93
  next();
88
94
  });
89
95
  app.use(express.json({ limit: '10mb' }));
package/dist/settings.js CHANGED
@@ -34,6 +34,14 @@ export function clearAnthropicApiKey(configDir) {
34
34
  delete settings.anthropicApiKey;
35
35
  writeConfig(SETTINGS_FILE, settings, configDir);
36
36
  }
37
+ export function getDefaultTemplate(configDir) {
38
+ return getSettings(configDir).defaultTemplate;
39
+ }
40
+ export function setDefaultTemplate(templateName, configDir) {
41
+ const settings = getSettings(configDir);
42
+ settings.defaultTemplate = templateName;
43
+ writeConfig(SETTINGS_FILE, settings, configDir);
44
+ }
37
45
  export function isOnboardingComplete(configDir) {
38
46
  return !!getSettings(configDir).onboardingCompletedAt;
39
47
  }
@@ -47,6 +55,15 @@ export function resetOnboarding(configDir) {
47
55
  delete settings.onboardingCompletedAt;
48
56
  writeConfig(SETTINGS_FILE, settings, configDir);
49
57
  }
58
+ // ── Portfolio profile ────────────────────────────────────────
59
+ export function getPortfolioProfile(configDir) {
60
+ return getSettings(configDir).portfolio ?? {};
61
+ }
62
+ export function savePortfolioProfile(data, configDir) {
63
+ const settings = getSettings(configDir);
64
+ settings.portfolio = data;
65
+ writeConfig(SETTINGS_FILE, settings, configDir);
66
+ }
50
67
  /**
51
68
  * Returns the Anthropic API key from settings file or env var.
52
69
  * Env var takes precedence.
@@ -81,15 +98,6 @@ export function loadEnhancedData(sessionId, configDir) {
81
98
  return null;
82
99
  }
83
100
  }
84
- export function markAsUploaded(sessionId, configDir) {
85
- const data = loadEnhancedData(sessionId, configDir);
86
- if (!data)
87
- return;
88
- data.uploaded = true;
89
- const dir = enhancedDir(configDir);
90
- mkdirSync(dir, { recursive: true });
91
- writeFileSync(enhancedPath(sessionId, configDir), JSON.stringify(data, null, 2), { mode: 0o600 });
92
- }
93
101
  export function deleteEnhancedData(sessionId, configDir) {
94
102
  const path = enhancedPath(sessionId, configDir);
95
103
  if (existsSync(path))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyiam",
3
- "version": "0.2.29",
3
+ "version": "0.3.0",
4
4
  "description": "Turn AI coding sessions into portfolio case studies",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,13 +31,11 @@
31
31
  "dev:api": "tsx watch src/index.ts open --no-open",
32
32
  "dev:app": "cd app && npm run dev",
33
33
  "test": "vitest run && cd app && npx vitest run",
34
- "test:backend": "vitest run",
34
+ "test:backend": "vitest run --exclude src/build-integrity.test.ts",
35
35
  "test:frontend": "cd app && npx vitest run"
36
36
  },
37
37
  "dependencies": {
38
38
  "@anthropic-ai/sdk": "^0.79.0",
39
- "@secretlint/node": "^11.4.0",
40
- "@secretlint/secretlint-rule-preset-recommend": "^11.4.0",
41
39
  "better-sqlite3": "^12.8.0",
42
40
  "commander": "^13.1.0",
43
41
  "cors": "^2.8.6",