@boldare/askdare 0.0.0-stage → 0.1.0-rc.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/dist/cli.js ADDED
@@ -0,0 +1,1461 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { parseArgs, parseEnv } from "node:util";
4
+ import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
5
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { z } from "zod";
7
+ import { EncryptJWT, errors, jwtDecrypt, jwtVerify } from "jose";
8
+ import { execFile } from "node:child_process";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ import OpenAI, { NotFoundError, toFile } from "openai";
12
+ // src/protocol/doctor.ts
13
+ const check = z.object({
14
+ status: z.enum([
15
+ "ok",
16
+ "warn",
17
+ "fail"
18
+ ]),
19
+ title: z.string().max(500),
20
+ fix: z.string().max(500).optional()
21
+ });
22
+ const doctorReportSchema = z.object({
23
+ development: z.boolean(),
24
+ sharedLimits: z.boolean(),
25
+ gallery: z.boolean(),
26
+ bots: z.array(z.object({
27
+ id: z.string().max(48),
28
+ checks: z.array(check).max(20),
29
+ turnTimeoutMs: z.number().int().positive().optional()
30
+ })).max(50)
31
+ });
32
+ // src/security/identity.ts
33
+ const MIN_SECRET_LENGTH$1 = 32;
34
+ const MIN_DISTINCT_CHARACTERS$1 = 12;
35
+ /** Longest allowed gap between `iat` and `exp`. */
36
+ const MAX_LIFETIME_SECONDS = 86400;
37
+ /** Identities travel inside access tokens, which the widget sends with every request. */
38
+ const MAX_IDENTITY_BYTES = 2048;
39
+ const MAX_CLAIMS = 20;
40
+ const REGISTERED = /* @__PURE__ */ new Set([
41
+ "iss",
42
+ "sub",
43
+ "aud",
44
+ "exp",
45
+ "nbf",
46
+ "iat",
47
+ "jti",
48
+ "name",
49
+ "email"
50
+ ]);
51
+ const encoder$1 = new TextEncoder();
52
+ /**
53
+ * Display names are often edited by the users themselves and may end up in instructions: one
54
+ * line, no control characters.
55
+ */
56
+ const oneLine = (text) => text.normalize("NFKC").replace(/\p{Cc}+/gu, " ").replace(/\s+/g, " ").trim();
57
+ /** The identity part of a JWT payload, or undefined when a claim has the wrong shape. */
58
+ function identityFromClaims(payload) {
59
+ const { sub } = payload;
60
+ if (typeof sub !== "string" || sub.length === 0 || sub.length > 256) return void 0;
61
+ const field = (value) => typeof value === "string" ? oneLine(value) || void 0 : value ?? void 0;
62
+ const name = field(payload["name"]);
63
+ const email = field(payload["email"]);
64
+ if (name !== void 0 && (typeof name !== "string" || name.length > 200)) return void 0;
65
+ if (email !== void 0 && (typeof email !== "string" || email.length > 320)) return void 0;
66
+ const claims = {};
67
+ for (const [key, value] of Object.entries(payload)) {
68
+ if (REGISTERED.has(key)) continue;
69
+ if (key.length > 64) return void 0;
70
+ if (typeof value === "string" || typeof value === "boolean") claims[key] = value;
71
+ else if (typeof value === "number" && Number.isFinite(value)) claims[key] = value;
72
+ }
73
+ if (Object.keys(claims).length > MAX_CLAIMS) return void 0;
74
+ return {
75
+ id: sub,
76
+ ...name === void 0 ? {} : { name },
77
+ ...email === void 0 ? {} : { email },
78
+ claims
79
+ };
80
+ }
81
+ /**
82
+ * Checks identity JWTs from the site's backend: HS256 with IDENTITY_SECRET, the bot as audience,
83
+ * a bounded lifetime (ADR-0009).
84
+ */
85
+ function createIdentityVerifier(options) {
86
+ const { secret } = options;
87
+ if (secret.length < MIN_SECRET_LENGTH$1 || new Set(secret).size < MIN_DISTINCT_CHARACTERS$1) throw new TypeError(`[askdare] IDENTITY_SECRET must be at least ${MIN_SECRET_LENGTH$1} random characters. Generate one with: node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"`);
88
+ if (secret === options.chatSecret) throw new TypeError("[askdare] IDENTITY_SECRET must differ from CHAT_SECRET.");
89
+ const key = encoder$1.encode(secret);
90
+ const now = options.now ?? (() => Math.floor(Date.now() / 1e3));
91
+ const hmac = crypto.subtle.importKey("raw", key, {
92
+ name: "HMAC",
93
+ hash: "SHA-256"
94
+ }, false, ["sign"]);
95
+ return {
96
+ async key(botId, identity) {
97
+ const mac = await crypto.subtle.sign("HMAC", await hmac, encoder$1.encode(`askdare identity key v1\n${botId}\n${identity.id}`));
98
+ return Array.from(new Uint8Array(mac).slice(0, 16), (byte) => byte.toString(16).padStart(2, "0")).join("");
99
+ },
100
+ async verify(token, botId) {
101
+ let payload;
102
+ try {
103
+ ({payload} = await jwtVerify(token, key, {
104
+ algorithms: ["HS256"],
105
+ audience: botId,
106
+ requiredClaims: [
107
+ "sub",
108
+ "iat",
109
+ "exp"
110
+ ],
111
+ maxTokenAge: MAX_LIFETIME_SECONDS,
112
+ currentDate: /* @__PURE__ */ new Date(now() * 1e3),
113
+ clockTolerance: 30
114
+ }));
115
+ } catch (error) {
116
+ if (error instanceof errors.JWTExpired) return {
117
+ ok: false,
118
+ reason: "expired"
119
+ };
120
+ if (error instanceof errors.JWSSignatureVerificationFailed) return {
121
+ ok: false,
122
+ reason: "signature"
123
+ };
124
+ if (error instanceof errors.JWTClaimValidationFailed && error.claim === "aud") return {
125
+ ok: false,
126
+ reason: "audience"
127
+ };
128
+ if (error instanceof errors.JWTClaimValidationFailed && error.claim === "iat") return {
129
+ ok: false,
130
+ reason: "lifetime"
131
+ };
132
+ return {
133
+ ok: false,
134
+ reason: "malformed"
135
+ };
136
+ }
137
+ const { iat, exp } = payload;
138
+ if (typeof iat !== "number" || typeof exp !== "number" || exp <= iat || exp - iat > MAX_LIFETIME_SECONDS) return {
139
+ ok: false,
140
+ reason: "lifetime"
141
+ };
142
+ const identity = identityFromClaims(payload);
143
+ if (!identity) return {
144
+ ok: false,
145
+ reason: "claims"
146
+ };
147
+ if (encoder$1.encode(JSON.stringify(identity)).length > MAX_IDENTITY_BYTES) return {
148
+ ok: false,
149
+ reason: "too_large"
150
+ };
151
+ if (exp <= now()) return {
152
+ ok: false,
153
+ reason: "expired"
154
+ };
155
+ return {
156
+ ok: true,
157
+ verified: {
158
+ identity,
159
+ expiresAt: Math.min(exp, now() + MAX_LIFETIME_SECONDS)
160
+ }
161
+ };
162
+ }
163
+ };
164
+ }
165
+ // src/errors.ts
166
+ const HTTP_STATUS = {
167
+ invalid_input: 400,
168
+ unauthorized: 401,
169
+ session_expired: 401,
170
+ not_found: 404,
171
+ content_blocked: 422,
172
+ rate_limited: 429,
173
+ budget_exceeded: 429,
174
+ internal_error: 500,
175
+ upstream_error: 502,
176
+ bot_unavailable: 503,
177
+ timeout: 504
178
+ };
179
+ const DEFAULT_MESSAGES = {
180
+ invalid_input: "The request was invalid.",
181
+ unauthorized: "Authentication is required.",
182
+ session_expired: "Your session has expired. Please try again.",
183
+ not_found: "Not found.",
184
+ content_blocked: "This message cannot be processed.",
185
+ rate_limited: "Too many messages. Please wait a moment.",
186
+ budget_exceeded: "The assistant is unavailable for the rest of the day.",
187
+ internal_error: "Something went wrong. Please try again.",
188
+ upstream_error: "The assistant is temporarily unavailable. Please try again.",
189
+ bot_unavailable: "The assistant is currently unavailable.",
190
+ timeout: "The assistant took too long to respond. Please try again."
191
+ };
192
+ /** An error with a stable, client-safe code. Anything else that escapes becomes `internal_error`. */
193
+ var AskdareError = class extends Error {
194
+ name = "AskdareError";
195
+ code;
196
+ status;
197
+ retryAfterMs;
198
+ key;
199
+ params;
200
+ constructor(code, options = {}) {
201
+ super(options.message ?? DEFAULT_MESSAGES[code], { cause: options.cause });
202
+ this.code = code;
203
+ this.status = HTTP_STATUS[code];
204
+ this.retryAfterMs = options.retryAfterMs;
205
+ this.key = options.key ?? (options.message === void 0 ? `error.${code}` : void 0);
206
+ this.params = options.params;
207
+ }
208
+ };
209
+ // src/security/tokens.ts
210
+ const ISSUER = "askdare";
211
+ const ACCESS_TYP = "askdare-access+jwt";
212
+ const RESUME_TYP = "askdare-resume+jwt";
213
+ const ATTACHMENT_TYP = "askdare-attachment+jwt";
214
+ const DOCTOR_TYP = "askdare-doctor+jwt";
215
+ const DOCTOR_AUDIENCE = "askdare-doctor";
216
+ const DOCTOR_TTL_SECONDS = 120;
217
+ const MIN_SECRET_LENGTH = 32;
218
+ const MIN_DISTINCT_CHARACTERS = 12;
219
+ const VISITOR_ID = /^v_[0-9a-f]{32}$/;
220
+ const CONVERSATION_ID = /^conv_[A-Za-z0-9_-]{1,128}$/;
221
+ const FILE_ID = /^file-[A-Za-z0-9_-]{1,128}$/;
222
+ /** How long after expiry an access token still identifies its visitor for a renewal. */
223
+ const RENEWAL_GRACE_SECONDS = 3600;
224
+ const encoder = new TextEncoder();
225
+ async function deriveKey(secret, name) {
226
+ if (secret.length < MIN_SECRET_LENGTH || new Set(secret).size < MIN_DISTINCT_CHARACTERS) throw new TypeError(`[askdare] ${name} must be at least ${MIN_SECRET_LENGTH} random characters. Generate one with: node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"`);
227
+ const material = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveBits"]);
228
+ const derive = async (info, bits) => new Uint8Array(await crypto.subtle.deriveBits({
229
+ name: "HKDF",
230
+ hash: "SHA-256",
231
+ salt: encoder.encode(ISSUER),
232
+ info: encoder.encode(info)
233
+ }, material, bits));
234
+ return {
235
+ kid: Array.from(await derive("askdare kid v1", 64), (byte) => byte.toString(16).padStart(2, "0")).join(""),
236
+ key: await derive("askdare token encryption v1", 256),
237
+ pseudonymKey: await derive("askdare pseudonym v1", 256)
238
+ };
239
+ }
240
+ /** The identity an access token carries, while it lasts. Our own encrypted claim: no re-check needed. */
241
+ function identityOf(payload, now) {
242
+ const { idn, ixp } = payload;
243
+ if (typeof idn !== "object" || idn === null || typeof ixp !== "number" || ixp <= now) return;
244
+ return {
245
+ identity: idn,
246
+ expiresAt: ixp
247
+ };
248
+ }
249
+ async function createTokenService(options) {
250
+ const current = await deriveKey(options.secret, "CHAT_SECRET");
251
+ const keys = /* @__PURE__ */ new Map([[current.kid, current.key]]);
252
+ for (const secret of options.previousSecrets ?? []) {
253
+ const previous = await deriveKey(secret, "CHAT_SECRET_PREVIOUS");
254
+ keys.set(previous.kid, previous.key);
255
+ }
256
+ const now = options.now ?? (() => Math.floor(Date.now() / 1e3));
257
+ const accessTtl = options.accessTtlSeconds ?? 900;
258
+ const resumeTtl = options.resumeTtlSeconds ?? 2592e3;
259
+ const attachmentTtl = options.attachmentTtlSeconds ?? 3600;
260
+ const pseudonymKey = await crypto.subtle.importKey("raw", current.pseudonymKey, {
261
+ name: "HMAC",
262
+ hash: "SHA-256"
263
+ }, false, ["sign"]);
264
+ async function issue(typ, botId, subject, ttl, claims = {}) {
265
+ const issuedAt = now();
266
+ const expiresAt = issuedAt + ttl;
267
+ return {
268
+ token: await new EncryptJWT(claims).setProtectedHeader({
269
+ alg: "dir",
270
+ enc: "A256GCM",
271
+ typ,
272
+ kid: current.kid
273
+ }).setIssuer(ISSUER).setAudience(botId).setSubject(subject).setIssuedAt(issuedAt).setExpirationTime(expiresAt).encrypt(current.key),
274
+ expiresAt
275
+ };
276
+ }
277
+ async function verify(token, typ, botId, clockTolerance = 0) {
278
+ const resolveKey = (header) => {
279
+ const key = header.kid === void 0 ? void 0 : keys.get(header.kid);
280
+ if (!key) throw new errors.JWEDecryptionFailed();
281
+ return key;
282
+ };
283
+ return jwtDecrypt(token, resolveKey, {
284
+ requiredClaims: [
285
+ "exp",
286
+ "iat",
287
+ "sub"
288
+ ],
289
+ issuer: ISSUER,
290
+ audience: botId,
291
+ typ,
292
+ keyManagementAlgorithms: ["dir"],
293
+ contentEncryptionAlgorithms: ["A256GCM"],
294
+ currentDate: /* @__PURE__ */ new Date(now() * 1e3),
295
+ clockTolerance
296
+ });
297
+ }
298
+ return {
299
+ issueAccess: (botId, visitorId, identity) => issue(ACCESS_TYP, botId, visitorId, identity ? Math.max(1, Math.min(accessTtl, identity.expiresAt - now())) : accessTtl, identity ? {
300
+ idn: { ...identity.identity },
301
+ ixp: identity.expiresAt
302
+ } : {}),
303
+ async verifyAccess(token, botId) {
304
+ try {
305
+ const { payload } = await verify(token, ACCESS_TYP, botId);
306
+ if (typeof payload.sub !== "string" || !VISITOR_ID.test(payload.sub)) throw new AskdareError("unauthorized");
307
+ return {
308
+ visitorId: payload.sub,
309
+ identity: identityOf(payload, now())
310
+ };
311
+ } catch (error) {
312
+ if (error instanceof errors.JWTExpired) throw new AskdareError("session_expired", { cause: error });
313
+ throw new AskdareError("unauthorized", { cause: error });
314
+ }
315
+ },
316
+ async verifyRenewal(token, botId) {
317
+ try {
318
+ const { payload } = await verify(token, ACCESS_TYP, botId, RENEWAL_GRACE_SECONDS);
319
+ if (typeof payload.sub !== "string" || !VISITOR_ID.test(payload.sub)) throw new AskdareError("unauthorized");
320
+ return {
321
+ visitorId: payload.sub,
322
+ identity: identityOf(payload, now())
323
+ };
324
+ } catch (error) {
325
+ throw new AskdareError("unauthorized", { cause: error });
326
+ }
327
+ },
328
+ async pseudonym(value) {
329
+ const digest = await crypto.subtle.sign("HMAC", pseudonymKey, encoder.encode(value));
330
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
331
+ },
332
+ issueResume: (botId, grant) => issue(RESUME_TYP, botId, grant.visitorId, resumeTtl, {
333
+ cid: grant.conversationId,
334
+ ...grant.identityKey ? { ids: grant.identityKey } : {}
335
+ }),
336
+ async verifyResume(token, botId) {
337
+ try {
338
+ const { payload } = await verify(token, RESUME_TYP, botId);
339
+ const { cid, ids } = payload;
340
+ if (typeof payload.sub !== "string" || !VISITOR_ID.test(payload.sub) || typeof cid !== "string" || !CONVERSATION_ID.test(cid) || ids !== void 0 && typeof ids !== "string") throw new AskdareError("not_found");
341
+ return {
342
+ visitorId: payload.sub,
343
+ conversationId: cid,
344
+ ...ids === void 0 ? {} : { identityKey: ids }
345
+ };
346
+ } catch (error) {
347
+ throw new AskdareError("not_found", { cause: error });
348
+ }
349
+ },
350
+ issueAttachment: (botId, grant) => issue(ATTACHMENT_TYP, botId, grant.visitorId, attachmentTtl, {
351
+ fid: grant.fileId,
352
+ knd: grant.kind,
353
+ est: grant.tokens
354
+ }),
355
+ async verifyAttachment(token, botId) {
356
+ const invalid = (cause) => new AskdareError("invalid_input", {
357
+ message: "An attachment expired or is invalid. Please attach it again.",
358
+ key: "error.attachmentExpired",
359
+ cause
360
+ });
361
+ let payload;
362
+ try {
363
+ ({payload} = await verify(token, ATTACHMENT_TYP, botId));
364
+ } catch (error) {
365
+ throw invalid(error);
366
+ }
367
+ const { fid, knd, est } = payload;
368
+ if (typeof payload.sub !== "string" || !VISITOR_ID.test(payload.sub) || typeof fid !== "string" || !FILE_ID.test(fid) || knd !== "image" && knd !== "pdf" || typeof est !== "number" || !Number.isSafeInteger(est) || est < 0) throw invalid();
369
+ return {
370
+ visitorId: payload.sub,
371
+ fileId: fid,
372
+ kind: knd,
373
+ tokens: est
374
+ };
375
+ },
376
+ issueDoctor: () => issue(DOCTOR_TYP, DOCTOR_AUDIENCE, "doctor", DOCTOR_TTL_SECONDS),
377
+ async verifyDoctor(token) {
378
+ try {
379
+ await verify(token, DOCTOR_TYP, DOCTOR_AUDIENCE);
380
+ } catch {
381
+ throw new AskdareError("unauthorized");
382
+ }
383
+ }
384
+ };
385
+ }
386
+ // src/cli/project.ts
387
+ const LOCKFILES = [
388
+ ["pnpm-lock.yaml", "pnpm"],
389
+ ["yarn.lock", "yarn"],
390
+ ["bun.lock", "bun"],
391
+ ["bun.lockb", "bun"],
392
+ ["package-lock.json", "npm"]
393
+ ];
394
+ /** Walks up from `start` (a monorepo keeps its lockfile and .gitignore at the top). */
395
+ function findUp(start, test) {
396
+ for (let dir = start;; dir = dirname(dir)) {
397
+ if (test(dir)) return dir;
398
+ if (dirname(dir) === dir || existsSync(join(dir, ".git"))) return void 0;
399
+ }
400
+ }
401
+ async function readProject(root) {
402
+ let dependencies;
403
+ try {
404
+ const json = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
405
+ dependencies = {
406
+ ...json.devDependencies,
407
+ ...json.dependencies
408
+ };
409
+ } catch {
410
+ dependencies = void 0;
411
+ }
412
+ const appDir = [join(root, "app"), join(root, "src", "app")].find((dir) => existsSync(dir));
413
+ let packageManager = "npm";
414
+ findUp(root, (dir) => {
415
+ const lock = LOCKFILES.find(([file]) => existsSync(join(dir, file)));
416
+ if (lock) packageManager = lock[1];
417
+ return lock !== void 0;
418
+ });
419
+ return {
420
+ root,
421
+ dependencies,
422
+ appDir,
423
+ typescript: existsSync(join(root, "tsconfig.json")),
424
+ packageManager
425
+ };
426
+ }
427
+ /** The version installed in `node_modules`; undefined when the package isn't installed. */
428
+ function installedVersion(project, name) {
429
+ let version;
430
+ findUp(project.root, (dir) => {
431
+ try {
432
+ const manifest = readFileSync(join(dir, "node_modules", name, "package.json"), "utf8");
433
+ version = JSON.parse(manifest).version;
434
+ return true;
435
+ } catch {
436
+ return false;
437
+ }
438
+ });
439
+ return version;
440
+ }
441
+ /** Whether a version (or range like `^4.1.0`) is at least `major.minor`; undefined if unreadable. */
442
+ function atLeast(version, major, minor = 0) {
443
+ const match = /(\d+)(?:\.(\d+))?/.exec(version ?? "");
444
+ if (match?.[1] === void 0) return void 0;
445
+ const [have, haveMinor] = [Number(match[1]), Number(match[2] ?? 0)];
446
+ return have > major || have === major && haveMinor >= minor;
447
+ }
448
+ function majorOf(version) {
449
+ const match = /(\d+)/.exec(version ?? "");
450
+ return match?.[1] === void 0 ? void 0 : Number(match[1]);
451
+ }
452
+ /** The catch-all route file for a base path, e.g. `app/api/chat/[...path]/route.ts`. */
453
+ function routeDir(project, basePath) {
454
+ return project.appDir && join(project.appDir, ...basePath.split("/").filter(Boolean), "[...path]");
455
+ }
456
+ function findFile(dir, name) {
457
+ if (dir === void 0) return void 0;
458
+ return [
459
+ "ts",
460
+ "tsx",
461
+ "js",
462
+ "jsx",
463
+ "mjs"
464
+ ].map((extension) => join(dir, `${name}.${extension}`)).find((file) => existsSync(file));
465
+ }
466
+ /** A path relative to the project, with forward slashes, for messages. */
467
+ function shown(project, file) {
468
+ return relative(project.root, file).split(sep).join("/");
469
+ }
470
+ function runScript(project, script) {
471
+ return project.packageManager === "npm" ? `npm run ${script}` : `${project.packageManager} ${script}`;
472
+ }
473
+ function addCommand(project, packages) {
474
+ return `${project.packageManager === "npm" ? "npm install" : `${project.packageManager} add`} ${packages.join(" ")}`;
475
+ }
476
+ /**
477
+ * Environment as `next dev` sees it: the shell, then `.env.development.local`, `.env.local`,
478
+ * `.env.development` and `.env`. An explicit `envFile` wins over the shell instead: it is what
479
+ * the user asked to check. Read without changing `process.env`.
480
+ */
481
+ async function readEnv(root, envFile, shell = process.env) {
482
+ const files = envFile ? [envFile] : [
483
+ ".env.development.local",
484
+ ".env.local",
485
+ ".env.development",
486
+ ".env"
487
+ ];
488
+ const loaded = [];
489
+ const env = {};
490
+ for (const file of files.toReversed()) {
491
+ let text;
492
+ try {
493
+ text = await readFile(resolve(root, file), "utf8");
494
+ } catch (error) {
495
+ if (envFile) throw error;
496
+ continue;
497
+ }
498
+ Object.assign(env, parseEnv(text));
499
+ loaded.unshift(file);
500
+ }
501
+ return {
502
+ env: envFile ? {
503
+ ...shell,
504
+ ...env
505
+ } : {
506
+ ...env,
507
+ ...shell
508
+ },
509
+ files: loaded
510
+ };
511
+ }
512
+ /** Whether git ignores `name`, a file in `root`: git's own answer, or 'unknown' without git. */
513
+ function gitCheckIgnore(root, name) {
514
+ return new Promise((done) => {
515
+ execFile("git", [
516
+ "check-ignore",
517
+ "-q",
518
+ "--",
519
+ name
520
+ ], {
521
+ cwd: root,
522
+ timeout: 5e3
523
+ }, (error) => {
524
+ if (error === null) done(true);
525
+ else done(error.code === 1 ? false : "unknown");
526
+ });
527
+ });
528
+ }
529
+ const GLOB_ONLY = /^[^/?[\]\\]+$/;
530
+ /**
531
+ * Whether git ignores `name`, a file in `root`. Asks git; outside a repository (or without git)
532
+ * reads the project's own .gitignore, and answers no whenever a line is beyond a simple glob.
533
+ */
534
+ async function gitIgnores(root, name) {
535
+ const verdict = await gitCheckIgnore(root, name);
536
+ if (verdict !== "unknown") return verdict;
537
+ let text;
538
+ try {
539
+ text = await readFile(join(root, ".gitignore"), "utf8");
540
+ } catch {
541
+ return false;
542
+ }
543
+ let ignored = false;
544
+ for (const line of text.split(/\r?\n/)) {
545
+ const trimmed = line.trim();
546
+ if (!trimmed || trimmed.startsWith("#")) continue;
547
+ const negated = trimmed.startsWith("!");
548
+ const pattern = (negated ? trimmed.slice(1) : trimmed).replace(/^\//, "");
549
+ if (pattern.includes("/") && !pattern.startsWith("**/")) continue;
550
+ if (!GLOB_ONLY.test(pattern)) {
551
+ if (negated) return false;
552
+ continue;
553
+ }
554
+ if (new RegExp(`^${pattern.replace(/[.+^${}()|]/g, "\\$&").replaceAll("*", "[^/]*")}$`).test(name)) ignored = !negated;
555
+ }
556
+ return ignored;
557
+ }
558
+ // src/cli/doctor.ts
559
+ const ok = (title) => ({
560
+ status: "ok",
561
+ title
562
+ });
563
+ const warn = (title, fix) => ({
564
+ status: "warn",
565
+ title,
566
+ fix
567
+ });
568
+ const fail = (title, fix) => ({
569
+ status: "fail",
570
+ title,
571
+ fix
572
+ });
573
+ const PROXY_FIX = "Exclude askdare's routes in proxy.ts, e.g. matcher: ['/((?!api/chat|_next/static|_next/image).*)'].";
574
+ function versions(project) {
575
+ const checks = [];
576
+ const node = majorOf(process.versions.node) ?? 0;
577
+ checks.push(node >= 22 ? ok(`Node ${process.versions.node}`) : fail(`Node ${process.versions.node} is too old.`, "Use Node 22 or later."));
578
+ const wanted = [
579
+ [
580
+ "next",
581
+ 16,
582
+ 0,
583
+ "Next.js 16"
584
+ ],
585
+ [
586
+ "react",
587
+ 19,
588
+ 0,
589
+ "React 19"
590
+ ],
591
+ [
592
+ "zod",
593
+ 4,
594
+ 1,
595
+ "zod 4.1 or later"
596
+ ],
597
+ [
598
+ "@boldare/askdare",
599
+ 0,
600
+ 0,
601
+ "@boldare/askdare"
602
+ ]
603
+ ];
604
+ const found = [];
605
+ for (const [name, major, minor, label] of wanted) {
606
+ const version = installedVersion(project, name);
607
+ if (version === void 0) checks.push(fail(`${name} is not installed.`, project.dependencies?.[name] ? `Run ${project.packageManager} install.` : `askdare needs ${label}: ${addCommand(project, [name])}`));
608
+ else if (!atLeast(version, major, minor)) checks.push(fail(`${name} ${version} is too old.`, `askdare needs ${label}.`));
609
+ else found.push(`${name} ${version}`);
610
+ }
611
+ if (found.length > 0) checks.splice(1, 0, ok(found.join(", ")));
612
+ return checks;
613
+ }
614
+ async function files(project, basePath) {
615
+ if (project.appDir === void 0) return [fail("No app/ or src/app/ folder.", "askdare needs the App Router.")];
616
+ const checks = [];
617
+ const route = findFile(routeDir(project, basePath), "route");
618
+ if (route === void 0) checks.push(fail(`No route at ${shown(project, join(routeDir(project, basePath) ?? "", "route.ts"))}.`, "Run `npx @boldare/askdare init`, or pass --base-path if you mounted askdare elsewhere."));
619
+ else {
620
+ const text = await readFile(route, "utf8");
621
+ const maxDuration = /export\s+const\s+maxDuration\s*=\s*(\d[\d_]*)/.exec(text)?.[1];
622
+ if (!text.includes("createChatHandler")) checks.push(fail(`${shown(project, route)} doesn't call createChatHandler.`));
623
+ else if (/export\s+const\s+runtime\s*=\s*['"]edge['"]/.test(text)) checks.push(fail(`${shown(project, route)} uses the Edge runtime.`, "askdare runs on Node.js only; remove `runtime`."));
624
+ else if (maxDuration === void 0) checks.push(warn(`${shown(project, route)} has no literal maxDuration.`, "Add `export const maxDuration = 300;` so long answers aren’t cut off on Vercel."));
625
+ else checks.push(ok(`${shown(project, route)} (maxDuration ${maxDuration.replaceAll("_", "")} s)`));
626
+ }
627
+ const middleware = [project.root, join(project.root, "src")].map((dir) => findFile(dir, "middleware")).find(Boolean);
628
+ if (middleware) checks.push(warn(`${shown(project, middleware)} is deprecated in Next.js 16.`, "Rename it to proxy.ts; the live checks below show whether it blocks askdare."));
629
+ return checks;
630
+ }
631
+ /** `remote`: checking a deployed app, whose own key and model the live checks cover. */
632
+ async function environment(project, env, envFiles, remote) {
633
+ const checks = [];
634
+ if (!remote) checks.push(env["OPENAI_API_KEY"] ? ok("OPENAI_API_KEY is set.") : fail("OPENAI_API_KEY is not set.", "Add the key of a dedicated OpenAI project to .env.local."));
635
+ const secret = env["CHAT_SECRET"];
636
+ const previous = (env["CHAT_SECRET_PREVIOUS"] ?? "").split(",").map((value) => value.trim()).filter(Boolean);
637
+ if (!secret) checks.push(fail("CHAT_SECRET is not set.", `Generate one: node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"`));
638
+ else try {
639
+ await createTokenService({
640
+ secret,
641
+ previousSecrets: previous
642
+ });
643
+ checks.push(ok(previous.length > 0 ? "CHAT_SECRET and CHAT_SECRET_PREVIOUS are strong." : "CHAT_SECRET is strong."));
644
+ } catch (error) {
645
+ checks.push(fail(error.message.replace("[askdare] ", "")));
646
+ }
647
+ const identitySecret = env["IDENTITY_SECRET"];
648
+ if (identitySecret) try {
649
+ createIdentityVerifier({
650
+ secret: identitySecret,
651
+ chatSecret: secret
652
+ });
653
+ checks.push(ok("IDENTITY_SECRET is strong and differs from CHAT_SECRET."));
654
+ } catch (error) {
655
+ checks.push(fail(error.message.replace("[askdare] ", "")));
656
+ }
657
+ const config = findFile(project.root, "askdare.config");
658
+ const usesModelVariable = env["ASKDARE_MODEL"] !== void 0 || config !== void 0 && (await readFile(config, "utf8")).includes("ASKDARE_MODEL");
659
+ if (!remote && usesModelVariable && !env["ASKDARE_MODEL"]) checks.push(fail(env["ASKDARE_MODEL"] === void 0 ? "ASKDARE_MODEL is not set." : "ASKDARE_MODEL is empty.", "Set it to a current OpenAI model your project may use, here and in every environment of your host (on Vercel: Production and Preview). Without a model the app fails to start."));
660
+ for (const file of envFiles) {
661
+ const name = relative(project.root, resolve(project.root, file));
662
+ if (name.startsWith("..") || isAbsolute(name) || await gitIgnores(project.root, name)) continue;
663
+ const values = parseEnv(await readFile(join(project.root, name), "utf8"));
664
+ if (values["OPENAI_API_KEY"] || values["CHAT_SECRET"]) checks.push(fail(`${name} holds secrets but is not in .gitignore.`, `Add \`${name.endsWith(".local") ? ".env*.local" : ".env*"}\` to .gitignore before you commit.`));
665
+ }
666
+ return checks;
667
+ }
668
+ async function request(fetcher, url, init = {}) {
669
+ try {
670
+ return await fetcher(url, {
671
+ redirect: "manual",
672
+ signal: AbortSignal.timeout(12e4),
673
+ ...init
674
+ });
675
+ } catch {
676
+ return fail(`Could not reach ${url}.`);
677
+ }
678
+ }
679
+ function redirectOrMissing(response, url) {
680
+ if (response.status >= 300 && response.status < 400) return fail(`${url} redirects to ${response.headers.get("location") ?? "another page"}.`, PROXY_FIX);
681
+ }
682
+ /** What the browser needs from widget.js and the chat page; things in front of askdare can break it. */
683
+ async function headers(fetcher, base, botId, origin) {
684
+ const checks = [];
685
+ const loaderUrl = `${base}/widget.js?bot=${botId}`;
686
+ const loader = await request(fetcher, loaderUrl);
687
+ if (!(loader instanceof Response)) return [loader];
688
+ const embedUrl = `${base}/embed/${botId}`;
689
+ const embed = await request(fetcher, embedUrl);
690
+ if (!(embed instanceof Response)) return [embed];
691
+ const loaderProblem = redirectOrMissing(loader, loaderUrl);
692
+ if (loaderProblem) checks.push(loaderProblem);
693
+ else if (!loader.ok || !(loader.headers.get("content-type") ?? "").includes("javascript")) checks.push(fail(`${loaderUrl} answered HTTP ${loader.status} instead of the script.`, PROXY_FIX));
694
+ else checks.push(ok("widget.js loads."));
695
+ const embedProblem = redirectOrMissing(embed, embedUrl);
696
+ if (embedProblem) checks.push(embedProblem);
697
+ else if (!embed.ok) checks.push(fail(`${embedUrl} answered HTTP ${embed.status}.`, PROXY_FIX));
698
+ else {
699
+ const ancestors = [...(embed.headers.get("content-security-policy") ?? "").matchAll(/frame-ancestors\s+([^;,]*)/g)].map((match) => match[1]?.trim());
700
+ const fix = "Leave /api/chat out of the headers() in next.config and of proxy.ts.";
701
+ if (origin !== void 0 && ancestors.length === 1) {
702
+ const allowed = (ancestors[0] ?? "").split(/\s+/);
703
+ const self = new URL(base).origin === origin;
704
+ checks.push(self || allowed.includes(origin) ? ok(`${origin} may embed the chat.`) : fail(`${origin} may not embed the chat: it isn't in the bot's security.allowedOrigins.`, `Add '${origin}' to security.allowedOrigins of bot "${botId}" and deploy.`));
705
+ }
706
+ if (embed.headers.has("x-frame-options")) checks.push(fail("X-Frame-Options on the chat page stops sites from embedding it.", fix));
707
+ else if (ancestors.length !== 1) checks.push(fail(ancestors.length === 0 ? "The chat page lost its frame-ancestors policy." : "A second Content-Security-Policy on the chat page overrides who may embed it.", fix));
708
+ else checks.push(ok(`Sites that may embed the chat: ${ancestors[0]}`));
709
+ }
710
+ if (loader.headers.has("set-cookie") || embed.headers.has("set-cookie")) checks.push(warn("Something sets cookies on askdare's responses.", "askdare uses no cookies; leave /api/chat out of the code that sets them."));
711
+ await Promise.all([loader.body?.cancel(), embed.body?.cancel()]);
712
+ return checks;
713
+ }
714
+ /** The literal `maxDuration` of the askdare route, in seconds, if the file has one. */
715
+ async function maxDurationOf(project, basePath) {
716
+ const route = findFile(routeDir(project, basePath), "route");
717
+ if (!route) return void 0;
718
+ const value = /export\s+const\s+maxDuration\s*=\s*(\d[\d_]*)/.exec(await readFile(route, "utf8"))?.[1];
719
+ return value === void 0 ? void 0 : Number(value.replaceAll("_", ""));
720
+ }
721
+ /**
722
+ * A bot whose answers may run longer than the route may: the platform ends the function first and
723
+ * the answer is cut off. 15 s of headroom for the history read and the stream's end.
724
+ */
725
+ function timeout(bot, routeSeconds) {
726
+ if (bot.turnTimeoutMs === void 0 || routeSeconds === void 0) return [];
727
+ const needed = Math.ceil(bot.turnTimeoutMs / 1e3) + 15;
728
+ return needed > routeSeconds ? [warn(`An answer may take up to ${Math.ceil(bot.turnTimeoutMs / 1e3)} s, but the route's maxDuration is ${routeSeconds} s.`, `Raise maxDuration to at least ${needed}, or lower limits.turnTimeoutMs.`)] : [];
729
+ }
730
+ async function live(options, project, env) {
731
+ const base = `${options.url.replace(/\/+$/, "")}${options.basePath}`;
732
+ const fetcher = options.fetch ?? fetch;
733
+ const title = `App at ${options.url}`;
734
+ const start = `Start the app (${runScript(project, "dev")}) or pass --url with where it runs.`;
735
+ let token;
736
+ try {
737
+ token = (await (await createTokenService({ secret: env["CHAT_SECRET"] ?? "" })).issueDoctor()).token;
738
+ } catch {
739
+ return [{
740
+ title,
741
+ checks: [warn("Skipped: the live checks need CHAT_SECRET.")]
742
+ }];
743
+ }
744
+ const response = await request(fetcher, `${base}/doctor`, {
745
+ method: "POST",
746
+ headers: { authorization: `Bearer ${token}` }
747
+ });
748
+ if (!(response instanceof Response)) return [{
749
+ title,
750
+ checks: [{
751
+ ...response,
752
+ fix: start
753
+ }]
754
+ }];
755
+ const problem = redirectOrMissing(response, `${base}/doctor`);
756
+ if (problem) return [{
757
+ title,
758
+ checks: [problem]
759
+ }];
760
+ const json = await response.json().catch(() => void 0);
761
+ if (!response.ok) {
762
+ const code = json?.error?.code;
763
+ return [{
764
+ title,
765
+ checks: [response.status === 404 && code === void 0 ? fail(`No askdare route at ${base}.`, "Check --url and --base-path, and the route file.") : code === "unauthorized" ? fail("The app rejected the doctor's token: its CHAT_SECRET differs from yours.", "Run doctor with the CHAT_SECRET of that app, e.g. --env-file .env.production.local.") : code === "not_found" ? fail("This app's askdare has no doctor endpoint.", "Update @boldare/askdare.") : code === "bot_unavailable" ? fail("The app is switched off (ASKDARE_DISABLED) or has no CHAT_SECRET where it runs.") : code === "rate_limited" ? fail("Too many doctor runs at once or this minute.", "Try again in a minute.") : fail(`${base}/doctor answered HTTP ${response.status}.`, "Check the app’s logs.")]
766
+ }];
767
+ }
768
+ const parsed = doctorReportSchema.safeParse(json);
769
+ if (!parsed.success) return [{
770
+ title,
771
+ checks: [fail(`${base}/doctor did not answer like askdare.`, PROXY_FIX)]
772
+ }];
773
+ const report = parsed.data;
774
+ const app = [ok(report.development ? "Reachable (development)." : "Reachable (production).")];
775
+ if (!report.development && report.gallery) app.push(warn("The state gallery is on.", "Turn `gallery` off in production."));
776
+ if (!report.development && !report.sharedLimits) app.push(warn("Rate limits and budgets are kept in memory, per instance.", "Fine for one server; with more (Vercel, several dynos) use @boldare/askdare-upstash."));
777
+ const sections = [{
778
+ title,
779
+ checks: app
780
+ }];
781
+ const routeSeconds = await maxDurationOf(project, options.basePath);
782
+ for (const bot of report.bots) sections.push({
783
+ title: `Bot "${bot.id}"`,
784
+ checks: [
785
+ ...bot.checks,
786
+ ...await headers(fetcher, base, bot.id, options.origin),
787
+ ...timeout(bot, routeSeconds)
788
+ ]
789
+ });
790
+ return sections;
791
+ }
792
+ const MARK = {
793
+ ok: "✓",
794
+ warn: "!",
795
+ fail: "✗"
796
+ };
797
+ /** localhost and loopback addresses: the app runs on this machine, with the env files here. */
798
+ function isLocal(url) {
799
+ return /^(localhost|127\.0\.0\.1|\[::1\])$|\.localhost$/.test(new URL(url).hostname);
800
+ }
801
+ /** `askdare doctor`: checks the project, its environment and the running app. Returns failures. */
802
+ async function doctor(options) {
803
+ const project = await readProject(options.root);
804
+ const { env, files: envFiles } = await readEnv(options.root, options.envFile, options.shell);
805
+ const sections = [
806
+ {
807
+ title: "Project",
808
+ checks: [...versions(project), ...await files(project, options.basePath)]
809
+ },
810
+ {
811
+ title: `Environment (${envFiles.join(", ") || "shell only"})`,
812
+ checks: await environment(project, env, envFiles, !isLocal(options.url))
813
+ },
814
+ ...await live(options, project, env)
815
+ ];
816
+ options.print("askdare doctor");
817
+ for (const section of sections) {
818
+ options.print(`\n${section.title}`);
819
+ for (const check of section.checks) {
820
+ options.print(` ${MARK[check.status]} ${check.title}`);
821
+ if (check.fix) options.print(` → ${check.fix}`);
822
+ }
823
+ }
824
+ const all = sections.flatMap((section) => section.checks);
825
+ const failures = all.filter((check) => check.status === "fail").length;
826
+ const warnings = all.filter((check) => check.status === "warn").length;
827
+ const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
828
+ options.print(failures + warnings === 0 ? "\nAll good." : `\n${plural(failures, "problem")}, ${plural(warnings, "warning")}.`);
829
+ return failures;
830
+ }
831
+ // src/cli/init.ts
832
+ var InitError = class extends Error {};
833
+ const BOT_ID = /^[a-z0-9][a-z0-9-]{0,47}$/;
834
+ function configTemplate(botId) {
835
+ return `import { defineBot } from '@boldare/askdare';
836
+
837
+ export const bots = [
838
+ defineBot({
839
+ id: '${botId}',
840
+ // Required: a current OpenAI model your project may use. Set ASKDARE_MODEL in .env.local.
841
+ model: process.env.ASKDARE_MODEL ?? '',
842
+ instructions: ({ locale }) =>
843
+ [
844
+ 'You are the assistant on our website. Answer briefly and politely.',
845
+ 'If you do not know something, say so.',
846
+ \`Reply in the language of the visitor's message; default to \${locale}.\`,
847
+ ].join(' '),
848
+ security: {
849
+ // Other sites that may embed the chat, e.g. ['https://www.example.com'].
850
+ allowedOrigins: [],
851
+ },
852
+ widget: {
853
+ locale: 'auto',
854
+ greeting: 'Hi! How can I help?',
855
+ theme: 'aura', // aura, halo, sage or care
856
+ },
857
+ // Knowledge base, tools, attachments, voice and limits: see node_modules/@boldare/askdare/docs.
858
+ }),
859
+ ];
860
+ `;
861
+ }
862
+ function routeTemplate(configImport) {
863
+ return `import { createChatHandler } from '@boldare/askdare/next';
864
+ import { bots } from '${configImport}';
865
+
866
+ // Must be a literal in this file; keep it above the bots' turn timeout (120 s by default).
867
+ export const maxDuration = 300;
868
+
869
+ export const { GET, POST, DELETE } = createChatHandler({ bots });
870
+ `;
871
+ }
872
+ async function readText(file) {
873
+ try {
874
+ return await readFile(file, "utf8");
875
+ } catch {
876
+ return "";
877
+ }
878
+ }
879
+ /**
880
+ * Adds the variables `.env.local` and `.env` don't mention yet; never changes existing lines.
881
+ * Returns what it added and the variables that are still empty.
882
+ */
883
+ async function addEnv(project, model) {
884
+ const file = join(project.root, ".env.local");
885
+ const current = await readText(file);
886
+ const known = {
887
+ ...parseEnv(await readText(join(project.root, ".env"))),
888
+ ...parseEnv(current)
889
+ };
890
+ const variables = [
891
+ [
892
+ "OPENAI_API_KEY",
893
+ "Key from a dedicated OpenAI project with a hard spend limit.",
894
+ ""
895
+ ],
896
+ [
897
+ "CHAT_SECRET",
898
+ "Signs visitor sessions. Generated by askdare init; use a different one in production.",
899
+ randomBytes(32).toString("base64url")
900
+ ],
901
+ [
902
+ "ASKDARE_MODEL",
903
+ "A current OpenAI model your project may use.",
904
+ model ?? ""
905
+ ]
906
+ ];
907
+ const lines = [];
908
+ const added = [];
909
+ const values = { ...known };
910
+ for (const [name, comment, value] of variables) {
911
+ if (Object.hasOwn(known, name)) continue;
912
+ lines.push(`# ${comment}`, `${name}=${value}`);
913
+ added.push(name === "CHAT_SECRET" ? "CHAT_SECRET (generated)" : name);
914
+ values[name] = value;
915
+ }
916
+ const empty = variables.map(([name]) => name).filter((name) => values[name] === "");
917
+ if (lines.length === 0) return {
918
+ added,
919
+ empty
920
+ };
921
+ const separator = current === "" || current.endsWith("\n\n") ? "" : current.endsWith("\n") ? "\n" : "\n\n";
922
+ await appendFile(file, `${separator}# askdare\n${lines.join("\n")}\n`, { mode: 384 });
923
+ return {
924
+ added,
925
+ empty
926
+ };
927
+ }
928
+ /** The chat script as `next/script`: loaded after hydration, so React never sees it in the body. */
929
+ const scriptTag = (botId) => `<Script src="/api/chat/widget.js?bot=${botId}" strategy="afterInteractive" />`;
930
+ const SCRIPT_IMPORT = "import Script from 'next/script';";
931
+ /** Adds the `next/script` import after the layout's last import (or at the top). */
932
+ function withScriptImport(text) {
933
+ if (/from\s+['"]next\/script['"]/.test(text)) return text;
934
+ const imports = [...text.matchAll(/^import[^;]*;/gm)];
935
+ const last = imports[imports.length - 1];
936
+ if (last === void 0) return `${SCRIPT_IMPORT}\n\n${text}`;
937
+ const end = last.index + last[0].length;
938
+ return `${text.slice(0, end)}\n${SCRIPT_IMPORT}${text.slice(end)}`;
939
+ }
940
+ /** Puts the loader before `</body>` of the root layout, if there is exactly one. */
941
+ async function addScript(project, botId) {
942
+ const layout = findFile(project.appDir, "layout");
943
+ if (layout === void 0) return "manual";
944
+ const text = await readFile(layout, "utf8");
945
+ if (text.includes("/api/chat/widget.js")) return "present";
946
+ const matches = [...text.matchAll(/^([ \t]*)(.*?)<\/body>/gm)];
947
+ if (matches.length !== 1 || matches[0] === void 0) return "manual";
948
+ const [match] = matches;
949
+ const script = scriptTag(botId);
950
+ const at = match.index + match[0].length - 7;
951
+ const tag = match[2] === "" ? ` ${script}\n${match[1]}` : script;
952
+ await writeFile(layout, withScriptImport(`${text.slice(0, at)}${tag}${text.slice(at)}`));
953
+ return "added";
954
+ }
955
+ const AGENT_RULES = `<!-- BEGIN:askdare-agent-rules -->
956
+
957
+ ## Website chat (askdare)
958
+
959
+ The chat uses \`@boldare/askdare\`. Before changing \`askdare.config.*\`, the \`api/chat\` route or the chat script, read \`node_modules/@boldare/askdare/llms.txt\` and the guide it points to; they match the installed version. After a change, run \`npx @boldare/askdare doctor\`.
960
+
961
+ <!-- END:askdare-agent-rules -->
962
+ `;
963
+ /** Points AI coding agents at the docs in the package, next to the block Next.js writes. */
964
+ async function addAgentRules(project) {
965
+ const file = join(project.root, "AGENTS.md");
966
+ const text = await readText(file);
967
+ if (text.includes("BEGIN:askdare-agent-rules")) return false;
968
+ await writeFile(file, text === "" ? AGENT_RULES : `${text.trimEnd()}\n\n${AGENT_RULES}`);
969
+ return true;
970
+ }
971
+ /**
972
+ * `askdare init`: the route, a bot config, `.env.local`, the script tag and a note for AI coding
973
+ * agents. Never overwrites what is there.
974
+ */
975
+ async function init(options) {
976
+ const { print, botId } = options;
977
+ if (!BOT_ID.test(botId)) throw new InitError("The bot ID may only use a-z, 0-9 and hyphens, up to 48 characters, and must not start with a hyphen.");
978
+ const project = await readProject(options.root);
979
+ if (project.dependencies === void 0) throw new InitError("No package.json here. Run init in the folder of your Next.js app.");
980
+ if (project.dependencies["next"] === void 0) throw new InitError("This is not a Next.js app (no \"next\" dependency). Create one first.");
981
+ if (project.appDir === void 0) throw new InitError("askdare needs the App Router, but there is no app/ or src/app/ folder.");
982
+ print("askdare init\n");
983
+ const extension = project.typescript ? "ts" : "js";
984
+ const existingConfig = findFile(project.root, "askdare.config");
985
+ const config = existingConfig ?? join(project.root, `askdare.config.${extension}`);
986
+ if (existingConfig) print(` = ${shown(project, config)} (kept)`);
987
+ else {
988
+ await writeFile(config, configTemplate(botId));
989
+ print(` + ${shown(project, config)}`);
990
+ }
991
+ const dir = routeDir(project, "/api/chat") ?? "";
992
+ const existingRoute = findFile(dir, "route");
993
+ if (existingRoute) print(` = ${shown(project, existingRoute)} (kept)`);
994
+ else {
995
+ const route = join(dir, `route.${extension}`);
996
+ const configImport = relative(dir, config.replace(/\.[cm]?[jt]sx?$/, "")).split(sep).join("/");
997
+ await mkdir(dirname(route), { recursive: true });
998
+ await writeFile(route, routeTemplate(configImport));
999
+ print(` + ${shown(project, route)}`);
1000
+ }
1001
+ if (!(await Promise.all([".env.local", ".env.production.local"].map((file) => gitIgnores(project.root, file)))).every(Boolean)) {
1002
+ await appendFile(join(project.root, ".gitignore"), "\n# local secrets\n.env*.local\n");
1003
+ print(" + .gitignore: .env*.local");
1004
+ }
1005
+ const { added, empty } = await addEnv(project, options.model);
1006
+ print(added.length > 0 ? ` + .env.local: ${added.join(", ")}` : " = .env.local (kept)");
1007
+ const script = await addScript(project, botId);
1008
+ const layout = findFile(project.appDir, "layout");
1009
+ if (script === "added" && layout) print(` + ${shown(project, layout)}: the chat script`);
1010
+ if (script === "present" && layout) print(` = ${shown(project, layout)} (already loads the chat)`);
1011
+ if (await addAgentRules(project)) print(" + AGENTS.md: where AI coding agents find the askdare docs");
1012
+ const steps = [];
1013
+ const missing = ["@boldare/askdare", "zod"].filter((name) => !project.dependencies?.[name]);
1014
+ if (missing.length > 0) steps.push(`Install: ${addCommand(project, missing)}`);
1015
+ if ((majorOf(project.dependencies["zod"]) ?? 4) < 4) steps.push(`askdare needs zod 4: ${addCommand(project, ["zod@^4.1"])}`);
1016
+ if (empty.length > 0) steps.push(`In .env.local, set ${empty.join(", ")}.`);
1017
+ if (script === "manual") steps.push(`Load the chat on your pages, e.g. in the root layout's <body>:\n ${SCRIPT_IMPORT}\n ${scriptTag(botId)}`);
1018
+ steps.push(`Start the app (${runScript(project, "dev")}), then check it: npx @boldare/askdare doctor`);
1019
+ steps.push(`To embed the chat on another site, add that site to allowedOrigins and paste:\n <script src="https://<your app>/api/chat/widget.js?bot=${botId}" async><\/script>`);
1020
+ print("\nNext:");
1021
+ steps.forEach((step, index) => {
1022
+ print(` ${index + 1}. ${step}`);
1023
+ });
1024
+ }
1025
+ // src/openai/knowledge-sync.ts
1026
+ /** File types OpenAI file search indexes that make sense for a website knowledge base. */
1027
+ const SUPPORTED = /* @__PURE__ */ new Set([
1028
+ ".md",
1029
+ ".mdx",
1030
+ ".txt",
1031
+ ".pdf",
1032
+ ".docx",
1033
+ ".pptx",
1034
+ ".html",
1035
+ ".htm",
1036
+ ".json"
1037
+ ]);
1038
+ /** File search only knows these spellings, so such files are uploaded under the known one. */
1039
+ const UPLOAD_EXTENSIONS = {
1040
+ ".mdx": ".md",
1041
+ ".htm": ".html"
1042
+ };
1043
+ /** Optional per-file titles and URLs, for files without front matter (PDFs, Word…). */
1044
+ const MANIFEST = "askdare.kb.json";
1045
+ const MAX_ATTRIBUTE = 512;
1046
+ function frontMatter(text) {
1047
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
1048
+ if (!match?.[1]) return {};
1049
+ const meta = {};
1050
+ for (const line of match[1].split(/\r?\n/)) {
1051
+ const field = /^(title|url):\s*(.+?)\s*$/.exec(line);
1052
+ if (field?.[1] && field[2]) meta[field[1]] = field[2].replace(/^(['"])(.*)\1$/, "$2");
1053
+ }
1054
+ return meta;
1055
+ }
1056
+ function checkUrl(url, path) {
1057
+ if (url === void 0) return void 0;
1058
+ if (!/^https?:\/\/\S+$/i.test(url) || url.length > MAX_ATTRIBUTE) throw new Error(`${path}: url must be an http(s) URL up to ${MAX_ATTRIBUTE} characters.`);
1059
+ return url;
1060
+ }
1061
+ /** Reads every supported file under `root`, with its hash and the title/URL to cite it by. */
1062
+ async function scanDirectory(root) {
1063
+ let manifest = {};
1064
+ try {
1065
+ manifest = JSON.parse(await readFile(join(root, MANIFEST), "utf8"));
1066
+ } catch (error) {
1067
+ if (error.code !== "ENOENT") throw new Error(`${MANIFEST} is not valid JSON.`, { cause: error });
1068
+ }
1069
+ const documents = [];
1070
+ const skipped = [];
1071
+ const walk = async (dir) => {
1072
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
1073
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
1074
+ const absolutePath = join(dir, entry.name);
1075
+ if (entry.isDirectory()) {
1076
+ await walk(absolutePath);
1077
+ continue;
1078
+ }
1079
+ const path = relative(root, absolutePath).split(sep).join("/");
1080
+ if (path === "askdare.kb.json") continue;
1081
+ if (!entry.isFile() || !SUPPORTED.has(extname(entry.name).toLowerCase())) {
1082
+ skipped.push(path);
1083
+ continue;
1084
+ }
1085
+ if (path.length > MAX_ATTRIBUTE) throw new Error(`${path}: path is longer than ${MAX_ATTRIBUTE}.`);
1086
+ const content = await readFile(absolutePath);
1087
+ const meta = {
1088
+ .../\.mdx?$/i.test(path) ? frontMatter(content.toString("utf8")) : {},
1089
+ ...manifest[path]
1090
+ };
1091
+ documents.push({
1092
+ path,
1093
+ absolutePath,
1094
+ sha256: createHash("sha256").update(content).digest("hex"),
1095
+ title: (meta.title ?? basename(path, extname(path))).slice(0, MAX_ATTRIBUTE),
1096
+ url: checkUrl(meta.url, path)
1097
+ });
1098
+ }
1099
+ };
1100
+ await walk(root);
1101
+ documents.sort((a, b) => a.path.localeCompare(b.path));
1102
+ return {
1103
+ documents,
1104
+ skipped
1105
+ };
1106
+ }
1107
+ /** Compares the folder with the store. Pure, so the diff is easy to test and to show as a dry run. */
1108
+ function planSync(local, remote) {
1109
+ const managed = remote.filter((file) => file.path !== void 0 && file.sha256 !== void 0);
1110
+ const byPath = /* @__PURE__ */ new Map();
1111
+ for (const file of managed) byPath.set(file.path ?? "", [...byPath.get(file.path ?? "") ?? [], file]);
1112
+ const upload = [];
1113
+ const retag = [];
1114
+ const remove = [];
1115
+ let unchanged = 0;
1116
+ for (const document of local) {
1117
+ const copies = byPath.get(document.path) ?? [];
1118
+ byPath.delete(document.path);
1119
+ const keep = copies.find((copy) => copy.sha256 === document.sha256 && copy.status === "completed");
1120
+ remove.push(...copies.filter((copy) => copy !== keep));
1121
+ if (!keep) upload.push(document);
1122
+ else if (keep.title !== document.title || keep.url !== document.url) retag.push({
1123
+ document,
1124
+ fileId: keep.fileId
1125
+ });
1126
+ else unchanged += 1;
1127
+ }
1128
+ for (const leftovers of byPath.values()) remove.push(...leftovers);
1129
+ return {
1130
+ upload,
1131
+ retag,
1132
+ remove,
1133
+ unchanged,
1134
+ unmanaged: remote.length - managed.length
1135
+ };
1136
+ }
1137
+ const attributesOf = (document) => ({
1138
+ path: document.path,
1139
+ sha256: document.sha256,
1140
+ title: document.title,
1141
+ ...document.url ? { url: document.url } : {}
1142
+ });
1143
+ const text = (value) => typeof value === "string" ? value : void 0;
1144
+ async function inBatches(items, size, run) {
1145
+ for (let index = 0; index < items.length; index += size) await Promise.all(items.slice(index, index + size).map(run));
1146
+ }
1147
+ /**
1148
+ * Makes a vector store mirror a local folder. The store is the source of truth: each file carries
1149
+ * its path and content hash as attributes, so re-running is a no-op and nothing is stored locally.
1150
+ */
1151
+ async function syncKnowledge(client, options) {
1152
+ const log = options.log ?? (() => {});
1153
+ const { documents, skipped } = await scanDirectory(options.root);
1154
+ let vectorStoreId = options.vectorStoreId;
1155
+ let created = false;
1156
+ const remote = [];
1157
+ if (vectorStoreId) for await (const file of client.vectorStores.files.list(vectorStoreId, { limit: 100 })) remote.push({
1158
+ fileId: file.id,
1159
+ status: file.status,
1160
+ path: text(file.attributes?.["path"]),
1161
+ sha256: text(file.attributes?.["sha256"]),
1162
+ title: text(file.attributes?.["title"]),
1163
+ url: text(file.attributes?.["url"])
1164
+ });
1165
+ const plan = planSync(documents, remote);
1166
+ if (options.dryRun) return {
1167
+ vectorStoreId,
1168
+ created,
1169
+ plan,
1170
+ skipped,
1171
+ failed: []
1172
+ };
1173
+ if (!vectorStoreId) {
1174
+ vectorStoreId = (await client.vectorStores.create({ name: options.name ?? "askdare knowledge" })).id;
1175
+ created = true;
1176
+ log(`Created vector store ${vectorStoreId}.`);
1177
+ }
1178
+ const storeId = vectorStoreId;
1179
+ const failed = [];
1180
+ await inBatches(plan.upload, 4, async (document) => {
1181
+ const extension = extname(document.path);
1182
+ const name = `${basename(document.path, extension)}${UPLOAD_EXTENSIONS[extension.toLowerCase()] ?? extension}`;
1183
+ let fileId;
1184
+ try {
1185
+ fileId = (await client.files.create({
1186
+ file: await toFile(await readFile(document.absolutePath), name),
1187
+ purpose: "assistants"
1188
+ })).id;
1189
+ const attached = await client.vectorStores.files.createAndPoll(storeId, {
1190
+ file_id: fileId,
1191
+ attributes: attributesOf(document)
1192
+ });
1193
+ if (attached.status === "completed") {
1194
+ log(`+ ${document.path}`);
1195
+ return;
1196
+ }
1197
+ failed.push({
1198
+ path: document.path,
1199
+ reason: attached.last_error?.message ?? attached.status
1200
+ });
1201
+ } catch (error) {
1202
+ failed.push({
1203
+ path: document.path,
1204
+ reason: error instanceof Error ? error.message : String(error)
1205
+ });
1206
+ }
1207
+ if (fileId) {
1208
+ const id = fileId;
1209
+ await client.vectorStores.files.delete(id, { vector_store_id: storeId }).catch(() => {});
1210
+ await client.files.delete(id).catch(() => {});
1211
+ }
1212
+ });
1213
+ await inBatches(plan.retag, 4, async ({ document, fileId }) => {
1214
+ await client.vectorStores.files.update(fileId, {
1215
+ vector_store_id: storeId,
1216
+ attributes: attributesOf(document)
1217
+ });
1218
+ log(`~ ${document.path}`);
1219
+ });
1220
+ await inBatches(plan.remove, 4, async (file) => {
1221
+ for (const remove of [() => client.vectorStores.files.delete(file.fileId, { vector_store_id: storeId }), () => client.files.delete(file.fileId)]) try {
1222
+ await remove();
1223
+ } catch (error) {
1224
+ if (!(error instanceof NotFoundError)) throw error;
1225
+ }
1226
+ log(`- ${file.path ?? file.fileId}`);
1227
+ });
1228
+ return {
1229
+ vectorStoreId: storeId,
1230
+ created,
1231
+ plan,
1232
+ skipped,
1233
+ failed
1234
+ };
1235
+ }
1236
+ /** Entry point for the CLI, which may not import `openai` itself (ADR-0002). */
1237
+ function syncKnowledgeFolder(apiKey, options) {
1238
+ return syncKnowledge(new OpenAI({
1239
+ apiKey,
1240
+ maxRetries: 3,
1241
+ timeout: 12e4
1242
+ }), options);
1243
+ }
1244
+ // src/cli/usage.ts
1245
+ /** A mistake in the command line: printed with the usage text. */
1246
+ var UsageError = class extends Error {};
1247
+ // src/cli/kb-sync.ts
1248
+ const KB_USAGE = `Usage: npx @boldare/askdare kb sync <folder> [options]
1249
+
1250
+ Makes an OpenAI vector store mirror a folder of documents
1251
+ (.md, .mdx, .txt, .pdf, .docx, .pptx, .html, .htm, .json). Re-running is a no-op.
1252
+ Titles and URLs for citations come from markdown front matter or askdare.kb.json.
1253
+
1254
+ Options:
1255
+ --store <vs_…> Vector store to sync (default: $KB_VECTOR_STORE_ID; created when absent)
1256
+ --name <name> Name for a newly created store
1257
+ --env-file <path> Load environment variables from this file
1258
+ (default: .env.local and .env in the current folder, when present)
1259
+ --dry-run Show what would change without changing anything
1260
+ -h, --help Show this help
1261
+
1262
+ Needs OPENAI_API_KEY in the environment.
1263
+ `;
1264
+ const STORE_ID = /^vs_[A-Za-z0-9_-]+$/;
1265
+ function report(result, dryRun) {
1266
+ const { plan } = result;
1267
+ console.log(`${dryRun ? "Would sync" : "Synced"}: ${plan.upload.length} added, ${plan.retag.length} retagged, ${plan.remove.length} removed, ${plan.unchanged} unchanged.`);
1268
+ if (dryRun) {
1269
+ for (const document of plan.upload) console.log(` + ${document.path}`);
1270
+ for (const { document } of plan.retag) console.log(` ~ ${document.path}`);
1271
+ for (const file of plan.remove) console.log(` - ${file.path ?? file.fileId}`);
1272
+ }
1273
+ if (result.skipped.length > 0) console.log(`Skipped (unsupported type): ${result.skipped.join(", ")}`);
1274
+ if (plan.unmanaged > 0) console.log(`Left alone: ${plan.unmanaged} file(s) in the store not uploaded by askdare.`);
1275
+ for (const failure of result.failed) console.error(`Failed: ${failure.path} (${failure.reason})`);
1276
+ if (result.created) {
1277
+ console.log("\nAdd the new store to your environment (for example .env.local):");
1278
+ console.log(` KB_VECTOR_STORE_ID=${result.vectorStoreId}`);
1279
+ }
1280
+ }
1281
+ async function kbSync(args) {
1282
+ const { values, positionals } = parseArgs({
1283
+ args: [...args],
1284
+ allowPositionals: true,
1285
+ options: {
1286
+ store: { type: "string" },
1287
+ name: { type: "string" },
1288
+ "env-file": { type: "string" },
1289
+ "dry-run": {
1290
+ type: "boolean",
1291
+ default: false
1292
+ },
1293
+ help: {
1294
+ type: "boolean",
1295
+ short: "h",
1296
+ default: false
1297
+ }
1298
+ }
1299
+ });
1300
+ if (values.help) {
1301
+ console.log(KB_USAGE);
1302
+ return 0;
1303
+ }
1304
+ if (positionals.length !== 1) throw new UsageError("Expected exactly one folder.");
1305
+ const envFiles = values["env-file"] ? [values["env-file"]] : [".env.local", ".env"].filter((file) => existsSync(file));
1306
+ for (const file of envFiles) process.loadEnvFile(file);
1307
+ if (envFiles.length > 0) console.log(`Loaded ${envFiles.join(", ")}.`);
1308
+ const apiKey = process.env["OPENAI_API_KEY"];
1309
+ if (!apiKey) throw new UsageError("OPENAI_API_KEY is not set.");
1310
+ const vectorStoreId = values.store ?? (process.env["KB_VECTOR_STORE_ID"] || void 0);
1311
+ if (vectorStoreId !== void 0 && !STORE_ID.test(vectorStoreId)) throw new UsageError("The vector store ID must look like vs_….");
1312
+ const dryRun = values["dry-run"];
1313
+ const result = await syncKnowledgeFolder(apiKey, {
1314
+ root: resolve(positionals[0] ?? "."),
1315
+ vectorStoreId,
1316
+ ...values.name ? { name: values.name } : {},
1317
+ dryRun,
1318
+ log: (line) => console.log(line)
1319
+ });
1320
+ report(result, dryRun);
1321
+ return result.failed.length > 0 ? 1 : 0;
1322
+ }
1323
+ // src/cli/index.ts
1324
+ const USAGE = `Usage: npx @boldare/askdare <command> [options]
1325
+
1326
+ Commands:
1327
+ init Add askdare to the Next.js app in this folder: a route, a bot
1328
+ config, .env.local and the chat script. Never overwrites files.
1329
+ --bot <id> Bot ID (default: support)
1330
+ --model <id> OpenAI model to put in .env.local as ASKDARE_MODEL
1331
+
1332
+ doctor Check the project, its environment and the running app: the
1333
+ OpenAI key, models, vector store, permissions and headers.
1334
+ --url <url> Where the app runs (default: http://localhost:3000)
1335
+ --base-path <p> Path of the askdare route (default: /api/chat)
1336
+ --env-file <path> Read CHAT_SECRET and the rest from this file
1337
+ (default: .env.development.local, .env.local,
1338
+ .env.development and .env, as next dev reads them)
1339
+ --origin <url> Check that this site may embed the chat (the bot's
1340
+ security.allowedOrigins), e.g. https://www.example.com
1341
+
1342
+ kb sync <folder> Make an OpenAI vector store mirror a folder of documents.
1343
+ See: npx @boldare/askdare kb sync --help
1344
+
1345
+ -v, --version Show the installed version
1346
+ -h, --help Show this help
1347
+ `;
1348
+ /** The package's own version: the CLI ships next to its package.json (dist/cli.js). */
1349
+ function version() {
1350
+ return createRequire(import.meta.url)("../package.json").version;
1351
+ }
1352
+ async function runInit(args) {
1353
+ const { values } = parseArgs({
1354
+ args: [...args],
1355
+ options: {
1356
+ bot: {
1357
+ type: "string",
1358
+ default: "support"
1359
+ },
1360
+ model: { type: "string" },
1361
+ help: {
1362
+ type: "boolean",
1363
+ short: "h",
1364
+ default: false
1365
+ }
1366
+ }
1367
+ });
1368
+ if (values.help) {
1369
+ console.log(USAGE);
1370
+ return 0;
1371
+ }
1372
+ try {
1373
+ await init({
1374
+ root: process.cwd(),
1375
+ botId: values.bot,
1376
+ model: values.model,
1377
+ print: (line) => console.log(line)
1378
+ });
1379
+ } catch (error) {
1380
+ if (error instanceof InitError) {
1381
+ console.error(`askdare init: ${error.message}`);
1382
+ return 1;
1383
+ }
1384
+ throw error;
1385
+ }
1386
+ return 0;
1387
+ }
1388
+ async function runDoctor(args) {
1389
+ const { values } = parseArgs({
1390
+ args: [...args],
1391
+ options: {
1392
+ url: {
1393
+ type: "string",
1394
+ default: "http://localhost:3000"
1395
+ },
1396
+ "base-path": {
1397
+ type: "string",
1398
+ default: "/api/chat"
1399
+ },
1400
+ "env-file": { type: "string" },
1401
+ origin: { type: "string" },
1402
+ help: {
1403
+ type: "boolean",
1404
+ short: "h",
1405
+ default: false
1406
+ }
1407
+ }
1408
+ });
1409
+ if (values.help) {
1410
+ console.log(USAGE);
1411
+ return 0;
1412
+ }
1413
+ let target;
1414
+ try {
1415
+ target = new URL(values.url);
1416
+ } catch {
1417
+ throw new UsageError("--url must be a URL such as https://your-app.example.com.");
1418
+ }
1419
+ if (target.protocol !== "https:" && !(target.protocol === "http:" && isLocal(values.url))) throw new UsageError("--url must use https (plain http only for localhost).");
1420
+ if (!/^(\/[a-zA-Z0-9._~-]+)+$/.test(values["base-path"])) throw new UsageError("--base-path must look like /api/chat.");
1421
+ let origin;
1422
+ if (values.origin !== void 0) try {
1423
+ origin = new URL(values.origin).origin;
1424
+ } catch {
1425
+ throw new UsageError("--origin must be a URL such as https://www.example.com.");
1426
+ }
1427
+ return await doctor({
1428
+ root: process.cwd(),
1429
+ url: values.url,
1430
+ basePath: values["base-path"],
1431
+ envFile: values["env-file"],
1432
+ origin,
1433
+ print: (line) => console.log(line)
1434
+ }) > 0 ? 1 : 0;
1435
+ }
1436
+ async function main(argv) {
1437
+ const [command, ...rest] = argv;
1438
+ if (command === "init") return runInit(rest);
1439
+ if (command === "doctor") return runDoctor(rest);
1440
+ if (command === "kb" && rest[0] === "sync") return kbSync(rest.slice(1));
1441
+ if (command === "-v" || command === "--version") {
1442
+ console.log(version());
1443
+ return 0;
1444
+ }
1445
+ if (command === void 0 || command === "-h" || command === "--help") {
1446
+ console.log(USAGE);
1447
+ return 0;
1448
+ }
1449
+ throw new UsageError(`Unknown command "${argv.join(" ")}".`);
1450
+ }
1451
+ main(process.argv.slice(2)).then((code) => {
1452
+ process.exitCode = code;
1453
+ }, (error) => {
1454
+ const parseError = error.code?.toString().startsWith("ERR_PARSE_ARGS");
1455
+ if (error instanceof UsageError || parseError) {
1456
+ const usage = process.argv[2] === "kb" ? KB_USAGE : USAGE;
1457
+ console.error(`${error.message}\n\n${usage}`);
1458
+ } else console.error(`askdare: ${error instanceof Error ? error.message : String(error)}`);
1459
+ process.exitCode = 1;
1460
+ });
1461
+ export {};