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/README.md +365 -2
- package/bin/clickroom.js +293 -0
- package/cli/changes.js +148 -0
- package/cli/client.js +86 -0
- package/cli/cloud.js +100 -0
- package/cli/config.js +140 -0
- package/cli/deploy.js +71 -0
- package/cli/detect.js +155 -0
- package/cli/dev.js +279 -0
- package/cli/gaps.js +37 -0
- package/cli/login.js +74 -0
- package/cli/mcp.js +205 -0
- package/cli/note.js +109 -0
- package/cli/process.js +47 -0
- package/cli/push.js +198 -0
- package/cli/service.js +110 -0
- package/cli/share.js +350 -0
- package/cli/vapid.js +10 -0
- package/package.json +38 -4
- package/worker/handoff.js +351 -0
package/cli/push.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { BACKUP_DIR, copyBuild, cloudUrl, buildPages, baseUrl, localUrl, isTeammate, authToken } from './config.js';
|
|
6
|
+
import { call } from './client.js';
|
|
7
|
+
import { dim } from './dev.js';
|
|
8
|
+
import { rememberFolder } from './changes.js';
|
|
9
|
+
|
|
10
|
+
const OUT_DIRS = ['dist', 'build', 'out', '.output/public'];
|
|
11
|
+
|
|
12
|
+
export class NoSnapshot extends Error {}
|
|
13
|
+
|
|
14
|
+
// Pushes a new version of a project: builds a frozen snapshot (static copy, or the
|
|
15
|
+
// project's build output) and registers it. `live: true` pushes a label-only version
|
|
16
|
+
// that shows the running dev server.
|
|
17
|
+
// local: the project's entry in clickroom.local.json ({ kind, source, target, cwd, dir, build, out })
|
|
18
|
+
export async function push(name, local, { note, live = false, build, out, quiet = false } = {}) {
|
|
19
|
+
const enc = encodeURIComponent(name);
|
|
20
|
+
const { next } = await call('GET', `/__ps/api/projects/${enc}/versions`);
|
|
21
|
+
if (live && isTeammate()) throw new NoSnapshot('Only builds can be shared from this computer: the live dev server here isn\'t reachable from clickroom.');
|
|
22
|
+
if (live) {
|
|
23
|
+
const version = await call('POST', `/__ps/api/projects/${enc}/versions`, { n: next, kind: 'live', note });
|
|
24
|
+
await rememberFolder(name, projectFolder(local), version.n).catch(() => {});
|
|
25
|
+
await backup(name);
|
|
26
|
+
return version;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let source;
|
|
30
|
+
const command = build || local.build;
|
|
31
|
+
const dir = local.dir || local.cwd || process.cwd();
|
|
32
|
+
if (local.kind === 'static' && !command) {
|
|
33
|
+
source = local.source;
|
|
34
|
+
} else {
|
|
35
|
+
const pkgDir = packageDir(dir, local.cwd);
|
|
36
|
+
const cmd = command || buildScript(pkgDir);
|
|
37
|
+
if (!cmd) throw new NoSnapshot(`No build script in ${path.join(pkgDir || dir, 'package.json')}, so there's nothing to snapshot.`);
|
|
38
|
+
const started = Date.now();
|
|
39
|
+
if (!quiet) console.log(dim(` Building v${next}: ${cmd}`));
|
|
40
|
+
await run(cmd, pkgDir || dir);
|
|
41
|
+
source = out || local.out ? path.resolve(pkgDir || dir, out || local.out) : findOutput(pkgDir || dir, started);
|
|
42
|
+
if (!source) throw new NoSnapshot(`The build finished, but no folder with an index.html was produced (looked in ${OUT_DIRS.join(', ')}).`);
|
|
43
|
+
}
|
|
44
|
+
if (!fs.existsSync(path.join(source, 'index.html'))) throw new NoSnapshot(`${source} has no index.html.`);
|
|
45
|
+
// A teammate's computer, or a cloud server that takes uploads: the files go up (only the ones
|
|
46
|
+
// it doesn't have yet), no deploy needed.
|
|
47
|
+
if (isTeammate() || (baseUrl() !== localUrl() && (await takesUploads()))) {
|
|
48
|
+
const files = await upload(name, source, { quiet });
|
|
49
|
+
const version = await call('POST', `/__ps/api/projects/${enc}/versions`, { n: next, kind: 'snapshot', note, pages: buildPages(source), files });
|
|
50
|
+
await rememberFolder(name, projectFolder(local), version.n).catch(() => {});
|
|
51
|
+
await backup(name);
|
|
52
|
+
return version;
|
|
53
|
+
}
|
|
54
|
+
copyBuild(name, source, next);
|
|
55
|
+
// In the cloud the snapshot ships with the worker: deploy first, then announce the version,
|
|
56
|
+
// so viewers only hear about it once it loads.
|
|
57
|
+
if (cloudUrl()) {
|
|
58
|
+
const { deploy } = await import('./deploy.js');
|
|
59
|
+
await deploy({ quiet });
|
|
60
|
+
}
|
|
61
|
+
const version = await call('POST', `/__ps/api/projects/${enc}/versions`, { n: next, kind: 'snapshot', note, pages: buildPages(source) });
|
|
62
|
+
await rememberFolder(name, projectFolder(local), version.n).catch(() => {});
|
|
63
|
+
await backup(name);
|
|
64
|
+
return version;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function takesUploads() {
|
|
68
|
+
const res = await fetch(baseUrl() + '/__ps/api/health').catch(() => null);
|
|
69
|
+
return !!(res?.ok && (await res.json().catch(() => ({}))).uploads);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Uploads a build's files: lists them with their SHA-256, then sends the ones the server is
|
|
73
|
+
// missing (usually only what changed since the last push). Returns the file list for the version.
|
|
74
|
+
export async function upload(name, source, { quiet = false } = {}) {
|
|
75
|
+
const enc = encodeURIComponent(name);
|
|
76
|
+
const files = listFiles(source);
|
|
77
|
+
if (!files.length) throw new NoSnapshot(`${source} is empty.`);
|
|
78
|
+
const big = files.find((f) => f.size > 25 * 1024 * 1024);
|
|
79
|
+
if (big) throw new NoSnapshot(`${big.path} is larger than 25 MB; leave it out of the build.`);
|
|
80
|
+
const { missing } = await call('POST', `/__ps/api/projects/${enc}/builds`, { files: files.map(({ path, hash, size }) => ({ path, hash, size })) });
|
|
81
|
+
const todo = files.filter((f, i, all) => missing.includes(f.hash) && all.findIndex((g) => g.hash === f.hash) === i);
|
|
82
|
+
const total = todo.reduce((n, f) => n + f.size, 0);
|
|
83
|
+
if (!quiet) console.log(dim(todo.length ? ` Uploading ${todo.length} of ${files.length} files (${mb(total)})…` : ` All ${files.length} files are uploaded already.`));
|
|
84
|
+
const send = async (f) => {
|
|
85
|
+
for (let attempt = 1; ; attempt++) {
|
|
86
|
+
const res = await fetch(`${baseUrl()}/__ps/api/projects/${enc}/blobs/${f.hash}`, {
|
|
87
|
+
method: 'PUT',
|
|
88
|
+
headers: { authorization: `Bearer ${authToken()}`, 'content-type': 'application/octet-stream' },
|
|
89
|
+
body: fs.readFileSync(f.abs),
|
|
90
|
+
}).catch((e) => ({ ok: false, status: 0, text: async () => e.message }));
|
|
91
|
+
if (res.ok) return;
|
|
92
|
+
if (attempt >= 3 || (res.status >= 400 && res.status < 500)) throw new Error(`uploading ${f.path} failed: ${(await res.text()).slice(0, 200)}`);
|
|
93
|
+
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const queue = [...todo];
|
|
97
|
+
await Promise.all(Array.from({ length: Math.min(6, queue.length) }, async () => {
|
|
98
|
+
while (queue.length) await send(queue.shift());
|
|
99
|
+
}));
|
|
100
|
+
return files.map(({ path, hash, size }) => ({ path, hash, size }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const SKIP_FILES = new Set(['node_modules', '.git', '.DS_Store']);
|
|
104
|
+
function listFiles(dir) {
|
|
105
|
+
const out = [];
|
|
106
|
+
const walk = (rel) => {
|
|
107
|
+
for (const e of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) {
|
|
108
|
+
if (SKIP_FILES.has(e.name)) continue;
|
|
109
|
+
const file = rel ? `${rel}/${e.name}` : e.name;
|
|
110
|
+
const abs = path.join(dir, file);
|
|
111
|
+
if (e.isDirectory()) walk(file);
|
|
112
|
+
else if (e.isFile()) {
|
|
113
|
+
const bytes = fs.readFileSync(abs);
|
|
114
|
+
out.push({ path: file, abs, size: bytes.length, hash: crypto.createHash('sha256').update(bytes).digest('hex') });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
walk('');
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const mb = (n) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
|
123
|
+
|
|
124
|
+
// The folder whose changes count as "new since the last push": the project root.
|
|
125
|
+
export function projectFolder(local) {
|
|
126
|
+
return local.cwd || local.dir || process.cwd();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function pushHint(err) {
|
|
130
|
+
return (
|
|
131
|
+
`${err.message}\n` +
|
|
132
|
+
' Tell clickroom how to build it: clickroom push --build "npm run build" --out dist\n' +
|
|
133
|
+
' Or push a label-only version that shows the live dev server: clickroom push --live'
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Saves every thread and version of a project to ~/.clickroom/backups/<name>.json.
|
|
138
|
+
export async function backup(name) {
|
|
139
|
+
try {
|
|
140
|
+
const enc = encodeURIComponent(name);
|
|
141
|
+
const [feedback, versions] = await Promise.all([
|
|
142
|
+
call('GET', `/__ps/api/projects/${enc}/feedback.json?status=all&kind=all`),
|
|
143
|
+
call('GET', `/__ps/api/projects/${enc}/versions`),
|
|
144
|
+
]);
|
|
145
|
+
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
|
146
|
+
fs.writeFileSync(path.join(BACKUP_DIR, `${name}.json`), JSON.stringify({ ...feedback, versions: versions.versions }, null, 2) + '\n');
|
|
147
|
+
} catch {}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Nearest folder with a package.json, from where clickroom was run up to the project root.
|
|
151
|
+
function packageDir(dir, root) {
|
|
152
|
+
let d = path.resolve(dir);
|
|
153
|
+
const stop = path.resolve(root || dir);
|
|
154
|
+
for (;;) {
|
|
155
|
+
if (fs.existsSync(path.join(d, 'package.json'))) return d;
|
|
156
|
+
if (d === stop || path.dirname(d) === d) return null;
|
|
157
|
+
d = path.dirname(d);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildScript(dir) {
|
|
162
|
+
if (!dir) return null;
|
|
163
|
+
let pkg;
|
|
164
|
+
try {
|
|
165
|
+
pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
if (!pkg.scripts?.build) return null;
|
|
170
|
+
// Nuxt's `build` makes a server app; `generate` makes the static site a snapshot needs.
|
|
171
|
+
const nuxt = pkg.dependencies?.nuxt || pkg.devDependencies?.nuxt;
|
|
172
|
+
const script = nuxt && pkg.scripts.generate ? 'generate' : 'build';
|
|
173
|
+
const has = (f) => fs.existsSync(path.join(dir, f));
|
|
174
|
+
const pm = has('pnpm-lock.yaml') ? 'pnpm' : has('yarn.lock') ? 'yarn' : has('bun.lockb') || has('bun.lock') ? 'bun' : 'npm';
|
|
175
|
+
return `${pm} run ${script}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function findOutput(dir, since) {
|
|
179
|
+
for (const d of OUT_DIRS) {
|
|
180
|
+
const index = path.join(dir, d, 'index.html');
|
|
181
|
+
if (fs.existsSync(index) && fs.statSync(index).mtimeMs >= since - 2000) return path.join(dir, d);
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function run(cmd, cwd) {
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const child = spawn(cmd, { cwd, shell: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, FORCE_COLOR: '0' } });
|
|
189
|
+
let log = '';
|
|
190
|
+
child.stdout.on('data', (d) => (log += d));
|
|
191
|
+
child.stderr.on('data', (d) => (log += d));
|
|
192
|
+
child.on('error', (e) => reject(new NoSnapshot(`Could not run "${cmd}": ${e.message}`)));
|
|
193
|
+
child.on('exit', (code) => {
|
|
194
|
+
if (code === 0) resolve(log);
|
|
195
|
+
else reject(new NoSnapshot(`"${cmd}" failed (exit ${code}):\n${log.trim().split('\n').slice(-12).map((l) => ' ' + l).join('\n')}`));
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
package/cli/service.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
5
|
+
import { ROOT, HOME, readLocal } from './config.js';
|
|
6
|
+
import { bold, dim } from './dev.js';
|
|
7
|
+
|
|
8
|
+
// `clickroom service …`: runs `clickroom dev --tunnel` as a macOS launchd agent, so the
|
|
9
|
+
// server and tunnel start at login and restart if they crash. No terminal has to stay open.
|
|
10
|
+
const LABEL = 'dev.clickroom.server';
|
|
11
|
+
const PLIST = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
12
|
+
const LOG_DIR = path.join(HOME, 'logs');
|
|
13
|
+
const LOG = path.join(LOG_DIR, 'server.log');
|
|
14
|
+
const DOMAIN = `gui/${process.getuid?.() ?? 501}`;
|
|
15
|
+
|
|
16
|
+
export async function service(action = 'status') {
|
|
17
|
+
if (process.platform !== 'darwin') throw new Error('clickroom service uses launchd and only works on macOS. Run `clickroom dev --tunnel` instead.');
|
|
18
|
+
if (action === 'install') return install();
|
|
19
|
+
if (action === 'uninstall') return uninstall();
|
|
20
|
+
if (action === 'status') return status();
|
|
21
|
+
if (action === 'logs') return logs();
|
|
22
|
+
throw new Error('usage: clickroom service install|uninstall|status|logs');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function install() {
|
|
26
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
27
|
+
fs.mkdirSync(path.dirname(PLIST), { recursive: true });
|
|
28
|
+
const nodeBin = path.dirname(process.execPath);
|
|
29
|
+
const env = {
|
|
30
|
+
PATH: [nodeBin, '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(':'),
|
|
31
|
+
HOME: os.homedir(),
|
|
32
|
+
};
|
|
33
|
+
if (process.env.CLICKROOM_HOME) env.CLICKROOM_HOME = process.env.CLICKROOM_HOME;
|
|
34
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
35
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
36
|
+
<plist version="1.0">
|
|
37
|
+
<dict>
|
|
38
|
+
<key>Label</key><string>${LABEL}</string>
|
|
39
|
+
<key>ProgramArguments</key>
|
|
40
|
+
<array>
|
|
41
|
+
${[process.execPath, path.join(ROOT, 'bin', 'clickroom.js'), 'dev', '--tunnel'].map((a) => ` <string>${xml(a)}</string>`).join('\n')}
|
|
42
|
+
</array>
|
|
43
|
+
<key>WorkingDirectory</key><string>${xml(ROOT)}</string>
|
|
44
|
+
<key>EnvironmentVariables</key>
|
|
45
|
+
<dict>
|
|
46
|
+
${Object.entries(env).map(([k, v]) => ` <key>${k}</key><string>${xml(v)}</string>`).join('\n')}
|
|
47
|
+
</dict>
|
|
48
|
+
<key>RunAtLoad</key><true/>
|
|
49
|
+
<key>KeepAlive</key><true/>
|
|
50
|
+
<key>ThrottleInterval</key><integer>10</integer>
|
|
51
|
+
<key>StandardOutPath</key><string>${xml(LOG)}</string>
|
|
52
|
+
<key>StandardErrorPath</key><string>${xml(LOG)}</string>
|
|
53
|
+
</dict>
|
|
54
|
+
</plist>
|
|
55
|
+
`;
|
|
56
|
+
if (loaded()) launchctl(['bootout', `${DOMAIN}/${LABEL}`], { quiet: true });
|
|
57
|
+
fs.writeFileSync(PLIST, plist);
|
|
58
|
+
launchctl(['bootstrap', DOMAIN, PLIST]);
|
|
59
|
+
console.log(`${bold('✔')} clickroom now runs in the background (starts at login, restarts if it stops).`);
|
|
60
|
+
console.log(dim(` Logs: clickroom service logs · Stop: clickroom service uninstall`));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function uninstall() {
|
|
64
|
+
if (loaded()) launchctl(['bootout', `${DOMAIN}/${LABEL}`]);
|
|
65
|
+
if (fs.existsSync(PLIST)) fs.unlinkSync(PLIST);
|
|
66
|
+
console.log(`${bold('✔')} Background service removed. The server and tunnel are stopped.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function status() {
|
|
70
|
+
if (!fs.existsSync(PLIST)) return console.log('Not installed. Run `clickroom service install`.');
|
|
71
|
+
const info = loaded() ? launchctl(['print', `${DOMAIN}/${LABEL}`], { quiet: true }) : '';
|
|
72
|
+
const state = info.match(/^\s*state = (.+)$/m)?.[1] || 'not loaded';
|
|
73
|
+
const pid = info.match(/^\s*pid = (\d+)$/m)?.[1];
|
|
74
|
+
const port = readLocal().port || 8787;
|
|
75
|
+
let health = 'not answering';
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch(`http://localhost:${port}/__ps/api/health`, { signal: AbortSignal.timeout(3000) });
|
|
78
|
+
if (res.ok) health = 'answering';
|
|
79
|
+
} catch {}
|
|
80
|
+
console.log(`service ${state}${pid ? ` (pid ${pid})` : ''}`);
|
|
81
|
+
console.log(`server http://localhost:${port} ${health}`);
|
|
82
|
+
console.log(dim(`logs ${LOG}`));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function logs() {
|
|
86
|
+
if (!fs.existsSync(LOG)) return console.log('No logs yet.');
|
|
87
|
+
spawn('tail', ['-n', '40', '-f', LOG], { stdio: 'inherit' });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function loaded() {
|
|
91
|
+
try {
|
|
92
|
+
execFileSync('launchctl', ['print', `${DOMAIN}/${LABEL}`], { stdio: 'ignore' });
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function launchctl(args, { quiet = false } = {}) {
|
|
100
|
+
try {
|
|
101
|
+
return execFileSync('launchctl', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (quiet) return '';
|
|
104
|
+
throw new Error(`launchctl ${args[0]} failed: ${(err.stderr || err.message).trim()}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function xml(s) {
|
|
109
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
110
|
+
}
|
package/cli/share.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { readLocal, writeLocal, copyBuild, cloudUrl, baseUrl, isTeammate, adminToken, ROOT } from './config.js';
|
|
6
|
+
import { call, isUp, signinLink } from './client.js';
|
|
7
|
+
import { projectRoot, projectName, parseTarget, detectTarget } from './detect.js';
|
|
8
|
+
import { startServer, bold, dim } from './dev.js';
|
|
9
|
+
import { push, pushHint, backup, NoSnapshot, projectFolder } from './push.js';
|
|
10
|
+
import { folderChanges } from './changes.js';
|
|
11
|
+
import { autoNote } from './note.js';
|
|
12
|
+
import { findGaps } from './gaps.js';
|
|
13
|
+
|
|
14
|
+
// `clickroom [port|url|dir]` inside any project: find what to share, make sure the
|
|
15
|
+
// clickroom server + tunnel are running (start them, or attach to the running one),
|
|
16
|
+
// register the project, make sure it has a pushed version, and hand out links.
|
|
17
|
+
// The terminal then stays open as the project's session: p pushes a new version.
|
|
18
|
+
export async function share(arg, opts) {
|
|
19
|
+
const cwd = process.cwd();
|
|
20
|
+
const root = projectRoot(cwd);
|
|
21
|
+
const cfg = readLocal();
|
|
22
|
+
cfg.projects ||= {};
|
|
23
|
+
// The same folder keeps its project name, even if package.json is renamed later.
|
|
24
|
+
const known = Object.entries(cfg.projects).find(([, p]) => p.cwd === root)?.[0];
|
|
25
|
+
let name = opts.name ? projectName(root, opts.name) : known || projectName(root);
|
|
26
|
+
const port = Number(opts.port || cfg.port || 8787);
|
|
27
|
+
// In cloud mode comments, versions and links live on the deployed worker; this Mac only
|
|
28
|
+
// relays Live views and API calls (the local server, usually the background service).
|
|
29
|
+
// A teammate's computer (`clickroom login`): their builds go to the team's clickroom server.
|
|
30
|
+
const teammate = isTeammate() && !opts.local;
|
|
31
|
+
if (!teammate && !adminToken() && !fs.existsSync(path.join(ROOT, 'node_modules', '.bin', 'wrangler'))) {
|
|
32
|
+
throw new Error('Not signed in. Run `clickroom login` first (it opens clickroom in your browser).');
|
|
33
|
+
}
|
|
34
|
+
const cloud = opts.local ? null : cloudUrl();
|
|
35
|
+
if (!cloud) process.env.CLICKROOM_URL = `http://localhost:${port}`;
|
|
36
|
+
|
|
37
|
+
let target = parseTarget(arg, cwd);
|
|
38
|
+
if (target) target.how = 'from the command line';
|
|
39
|
+
else target = await detectTarget(root, { exclude: [port, 9229, 9230] });
|
|
40
|
+
// From a teammate's computer only builds are shared, so a running dev server isn't needed:
|
|
41
|
+
// the project's build script makes each version.
|
|
42
|
+
if (teammate && (!target || target.kind === 'url')) target = { kind: 'build', target: null, source: null, how: 'each push runs its build' };
|
|
43
|
+
if (!target) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'No running dev server found for this project.\n' +
|
|
46
|
+
' Start it first (e.g. `npm run dev`), or tell clickroom what to share:\n' +
|
|
47
|
+
' clickroom 5173 · clickroom http://localhost:3000 · clickroom ./dist',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const label = target.kind === 'url' ? target.target : target.kind === 'build' ? 'its build' : target.source;
|
|
51
|
+
// A port that isn't proven to belong to this project could be any app on the machine:
|
|
52
|
+
// never publish it without an explicit yes.
|
|
53
|
+
if (target.uncertain) {
|
|
54
|
+
const hint = `Pass the port explicitly if that's the right app: \`clickroom ${new URL(target.target).port}\``;
|
|
55
|
+
if (!process.stdin.isTTY) throw new Error(`No dev server found running in this project. ${label} answers, but it may be a different app.\n ${hint}`);
|
|
56
|
+
const answer = await ask(`No dev server found running in this project, but ${label} answers (could be another app).\nShare ${label} publicly? [y/N] `);
|
|
57
|
+
if (!/^y(es)?$/i.test(answer.trim())) throw new Error(`Cancelled. ${hint}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let server = null;
|
|
61
|
+
if (teammate) {
|
|
62
|
+
console.log(dim(` Sharing through ${cloud} as you`));
|
|
63
|
+
} else if (cloud) {
|
|
64
|
+
console.log(dim(` Sharing through ${cloud}`));
|
|
65
|
+
const relay = await fetch(`http://localhost:${port}/__ps/api/health`).then((r) => r.ok, () => false);
|
|
66
|
+
if (!relay) console.log(dim(' The local clickroom server is not running, so the Live view and app API calls are offline.\n Start it in the background with `clickroom service install`.'));
|
|
67
|
+
} else if (await isUp()) {
|
|
68
|
+
console.log(dim(` Attaching to the clickroom server already running on :${port}`));
|
|
69
|
+
} else {
|
|
70
|
+
console.log(dim(` Starting clickroom${opts.local ? '' : ' + Cloudflare Tunnel'}…`));
|
|
71
|
+
server = await startServer({ ...opts, port, tunnel: !opts.local });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const project = await retry(() => call('POST', '/__ps/api/projects', {
|
|
75
|
+
name,
|
|
76
|
+
kind: target.kind,
|
|
77
|
+
target: target.target,
|
|
78
|
+
source: target.source || target.target,
|
|
79
|
+
root,
|
|
80
|
+
}));
|
|
81
|
+
if (project.name !== name) {
|
|
82
|
+
console.log(dim(project.renamed === 'name-taken' ? ` "${name}" is taken by another folder; sharing this one as "${project.name}".` : ` This folder is already shared as "${project.name}"; keeping that name so its comments stay together.`));
|
|
83
|
+
if (cfg.projects[name]?.cwd === root) delete cfg.projects[name];
|
|
84
|
+
name = project.name;
|
|
85
|
+
}
|
|
86
|
+
const prev = cfg.projects[name] || {};
|
|
87
|
+
const local = { ...prev, kind: target.kind, target: target.target || null, source: target.source || null, cwd: root, dir: cwd };
|
|
88
|
+
cfg.projects[name] = local;
|
|
89
|
+
cfg.port = port;
|
|
90
|
+
writeLocal(cfg);
|
|
91
|
+
if (target.kind === 'static' && !teammate) copyBuild(name, target.source);
|
|
92
|
+
console.log(`${bold('✔')} Sharing ${bold(name)} → ${label} ${dim(`(${target.how})`)}`);
|
|
93
|
+
if (target.others?.length) console.log(dim(` Also listening: ${target.others.join(', ')}. Use \`clickroom <port>\` to pick another.`));
|
|
94
|
+
|
|
95
|
+
// Clients only ever see pushed versions. The first share pushes v1.
|
|
96
|
+
const { versions } = await call('GET', `/__ps/api/projects/${encodeURIComponent(name)}/versions`);
|
|
97
|
+
if (!versions.length) await doPush(name, local, { note: 'First share', first: true });
|
|
98
|
+
else console.log(dim(` Latest version: v${versions[0].n}${versions[0].note ? ` · ${versions[0].note}` : ''} (${ago(versions[0].createdAt)})`));
|
|
99
|
+
|
|
100
|
+
const meta = teammate ? {} : await call('GET', '/__ps/api/meta');
|
|
101
|
+
let tunnelUrl = cloud || meta.tunnelUrl;
|
|
102
|
+
let base, teamUrl, clientUrl;
|
|
103
|
+
const links = () => {
|
|
104
|
+
base = tunnelUrl || `http://localhost:${port}`;
|
|
105
|
+
// The review's own address when the server has one (APP_URL/<name>), else /s/<name>.
|
|
106
|
+
teamUrl = project.reviewUrl || `${base}/s/${encodeURIComponent(name)}`;
|
|
107
|
+
clientUrl = `${project.reviewUrl || `${project.origin || base}/s/${encodeURIComponent(name)}`}?t=${project.token}`;
|
|
108
|
+
console.log('');
|
|
109
|
+
console.log(` ${bold('Review (you, team)')} ${teamUrl} ${dim('(press o to open it signed in)')}`);
|
|
110
|
+
console.log(` ${bold('Client link')} ${clientUrl}`);
|
|
111
|
+
console.log(` ${bold('All sessions')} ${base}/`);
|
|
112
|
+
};
|
|
113
|
+
// Opens the review signed in, with a one-time link (never the admin key).
|
|
114
|
+
const openTeam = () =>
|
|
115
|
+
signinLink(`/s/${encodeURIComponent(name)}`, cloud || baseUrl()).then(openUrl, (e) => console.log(dim(` Could not open the review: ${e.message}`)));
|
|
116
|
+
links();
|
|
117
|
+
if (!tunnelUrl) {
|
|
118
|
+
console.log(dim(server ? ' Local only (--local).' : ' The running server has no tunnel (it was started with --local). Stop it and re-run to get a public link.'));
|
|
119
|
+
}
|
|
120
|
+
if (copy(clientUrl)) console.log(dim(' Client link copied to clipboard.'));
|
|
121
|
+
if (!opts['no-open'] && opts.open !== false) openTeam();
|
|
122
|
+
|
|
123
|
+
const watcher = target.kind === 'static' ? watchBuild(target.source) : null;
|
|
124
|
+
if (teammate) console.log(dim(' The team sees it on the dashboard and gets each version you push.'));
|
|
125
|
+
console.log('');
|
|
126
|
+
if (process.stdin.isTTY) console.log(` ${bold('p')} push new version ${bold('o')} open review ${bold('c')} copy client link ${bold('q')} quit`);
|
|
127
|
+
if (cloud) console.log(dim(` Links stay up when you quit or your ${teammate ? 'computer' : 'Mac'} is off; quitting only ends this session.`));
|
|
128
|
+
else console.log(dim(server ? ' Quitting stops sharing (closes the tunnel for every project).' : ' Quitting detaches. The server and tunnel belong to the terminal that started them.'));
|
|
129
|
+
|
|
130
|
+
let pushing = false;
|
|
131
|
+
let changes; // what the folder has that the last push didn't (see changes.js)
|
|
132
|
+
// "Find gaps with Claude" from the review: one run at a time; its state goes out with each beat
|
|
133
|
+
// (the last result for 10 minutes), so the review can show it.
|
|
134
|
+
let clarify = null;
|
|
135
|
+
const gapsNow = async () => {
|
|
136
|
+
if (clarify?.status === 'running') return;
|
|
137
|
+
clarify = { status: 'running', found: 0, at: Date.now() };
|
|
138
|
+
console.log(dim(' Claude is looking for gaps in the prototype (Clarify)…'));
|
|
139
|
+
try {
|
|
140
|
+
const found = await findGaps(name, projectFolder(local));
|
|
141
|
+
clarify = { status: 'done', found, at: Date.now() };
|
|
142
|
+
console.log(`${bold('✔')} ${found} draft${found === 1 ? '' : 's'} added to Clarify`);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
clarify = { status: 'failed', found: 0, at: Date.now(), error: e.message };
|
|
145
|
+
console.log(dim(` Finding gaps failed: ${e.message}`));
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
// No note given (Push vN on the dashboard, Enter at the prompt): one is written for it.
|
|
149
|
+
const pushNow = async (note) => {
|
|
150
|
+
if (pushing) return;
|
|
151
|
+
pushing = true;
|
|
152
|
+
try {
|
|
153
|
+
if (!note) {
|
|
154
|
+
console.log(dim(' Writing the version note…'));
|
|
155
|
+
note = await autoNote(name, projectFolder(local)).catch(() => null);
|
|
156
|
+
}
|
|
157
|
+
await doPush(name, local, { note });
|
|
158
|
+
changes = await folderChanges(name, projectFolder(local)).catch(() => changes);
|
|
159
|
+
} finally {
|
|
160
|
+
pushing = false;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
let heartbeat, keys;
|
|
165
|
+
await new Promise((resolve) => {
|
|
166
|
+
const done = () => resolve();
|
|
167
|
+
process.once('SIGINT', done);
|
|
168
|
+
process.once('SIGTERM', done);
|
|
169
|
+
server?.exited.then(() => {
|
|
170
|
+
console.log(dim(' clickroom server stopped.'));
|
|
171
|
+
resolve();
|
|
172
|
+
});
|
|
173
|
+
// Marks this project live on the sessions overview, picks up pushes requested from the
|
|
174
|
+
// dashboard, and notices when the owning terminal stops sharing.
|
|
175
|
+
// The server reopens a dropped tunnel itself (new URL); tolerate the restart window.
|
|
176
|
+
let misses = 0;
|
|
177
|
+
// Unpushed work in the folder, checked every 30 s (metadata only) and sent with the beat.
|
|
178
|
+
const look = () => folderChanges(name, projectFolder(local)).then((c) => (changes = c), () => {});
|
|
179
|
+
look();
|
|
180
|
+
const looking = setInterval(look, 30000);
|
|
181
|
+
process.once('exit', () => clearInterval(looking));
|
|
182
|
+
const beat = async () => {
|
|
183
|
+
try {
|
|
184
|
+
const recent = clarify && (clarify.status === 'running' || Date.now() - clarify.at < 10 * 60 * 1000) ? { clarify } : {};
|
|
185
|
+
const { pending } = await call('POST', `/__ps/api/projects/${encodeURIComponent(name)}/session`, { pid: process.pid, target: label, ...(changes ? { changes } : {}), ...recent });
|
|
186
|
+
if (misses >= 3) console.log(dim(` Reconnected to ${baseUrl()}.`));
|
|
187
|
+
misses = 0;
|
|
188
|
+
for (const r of pending) {
|
|
189
|
+
if (r.type === 'push') pushNow(r.note || null);
|
|
190
|
+
if (r.type === 'clarify') gapsNow();
|
|
191
|
+
}
|
|
192
|
+
// Never announce a relay host as the address to share once a cloud site exists.
|
|
193
|
+
const { tunnelUrl: now } = cloud || cloudUrl() ? {} : await call('GET', '/__ps/api/meta');
|
|
194
|
+
if (now && now !== tunnelUrl) {
|
|
195
|
+
tunnelUrl = now;
|
|
196
|
+
console.log(`\n${bold('!')} The Cloudflare Tunnel was reopened with a new address. Old links no longer work; send these:`);
|
|
197
|
+
links();
|
|
198
|
+
if (copy(clientUrl)) console.log(dim(' New client link copied to clipboard.'));
|
|
199
|
+
}
|
|
200
|
+
} catch (e) {
|
|
201
|
+
if (++misses === 3) console.log(dim(` Can't reach ${baseUrl()} (${e.message}). Retrying…`));
|
|
202
|
+
// A cloud deployment doesn't go away with a terminal: keep retrying through network blips.
|
|
203
|
+
if (!server && !cloud && misses >= 18 && !(await isUp())) {
|
|
204
|
+
console.log(dim(' The clickroom server was stopped by the terminal that started it. Run `clickroom` again to share.'));
|
|
205
|
+
resolve();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
beat();
|
|
210
|
+
heartbeat = setInterval(beat, 5000);
|
|
211
|
+
if (process.stdin.isTTY) {
|
|
212
|
+
keys = onKeys(async (key) => {
|
|
213
|
+
if (key === 'quit') return resolve();
|
|
214
|
+
if (key === 'p') {
|
|
215
|
+
keys.pause();
|
|
216
|
+
const note = (await ask(' Version note (Enter: write one from the changes): ')).trim();
|
|
217
|
+
keys.resume();
|
|
218
|
+
await pushNow(note || null);
|
|
219
|
+
}
|
|
220
|
+
if (key === 'o') openTeam();
|
|
221
|
+
if (key === 'c' && copy(clientUrl)) console.log(dim(' Client link copied.'));
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
clearInterval(heartbeat);
|
|
226
|
+
keys?.stop();
|
|
227
|
+
watcher?.close();
|
|
228
|
+
await call('DELETE', `/__ps/api/projects/${encodeURIComponent(name)}/session`).catch(() => {});
|
|
229
|
+
await backup(name);
|
|
230
|
+
if (server) {
|
|
231
|
+
server.stop();
|
|
232
|
+
await Promise.race([server.exited, new Promise((r) => setTimeout(r, 3000))]);
|
|
233
|
+
console.log(dim('\n Tunnel closed.'));
|
|
234
|
+
}
|
|
235
|
+
process.exit(0);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function doPush(name, local, { note, first = false }) {
|
|
239
|
+
try {
|
|
240
|
+
const v = await push(name, local, { note });
|
|
241
|
+
console.log(`${bold('✔')} Pushed ${bold(`v${v.n}`)}${v.note ? ` · ${v.note}` : ''}. Viewers are told a new version is available.`);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
if (!(err instanceof NoSnapshot)) throw err;
|
|
244
|
+
if (first && isTeammate()) {
|
|
245
|
+
console.log(`${bold('✘')} Couldn't build this project, so there's nothing to share yet.`);
|
|
246
|
+
throw new Error(pushHint(err).replace(/\n.*--live.*$/m, ''));
|
|
247
|
+
}
|
|
248
|
+
if (first) {
|
|
249
|
+
const v = await push(name, local, { note, live: true });
|
|
250
|
+
console.log(`${bold('!')} Couldn't snapshot this project, so v${v.n} shows your live dev server (clients see edits as you make them).`);
|
|
251
|
+
console.log(dim(' ' + pushHint(err).replace(/\n/g, '\n ')));
|
|
252
|
+
} else {
|
|
253
|
+
console.log(`${bold('✘')} Push failed. ${pushHint(err)}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Raw-mode keys for the session terminal: p / o / c / q (and Ctrl+C).
|
|
259
|
+
function onKeys(handler) {
|
|
260
|
+
readline.emitKeypressEvents(process.stdin);
|
|
261
|
+
const listener = (str, key = {}) => {
|
|
262
|
+
if ((key.ctrl && key.name === 'c') || key.name === 'q') return handler('quit');
|
|
263
|
+
if (['p', 'o', 'c'].includes(key.name) && !key.ctrl && !key.meta) handler(key.name);
|
|
264
|
+
};
|
|
265
|
+
const resume = () => {
|
|
266
|
+
process.stdin.setRawMode(true);
|
|
267
|
+
process.stdin.resume();
|
|
268
|
+
process.stdin.on('keypress', listener);
|
|
269
|
+
};
|
|
270
|
+
const pause = () => {
|
|
271
|
+
process.stdin.off('keypress', listener);
|
|
272
|
+
process.stdin.setRawMode(false);
|
|
273
|
+
};
|
|
274
|
+
resume();
|
|
275
|
+
return {
|
|
276
|
+
pause,
|
|
277
|
+
resume,
|
|
278
|
+
stop() {
|
|
279
|
+
pause();
|
|
280
|
+
process.stdin.pause();
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Retries network failures (DNS hiccups, Wi-Fi switching) a few times before giving up.
|
|
286
|
+
async function retry(fn, tries = 5) {
|
|
287
|
+
for (let i = 1; ; i++) {
|
|
288
|
+
try {
|
|
289
|
+
return await fn();
|
|
290
|
+
} catch (err) {
|
|
291
|
+
if (!err.offline || i >= tries) throw err;
|
|
292
|
+
await new Promise((r) => setTimeout(r, 2000 * i));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function ask(question) {
|
|
298
|
+
return new Promise((resolve) => {
|
|
299
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
300
|
+
rl.question(question, (answer) => {
|
|
301
|
+
rl.close();
|
|
302
|
+
resolve(answer);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function ago(iso) {
|
|
308
|
+
const s = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
|
309
|
+
if (s < 60) return 'just now';
|
|
310
|
+
if (s < 3600) return `${Math.round(s / 60)}m ago`;
|
|
311
|
+
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
|
|
312
|
+
return `${Math.round(s / 86400)}d ago`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Static builds aren't re-published on change any more: changes wait for an explicit push.
|
|
316
|
+
function watchBuild(source) {
|
|
317
|
+
let timer, notified = false;
|
|
318
|
+
try {
|
|
319
|
+
const w = fs.watch(source, { recursive: true }, () => {
|
|
320
|
+
clearTimeout(timer);
|
|
321
|
+
timer = setTimeout(() => {
|
|
322
|
+
if (!notified) console.log(dim(` Changes detected in ${source}. Press p to push them as a new version.`));
|
|
323
|
+
notified = true;
|
|
324
|
+
setTimeout(() => (notified = false), 60000);
|
|
325
|
+
}, 500);
|
|
326
|
+
});
|
|
327
|
+
return w;
|
|
328
|
+
} catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function copy(text) {
|
|
334
|
+
const cmd = process.platform === 'darwin' ? ['pbcopy'] : process.platform === 'win32' ? ['clip'] : ['wl-copy'];
|
|
335
|
+
try {
|
|
336
|
+
const p = spawn(cmd[0], cmd.slice(1), { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
337
|
+
p.on('error', () => {});
|
|
338
|
+
p.stdin.end(text);
|
|
339
|
+
return true;
|
|
340
|
+
} catch {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function openUrl(url) {
|
|
346
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
347
|
+
try {
|
|
348
|
+
spawn(cmd, [url], { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
|
|
349
|
+
} catch {}
|
|
350
|
+
}
|
package/cli/vapid.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Makes the key pair the server signs its notifications with (worker/push.js):
|
|
2
|
+
// VAPID_PUBLIC_KEY goes in wrangler.cloud.jsonc's vars, VAPID_PRIVATE_KEY is a secret
|
|
3
|
+
// (`npx wrangler secret put VAPID_PRIVATE_KEY -c wrangler.cloud.jsonc`, and .dev.vars locally).
|
|
4
|
+
// Changing the pair ends every subscription: browsers have to turn notifications on again.
|
|
5
|
+
import { webcrypto as crypto } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
|
8
|
+
const pub = Buffer.from(await crypto.subtle.exportKey('raw', pair.publicKey)).toString('base64url');
|
|
9
|
+
const { d } = await crypto.subtle.exportKey('jwk', pair.privateKey);
|
|
10
|
+
console.log(`VAPID_PUBLIC_KEY=${pub}\nVAPID_PRIVATE_KEY=${d}`);
|