heyiam 0.3.0 → 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 (94) hide show
  1. package/dist/auth.js +29 -3
  2. package/dist/db.js +1 -1
  3. package/dist/export.js +84 -2
  4. package/dist/github.js +381 -0
  5. package/dist/parsers/index.js +22 -3
  6. package/dist/public/assets/index-Coilyhtr.css +1 -0
  7. package/dist/public/assets/index-D0noVMFu.js +44 -0
  8. package/dist/public/index.html +2 -2
  9. package/dist/render/templates/aurora/portfolio.liquid +10 -22
  10. package/dist/render/templates/aurora/project.liquid +1 -1
  11. package/dist/render/templates/aurora/styles.css +6 -0
  12. package/dist/render/templates/bauhaus/portfolio.liquid +9 -19
  13. package/dist/render/templates/bauhaus/styles.css +4 -0
  14. package/dist/render/templates/blueprint/portfolio.liquid +10 -24
  15. package/dist/render/templates/blueprint/styles.css +4 -0
  16. package/dist/render/templates/canvas/portfolio.liquid +17 -29
  17. package/dist/render/templates/canvas/styles.css +4 -0
  18. package/dist/render/templates/carbon/portfolio.liquid +9 -19
  19. package/dist/render/templates/carbon/styles.css +6 -0
  20. package/dist/render/templates/chalk/portfolio.liquid +9 -19
  21. package/dist/render/templates/chalk/styles.css +4 -0
  22. package/dist/render/templates/circuit/portfolio.liquid +10 -20
  23. package/dist/render/templates/circuit/project.liquid +1 -1
  24. package/dist/render/templates/circuit/styles.css +6 -0
  25. package/dist/render/templates/cosmos/portfolio.liquid +10 -20
  26. package/dist/render/templates/cosmos/project.liquid +1 -1
  27. package/dist/render/templates/cosmos/styles.css +6 -0
  28. package/dist/render/templates/daylight/portfolio.liquid +10 -20
  29. package/dist/render/templates/daylight/project.liquid +1 -1
  30. package/dist/render/templates/daylight/styles.css +4 -0
  31. package/dist/render/templates/editorial/portfolio.liquid +11 -27
  32. package/dist/render/templates/editorial/styles.css +4 -0
  33. package/dist/render/templates/ember/portfolio.liquid +11 -23
  34. package/dist/render/templates/ember/project.liquid +1 -1
  35. package/dist/render/templates/ember/styles.css +6 -0
  36. package/dist/render/templates/glacier/portfolio.liquid +10 -20
  37. package/dist/render/templates/glacier/project.liquid +1 -1
  38. package/dist/render/templates/glacier/styles.css +4 -0
  39. package/dist/render/templates/grid/portfolio.liquid +9 -19
  40. package/dist/render/templates/grid/styles.css +4 -0
  41. package/dist/render/templates/kinetic/portfolio.liquid +10 -22
  42. package/dist/render/templates/kinetic/project.liquid +1 -1
  43. package/dist/render/templates/kinetic/styles.css +4 -0
  44. package/dist/render/templates/meridian/portfolio.liquid +11 -23
  45. package/dist/render/templates/meridian/styles.css +6 -0
  46. package/dist/render/templates/minimal/portfolio.liquid +10 -10
  47. package/dist/render/templates/minimal/styles.css +4 -0
  48. package/dist/render/templates/mono/portfolio.liquid +9 -19
  49. package/dist/render/templates/mono/styles.css +6 -0
  50. package/dist/render/templates/neon/portfolio.liquid +10 -20
  51. package/dist/render/templates/neon/project.liquid +1 -1
  52. package/dist/render/templates/neon/styles.css +6 -0
  53. package/dist/render/templates/noir/portfolio.liquid +5 -5
  54. package/dist/render/templates/noir/styles.css +6 -0
  55. package/dist/render/templates/obsidian/portfolio.liquid +9 -19
  56. package/dist/render/templates/obsidian/styles.css +6 -0
  57. package/dist/render/templates/paper/portfolio.liquid +9 -19
  58. package/dist/render/templates/paper/styles.css +4 -0
  59. package/dist/render/templates/parallax/portfolio.liquid +9 -19
  60. package/dist/render/templates/parallax/styles.css +6 -0
  61. package/dist/render/templates/parchment/portfolio.liquid +9 -19
  62. package/dist/render/templates/parchment/styles.css +4 -0
  63. package/dist/render/templates/radar/portfolio.liquid +9 -19
  64. package/dist/render/templates/radar/styles.css +6 -0
  65. package/dist/render/templates/showcase/portfolio.liquid +9 -19
  66. package/dist/render/templates/showcase/styles.css +5 -0
  67. package/dist/render/templates/signal/portfolio.liquid +9 -19
  68. package/dist/render/templates/signal/styles.css +6 -0
  69. package/dist/render/templates/strata/portfolio.liquid +10 -22
  70. package/dist/render/templates/strata/styles.css +4 -0
  71. package/dist/render/templates/terminal/portfolio.liquid +10 -26
  72. package/dist/render/templates/terminal/styles.css +5 -0
  73. package/dist/render/templates/verdant/portfolio.liquid +11 -23
  74. package/dist/render/templates/verdant/project.liquid +1 -1
  75. package/dist/render/templates/verdant/styles.css +4 -0
  76. package/dist/render/templates/zen/portfolio.liquid +10 -22
  77. package/dist/render/templates/zen/styles.css +4 -0
  78. package/dist/routes/auth.js +7 -3
  79. package/dist/routes/context.js +2 -0
  80. package/dist/routes/delete.js +195 -0
  81. package/dist/routes/enhance.js +40 -0
  82. package/dist/routes/github.js +254 -0
  83. package/dist/routes/index.js +2 -0
  84. package/dist/routes/portfolio-render-data.js +160 -0
  85. package/dist/routes/preview.js +85 -10
  86. package/dist/routes/projects.js +50 -5
  87. package/dist/routes/publish.js +306 -15
  88. package/dist/routes/settings.js +102 -2
  89. package/dist/search.js +6 -0
  90. package/dist/server.js +3 -1
  91. package/dist/settings.js +95 -0
  92. package/package.json +2 -1
  93. package/dist/public/assets/index-BZ65TU_Y.js +0 -40
  94. package/dist/public/assets/index-CqCaW2cb.css +0 -1
package/dist/auth.js CHANGED
@@ -19,7 +19,16 @@ export function writeConfig(filename, data, configDir = getConfigDir()) {
19
19
  writeFileSync(join(configDir, filename), JSON.stringify(data, null, 2), { mode: 0o600 });
20
20
  }
21
21
  export function getAuthToken(configDir = getConfigDir()) {
22
- return readConfig(AUTH_FILE, configDir);
22
+ const raw = readConfig(AUTH_FILE, configDir);
23
+ if (!raw)
24
+ return null;
25
+ // Legacy configs may contain mixed-case usernames (prior to the
26
+ // normalize-on-write change). Always project to the canonical form so
27
+ // callers — URL construction, display, server requests — stay consistent.
28
+ if (raw.username && raw.username !== raw.username.toLowerCase()) {
29
+ return { ...raw, username: normalizeUsername(raw.username) };
30
+ }
31
+ return raw;
23
32
  }
24
33
  export function deleteAuthToken(configDir = getConfigDir()) {
25
34
  const filePath = join(configDir, AUTH_FILE);
@@ -27,8 +36,22 @@ export function deleteAuthToken(configDir = getConfigDir()) {
27
36
  unlinkSync(filePath);
28
37
  }
29
38
  }
39
+ /**
40
+ * Normalize a username to the canonical form used everywhere in the CLI:
41
+ * lowercase, whitespace trimmed. Mirrors Phoenix's DB validation regex
42
+ * `^[a-z0-9-]+$`. Guards against Phoenix responses or legacy configs that
43
+ * contain mixed-case usernames (e.g. "Ben" -> "ben") so URL construction
44
+ * and display are consistent.
45
+ */
46
+ export function normalizeUsername(username) {
47
+ return username.trim().toLowerCase();
48
+ }
30
49
  export function saveAuthToken(token, username, configDir = getConfigDir()) {
31
- const config = { token, username, savedAt: new Date().toISOString() };
50
+ const config = {
51
+ token,
52
+ username: normalizeUsername(username),
53
+ savedAt: new Date().toISOString(),
54
+ };
32
55
  writeConfig(AUTH_FILE, config, configDir);
33
56
  }
34
57
  export async function checkAuthStatus(apiBaseUrl, configDir = getConfigDir(), fetchFn = fetch) {
@@ -41,7 +64,10 @@ export async function checkAuthStatus(apiBaseUrl, configDir = getConfigDir(), fe
41
64
  if (!res.ok)
42
65
  return { authenticated: false };
43
66
  const body = (await res.json());
44
- return { authenticated: true, username: body.username };
67
+ return {
68
+ authenticated: true,
69
+ username: body.username ? normalizeUsername(body.username) : body.username,
70
+ };
45
71
  }
46
72
  export async function deviceAuthFlow(apiBaseUrl, configDir = getConfigDir(), options = {}) {
47
73
  const fetchFn = options.fetchFn ?? fetch;
package/dist/db.js CHANGED
@@ -9,7 +9,7 @@ import { homedir } from 'node:os';
9
9
  function getDataDir() {
10
10
  return process.env.HEYIAM_DATA_DIR || join(homedir(), '.local', 'share', 'heyiam');
11
11
  }
12
- function getDbPath() {
12
+ export function getDbPath() {
13
13
  return join(getDataDir(), 'sessions.db');
14
14
  }
15
15
  const CURRENT_SCHEMA_VERSION = 5;
package/dist/export.js CHANGED
@@ -10,7 +10,7 @@ import { deflateRawSync } from 'node:zlib';
10
10
  import { join, resolve, dirname } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { loadEnhancedData, getDefaultTemplate } from './settings.js';
13
- import { renderProjectHtml, renderSessionHtml } from './render/index.js';
13
+ import { renderProjectHtml, renderSessionHtml, renderPortfolioHtml } from './render/index.js';
14
14
  import { resolveTemplate, getTemplateCss } from './render/templates.js';
15
15
  import { escapeHtml, displayNameFromDir, toSlug } from './format-utils.js';
16
16
  import { buildProjectRenderData, buildSessionRenderData, buildSessionCard, } from './render/build-render-data.js';
@@ -424,7 +424,9 @@ export function createZipBuffer(entries) {
424
424
  const dosDate = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff;
425
425
  for (const entry of entries) {
426
426
  const nameBytes = Buffer.from(entry.path, 'utf-8');
427
- const raw = Buffer.from(entry.content, 'utf-8');
427
+ const raw = Buffer.isBuffer(entry.content)
428
+ ? entry.content
429
+ : Buffer.from(entry.content, 'utf-8');
428
430
  const compressed = deflateRawSync(raw);
429
431
  const crc = crc32(raw);
430
432
  // Local file header
@@ -540,3 +542,83 @@ function buildStandalonePage(title, bodyHtml, opts) {
540
542
  </body>
541
543
  </html>`;
542
544
  }
545
+ // ── Portfolio site export (Phase 1) ────────────────────────────
546
+ /**
547
+ * Render a portfolio landing page to a body HTML fragment (no `<html>` shell).
548
+ *
549
+ * Used by the upload path (Phase 2) to store pre-rendered HTML in
550
+ * `users.rendered_portfolio_html` for Phoenix to serve.
551
+ */
552
+ export function generatePortfolioHtmlFragment(data, templateName) {
553
+ const template = templateName ?? resolveTemplate(undefined, getDefaultTemplate());
554
+ return renderPortfolioHtml(data, template);
555
+ }
556
+ /**
557
+ * Rewrite hardcoded `/{username}/{slug}` absolute project links produced by
558
+ * the current portfolio Liquid templates into relative links that resolve
559
+ * against `projects/{slug}/index.html` when opened from disk.
560
+ *
561
+ * AUDIT FINDING (Phase 1): all 29 portfolio.liquid templates (editorial,
562
+ * blueprint, kinetic, and 26 others) hardcode `/{{ user.username }}/{{ p.slug }}`.
563
+ * Introducing a per-context base URL variable would require changes to
564
+ * `render/liquid.ts` and every template — out of scope for this phase, which
565
+ * only permits editing export.ts, types.ts, and three template files. We
566
+ * rewrite the URLs at the generator boundary instead. Hosted Phoenix serving
567
+ * is unaffected: the render output still contains absolute paths when routed
568
+ * through `renderPortfolioHtml` directly.
569
+ */
570
+ function rewritePortfolioProjectLinks(html, username) {
571
+ // Escape regex metacharacters in username before embedding.
572
+ const safeUser = username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
573
+ // Matches href="/{username}/{slug}" and href="/{username}/{slug}/{session}"
574
+ const projectOnly = new RegExp(`href="/${safeUser}/([a-z0-9][a-z0-9-]*)"`, 'g');
575
+ const projectSession = new RegExp(`href="/${safeUser}/([a-z0-9][a-z0-9-]*)/([a-z0-9][a-z0-9-]*)"`, 'g');
576
+ return html
577
+ .replace(projectSession, 'href="projects/$1/sessions/$2.html"')
578
+ .replace(projectOnly, 'href="projects/$1/index.html"');
579
+ }
580
+ /**
581
+ * Generate a complete static portfolio site as a directory tree.
582
+ *
583
+ * Output structure:
584
+ * ```
585
+ * {outputDir}/
586
+ * index.html — portfolio landing page
587
+ * projects/{slug}/index.html — per-project detail page
588
+ * projects/{slug}/sessions/{slug}.html — per-session pages (featured)
589
+ * ```
590
+ *
591
+ * All internal links are relative; the resulting directory can be opened
592
+ * directly via `file://` without a server. Font loading remains CDN-linked.
593
+ *
594
+ * @param portfolioData Render data for the landing page.
595
+ * @param projects Per-project inputs. Each must have a unique dirName.
596
+ * @param outputDir Absolute output directory. Caller is responsible for
597
+ * validating the path before calling.
598
+ * @param templateName Optional template override (defaults to user setting).
599
+ */
600
+ export async function generatePortfolioSite(portfolioData, projects, outputDir, templateName) {
601
+ const files = [];
602
+ let totalBytes = 0;
603
+ mkdirSync(outputDir, { recursive: true });
604
+ const template = templateName ?? resolveTemplate(undefined, getDefaultTemplate());
605
+ const username = portfolioData.user.username;
606
+ // ── Landing page ────────────────────────────────────────────
607
+ const portfolioBody = rewritePortfolioProjectLinks(renderPortfolioHtml(portfolioData, template), username);
608
+ const portfolioHtml = buildStandalonePage(portfolioData.user.displayName || username, portfolioBody, {
609
+ description: portfolioData.user.bio?.slice(0, 200) || undefined,
610
+ templateName: template,
611
+ });
612
+ totalBytes += writeAndTrack(join(outputDir, 'index.html'), portfolioHtml, files);
613
+ // ── Per-project sub-sites ───────────────────────────────────
614
+ const projectsRoot = join(outputDir, 'projects');
615
+ mkdirSync(projectsRoot, { recursive: true });
616
+ for (const p of projects) {
617
+ const projectSlug = slugify(p.dirName);
618
+ const projectDir = join(projectsRoot, projectSlug);
619
+ const result = await exportHtml(p.dirName, p.cache, p.sessions, projectDir, username, p.opts);
620
+ files.push(...result.files);
621
+ totalBytes += result.totalBytes;
622
+ }
623
+ return { files, totalBytes, outputPath: outputDir };
624
+ }
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
+ }
@@ -31,7 +31,26 @@ export async function parseSession(path) {
31
31
  * vs `/`.
32
32
  */
33
33
  export function encodeDirPath(absolutePath) {
34
- return absolutePath.replace(/[/.]/g, "-");
34
+ return absolutePath.replace(/[/\\.:]/g, "-").replace(/-+/g, "-");
35
+ }
36
+ /**
37
+ * Best-effort decode of an encoded project directory back to an absolute path.
38
+ * Returns null when the format is unrecognizable (the encoding is lossy).
39
+ *
40
+ * Unix: "-Users-ben-Dev-myapp" → "/Users/ben/Dev/myapp"
41
+ * Windows: "C-Users-ben-Dev-myapp" → "C:/Users/ben/Dev/myapp"
42
+ */
43
+ export function decodeDirPath(encoded) {
44
+ // Windows: starts with a single uppercase letter then "-"
45
+ const winMatch = encoded.match(/^([A-Z])-(.+)$/);
46
+ if (winMatch) {
47
+ return `${winMatch[1]}:/${winMatch[2].replace(/-/g, "/")}`;
48
+ }
49
+ // Unix: starts with "-"
50
+ if (encoded.startsWith("-")) {
51
+ return encoded.replace(/^-/, "/").replace(/-/g, "/");
52
+ }
53
+ return null;
35
54
  }
36
55
  /**
37
56
  * Scan all supported tools for sessions and merge by project directory.
@@ -85,8 +104,8 @@ export async function listSessions(basePath) {
85
104
  for (const s of claudeSessions) {
86
105
  // Claude projectDir is encoded like "-Users-ben-Dev-myapp";
87
106
  // attempt to reverse to "/Users/ben/Dev/myapp"
88
- const decoded = s.projectDir.replace(/^-/, "/").replace(/-/g, "/");
89
- if (decoded.startsWith("/"))
107
+ const decoded = decodeDirPath(s.projectDir);
108
+ if (decoded)
90
109
  knownDirs.push(decoded);
91
110
  }
92
111
  // 4. Gemini sessions — resolve SHA-256 hashes to real project paths