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/bin/clickroom.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { NAME_RE, readLocal, writeLocal, copyBuild, removeBuild, baseUrl } from '../cli/config.js';
|
|
6
|
+
import { call } from '../cli/client.js';
|
|
7
|
+
|
|
8
|
+
const HELP = `clickroom: multiplayer review for locally built prototypes
|
|
9
|
+
|
|
10
|
+
On a teammate's computer
|
|
11
|
+
clickroom login <workspace> connect this computer to your team's workspace (acme → acme.clickroom.dev):
|
|
12
|
+
approve it in the browser where you're signed in from your invite
|
|
13
|
+
clickroom then, in a project: builds it, uploads it as v1 and keeps the session open
|
|
14
|
+
(p pushes the next version). The team sees it on the dashboard.
|
|
15
|
+
clickroom logout | whoami
|
|
16
|
+
|
|
17
|
+
Share the project you're in (run inside any project folder)
|
|
18
|
+
clickroom [port|url|dir] [--name NAME] [--local] [--no-open] [--allow-email ADDR] [--tunnel-name NAME]
|
|
19
|
+
Finds the project's running dev server (or takes a port / URL / build folder), starts the review
|
|
20
|
+
server with a Cloudflare Quick Tunnel (or attaches to the one already running), opens the review
|
|
21
|
+
as team and copies the client link. Clients see pushed versions: the first share pushes v1
|
|
22
|
+
(a snapshot of your build), then press p in that terminal to push the next one. q quits.
|
|
23
|
+
clickroom auto-detect (dev server started in this project, else common ports, else dist/)
|
|
24
|
+
clickroom 5173 a local port
|
|
25
|
+
clickroom ./dist a static build
|
|
26
|
+
--local no tunnel, http://localhost only
|
|
27
|
+
--allow-email '*@acme.com' Quick Tunnel that requires email login
|
|
28
|
+
--tunnel-name my-tunnel named tunnel (stable hostname), remembered for next runs; 'none' to forget
|
|
29
|
+
|
|
30
|
+
Versions
|
|
31
|
+
clickroom push [name] [note] [--build CMD] [--out DIR] [--live]
|
|
32
|
+
Snapshot the project as a new version (runs its build script, copies the output). Same as p in
|
|
33
|
+
the session terminal. --live pushes a label-only version that shows the live dev server.
|
|
34
|
+
|
|
35
|
+
Process feedback
|
|
36
|
+
clickroom process [name] [--print] [--no-comments] [--no-questions]
|
|
37
|
+
Gathers what reviewers left that isn't in the prototype yet (open comments, answered questions),
|
|
38
|
+
writes it to .clickroom/process.md and starts Claude Code on it. The agent marks each item, and
|
|
39
|
+
the next push records the version it landed in. --print writes the brief to stdout instead.
|
|
40
|
+
|
|
41
|
+
Other commands
|
|
42
|
+
clickroom dev [--tunnel] [--tunnel-name NAME] [--port 8787] [--lan] [--verbose] serve all registered projects
|
|
43
|
+
clickroom add <name> <dir|url> [--watch] register a project without sharing it yet
|
|
44
|
+
clickroom list projects: live sessions, latest version, feedback counts
|
|
45
|
+
clickroom links <name> print client links
|
|
46
|
+
clickroom sync <name> | remove <name>
|
|
47
|
+
clickroom feedback <name> [--status actionable|open|queued|in_progress|resolved|active|all] [--questions] [--json] [--out FILE]
|
|
48
|
+
--questions lists the questions asked to the client and their answers instead
|
|
49
|
+
clickroom questions import <name> <file.json> [--name AGENT]
|
|
50
|
+
adds questions and assumptions for the client from [{key, type, text, now, page, selector}];
|
|
51
|
+
running it again updates the items with the same key instead of adding them twice
|
|
52
|
+
clickroom resolve <name> <n> [--note TEXT] [--close]
|
|
53
|
+
marks a thread fixed: the reviewer checks it in the next pushed version. --close resolves it outright.
|
|
54
|
+
clickroom mcp MCP server (stdio) for coding agents
|
|
55
|
+
clickroom service install|uninstall|status|logs run the server + tunnel in the background (macOS launchd)
|
|
56
|
+
clickroom cloud setup | url <url> | off host links on Cloudflare so they work while this Mac is off
|
|
57
|
+
clickroom deploy redeploy the cloud site (pushes do this automatically)
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const { values: opts, positionals } = parseArgs({
|
|
61
|
+
allowPositionals: true,
|
|
62
|
+
strict: false,
|
|
63
|
+
options: {
|
|
64
|
+
tunnel: { type: 'boolean' },
|
|
65
|
+
'tunnel-name': { type: 'string' },
|
|
66
|
+
'allow-email': { type: 'string', multiple: true },
|
|
67
|
+
'public-url': { type: 'string' },
|
|
68
|
+
port: { type: 'string' },
|
|
69
|
+
lan: { type: 'boolean' },
|
|
70
|
+
verbose: { type: 'boolean' },
|
|
71
|
+
watch: { type: 'boolean' },
|
|
72
|
+
status: { type: 'string' },
|
|
73
|
+
json: { type: 'boolean' },
|
|
74
|
+
questions: { type: 'boolean' },
|
|
75
|
+
out: { type: 'string' },
|
|
76
|
+
note: { type: 'string' },
|
|
77
|
+
name: { type: 'string' },
|
|
78
|
+
local: { type: 'boolean' },
|
|
79
|
+
close: { type: 'boolean' },
|
|
80
|
+
build: { type: 'string' },
|
|
81
|
+
live: { type: 'boolean' },
|
|
82
|
+
'no-open': { type: 'boolean' },
|
|
83
|
+
print: { type: 'boolean' },
|
|
84
|
+
'no-comments': { type: 'boolean' },
|
|
85
|
+
'no-questions': { type: 'boolean' },
|
|
86
|
+
help: { type: 'boolean', short: 'h' },
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const [cmd, ...args] = positionals;
|
|
90
|
+
const COMMANDS = new Set(['login', 'logout', 'whoami', 'push', 'dev', 'serve', 'add', 'sync', 'list', 'ls', 'links', 'remove', 'rm', 'feedback', 'questions', 'process', 'resolve', 'mcp', 'service', 'cloud', 'deploy']);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await main();
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error(`error: ${err.message}`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// `<cmd> [name] [...]`: the name is optional inside a shared project folder.
|
|
100
|
+
async function projectFromArgs(cfg, cmd) {
|
|
101
|
+
const { projectRoot } = await import('../cli/detect.js');
|
|
102
|
+
const here = projectRoot(process.cwd());
|
|
103
|
+
let [name, ...rest] = args;
|
|
104
|
+
if (!cfg.projects?.[name]) {
|
|
105
|
+
rest = args;
|
|
106
|
+
name = Object.entries(cfg.projects || {}).find(([, p]) => p.cwd === here)?.[0];
|
|
107
|
+
if (!name) throw new Error(`run this inside a shared project folder (run \`clickroom\` there first), or pass its name: clickroom ${cmd} <name>`);
|
|
108
|
+
}
|
|
109
|
+
return { name, rest, root: cfg.projects[name].cwd || here };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function main() {
|
|
113
|
+
if (opts.help || cmd === 'help') return console.log(HELP);
|
|
114
|
+
if (!cmd || !COMMANDS.has(cmd)) {
|
|
115
|
+
const { share } = await import('../cli/share.js');
|
|
116
|
+
return share(cmd, opts);
|
|
117
|
+
}
|
|
118
|
+
switch (cmd) {
|
|
119
|
+
case 'login':
|
|
120
|
+
case 'logout':
|
|
121
|
+
case 'whoami': {
|
|
122
|
+
const m = await import('../cli/login.js');
|
|
123
|
+
return m[cmd](args[0]);
|
|
124
|
+
}
|
|
125
|
+
case 'push': {
|
|
126
|
+
const { push, pushHint, NoSnapshot } = await import('../cli/push.js');
|
|
127
|
+
const cfg = readLocal();
|
|
128
|
+
const { name, rest } = await projectFromArgs(cfg, 'push');
|
|
129
|
+
const note = opts.note || rest.join(' ') || null;
|
|
130
|
+
try {
|
|
131
|
+
const v = await push(name, cfg.projects[name], { note, live: !!opts.live, build: opts.build, out: opts.out });
|
|
132
|
+
return console.log(`pushed ${name} v${v.n}${v.note ? ` · ${v.note}` : ''}`);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err instanceof NoSnapshot) throw new Error(pushHint(err));
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
case 'process': {
|
|
139
|
+
const { gather, buildBrief, writeBrief, hasClaude, startClaude } = await import('../cli/process.js');
|
|
140
|
+
const cfg = readLocal();
|
|
141
|
+
const { name, root } = await projectFromArgs(cfg, 'process');
|
|
142
|
+
const found = await gather(name, { comments: !opts['no-comments'], questions: !opts['no-questions'] });
|
|
143
|
+
const { comments, answers } = found;
|
|
144
|
+
if (!comments.count && !answers.count) return console.error(`${name}: nothing new to process`);
|
|
145
|
+
const brief = buildBrief(name, found, { root });
|
|
146
|
+
if (opts.print) return process.stdout.write(brief);
|
|
147
|
+
console.log(`${name}: ${comments.count} comment${comments.count === 1 ? '' : 's'}, ${answers.count} answer${answers.count === 1 ? '' : 's'} to work in`);
|
|
148
|
+
const file = writeBrief(root, brief);
|
|
149
|
+
console.log(`wrote ${file}`);
|
|
150
|
+
if (!hasClaude()) return console.log(`Claude Code isn't installed: hand ${file} to your agent, or run \`clickroom process --print\`.`);
|
|
151
|
+
console.log('starting Claude Code…');
|
|
152
|
+
process.exitCode = await startClaude(root, name);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
case 'dev':
|
|
156
|
+
case 'serve': {
|
|
157
|
+
const { dev } = await import('../cli/dev.js');
|
|
158
|
+
return dev(opts);
|
|
159
|
+
}
|
|
160
|
+
case 'add': {
|
|
161
|
+
const [name, target] = args;
|
|
162
|
+
if (!name || !target) throw new Error('usage: clickroom add <name> <dir|url> [--watch]');
|
|
163
|
+
if (!NAME_RE.test(name)) throw new Error('name must be lowercase letters, digits and dashes');
|
|
164
|
+
const cfg = readLocal();
|
|
165
|
+
cfg.projects ||= {};
|
|
166
|
+
if (/^https?:\/\//.test(target)) {
|
|
167
|
+
cfg.projects[name] = { kind: 'url', target };
|
|
168
|
+
} else {
|
|
169
|
+
const source = path.resolve(target);
|
|
170
|
+
copyBuild(name, source);
|
|
171
|
+
cfg.projects[name] = { kind: 'static', source, watch: !!opts.watch };
|
|
172
|
+
}
|
|
173
|
+
writeLocal(cfg);
|
|
174
|
+
const p = cfg.projects[name];
|
|
175
|
+
try {
|
|
176
|
+
const reg = await call('POST', '/__ps/api/projects', { name, kind: p.kind, target: p.target, source: p.source || p.target });
|
|
177
|
+
console.log(`added ${name}: ${baseUrl()}/s/${name}?t=${reg.token}`);
|
|
178
|
+
if (p.watch) console.log('note: --watch takes effect on the next `clickroom dev` start.');
|
|
179
|
+
} catch (e) {
|
|
180
|
+
if (!e.offline) throw e;
|
|
181
|
+
console.log(`added ${name}. It will be registered when you run \`clickroom dev\`.`);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
case 'sync': {
|
|
186
|
+
const [name] = args;
|
|
187
|
+
const p = readLocal().projects?.[name];
|
|
188
|
+
if (!p || p.kind !== 'static') throw new Error(`no static project named ${name}`);
|
|
189
|
+
copyBuild(name, p.source);
|
|
190
|
+
return console.log(`synced ${name} from ${p.source}`);
|
|
191
|
+
}
|
|
192
|
+
case 'list':
|
|
193
|
+
case 'ls': {
|
|
194
|
+
const { projects, meta } = await call('GET', '/__ps/api/projects');
|
|
195
|
+
if (!projects.length) return console.log('no projects. Add one with `clickroom add <name> <dir|url>`');
|
|
196
|
+
projects.sort((a, b) => (b.session?.live ? 1 : 0) - (a.session?.live ? 1 : 0) || a.name.localeCompare(b.name));
|
|
197
|
+
for (const p of projects) {
|
|
198
|
+
const c = p.counts;
|
|
199
|
+
const v = c.latestVersion ? `v${c.latestVersion.n}` : '-';
|
|
200
|
+
console.log(
|
|
201
|
+
`${p.session?.live ? '●' : '○'} ${p.name.padEnd(18)} ${v.padEnd(5)} ${(p.session?.live ? p.session.target || '' : 'offline').padEnd(24)} open ${c.open + c.queued + c.in_progress} resolved ${c.resolved}${c.questions ? ` questions ${c.questions}` : ''}${c.drafts ? ` drafts ${c.drafts}` : ''} viewers ${c.online}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (meta.tunnelUrl) console.log(`\ntunnel: ${meta.tunnelUrl}`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
case 'links': {
|
|
208
|
+
const [name] = args;
|
|
209
|
+
if (!name) throw new Error('usage: clickroom links <name>');
|
|
210
|
+
const { project, meta } = await call('GET', `/__ps/api/projects/${encodeURIComponent(name || '')}`);
|
|
211
|
+
if (meta?.tunnelUrl) console.log(`public: ${meta.tunnelUrl}/s/${name}?t=${project.token}`);
|
|
212
|
+
console.log(`local: ${meta?.localUrl || baseUrl()}/s/${name}?t=${project.token}`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
case 'remove':
|
|
216
|
+
case 'rm': {
|
|
217
|
+
const [name] = args;
|
|
218
|
+
if (!name) throw new Error('usage: clickroom remove <name>');
|
|
219
|
+
const cfg = readLocal();
|
|
220
|
+
delete cfg.projects?.[name];
|
|
221
|
+
writeLocal(cfg);
|
|
222
|
+
removeBuild(name);
|
|
223
|
+
try {
|
|
224
|
+
await call('DELETE', `/__ps/api/projects/${encodeURIComponent(name)}`);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
if (!e.offline) throw e;
|
|
227
|
+
}
|
|
228
|
+
return console.log(`removed ${name}`);
|
|
229
|
+
}
|
|
230
|
+
case 'feedback': {
|
|
231
|
+
const [name] = args;
|
|
232
|
+
if (!name) throw new Error('usage: clickroom feedback <name>');
|
|
233
|
+
const status = opts.status || (opts.questions ? 'open' : 'actionable');
|
|
234
|
+
const ext = opts.json ? 'json' : 'md';
|
|
235
|
+
const kind = opts.questions ? '&kind=question' : '';
|
|
236
|
+
const data = await call('GET', `/__ps/api/projects/${encodeURIComponent(name)}/feedback.${ext}?status=${encodeURIComponent(status)}${kind}`);
|
|
237
|
+
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2) + '\n';
|
|
238
|
+
if (opts.out) {
|
|
239
|
+
fs.writeFileSync(opts.out, text);
|
|
240
|
+
return console.log(`wrote ${opts.out}`);
|
|
241
|
+
}
|
|
242
|
+
return process.stdout.write(text);
|
|
243
|
+
}
|
|
244
|
+
case 'questions': {
|
|
245
|
+
const [sub, name, file] = args;
|
|
246
|
+
if (sub !== 'import' || !name || !file) throw new Error('usage: clickroom questions import <name> <file.json>');
|
|
247
|
+
const items = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
248
|
+
if (!Array.isArray(items)) throw new Error(`${file} must hold an array of questions`);
|
|
249
|
+
// Imported lists come from an agent or a script, so they're credited to one, like MCP questions.
|
|
250
|
+
const author = { name: opts.name || process.env.CLICKROOM_AGENT_NAME || 'Claude', role: 'agent', color: '#7c3aed' };
|
|
251
|
+
let created = 0, updated = 0;
|
|
252
|
+
for (const item of items) {
|
|
253
|
+
const t = await call('POST', `/__ps/api/projects/${encodeURIComponent(name)}/threads`, {
|
|
254
|
+
kind: 'question', body: item.text, type: item.type, now: item.now, key: item.key, page: item.page,
|
|
255
|
+
el: item.selector ? { selector: item.selector } : undefined, author, notify: false,
|
|
256
|
+
});
|
|
257
|
+
if (t.createdAt === t.updatedAt) created++;
|
|
258
|
+
else updated++;
|
|
259
|
+
}
|
|
260
|
+
return console.log(`${name}: ${created} question${created === 1 ? '' : 's'} added as drafts for the team to review, ${updated} updated`);
|
|
261
|
+
}
|
|
262
|
+
case 'resolve': {
|
|
263
|
+
const [name, n] = args;
|
|
264
|
+
if (!name || !n) throw new Error('usage: clickroom resolve <name> <n> [--note TEXT] [--close]');
|
|
265
|
+
const t = await call('PATCH', `/__ps/api/projects/${encodeURIComponent(name)}/threads/${Number(String(n).replace('#', ''))}`, {
|
|
266
|
+
status: opts.close ? 'resolved' : 'review',
|
|
267
|
+
note: opts.note,
|
|
268
|
+
author: { name: process.env.CLICKROOM_AGENT_NAME || 'CLI', role: 'agent' },
|
|
269
|
+
});
|
|
270
|
+
if (!opts.close && t.kind === 'question') return console.log(`#${t.seq} marked processed: ${t.fixedIn ? `in v${t.fixedIn}` : 'the next push records the version'}`);
|
|
271
|
+
return console.log(opts.close ? `resolved #${t.seq}` : `#${t.seq} marked fixed: the reviewer checks it ${t.fixedIn ? `in ${t.fixedIn === 'live' ? 'the live server' : 'v' + t.fixedIn}` : 'once you push a new version'}`);
|
|
272
|
+
}
|
|
273
|
+
case 'cloud': {
|
|
274
|
+
const { cloud } = await import('../cli/cloud.js');
|
|
275
|
+
return cloud(args[0], args[1]);
|
|
276
|
+
}
|
|
277
|
+
case 'deploy': {
|
|
278
|
+
const { deploy } = await import('../cli/deploy.js');
|
|
279
|
+
const { url } = await deploy();
|
|
280
|
+
return console.log(`deployed${url ? `: ${url}` : ''}`);
|
|
281
|
+
}
|
|
282
|
+
case 'service': {
|
|
283
|
+
const { service } = await import('../cli/service.js');
|
|
284
|
+
return service(args[0]);
|
|
285
|
+
}
|
|
286
|
+
case 'mcp': {
|
|
287
|
+
const { runMcp } = await import('../cli/mcp.js');
|
|
288
|
+
return runMcp();
|
|
289
|
+
}
|
|
290
|
+
default:
|
|
291
|
+
throw new Error(`unknown command "${cmd}". Run \`clickroom help\`.`);
|
|
292
|
+
}
|
|
293
|
+
}
|
package/cli/changes.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
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 { HOME } from './config.js';
|
|
6
|
+
|
|
7
|
+
// "New changes since v15": at every push the CLI remembers the project folder's files (size
|
|
8
|
+
// and modification time, plus git HEAD), and while a session runs it compares the folder with
|
|
9
|
+
// that, so the dashboard can say there's something clients haven't seen yet. Only file
|
|
10
|
+
// metadata is read, never contents. Kept in ~/.clickroom/folders/<name>.json.
|
|
11
|
+
|
|
12
|
+
const DIR = path.join(HOME, 'folders');
|
|
13
|
+
// Without git: folders that are never source (dependencies, build output, caches).
|
|
14
|
+
const SKIP = new Set(['node_modules', 'dist', 'build', 'out', '.output', '.nuxt', '.next', '.svelte-kit', '.vercel', '.turbo', 'coverage']);
|
|
15
|
+
const MAX_FILES = 20000;
|
|
16
|
+
|
|
17
|
+
function git(args, cwd, env) {
|
|
18
|
+
return new Promise((resolve) => execFile('git', args, { cwd, env: env || process.env, timeout: 20000, maxBuffer: 64 * 1024 * 1024 }, (err, out) => resolve(err ? null : out)));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A git tree of the whole working copy as it is (uncommitted and new files too, .gitignore
|
|
22
|
+
// respected), made in a throwaway index so the repo's own index, branches and stash stay as
|
|
23
|
+
// they are. Used to diff "what changed since the last push" exactly.
|
|
24
|
+
async function workingTree(dir) {
|
|
25
|
+
const tmp = path.join(os.tmpdir(), `clickroom-index-${process.pid}-${Date.now()}`);
|
|
26
|
+
const env = { ...process.env, GIT_INDEX_FILE: tmp };
|
|
27
|
+
try {
|
|
28
|
+
await git(['read-tree', 'HEAD'], dir, env); // a head start; fails harmlessly in an empty repo
|
|
29
|
+
if ((await git(['add', '-A'], dir, env)) == null) return null;
|
|
30
|
+
return (await git(['write-tree'], dir, env))?.trim() || null;
|
|
31
|
+
} finally {
|
|
32
|
+
fs.rmSync(tmp, { force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function listFiles(dir) {
|
|
37
|
+
const out = await git(['ls-files', '-co', '--exclude-standard', '-z'], dir);
|
|
38
|
+
if (out != null) return { files: out.split('\0').filter(Boolean).slice(0, MAX_FILES), git: true };
|
|
39
|
+
const files = [];
|
|
40
|
+
walk(dir, '', files);
|
|
41
|
+
return { files, git: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function walk(root, rel, acc) {
|
|
45
|
+
let entries;
|
|
46
|
+
try {
|
|
47
|
+
entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
|
|
48
|
+
} catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
if (acc.length >= MAX_FILES) return;
|
|
53
|
+
if (e.name.startsWith('.') || SKIP.has(e.name)) continue;
|
|
54
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
55
|
+
if (e.isDirectory()) walk(root, r, acc);
|
|
56
|
+
else if (e.isFile()) acc.push(r);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The folder as it is now: { files: { path: "size:mtime" }, head }.
|
|
61
|
+
export async function folderState(dir) {
|
|
62
|
+
const { files, git: isGit } = await listFiles(dir);
|
|
63
|
+
const out = {};
|
|
64
|
+
for (const f of files) {
|
|
65
|
+
try {
|
|
66
|
+
const st = fs.statSync(path.join(dir, f));
|
|
67
|
+
if (st.isFile()) out[f] = `${st.size}:${Math.round(st.mtimeMs)}`;
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
const head = isGit ? (await git(['rev-parse', 'HEAD'], dir))?.trim() || null : null;
|
|
71
|
+
return { files: out, head, git: isGit };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function statePath(name) {
|
|
75
|
+
return path.join(DIR, `${name}.json`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// After a push (version n): this is what clients have now.
|
|
79
|
+
export async function rememberFolder(name, dir, n = null) {
|
|
80
|
+
const now = await folderState(dir);
|
|
81
|
+
const tree = now.git ? await workingTree(dir) : null;
|
|
82
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
83
|
+
fs.writeFileSync(statePath(name), JSON.stringify({ ...now, tree, n, dir, at: Date.now() }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Never sent to Claude: lockfiles, minified and generated files, binaries.
|
|
87
|
+
const NOT_SOURCE = ['*lock*', '*.min.*', '*.map', ...['png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'pdf', 'woff', 'woff2', 'ttf', 'zip'].map((x) => `*.${x}`)].map((g) => `:(exclude,glob)**/${g}`);
|
|
88
|
+
const DIFF_MAX = 40 * 1024;
|
|
89
|
+
|
|
90
|
+
// The source changes since the last push, for writing its note: { stat, patch }, or null
|
|
91
|
+
// when there's nothing to compare (no git, nothing pushed from here yet, nothing changed).
|
|
92
|
+
// With a tree from the last push the diff is exact; without one (pushed before clickroom kept
|
|
93
|
+
// trees) it falls back to the changed files against the last commit.
|
|
94
|
+
export async function changedSource(name, dir) {
|
|
95
|
+
let base = null;
|
|
96
|
+
try {
|
|
97
|
+
base = JSON.parse(fs.readFileSync(statePath(name), 'utf8'));
|
|
98
|
+
} catch {}
|
|
99
|
+
if (!base || base.dir !== dir) return null;
|
|
100
|
+
let stat, patch;
|
|
101
|
+
if (base.tree) {
|
|
102
|
+
const now = await workingTree(dir);
|
|
103
|
+
if (!now || now === base.tree) return null;
|
|
104
|
+
stat = await git(['diff-tree', '-r', '-M', '--stat=120', base.tree, now, '--', '.', ...NOT_SOURCE], dir);
|
|
105
|
+
patch = await git(['diff-tree', '-r', '-M', '-p', '--no-color', base.tree, now, '--', '.', ...NOT_SOURCE], dir);
|
|
106
|
+
} else {
|
|
107
|
+
if ((await git(['rev-parse', 'HEAD'], dir)) == null) return null;
|
|
108
|
+
const { changed, added, removed } = compare(base.files, (await folderState(dir)).files);
|
|
109
|
+
const paths = [...changed, ...added, ...removed].slice(0, 200);
|
|
110
|
+
if (!paths.length) return null;
|
|
111
|
+
stat = await git(['diff', 'HEAD', '-M', '--stat=120', '--', ...paths], dir);
|
|
112
|
+
patch = await git(['diff', 'HEAD', '-M', '-p', '--no-color', '--', ...paths], dir);
|
|
113
|
+
}
|
|
114
|
+
if (!stat?.trim() && !patch?.trim()) return null;
|
|
115
|
+
return { stat: stat || '', patch: (patch || '').length > DIFF_MAX ? patch.slice(0, DIFF_MAX) + '\n[… diff cut off]' : patch || '' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function compare(before, after) {
|
|
119
|
+
const changed = [], added = [], removed = [];
|
|
120
|
+
for (const [f, v] of Object.entries(after)) {
|
|
121
|
+
if (!(f in before)) added.push(f);
|
|
122
|
+
else if (before[f] !== v) changed.push(f);
|
|
123
|
+
}
|
|
124
|
+
for (const f of Object.keys(before)) if (!(f in after)) removed.push(f);
|
|
125
|
+
return { changed, added, removed };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// What changed in the folder since the last push. The first time (no record yet, or another
|
|
129
|
+
// folder) the folder as it is becomes the starting point, so nothing is reported.
|
|
130
|
+
export async function folderChanges(name, dir) {
|
|
131
|
+
let base = null;
|
|
132
|
+
try {
|
|
133
|
+
base = JSON.parse(fs.readFileSync(statePath(name), 'utf8'));
|
|
134
|
+
} catch {}
|
|
135
|
+
if (!base || base.dir !== dir) {
|
|
136
|
+
await rememberFolder(name, dir, base?.n ?? null);
|
|
137
|
+
return { files: 0, added: 0, removed: 0, paths: [], commits: [], since: base?.n ?? null };
|
|
138
|
+
}
|
|
139
|
+
const now = await folderState(dir);
|
|
140
|
+
const { changed, added, removed } = compare(base.files, now.files);
|
|
141
|
+
let commits = [];
|
|
142
|
+
if (base.head && now.head && base.head !== now.head) {
|
|
143
|
+
const log = await git(['log', '--format=%s', '-n', '5', `${base.head}..${now.head}`], dir);
|
|
144
|
+
commits = log ? log.split('\n').filter(Boolean) : [];
|
|
145
|
+
}
|
|
146
|
+
const paths = [...changed, ...added, ...removed];
|
|
147
|
+
return { files: paths.length, added: added.length, removed: removed.length, paths: paths.slice(0, 5), commits, since: base.n ?? null };
|
|
148
|
+
}
|
package/cli/client.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { baseUrl, authToken, isTeammate } from './config.js';
|
|
4
|
+
|
|
5
|
+
// HTTP client for the clickroom server, with the admin key (the owner's Mac) or a teammate's
|
|
6
|
+
// own key (`clickroom login`).
|
|
7
|
+
export async function call(method, pathname, body) {
|
|
8
|
+
const token = authToken();
|
|
9
|
+
if (!token) {
|
|
10
|
+
const err = new Error('Not signed in. Run `clickroom login` (or, on the owner\'s Mac, `clickroom dev` once).');
|
|
11
|
+
err.offline = true;
|
|
12
|
+
throw err;
|
|
13
|
+
}
|
|
14
|
+
let res;
|
|
15
|
+
try {
|
|
16
|
+
res = await fetch(baseUrl() + pathname, {
|
|
17
|
+
method,
|
|
18
|
+
headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
19
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
20
|
+
});
|
|
21
|
+
} catch {
|
|
22
|
+
const err = new Error(`clickroom is not running at ${baseUrl()}. Start it with \`clickroom dev\`.`);
|
|
23
|
+
err.offline = true;
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
const text = await res.text();
|
|
27
|
+
let data = text;
|
|
28
|
+
try {
|
|
29
|
+
data = JSON.parse(text);
|
|
30
|
+
} catch {}
|
|
31
|
+
if (res.status === 401 && isTeammate()) throw new Error('Your clickroom sign-in has ended (revoked, or unused for 90 days). Run `clickroom login` again.');
|
|
32
|
+
if (!res.ok) throw new Error((data && data.error) || `${res.status} ${res.statusText}`);
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A one-time link (15 minutes) that signs the owner (or the signed-in teammate) into a browser
|
|
37
|
+
// and then opens `next`.
|
|
38
|
+
// The admin key itself never goes into a URL or a browser.
|
|
39
|
+
export async function signinLink(next = '/', base = baseUrl()) {
|
|
40
|
+
const res = await fetch(base.replace(/\/$/, '') + '/__ps/api/signin-links', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { authorization: `Bearer ${authToken()}`, 'content-type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ next, name: ownerName() }),
|
|
44
|
+
});
|
|
45
|
+
if (!res.ok) throw new Error(`could not make a sign-in link (${res.status})`);
|
|
46
|
+
return (await res.json()).url;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The owner's name on first sign-in (it can be changed on the dashboard).
|
|
50
|
+
function ownerName() {
|
|
51
|
+
try {
|
|
52
|
+
const name = execFileSync('git', ['config', 'user.name'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
53
|
+
if (name) return name;
|
|
54
|
+
} catch {}
|
|
55
|
+
return os.userInfo().username;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Uploads a local file (e.g. a screenshot) with its content type.
|
|
59
|
+
export async function callUpload(pathname, bytes, mime) {
|
|
60
|
+
const res = await fetch(baseUrl() + pathname, {
|
|
61
|
+
method: 'PUT',
|
|
62
|
+
headers: { authorization: `Bearer ${authToken()}`, 'content-type': mime },
|
|
63
|
+
body: bytes,
|
|
64
|
+
});
|
|
65
|
+
const data = await res.json().catch(() => ({}));
|
|
66
|
+
if (!res.ok) throw new Error(data.error || `${res.status} ${res.statusText}`);
|
|
67
|
+
return data;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A binary resource (e.g. a screenshot) as { mime, base64 }, or null when there is none.
|
|
71
|
+
export async function callBinary(pathname) {
|
|
72
|
+
const token = authToken();
|
|
73
|
+
if (!token) return null;
|
|
74
|
+
const res = await fetch(baseUrl() + pathname, { headers: { authorization: `Bearer ${token}` } }).catch(() => null);
|
|
75
|
+
if (!res?.ok) return null;
|
|
76
|
+
return { mime: (res.headers.get('content-type') || '').split(';')[0], base64: Buffer.from(await res.arrayBuffer()).toString('base64') };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function isUp() {
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch(baseUrl() + '/__ps/api/health');
|
|
82
|
+
return res.ok;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
package/cli/cloud.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readLocal, writeLocal, adminToken, localUrl } from './config.js';
|
|
2
|
+
import { deploy, wrangler, CLOUD_CONFIG } from './deploy.js';
|
|
3
|
+
import { bold, dim } from './dev.js';
|
|
4
|
+
|
|
5
|
+
// `clickroom cloud …`: run the review site on Cloudflare so links work while this Mac is off.
|
|
6
|
+
// setup deploy, set the admin token, copy every local project (token + history) up
|
|
7
|
+
// url <url> talk to the cloud deployment at <url> (e.g. https://clickroom.dev)
|
|
8
|
+
// off go back to the local server only
|
|
9
|
+
export async function cloud(action, arg) {
|
|
10
|
+
if (action === 'setup') return setup();
|
|
11
|
+
if (action === 'url') return useUrl(arg);
|
|
12
|
+
if (action === 'off') return off();
|
|
13
|
+
const cfg = readLocal();
|
|
14
|
+
console.log(cfg.cloudUrl ? `cloud: ${cfg.cloudUrl}` : 'cloud: off (local server only). Run `clickroom cloud setup`.');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function setup() {
|
|
18
|
+
const token = adminToken();
|
|
19
|
+
if (!token) throw new Error('No admin token yet. Run `clickroom dev` once first.');
|
|
20
|
+
console.log(dim(` Deploying with ${CLOUD_CONFIG}…`));
|
|
21
|
+
const { url: workersDev } = await deploy({ quiet: true });
|
|
22
|
+
// Same admin token as the local server, so team links and dashboard cookies keep working.
|
|
23
|
+
await wrangler(['secret', 'put', 'ADMIN_TOKEN', '-c', CLOUD_CONFIG], { input: token + '\n' });
|
|
24
|
+
const cfg = readLocal();
|
|
25
|
+
const url = (cfg.cloudUrl || workersDev || '').replace(/\/$/, '');
|
|
26
|
+
if (!url) throw new Error('Deployed, but could not find the worker URL. Pass it with `clickroom cloud url <url>`.');
|
|
27
|
+
await waitHealthy(url);
|
|
28
|
+
// A new secret takes a few seconds to reach the running worker.
|
|
29
|
+
for (let i = 0; i < 30; i++) {
|
|
30
|
+
const res = await fetch(url + '/__ps/api/projects', { headers: { authorization: `Bearer ${token}` } }).catch(() => null);
|
|
31
|
+
if (res?.ok) break;
|
|
32
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
33
|
+
}
|
|
34
|
+
await migrate(url, token);
|
|
35
|
+
writeLocal({ ...readLocal(), cloudUrl: url });
|
|
36
|
+
console.log(`${bold('✔')} Cloud ready at ${url}. Projects, comments, versions and links were copied.`);
|
|
37
|
+
console.log(dim(' Sessions, pushes, the dashboard and MCP now use the cloud. Keep the background service running for Live views.'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function migrate(url, token) {
|
|
41
|
+
const local = localUrl();
|
|
42
|
+
const get = async (base, p) => {
|
|
43
|
+
const res = await fetch(base + p, { headers: { authorization: `Bearer ${token}` } });
|
|
44
|
+
if (!res.ok) throw new Error(`${base}${p}: ${res.status}`);
|
|
45
|
+
return res.json();
|
|
46
|
+
};
|
|
47
|
+
let projects;
|
|
48
|
+
try {
|
|
49
|
+
({ projects } = await get(local, '/__ps/api/projects'));
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error(`The local clickroom server (${local}) isn't answering, so there's nothing to copy. Start it (\`clickroom service install\`) and run setup again.`);
|
|
52
|
+
}
|
|
53
|
+
for (const p of projects) {
|
|
54
|
+
const data = await get(local, `/__ps/api/projects/${encodeURIComponent(p.name)}/export`);
|
|
55
|
+
const res = await fetch(url + '/__ps/api/import', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
58
|
+
body: JSON.stringify(data),
|
|
59
|
+
});
|
|
60
|
+
if (res.status === 409) console.log(dim(` ${p.name}: already in the cloud, left as is`));
|
|
61
|
+
else if (!res.ok) throw new Error(`importing ${p.name} failed: ${(await res.text()).slice(0, 200)}`);
|
|
62
|
+
else console.log(` ${p.name}: ${data.room.threads.length} threads, ${data.room.versions.length} versions copied`);
|
|
63
|
+
}
|
|
64
|
+
await fetch(url + '/__ps/api/meta', {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
67
|
+
body: JSON.stringify({ tunnelUrl: url, localUrl: url }),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function useUrl(url) {
|
|
72
|
+
if (!/^https:\/\//.test(url || '')) throw new Error('usage: clickroom cloud url https://share.example.com');
|
|
73
|
+
url = url.replace(/\/$/, '');
|
|
74
|
+
await waitHealthy(url, 1);
|
|
75
|
+
const token = adminToken();
|
|
76
|
+
await fetch(url + '/__ps/api/meta', {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({ tunnelUrl: url, localUrl: url }),
|
|
80
|
+
});
|
|
81
|
+
writeLocal({ ...readLocal(), cloudUrl: url });
|
|
82
|
+
console.log(`${bold('✔')} Using ${url}.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function off() {
|
|
86
|
+
const cfg = readLocal();
|
|
87
|
+
delete cfg.cloudUrl;
|
|
88
|
+
writeLocal(cfg);
|
|
89
|
+
console.log(`${bold('✔')} Back to the local server only. Data in the cloud is left as is.`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function waitHealthy(url, tries = 30) {
|
|
93
|
+
for (let i = 0; i < tries; i++) {
|
|
94
|
+
try {
|
|
95
|
+
if ((await fetch(url + '/__ps/api/health')).ok) return;
|
|
96
|
+
} catch {}
|
|
97
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`${url} is not answering yet.`);
|
|
100
|
+
}
|