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.
- package/dist/auth.js +29 -3
- package/dist/db.js +1 -1
- package/dist/export.js +84 -2
- package/dist/github.js +381 -0
- package/dist/parsers/index.js +22 -3
- package/dist/public/assets/index-Coilyhtr.css +1 -0
- package/dist/public/assets/index-D0noVMFu.js +44 -0
- package/dist/public/index.html +2 -2
- package/dist/render/templates/aurora/portfolio.liquid +10 -22
- package/dist/render/templates/aurora/project.liquid +1 -1
- package/dist/render/templates/aurora/styles.css +6 -0
- package/dist/render/templates/bauhaus/portfolio.liquid +9 -19
- package/dist/render/templates/bauhaus/styles.css +4 -0
- package/dist/render/templates/blueprint/portfolio.liquid +10 -24
- package/dist/render/templates/blueprint/styles.css +4 -0
- package/dist/render/templates/canvas/portfolio.liquid +17 -29
- package/dist/render/templates/canvas/styles.css +4 -0
- package/dist/render/templates/carbon/portfolio.liquid +9 -19
- package/dist/render/templates/carbon/styles.css +6 -0
- package/dist/render/templates/chalk/portfolio.liquid +9 -19
- package/dist/render/templates/chalk/styles.css +4 -0
- package/dist/render/templates/circuit/portfolio.liquid +10 -20
- package/dist/render/templates/circuit/project.liquid +1 -1
- package/dist/render/templates/circuit/styles.css +6 -0
- package/dist/render/templates/cosmos/portfolio.liquid +10 -20
- package/dist/render/templates/cosmos/project.liquid +1 -1
- package/dist/render/templates/cosmos/styles.css +6 -0
- package/dist/render/templates/daylight/portfolio.liquid +10 -20
- package/dist/render/templates/daylight/project.liquid +1 -1
- package/dist/render/templates/daylight/styles.css +4 -0
- package/dist/render/templates/editorial/portfolio.liquid +11 -27
- package/dist/render/templates/editorial/styles.css +4 -0
- package/dist/render/templates/ember/portfolio.liquid +11 -23
- package/dist/render/templates/ember/project.liquid +1 -1
- package/dist/render/templates/ember/styles.css +6 -0
- package/dist/render/templates/glacier/portfolio.liquid +10 -20
- package/dist/render/templates/glacier/project.liquid +1 -1
- package/dist/render/templates/glacier/styles.css +4 -0
- package/dist/render/templates/grid/portfolio.liquid +9 -19
- package/dist/render/templates/grid/styles.css +4 -0
- package/dist/render/templates/kinetic/portfolio.liquid +10 -22
- package/dist/render/templates/kinetic/project.liquid +1 -1
- package/dist/render/templates/kinetic/styles.css +4 -0
- package/dist/render/templates/meridian/portfolio.liquid +11 -23
- package/dist/render/templates/meridian/styles.css +6 -0
- package/dist/render/templates/minimal/portfolio.liquid +10 -10
- package/dist/render/templates/minimal/styles.css +4 -0
- package/dist/render/templates/mono/portfolio.liquid +9 -19
- package/dist/render/templates/mono/styles.css +6 -0
- package/dist/render/templates/neon/portfolio.liquid +10 -20
- package/dist/render/templates/neon/project.liquid +1 -1
- package/dist/render/templates/neon/styles.css +6 -0
- package/dist/render/templates/noir/portfolio.liquid +5 -5
- package/dist/render/templates/noir/styles.css +6 -0
- package/dist/render/templates/obsidian/portfolio.liquid +9 -19
- package/dist/render/templates/obsidian/styles.css +6 -0
- package/dist/render/templates/paper/portfolio.liquid +9 -19
- package/dist/render/templates/paper/styles.css +4 -0
- package/dist/render/templates/parallax/portfolio.liquid +9 -19
- package/dist/render/templates/parallax/styles.css +6 -0
- package/dist/render/templates/parchment/portfolio.liquid +9 -19
- package/dist/render/templates/parchment/styles.css +4 -0
- package/dist/render/templates/radar/portfolio.liquid +9 -19
- package/dist/render/templates/radar/styles.css +6 -0
- package/dist/render/templates/showcase/portfolio.liquid +9 -19
- package/dist/render/templates/showcase/styles.css +5 -0
- package/dist/render/templates/signal/portfolio.liquid +9 -19
- package/dist/render/templates/signal/styles.css +6 -0
- package/dist/render/templates/strata/portfolio.liquid +10 -22
- package/dist/render/templates/strata/styles.css +4 -0
- package/dist/render/templates/terminal/portfolio.liquid +10 -26
- package/dist/render/templates/terminal/styles.css +5 -0
- package/dist/render/templates/verdant/portfolio.liquid +11 -23
- package/dist/render/templates/verdant/project.liquid +1 -1
- package/dist/render/templates/verdant/styles.css +4 -0
- package/dist/render/templates/zen/portfolio.liquid +10 -22
- package/dist/render/templates/zen/styles.css +4 -0
- package/dist/routes/auth.js +7 -3
- package/dist/routes/context.js +2 -0
- package/dist/routes/delete.js +195 -0
- package/dist/routes/enhance.js +40 -0
- package/dist/routes/github.js +254 -0
- package/dist/routes/index.js +2 -0
- package/dist/routes/portfolio-render-data.js +160 -0
- package/dist/routes/preview.js +85 -10
- package/dist/routes/projects.js +50 -5
- package/dist/routes/publish.js +306 -15
- package/dist/routes/settings.js +102 -2
- package/dist/search.js +6 -0
- package/dist/server.js +3 -1
- package/dist/settings.js +95 -0
- package/package.json +2 -1
- package/dist/public/assets/index-BZ65TU_Y.js +0 -40
- package/dist/public/assets/index-CqCaW2cb.css +0 -1
package/dist/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
|
-
|
|
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 = {
|
|
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 {
|
|
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.
|
|
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
|
+
}
|
package/dist/parsers/index.js
CHANGED
|
@@ -31,7 +31,26 @@ export async function parseSession(path) {
|
|
|
31
31
|
* vs `/`.
|
|
32
32
|
*/
|
|
33
33
|
export function encodeDirPath(absolutePath) {
|
|
34
|
-
return absolutePath.replace(/[
|
|
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
|
|
89
|
-
if (decoded
|
|
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
|