design-share 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/detect.js ADDED
@@ -0,0 +1,76 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { git, tryGit } from './git.js';
4
+
5
+ const CONFIG_FILE = '.design-share.json';
6
+
7
+ export async function detectRepo(cwd) {
8
+ let root;
9
+ try {
10
+ root = await git(cwd, ['rev-parse', '--show-toplevel']);
11
+ } catch {
12
+ throw new Error('not inside a git repository. cd into your project repo and run npx design-share again.');
13
+ }
14
+ const remote = await tryGit(root, ['remote', 'get-url', 'origin']);
15
+ return { root, name: path.basename(root), remote };
16
+ }
17
+
18
+ export async function detectIdentity(repoRoot) {
19
+ const name = (await tryGit(repoRoot, ['config', 'user.name'])) || '';
20
+ const email = (await tryGit(repoRoot, ['config', 'user.email'])) || '';
21
+ let slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '');
22
+ if (!slug && email) slug = email.split('@')[0].toLowerCase().replace(/[^a-z0-9]+/g, '');
23
+ if (!slug) slug = 'designer';
24
+ return { name: name.trim() || slug, email: email.trim(), slug };
25
+ }
26
+
27
+ export async function currentBranch(repoRoot) {
28
+ const b = await tryGit(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']);
29
+ return b === 'HEAD' ? null : b;
30
+ }
31
+
32
+ export function readRepoConfig(repoRoot) {
33
+ const p = path.join(repoRoot, CONFIG_FILE);
34
+ if (!fs.existsSync(p)) return null;
35
+ try {
36
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export function writeRepoConfig(repoRoot, config) {
43
+ const p = path.join(repoRoot, CONFIG_FILE);
44
+ fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n');
45
+ return p;
46
+ }
47
+
48
+ // Returns { type: 'command'|'static', command?, dir?, source, label }
49
+ export function detectPreview(repoRoot) {
50
+ const saved = readRepoConfig(repoRoot);
51
+ if (saved && saved.preview && saved.preview.type) {
52
+ return { ...saved.preview, source: 'config' };
53
+ }
54
+
55
+ const pkgPath = path.join(repoRoot, 'package.json');
56
+ if (fs.existsSync(pkgPath)) {
57
+ let pkg;
58
+ try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch { pkg = null; }
59
+ const scripts = (pkg && pkg.scripts) || {};
60
+ const deps = { ...(pkg && pkg.dependencies), ...(pkg && pkg.devDependencies) };
61
+ const pick = ['dev', 'start', 'storybook', 'serve'].find((s) => scripts[s]);
62
+ if (pick) {
63
+ let label = `npm run ${pick}`;
64
+ if (deps.vite) label += ' (vite)';
65
+ else if (deps.next) label += ' (next)';
66
+ else if (deps['@storybook/cli'] || pick === 'storybook') label += ' (storybook)';
67
+ return { type: 'command', command: `npm run ${pick}`, source: 'package.json', label };
68
+ }
69
+ }
70
+
71
+ if (fs.existsSync(path.join(repoRoot, 'index.html'))) {
72
+ return { type: 'static', dir: '.', source: 'index.html', label: 'static folder' };
73
+ }
74
+
75
+ return null;
76
+ }
package/src/git.js ADDED
@@ -0,0 +1,47 @@
1
+ import { execFile } from 'node:child_process';
2
+
3
+ export function git(repoRoot, args, opts = {}) {
4
+ return new Promise((resolve, reject) => {
5
+ execFile('git', args, {
6
+ cwd: repoRoot,
7
+ maxBuffer: 16 * 1024 * 1024,
8
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...opts.env },
9
+ }, (err, stdout, stderr) => {
10
+ if (err) {
11
+ const e = new Error((stderr || err.message).trim());
12
+ e.code = err.code;
13
+ e.stderr = stderr;
14
+ reject(e);
15
+ } else {
16
+ resolve(stdout.replace(/\n$/, ''));
17
+ }
18
+ });
19
+ });
20
+ }
21
+
22
+ export function gitInput(repoRoot, args, input, opts = {}) {
23
+ return new Promise((resolve, reject) => {
24
+ const child = execFile('git', args, {
25
+ cwd: repoRoot,
26
+ maxBuffer: 16 * 1024 * 1024,
27
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...opts.env },
28
+ }, (err, stdout, stderr) => {
29
+ if (err) {
30
+ const e = new Error((stderr || err.message).trim());
31
+ e.stderr = stderr;
32
+ reject(e);
33
+ } else {
34
+ resolve(stdout.replace(/\n$/, ''));
35
+ }
36
+ });
37
+ child.stdin.end(input);
38
+ });
39
+ }
40
+
41
+ export async function tryGit(repoRoot, args, opts = {}) {
42
+ try {
43
+ return await git(repoRoot, args, opts);
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
package/src/menubar.js ADDED
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { execFile } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
8
+ const APP_SOURCE = path.join(PKG_ROOT, 'dist', 'DesignShare.app');
9
+ const APP_TARGET = path.join(os.homedir(), 'Applications', 'DesignShare.app');
10
+
11
+ function run(cmd, args) {
12
+ return new Promise((resolve, reject) => {
13
+ execFile(cmd, args, (err, stdout, stderr) => {
14
+ if (err) reject(new Error((stderr || err.message).trim()));
15
+ else resolve(stdout.trim());
16
+ });
17
+ });
18
+ }
19
+
20
+ export function appAvailable() {
21
+ return process.platform === 'darwin' && fs.existsSync(APP_SOURCE);
22
+ }
23
+
24
+ export function appInstalled() {
25
+ return fs.existsSync(APP_TARGET);
26
+ }
27
+
28
+ export async function installApp() {
29
+ if (process.platform !== 'darwin') throw new Error('the menu bar app is macOS only.');
30
+ if (!fs.existsSync(APP_SOURCE)) {
31
+ throw new Error('the menu bar app is not bundled in this package build. run scripts/build-app.sh first.');
32
+ }
33
+ fs.mkdirSync(path.dirname(APP_TARGET), { recursive: true });
34
+ fs.rmSync(APP_TARGET, { recursive: true, force: true });
35
+ // ditto preserves the bundle structure and extended attributes, including the signature.
36
+ await run('ditto', [APP_SOURCE, APP_TARGET]);
37
+ return APP_TARGET;
38
+ }
39
+
40
+ export async function launchApp() {
41
+ await run('open', ['-g', APP_TARGET]);
42
+ }
43
+
44
+ export async function quitApp() {
45
+ try {
46
+ await run('osascript', ['-e', 'tell application "DesignShare" to quit']);
47
+ } catch { /* not running */ }
48
+ }
@@ -0,0 +1,240 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import http from 'node:http';
4
+ import net from 'node:net';
5
+ import { spawn } from 'node:child_process';
6
+ import { git, tryGit } from './git.js';
7
+
8
+ const URL_RE = /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):\d+[^\s'"]*)/;
9
+ const START_TIMEOUT_MS = 90_000;
10
+ const MIME = {
11
+ '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
12
+ '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml',
13
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
14
+ '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.webp': 'image/webp',
15
+ '.mp4': 'video/mp4', '.txt': 'text/plain',
16
+ };
17
+
18
+ function stripAnsi(s) {
19
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
20
+ }
21
+
22
+ function portFree(port) {
23
+ return new Promise((resolve) => {
24
+ const probe = net.createServer();
25
+ probe.once('error', () => resolve(false));
26
+ probe.listen(port, '127.0.0.1', () => probe.close(() => resolve(true)));
27
+ });
28
+ }
29
+
30
+ async function findFreePort(start) {
31
+ let port = start;
32
+ while (!(await portFree(port))) port++;
33
+ return port;
34
+ }
35
+
36
+ export class PreviewManager {
37
+ constructor(repoRoot, previewConfig, identity) {
38
+ this.repoRoot = repoRoot;
39
+ this.previewConfig = previewConfig;
40
+ this.identity = identity;
41
+ this.previews = new Map(); // key user/branch -> record
42
+ this.nextPort = 4501;
43
+ this.worktreeBase = path.join(repoRoot, '.design-share', 'worktrees');
44
+ }
45
+
46
+ ensureExcluded() {
47
+ // Keeps the hidden worktree area out of git status for everyone,
48
+ // without touching the repo's committed .gitignore.
49
+ try {
50
+ const exclude = path.join(this.repoRoot, '.git', 'info', 'exclude');
51
+ const line = '.design-share/';
52
+ const cur = fs.existsSync(exclude) ? fs.readFileSync(exclude, 'utf8') : '';
53
+ if (!cur.split('\n').includes(line)) {
54
+ fs.mkdirSync(path.dirname(exclude), { recursive: true });
55
+ fs.writeFileSync(exclude, cur + (cur.endsWith('\n') || cur === '' ? '' : '\n') + line + '\n');
56
+ }
57
+ } catch { /* non fatal */ }
58
+ }
59
+
60
+ get(key) {
61
+ return this.previews.get(key);
62
+ }
63
+
64
+ statuses() {
65
+ const out = {};
66
+ for (const [key, p] of this.previews) {
67
+ out[key] = {
68
+ status: p.status, url: p.url, commit: p.commit || null,
69
+ error: p.error || null, logTail: p.log.slice(-1600),
70
+ };
71
+ }
72
+ return out;
73
+ }
74
+
75
+ async ensureOwn(branch) {
76
+ const key = `${this.identity.slug}/${branch}`;
77
+ const existing = this.previews.get(key);
78
+ if (existing && existing.status !== 'error' && existing.status !== 'stopped') return existing;
79
+ const commit = await tryGit(this.repoRoot, ['rev-parse', 'HEAD']);
80
+ return this.start(key, { cwd: this.repoRoot, commit, own: true });
81
+ }
82
+
83
+ async ensureBranch(user, branch, ownBranch) {
84
+ if (user === this.identity.slug && branch === ownBranch) {
85
+ return this.ensureOwn(branch);
86
+ }
87
+ const key = `${user}/${branch}`;
88
+ await tryGit(this.repoRoot, ['fetch', '--quiet', 'origin', branch]);
89
+ const commit = await tryGit(this.repoRoot, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`])
90
+ || await tryGit(this.repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
91
+ if (!commit) {
92
+ const rec = this.record(key);
93
+ rec.status = 'error';
94
+ rec.error = `branch ${branch} was not found on origin. the author may not have pushed yet.`;
95
+ return rec;
96
+ }
97
+
98
+ const existing = this.previews.get(key);
99
+ if (existing && existing.status !== 'error' && existing.status !== 'stopped') {
100
+ if (existing.commit !== commit && existing.worktree) {
101
+ await tryGit(existing.worktree, ['checkout', '--quiet', '--detach', commit]);
102
+ existing.commit = commit;
103
+ }
104
+ return existing;
105
+ }
106
+
107
+ this.ensureExcluded();
108
+ const safe = `${user}__${branch}`.replace(/[^a-zA-Z0-9._-]+/g, '-');
109
+ const wt = path.join(this.worktreeBase, safe);
110
+ if (fs.existsSync(path.join(wt, '.git'))) {
111
+ await tryGit(wt, ['checkout', '--quiet', '--detach', commit]);
112
+ } else {
113
+ fs.mkdirSync(this.worktreeBase, { recursive: true });
114
+ await tryGit(this.repoRoot, ['worktree', 'prune']);
115
+ await git(this.repoRoot, ['worktree', 'add', '--quiet', '--detach', wt, commit]);
116
+ }
117
+
118
+ // Dev servers need dependencies. Borrow the root install when present.
119
+ const rootModules = path.join(this.repoRoot, 'node_modules');
120
+ const wtModules = path.join(wt, 'node_modules');
121
+ if (fs.existsSync(rootModules) && !fs.existsSync(wtModules) && fs.existsSync(path.join(wt, 'package.json'))) {
122
+ try { fs.symlinkSync(rootModules, wtModules, 'dir'); } catch { /* non fatal */ }
123
+ }
124
+
125
+ return this.start(key, { cwd: wt, commit, worktree: wt });
126
+ }
127
+
128
+ record(key) {
129
+ let rec = this.previews.get(key);
130
+ if (!rec) {
131
+ rec = { status: 'idle', url: null, log: '', proc: null };
132
+ this.previews.set(key, rec);
133
+ }
134
+ return rec;
135
+ }
136
+
137
+ async start(key, { cwd, commit, worktree, own }) {
138
+ const rec = this.record(key);
139
+ rec.status = 'starting';
140
+ rec.url = null;
141
+ rec.error = null;
142
+ rec.log = '';
143
+ rec.commit = commit;
144
+ rec.worktree = worktree || null;
145
+ rec.own = !!own;
146
+ rec.startedAt = Date.now();
147
+
148
+ const cfg = this.previewConfig;
149
+ if (!cfg) {
150
+ rec.status = 'error';
151
+ rec.error = 'no preview command configured for this repo.';
152
+ return rec;
153
+ }
154
+
155
+ if (cfg.type === 'static') {
156
+ const dir = path.resolve(cwd, cfg.dir || '.');
157
+ const server = http.createServer((req, res) => serveStatic(dir, req, res));
158
+ server.listen(0, '127.0.0.1', () => {
159
+ rec.url = `http://localhost:${server.address().port}/`;
160
+ rec.status = 'ready';
161
+ });
162
+ rec.server = server;
163
+ return rec;
164
+ }
165
+
166
+ const port = await findFreePort(this.nextPort);
167
+ this.nextPort = port + 1;
168
+ const proc = spawn(cfg.command, {
169
+ cwd,
170
+ shell: true,
171
+ detached: true,
172
+ env: { ...process.env, PORT: String(port), BROWSER: 'none' },
173
+ });
174
+ rec.proc = proc;
175
+
176
+ const onData = (buf) => {
177
+ const text = stripAnsi(buf.toString());
178
+ rec.log = (rec.log + text).slice(-8000);
179
+ if (!rec.url) {
180
+ const m = text.match(URL_RE);
181
+ if (m) {
182
+ rec.url = m[1].replace('0.0.0.0', 'localhost').replace('127.0.0.1', 'localhost');
183
+ rec.status = 'ready';
184
+ }
185
+ }
186
+ };
187
+ proc.stdout.on('data', onData);
188
+ proc.stderr.on('data', onData);
189
+ proc.on('exit', (code) => {
190
+ if (rec.status !== 'ready' || code !== 0) {
191
+ rec.status = rec.status === 'ready' ? 'stopped' : 'error';
192
+ if (rec.status === 'error') rec.error = `preview command exited with code ${code}.`;
193
+ } else {
194
+ rec.status = 'stopped';
195
+ }
196
+ rec.proc = null;
197
+ });
198
+
199
+ setTimeout(() => {
200
+ if (rec.status === 'starting') {
201
+ rec.status = 'error';
202
+ rec.error = 'could not detect a preview URL from the command output.';
203
+ }
204
+ }, START_TIMEOUT_MS).unref();
205
+
206
+ return rec;
207
+ }
208
+
209
+ stopAll() {
210
+ for (const [, rec] of this.previews) {
211
+ if (rec.proc && rec.proc.pid) {
212
+ try { process.kill(-rec.proc.pid, 'SIGTERM'); } catch { /* gone */ }
213
+ }
214
+ if (rec.server) {
215
+ try { rec.server.close(); } catch { /* gone */ }
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ export function serveStatic(root, req, res) {
222
+ try {
223
+ const urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname);
224
+ let filePath = path.normalize(path.join(root, urlPath));
225
+ if (!filePath.startsWith(root)) {
226
+ res.writeHead(403); res.end('forbidden'); return;
227
+ }
228
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
229
+ filePath = path.join(filePath, 'index.html');
230
+ }
231
+ if (!fs.existsSync(filePath)) {
232
+ res.writeHead(404); res.end('not found'); return;
233
+ }
234
+ const ext = path.extname(filePath).toLowerCase();
235
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
236
+ fs.createReadStream(filePath).pipe(res);
237
+ } catch {
238
+ res.writeHead(500); res.end('error');
239
+ }
240
+ }
@@ -0,0 +1,42 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ // The menu bar app reads this registry to know which repos have daemons,
6
+ // where they listen, and how to respawn one that died with the terminal.
7
+ const DIR = path.join(os.homedir(), '.design-share');
8
+ const FILE = path.join(DIR, 'daemons.json');
9
+ const CONFIG = path.join(DIR, 'config.json');
10
+
11
+ function readJson(file, fallback) {
12
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
13
+ }
14
+
15
+ function writeJson(file, data) {
16
+ fs.mkdirSync(DIR, { recursive: true });
17
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
18
+ }
19
+
20
+ export function readRegistry() {
21
+ return readJson(FILE, { repos: {} });
22
+ }
23
+
24
+ export function upsertDaemon(repoRoot, entry) {
25
+ const reg = readRegistry();
26
+ reg.repos[repoRoot] = { ...reg.repos[repoRoot], ...entry, updatedAt: Date.now() };
27
+ writeJson(FILE, reg);
28
+ }
29
+
30
+ export function removeDaemon(repoRoot) {
31
+ const reg = readRegistry();
32
+ delete reg.repos[repoRoot];
33
+ writeJson(FILE, reg);
34
+ }
35
+
36
+ export function readUserConfig() {
37
+ return readJson(CONFIG, {});
38
+ }
39
+
40
+ export function writeUserConfig(patch) {
41
+ writeJson(CONFIG, { ...readUserConfig(), ...patch });
42
+ }
package/src/server.js ADDED
@@ -0,0 +1,214 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import http from 'node:http';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { tryGit } from './git.js';
6
+ import { currentBranch } from './detect.js';
7
+ import { serveStatic } from './previews.js';
8
+
9
+ const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
10
+ const SYNC_INTERVAL_MS = 15_000;
11
+ const FETCH_INTERVAL_MS = 60_000;
12
+
13
+ function json(res, code, data) {
14
+ const body = JSON.stringify(data);
15
+ res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
16
+ res.end(body);
17
+ }
18
+
19
+ function readBody(req) {
20
+ return new Promise((resolve, reject) => {
21
+ let data = '';
22
+ req.on('data', (c) => { data += c; if (data.length > 1_000_000) req.destroy(); });
23
+ req.on('end', () => {
24
+ try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); }
25
+ });
26
+ req.on('error', reject);
27
+ });
28
+ }
29
+
30
+ export class DesignShareServer {
31
+ constructor({ repo, identity, store, previews, port }) {
32
+ this.repo = repo;
33
+ this.identity = identity;
34
+ this.store = store;
35
+ this.previews = previews;
36
+ this.port = port;
37
+ this.server = null;
38
+ this.timers = [];
39
+ }
40
+
41
+ async branchHeads() {
42
+ const heads = {};
43
+ const raw = await tryGit(this.repo.root, [
44
+ 'for-each-ref', '--format=%(refname)%09%(objectname)%09%(committerdate:unix)%09%(authorname)',
45
+ 'refs/heads', 'refs/remotes/origin',
46
+ ]);
47
+ if (raw) {
48
+ for (const line of raw.split('\n')) {
49
+ const [ref, sha, time, author] = line.split('\t');
50
+ if (!ref || ref.endsWith('/HEAD')) continue;
51
+ const branch = ref.replace(/^refs\/heads\//, '').replace(/^refs\/remotes\/origin\//, '');
52
+ const isRemote = ref.startsWith('refs/remotes/');
53
+ // Remote tip wins as the shared truth; local tip fills gaps.
54
+ if (!heads[branch] || isRemote) {
55
+ heads[branch] = { sha, time: Number(time) || 0, author: author || '' };
56
+ }
57
+ }
58
+ }
59
+ return heads;
60
+ }
61
+
62
+ async board() {
63
+ const ownBranch = await currentBranch(this.repo.root);
64
+ const ownHead = await tryGit(this.repo.root, ['rev-parse', 'HEAD']);
65
+ const heads = await this.branchHeads();
66
+ const state = this.store.state;
67
+ const shares = Object.values(state.shares || {}).filter((s) => s.active);
68
+ const sharedBranchSet = new Set(shares.map((s) => s.branch));
69
+ const defaultish = new Set(['main', 'master', 'develop', 'dev']);
70
+ const others = Object.entries(heads)
71
+ .filter(([b]) => !sharedBranchSet.has(b) && !defaultish.has(b) && b !== ownBranch)
72
+ .map(([branch, h]) => ({ branch, ...h }))
73
+ .sort((a, b) => b.time - a.time)
74
+ .slice(0, 30);
75
+
76
+ return {
77
+ repo: { name: this.repo.name, remote: this.repo.remote, root: this.repo.root },
78
+ me: this.identity,
79
+ ownBranch,
80
+ ownHead,
81
+ port: this.port,
82
+ shares,
83
+ heads,
84
+ otherBranches: others,
85
+ comments: Object.values(state.comments || {}),
86
+ previews: this.previews.statuses(),
87
+ sync: {
88
+ hasRemote: this.store.hasRemote,
89
+ lastSync: this.store.lastSync,
90
+ lastSyncError: this.store.lastSyncError,
91
+ },
92
+ };
93
+ }
94
+
95
+ async handle(req, res) {
96
+ const url = new URL(req.url, `http://localhost:${this.port}`);
97
+ const p = url.pathname;
98
+
99
+ try {
100
+ if (p.startsWith('/api/')) {
101
+ if (req.method === 'GET' && p === '/api/board') {
102
+ return json(res, 200, await this.board());
103
+ }
104
+ if (req.method === 'POST' && p === '/api/share') {
105
+ const body = await readBody(req);
106
+ const branch = body.branch || (await currentBranch(this.repo.root));
107
+ this.store.share({ branch, title: body.title });
108
+ this.syncSoon();
109
+ return json(res, 200, { ok: true, branch });
110
+ }
111
+ if (req.method === 'POST' && p === '/api/unshare') {
112
+ const body = await readBody(req);
113
+ this.store.unshare(body.branch);
114
+ this.syncSoon();
115
+ return json(res, 200, { ok: true });
116
+ }
117
+ if (req.method === 'POST' && p === '/api/comments') {
118
+ const body = await readBody(req);
119
+ const heads = await this.branchHeads();
120
+ const ownBranch = await currentBranch(this.repo.root);
121
+ let commit = heads[body.branch] ? heads[body.branch].sha : null;
122
+ if (body.branch === ownBranch) {
123
+ commit = await tryGit(this.repo.root, ['rev-parse', 'HEAD']);
124
+ }
125
+ const id = this.store.addComment({
126
+ targetUser: body.targetUser,
127
+ branch: body.branch,
128
+ route: body.route || '/',
129
+ xPct: body.xPct,
130
+ yPct: body.yPct,
131
+ viewportLabel: body.viewportLabel || 'desktop',
132
+ viewportW: body.viewportW,
133
+ viewportH: body.viewportH,
134
+ commit,
135
+ text: String(body.text || '').slice(0, 4000),
136
+ });
137
+ this.syncSoon();
138
+ return json(res, 200, { ok: true, id });
139
+ }
140
+ const replyMatch = p.match(/^\/api\/comments\/([a-z0-9]+)\/replies$/);
141
+ if (req.method === 'POST' && replyMatch) {
142
+ const body = await readBody(req);
143
+ this.store.reply(replyMatch[1], String(body.text || '').slice(0, 4000));
144
+ this.syncSoon();
145
+ return json(res, 200, { ok: true });
146
+ }
147
+ const resolveMatch = p.match(/^\/api\/comments\/([a-z0-9]+)\/resolve$/);
148
+ if (req.method === 'POST' && resolveMatch) {
149
+ const body = await readBody(req);
150
+ this.store.setResolved(resolveMatch[1], body.resolved !== false);
151
+ this.syncSoon();
152
+ return json(res, 200, { ok: true });
153
+ }
154
+ if (req.method === 'POST' && p === '/api/preview/start') {
155
+ const body = await readBody(req);
156
+ const ownBranch = await currentBranch(this.repo.root);
157
+ const rec = await this.previews.ensureBranch(body.user, body.branch, ownBranch);
158
+ return json(res, 200, {
159
+ ok: true,
160
+ status: rec.status, url: rec.url, error: rec.error || null, logTail: rec.log ? rec.log.slice(-1600) : '',
161
+ });
162
+ }
163
+ return json(res, 404, { error: 'not found' });
164
+ }
165
+
166
+ // Dashboard static files
167
+ if (p === '/' || p === '/index.html') {
168
+ res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });
169
+ return res.end(fs.readFileSync(path.join(PUBLIC_DIR, 'index.html')));
170
+ }
171
+ return serveStatic(PUBLIC_DIR, req, res);
172
+ } catch (err) {
173
+ return json(res, 500, { error: err.message });
174
+ }
175
+ }
176
+
177
+ syncSoon() {
178
+ clearTimeout(this._syncSoonTimer);
179
+ this._syncSoonTimer = setTimeout(() => this.store.sync(), 800);
180
+ }
181
+
182
+ listen() {
183
+ return new Promise((resolve, reject) => {
184
+ const attempt = (port, remaining) => {
185
+ const server = http.createServer((req, res) => this.handle(req, res));
186
+ server.once('error', (err) => {
187
+ if (err.code === 'EADDRINUSE' && remaining > 0) {
188
+ attempt(port + 1, remaining - 1);
189
+ } else {
190
+ reject(err);
191
+ }
192
+ });
193
+ server.listen(port, '127.0.0.1', () => {
194
+ this.port = port;
195
+ this.server = server;
196
+ this.timers.push(setInterval(() => this.store.sync(), SYNC_INTERVAL_MS));
197
+ this.timers.push(setInterval(
198
+ () => tryGit(this.repo.root, ['fetch', '--quiet', 'origin']),
199
+ FETCH_INTERVAL_MS,
200
+ ));
201
+ resolve(port);
202
+ });
203
+ };
204
+ attempt(this.port, 20);
205
+ });
206
+ }
207
+
208
+ close() {
209
+ this.timers.forEach(clearInterval);
210
+ clearTimeout(this._syncSoonTimer);
211
+ if (this.server) this.server.close();
212
+ this.previews.stopAll();
213
+ }
214
+ }