clickroom 0.0.0-stage → 0.2.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/cli/config.js ADDED
@@ -0,0 +1,140 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import os from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8
+ export const SITE_P = path.join(ROOT, 'site', 'p');
9
+ // Renamed from protoshare.local.json when the tool became clickroom.
10
+ const OLD_LOCAL_CONFIG = path.join(ROOT, 'protoshare.local.json');
11
+ if (!fs.existsSync(path.join(ROOT, 'clickroom.local.json')) && fs.existsSync(OLD_LOCAL_CONFIG)) fs.renameSync(OLD_LOCAL_CONFIG, path.join(ROOT, 'clickroom.local.json'));
12
+ const DEV_VARS = path.join(ROOT, '.dev.vars');
13
+ export const NAME_RE = /^[a-z0-9][a-z0-9-]{0,40}$/;
14
+
15
+ // Comments and versions live outside the repo so cleaning it (e.g. deleting .wrangler)
16
+ // can't lose feedback. CLICKROOM_HOME overrides the location.
17
+ export const HOME = process.env.CLICKROOM_HOME || path.join(os.homedir(), '.clickroom');
18
+ export const STATE_DIR = path.join(HOME, 'state');
19
+ export const BACKUP_DIR = path.join(HOME, 'backups');
20
+ // The projects shared from this computer: next to the clickroom code for the owner's checkout
21
+ // (where it always was), in ~/.clickroom for an installed clickroom (npm i -g clickroom).
22
+ const LOCAL_CONFIG = fs.existsSync(path.join(ROOT, 'clickroom.local.json')) || fs.existsSync(DEV_VARS) ? path.join(ROOT, 'clickroom.local.json') : path.join(HOME, 'config.json');
23
+ // `clickroom login`: a teammate's own key for a clickroom server ({ url, token, name, email }).
24
+ const CREDENTIALS = path.join(HOME, 'credentials.json');
25
+
26
+ // Where the CLI, MCP server and project sessions talk to: the cloud deployment once
27
+ // `clickroom cloud setup` has run, otherwise the local server. CLICKROOM_URL overrides both.
28
+ export function baseUrl() {
29
+ return (process.env.CLICKROOM_URL || cloudUrl() || localUrl()).replace(/\/$/, '');
30
+ }
31
+
32
+ export function cloudUrl() {
33
+ return readLocal().cloudUrl || (isTeammate() && credentials()?.url) || null;
34
+ }
35
+
36
+ export function localUrl() {
37
+ return `http://localhost:${readLocal().port || 8787}`;
38
+ }
39
+
40
+ // What the CLI signs its calls with: the admin key on the owner's Mac, or else the key a
41
+ // teammate got from `clickroom login`. CLICKROOM_TOKEN (a teammate's key) overrides both.
42
+ export function authToken() {
43
+ return process.env.CLICKROOM_TOKEN || adminToken() || credentials()?.token || null;
44
+ }
45
+
46
+ // A teammate's CLI (not the owner's): pushes upload their builds and the project only shows
47
+ // pushed versions.
48
+ export function isTeammate() {
49
+ return !!process.env.CLICKROOM_TOKEN || (!adminToken() && !!credentials()?.token);
50
+ }
51
+
52
+ export function credentials() {
53
+ try {
54
+ return JSON.parse(fs.readFileSync(CREDENTIALS, 'utf8'));
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ export function saveCredentials(c) {
61
+ fs.mkdirSync(HOME, { recursive: true });
62
+ if (c) fs.writeFileSync(CREDENTIALS, JSON.stringify(c, null, 2) + '\n', { mode: 0o600 });
63
+ else fs.rmSync(CREDENTIALS, { force: true });
64
+ }
65
+
66
+ // Admin token shared by the worker (.dev.vars) and the CLI/MCP.
67
+ export function adminToken({ create = false } = {}) {
68
+ if (process.env.CLICKROOM_ADMIN_TOKEN) return process.env.CLICKROOM_ADMIN_TOKEN;
69
+ if (fs.existsSync(DEV_VARS)) {
70
+ const m = fs.readFileSync(DEV_VARS, 'utf8').match(/^ADMIN_TOKEN\s*=\s*"?([^"\n]+)"?/m);
71
+ if (m) return m[1].trim();
72
+ }
73
+ if (!create) return null;
74
+ const token = crypto.randomBytes(18).toString('hex');
75
+ const prev = fs.existsSync(DEV_VARS) ? fs.readFileSync(DEV_VARS, 'utf8').replace(/\n?$/, '\n') : '';
76
+ fs.writeFileSync(DEV_VARS, `${prev}ADMIN_TOKEN=${token}\n`, { mode: 0o600 });
77
+ return token;
78
+ }
79
+
80
+ // Local-only bookkeeping: where static projects are copied from, and whether to watch them.
81
+ export function readLocal() {
82
+ try {
83
+ return JSON.parse(fs.readFileSync(LOCAL_CONFIG, 'utf8'));
84
+ } catch {
85
+ return { projects: {} };
86
+ }
87
+ }
88
+
89
+ export function writeLocal(cfg) {
90
+ fs.mkdirSync(path.dirname(LOCAL_CONFIG), { recursive: true });
91
+ fs.writeFileSync(LOCAL_CONFIG, JSON.stringify(cfg, null, 2) + '\n');
92
+ }
93
+
94
+ const SKIP = new Set(['node_modules', '.git', '.DS_Store']);
95
+
96
+ // Copies a build into site/p/<name> (unversioned) or site/p/<name>/v<version> (a pushed
97
+ // snapshot). Snapshot folders are never touched by an unversioned copy.
98
+ export function copyBuild(name, source, version = null) {
99
+ const src = path.resolve(source);
100
+ if (!fs.existsSync(src) || !fs.statSync(src).isDirectory()) throw new Error(`Not a directory: ${src}`);
101
+ if (!fs.existsSync(path.join(src, 'index.html'))) {
102
+ console.warn(`warning: ${src} has no index.html, so the review opens on a 404 until you navigate to a page.`);
103
+ }
104
+ const dest = version == null ? path.join(SITE_P, name) : path.join(SITE_P, name, `v${version}`);
105
+ const tmp = `${dest}.tmp-${process.pid}`;
106
+ fs.rmSync(tmp, { recursive: true, force: true });
107
+ fs.cpSync(src, tmp, { recursive: true, filter: (p) => !SKIP.has(path.basename(p)) });
108
+ if (version == null && fs.existsSync(dest)) {
109
+ for (const entry of fs.readdirSync(dest)) {
110
+ if (/^v\d+$/.test(entry)) fs.renameSync(path.join(dest, entry), path.join(tmp, entry));
111
+ }
112
+ }
113
+ fs.rmSync(dest, { recursive: true, force: true });
114
+ fs.renameSync(tmp, dest);
115
+ return dest;
116
+ }
117
+
118
+ // The HTML pages of a build, for the review's Pages list: [{ path: '/about/', title }].
119
+ // index.html stands for its folder. Dependencies and dot folders are skipped.
120
+ export function buildPages(dir, limit = 500) {
121
+ const out = [];
122
+ const walk = (rel) => {
123
+ for (const e of fs.readdirSync(path.join(dir, rel), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
124
+ if (out.length >= limit || e.name.startsWith('.') || SKIP.has(e.name)) continue;
125
+ const file = path.posix.join(rel, e.name);
126
+ if (e.isDirectory()) walk(file);
127
+ else if (/\.html?$/i.test(e.name)) {
128
+ const html = fs.readFileSync(path.join(dir, file), 'utf8').slice(0, 20000);
129
+ const title = html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1].replace(/\s+/g, ' ').trim() || null;
130
+ out.push({ path: '/' + file.replace(/(^|\/)index\.html?$/i, '$1'), title });
131
+ }
132
+ }
133
+ };
134
+ walk('');
135
+ return out;
136
+ }
137
+
138
+ export function removeBuild(name) {
139
+ fs.rmSync(path.join(SITE_P, name), { recursive: true, force: true });
140
+ }
package/cli/deploy.js ADDED
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { ROOT, HOME } from './config.js';
5
+ import { dim } from './dev.js';
6
+
7
+ export const CLOUD_CONFIG = path.join(ROOT, 'wrangler.cloud.jsonc');
8
+ const LOCK = path.join(HOME, 'deploy.lock');
9
+ const ASSET_LIMIT = 20000; // Workers static assets per version on the Free plan
10
+
11
+ // Deploys the cloud worker with everything in site/: the review UI plus every pushed
12
+ // snapshot. Uploads are content-hashed, so only new files travel. Deploys from several
13
+ // terminals at once are queued with a lock file.
14
+ export async function deploy({ quiet = false } = {}) {
15
+ await lock();
16
+ try {
17
+ const files = countFiles(path.join(ROOT, 'site'));
18
+ if (files > ASSET_LIMIT * 0.9) console.log(`warning: ${files} files in site/ (the Free plan allows ${ASSET_LIMIT}). Remove old snapshots under site/p/<name>/v*.`);
19
+ if (!quiet) console.log(dim(' Uploading to Cloudflare…'));
20
+ const out = await wrangler(['deploy', '-c', CLOUD_CONFIG]);
21
+ return { url: out.match(/https:\/\/[^\s]+\.workers\.dev/)?.[0] || null, log: out };
22
+ } finally {
23
+ fs.rmSync(LOCK, { force: true });
24
+ }
25
+ }
26
+
27
+ export function wrangler(args, { input } = {}) {
28
+ return new Promise((resolve, reject) => {
29
+ const bin = path.join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'wrangler.cmd' : 'wrangler');
30
+ const child = spawn(bin, args, { cwd: ROOT, env: { ...process.env, FORCE_COLOR: '0' }, stdio: ['pipe', 'pipe', 'pipe'] });
31
+ let log = '';
32
+ child.stdout.on('data', (d) => (log += d));
33
+ child.stderr.on('data', (d) => (log += d));
34
+ child.stdin.end(input ?? '');
35
+ child.on('error', reject);
36
+ child.on('exit', (code) => {
37
+ if (code === 0) resolve(log);
38
+ else reject(new Error(`wrangler ${args[0]} failed:\n${log.trim().split('\n').slice(-15).join('\n')}`));
39
+ });
40
+ });
41
+ }
42
+
43
+ async function lock() {
44
+ fs.mkdirSync(HOME, { recursive: true });
45
+ for (let i = 0; i < 600; i++) {
46
+ try {
47
+ fs.writeFileSync(LOCK, String(process.pid), { flag: 'wx' });
48
+ return;
49
+ } catch {
50
+ const pid = Number(fs.readFileSync(LOCK, 'utf8')) || 0;
51
+ if (!alive(pid)) fs.rmSync(LOCK, { force: true });
52
+ else await new Promise((r) => setTimeout(r, 1000));
53
+ }
54
+ }
55
+ throw new Error(`another deploy is still running (lock: ${LOCK})`);
56
+ }
57
+
58
+ function alive(pid) {
59
+ try {
60
+ process.kill(pid, 0);
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ function countFiles(dir) {
68
+ let n = 0;
69
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) n += e.isDirectory() ? countFiles(path.join(dir, e.name)) : 1;
70
+ return n;
71
+ }
package/cli/detect.js ADDED
@@ -0,0 +1,155 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+
6
+ const run = promisify(execFile);
7
+ export const COMMON_PORTS = [5173, 5174, 3000, 3001, 4321, 4173, 4200, 8080, 8000, 6006];
8
+ const BUILD_DIRS = ['dist', 'build', 'out', '.output/public'];
9
+
10
+ // Project name: --name, package.json name (scope stripped), else the directory name.
11
+ export function projectName(root, override) {
12
+ if (override) return slug(override);
13
+ try {
14
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
15
+ if (pkg.name) return slug(pkg.name.replace(/^@[^/]+\//, ''));
16
+ } catch {}
17
+ return slug(path.basename(root));
18
+ }
19
+
20
+ export function slug(s) {
21
+ const out = String(s)
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9-]+/g, '-')
24
+ .replace(/^-+|-+$/g, '')
25
+ .slice(0, 41)
26
+ .replace(/-+$/, '');
27
+ return out || 'prototype';
28
+ }
29
+
30
+ // The project root is the nearest enclosing git repo, else the current directory.
31
+ export function projectRoot(cwd) {
32
+ let dir = path.resolve(cwd);
33
+ for (;;) {
34
+ if (fs.existsSync(path.join(dir, '.git'))) return dir;
35
+ const up = path.dirname(dir);
36
+ if (up === dir) return path.resolve(cwd);
37
+ dir = up;
38
+ }
39
+ }
40
+
41
+ // Explicit target: "5173" → localhost port, "http://…" → URL, a path → static build.
42
+ export function parseTarget(arg, cwd) {
43
+ if (arg == null || arg === '') return null;
44
+ const s = String(arg);
45
+ if (/^\d{2,5}$/.test(s)) return { kind: 'url', target: `http://localhost:${s}` };
46
+ if (/^:\d{2,5}$/.test(s)) return { kind: 'url', target: `http://localhost${s}` };
47
+ if (/^https?:\/\//.test(s)) return { kind: 'url', target: new URL(s).toString() };
48
+ if (/^(localhost|127\.0\.0\.1):\d+/.test(s)) return { kind: 'url', target: `http://${s}` };
49
+ const dir = path.resolve(cwd, s);
50
+ if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) return { kind: 'static', source: dir };
51
+ throw new Error(`"${s}" is not a port, URL or directory`);
52
+ }
53
+
54
+ // Parses `lsof -nP -iTCP -sTCP:LISTEN -Fpcn` field output.
55
+ export function parseLsofListen(out) {
56
+ const rows = [];
57
+ let pid = null, command = null;
58
+ for (const line of out.split('\n')) {
59
+ const tag = line[0], val = line.slice(1);
60
+ if (tag === 'p') pid = Number(val);
61
+ else if (tag === 'c') command = val;
62
+ else if (tag === 'n') {
63
+ const m = val.match(/:(\d+)$/);
64
+ if (m && pid) rows.push({ pid, command, port: Number(m[1]), addr: val });
65
+ }
66
+ }
67
+ const seen = new Set();
68
+ return rows.filter((r) => (seen.has(`${r.pid}:${r.port}`) ? false : seen.add(`${r.pid}:${r.port}`)));
69
+ }
70
+
71
+ // Parses `lsof -a -d cwd -p <pids> -Fpn` output into pid → cwd.
72
+ export function parseLsofCwd(out) {
73
+ const map = new Map();
74
+ let pid = null;
75
+ for (const line of out.split('\n')) {
76
+ if (line[0] === 'p') pid = Number(line.slice(1));
77
+ else if (line[0] === 'n' && pid) map.set(pid, line.slice(1));
78
+ }
79
+ return map;
80
+ }
81
+
82
+ export function isInside(child, parent) {
83
+ const rel = path.relative(parent, child);
84
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
85
+ }
86
+
87
+ // Listening TCP servers whose process runs inside the project directory.
88
+ export async function serversInProject(root, { exclude = [] } = {}) {
89
+ let listen;
90
+ try {
91
+ ({ stdout: listen } = await run('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-Fpcn'], { maxBuffer: 8 << 20 }));
92
+ } catch (e) {
93
+ if (e.stdout) listen = e.stdout; // lsof exits 1 when some entries are inaccessible
94
+ else return [];
95
+ }
96
+ const rows = parseLsofListen(listen).filter((r) => !exclude.includes(r.port));
97
+ if (!rows.length) return [];
98
+ const pids = [...new Set(rows.map((r) => r.pid))];
99
+ let cwdOut = '';
100
+ try {
101
+ ({ stdout: cwdOut } = await run('lsof', ['-a', '-d', 'cwd', '-p', pids.join(','), '-Fpn'], { maxBuffer: 8 << 20 }));
102
+ } catch (e) {
103
+ cwdOut = e.stdout || '';
104
+ }
105
+ const cwds = parseLsofCwd(cwdOut);
106
+ return rows
107
+ .map((r) => ({ ...r, cwd: cwds.get(r.pid) }))
108
+ .filter((r) => r.cwd && isInside(r.cwd, root))
109
+ .sort((a, b) => a.port - b.port);
110
+ }
111
+
112
+ // Is something on this port answering HTTP? Prefers HTML pages.
113
+ export async function probe(port) {
114
+ try {
115
+ const res = await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(1500), redirect: 'manual' });
116
+ res.body?.cancel().catch(() => {});
117
+ return { port, ok: true, html: /text\/html/.test(res.headers.get('content-type') || ''), status: res.status };
118
+ } catch {
119
+ return { port, ok: false };
120
+ }
121
+ }
122
+
123
+ // Finds what to share in `root`: a dev server started from the project, a common
124
+ // dev port, or a build directory.
125
+ export async function detectTarget(root, { exclude = [] } = {}) {
126
+ const own = await serversInProject(root, { exclude });
127
+ const probed = (await Promise.all(own.map(async (s) => ({ ...s, ...(await probe(s.port)) })))).filter((s) => s.ok);
128
+ const ranked = [...probed.filter((s) => s.html), ...probed.filter((s) => !s.html)];
129
+ if (ranked.length) {
130
+ const pick = ranked[0];
131
+ return {
132
+ kind: 'url',
133
+ target: `http://localhost:${pick.port}`,
134
+ how: `${pick.command || 'process'} (pid ${pick.pid}) running in this project`,
135
+ others: ranked.slice(1).map((s) => s.port),
136
+ };
137
+ }
138
+
139
+ const common = (await Promise.all(COMMON_PORTS.filter((p) => !exclude.includes(p)).map(probe))).filter((p) => p.ok && p.html);
140
+ if (common.length) {
141
+ return {
142
+ kind: 'url',
143
+ target: `http://localhost:${common[0].port}`,
144
+ how: 'common dev port (not confirmed to belong to this project)',
145
+ others: common.slice(1).map((p) => p.port),
146
+ uncertain: true,
147
+ };
148
+ }
149
+
150
+ for (const d of BUILD_DIRS) {
151
+ const dir = path.join(root, d);
152
+ if (fs.existsSync(path.join(dir, 'index.html'))) return { kind: 'static', source: dir, how: `build output in ${d}/` };
153
+ }
154
+ return null;
155
+ }
package/cli/dev.js ADDED
@@ -0,0 +1,279 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { spawn, execFileSync } from 'node:child_process';
5
+ import { ROOT, HOME, STATE_DIR, cloudUrl, adminToken, readLocal, writeLocal, copyBuild } from './config.js';
6
+ import { call, isUp, signinLink } from './client.js';
7
+
8
+ // Quick Tunnel: one URL on the same line. Named tunnel: one or more hostnames on the next lines.
9
+ const TUNNEL_RE = /Sharing via Cloudflare Tunnel:\s*(https:\/\/[^\s/]+)/;
10
+ export const dim = (s) => (process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s);
11
+ export const bold = (s) => (process.stdout.isTTY ? `\x1b[1m${s}\x1b[0m` : s);
12
+
13
+ // Runs the worker under `wrangler dev`, optionally exposed through a Cloudflare
14
+ // local dev tunnel (Quick Tunnel by default, named tunnel with --tunnel-name).
15
+ // Resolves once the worker is up and, if requested, the tunnel URL is known.
16
+ export async function startServer(opts = {}) {
17
+ const cfg = readLocal();
18
+ const port = Number(opts.port || cfg.port || 8787);
19
+ const token = adminToken({ create: true });
20
+ cfg.port = port;
21
+ // A named tunnel (stable hostname on your own domain) is remembered: once set up,
22
+ // every later `clickroom` uses it. `--tunnel-name none` goes back to Quick Tunnels.
23
+ if (opts['tunnel-name'] === 'none') delete cfg.tunnelName;
24
+ else if (opts['tunnel-name']) cfg.tunnelName = opts['tunnel-name'];
25
+ else if (cfg.tunnelName && opts.tunnel && !opts['allow-email']?.length) opts = { ...opts, 'tunnel-name': cfg.tunnelName };
26
+ if (opts['tunnel-name'] === 'none') opts = { ...opts, 'tunnel-name': undefined };
27
+ writeLocal(cfg);
28
+ process.env.CLICKROOM_URL = `http://localhost:${port}`;
29
+
30
+ if (await isUp() && killOrphan(port)) await new Promise((r) => setTimeout(r, 2000));
31
+ if (await isUp()) throw new Error(`Something is already serving on port ${port}. Stop it or pass --port.`);
32
+
33
+ // Refresh static builds before wrangler starts so the asset manifest is current.
34
+ for (const [name, p] of Object.entries(cfg.projects || {})) {
35
+ if (p.kind === 'static' && p.source && fs.existsSync(p.source)) copyBuild(name, p.source);
36
+ }
37
+
38
+ migrateState();
39
+ const args = ['dev', '--port', String(port), '--persist-to', STATE_DIR, '--show-interactive-dev-session=false'];
40
+ if (opts.lan) args.push('--ip', '0.0.0.0');
41
+ if (cloudUrl()) args.push('--var', `CLOUD_URL:${cloudUrl()}`);
42
+ if (opts['tunnel-name'] && cfg.tunnelName === opts['tunnel-name'] && cfg.tunnelPort && cfg.tunnelPort !== port) {
43
+ console.log(dim(` Note: tunnel "${opts['tunnel-name']}" was set up for port ${cfg.tunnelPort}; its hostname won't reach port ${port}.`));
44
+ }
45
+ if (opts['tunnel-name']) {
46
+ cfg.tunnelPort ||= port;
47
+ writeLocal(cfg);
48
+ }
49
+ const tunnel = !!(opts.tunnel || opts['tunnel-name'] || opts['allow-email']?.length);
50
+ if (tunnel) args.push('--tunnel');
51
+ if (opts['tunnel-name']) args.push('--tunnel-name', opts['tunnel-name']);
52
+ for (const mail of [].concat(opts['allow-email'] || [])) args.push('--tunnel-allowed-mail', mail);
53
+ // Local only: every project gets its own origin (<name>.localhost), so prototype code can't
54
+ // reach the admin API. A tunnel has one hostname, so there prototypes share clickroom's origin.
55
+ if (!tunnel && !opts.lan && !cloudUrl()) args.push('--var', 'PROTO_HOST:{name}.localhost', '--var', `APP_URL:http://localhost:${port}`);
56
+ else if (tunnel && !cloudUrl() && !opts['public-url']) console.log(dim(' Note: over a tunnel, prototypes run on clickroom\'s own origin (not isolated per project).'));
57
+
58
+ const bin = path.join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'wrangler.cmd' : 'wrangler');
59
+ // The npm package leaves wrangler out: teammates share through a clickroom server (`clickroom login`).
60
+ if (!fs.existsSync(bin)) throw new Error('Running a clickroom server needs a checkout of clickroom with `npm install`. To share through your team\'s clickroom, run `clickroom login`.');
61
+ if (opts.verbose) console.log(dim(`$ wrangler ${args.join(' ')}`));
62
+
63
+ let child, buffer, tunnelFound, resolveTunnel, tunnelDied;
64
+ let stopped = false, restarting = false, probe = null;
65
+ let resolveExited;
66
+ const exited = new Promise((r) => (resolveExited = r));
67
+ const launch = () => {
68
+ // Own process group, so stopping also takes down workerd and the cloudflared tunnel.
69
+ child = spawn(bin, args, {
70
+ cwd: ROOT,
71
+ env: { ...process.env, FORCE_COLOR: '0' },
72
+ stdio: ['ignore', 'pipe', 'pipe'],
73
+ detached: process.platform !== 'win32',
74
+ });
75
+ buffer = '';
76
+ tunnelFound = new Promise((r) => (resolveTunnel = r));
77
+ const onData = (chunk) => {
78
+ const text = chunk.toString().replace(/\x1b\[[0-9;]*m/g, '');
79
+ buffer += text;
80
+ if (opts.verbose) process.stdout.write(dim(text));
81
+ else for (const line of text.split('\n')) if (/✘|\berror\b/i.test(line) && !/cdn-cgi|cloudflared exited/.test(line)) console.log(dim(line));
82
+ const m = buffer.match(TUNNEL_RE);
83
+ if (m) resolveTunnel(m[1]);
84
+ if (/No Cloudflare Tunnel named/.test(text)) console.log(dim(' Create it in the Cloudflare dashboard (Networking → Tunnels), or run `npx wrangler tunnel list` to check the name.'));
85
+ if (/has no route|does not route to|ingress/i.test(text) && opts['tunnel-name']) console.log(dim(` The tunnel's public hostname must point to http://localhost:${port} (Cloudflare dashboard → Tunnels → ${opts['tunnel-name']} → Routes).`));
86
+ if (/not authenticated|wrangler login/i.test(text) && opts['tunnel-name']) console.log(dim(' Named tunnels need wrangler logged in to your Cloudflare account: run `npx wrangler login` in the clickroom folder.'));
87
+ // wrangler keeps serving when its cloudflared connector dies, but never restarts it.
88
+ if (tunnel && !stopped && /cloudflared (exited|terminated)/.test(text)) tunnelDied?.();
89
+ };
90
+ child.stdout.on('data', onData);
91
+ child.stderr.on('data', onData);
92
+ const c = child;
93
+ c.on('exit', (code) => {
94
+ if (!restarting && c === child) resolveExited(code);
95
+ });
96
+ };
97
+ const kill = (c, sig = 'SIGTERM') => {
98
+ try {
99
+ if (process.platform === 'win32') c.kill(sig);
100
+ else process.kill(-c.pid, sig);
101
+ } catch {}
102
+ };
103
+ const stop = (sig = 'SIGTERM') => {
104
+ if (stopped) return;
105
+ stopped = true;
106
+ clearInterval(probe);
107
+ kill(child, sig);
108
+ };
109
+ process.on('exit', () => stop());
110
+
111
+ const waitUp = async () => {
112
+ for (let i = 0; i < 120 && !(await isUp()); i++) {
113
+ if (child.exitCode !== null) break;
114
+ await new Promise((r) => setTimeout(r, 500));
115
+ }
116
+ return isUp();
117
+ };
118
+
119
+ launch();
120
+ if (!(await waitUp())) {
121
+ stop();
122
+ throw new Error(`wrangler did not start.\n${buffer.split('\n').slice(-15).join('\n')}`);
123
+ }
124
+
125
+ await call('POST', '/__ps/api/meta', {
126
+ localUrl: `http://localhost:${port}`,
127
+ cliPath: path.join(ROOT, 'bin', 'clickroom.js'),
128
+ tunnelUrl: opts['public-url'] || null,
129
+ });
130
+ for (const [name, p] of Object.entries(cfg.projects || {})) {
131
+ await call('POST', '/__ps/api/projects', { name, kind: p.kind, target: p.target, source: p.source || p.target, root: p.cwd || undefined });
132
+ }
133
+ startWatchers(cfg);
134
+
135
+ let publicUrl = opts['public-url'] ? String(opts['public-url']).replace(/\/$/, '') : null;
136
+ const awaitTunnel = async () => {
137
+ const url = await Promise.race([tunnelFound, new Promise((r) => setTimeout(() => r(null), 60000))]);
138
+ // In cloud mode the tunnel only carries the relay; the address to share is the cloud one,
139
+ // so that's what this server advertises (to sessions, `clickroom links`, the dashboard).
140
+ if (url) await call('POST', '/__ps/api/meta', { tunnelUrl: cloudUrl() || url });
141
+ return url;
142
+ };
143
+ if (tunnel && !publicUrl) {
144
+ publicUrl = await awaitTunnel();
145
+ if (!publicUrl) console.log('warning: no tunnel URL after 60s. Re-run with --verbose to see wrangler output.');
146
+ }
147
+
148
+ // Tunnel watchdog. A Quick Tunnel can drop (sleep, network change, Cloudflare recycling
149
+ // it) while wrangler keeps serving locally, which leaves every shared link dead. When the
150
+ // connector exits, or the public URL stops answering, restart wrangler for a new tunnel.
151
+ // State persists across the restart; session terminals pick up and print the new links.
152
+ if (tunnel && !opts['public-url']) {
153
+ const reopen = async (why) => {
154
+ if (restarting || stopped) return;
155
+ restarting = true;
156
+ console.log(dim(` Cloudflare Tunnel lost (${why}). Reopening…`));
157
+ if (!cloudUrl()) await call('POST', '/__ps/api/meta', { tunnelUrl: null }).catch(() => {});
158
+ const old = child;
159
+ kill(old);
160
+ await Promise.race([new Promise((r) => old.on('exit', r)), new Promise((r) => setTimeout(r, 5000))]);
161
+ if (old.exitCode === null) kill(old, 'SIGKILL');
162
+ if (stopped) return;
163
+ launch();
164
+ restarting = false;
165
+ if (!(await waitUp())) {
166
+ console.log(` wrangler did not come back.\n${buffer.split('\n').slice(-15).join('\n')}`);
167
+ return stop();
168
+ }
169
+ await call('POST', '/__ps/api/meta', { localUrl: `http://localhost:${port}`, cliPath: path.join(ROOT, 'bin', 'clickroom.js') }).catch(() => {});
170
+ publicUrl = await awaitTunnel();
171
+ console.log(publicUrl ? `${bold('✔')} Tunnel reopened: ${publicUrl}${opts['tunnel-name'] ? '' : ' (old links no longer work)'}` : 'warning: tunnel did not come back within 60s.');
172
+ failures = 0;
173
+ };
174
+ tunnelDied = () => reopen('connector exited');
175
+ let failures = 0;
176
+ probe = setInterval(async () => {
177
+ if (restarting || stopped || !publicUrl) return;
178
+ try {
179
+ const res = await fetch(`${publicUrl}/__ps/api/health`, { signal: AbortSignal.timeout(10000) });
180
+ failures = res.ok ? 0 : failures + 1;
181
+ } catch {
182
+ failures++;
183
+ }
184
+ if (failures >= 3) reopen('public URL not answering');
185
+ }, 30000);
186
+ probe.unref?.();
187
+ }
188
+ return { port, token, get publicUrl() { return publicUrl; }, localUrl: `http://localhost:${port}`, stop, exited };
189
+ }
190
+
191
+ // `clickroom dev`: run the server for all registered projects.
192
+ export async function dev(opts) {
193
+ const server = await startServer(opts);
194
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => server.stop(sig));
195
+ server.exited.then((code) => process.exit(code ?? 0));
196
+
197
+ console.log('');
198
+ const cloud = cloudUrl();
199
+ if (cloud) {
200
+ // Comments, versions and links live in the cloud; this server only relays Live views
201
+ // and app API calls from there to the dev servers on this Mac.
202
+ console.log(`${bold('clickroom')} relay running at ${server.localUrl}${server.publicUrl ? ` (${server.publicUrl})` : ''}`);
203
+ console.log(` Review site and dashboard: ${cloud}/`);
204
+ console.log(` Sign in (one-time link, 10 min): ${await signinLink('/', cloud).catch(() => 'run `clickroom` in a project and press o')}`);
205
+ if (!server.publicUrl) console.log(dim(' No tunnel: the cloud cannot reach this Mac. Start with `clickroom dev --tunnel`.'));
206
+ return;
207
+ }
208
+ console.log(`${bold('clickroom')} running at ${server.localUrl}`);
209
+ console.log(` Team dashboard: ${server.localUrl}/`);
210
+ console.log(` Sign in (one-time link, 10 min): ${await signinLink('/', server.localUrl)}`);
211
+ if (server.publicUrl) console.log(` ${bold('Cloudflare Tunnel')} ${server.publicUrl}`);
212
+ const { projects } = await call('GET', '/__ps/api/projects');
213
+ if (!projects.length) console.log(dim(' No prototypes yet: run `clickroom` inside a project, or `clickroom add <name> <dir|url>`'));
214
+ for (const p of projects) {
215
+ console.log(` ${bold(p.name.padEnd(16))} ${server.publicUrl || server.localUrl}/s/${encodeURIComponent(p.name)}?t=${p.token}`);
216
+ }
217
+ if (!server.publicUrl) console.log(dim(' Local only. Use `clickroom dev --tunnel` to share with clients over a Cloudflare Quick Tunnel.'));
218
+ }
219
+
220
+ // A clickroom server that died hard (kill -9, crash) leaves its wrangler running on the port
221
+ // with no owner. Under launchd that would block every restart, so stop it if it's ours.
222
+ function killOrphan(port) {
223
+ try {
224
+ const out = execFileSync('pgrep', ['-f', `wrangler-dist/cli.js dev --port ${port} `], { encoding: 'utf8' });
225
+ const pids = out.split('\n').map(Number).filter(Boolean);
226
+ let killed = false;
227
+ for (const pid of pids) {
228
+ // We spawn wrangler detached, so the group leader is the process we started. If that
229
+ // leader was reparented to launchd (ppid 1), its clickroom owner is gone.
230
+ const pgid = execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' }).trim();
231
+ const [ppid, ...cmd] = execFileSync('ps', ['-o', 'ppid=,command=', '-p', pgid], { encoding: 'utf8' }).trim().split(/\s+/);
232
+ if (ppid !== '1' || !cmd.join(' ').includes(ROOT)) continue;
233
+ process.kill(-Number(pgid), 'SIGTERM');
234
+ console.log(dim(` Stopped a leftover clickroom server (pid ${pid}) on port ${port}.`));
235
+ killed = true;
236
+ }
237
+ return killed;
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ // Earlier versions kept state in <repo>/.wrangler/state. Copy it (never move) to the
244
+ // clickroom home the first time, so existing comments carry over.
245
+ function migrateState() {
246
+ // The tool used to be called protoshare and kept everything in ~/.protoshare.
247
+ const oldHome = path.join(os.homedir(), '.protoshare');
248
+ if (!process.env.CLICKROOM_HOME && !fs.existsSync(HOME) && fs.existsSync(oldHome)) {
249
+ fs.cpSync(oldHome, HOME, { recursive: true });
250
+ console.log(dim(` Comments and backups are now in ${HOME} (copied from ${oldHome}, which is left in place).`));
251
+ }
252
+ const old = path.join(ROOT, '.wrangler', 'state');
253
+ if (fs.existsSync(STATE_DIR) || !fs.existsSync(old)) return;
254
+ fs.mkdirSync(path.dirname(STATE_DIR), { recursive: true });
255
+ fs.cpSync(old, STATE_DIR, { recursive: true });
256
+ console.log(dim(` Comment storage is now in ${STATE_DIR} (copied from ${old}, which is left in place).`));
257
+ }
258
+
259
+ function startWatchers(cfg) {
260
+ for (const [name, p] of Object.entries(cfg.projects || {})) {
261
+ if (p.kind !== 'static' || !p.watch || !p.source) continue;
262
+ let timer;
263
+ try {
264
+ fs.watch(p.source, { recursive: true }, () => {
265
+ clearTimeout(timer);
266
+ timer = setTimeout(() => {
267
+ try {
268
+ copyBuild(name, p.source);
269
+ console.log(dim(`↻ ${name}: rebuilt from ${p.source}`));
270
+ } catch (e) {
271
+ console.error(e.message);
272
+ }
273
+ }, 300);
274
+ });
275
+ } catch (e) {
276
+ console.error(`could not watch ${p.source}: ${e.message}`);
277
+ }
278
+ }
279
+ }