heyiam 0.2.29 → 0.3.1

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 (186) hide show
  1. package/README.md +45 -0
  2. package/dist/auth.js +29 -3
  3. package/dist/config.js +10 -1
  4. package/dist/db.js +0 -1
  5. package/dist/export.js +124 -27
  6. package/dist/format-utils.js +5 -0
  7. package/dist/github.js +381 -0
  8. package/dist/index.js +168 -0
  9. package/dist/mount.js +300 -102
  10. package/dist/parsers/claude.js +2 -28
  11. package/dist/parsers/codex.js +2 -26
  12. package/dist/parsers/cursor.js +2 -26
  13. package/dist/parsers/duration.js +35 -0
  14. package/dist/parsers/gemini.js +2 -20
  15. package/dist/parsers/index.js +22 -3
  16. package/dist/parsers/types.js +0 -1
  17. package/dist/public/assets/index-Coilyhtr.css +1 -0
  18. package/dist/public/assets/index-D0noVMFu.js +44 -0
  19. package/dist/public/index.html +2 -2
  20. package/dist/redact.js +4 -104
  21. package/dist/render/build-render-data.js +9 -2
  22. package/dist/render/index.js +32 -5
  23. package/dist/render/liquid.js +147 -7
  24. package/dist/render/mock-data.js +303 -0
  25. package/dist/render/templates/aurora/portfolio.liquid +192 -0
  26. package/dist/render/templates/aurora/project.liquid +260 -0
  27. package/dist/render/templates/aurora/session.liquid +223 -0
  28. package/dist/render/templates/aurora/styles.css +1184 -0
  29. package/dist/render/templates/bauhaus/portfolio.liquid +169 -0
  30. package/dist/render/templates/bauhaus/project.liquid +300 -0
  31. package/dist/render/templates/bauhaus/session.liquid +333 -0
  32. package/dist/render/templates/bauhaus/styles.css +1645 -0
  33. package/dist/render/templates/blueprint/portfolio.liquid +153 -0
  34. package/dist/render/templates/blueprint/project.liquid +286 -0
  35. package/dist/render/templates/blueprint/session.liquid +248 -0
  36. package/dist/render/templates/blueprint/styles.css +1289 -0
  37. package/dist/render/templates/canvas/portfolio.liquid +203 -0
  38. package/dist/render/templates/canvas/project.liquid +235 -0
  39. package/dist/render/templates/canvas/session.liquid +223 -0
  40. package/dist/render/templates/canvas/styles.css +1440 -0
  41. package/dist/render/templates/carbon/portfolio.liquid +160 -0
  42. package/dist/render/templates/carbon/project.liquid +249 -0
  43. package/dist/render/templates/carbon/session.liquid +190 -0
  44. package/dist/render/templates/carbon/styles.css +1097 -0
  45. package/dist/render/templates/chalk/portfolio.liquid +189 -0
  46. package/dist/render/templates/chalk/project.liquid +245 -0
  47. package/dist/render/templates/chalk/session.liquid +215 -0
  48. package/dist/render/templates/chalk/styles.css +1161 -0
  49. package/dist/render/templates/circuit/portfolio.liquid +152 -0
  50. package/dist/render/templates/circuit/project.liquid +247 -0
  51. package/dist/render/templates/circuit/session.liquid +205 -0
  52. package/dist/render/templates/circuit/styles.css +1409 -0
  53. package/dist/render/templates/cosmos/portfolio.liquid +222 -0
  54. package/dist/render/templates/cosmos/project.liquid +327 -0
  55. package/dist/render/templates/cosmos/session.liquid +239 -0
  56. package/dist/render/templates/cosmos/styles.css +1157 -0
  57. package/dist/render/templates/daylight/portfolio.liquid +207 -0
  58. package/dist/render/templates/daylight/project.liquid +229 -0
  59. package/dist/render/templates/daylight/session.liquid +219 -0
  60. package/dist/render/templates/daylight/styles.css +1315 -0
  61. package/dist/render/templates/editorial/portfolio.liquid +110 -0
  62. package/dist/render/templates/editorial/project.liquid +202 -0
  63. package/dist/render/templates/editorial/session.liquid +171 -0
  64. package/dist/render/templates/editorial/styles.css +826 -0
  65. package/dist/render/templates/ember/portfolio.liquid +306 -0
  66. package/dist/render/templates/ember/project.liquid +232 -0
  67. package/dist/render/templates/ember/session.liquid +202 -0
  68. package/dist/render/templates/ember/styles.css +1289 -0
  69. package/dist/render/templates/glacier/portfolio.liquid +261 -0
  70. package/dist/render/templates/glacier/project.liquid +288 -0
  71. package/dist/render/templates/glacier/session.liquid +217 -0
  72. package/dist/render/templates/glacier/styles.css +1204 -0
  73. package/dist/render/templates/grid/portfolio.liquid +255 -0
  74. package/dist/render/templates/grid/project.liquid +306 -0
  75. package/dist/render/templates/grid/session.liquid +260 -0
  76. package/dist/render/templates/grid/styles.css +1445 -0
  77. package/dist/render/templates/kinetic/portfolio.liquid +158 -0
  78. package/dist/render/templates/kinetic/project.liquid +242 -0
  79. package/dist/render/templates/kinetic/session.liquid +228 -0
  80. package/dist/render/templates/kinetic/styles.css +948 -0
  81. package/dist/render/templates/meridian/portfolio.liquid +243 -0
  82. package/dist/render/templates/meridian/project.liquid +376 -0
  83. package/dist/render/templates/meridian/session.liquid +298 -0
  84. package/dist/render/templates/meridian/styles.css +1375 -0
  85. package/dist/render/templates/minimal/portfolio.liquid +71 -0
  86. package/dist/render/templates/minimal/project.liquid +154 -0
  87. package/dist/render/templates/minimal/session.liquid +140 -0
  88. package/dist/render/templates/minimal/styles.css +529 -0
  89. package/dist/render/templates/mono/portfolio.liquid +281 -0
  90. package/dist/render/templates/mono/project.liquid +275 -0
  91. package/dist/render/templates/mono/session.liquid +276 -0
  92. package/dist/render/templates/mono/styles.css +1022 -0
  93. package/dist/render/templates/neon/portfolio.liquid +207 -0
  94. package/dist/render/templates/neon/project.liquid +225 -0
  95. package/dist/render/templates/neon/session.liquid +195 -0
  96. package/dist/render/templates/neon/styles.css +1271 -0
  97. package/dist/render/templates/noir/portfolio.liquid +137 -0
  98. package/dist/render/templates/noir/project.liquid +220 -0
  99. package/dist/render/templates/noir/session.liquid +241 -0
  100. package/dist/render/templates/noir/styles.css +1229 -0
  101. package/dist/render/templates/obsidian/portfolio.liquid +247 -0
  102. package/dist/render/templates/obsidian/project.liquid +280 -0
  103. package/dist/render/templates/obsidian/session.liquid +241 -0
  104. package/dist/render/templates/obsidian/styles.css +1407 -0
  105. package/dist/render/templates/paper/portfolio.liquid +257 -0
  106. package/dist/render/templates/paper/project.liquid +235 -0
  107. package/dist/render/templates/paper/session.liquid +271 -0
  108. package/dist/render/templates/paper/styles.css +1513 -0
  109. package/dist/render/templates/parallax/portfolio.liquid +295 -0
  110. package/dist/render/templates/parallax/project.liquid +275 -0
  111. package/dist/render/templates/parallax/session.liquid +295 -0
  112. package/dist/render/templates/parallax/styles.css +1880 -0
  113. package/dist/render/templates/parchment/portfolio.liquid +280 -0
  114. package/dist/render/templates/parchment/project.liquid +289 -0
  115. package/dist/render/templates/parchment/session.liquid +346 -0
  116. package/dist/render/templates/parchment/styles.css +1401 -0
  117. package/dist/render/templates/partials/_beats.liquid +16 -0
  118. package/dist/render/templates/partials/_breadcrumb.liquid +9 -0
  119. package/dist/render/templates/partials/_footer.liquid +7 -0
  120. package/dist/render/templates/partials/_growth-chart.liquid +7 -0
  121. package/dist/render/templates/partials/_key-decisions.liquid +20 -0
  122. package/dist/render/templates/partials/_links.liquid +16 -0
  123. package/dist/render/templates/partials/_narrative.liquid +8 -0
  124. package/dist/render/templates/partials/_phases.liquid +20 -0
  125. package/dist/render/templates/partials/_portfolio-header.liquid +20 -0
  126. package/dist/render/templates/partials/_portfolio-projects.liquid +16 -0
  127. package/dist/render/templates/partials/_portfolio-stats.liquid +19 -0
  128. package/dist/render/templates/partials/_qa.liquid +13 -0
  129. package/dist/render/templates/partials/_screenshot.liquid +15 -0
  130. package/dist/render/templates/partials/_session-cards.liquid +30 -0
  131. package/dist/render/templates/partials/_session-header.liquid +39 -0
  132. package/dist/render/templates/partials/_session-sidebar.liquid +30 -0
  133. package/dist/render/templates/partials/_skills.liquid +12 -0
  134. package/dist/render/templates/partials/_source-breakdown.liquid +22 -0
  135. package/dist/render/templates/partials/_stats.liquid +38 -0
  136. package/dist/render/templates/partials/_work-timeline.liquid +7 -0
  137. package/dist/render/templates/project.liquid +7 -4
  138. package/dist/render/templates/radar/portfolio.liquid +223 -0
  139. package/dist/render/templates/radar/project.liquid +278 -0
  140. package/dist/render/templates/radar/session.liquid +300 -0
  141. package/dist/render/templates/radar/styles.css +1055 -0
  142. package/dist/render/templates/showcase/portfolio.liquid +221 -0
  143. package/dist/render/templates/showcase/project.liquid +237 -0
  144. package/dist/render/templates/showcase/session.liquid +210 -0
  145. package/dist/render/templates/showcase/styles.css +1284 -0
  146. package/dist/render/templates/signal/portfolio.liquid +217 -0
  147. package/dist/render/templates/signal/project.liquid +278 -0
  148. package/dist/render/templates/signal/session.liquid +282 -0
  149. package/dist/render/templates/signal/styles.css +1401 -0
  150. package/dist/render/templates/strata/portfolio.liquid +180 -0
  151. package/dist/render/templates/strata/project.liquid +282 -0
  152. package/dist/render/templates/strata/session.liquid +261 -0
  153. package/dist/render/templates/strata/styles.css +1354 -0
  154. package/dist/render/templates/styles.css +1190 -0
  155. package/dist/render/templates/terminal/portfolio.liquid +102 -0
  156. package/dist/render/templates/terminal/project.liquid +161 -0
  157. package/dist/render/templates/terminal/session.liquid +145 -0
  158. package/dist/render/templates/terminal/styles.css +497 -0
  159. package/dist/render/templates/verdant/portfolio.liquid +321 -0
  160. package/dist/render/templates/verdant/project.liquid +309 -0
  161. package/dist/render/templates/verdant/session.liquid +237 -0
  162. package/dist/render/templates/verdant/styles.css +1261 -0
  163. package/dist/render/templates/zen/portfolio.liquid +124 -0
  164. package/dist/render/templates/zen/project.liquid +187 -0
  165. package/dist/render/templates/zen/session.liquid +203 -0
  166. package/dist/render/templates/zen/styles.css +1211 -0
  167. package/dist/render/templates.js +90 -0
  168. package/dist/routes/auth.js +7 -3
  169. package/dist/routes/context.js +17 -10
  170. package/dist/routes/delete.js +195 -0
  171. package/dist/routes/enhance.js +57 -40
  172. package/dist/routes/export.js +14 -4
  173. package/dist/routes/github.js +254 -0
  174. package/dist/routes/index.js +2 -0
  175. package/dist/routes/portfolio-render-data.js +160 -0
  176. package/dist/routes/preview.js +555 -108
  177. package/dist/routes/projects.js +61 -24
  178. package/dist/routes/publish.js +320 -31
  179. package/dist/routes/settings.js +194 -1
  180. package/dist/routes/sse.js +9 -0
  181. package/dist/search.js +6 -0
  182. package/dist/server.js +11 -3
  183. package/dist/settings.js +112 -9
  184. package/package.json +3 -4
  185. package/dist/public/assets/index-CC9G8EF1.js +0 -21
  186. package/dist/public/assets/index-Dalqz2mC.css +0 -1
package/dist/github.js ADDED
@@ -0,0 +1,381 @@
1
+ // GitHub integration: OAuth device flow, keychain-backed token storage,
2
+ // repo listing, and Git Data API tree-push to publish a static site to
3
+ // GitHub Pages. All pure logic — no Express. Routes live in
4
+ // `routes/github.ts`.
5
+ //
6
+ // Secret handling rules (see trc-secrets-management):
7
+ // * Tokens are stored ONLY in the OS keychain via keytar. Never written
8
+ // to disk in plaintext, never logged, never returned in responses.
9
+ // * Errors are sanitized before surfacing — if the GitHub API echoes
10
+ // the token back in an error body, we do not propagate that body.
11
+ // * Device codes are short-lived; only the resulting access token is
12
+ // persisted.
13
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
14
+ import { join, relative, sep as pathSep } from 'node:path';
15
+ // TODO(phase-5-launch): replace with real client_id registered for the
16
+ // heyi.am CLI OAuth App before merging to main. Founder owns this.
17
+ export const GITHUB_OAUTH_CLIENT_ID = 'Iv1.PLACEHOLDER_CLIENT_ID';
18
+ const KEYTAR_SERVICE = 'heyiam';
19
+ const KEYTAR_ACCOUNT = 'github';
20
+ const GITHUB_API = 'https://api.github.com';
21
+ const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code';
22
+ const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
23
+ export class GitHubError extends Error {
24
+ code;
25
+ status;
26
+ constructor(code, message, status) {
27
+ super(message);
28
+ this.name = 'GitHubError';
29
+ this.code = code;
30
+ this.status = status;
31
+ }
32
+ }
33
+ function sanitizeMessage(msg, token) {
34
+ let out = msg;
35
+ if (token)
36
+ out = out.split(token).join('[redacted]');
37
+ return out;
38
+ }
39
+ // ── Fetch helpers ──────────────────────────────────────────────────────
40
+ async function ghFetch(url, init) {
41
+ const { token, ...rest } = init;
42
+ const headers = new Headers(rest.headers);
43
+ headers.set('Accept', 'application/vnd.github+json');
44
+ headers.set('X-GitHub-Api-Version', '2022-11-28');
45
+ headers.set('User-Agent', 'heyiam-cli');
46
+ if (token)
47
+ headers.set('Authorization', `Bearer ${token}`);
48
+ return fetch(url, { ...rest, headers });
49
+ }
50
+ async function ghJson(url, init, code = 'GITHUB_API_FAILED') {
51
+ const res = await ghFetch(url, init);
52
+ if (!res.ok) {
53
+ let detail = `HTTP ${res.status}`;
54
+ try {
55
+ const body = await res.json();
56
+ if (body?.message)
57
+ detail = body.message;
58
+ }
59
+ catch { /* non-JSON body */ }
60
+ throw new GitHubError(code, sanitizeMessage(detail, init.token), res.status);
61
+ }
62
+ return res.json();
63
+ }
64
+ // ── OAuth device flow ──────────────────────────────────────────────────
65
+ export async function requestDeviceCode(scopes) {
66
+ const res = await fetch(GITHUB_DEVICE_CODE_URL, {
67
+ method: 'POST',
68
+ headers: {
69
+ Accept: 'application/json',
70
+ 'Content-Type': 'application/json',
71
+ 'User-Agent': 'heyiam-cli',
72
+ },
73
+ body: JSON.stringify({
74
+ client_id: GITHUB_OAUTH_CLIENT_ID,
75
+ scope: scopes.join(' '),
76
+ }),
77
+ });
78
+ if (!res.ok) {
79
+ throw new GitHubError('DEVICE_CODE_FAILED', `Failed to request device code: HTTP ${res.status}`, res.status);
80
+ }
81
+ const body = await res.json();
82
+ if (!body.device_code || !body.user_code || !body.verification_uri) {
83
+ throw new GitHubError('DEVICE_CODE_FAILED', 'Malformed device code response');
84
+ }
85
+ return {
86
+ device_code: body.device_code,
87
+ user_code: body.user_code,
88
+ verification_uri: body.verification_uri,
89
+ expires_in: body.expires_in ?? 900,
90
+ interval: body.interval ?? 5,
91
+ };
92
+ }
93
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
94
+ /**
95
+ * Make a single poll attempt against GitHub's device-flow token endpoint.
96
+ *
97
+ * Returns immediately — no sleep, no loop. The caller (frontend) is
98
+ * responsible for retry scheduling so the Express worker is never blocked.
99
+ */
100
+ export async function pollForTokenOnce(deviceCode) {
101
+ const res = await fetch(GITHUB_TOKEN_URL, {
102
+ method: 'POST',
103
+ headers: {
104
+ Accept: 'application/json',
105
+ 'Content-Type': 'application/json',
106
+ 'User-Agent': 'heyiam-cli',
107
+ },
108
+ body: JSON.stringify({
109
+ client_id: GITHUB_OAUTH_CLIENT_ID,
110
+ device_code: deviceCode,
111
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
112
+ }),
113
+ });
114
+ const body = await res.json().catch(() => ({}));
115
+ if (body.access_token) {
116
+ return { status: 'success', access_token: body.access_token };
117
+ }
118
+ switch (body.error) {
119
+ case 'authorization_pending':
120
+ case 'slow_down':
121
+ return { status: 'pending' };
122
+ case 'expired_token':
123
+ return { status: 'expired' };
124
+ case 'access_denied':
125
+ return { status: 'denied' };
126
+ default:
127
+ // Unexpected error from GitHub — surface as a thrown error so the
128
+ // route handler maps it to a 500 via handleGitHubError.
129
+ throw new GitHubError('TOKEN_POLL_FAILED', body.error_description || body.error || `HTTP ${res.status}`);
130
+ }
131
+ }
132
+ let keytarOverride = null;
133
+ /** Test hook — inject a mock keytar implementation. */
134
+ export function __setKeytarForTests(mock) {
135
+ keytarOverride = mock;
136
+ }
137
+ async function getKeytar() {
138
+ if (keytarOverride)
139
+ return keytarOverride;
140
+ try {
141
+ const mod = await import('keytar');
142
+ return mod.default ?? mod;
143
+ }
144
+ catch (err) {
145
+ throw new GitHubError('KEYCHAIN_UNAVAILABLE', `OS keychain unavailable: ${err.message}`);
146
+ }
147
+ }
148
+ export async function storeToken(token) {
149
+ const keytar = await getKeytar();
150
+ try {
151
+ await keytar.setPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT, token);
152
+ }
153
+ catch (err) {
154
+ throw new GitHubError('KEYCHAIN_UNAVAILABLE', `Failed to store token in keychain: ${err.message}`);
155
+ }
156
+ }
157
+ export async function loadToken() {
158
+ const keytar = await getKeytar();
159
+ try {
160
+ return await keytar.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
161
+ }
162
+ catch (err) {
163
+ throw new GitHubError('KEYCHAIN_UNAVAILABLE', `Failed to read token from keychain: ${err.message}`);
164
+ }
165
+ }
166
+ export async function deleteToken() {
167
+ const keytar = await getKeytar();
168
+ try {
169
+ await keytar.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
170
+ }
171
+ catch (err) {
172
+ throw new GitHubError('KEYCHAIN_UNAVAILABLE', `Failed to delete token from keychain: ${err.message}`);
173
+ }
174
+ }
175
+ // ── User + repo lookup ────────────────────────────────────────────────
176
+ export async function getAuthenticatedUser(token) {
177
+ const user = await ghJson(`${GITHUB_API}/user`, { token });
178
+ return {
179
+ login: user.login,
180
+ name: user.name ?? null,
181
+ avatar_url: user.avatar_url,
182
+ };
183
+ }
184
+ export async function listRepos(token) {
185
+ const repos = await ghJson(`${GITHUB_API}/user/repos?per_page=100&type=owner&sort=updated`, { token });
186
+ return repos.map((r) => ({
187
+ name: String(r.name ?? ''),
188
+ full_name: String(r.full_name ?? ''),
189
+ default_branch: String(r.default_branch ?? 'main'),
190
+ has_pages: Boolean(r.has_pages),
191
+ private: Boolean(r.private),
192
+ }));
193
+ }
194
+ function walkFiles(root) {
195
+ const out = [];
196
+ const stack = [root];
197
+ while (stack.length > 0) {
198
+ const current = stack.pop();
199
+ let entries;
200
+ try {
201
+ entries = readdirSync(current);
202
+ }
203
+ catch (err) {
204
+ throw new GitHubError('INVALID_SOURCE_DIR', `Cannot read ${current}: ${err.message}`);
205
+ }
206
+ for (const entry of entries) {
207
+ const abs = join(current, entry);
208
+ const st = statSync(abs);
209
+ if (st.isDirectory())
210
+ stack.push(abs);
211
+ else if (st.isFile())
212
+ out.push(abs);
213
+ }
214
+ }
215
+ return out;
216
+ }
217
+ function toPosixRel(root, abs) {
218
+ return relative(root, abs).split(pathSep).join('/');
219
+ }
220
+ /**
221
+ * Push a static site directory to GitHub using the Git Data API.
222
+ *
223
+ * Flow (for ref UPDATE, which is the common case):
224
+ * 1. Create one blob per file (N calls).
225
+ * 2. Get current HEAD commit -> base tree sha.
226
+ * 3. Create a new tree with all blobs.
227
+ * 4. Create a new commit pointing at the tree with HEAD as parent.
228
+ * 5. Update the branch ref to the new commit.
229
+ *
230
+ * For very first push where the branch does not exist yet, we create the
231
+ * ref as a new branch off the default branch (or as an orphan if the
232
+ * repo is empty).
233
+ *
234
+ * "3 API calls typical case" in the plan refers to the tree + commit +
235
+ * ref-update tail; blob uploads are per-file and run first. We keep
236
+ * blob creation sequential to bound memory + rate-limit exposure.
237
+ */
238
+ export async function pushSiteToRepo(args) {
239
+ const { token, owner, repo, branch, sourceDir } = args;
240
+ const files = walkFiles(sourceDir);
241
+ if (files.length === 0) {
242
+ throw new GitHubError('INVALID_SOURCE_DIR', `No files to push in ${sourceDir}`);
243
+ }
244
+ // 1. Create blobs (one API call per file).
245
+ const blobs = [];
246
+ for (const abs of files) {
247
+ const content = readFileSync(abs);
248
+ const body = {
249
+ content: content.toString('base64'),
250
+ encoding: 'base64',
251
+ };
252
+ const blob = await ghJson(`${GITHUB_API}/repos/${owner}/${repo}/git/blobs`, {
253
+ token,
254
+ method: 'POST',
255
+ body: JSON.stringify(body),
256
+ headers: { 'Content-Type': 'application/json' },
257
+ });
258
+ blobs.push({
259
+ path: toPosixRel(sourceDir, abs),
260
+ sha: blob.sha,
261
+ mode: '100644',
262
+ type: 'blob',
263
+ });
264
+ }
265
+ // 2. Look up parent commit, if any.
266
+ let parentCommitSha = null;
267
+ const refRes = await ghFetch(`${GITHUB_API}/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(branch)}`, { token });
268
+ if (refRes.ok) {
269
+ const refBody = await refRes.json();
270
+ parentCommitSha = refBody.object?.sha ?? null;
271
+ }
272
+ else if (refRes.status !== 404) {
273
+ let detail = `HTTP ${refRes.status}`;
274
+ try {
275
+ const body = await refRes.json();
276
+ if (body.message)
277
+ detail = body.message;
278
+ }
279
+ catch { /* ignore */ }
280
+ throw new GitHubError('GITHUB_API_FAILED', sanitizeMessage(detail, token), refRes.status);
281
+ }
282
+ // 3. Create tree.
283
+ const tree = await ghJson(`${GITHUB_API}/repos/${owner}/${repo}/git/trees`, {
284
+ token,
285
+ method: 'POST',
286
+ headers: { 'Content-Type': 'application/json' },
287
+ body: JSON.stringify({ tree: blobs }),
288
+ });
289
+ // 4. Create commit.
290
+ const commit = await ghJson(`${GITHUB_API}/repos/${owner}/${repo}/git/commits`, {
291
+ token,
292
+ method: 'POST',
293
+ headers: { 'Content-Type': 'application/json' },
294
+ body: JSON.stringify({
295
+ message: 'Publish portfolio (heyi.am)',
296
+ tree: tree.sha,
297
+ parents: parentCommitSha ? [parentCommitSha] : [],
298
+ }),
299
+ });
300
+ // 5. Update or create ref.
301
+ if (parentCommitSha) {
302
+ await ghJson(`${GITHUB_API}/repos/${owner}/${repo}/git/refs/heads/${encodeURIComponent(branch)}`, {
303
+ token,
304
+ method: 'PATCH',
305
+ headers: { 'Content-Type': 'application/json' },
306
+ body: JSON.stringify({ sha: commit.sha, force: true }),
307
+ });
308
+ }
309
+ else {
310
+ await ghJson(`${GITHUB_API}/repos/${owner}/${repo}/git/refs`, {
311
+ token,
312
+ method: 'POST',
313
+ headers: { 'Content-Type': 'application/json' },
314
+ body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: commit.sha }),
315
+ });
316
+ }
317
+ return {
318
+ commitSha: commit.sha,
319
+ treeSha: tree.sha,
320
+ filesUploaded: blobs.length,
321
+ };
322
+ }
323
+ // ── Pages enable + poll ───────────────────────────────────────────────
324
+ /**
325
+ * Idempotent — 409 "already enabled" is treated as success.
326
+ */
327
+ export async function enablePages(args) {
328
+ const { token, owner, repo, branch } = args;
329
+ const res = await ghFetch(`${GITHUB_API}/repos/${owner}/${repo}/pages`, {
330
+ token,
331
+ method: 'POST',
332
+ headers: { 'Content-Type': 'application/json' },
333
+ body: JSON.stringify({
334
+ source: { branch, path: '/' },
335
+ }),
336
+ });
337
+ if (res.ok || res.status === 201 || res.status === 204)
338
+ return;
339
+ if (res.status === 409)
340
+ return; // already enabled
341
+ let detail = `HTTP ${res.status}`;
342
+ try {
343
+ const body = await res.json();
344
+ if (body.message)
345
+ detail = body.message;
346
+ }
347
+ catch { /* ignore */ }
348
+ throw new GitHubError('GITHUB_API_FAILED', sanitizeMessage(detail, token), res.status);
349
+ }
350
+ export async function pollPagesBuild(args) {
351
+ const { token, owner, repo } = args;
352
+ const intervalMs = args.intervalMs ?? 5_000;
353
+ const timeoutMs = args.timeoutMs ?? 5 * 60 * 1000;
354
+ const sleep = args.sleep ?? defaultSleep;
355
+ const startedAt = Date.now();
356
+ while (Date.now() - startedAt < timeoutMs) {
357
+ const res = await ghFetch(`${GITHUB_API}/repos/${owner}/${repo}/pages/builds/latest`, { token });
358
+ if (res.ok) {
359
+ const body = await res.json();
360
+ if (body.status === 'built')
361
+ return body;
362
+ if (body.status === 'errored') {
363
+ throw new GitHubError('PAGES_BUILD_FAILED', body.error?.message || 'Pages build errored');
364
+ }
365
+ // queued | building — keep polling
366
+ }
367
+ else if (res.status !== 404) {
368
+ // 404 can occur briefly before the first build record exists.
369
+ let detail = `HTTP ${res.status}`;
370
+ try {
371
+ const body = await res.json();
372
+ if (body.message)
373
+ detail = body.message;
374
+ }
375
+ catch { /* ignore */ }
376
+ throw new GitHubError('GITHUB_API_FAILED', sanitizeMessage(detail, token), res.status);
377
+ }
378
+ await sleep(intervalMs);
379
+ }
380
+ throw new GitHubError('PAGES_BUILD_TIMEOUT', 'Timed out waiting for Pages build');
381
+ }
package/dist/index.js CHANGED
@@ -650,6 +650,174 @@ daemon
650
650
  }
651
651
  console.log('\n Daemon uninstalled.\n');
652
652
  });
653
+ // ── Embed command ──────────────────────────────────────────
654
+ program
655
+ .command('embed')
656
+ .description('Generate embeddable widget snippets for your portfolio and projects')
657
+ .option('--project <name>', 'Generate embed for a specific project')
658
+ .option('--format <type>', 'Output format: widget, iframe, badge, html, or all', '')
659
+ .option('--sections <list>', 'Sections to include (comma-separated: stats,tools,skills,heatmap,recent)', 'stats')
660
+ .option('--theme <theme>', 'Color theme (dark or light)', 'dark')
661
+ .action(async (opts) => {
662
+ const { getAuthToken } = await import('./auth.js');
663
+ const { PUBLIC_URL } = await import('./config.js');
664
+ const { getUploadedState, getDataDir } = await import('./settings.js');
665
+ const { displayNameFromDir } = await import('./sync.js');
666
+ const { readdirSync, existsSync } = await import('node:fs');
667
+ const { join } = await import('node:path');
668
+ const auth = getAuthToken();
669
+ const username = auth?.username;
670
+ const sections = opts.sections || 'stats';
671
+ const theme = opts.theme || 'dark';
672
+ const queryParts = [];
673
+ if (sections !== 'stats')
674
+ queryParts.push(`sections=${sections}`);
675
+ if (theme !== 'dark')
676
+ queryParts.push(`theme=${theme}`);
677
+ const query = queryParts.length > 0 ? `?${queryParts.join('&')}` : '';
678
+ // Find published projects
679
+ const publishedDir = join(getDataDir(), 'published');
680
+ const publishedProjects = [];
681
+ if (existsSync(publishedDir)) {
682
+ for (const file of readdirSync(publishedDir)) {
683
+ if (!file.endsWith('.json'))
684
+ continue;
685
+ const dirName = file.replace(/\.json$/, '');
686
+ const state = getUploadedState(dirName);
687
+ if (state?.slug) {
688
+ publishedProjects.push({ dirName, slug: state.slug });
689
+ }
690
+ }
691
+ }
692
+ const isPublished = username && publishedProjects.length > 0;
693
+ const format = opts.format || (isPublished ? 'all' : 'html');
694
+ // Build local stats for static HTML
695
+ const db = openDatabase();
696
+ await syncSessionIndex(db);
697
+ const { getAllProjectStats } = await import('./db.js');
698
+ const allStats = getAllProjectStats(db);
699
+ if (opts.project) {
700
+ const match = publishedProjects.find((p) => p.slug === opts.project || p.dirName === opts.project || displayNameFromDir(p.dirName) === opts.project);
701
+ const localMatch = allStats.find((p) => p.projectName === opts.project || p.projectDir === opts.project || displayNameFromDir(p.projectDir) === opts.project);
702
+ if (!match && !localMatch) {
703
+ console.log(`\n Project "${opts.project}" not found.`);
704
+ if (allStats.length > 0) {
705
+ console.log(' Available projects:');
706
+ for (const p of allStats)
707
+ console.log(` ${p.projectName}`);
708
+ }
709
+ console.log('');
710
+ db.close();
711
+ return;
712
+ }
713
+ const base = match && username ? `${PUBLIC_URL}/${username}/${match.slug}` : null;
714
+ const stats = localMatch || (match ? allStats.find((p) => displayNameFromDir(p.projectDir) === displayNameFromDir(match.dirName)) : null);
715
+ const title = stats?.projectName || match?.slug || opts.project;
716
+ console.log('');
717
+ console.log(` ── ${title} ──────────────────────────────`);
718
+ printFormats(format, base, query, sections, theme, stats ? {
719
+ name: stats.projectName,
720
+ sessions: stats.sessionCount,
721
+ loc: stats.totalLoc,
722
+ duration: stats.totalDuration,
723
+ skills: stats.skills,
724
+ } : null);
725
+ }
726
+ else {
727
+ // Portfolio level
728
+ const base = username ? `${PUBLIC_URL}/${username}` : null;
729
+ // Aggregate stats across all projects
730
+ const totalSessions = allStats.reduce((s, p) => s + p.sessionCount, 0);
731
+ const totalLoc = allStats.reduce((s, p) => s + p.totalLoc, 0);
732
+ const totalDuration = allStats.reduce((s, p) => s + p.totalDuration, 0);
733
+ const allSkills = [...new Set(allStats.flatMap((p) => p.skills))].slice(0, 8);
734
+ console.log('');
735
+ console.log(' ── Portfolio ──────────────────────────────────');
736
+ printFormats(format, base, query, sections, theme, {
737
+ name: username || 'portfolio',
738
+ sessions: totalSessions,
739
+ loc: totalLoc,
740
+ duration: totalDuration,
741
+ skills: allSkills,
742
+ projectCount: allStats.length,
743
+ });
744
+ // Also show each published project
745
+ if (format !== 'html' && publishedProjects.length > 0) {
746
+ for (const p of publishedProjects) {
747
+ const projBase = `${PUBLIC_URL}/${username}/${p.slug}`;
748
+ const stats = allStats.find((s) => displayNameFromDir(s.projectDir) === displayNameFromDir(p.dirName));
749
+ console.log(` ── ${p.slug} ──────────────────────────────`);
750
+ printFormats(format, projBase, query, sections, theme, stats ? {
751
+ name: stats.projectName,
752
+ sessions: stats.sessionCount,
753
+ loc: stats.totalLoc,
754
+ duration: stats.totalDuration,
755
+ skills: stats.skills,
756
+ } : null);
757
+ }
758
+ }
759
+ }
760
+ db.close();
761
+ });
762
+ function printFormats(format, base, query, _sections, _theme, stats) {
763
+ const showAll = format === 'all';
764
+ if (base && (showAll || format === 'badge')) {
765
+ console.log('');
766
+ console.log(' Badge (GitHub README, markdown):');
767
+ console.log(` [![heyi.am](${base}/embed.svg)](${base})`);
768
+ console.log('');
769
+ }
770
+ if (base && (showAll || format === 'widget')) {
771
+ const dataAttrs = [`data-username="${base.split('/').slice(-2, -1)[0] || ''}"`,];
772
+ // If it's a project URL (3+ path segments after domain), add data-project
773
+ const pathParts = new URL(base).pathname.split('/').filter(Boolean);
774
+ if (pathParts.length >= 2)
775
+ dataAttrs.push(`data-project="${pathParts[1]}"`);
776
+ if (_sections !== 'stats')
777
+ dataAttrs.push(`data-sections="${_sections}"`);
778
+ if (_theme !== 'dark')
779
+ dataAttrs.push(`data-theme="${_theme}"`);
780
+ console.log(' Widget (personal site, blog):');
781
+ console.log(` <div class="heyiam-embed" ${dataAttrs.join(' ')}></div>`);
782
+ console.log(` <script src="${new URL(base).origin}/embed.js"></script>`);
783
+ console.log('');
784
+ }
785
+ if (base && (showAll || format === 'iframe')) {
786
+ console.log(' iframe:');
787
+ console.log(` <iframe src="${base}/embed${query}" width="480" height="200" frameborder="0"></iframe>`);
788
+ console.log('');
789
+ }
790
+ if (showAll || format === 'html') {
791
+ if (stats) {
792
+ const durationStr = stats.duration >= 60 ? `${Math.round(stats.duration / 60)}h` : `${stats.duration}m`;
793
+ const locStr = stats.loc >= 1000 ? `${(stats.loc / 1000).toFixed(1)}k` : String(stats.loc);
794
+ const skillChips = stats.skills.slice(0, 6).map((s) => `<span style="font-size:10px;padding:2px 8px;border-radius:3px;background:#1f2937;color:#9ca3af">${s}</span>`).join(' ');
795
+ const projectLine = stats.projectCount ? `<span style="font-size:10px;color:#6b7280">${stats.projectCount} projects</span>` : '';
796
+ console.log(' Static HTML (works anywhere, no JS needed):');
797
+ console.log(` <div style="font-family:ui-monospace,monospace;background:#0a0a0f;color:#e5e7eb;padding:16px 20px;border-radius:6px">`);
798
+ console.log(` <div style="font-size:15px;font-weight:600;color:#f9fafb;margin-bottom:12px">${stats.name} ${projectLine}</div>`);
799
+ console.log(` <div style="display:flex;gap:20px;flex-wrap:wrap;margin-bottom:10px">`);
800
+ console.log(` <div><div style="font-size:18px;font-weight:700;color:#f9fafb">${stats.sessions}</div><div style="font-size:10px;color:#6b7280;text-transform:uppercase;letter-spacing:0.08em">Sessions</div></div>`);
801
+ console.log(` <div><div style="font-size:18px;font-weight:700;color:#f9fafb">${locStr}</div><div style="font-size:10px;color:#6b7280;text-transform:uppercase;letter-spacing:0.08em">Lines Changed</div></div>`);
802
+ console.log(` <div><div style="font-size:18px;font-weight:700;color:#f9fafb">${durationStr}</div><div style="font-size:10px;color:#6b7280;text-transform:uppercase;letter-spacing:0.08em">Active Time</div></div>`);
803
+ console.log(` </div>`);
804
+ if (skillChips) {
805
+ console.log(` <div style="display:flex;gap:6px;flex-wrap:wrap">${skillChips}</div>`);
806
+ }
807
+ console.log(` </div>`);
808
+ console.log('');
809
+ }
810
+ else {
811
+ console.log(' Static HTML: No local stats available for this project.');
812
+ console.log('');
813
+ }
814
+ }
815
+ if (!base && format !== 'html') {
816
+ console.log(' Not published yet. Only static HTML is available.');
817
+ console.log(' Publish from the dashboard, then run again for badge/widget/iframe.');
818
+ console.log('');
819
+ }
820
+ }
653
821
  // ── Logout command ──────────────────────────────────────────
654
822
  program
655
823
  .command('logout')