@devaccel-labs/comment-service 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @devaccel-labs/comment-service
2
+
3
+ In-page comments for TanStack Start apps on Cloudflare: select any component, comment, reply, @mention teammates, attach files and screenshots. Threads stay attached to the component across redeploys.
4
+
5
+ ```ts
6
+ // vite.config.ts, before tanstackStart()
7
+ import { commentService } from "@devaccel-labs/comment-service/vite"
8
+
9
+ // src/routes/__root.tsx, before <Scripts />
10
+ import { CommentToolbar } from "@devaccel-labs/comment-service/react"
11
+
12
+ // src/routes/api/comment-service/session.ts
13
+ import { handleSessionRequest } from "@devaccel-labs/comment-service/server"
14
+ ```
15
+
16
+ The toolbar talks to a DevAccel comment-service Worker. Configure the app with `COMMENT_SERVICE_URL`, `COMMENT_SERVICE_PROJECT`, `COMMENT_SERVICE_ENVIRONMENT` and a server-only `COMMENT_SERVICE_SECRET`; provision them with `npx comment-service provision` and check an install with `npx comment-service doctor`.
package/dist/cli.js ADDED
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { spawn } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { parseArgs } from "node:util";
8
+ var DEFAULT_API = "https://devaccel-comment-service.devaccel-labs.workers.dev";
9
+ var USER_AGENT = "Mozilla/5.0 (compatible; comment-service-cli/0.1)";
10
+ var KEYS = ["COMMENT_SERVICE_URL", "COMMENT_SERVICE_PROJECT", "COMMENT_SERVICE_ENVIRONMENT", "COMMENT_SERVICE_SECRET"];
11
+ var HELP = `comment-service <command> [options]
12
+
13
+ Commands
14
+ provision Create (or rotate) a project + environment on the comment service.
15
+ doctor Check that a running host app is wired correctly.
16
+
17
+ provision
18
+ --project <slug> Project slug, e.g. jebelai-mockups (required)
19
+ --name <text> Display name for a new project (default: slug)
20
+ --env <name> Environment name: local, staging, production (required)
21
+ --origin <url> Allowed browser origin. Repeat for several. Required.
22
+ --passphrase <text> Passphrase people type to unlock. Generated if omitted.
23
+ --rotate Environment exists: rotate its secret (and passphrase if given).
24
+ --dev-vars <file> Write COMMENT_SERVICE_* into this env file (local dev).
25
+ --wrangler-secret Pipe the secret into \`wrangler secret put COMMENT_SERVICE_SECRET\` (cwd).
26
+ --admin-env <file> File with ADMIN_TOKEN=... (else $COMMENT_SERVICE_ADMIN_TOKEN).
27
+ --api <url> Comment service origin (default: ${DEFAULT_API}).
28
+ --json Machine-readable output. Never includes the secret.
29
+
30
+ doctor
31
+ --app <url> Running host app origin, e.g. http://localhost:3400 (required)
32
+ --page <path> Page to scan for component stamps (default: /)
33
+ --dev-vars <file> Read COMMENT_SERVICE_URL / COMMENT_SERVICE_PASSPHRASE from here.
34
+ --passphrase <text> Also prove unlock + list threads with this passphrase.
35
+ --header "<k>: <v>" Extra header for app requests. Repeat. Use for Cloudflare Access
36
+ service tokens (cf-access-client-id / cf-access-client-secret).
37
+ --json Machine-readable output.
38
+ `;
39
+ function fail(message) {
40
+ console.error(`comment-service: ${message}`);
41
+ process.exit(1);
42
+ }
43
+ function readEnvFile(file) {
44
+ if (!fs.existsSync(file)) return {};
45
+ const out = {};
46
+ for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
47
+ const match = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
48
+ if (!match) continue;
49
+ out[match[1]] = match[2].replace(/^(['"])(.*)\1$/, "$2");
50
+ }
51
+ return out;
52
+ }
53
+ function writeEnvFile(file, values) {
54
+ const lines = fs.existsSync(file) ? fs.readFileSync(file, "utf8").split(/\r?\n/) : [];
55
+ const pending = new Map(Object.entries(values));
56
+ const next = lines.map((line) => {
57
+ const key = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=/)?.[1];
58
+ if (!key || !pending.has(key)) return line;
59
+ const value = pending.get(key);
60
+ pending.delete(key);
61
+ return `${key}=${value}`;
62
+ });
63
+ while (next.length && next.at(-1) === "") next.pop();
64
+ for (const [key, value] of pending) next.push(`${key}=${value}`);
65
+ fs.writeFileSync(file, `${next.join("\n")}
66
+ `, { mode: 384 });
67
+ }
68
+ function generatePassphrase() {
69
+ const alphabet = "abcdefghjkmnpqrstuvwxyz23456789";
70
+ const bytes = crypto.getRandomValues(new Uint8Array(12));
71
+ const chars = [...bytes].map((byte) => alphabet[byte % alphabet.length]);
72
+ return [chars.slice(0, 4), chars.slice(4, 8), chars.slice(8, 12)].map((group) => group.join("")).join("-");
73
+ }
74
+ function normalizeOrigin(value) {
75
+ try {
76
+ return new URL(value).origin;
77
+ } catch {
78
+ fail(`"${value}" is not an absolute URL.`);
79
+ }
80
+ }
81
+ var HttpFailure = class extends Error {
82
+ constructor(status, code, message) {
83
+ super(message);
84
+ this.status = status;
85
+ this.code = code;
86
+ }
87
+ status;
88
+ code;
89
+ };
90
+ async function call(url, init = {}) {
91
+ const response = await fetch(url, { ...init, headers: { "user-agent": USER_AGENT, ...init.headers } });
92
+ const text = await response.text();
93
+ let body = null;
94
+ try {
95
+ body = text ? JSON.parse(text) : null;
96
+ } catch {
97
+ body = null;
98
+ }
99
+ if (!response.ok) {
100
+ const error = body?.error;
101
+ throw new HttpFailure(response.status, error?.code ?? "http_error", error?.message ?? text.slice(0, 200));
102
+ }
103
+ return body;
104
+ }
105
+ function wranglerBin(cwd) {
106
+ const local = path.join(cwd, "node_modules", ".bin", process.platform === "win32" ? "wrangler.cmd" : "wrangler");
107
+ return fs.existsSync(local) ? { command: local, args: [] } : { command: "npx", args: ["wrangler"] };
108
+ }
109
+ function putWranglerSecret(secret) {
110
+ const cwd = process.cwd();
111
+ const bin = wranglerBin(cwd);
112
+ return new Promise((resolve, reject) => {
113
+ const child = spawn(bin.command, [...bin.args, "secret", "put", "COMMENT_SERVICE_SECRET"], {
114
+ cwd,
115
+ stdio: ["pipe", "inherit", "inherit"]
116
+ });
117
+ child.stdin.write(secret);
118
+ child.stdin.end();
119
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`wrangler secret put exited ${code}`)));
120
+ });
121
+ }
122
+ async function provision(flags) {
123
+ const project = String(flags.project ?? "");
124
+ const environment = String(flags.env ?? "");
125
+ if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(project)) fail("--project must be a lowercase slug (2-41 chars).");
126
+ if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(environment)) fail("--env must be a lowercase name like local or production.");
127
+ const origins = (flags.origin ?? []).map(normalizeOrigin);
128
+ if (!origins.length) fail("pass at least one --origin (the URL people open the app at).");
129
+ const api = String(flags.api ?? process.env.COMMENT_SERVICE_URL ?? DEFAULT_API).replace(/\/$/, "");
130
+ const adminFile = flags["admin-env"] ? readEnvFile(String(flags["admin-env"])) : {};
131
+ const admin = process.env.COMMENT_SERVICE_ADMIN_TOKEN || adminFile.ADMIN_TOKEN;
132
+ if (!admin) fail("no admin token. Set COMMENT_SERVICE_ADMIN_TOKEN or pass --admin-env <file with ADMIN_TOKEN=>.");
133
+ const headers = { authorization: `Bearer ${admin}`, "content-type": "application/json" };
134
+ const passphrase = typeof flags.passphrase === "string" ? flags.passphrase : generatePassphrase();
135
+ const base = `${api}/v1/admin/projects`;
136
+ let projectCreated = true;
137
+ await call(base, { method: "POST", headers, body: JSON.stringify({ name: String(flags.name ?? project), slug: project }) }).catch(
138
+ (error) => {
139
+ if (error instanceof HttpFailure && error.status === 409) projectCreated = false;
140
+ else throw error;
141
+ }
142
+ );
143
+ let secret;
144
+ let rotated = false;
145
+ try {
146
+ const created = await call(`${base}/${project}/environments`, {
147
+ method: "POST",
148
+ headers,
149
+ body: JSON.stringify({ name: environment, passphrase, origins })
150
+ });
151
+ secret = created.integrationSecret;
152
+ } catch (error) {
153
+ if (!(error instanceof HttpFailure) || error.status !== 409) throw error;
154
+ if (!flags.rotate) {
155
+ fail(
156
+ `environment "${environment}" already exists for "${project}". Re-run with --rotate to issue a new secret (the old one stops working), or reuse the values you already have.`
157
+ );
158
+ }
159
+ await call(`${base}/${project}/environments/${environment}`, {
160
+ method: "PATCH",
161
+ headers,
162
+ body: JSON.stringify({ origins, ...typeof flags.passphrase === "string" ? { passphrase } : {} })
163
+ });
164
+ const next = await call(`${base}/${project}/environments/${environment}/rotate-secret`, {
165
+ method: "POST",
166
+ headers
167
+ });
168
+ secret = next.integrationSecret;
169
+ rotated = true;
170
+ }
171
+ const passphraseChanged = !rotated || typeof flags.passphrase === "string";
172
+ const values = {
173
+ COMMENT_SERVICE_URL: api,
174
+ COMMENT_SERVICE_PROJECT: project,
175
+ COMMENT_SERVICE_ENVIRONMENT: environment,
176
+ COMMENT_SERVICE_SECRET: secret
177
+ };
178
+ const devVars = typeof flags["dev-vars"] === "string" ? flags["dev-vars"] : null;
179
+ if (devVars) {
180
+ writeEnvFile(devVars, passphraseChanged ? { ...values, COMMENT_SERVICE_PASSPHRASE: passphrase } : values);
181
+ }
182
+ if (flags["wrangler-secret"]) await putWranglerSecret(secret);
183
+ const summary = {
184
+ api,
185
+ project,
186
+ projectCreated,
187
+ environment,
188
+ rotated,
189
+ origins,
190
+ passphrase: passphraseChanged ? passphrase : null,
191
+ wroteDevVars: devVars,
192
+ wranglerSecret: Boolean(flags["wrangler-secret"]),
193
+ vars: { COMMENT_SERVICE_URL: api, COMMENT_SERVICE_PROJECT: project, COMMENT_SERVICE_ENVIRONMENT: environment }
194
+ };
195
+ if (flags.json) {
196
+ console.log(JSON.stringify(summary, null, 2));
197
+ return;
198
+ }
199
+ console.log(`${projectCreated ? "Created" : "Using"} project ${project} on ${api}`);
200
+ console.log(`${rotated ? "Rotated" : "Created"} environment ${environment} (origins: ${origins.join(", ")})`);
201
+ if (passphraseChanged) console.log(`Passphrase: ${passphrase}`);
202
+ if (devVars) console.log(`Wrote ${KEYS.join(", ")}${passphraseChanged ? ", COMMENT_SERVICE_PASSPHRASE" : ""} to ${devVars}`);
203
+ if (flags["wrangler-secret"]) console.log("Stored COMMENT_SERVICE_SECRET as a Worker secret.");
204
+ if (!devVars && !flags["wrangler-secret"]) {
205
+ console.log("\nCOMMENT_SERVICE_SECRET is shown once. Store it as a server-only secret:");
206
+ console.log(`COMMENT_SERVICE_SECRET=${secret}`);
207
+ }
208
+ if (!devVars) {
209
+ console.log("\nNon-secret vars for wrangler.jsonc `vars`:");
210
+ console.log(JSON.stringify(summary.vars, null, 2));
211
+ }
212
+ }
213
+ async function doctor(flags) {
214
+ if (typeof flags.app !== "string") fail("--app <url> is required, e.g. http://localhost:3400");
215
+ const app = normalizeOrigin(flags.app);
216
+ const page = typeof flags.page === "string" ? flags.page : "/";
217
+ const file = typeof flags["dev-vars"] === "string" ? readEnvFile(flags["dev-vars"]) : {};
218
+ const passphrase = typeof flags.passphrase === "string" ? flags.passphrase : file.COMMENT_SERVICE_PASSPHRASE;
219
+ const checks = [];
220
+ const add = (name, ok2, detail) => checks.push({ name, ok: ok2, detail });
221
+ const extra = {};
222
+ for (const raw of flags.header ?? []) {
223
+ const cut = raw.indexOf(":");
224
+ if (cut < 1) fail(`--header "${raw}" must look like "name: value".`);
225
+ extra[raw.slice(0, cut).trim().toLowerCase()] = raw.slice(cut + 1).trim();
226
+ }
227
+ const appFetch = async (pathname, accept) => {
228
+ const response = await fetch(`${app}${pathname}`, {
229
+ headers: { accept, "user-agent": USER_AGENT, ...extra },
230
+ redirect: "manual"
231
+ });
232
+ const location = response.headers.get("location") ?? "";
233
+ if (response.status >= 300 && response.status < 400 && location.includes("cloudflareaccess.com")) {
234
+ throw new Error(
235
+ "blocked by Cloudflare Access before reaching the app. Verify in a browser signed in to Access, or pass an Access service token with --header 'cf-access-client-id: \u2026' --header 'cf-access-client-secret: \u2026'."
236
+ );
237
+ }
238
+ if (response.status >= 300 && response.status < 400 && location) {
239
+ return fetch(new URL(location, `${app}${pathname}`), { headers: { accept, "user-agent": USER_AGENT, ...extra } });
240
+ }
241
+ return response;
242
+ };
243
+ let session = null;
244
+ try {
245
+ const response = await appFetch("/api/comment-service/session", "application/json");
246
+ const text = await response.text();
247
+ try {
248
+ session = JSON.parse(text);
249
+ } catch {
250
+ add("session route", false, `GET /api/comment-service/session returned ${response.status} non-JSON. Is the server route file in src/routes/api/comment-service/session.ts?`);
251
+ }
252
+ } catch (error) {
253
+ const message = error.message;
254
+ add("session route", false, message.startsWith("blocked by") ? message : `could not reach ${app}: ${message}. Is the dev server running?`);
255
+ }
256
+ if (session) {
257
+ if (session.enabled && session.hostToken) {
258
+ add("session route", true, `${session.project} / ${session.environment} via ${session.apiUrl}`);
259
+ } else if (session.error) {
260
+ add("session route", false, `service rejected the app (${session.status ?? "?"}): ${session.error} [origin seen: ${session.origin}]`);
261
+ } else {
262
+ add("session route", false, "enabled:false. COMMENT_SERVICE_URL/PROJECT/ENVIRONMENT/SECRET are not all visible to the app runtime (.dev.vars locally, vars + secret when deployed).");
263
+ }
264
+ }
265
+ const api = (session?.apiUrl ?? file.COMMENT_SERVICE_URL ?? DEFAULT_API).replace(/\/$/, "");
266
+ try {
267
+ const health = await call(`${api}/v1/health`);
268
+ add("service health", Boolean(health?.ok), api);
269
+ } catch (error) {
270
+ add("service health", false, `${api}: ${error.message}`);
271
+ }
272
+ try {
273
+ const response = await appFetch(page, "text/html");
274
+ const html = await response.text();
275
+ const stamps = html.match(/data-cs-cmp=/g)?.length ?? 0;
276
+ add(
277
+ "component stamps",
278
+ stamps > 0,
279
+ stamps > 0 ? `${stamps} data-cs-cmp attributes in SSR HTML of ${page}` : `none in ${page}. Add commentService() from @devaccel-labs/comment-service/vite to vite plugins, before tanstackStart().`
280
+ );
281
+ } catch (error) {
282
+ add("component stamps", false, error.message);
283
+ }
284
+ if (session?.hostToken && passphrase) {
285
+ try {
286
+ const member = await call(`${api}/v1/auth`, {
287
+ method: "POST",
288
+ headers: { "content-type": "application/json", origin: app },
289
+ body: JSON.stringify({ hostToken: session.hostToken, displayName: "doctor", passphrase })
290
+ });
291
+ const listed = await call(`${api}/v1/threads`, {
292
+ headers: { authorization: `Bearer ${member.token}`, origin: app }
293
+ });
294
+ add("unlock + list", true, `passphrase accepted, ${listed.threads?.length ?? 0} threads visible`);
295
+ } catch (error) {
296
+ add("unlock + list", false, error.message);
297
+ }
298
+ } else if (!passphrase) {
299
+ add("unlock + list", true, "skipped (no passphrase given)");
300
+ }
301
+ const ok = checks.every((check) => check.ok);
302
+ if (flags.json) console.log(JSON.stringify({ ok, app, checks }, null, 2));
303
+ else {
304
+ for (const check of checks) console.log(`${check.ok ? "PASS" : "FAIL"} ${check.name.padEnd(17)} ${check.detail}`);
305
+ console.log(ok ? "\nAll checks passed." : "\nSome checks failed.");
306
+ }
307
+ if (!ok) process.exit(1);
308
+ }
309
+ async function main() {
310
+ const [command, ...rest] = process.argv.slice(2);
311
+ if (!command || command === "help" || command === "--help" || command === "-h") {
312
+ console.log(HELP);
313
+ return;
314
+ }
315
+ const { values } = parseArgs({
316
+ args: rest,
317
+ allowPositionals: false,
318
+ options: {
319
+ project: { type: "string" },
320
+ name: { type: "string" },
321
+ env: { type: "string" },
322
+ origin: { type: "string", multiple: true },
323
+ passphrase: { type: "string" },
324
+ rotate: { type: "boolean" },
325
+ "dev-vars": { type: "string" },
326
+ "wrangler-secret": { type: "boolean" },
327
+ "admin-env": { type: "string" },
328
+ api: { type: "string" },
329
+ app: { type: "string" },
330
+ page: { type: "string" },
331
+ header: { type: "string", multiple: true },
332
+ json: { type: "boolean" },
333
+ help: { type: "boolean", short: "h" }
334
+ }
335
+ });
336
+ if (values.help) {
337
+ console.log(HELP);
338
+ return;
339
+ }
340
+ if (command === "provision") return provision(values);
341
+ if (command === "doctor") return doctor(values);
342
+ fail(`unknown command "${command}".
343
+
344
+ ${HELP}`);
345
+ }
346
+ main().catch((error) => {
347
+ if (error instanceof HttpFailure) fail(`${error.status} ${error.code}: ${error.message}`);
348
+ fail(error instanceof Error ? error.message : String(error));
349
+ });
@@ -0,0 +1,2 @@
1
+ export { CommentToolbar } from "./types/react/index.js"
2
+ export type { ToolbarPosition, ToolbarTheme } from "./types/react/index.js"