dazzer-connect 0.1.1 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +61 -2
  2. package/dist/index.js +1940 -394
  3. package/package.json +7 -7
package/dist/index.js CHANGED
@@ -4,6 +4,392 @@
4
4
  import { createRequire } from "node:module";
5
5
  import { Command } from "commander";
6
6
 
7
+ // src/commands/add.ts
8
+ import { open, stat as statPath } from "node:fs/promises";
9
+ import { basename, extname } from "node:path";
10
+
11
+ // src/lib/output.ts
12
+ function writeOut(message) {
13
+ process.stdout.write(`${message}
14
+ `);
15
+ }
16
+ function writeErr(message) {
17
+ process.stderr.write(`${message}
18
+ `);
19
+ }
20
+ var CANNOT_REACH_DAZZER = "I can't reach Dazzer right now \u2014 nothing was sent. Try again shortly.";
21
+ var SIGN_IN_NO_LONGER_VALID = "Your Dazzer sign-in is no longer valid. Reconnect your client (or run `dazzer auth login`).";
22
+ function printDeviceInstructions(deviceAuth) {
23
+ writeOut("");
24
+ if (deviceAuth.verification_uri_complete) {
25
+ writeOut("To finish signing in, open this link in your browser:");
26
+ writeOut(` ${deviceAuth.verification_uri_complete}`);
27
+ } else {
28
+ writeOut("To finish signing in, open this page in your browser:");
29
+ writeOut(` ${deviceAuth.verification_uri}`);
30
+ writeOut("");
31
+ writeOut("Then enter this code:");
32
+ writeOut(` ${deviceAuth.user_code}`);
33
+ }
34
+ writeOut("");
35
+ writeOut("Waiting for you to approve in the browser...");
36
+ }
37
+ function printBrowserOpening(url) {
38
+ writeOut("");
39
+ writeOut("Opening your browser to sign in\u2026");
40
+ writeOut("If it doesn't open automatically, paste this into your browser:");
41
+ writeOut(` ${url}`);
42
+ writeOut("");
43
+ }
44
+ function printLoginSuccess(keychainOk) {
45
+ writeOut("");
46
+ writeOut("Signed in successfully.");
47
+ if (!keychainOk) {
48
+ writeErr("Warning: could not save tokens to the OS keychain. Use the config below.");
49
+ }
50
+ }
51
+ function printConnectGuidance(mcpServerUrl, accessToken) {
52
+ const url = mcpServerUrl.replace(/\/$/, "");
53
+ writeOut("");
54
+ writeOut("Connect an AI client to Dazzer.");
55
+ writeOut("");
56
+ writeOut("Recommended \u2014 add this server by URL and sign in from your client;");
57
+ writeOut("it stays connected and renews itself:");
58
+ writeOut("");
59
+ writeOut("{");
60
+ writeOut(' "mcpServers": {');
61
+ writeOut(' "dazzer": {');
62
+ writeOut(` "url": "${url}"`);
63
+ writeOut(" }");
64
+ writeOut(" }");
65
+ writeOut("}");
66
+ writeOut("");
67
+ writeOut("Fallback \u2014 only if your client cannot sign in and needs a pasted token.");
68
+ writeOut("This token expires in about an hour; re-run `dazzer auth login` to renew:");
69
+ writeOut("");
70
+ writeOut("{");
71
+ writeOut(' "mcpServers": {');
72
+ writeOut(' "dazzer": {');
73
+ writeOut(` "url": "${url}",`);
74
+ writeOut(' "headers": {');
75
+ writeOut(` "Authorization": "Bearer ${accessToken}"`);
76
+ writeOut(" }");
77
+ writeOut(" }");
78
+ writeOut(" }");
79
+ writeOut("}");
80
+ writeOut("");
81
+ }
82
+ function printAuthStatus(params) {
83
+ switch (params.state) {
84
+ case "signed-out":
85
+ writeOut("Not signed in. Run: dazzer auth login");
86
+ return;
87
+ case "store-unreadable":
88
+ writeOut("Cannot tell \u2014 this machine's credential store could not be opened.");
89
+ writeOut(" Common on servers and containers, which often have none.");
90
+ writeOut(" Run: dazzer auth login \u2014 it prints a pass you can paste in.");
91
+ return;
92
+ case "expired":
93
+ writeOut("Your sign-in has expired or been revoked. Run: dazzer auth login");
94
+ return;
95
+ case "unverified":
96
+ writeOut(
97
+ "Signed in on this device, but the sign-in server could not be reached to confirm it."
98
+ );
99
+ if (params.issuer) {
100
+ writeOut(` Signed in at: ${params.issuer}`);
101
+ }
102
+ if (params.lastKnownExpiresAt) {
103
+ writeOut(` Last confirmed expiry: ${new Date(params.lastKnownExpiresAt).toISOString()}`);
104
+ }
105
+ writeOut(" Check your connection, then run this again to confirm.");
106
+ return;
107
+ case "signed-in":
108
+ writeOut("Signed in \u2014 confirmed just now.");
109
+ if (params.issuer) {
110
+ writeOut(` Signed in at: ${params.issuer}`);
111
+ }
112
+ if (params.expiresAt) {
113
+ writeOut(
114
+ ` This token expires ${new Date(params.expiresAt).toISOString()} and refreshes automatically.`
115
+ );
116
+ }
117
+ return;
118
+ }
119
+ }
120
+ function printLogoutSuccess() {
121
+ writeOut("Signed out.");
122
+ }
123
+ function printLogoutStoreLocked(offerForget) {
124
+ writeErr(
125
+ "Not signed out. This machine's credential store could not be opened, so a sign-in saved here may still work."
126
+ );
127
+ writeErr(
128
+ offerForget ? " Unlock it and try again. If this machine has no credential store, run: dazzer auth logout --forget-this-machine" : " Unlock it and try again."
129
+ );
130
+ }
131
+ function printLogoutNotCancelledYet() {
132
+ writeErr(
133
+ "Not signed out. Your sign-in could not be cancelled right now, so it is still saved here."
134
+ );
135
+ writeErr(" Try again: dazzer auth logout");
136
+ }
137
+ function printLogoutCannotCancel() {
138
+ writeOut(
139
+ "Signed out on this device, but your sign-in could not be cancelled at the server. It stops working once it goes 30 days unused."
140
+ );
141
+ }
142
+ function printLogoutNothingFound() {
143
+ writeOut("No saved sign-in was found here to cancel.");
144
+ }
145
+ function printLogoutIncomplete(cancelledAtServer) {
146
+ if (cancelledAtServer) {
147
+ writeErr(
148
+ "Not fully signed out. Your sign-in is cancelled, but this machine's credential store would not let it be removed, so what is saved here can work for up to an hour."
149
+ );
150
+ } else {
151
+ writeErr(
152
+ "Not signed out. This machine's credential store would not let your saved sign-in be removed, so it may still work from here."
153
+ );
154
+ }
155
+ writeErr(" Try again: dazzer auth logout");
156
+ }
157
+ function printLogoutForgotten() {
158
+ writeOut(
159
+ "Forgot this machine's sign-in record. Nothing was cancelled, and any sign-in still saved in this machine's credential store stays there."
160
+ );
161
+ }
162
+ function printForgetFailed(configDir) {
163
+ writeErr("Not forgotten: this machine's sign-in record could not be removed.");
164
+ writeErr(` Remove it by hand: ${configDir}`);
165
+ }
166
+ function printLogoutPassLeft() {
167
+ writeOut(
168
+ "No sign-in that can be renewed is saved here. A short-lived pass this machine's credential store would not remove stops working within the hour."
169
+ );
170
+ }
171
+ function printLogoutBadSettings() {
172
+ writeErr(
173
+ "Not signed out. DAZZER_AUTH_ISSUER or DAZZER_AUTH_CLIENT_ID is not a valid setting, so there is nowhere to cancel your sign-in."
174
+ );
175
+ writeErr(" Fix it, then run: dazzer auth logout");
176
+ }
177
+ function printForgetRefused() {
178
+ writeErr(
179
+ "Not forgotten: a sign-in may be saved in this machine's credential store. Unlock it if needed, then run: dazzer auth logout"
180
+ );
181
+ }
182
+ function fail(message) {
183
+ writeErr(`Error: ${message}`);
184
+ process.exit(1);
185
+ }
186
+
187
+ // src/lib/refresh.ts
188
+ var RefreshRejectedError = class extends Error {
189
+ constructor(oauthError) {
190
+ super(`Could not refresh token: ${oauthError}`);
191
+ this.oauthError = oauthError;
192
+ this.name = "RefreshRejectedError";
193
+ }
194
+ oauthError;
195
+ };
196
+ async function refreshAccessToken(options) {
197
+ const fetchFn = options.fetchFn ?? fetch;
198
+ const params = new URLSearchParams({
199
+ grant_type: "refresh_token",
200
+ client_id: options.clientId,
201
+ refresh_token: options.refreshToken
202
+ });
203
+ if (options.resource) {
204
+ params.set("resource", options.resource);
205
+ }
206
+ const res = await fetchFn(options.discovery.tokenEndpoint, {
207
+ method: "POST",
208
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
209
+ body: params.toString()
210
+ });
211
+ const body = await res.json();
212
+ if (!res.ok) {
213
+ const error = typeof body.error === "string" ? body.error : "refresh_failed";
214
+ throw new RefreshRejectedError(error);
215
+ }
216
+ const accessToken = body.access_token;
217
+ const refreshToken = body.refresh_token;
218
+ if (typeof accessToken !== "string") {
219
+ throw new Error("The authorization server returned an unexpected refresh response.");
220
+ }
221
+ const newRefresh = typeof refreshToken === "string" ? refreshToken : options.refreshToken;
222
+ const expiresIn = typeof body.expires_in === "number" ? body.expires_in : null;
223
+ await options.tokenStore.save({ accessToken, refreshToken: newRefresh });
224
+ return { accessToken, refreshToken: newRefresh, expiresIn };
225
+ }
226
+ async function getValidAccessToken(options) {
227
+ const stored = await options.tokenStore.load();
228
+ if (!stored) {
229
+ return { status: "signed-out" };
230
+ }
231
+ try {
232
+ const refreshed = await refreshAccessToken({ ...options, refreshToken: stored.refreshToken });
233
+ return { status: "refreshed", expiresIn: refreshed.expiresIn };
234
+ } catch (err) {
235
+ if (err instanceof RefreshRejectedError) {
236
+ return { status: "rejected" };
237
+ }
238
+ return { status: "unreachable" };
239
+ }
240
+ }
241
+ async function persistLoginTokens(tokenStore, tokens) {
242
+ if (!tokens.refresh_token) {
243
+ throw new Error("The authorization server did not return a refresh token.");
244
+ }
245
+ const stored = {
246
+ accessToken: tokens.access_token,
247
+ refreshToken: tokens.refresh_token
248
+ };
249
+ await tokenStore.save(stored);
250
+ return stored;
251
+ }
252
+
253
+ // src/lib/api-client.ts
254
+ function assertHttpsServiceAddress(address) {
255
+ let parsed;
256
+ try {
257
+ parsed = new URL(address);
258
+ } catch {
259
+ throw new Error(`Not a valid Dazzer address: ${address}. Check DAZZER_API_BASE_URL.`);
260
+ }
261
+ if (parsed.protocol !== "https:") {
262
+ throw new Error(
263
+ `The Dazzer address must be an https URL (got ${parsed.protocol}//). Check DAZZER_API_BASE_URL.`
264
+ );
265
+ }
266
+ return address.replace(/\/$/, "");
267
+ }
268
+ var CLIENT_HEADER = "x-dazzer-client";
269
+ function readOfferedPlaces(offered) {
270
+ if (!Array.isArray(offered)) return [];
271
+ return offered.flatMap((place) => {
272
+ if (typeof place !== "object" || place === null) return [];
273
+ const { scope_id: scopeId, space } = place;
274
+ if (typeof scopeId !== "number" || !Number.isFinite(scopeId)) return [];
275
+ if (typeof space !== "string" || space.trim() === "") return [];
276
+ return [{ scopeId, space }];
277
+ });
278
+ }
279
+ async function readJson(res) {
280
+ try {
281
+ return await res.json();
282
+ } catch {
283
+ return void 0;
284
+ }
285
+ }
286
+ function refusalDetail(body) {
287
+ if (typeof body !== "object" || body === null) return void 0;
288
+ const flat = body.detail;
289
+ if (typeof flat === "string" && flat !== "") return flat;
290
+ const enveloped = body.error;
291
+ if (typeof enveloped === "object" && enveloped !== null) {
292
+ const message = enveloped.message;
293
+ if (typeof message === "string" && message !== "") return message;
294
+ }
295
+ return void 0;
296
+ }
297
+ function refusalCode(body) {
298
+ if (typeof body !== "object" || body === null) return void 0;
299
+ const flat = body.code;
300
+ if (typeof flat === "string" && flat !== "") return flat;
301
+ const enveloped = body.error;
302
+ if (typeof enveloped === "object" && enveloped !== null) {
303
+ const code = enveloped.code;
304
+ if (typeof code === "string" && code !== "") return code;
305
+ }
306
+ return void 0;
307
+ }
308
+ function refusalPlaces(body) {
309
+ if (typeof body !== "object" || body === null) return {};
310
+ const offered = body.choices;
311
+ const places = readOfferedPlaces(offered);
312
+ const keep = body.keep_to_yourself;
313
+ return {
314
+ ...places.length > 0 ? { choices: places } : {},
315
+ ...typeof keep === "boolean" ? { keepToYourself: keep } : {}
316
+ };
317
+ }
318
+ async function resolveCredential(auth2) {
319
+ let held;
320
+ try {
321
+ held = await auth2.tokenStore.load();
322
+ } catch {
323
+ return { status: "store-unreadable" };
324
+ }
325
+ if (!held) return { status: "signed-out" };
326
+ let state;
327
+ try {
328
+ state = await getValidAccessToken(auth2);
329
+ } catch {
330
+ return { status: "store-unreadable" };
331
+ }
332
+ switch (state.status) {
333
+ case "signed-out":
334
+ return { status: "signed-out" };
335
+ case "rejected":
336
+ return { status: "sign-in-invalid" };
337
+ case "unreachable":
338
+ return { held: held.accessToken };
339
+ case "refreshed": {
340
+ try {
341
+ const rotated = await auth2.tokenStore.load();
342
+ return rotated ? { held: rotated.accessToken } : { status: "signed-out" };
343
+ } catch {
344
+ return { status: "store-unreadable" };
345
+ }
346
+ }
347
+ }
348
+ }
349
+ async function callDazzer(request) {
350
+ const base = assertHttpsServiceAddress(request.baseUrl);
351
+ const credential = await resolveCredential(request.auth);
352
+ if (!("held" in credential)) {
353
+ if (credential.status === "sign-in-invalid") {
354
+ return { status: "sign-in-invalid", message: SIGN_IN_NO_LONGER_VALID };
355
+ }
356
+ return { status: credential.status };
357
+ }
358
+ const fetchFn = request.fetchFn ?? fetch;
359
+ const headers = {
360
+ accept: "application/json",
361
+ authorization: `Bearer ${credential.held}`,
362
+ [CLIENT_HEADER]: request.client
363
+ };
364
+ if (request.body !== void 0) headers["content-type"] = "application/json";
365
+ let res;
366
+ try {
367
+ res = await fetchFn(`${base}${request.path}`, {
368
+ method: request.method ?? "POST",
369
+ headers,
370
+ body: request.body === void 0 ? void 0 : JSON.stringify(request.body)
371
+ });
372
+ } catch {
373
+ return { status: "unreachable", message: CANNOT_REACH_DAZZER };
374
+ }
375
+ if (res.status === 401) {
376
+ return { status: "sign-in-invalid", message: SIGN_IN_NO_LONGER_VALID };
377
+ }
378
+ const body = await readJson(res);
379
+ if (!res.ok) {
380
+ const detail = refusalDetail(body);
381
+ const code = refusalCode(body);
382
+ return {
383
+ status: "refused",
384
+ httpStatus: res.status,
385
+ ...detail === void 0 ? {} : { detail },
386
+ ...code === void 0 ? {} : { code },
387
+ ...refusalPlaces(body)
388
+ };
389
+ }
390
+ return { status: "ok", httpStatus: res.status, body };
391
+ }
392
+
7
393
  // src/lib/config-store.ts
8
394
  import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
9
395
  import { join } from "node:path";
@@ -22,12 +408,26 @@ function assertHttpsIssuer(issuer) {
22
408
  }
23
409
  return issuer.replace(/\/$/, "");
24
410
  }
25
- async function discoverEndpoints(issuer) {
411
+ var DiscoveryAnswered = class extends Error {
412
+ constructor(status) {
413
+ super(`Could not reach the authorization server (${status}). Check --issuer.`);
414
+ this.status = status;
415
+ this.name = "DiscoveryAnswered";
416
+ }
417
+ status;
418
+ };
419
+ var DiscoveryUnusable = class extends Error {
420
+ constructor(message) {
421
+ super(message);
422
+ this.name = "DiscoveryUnusable";
423
+ }
424
+ };
425
+ async function discoverEndpoints(issuer, fetchFn = fetch) {
26
426
  const normalized = assertHttpsIssuer(issuer);
27
427
  const url = `${normalized}/.well-known/oauth-authorization-server`;
28
- const res = await fetch(url);
428
+ const res = await fetchFn(url);
29
429
  if (!res.ok) {
30
- throw new Error(`Could not reach the authorization server (${res.status}). Check --issuer.`);
430
+ throw new DiscoveryAnswered(res.status);
31
431
  }
32
432
  const body = await res.json();
33
433
  const authorizationEndpoint = body.authorization_endpoint;
@@ -35,13 +435,15 @@ async function discoverEndpoints(issuer) {
35
435
  const tokenEndpoint = body.token_endpoint;
36
436
  const revocationEndpoint = body.revocation_endpoint;
37
437
  if (typeof tokenEndpoint !== "string" || tokenEndpoint === "") {
38
- throw new Error("This authorization server is missing a token endpoint.");
438
+ throw new DiscoveryUnusable("This authorization server is missing a token endpoint.");
39
439
  }
40
440
  const hasAuthorization = typeof authorizationEndpoint === "string" && authorizationEndpoint !== "";
41
441
  const hasDevice = typeof deviceAuthorizationEndpoint === "string" && deviceAuthorizationEndpoint !== "";
42
442
  const hasRevocation = typeof revocationEndpoint === "string" && revocationEndpoint !== "";
43
443
  if (!hasAuthorization && !hasDevice) {
44
- throw new Error("This authorization server does not support browser or device sign-in.");
444
+ throw new DiscoveryUnusable(
445
+ "This authorization server does not support browser or device sign-in."
446
+ );
45
447
  }
46
448
  return {
47
449
  issuer: typeof body.issuer === "string" ? body.issuer : normalized,
@@ -66,8 +468,9 @@ async function saveAuthMetadata(meta) {
66
468
  throw new Error("The authorization server returned an unusable client id.");
67
469
  }
68
470
  const expiresAt = typeof meta.expiresAt === "number" && Number.isFinite(meta.expiresAt) ? meta.expiresAt : null;
471
+ const savedToStore = typeof meta.savedToStore === "boolean" ? meta.savedToStore : void 0;
69
472
  await ensureConfigDir();
70
- const checked = { issuer, clientId, expiresAt };
473
+ const checked = { issuer, clientId, expiresAt, savedToStore };
71
474
  await writeFile(CONFIG_FILE, `${JSON.stringify(checked, null, 2)}
72
475
  `, "utf8");
73
476
  }
@@ -78,15 +481,1222 @@ async function loadAuthMetadata() {
78
481
  if (typeof parsed.issuer !== "string" || typeof parsed.clientId !== "string") {
79
482
  return null;
80
483
  }
81
- return parsed;
484
+ const { savedToStore, ...rest } = parsed;
485
+ return typeof savedToStore === "boolean" ? { ...rest, savedToStore } : rest;
82
486
  } catch {
83
487
  return null;
84
488
  }
85
489
  }
86
- async function clearAuthMetadata() {
87
- try {
88
- await unlink(CONFIG_FILE);
89
- } catch {
490
+ async function clearAuthMetadata() {
491
+ try {
492
+ await unlink(CONFIG_FILE);
493
+ } catch (error) {
494
+ if (error.code !== "ENOENT") throw error;
495
+ }
496
+ }
497
+ function getConfigDir() {
498
+ return paths.config;
499
+ }
500
+
501
+ // src/lib/send.ts
502
+ var DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;
503
+ var DEFAULT_ATTEMPTS = 5;
504
+ var BLOCK_BYTES = 256 * 1024;
505
+ var RESUMABLE_START = "x-goog-resumable";
506
+ function connectionDroppedLine(arrived, total) {
507
+ const percent = total > 0 ? Math.floor(arrived / total * 100) : 0;
508
+ return `Connection dropped at ${percent}% \u2014 picking up where it stopped.`;
509
+ }
510
+ function pieceEnd(start, total, chunkBytes) {
511
+ const proposed = Math.min(start + chunkBytes, total);
512
+ if (proposed >= total) return total;
513
+ const aligned = Math.floor(proposed / BLOCK_BYTES) * BLOCK_BYTES;
514
+ return aligned > start ? aligned : proposed;
515
+ }
516
+ function rangeReached(res) {
517
+ const range = res.headers.get("range");
518
+ if (range === null || range === "") return 0;
519
+ const match = /bytes=0-(\d+)/i.exec(range);
520
+ if (match?.[1] === void 0) return null;
521
+ return Number(match[1]) + 1;
522
+ }
523
+ async function openSession(uploadUrl, contentType, fetchFn) {
524
+ let res;
525
+ try {
526
+ res = await fetchFn(uploadUrl, {
527
+ method: "POST",
528
+ headers: { [RESUMABLE_START]: "start", "content-type": contentType },
529
+ // Never followed. The store answers this exchange with a 3xx-shaped status of its own that
530
+ // is not a redirect, and following it would send the session somewhere nobody signed.
531
+ redirect: "manual"
532
+ });
533
+ } catch {
534
+ return { started: null, reached: false };
535
+ }
536
+ if (res.status !== 200 && res.status !== 201) return { started: null, reached: true };
537
+ const location = res.headers.get("location");
538
+ return location === null || location === "" ? { started: null, reached: true } : { started: location };
539
+ }
540
+ async function askWhatArrived(session, total, fetchFn) {
541
+ let res;
542
+ try {
543
+ res = await fetchFn(session, {
544
+ method: "PUT",
545
+ headers: { "content-range": `bytes */${total}` },
546
+ redirect: "manual"
547
+ });
548
+ } catch {
549
+ return null;
550
+ }
551
+ if (res.status === 200 || res.status === 201) return total;
552
+ if (res.status !== 308) return null;
553
+ return rangeReached(res);
554
+ }
555
+ async function sendFile(input) {
556
+ const fetchFn = input.fetchFn ?? fetch;
557
+ const chunkBytes = input.chunkBytes ?? DEFAULT_CHUNK_BYTES;
558
+ let attemptsLeft = input.attempts ?? DEFAULT_ATTEMPTS;
559
+ const session = await openSession(input.uploadUrl, input.contentType, fetchFn);
560
+ if (session.started === null) {
561
+ return { status: "not-started", reached: session.reached };
562
+ }
563
+ let arrived = 0;
564
+ while (arrived < input.byteSize) {
565
+ const end = pieceEnd(arrived, input.byteSize, chunkBytes);
566
+ const piece = await input.read(arrived, end - arrived);
567
+ let res;
568
+ try {
569
+ res = await fetchFn(session.started, {
570
+ method: "PUT",
571
+ headers: {
572
+ "content-type": input.contentType,
573
+ "content-range": `bytes ${arrived}-${end - 1}/${input.byteSize}`
574
+ },
575
+ body: piece,
576
+ redirect: "manual"
577
+ });
578
+ } catch {
579
+ attemptsLeft -= 1;
580
+ if (attemptsLeft <= 0) return { status: "incomplete", arrived };
581
+ const confirmed = await askWhatArrived(session.started, input.byteSize, fetchFn);
582
+ if (confirmed === null) return { status: "incomplete", arrived };
583
+ if (confirmed >= input.byteSize) return { status: "sent", arrived: input.byteSize };
584
+ input.onNote?.(connectionDroppedLine(confirmed, input.byteSize));
585
+ arrived = confirmed;
586
+ continue;
587
+ }
588
+ if (res.status === 200 || res.status === 201)
589
+ return { status: "sent", arrived: input.byteSize };
590
+ if (res.status !== 308) return { status: "incomplete", arrived };
591
+ const reached = rangeReached(res);
592
+ if (reached === null || reached <= arrived) return { status: "incomplete", arrived };
593
+ arrived = reached;
594
+ }
595
+ return { status: "sent", arrived };
596
+ }
597
+
598
+ // src/lib/token-store.ts
599
+ import { Entry } from "@napi-rs/keyring";
600
+ var SERVICE = "dazzer-oauth-cli";
601
+ var ACCESS_ACCOUNT = "access-token";
602
+ var REFRESH_ACCOUNT = "refresh-token";
603
+ var KeychainTokenStore = class {
604
+ constructor(openEntry = (account) => new Entry(SERVICE, account)) {
605
+ this.openEntry = openEntry;
606
+ }
607
+ openEntry;
608
+ async save(tokens) {
609
+ this.openEntry(ACCESS_ACCOUNT).setPassword(tokens.accessToken);
610
+ this.openEntry(REFRESH_ACCOUNT).setPassword(tokens.refreshToken);
611
+ }
612
+ async load() {
613
+ const accessToken = this.openEntry(ACCESS_ACCOUNT).getPassword();
614
+ const refreshToken = this.openEntry(REFRESH_ACCOUNT).getPassword();
615
+ if (!accessToken || !refreshToken) {
616
+ return null;
617
+ }
618
+ return { accessToken, refreshToken };
619
+ }
620
+ async loadRefreshToken() {
621
+ return this.openEntry(REFRESH_ACCOUNT).getPassword() || null;
622
+ }
623
+ /**
624
+ * NOTHING SAVED IS NOT AN ERROR, AND A FAILED REMOVAL IS NEVER HIDDEN. A delete of nothing answers
625
+ * `false` rather than throwing, so there is no missing-entry error to swallow. What the keychain
626
+ * library does throw is a removal the store refused. Swallowing that told a person they were signed
627
+ * out while their sign-in was still saved on the machine.
628
+ *
629
+ * THE LONG-LIVED HALF GOES FIRST, AND IF IT WILL NOT, NOTHING ELSE IS TOUCHED. Both then stay, so a
630
+ * second sign-out still finds a whole sign-in to cancel. Removing the access token anyway would leave
631
+ * only the refresh token — which can still renew a sign-in for weeks. Once the refresh token is gone,
632
+ * an access token left behind by a failed second removal expires within the hour.
633
+ */
634
+ async clear() {
635
+ this.openEntry(REFRESH_ACCOUNT).deletePassword();
636
+ this.openEntry(ACCESS_ACCOUNT).deletePassword();
637
+ }
638
+ };
639
+ function createTokenStore() {
640
+ return new KeychainTokenStore();
641
+ }
642
+
643
+ // src/lib/walk.ts
644
+ import { readdir, realpath, stat } from "node:fs/promises";
645
+ import { join as join2, sep } from "node:path";
646
+ function inside(base, candidate) {
647
+ return candidate === base || candidate.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
648
+ }
649
+ async function walkFolder(root) {
650
+ const base = await realpath(root);
651
+ if (!(await stat(base)).isDirectory()) {
652
+ throw new Error("walkFolder was given something that is not a folder");
653
+ }
654
+ const files = [];
655
+ const skipped = [];
656
+ const handedOver = /* @__PURE__ */ new Set();
657
+ const walked = /* @__PURE__ */ new Set([base]);
658
+ async function descend(dir, prefix, isRoot = false) {
659
+ let entries;
660
+ try {
661
+ entries = await readdir(dir, { withFileTypes: true });
662
+ } catch (err) {
663
+ if (isRoot) throw err;
664
+ skipped.push({ path: dir, relative: prefix.replace(/\/$/, ""), reason: "unreadable" });
665
+ return;
666
+ }
667
+ entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
668
+ for (const entry of entries) {
669
+ if (entry.name.startsWith(".")) continue;
670
+ const full = join2(dir, entry.name);
671
+ const relative = `${prefix}${entry.name}`;
672
+ if (entry.isSymbolicLink()) {
673
+ await followShortcut(full, relative);
674
+ continue;
675
+ }
676
+ if (entry.isDirectory()) {
677
+ await enter(full, relative);
678
+ continue;
679
+ }
680
+ if (entry.isFile()) take(full, relative);
681
+ }
682
+ }
683
+ async function enter(full, relative) {
684
+ let real;
685
+ try {
686
+ real = await realpath(full);
687
+ } catch {
688
+ skipped.push({ path: full, relative, reason: "unreadable" });
689
+ return;
690
+ }
691
+ if (walked.has(real)) return;
692
+ walked.add(real);
693
+ await descend(real, `${relative}/`);
694
+ }
695
+ function take(full, relative, real) {
696
+ const identity = real ?? full;
697
+ if (handedOver.has(identity)) return;
698
+ handedOver.add(identity);
699
+ files.push({ path: full, relative });
700
+ }
701
+ async function followShortcut(full, relative) {
702
+ let real;
703
+ try {
704
+ real = await realpath(full);
705
+ } catch {
706
+ skipped.push({ path: full, relative, reason: "unreadable" });
707
+ return;
708
+ }
709
+ if (!inside(base, real)) {
710
+ skipped.push({ path: full, relative, reason: "leads-out" });
711
+ return;
712
+ }
713
+ let kind;
714
+ try {
715
+ kind = await stat(real);
716
+ } catch {
717
+ skipped.push({ path: full, relative, reason: "unreadable" });
718
+ return;
719
+ }
720
+ if (kind.isDirectory()) {
721
+ if (walked.has(real)) return;
722
+ walked.add(real);
723
+ await descend(real, `${relative}/`);
724
+ return;
725
+ }
726
+ if (kind.isFile()) take(real, relative, real);
727
+ }
728
+ await descend(base, "", true);
729
+ files.sort((a, b) => a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0);
730
+ skipped.sort((a, b) => a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0);
731
+ return { files, skipped };
732
+ }
733
+
734
+ // src/commands/add.ts
735
+ var SPACE_PATH = "/documents/v1/space";
736
+ var LANDING_PATH = "/documents/v1/landing";
737
+ var LANDED_PATH = "/documents/v1/landed";
738
+ var cannotOpen = (name2) => `Couldn't open ${name2} \u2014 check its permissions.`;
739
+ var SLOWING_DOWN = "Slowing down to stay within limits \u2014 still going.";
740
+ var ALREADY_IN = "That one's already in \u2014 nothing to do.";
741
+ var waitingOnYou = (many) => `${many} waiting on your answer.`;
742
+ var collectedQuestion = (names, space) => `${names.length} of those look like newer versions of documents already in ${space} \u2014 ${listed(names)}. Treat all ${inWords(names.length)} as newer versions, keep all ${inWords(names.length)} as separate documents, or go through them one at a time?`;
743
+ function listed(names) {
744
+ if (names.length <= 1) return names[0] ?? "";
745
+ return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
746
+ }
747
+ var NUMBER_WORDS = [
748
+ "",
749
+ "one",
750
+ "two",
751
+ "three",
752
+ "four",
753
+ "five",
754
+ "six",
755
+ "seven",
756
+ "eight",
757
+ "nine",
758
+ "ten"
759
+ ];
760
+ var inWords = (many) => NUMBER_WORDS[many] ?? String(many);
761
+ var cannotFind = (name2) => `Couldn't find ${name2}.`;
762
+ var didNotFinish = (name2) => `The send didn't finish \u2014 ${name2} is not in Dazzer. Try again shortly.`;
763
+ var wentWrong = (name2) => `Something went wrong and ${name2} is not in Dazzer. Try again shortly.`;
764
+ var descriptionsNotFound = (name2) => `Couldn't find the descriptions file ${name2} \u2014 nothing was sent.`;
765
+ var descriptionsUnreadable = (name2) => `Couldn't read the descriptions file ${name2} \u2014 nothing was sent.`;
766
+ var descriptionsNotJson = (name2) => `The descriptions file ${name2} is not JSON \u2014 nothing was sent.`;
767
+ var descriptionsNotAnObject = (name2) => `The descriptions file ${name2} is not one JSON object keyed by file path \u2014 nothing was sent.`;
768
+ var isTheDescriptionsFile = (name2) => `${name2} is the descriptions file \u2014 nothing was sent.`;
769
+ function namingNoFile(keys) {
770
+ const many = keys.length;
771
+ const which = many <= NAMED_WHEN_AT_MOST ? ` \u2014 ${listed(keys.map(asPrinted))}` : "";
772
+ return `${many} ${many === 1 ? "entry names" : "entries name"} no file being added${which}. Nothing was sent for ${many === 1 ? "it" : "them"}.`;
773
+ }
774
+ var noEntryMatches = (name2, example) => `No entry in ${name2} names a file being added \u2014 nothing was sent.` + (example === void 0 ? "" : ` A key looks like ${JSON.stringify(asPrinted(example))}.`);
775
+ var keepItBeside = "The descriptions file is inside the folder. Keep it beside the folder instead, so no later run sends it as a document.";
776
+ var itHadNoDescription = "It had no description.";
777
+ function alreadyThereDescriptions(counted) {
778
+ const parts = [
779
+ ...counted.filed > 0 ? [`${counted.filed} took the description sent now`] : [],
780
+ ...counted.alreadyDescribed > 0 ? [`${counted.alreadyDescribed} already had a description`] : [],
781
+ ...counted.notKept > 0 ? [`${counted.notKept} did not take the description sent`] : []
782
+ ];
783
+ return `Of those already there: ${parts.join(", ")}.`;
784
+ }
785
+ var itsDescriptionFiledNow = "Its description was filed now.";
786
+ var itAlreadyHasOne = "It already has a description, and keeps it.";
787
+ var itsDescriptionNotKept = "The description sent was not kept.";
788
+ var NAMED_WHEN_AT_MOST = 5;
789
+ var choiceLine = (space, scopeId) => ` ${asPrinted(space)} \u2014 ${scopeId}`;
790
+ var answerWith = (keepToYourself, numbered) => {
791
+ const ways = [
792
+ '--space "<name>"',
793
+ ...numbered ? ["--space <number>"] : [],
794
+ ...keepToYourself ? ["--keep-to-myself"] : []
795
+ ];
796
+ const last = ways[ways.length - 1] ?? "";
797
+ const said = ways.length === 1 ? last : `${ways.slice(0, -1).join(", ")}, or ${last}`;
798
+ return `Run this again with ${said}.`;
799
+ };
800
+ var DECLARED_BY_EXTENSION = {
801
+ ".csv": "text/csv",
802
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
803
+ ".gif": "image/gif",
804
+ ".htm": "text/html",
805
+ ".html": "text/html",
806
+ ".jpeg": "image/jpeg",
807
+ ".jpg": "image/jpeg",
808
+ ".markdown": "text/markdown",
809
+ ".md": "text/markdown",
810
+ ".pdf": "application/pdf",
811
+ ".png": "image/png",
812
+ ".rtf": "application/rtf",
813
+ ".text": "text/plain",
814
+ ".txt": "text/plain",
815
+ ".webp": "image/webp"
816
+ };
817
+ var UNDECLARED = "application/octet-stream";
818
+ function asPrinted(name2) {
819
+ return name2.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ").trim();
820
+ }
821
+ function readEnvelope(body) {
822
+ if (typeof body !== "object" || body === null) return null;
823
+ const data = body.data;
824
+ return typeof data === "object" && data !== null ? data : null;
825
+ }
826
+ function readChoices(data) {
827
+ return readOfferedPlaces(data.choices);
828
+ }
829
+ function readLanding(body) {
830
+ const data = readEnvelope(body);
831
+ if (data === null) return null;
832
+ const { ticket, upload_url: uploadUrl, content_type: contentType, space_name: space } = data;
833
+ if (typeof ticket !== "string" || ticket === "") return null;
834
+ if (typeof uploadUrl !== "string" || uploadUrl === "") return null;
835
+ if (typeof contentType !== "string" || contentType === "") return null;
836
+ return {
837
+ ticket,
838
+ upload_url: uploadUrl,
839
+ content_type: contentType,
840
+ space_name: typeof space === "string" && space.trim() !== "" ? space : null
841
+ };
842
+ }
843
+ function named(space) {
844
+ return space === null ? "that space" : space;
845
+ }
846
+ async function resolveAuth(options) {
847
+ const tokenStore = createTokenStore();
848
+ const meta = await loadAuthMetadata();
849
+ const issuer = meta?.issuer ?? options.issuer;
850
+ try {
851
+ const discovery = await discoverEndpoints(issuer);
852
+ return {
853
+ discovery,
854
+ clientId: meta?.clientId ?? options.clientId,
855
+ tokenStore,
856
+ resource: options.resource
857
+ };
858
+ } catch {
859
+ return null;
860
+ }
861
+ }
862
+ var FIRST_WAIT_MS = 1e3;
863
+ var LONGEST_WAIT_MS = 3e4;
864
+ var PACED_ATTEMPTS = 6;
865
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
866
+ function voiceFor(write) {
867
+ let said = false;
868
+ return {
869
+ write,
870
+ slowingDown: () => {
871
+ if (said) return;
872
+ said = true;
873
+ write(SLOWING_DOWN);
874
+ }
875
+ };
876
+ }
877
+ async function callPaced(request, deps, voice) {
878
+ const wait = deps.delay ?? sleep;
879
+ let waiting = FIRST_WAIT_MS;
880
+ for (let attempt = 1; ; attempt += 1) {
881
+ const answer = await callDazzer(request);
882
+ if (answer.status !== "refused" || answer.httpStatus !== 429) return answer;
883
+ if (attempt >= PACED_ATTEMPTS) return answer;
884
+ voice.slowingDown();
885
+ await wait(waiting);
886
+ waiting = Math.min(waiting * 2, LONGEST_WAIT_MS);
887
+ }
888
+ }
889
+ function askedWith(options) {
890
+ if (options.keepToMyself === true) return { keep_to_myself: true };
891
+ const answer = options.space?.trim();
892
+ if (answer === void 0 || answer === "") return {};
893
+ return { space: /^\d+$/.test(answer) ? Number(answer) : answer };
894
+ }
895
+ async function settleSpace(options, auth2, deps, voice, name2) {
896
+ const answer = await callPaced(
897
+ {
898
+ baseUrl: options.baseUrl,
899
+ client: options.client,
900
+ path: SPACE_PATH,
901
+ method: "POST",
902
+ body: askedWith(options),
903
+ auth: auth2,
904
+ fetchFn: deps.fetchFn
905
+ },
906
+ deps,
907
+ voice
908
+ );
909
+ if (answer.status !== "ok") {
910
+ say(voice.write, answer, name2);
911
+ return null;
912
+ }
913
+ const data = readEnvelope(answer.body);
914
+ if (data === null) {
915
+ voice.write(wentWrong(name2));
916
+ return null;
917
+ }
918
+ const space = typeof data.space === "string" && data.space.trim() !== "" ? data.space : null;
919
+ if (data.settled === true) {
920
+ return { scopeId: typeof data.scope_id === "number" ? data.scope_id : null, space };
921
+ }
922
+ const question = typeof data.question === "string" && data.question !== "" ? data.question : null;
923
+ if (question === null) {
924
+ voice.write(wentWrong(name2));
925
+ return null;
926
+ }
927
+ voice.write(question);
928
+ const choices = readChoices(data);
929
+ for (const choice of choices) voice.write(choiceLine(choice.space, choice.scopeId));
930
+ voice.write(answerWith(data.keep_to_yourself === true, choices.length > 0));
931
+ return null;
932
+ }
933
+ var HELD_FOR_AN_ANSWER = "same_name_different_contents";
934
+ function fromAnswer(answer) {
935
+ if (answer.status === "refused") {
936
+ if (answer.code === HELD_FOR_AN_ANSWER && answer.detail !== void 0) {
937
+ return { kind: "held", question: answer.detail };
938
+ }
939
+ return {
940
+ kind: "refused",
941
+ ...answer.detail === void 0 ? {} : { said: answer.detail },
942
+ ...answer.code === void 0 ? {} : { code: answer.code },
943
+ // Carried, not filled in: absent stays absent, so nothing downstream can print an offer the
944
+ // service did not make.
945
+ ...answer.choices === void 0 ? {} : { choices: answer.choices },
946
+ ...answer.keepToYourself === void 0 ? {} : { keepToYourself: answer.keepToYourself }
947
+ };
948
+ }
949
+ return { kind: "stop", answer };
950
+ }
951
+ async function sendOne(input) {
952
+ const { deps, handle, options, voice } = input;
953
+ const described = await callPaced(
954
+ {
955
+ baseUrl: options.baseUrl,
956
+ client: options.client,
957
+ path: LANDING_PATH,
958
+ method: "POST",
959
+ body: {
960
+ file_name: basename(input.path),
961
+ byte_size: input.byteSize,
962
+ mime_type: DECLARED_BY_EXTENSION[extname(input.path).toLowerCase()] ?? UNDECLARED,
963
+ ...input.scopeId === null ? {} : { scope_id: input.scopeId }
964
+ },
965
+ auth: input.auth,
966
+ fetchFn: deps.fetchFn
967
+ },
968
+ deps,
969
+ voice
970
+ );
971
+ if (described.status !== "ok") return fromAnswer(described);
972
+ const landing = readLanding(described.body);
973
+ if (landing === null) return { kind: "went-wrong" };
974
+ let couldNotRead = false;
975
+ let sent;
976
+ try {
977
+ sent = await sendFile({
978
+ uploadUrl: landing.upload_url,
979
+ contentType: landing.content_type,
980
+ byteSize: input.byteSize,
981
+ read: async (start, length) => {
982
+ try {
983
+ const buffer = Buffer.allocUnsafe(length);
984
+ let filled = 0;
985
+ while (filled < length) {
986
+ const { bytesRead } = await handle.read(
987
+ buffer,
988
+ filled,
989
+ length - filled,
990
+ start + filled
991
+ );
992
+ if (bytesRead === 0) break;
993
+ filled += bytesRead;
994
+ }
995
+ return filled === length ? buffer : buffer.subarray(0, filled);
996
+ } catch (err) {
997
+ couldNotRead = true;
998
+ throw err;
999
+ }
1000
+ },
1001
+ fetchFn: deps.uploadFetchFn,
1002
+ chunkBytes: deps.chunkBytes,
1003
+ attempts: deps.attempts,
1004
+ onNote: voice.write
1005
+ });
1006
+ } catch (err) {
1007
+ if (couldNotRead) return { kind: "cannot-read" };
1008
+ throw err;
1009
+ }
1010
+ if (sent.status !== "sent") {
1011
+ return sent.status === "not-started" && !sent.reached ? { kind: "not-reached" } : { kind: "did-not-finish" };
1012
+ }
1013
+ const landed = await callPaced(
1014
+ {
1015
+ baseUrl: options.baseUrl,
1016
+ client: options.client,
1017
+ path: LANDED_PATH,
1018
+ method: "POST",
1019
+ body: { ticket: landing.ticket, ...input.described },
1020
+ auth: input.auth,
1021
+ fetchFn: deps.fetchFn
1022
+ },
1023
+ deps,
1024
+ voice
1025
+ );
1026
+ if (landed.status !== "ok") return fromAnswer(landed);
1027
+ const stored = readEnvelope(landed.body);
1028
+ if (stored === null) return { kind: "went-wrong" };
1029
+ const alreadyHere = stored.already_here === true;
1030
+ const becameOfIt = whatBecameOfIt(stored, alreadyHere, "description" in (input.described ?? {}));
1031
+ if (alreadyHere) return { kind: "already-here", ...becameOfIt };
1032
+ return {
1033
+ kind: "added",
1034
+ fileName: typeof stored.file_name === "string" ? asPrinted(stored.file_name) : asPrinted(basename(input.path)),
1035
+ space: typeof stored.space_name === "string" ? stored.space_name : null,
1036
+ ...becameOfIt
1037
+ };
1038
+ }
1039
+ var ENTRY_FIELDS = ["description", "facts", "document_date", "transcription"];
1040
+ var DESCRIPTION_FIELDS = [
1041
+ "what_it_is",
1042
+ "what_it_covers",
1043
+ "what_it_decides",
1044
+ "how_much_was_read"
1045
+ ];
1046
+ var QUOTED_FIELDS = ["text", "quote"];
1047
+ var MOST_FACTS = 100;
1048
+ var MOST_TYPED_CHARACTERS = 1e6;
1049
+ var MOST_MESSAGE_BYTES = 1024 * 1024;
1050
+ var LONGEST_TICKET = 4096;
1051
+ var A_DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
1052
+ var A_MOMENT_ON_THE_DAY = /^T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
1053
+ var EARLIEST_YEAR = 1e3;
1054
+ function aDateTheDoorReads(value) {
1055
+ const written = value.trim();
1056
+ const day = A_DAY.exec(written.slice(0, 10));
1057
+ if (day === null) return false;
1058
+ const [year, month, date] = [Number(day[1]), Number(day[2]), Number(day[3])];
1059
+ if (year < EARLIEST_YEAR) return false;
1060
+ const midnight = new Date(Date.UTC(year, month - 1, date));
1061
+ if (midnight.getUTCFullYear() !== year || midnight.getUTCMonth() !== month - 1 || midnight.getUTCDate() !== date) {
1062
+ return false;
1063
+ }
1064
+ if (written.length === 10) return true;
1065
+ return A_MOMENT_ON_THE_DAY.test(written.slice(10)) && !Number.isNaN(new Date(written).getTime());
1066
+ }
1067
+ var sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino;
1068
+ var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1069
+ function asKey(path) {
1070
+ let key = path.normalize("NFC").replaceAll("\\", "/");
1071
+ while (key.startsWith("./")) key = key.slice(2);
1072
+ return key;
1073
+ }
1074
+ async function readDescriptions(path) {
1075
+ const name2 = asPrinted(basename(path));
1076
+ let handle;
1077
+ try {
1078
+ handle = await open(path, "r");
1079
+ } catch (err) {
1080
+ const missing = err?.code === "ENOENT";
1081
+ return { said: missing ? descriptionsNotFound(name2) : descriptionsUnreadable(name2) };
1082
+ }
1083
+ let text;
1084
+ let identity;
1085
+ try {
1086
+ const info = await handle.stat({ bigint: true });
1087
+ identity = { dev: info.dev, ino: info.ino };
1088
+ text = await handle.readFile("utf8");
1089
+ } catch {
1090
+ return { said: descriptionsUnreadable(name2) };
1091
+ } finally {
1092
+ await handle.close();
1093
+ }
1094
+ const json = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
1095
+ let parsed;
1096
+ try {
1097
+ parsed = JSON.parse(json);
1098
+ } catch {
1099
+ return { said: descriptionsNotJson(name2) };
1100
+ }
1101
+ if (!isPlainObject(parsed)) return { said: descriptionsNotAnObject(name2) };
1102
+ const entries = /* @__PURE__ */ new Map();
1103
+ for (const [key, value] of Object.entries(parsed)) {
1104
+ const matched = asKey(key);
1105
+ const already = entries.get(matched);
1106
+ entries.set(matched, { keys: [...already?.keys ?? [], key], value: already?.value ?? value });
1107
+ }
1108
+ return { name: name2, entries, identity };
1109
+ }
1110
+ function quoted(text) {
1111
+ const flat = asPrinted(text);
1112
+ return `"${flat.length > 40 ? `${flat.slice(0, 40)}\u2026` : flat}"`;
1113
+ }
1114
+ var A_QUOTED_SENTENCE = '{"text": "...", "quote": "..."}';
1115
+ function strayKey(value, fields, where, mayHold) {
1116
+ const stray = Object.keys(value).find((key) => !fields.includes(key));
1117
+ return stray === void 0 ? void 0 : `${where} holds ${quoted(stray)}. It holds only ${mayHold}.`;
1118
+ }
1119
+ function quotedSentenceWrong(value, where) {
1120
+ const stray = strayKey(value, QUOTED_FIELDS, where, "text and quote");
1121
+ if (stray !== void 0) return stray;
1122
+ for (const field of QUOTED_FIELDS) {
1123
+ const said = value[field];
1124
+ if (said !== void 0 && said !== null && typeof said !== "string") {
1125
+ return `the ${field} of ${where} is not text. Write it in quotes, like "Spring launch plan".`;
1126
+ }
1127
+ }
1128
+ return void 0;
1129
+ }
1130
+ function lineWrong(description, line, mayBeNull) {
1131
+ if (!(line in description)) return void 0;
1132
+ const value = description[line];
1133
+ if (value === null && mayBeNull) return void 0;
1134
+ const where = `description.${line}`;
1135
+ if (!isPlainObject(value)) {
1136
+ return `${where} is not an object. Write it like ${A_QUOTED_SENTENCE}${mayBeNull ? ", or null when the document settles nothing" : ""}.`;
1137
+ }
1138
+ return quotedSentenceWrong(value, where);
1139
+ }
1140
+ function descriptionWrong(value) {
1141
+ if (!isPlainObject(value)) {
1142
+ return `description is not an object. Write it like {"what_it_is": ${A_QUOTED_SENTENCE}, ...}.`;
1143
+ }
1144
+ return strayKey(
1145
+ value,
1146
+ DESCRIPTION_FIELDS,
1147
+ "description",
1148
+ "what_it_is, what_it_covers, what_it_decides and how_much_was_read"
1149
+ ) ?? lineWrong(value, "what_it_is", false) ?? lineWrong(value, "what_it_covers", false) ?? lineWrong(value, "what_it_decides", true);
1150
+ }
1151
+ function factsWrong(value) {
1152
+ if (!Array.isArray(value)) return `facts is not a list. Write it like [${A_QUOTED_SENTENCE}].`;
1153
+ if (value.length > MOST_FACTS) {
1154
+ return `facts holds ${value.length} facts. It takes at most ${MOST_FACTS}.`;
1155
+ }
1156
+ for (const [at, fact] of value.entries()) {
1157
+ const where = `fact ${at + 1}`;
1158
+ if (!isPlainObject(fact)) {
1159
+ return `${where} is not an object. Write each fact like ${A_QUOTED_SENTENCE}.`;
1160
+ }
1161
+ const wrong = quotedSentenceWrong(fact, where);
1162
+ if (wrong !== void 0) return wrong;
1163
+ }
1164
+ return void 0;
1165
+ }
1166
+ function dateWrong(value) {
1167
+ if (typeof value !== "string") return 'document_date is not text. Write it like "2026-03-01".';
1168
+ return aDateTheDoorReads(value) ? void 0 : `document_date ${quoted(value)} is not a date. Write it like "2026-03-01".`;
1169
+ }
1170
+ function typedWrong(value) {
1171
+ if (typeof value !== "string") {
1172
+ return "transcription is not text. Write the typed-out words as one string.";
1173
+ }
1174
+ return /\S/.test(value) && value.length > MOST_TYPED_CHARACTERS ? `transcription runs to ${value.length.toLocaleString("en-US")} characters. It takes at most ${MOST_TYPED_CHARACTERS.toLocaleString("en-US")}.` : void 0;
1175
+ }
1176
+ function sizeWrong(entry) {
1177
+ const bytes = Buffer.byteLength(
1178
+ JSON.stringify({ ticket: "t".repeat(LONGEST_TICKET), ...entry }),
1179
+ "utf8"
1180
+ );
1181
+ return bytes > MOST_MESSAGE_BYTES ? `its entry comes to ${bytes.toLocaleString("en-US")} bytes. One message takes at most ${MOST_MESSAGE_BYTES.toLocaleString("en-US")}.` : void 0;
1182
+ }
1183
+ function checkEntry(found) {
1184
+ if (found.keys.length > 1) {
1185
+ return {
1186
+ wrong: `${found.keys.length} entries name it \u2014 ${listed(found.keys.map(quoted))}. Keep one.`
1187
+ };
1188
+ }
1189
+ const entry = found.value;
1190
+ if (!isPlainObject(entry)) {
1191
+ return {
1192
+ wrong: 'its entry is not an object. Write it like {"description": {...}, "facts": [...], "document_date": "2026-03-01"}.'
1193
+ };
1194
+ }
1195
+ const wrong = strayKey(
1196
+ entry,
1197
+ ENTRY_FIELDS,
1198
+ "its entry",
1199
+ "description, facts, document_date and transcription"
1200
+ ) ?? ("description" in entry ? descriptionWrong(entry.description) : void 0) ?? ("facts" in entry ? factsWrong(entry.facts) : void 0) ?? ("document_date" in entry ? dateWrong(entry.document_date) : void 0) ?? ("transcription" in entry ? typedWrong(entry.transcription) : void 0) ?? sizeWrong(entry);
1201
+ return wrong === void 0 ? { described: entry } : { wrong };
1202
+ }
1203
+ var wordsOf = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
1204
+ var listOf = (value) => Array.isArray(value) ? value : [];
1205
+ var LINE_DROPPED = {
1206
+ "line-empty": "it has no words",
1207
+ "line-too-long": "it is too long",
1208
+ "line-verbatim-from-document": "its words are copied out of the document",
1209
+ "quote-empty": "it quotes nothing",
1210
+ "quote-too-long": "its quote is too long",
1211
+ "quote-not-in-document": "the words it quotes are not in the document"
1212
+ };
1213
+ var FACT_DROPPED = {
1214
+ "fact-empty": "it has no words",
1215
+ "fact-too-long": "it is too long",
1216
+ "quote-empty": "it quotes nothing",
1217
+ "quote-too-long": "its quote is too long",
1218
+ "unstorable-characters": "it holds a character that cannot be stored",
1219
+ "quote-not-in-document": "the words it quotes are not in the document",
1220
+ "nothing-to-check-it-against": "there are no words in the file to check it against"
1221
+ };
1222
+ var DATE_NOT_KEPT = {
1223
+ "later-than-today": "it is later than today",
1224
+ "document-already-held": "the document was already here, with the date it came with",
1225
+ "stored-as-the-moment-it-arrived": "that day had not begun in universal time, so the moment it arrived is kept"
1226
+ };
1227
+ var DESCRIPTION_NOT_FILED = {
1228
+ nothing_to_check_it_against: "there are no words in the file to check it against"
1229
+ };
1230
+ var TYPING_NOT_USED = {
1231
+ not_used_file_has_its_own_text: "the file has words of its own",
1232
+ not_used_document_already_held: "the document was already here"
1233
+ };
1234
+ var inPlainWords = (words, name2) => words[name2] ?? name2;
1235
+ var SHORT_EXAMPLE = 120;
1236
+ function whatBecameOfIt(stored, alreadyHere, descriptionSent) {
1237
+ const lines = [];
1238
+ let filing;
1239
+ const description = isPlainObject(stored.description) ? stored.description : null;
1240
+ if (description !== null) {
1241
+ const state = wordsOf(description.state);
1242
+ const what = wordsOf(description.what);
1243
+ const how = wordsOf(description.how);
1244
+ if (descriptionSent && state !== void 0) {
1245
+ filing = state === "filed" ? "filed" : alreadyHere && state === "not_filed" && what === void 0 ? "already-described" : "not-kept";
1246
+ }
1247
+ if (state === "not_filed" && what !== void 0) {
1248
+ lines.push(how === void 0 ? what : `${what} ${how}`);
1249
+ } else if (state !== void 0 && state !== "filed" && state !== "none_supplied" && !(alreadyHere && state === "not_filed")) {
1250
+ lines.push(
1251
+ state === "not_filed" ? "description not filed." : `description not filed: ${inPlainWords(DESCRIPTION_NOT_FILED, state)}`
1252
+ );
1253
+ }
1254
+ for (const dropped of listOf(description.dropped)) {
1255
+ if (!isPlainObject(dropped)) continue;
1256
+ const line = wordsOf(dropped.line);
1257
+ const reason = wordsOf(dropped.reason);
1258
+ const what2 = wordsOf(dropped.what);
1259
+ const how2 = wordsOf(dropped.how);
1260
+ const why = what2 === void 0 ? reason === void 0 ? void 0 : inPlainWords(LINE_DROPPED, reason) : how2 === void 0 ? what2 : `${what2} ${how2}`;
1261
+ if (line !== void 0 && why !== void 0) lines.push(`${line} dropped: ${why}`);
1262
+ }
1263
+ const howMuch = isPlainObject(description.how_much_was_read) ? description.how_much_was_read : null;
1264
+ const howMuchWhat = howMuch === null ? void 0 : wordsOf(howMuch.what);
1265
+ if (howMuch !== null && howMuchWhat !== void 0) {
1266
+ const howMuchHow = wordsOf(howMuch.how);
1267
+ const example = howMuch.example === void 0 ? "" : JSON.stringify(howMuch.example);
1268
+ lines.push(
1269
+ `how_much_was_read not kept: ${howMuchWhat}` + (howMuchHow === void 0 ? "" : ` ${howMuchHow}`) + (example !== "" && example.length <= SHORT_EXAMPLE ? ` Example: ${example}` : "")
1270
+ );
1271
+ }
1272
+ }
1273
+ const facts = isPlainObject(stored.facts) ? stored.facts : null;
1274
+ for (const dropped of listOf(facts?.dropped)) {
1275
+ if (!isPlainObject(dropped)) continue;
1276
+ const reason = wordsOf(dropped.reason);
1277
+ if (typeof dropped.fact === "number" && reason !== void 0) {
1278
+ lines.push(`fact ${dropped.fact} dropped: ${inPlainWords(FACT_DROPPED, reason)}`);
1279
+ }
1280
+ }
1281
+ const date = isPlainObject(stored.date_not_kept) ? wordsOf(stored.date_not_kept.reason) : void 0;
1282
+ if (date !== void 0 && !(alreadyHere && date === "document-already-held")) {
1283
+ lines.push(`document_date not kept: ${inPlainWords(DATE_NOT_KEPT, date)}`);
1284
+ }
1285
+ const typed = wordsOf(stored.transcription);
1286
+ if (typed !== void 0 && typed !== "used" && !(alreadyHere && typed === "not_used_document_already_held")) {
1287
+ lines.push(`transcription not used: ${inPlainWords(TYPING_NOT_USED, typed)}`);
1288
+ }
1289
+ return {
1290
+ notKept: lines.map(asPrinted),
1291
+ ...filing === void 0 ? {} : { description: filing }
1292
+ };
1293
+ }
1294
+ async function sendOneSafely(input) {
1295
+ try {
1296
+ return await sendOne(input);
1297
+ } catch {
1298
+ return { kind: "went-wrong" };
1299
+ }
1300
+ }
1301
+ async function openOne(path) {
1302
+ try {
1303
+ return await open(path, "r");
1304
+ } catch (err) {
1305
+ return err?.code === "ENOENT" ? "missing" : "cannot-open";
1306
+ }
1307
+ }
1308
+ var REASON_ORDER = [
1309
+ "type-not-readable",
1310
+ "too-large",
1311
+ "already-here",
1312
+ "refused",
1313
+ "cannot-open",
1314
+ "did-not-finish",
1315
+ "leads-out",
1316
+ "entry-wrong-shape",
1317
+ "the-descriptions-file"
1318
+ ];
1319
+ var NAMED_ALWAYS = /* @__PURE__ */ new Set(["entry-wrong-shape"]);
1320
+ var namedWithoutWhy = (skip) => NAMED_ALWAYS.has(skip.reason) || skip.named === true;
1321
+ var FROM_THE_SERVICE = {
1322
+ type_not_readable: "type-not-readable",
1323
+ too_large: "too-large"
1324
+ };
1325
+ var COUNTED = {
1326
+ "type-not-readable": (many) => `${many} file type${many === 1 ? "" : "s"} I can't read`,
1327
+ "too-large": (many) => `${many} too large`,
1328
+ "already-here": (many) => `${many} already there`,
1329
+ refused: (many) => `${many} refused`,
1330
+ "cannot-open": (many) => `${many} I couldn't open`,
1331
+ "did-not-finish": (many) => `${many} that didn't finish`,
1332
+ "leads-out": (many) => `${many} shortcut${many === 1 ? "" : "s"} out of the folder`,
1333
+ "entry-wrong-shape": (many) => `${many} with an entry in the wrong shape`,
1334
+ "the-descriptions-file": (many) => `${many} that ${many === 1 ? "is" : "are"} the descriptions file`
1335
+ };
1336
+ var IN_DETAIL = {
1337
+ "type-not-readable": "a file type I can't read",
1338
+ "too-large": "too large",
1339
+ "already-here": "already there",
1340
+ refused: "refused",
1341
+ "cannot-open": "I couldn't open it",
1342
+ "did-not-finish": "the send didn't finish",
1343
+ "leads-out": "a shortcut out of the folder",
1344
+ "entry-wrong-shape": "not sent: its entry is in the wrong shape.",
1345
+ "the-descriptions-file": "the descriptions file itself, never sent"
1346
+ };
1347
+ function countLine(added, space, skipped, why, undescribed = []) {
1348
+ const documents = `${added} document${added === 1 ? "" : "s"} in ${named(space)}.` + (undescribed.length === 0 ? "" : ` ${undescribed.length} had no description${undescribed.length <= NAMED_WHEN_AT_MOST ? ` \u2014 ${listed(undescribed.map(asPrinted))}` : ""}.`);
1349
+ if (skipped.length === 0) return documents;
1350
+ const reasons = REASON_ORDER.flatMap((reason) => {
1351
+ const many = skipped.filter((skip) => skip.reason === reason).length;
1352
+ return many === 0 ? [] : [COUNTED[reason](many)];
1353
+ }).join(", ");
1354
+ const pointAtWhy = !why && skipped.some((skip) => !namedWithoutWhy(skip) && skip.reason !== "the-descriptions-file");
1355
+ return `${documents} ${skipped.length} skipped \u2014 ${reasons}.${pointAtWhy ? " Run with --why to see each." : ""}`;
1356
+ }
1357
+ function whyLine(skip) {
1358
+ const because = skip.said === void 0 ? IN_DETAIL[skip.reason] : skip.reason === "refused" ? `refused: ${skip.said}` : skip.said;
1359
+ return `${asPrinted(skip.relative)} \u2014 ${because}`;
1360
+ }
1361
+ var FROM_THE_WALK = {
1362
+ "leads-out": "leads-out",
1363
+ unreadable: "cannot-open"
1364
+ };
1365
+ async function runAdd(options, deps = {}) {
1366
+ const voice = voiceFor(deps.write ?? ((line) => process.stdout.write(`${line}
1367
+ `)));
1368
+ const name2 = asPrinted(basename(options.file));
1369
+ let isFolder;
1370
+ try {
1371
+ isFolder = (await statPath(options.file)).isDirectory();
1372
+ } catch (err) {
1373
+ voice.write(
1374
+ err?.code === "ENOENT" ? cannotFind(name2) : cannotOpen(name2)
1375
+ );
1376
+ return { ok: false };
1377
+ }
1378
+ let descriptions;
1379
+ if (options.describedIn !== void 0) {
1380
+ const read = await readDescriptions(options.describedIn);
1381
+ if ("said" in read) {
1382
+ voice.write(read.said);
1383
+ return { ok: false };
1384
+ }
1385
+ descriptions = read;
1386
+ }
1387
+ return isFolder ? await addFolder(options, deps, voice, descriptions) : await addOneFile(options, deps, voice, descriptions);
1388
+ }
1389
+ async function addOneFile(options, deps, voice, descriptions) {
1390
+ const name2 = asPrinted(basename(options.file));
1391
+ const handle = await openOne(options.file);
1392
+ if (typeof handle === "string") {
1393
+ voice.write(handle === "missing" ? cannotFind(name2) : cannotOpen(name2));
1394
+ return { ok: false };
1395
+ }
1396
+ try {
1397
+ const info = await handle.stat({ bigint: true });
1398
+ const byteSize = Number(info.size);
1399
+ let described;
1400
+ if (descriptions !== void 0) {
1401
+ if (sameFile({ dev: info.dev, ino: info.ino }, descriptions.identity)) {
1402
+ voice.write(isTheDescriptionsFile(name2));
1403
+ return { ok: false };
1404
+ }
1405
+ const key = asKey(basename(options.file));
1406
+ const found = descriptions.entries.get(key);
1407
+ if (found === void 0 && descriptions.entries.size > 0) {
1408
+ voice.write(noEntryMatches(descriptions.name, basename(options.file)));
1409
+ return { ok: false };
1410
+ }
1411
+ const namingNothing = [...descriptions.entries].filter(([other]) => other !== key).flatMap(([, other]) => other.keys);
1412
+ if (namingNothing.length > 0) voice.write(namingNoFile(namingNothing.sort()));
1413
+ if (found !== void 0) {
1414
+ const checked = checkEntry(found);
1415
+ if ("wrong" in checked) {
1416
+ voice.write(`${name2} \u2014 not sent: ${checked.wrong}`);
1417
+ return { ok: false };
1418
+ }
1419
+ described = checked.described;
1420
+ }
1421
+ }
1422
+ const auth2 = deps.auth ?? await resolveAuth(options);
1423
+ if (auth2 === null) {
1424
+ voice.write(CANNOT_REACH_DAZZER);
1425
+ return { ok: false };
1426
+ }
1427
+ const settled = await settleSpace(options, auth2, deps, voice, name2);
1428
+ if (settled === null) return { ok: false };
1429
+ const outcome = await sendOneSafely({
1430
+ path: options.file,
1431
+ handle,
1432
+ byteSize,
1433
+ scopeId: settled.scopeId,
1434
+ ...described === void 0 ? {} : { described },
1435
+ options,
1436
+ deps,
1437
+ auth: auth2,
1438
+ voice
1439
+ });
1440
+ const result = answerForOneFile(outcome, name2, voice);
1441
+ if (outcome.kind === "added" && descriptions !== void 0 && !("description" in (described ?? {}))) {
1442
+ voice.write(itHadNoDescription);
1443
+ }
1444
+ if (outcome.kind === "already-here" && outcome.description !== void 0) {
1445
+ voice.write(
1446
+ outcome.description === "filed" ? itsDescriptionFiledNow : outcome.description === "already-described" ? itAlreadyHasOne : itsDescriptionNotKept
1447
+ );
1448
+ }
1449
+ if (outcome.kind === "added" || outcome.kind === "already-here") {
1450
+ for (const line of outcome.notKept) voice.write(`${name2} \u2014 ${line}`);
1451
+ }
1452
+ return result;
1453
+ } finally {
1454
+ await handle.close();
1455
+ }
1456
+ }
1457
+ function answerForOneFile(outcome, name2, voice) {
1458
+ switch (outcome.kind) {
1459
+ case "added":
1460
+ voice.write(`${outcome.fileName} is in Dazzer, in ${named(outcome.space)}.`);
1461
+ return { ok: true };
1462
+ case "already-here":
1463
+ voice.write(ALREADY_IN);
1464
+ return { ok: true };
1465
+ // ONE FILE, ONE QUESTION, IN THE SERVICE'S OWN WORDS. It is the only thing that knows what it
1466
+ // already holds and what the space is called, and rewriting its question here would be a
1467
+ // second wording of a question somebody approved. Nothing was added and nobody has answered,
1468
+ // so this run did not do what it was asked to — the same answer a run that asked where things
1469
+ // go gives.
1470
+ case "held":
1471
+ voice.write(outcome.question);
1472
+ return { ok: false };
1473
+ // ONE FILE, ONE REFUSAL, AND THE PLACES IT OFFERED. The sentence is the service's and is
1474
+ // printed as sent; the places under it are the service's too, and without them a refusal
1475
+ // that says to name one of them is a dead end for the person holding this one file.
1476
+ case "refused":
1477
+ voice.write(outcome.said ?? wentWrong(name2));
1478
+ offerThePlaces(voice.write, outcome);
1479
+ return { ok: false };
1480
+ case "did-not-finish":
1481
+ voice.write(didNotFinish(name2));
1482
+ return { ok: false };
1483
+ case "not-reached":
1484
+ voice.write(CANNOT_REACH_DAZZER);
1485
+ return { ok: false };
1486
+ case "went-wrong":
1487
+ voice.write(wentWrong(name2));
1488
+ return { ok: false };
1489
+ // IT OPENED AND WOULD NOT READ — answered in the approved sentence for a file that cannot be
1490
+ // opened, because that is the same thing a person can act on, and never as a crash.
1491
+ case "cannot-read":
1492
+ voice.write(cannotOpen(name2));
1493
+ return { ok: false };
1494
+ case "stop":
1495
+ say(voice.write, outcome.answer, name2);
1496
+ return { ok: false };
1497
+ }
1498
+ }
1499
+ async function addFolder(options, deps, voice, descriptions) {
1500
+ const name2 = asPrinted(basename(options.file));
1501
+ const walked = await (async () => {
1502
+ try {
1503
+ return await walkFolder(options.file);
1504
+ } catch {
1505
+ return null;
1506
+ }
1507
+ })();
1508
+ if (walked === null) {
1509
+ voice.write(cannotOpen(name2));
1510
+ return { ok: false };
1511
+ }
1512
+ const found = new Set([...walked.files, ...walked.skipped].map((one) => asKey(one.relative)));
1513
+ const entries = [...descriptions?.entries ?? /* @__PURE__ */ new Map()];
1514
+ const namingNothing = entries.filter(([key]) => !found.has(key)).flatMap(([, entry]) => entry.keys).sort();
1515
+ if (descriptions !== void 0 && entries.length > 0 && entries.every(([key]) => !found.has(key))) {
1516
+ voice.write(
1517
+ noEntryMatches(
1518
+ descriptions.name,
1519
+ await aKeyItWouldMatch(walked.files, descriptions.identity)
1520
+ )
1521
+ );
1522
+ return { ok: false };
1523
+ }
1524
+ const auth2 = deps.auth ?? await resolveAuth(options);
1525
+ if (auth2 === null) {
1526
+ voice.write(CANNOT_REACH_DAZZER);
1527
+ return { ok: false };
1528
+ }
1529
+ const settled = await settleSpace(options, auth2, deps, voice, name2);
1530
+ if (settled === null) return { ok: false };
1531
+ const skipped = walked.skipped.map((skip) => ({
1532
+ relative: skip.relative,
1533
+ reason: FROM_THE_WALK[skip.reason]
1534
+ }));
1535
+ const held = [];
1536
+ let added = 0;
1537
+ let ran = true;
1538
+ const undescribed = [];
1539
+ const notKept = [];
1540
+ const alreadyThere = { filed: 0, alreadyDescribed: 0, notKept: 0 };
1541
+ for (const file of walked.files) {
1542
+ let described;
1543
+ const entry = descriptions?.entries.get(asKey(file.relative));
1544
+ if (entry !== void 0) {
1545
+ const checked = checkEntry(entry);
1546
+ if ("wrong" in checked) {
1547
+ skipped.push({
1548
+ relative: file.relative,
1549
+ reason: "entry-wrong-shape",
1550
+ said: `not sent: ${checked.wrong}`
1551
+ });
1552
+ continue;
1553
+ }
1554
+ described = checked.described;
1555
+ }
1556
+ const handle = await openOne(file.path);
1557
+ if (typeof handle === "string") {
1558
+ skipped.push({ relative: file.relative, reason: "cannot-open" });
1559
+ continue;
1560
+ }
1561
+ let outcome;
1562
+ try {
1563
+ const info = await handle.stat({ bigint: true });
1564
+ outcome = descriptions !== void 0 && sameFile({ dev: info.dev, ino: info.ino }, descriptions.identity) ? "the-descriptions-file" : await sendOneSafely({
1565
+ path: file.path,
1566
+ handle,
1567
+ byteSize: Number(info.size),
1568
+ scopeId: settled.scopeId,
1569
+ ...described === void 0 ? {} : { described },
1570
+ options,
1571
+ deps,
1572
+ auth: auth2,
1573
+ voice
1574
+ });
1575
+ } finally {
1576
+ await handle.close();
1577
+ }
1578
+ if (outcome === "the-descriptions-file") {
1579
+ skipped.push({ relative: file.relative, reason: "the-descriptions-file" });
1580
+ continue;
1581
+ }
1582
+ if (outcome.kind === "added" || outcome.kind === "already-here") {
1583
+ if (outcome.notKept.length > 0)
1584
+ notKept.push({ relative: file.relative, lines: outcome.notKept });
1585
+ }
1586
+ if (outcome.kind === "already-here" && outcome.description !== void 0) {
1587
+ const answer = outcome.description;
1588
+ alreadyThere[answer === "filed" ? "filed" : answer === "already-described" ? "alreadyDescribed" : "notKept"] += 1;
1589
+ }
1590
+ if (outcome.kind === "added") {
1591
+ added += 1;
1592
+ if (descriptions !== void 0 && !("description" in (described ?? {}))) {
1593
+ undescribed.push(file.relative);
1594
+ }
1595
+ continue;
1596
+ }
1597
+ if (outcome.kind === "already-here") {
1598
+ skipped.push({ relative: file.relative, reason: "already-here" });
1599
+ continue;
1600
+ }
1601
+ if (outcome.kind === "held") {
1602
+ held.push({
1603
+ relative: file.relative,
1604
+ name: basename(file.path),
1605
+ question: outcome.question
1606
+ });
1607
+ continue;
1608
+ }
1609
+ if (outcome.kind === "refused") {
1610
+ skipped.push({
1611
+ relative: file.relative,
1612
+ // KEYED ON THE SERVICE'S CODE, never on the words it sent: the words are the person's and
1613
+ // may be reworded, the code is the label the service publishes.
1614
+ reason: (outcome.code === void 0 ? void 0 : FROM_THE_SERVICE[outcome.code]) ?? "refused",
1615
+ ...outcome.said === void 0 ? {} : { said: outcome.said },
1616
+ // REFUSED ALTHOUGH IT CARRIED AN ENTRY: most likely about what the entry says, which only
1617
+ // the person's assistant can put right — so it is named, in the service's own sentence,
1618
+ // without anybody having to run the whole folder again with `--why` to find out.
1619
+ ...described === void 0 ? {} : { named: true }
1620
+ });
1621
+ continue;
1622
+ }
1623
+ if (outcome.kind === "cannot-read") {
1624
+ skipped.push({ relative: file.relative, reason: "cannot-open" });
1625
+ continue;
1626
+ }
1627
+ if (outcome.kind === "did-not-finish" || outcome.kind === "went-wrong") {
1628
+ skipped.push({ relative: file.relative, reason: "did-not-finish" });
1629
+ continue;
1630
+ }
1631
+ if (outcome.kind === "not-reached") voice.write(CANNOT_REACH_DAZZER);
1632
+ else say(voice.write, outcome.answer, name2);
1633
+ ran = false;
1634
+ break;
1635
+ }
1636
+ skipped.sort((a, b) => a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0);
1637
+ held.sort((a, b) => a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0);
1638
+ notKept.sort((a, b) => a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0);
1639
+ voice.write(countLine(added, settled.space, skipped, options.why === true, undescribed));
1640
+ if (skipped.some((skip) => skip.reason === "the-descriptions-file")) voice.write(keepItBeside);
1641
+ if (alreadyThere.filed + alreadyThere.alreadyDescribed + alreadyThere.notKept > 0) {
1642
+ voice.write(alreadyThereDescriptions(alreadyThere));
1643
+ }
1644
+ if (namingNothing.length > 0) voice.write(namingNoFile(namingNothing));
1645
+ if (held.length > 0) voice.write(waitingOnYou(held.length));
1646
+ for (const skip of skipped) {
1647
+ if (options.why === true || namedWithoutWhy(skip)) voice.write(whyLine(skip));
1648
+ }
1649
+ for (const one of notKept) {
1650
+ for (const line of one.lines) voice.write(`${asPrinted(one.relative)} \u2014 ${line}`);
1651
+ }
1652
+ const onlyOne = held.length === 1 ? held[0] : void 0;
1653
+ if (onlyOne !== void 0) {
1654
+ voice.write(onlyOne.question);
1655
+ } else if (held.length > 1) {
1656
+ voice.write(
1657
+ collectedQuestion(
1658
+ held.map((one) => asPrinted(one.name)),
1659
+ named(settled.space)
1660
+ )
1661
+ );
1662
+ }
1663
+ return { ok: ran };
1664
+ }
1665
+ async function aKeyItWouldMatch(files, descriptionsFile) {
1666
+ for (const file of files) {
1667
+ try {
1668
+ const info = await statPath(file.path, { bigint: true });
1669
+ if (!sameFile({ dev: info.dev, ino: info.ino }, descriptionsFile)) return file.relative;
1670
+ } catch {
1671
+ }
1672
+ }
1673
+ return void 0;
1674
+ }
1675
+ function offerThePlaces(write, offer) {
1676
+ const places = offer.choices ?? [];
1677
+ const keepToYourself = offer.keepToYourself === true;
1678
+ if (places.length === 0 && !keepToYourself) return;
1679
+ for (const place of places) write(choiceLine(place.space, place.scopeId));
1680
+ write(answerWith(keepToYourself, places.length > 0));
1681
+ }
1682
+ function say(write, answer, name2) {
1683
+ switch (answer.status) {
1684
+ case "refused":
1685
+ write(answer.detail ?? wentWrong(name2));
1686
+ offerThePlaces(write, answer);
1687
+ return;
1688
+ case "sign-in-invalid":
1689
+ case "unreachable":
1690
+ write(answer.message);
1691
+ return;
1692
+ case "signed-out":
1693
+ printAuthStatus({ state: "signed-out" });
1694
+ return;
1695
+ case "store-unreadable":
1696
+ printAuthStatus({ state: "store-unreadable" });
1697
+ return;
1698
+ case "ok":
1699
+ return;
90
1700
  }
91
1701
  }
92
1702
 
@@ -134,12 +1744,12 @@ async function requestDeviceCode(options) {
134
1744
  }
135
1745
  async function pollForTokens(options) {
136
1746
  const fetchFn = options.fetchFn ?? fetch;
137
- const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1747
+ const sleep2 = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
138
1748
  const baseIntervalMs = (options.deviceAuth.interval ?? 5) * 1e3;
139
1749
  let intervalMs = options.pollIntervalMs ?? baseIntervalMs;
140
1750
  const deadline = Date.now() + (options.maxWaitMs ?? options.deviceAuth.expires_in * 1e3);
141
1751
  while (Date.now() < deadline) {
142
- await sleep(intervalMs);
1752
+ await sleep2(intervalMs);
143
1753
  const params = new URLSearchParams({
144
1754
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
145
1755
  device_code: options.deviceCode,
@@ -329,279 +1939,111 @@ function buildAuthorizeUrl(endpoint, params) {
329
1939
  }
330
1940
  function statusPage(title, heading, body, mark) {
331
1941
  return renderDocument({
332
- title,
333
- centered: true,
334
- body: pageShell(`${mark}<h1>${heading}</h1><p>${body}</p>
335
- <div class="foot">Dazzer secure sign-in</div>`)
336
- });
337
- }
338
- function successPage() {
339
- return statusPage(
340
- "Signed in to Dazzer",
341
- "You're signed in",
342
- "You can close this tab and return to your terminal.",
343
- successMark
344
- );
345
- }
346
- function errorPage(reason) {
347
- return statusPage(
348
- "Sign-in failed",
349
- "Something went wrong",
350
- `We couldn't complete sign-in (${escapeHtml(reason)}). Close this tab and run the command again.`,
351
- errorMark
352
- );
353
- }
354
- function sendHtml(res, html) {
355
- res.statusCode = 200;
356
- res.setHeader("Content-Type", "text/html; charset=utf-8");
357
- res.end(html);
358
- }
359
- async function runLoopbackFlow(options) {
360
- const fetchFn = options.fetchFn ?? fetch;
361
- const { authorizationEndpoint } = options.discovery;
362
- if (!authorizationEndpoint) {
363
- throw new Error("This authorization server does not support browser sign-in.");
364
- }
365
- const { verifier, challenge } = createPkce();
366
- const state = base64url(randomBytes(16));
367
- const scope = options.scope ?? DEFAULT_SCOPE2;
368
- const { code, redirectUri } = await new Promise(
369
- (resolve, reject) => {
370
- let timer;
371
- const server = createServer((req, res2) => {
372
- const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
373
- if (requestUrl.pathname !== CALLBACK_PATH) {
374
- res2.statusCode = 404;
375
- res2.end("Not found");
376
- return;
377
- }
378
- const addr = server.address();
379
- const redirect = `http://127.0.0.1:${addr.port}${CALLBACK_PATH}`;
380
- const returnedState = requestUrl.searchParams.get("state");
381
- const errorParam = requestUrl.searchParams.get("error");
382
- const returnedCode = requestUrl.searchParams.get("code");
383
- const finish = (err) => {
384
- if (timer) clearTimeout(timer);
385
- server.close();
386
- if (err) reject(err);
387
- };
388
- if (errorParam) {
389
- sendHtml(res2, errorPage(errorParam));
390
- finish(new Error(`Sign-in failed: ${errorParam}.`));
391
- return;
392
- }
393
- if (returnedState !== state) {
394
- sendHtml(res2, errorPage("state_mismatch"));
395
- finish(new Error("Sign-in failed: state mismatch."));
396
- return;
397
- }
398
- if (!returnedCode) {
399
- sendHtml(res2, errorPage("missing_code"));
400
- finish(new Error("Sign-in failed: no authorization code returned."));
401
- return;
402
- }
403
- sendHtml(res2, successPage());
404
- finish();
405
- resolve({ code: returnedCode, redirectUri: redirect });
406
- });
407
- server.on("error", (err) => {
408
- if (timer) clearTimeout(timer);
409
- reject(err);
410
- });
411
- server.listen(0, "127.0.0.1", () => {
412
- const addr = server.address();
413
- const redirect = `http://127.0.0.1:${addr.port}${CALLBACK_PATH}`;
414
- const authorizeUrl = buildAuthorizeUrl(authorizationEndpoint, {
415
- clientId: options.clientId,
416
- redirectUri: redirect,
417
- scope,
418
- state,
419
- challenge,
420
- resource: options.resource
421
- });
422
- timer = setTimeout(() => {
423
- server.close();
424
- reject(new Error("Timed out waiting for sign-in. Run the command again."));
425
- }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
426
- (options.openBrowser ?? launchBrowser)(authorizeUrl);
427
- options.onPrompt?.(authorizeUrl);
428
- });
429
- }
430
- );
431
- const params = new URLSearchParams({
432
- grant_type: "authorization_code",
433
- code,
434
- redirect_uri: redirectUri,
435
- client_id: options.clientId,
436
- code_verifier: verifier
437
- });
438
- if (options.resource) {
439
- params.set("resource", options.resource);
440
- }
441
- const res = await fetchFn(options.discovery.tokenEndpoint, {
442
- method: "POST",
443
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
444
- body: params.toString()
445
- });
446
- const body = await res.json();
447
- if (!res.ok) {
448
- const error = typeof body.error === "string" ? body.error : "token_exchange_failed";
449
- throw new Error(`Token request failed: ${error}`);
450
- }
451
- const accessToken = body.access_token;
452
- if (typeof accessToken !== "string") {
453
- throw new Error("The authorization server returned an unexpected token response.");
454
- }
455
- return {
456
- access_token: accessToken,
457
- refresh_token: typeof body.refresh_token === "string" ? body.refresh_token : void 0,
458
- expires_in: typeof body.expires_in === "number" ? body.expires_in : void 0,
459
- token_type: typeof body.token_type === "string" ? body.token_type : void 0
460
- };
461
- }
462
- function browserLikelyAvailable() {
463
- if (process.platform === "darwin" || process.platform === "win32") {
464
- return true;
465
- }
466
- return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
467
- }
468
-
469
- // src/lib/output.ts
470
- function writeOut(message) {
471
- process.stdout.write(`${message}
472
- `);
473
- }
474
- function writeErr(message) {
475
- process.stderr.write(`${message}
476
- `);
477
- }
478
- function printDeviceInstructions(deviceAuth) {
479
- writeOut("");
480
- if (deviceAuth.verification_uri_complete) {
481
- writeOut("To finish signing in, open this link in your browser:");
482
- writeOut(` ${deviceAuth.verification_uri_complete}`);
483
- } else {
484
- writeOut("To finish signing in, open this page in your browser:");
485
- writeOut(` ${deviceAuth.verification_uri}`);
486
- writeOut("");
487
- writeOut("Then enter this code:");
488
- writeOut(` ${deviceAuth.user_code}`);
489
- }
490
- writeOut("");
491
- writeOut("Waiting for you to approve in the browser...");
492
- }
493
- function printBrowserOpening(url) {
494
- writeOut("");
495
- writeOut("Opening your browser to sign in\u2026");
496
- writeOut("If it doesn't open automatically, paste this into your browser:");
497
- writeOut(` ${url}`);
498
- writeOut("");
499
- }
500
- function printLoginSuccess(keychainOk) {
501
- writeOut("");
502
- writeOut("Signed in successfully.");
503
- if (!keychainOk) {
504
- writeErr("Warning: could not save tokens to the OS keychain. Use the config below.");
505
- }
506
- }
507
- function printConnectGuidance(mcpServerUrl, accessToken) {
508
- const url = mcpServerUrl.replace(/\/$/, "");
509
- writeOut("");
510
- writeOut("Connect an AI client to Dazzer.");
511
- writeOut("");
512
- writeOut("Recommended \u2014 add this server by URL and sign in from your client;");
513
- writeOut("it stays connected and renews itself:");
514
- writeOut("");
515
- writeOut("{");
516
- writeOut(' "mcpServers": {');
517
- writeOut(' "dazzer": {');
518
- writeOut(` "url": "${url}"`);
519
- writeOut(" }");
520
- writeOut(" }");
521
- writeOut("}");
522
- writeOut("");
523
- writeOut("Fallback \u2014 only if your client cannot sign in and needs a pasted token.");
524
- writeOut("This token expires in about an hour; re-run `dazzer auth login` to renew:");
525
- writeOut("");
526
- writeOut("{");
527
- writeOut(' "mcpServers": {');
528
- writeOut(' "dazzer": {');
529
- writeOut(` "url": "${url}",`);
530
- writeOut(' "headers": {');
531
- writeOut(` "Authorization": "Bearer ${accessToken}"`);
532
- writeOut(" }");
533
- writeOut(" }");
534
- writeOut(" }");
535
- writeOut("}");
536
- writeOut("");
537
- }
538
- function printAuthStatus(params) {
539
- switch (params.state) {
540
- case "signed-out":
541
- writeOut("Not signed in. Run: dazzer auth login");
542
- return;
543
- case "store-unreadable":
544
- writeOut("Cannot tell \u2014 this machine's credential store could not be opened.");
545
- writeOut(" Common on servers and containers, which often have none.");
546
- writeOut(" Run: dazzer auth login \u2014 it prints a pass you can paste in.");
547
- return;
548
- case "expired":
549
- writeOut("Your sign-in has expired or been revoked. Run: dazzer auth login");
550
- return;
551
- case "unverified":
552
- writeOut(
553
- "Signed in on this device, but the sign-in server could not be reached to confirm it."
554
- );
555
- if (params.issuer) {
556
- writeOut(` Signed in at: ${params.issuer}`);
557
- }
558
- if (params.lastKnownExpiresAt) {
559
- writeOut(` Last confirmed expiry: ${new Date(params.lastKnownExpiresAt).toISOString()}`);
560
- }
561
- writeOut(" Check your connection, then run this again to confirm.");
562
- return;
563
- case "signed-in":
564
- writeOut("Signed in \u2014 confirmed just now.");
565
- if (params.issuer) {
566
- writeOut(` Signed in at: ${params.issuer}`);
567
- }
568
- if (params.expiresAt) {
569
- writeOut(
570
- ` This token expires ${new Date(params.expiresAt).toISOString()} and refreshes automatically.`
571
- );
572
- }
573
- return;
574
- }
1942
+ title,
1943
+ centered: true,
1944
+ body: pageShell(`${mark}<h1>${heading}</h1><p>${body}</p>
1945
+ <div class="foot">Dazzer secure sign-in</div>`)
1946
+ });
575
1947
  }
576
- function printLogoutSuccess(serverRevoked) {
577
- if (serverRevoked) {
578
- writeOut("Signed out.");
579
- return;
580
- }
581
- writeOut(
582
- "Signed out on this device. We could not reach the server to revoke your tokens, so they will expire on their own."
1948
+ function successPage() {
1949
+ return statusPage(
1950
+ "Signed in to Dazzer",
1951
+ "You're signed in",
1952
+ "You can close this tab and return to your terminal.",
1953
+ successMark
583
1954
  );
584
1955
  }
585
- function fail(message) {
586
- writeErr(`Error: ${message}`);
587
- process.exit(1);
1956
+ function errorPage(reason) {
1957
+ return statusPage(
1958
+ "Sign-in failed",
1959
+ "Something went wrong",
1960
+ `We couldn't complete sign-in (${escapeHtml(reason)}). Close this tab and run the command again.`,
1961
+ errorMark
1962
+ );
588
1963
  }
589
-
590
- // src/lib/refresh.ts
591
- var RefreshRejectedError = class extends Error {
592
- constructor(oauthError) {
593
- super(`Could not refresh token: ${oauthError}`);
594
- this.oauthError = oauthError;
595
- this.name = "RefreshRejectedError";
596
- }
597
- oauthError;
598
- };
599
- async function refreshAccessToken(options) {
1964
+ function sendHtml(res, html) {
1965
+ res.statusCode = 200;
1966
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
1967
+ res.end(html);
1968
+ }
1969
+ async function runLoopbackFlow(options) {
600
1970
  const fetchFn = options.fetchFn ?? fetch;
1971
+ const { authorizationEndpoint } = options.discovery;
1972
+ if (!authorizationEndpoint) {
1973
+ throw new Error("This authorization server does not support browser sign-in.");
1974
+ }
1975
+ const { verifier, challenge } = createPkce();
1976
+ const state = base64url(randomBytes(16));
1977
+ const scope = options.scope ?? DEFAULT_SCOPE2;
1978
+ const { code, redirectUri } = await new Promise(
1979
+ (resolve, reject) => {
1980
+ let timer;
1981
+ const server = createServer((req, res2) => {
1982
+ const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
1983
+ if (requestUrl.pathname !== CALLBACK_PATH) {
1984
+ res2.statusCode = 404;
1985
+ res2.end("Not found");
1986
+ return;
1987
+ }
1988
+ const addr = server.address();
1989
+ const redirect = `http://127.0.0.1:${addr.port}${CALLBACK_PATH}`;
1990
+ const returnedState = requestUrl.searchParams.get("state");
1991
+ const errorParam = requestUrl.searchParams.get("error");
1992
+ const returnedCode = requestUrl.searchParams.get("code");
1993
+ const finish = (err) => {
1994
+ if (timer) clearTimeout(timer);
1995
+ server.close();
1996
+ if (err) reject(err);
1997
+ };
1998
+ if (errorParam) {
1999
+ sendHtml(res2, errorPage(errorParam));
2000
+ finish(new Error(`Sign-in failed: ${errorParam}.`));
2001
+ return;
2002
+ }
2003
+ if (returnedState !== state) {
2004
+ sendHtml(res2, errorPage("state_mismatch"));
2005
+ finish(new Error("Sign-in failed: state mismatch."));
2006
+ return;
2007
+ }
2008
+ if (!returnedCode) {
2009
+ sendHtml(res2, errorPage("missing_code"));
2010
+ finish(new Error("Sign-in failed: no authorization code returned."));
2011
+ return;
2012
+ }
2013
+ sendHtml(res2, successPage());
2014
+ finish();
2015
+ resolve({ code: returnedCode, redirectUri: redirect });
2016
+ });
2017
+ server.on("error", (err) => {
2018
+ if (timer) clearTimeout(timer);
2019
+ reject(err);
2020
+ });
2021
+ server.listen(0, "127.0.0.1", () => {
2022
+ const addr = server.address();
2023
+ const redirect = `http://127.0.0.1:${addr.port}${CALLBACK_PATH}`;
2024
+ const authorizeUrl = buildAuthorizeUrl(authorizationEndpoint, {
2025
+ clientId: options.clientId,
2026
+ redirectUri: redirect,
2027
+ scope,
2028
+ state,
2029
+ challenge,
2030
+ resource: options.resource
2031
+ });
2032
+ timer = setTimeout(() => {
2033
+ server.close();
2034
+ reject(new Error("Timed out waiting for sign-in. Run the command again."));
2035
+ }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
2036
+ (options.openBrowser ?? launchBrowser)(authorizeUrl);
2037
+ options.onPrompt?.(authorizeUrl);
2038
+ });
2039
+ }
2040
+ );
601
2041
  const params = new URLSearchParams({
602
- grant_type: "refresh_token",
2042
+ grant_type: "authorization_code",
2043
+ code,
2044
+ redirect_uri: redirectUri,
603
2045
  client_id: options.clientId,
604
- refresh_token: options.refreshToken
2046
+ code_verifier: verifier
605
2047
  });
606
2048
  if (options.resource) {
607
2049
  params.set("resource", options.resource);
@@ -613,83 +2055,25 @@ async function refreshAccessToken(options) {
613
2055
  });
614
2056
  const body = await res.json();
615
2057
  if (!res.ok) {
616
- const error = typeof body.error === "string" ? body.error : "refresh_failed";
617
- throw new RefreshRejectedError(error);
2058
+ const error = typeof body.error === "string" ? body.error : "token_exchange_failed";
2059
+ throw new Error(`Token request failed: ${error}`);
618
2060
  }
619
2061
  const accessToken = body.access_token;
620
- const refreshToken = body.refresh_token;
621
2062
  if (typeof accessToken !== "string") {
622
- throw new Error("The authorization server returned an unexpected refresh response.");
623
- }
624
- const newRefresh = typeof refreshToken === "string" ? refreshToken : options.refreshToken;
625
- const expiresIn = typeof body.expires_in === "number" ? body.expires_in : null;
626
- await options.tokenStore.save({ accessToken, refreshToken: newRefresh });
627
- return { accessToken, refreshToken: newRefresh, expiresIn };
628
- }
629
- async function getValidAccessToken(options) {
630
- const stored = await options.tokenStore.load();
631
- if (!stored) {
632
- return { status: "signed-out" };
633
- }
634
- try {
635
- const refreshed = await refreshAccessToken({ ...options, refreshToken: stored.refreshToken });
636
- return { status: "refreshed", expiresIn: refreshed.expiresIn };
637
- } catch (err) {
638
- if (err instanceof RefreshRejectedError) {
639
- return { status: "rejected" };
640
- }
641
- return { status: "unreachable" };
642
- }
643
- }
644
- async function persistLoginTokens(tokenStore, tokens) {
645
- if (!tokens.refresh_token) {
646
- throw new Error("The authorization server did not return a refresh token.");
2063
+ throw new Error("The authorization server returned an unexpected token response.");
647
2064
  }
648
- const stored = {
649
- accessToken: tokens.access_token,
650
- refreshToken: tokens.refresh_token
2065
+ return {
2066
+ access_token: accessToken,
2067
+ refresh_token: typeof body.refresh_token === "string" ? body.refresh_token : void 0,
2068
+ expires_in: typeof body.expires_in === "number" ? body.expires_in : void 0,
2069
+ token_type: typeof body.token_type === "string" ? body.token_type : void 0
651
2070
  };
652
- await tokenStore.save(stored);
653
- return stored;
654
- }
655
-
656
- // src/lib/token-store.ts
657
- import { Entry } from "@napi-rs/keyring";
658
- var SERVICE = "dazzer-oauth-cli";
659
- var ACCESS_ACCOUNT = "access-token";
660
- var REFRESH_ACCOUNT = "refresh-token";
661
- function accessEntry() {
662
- return new Entry(SERVICE, ACCESS_ACCOUNT);
663
- }
664
- function refreshEntry() {
665
- return new Entry(SERVICE, REFRESH_ACCOUNT);
666
2071
  }
667
- var KeychainTokenStore = class {
668
- async save(tokens) {
669
- accessEntry().setPassword(tokens.accessToken);
670
- refreshEntry().setPassword(tokens.refreshToken);
671
- }
672
- async load() {
673
- const accessToken = accessEntry().getPassword();
674
- const refreshToken = refreshEntry().getPassword();
675
- if (!accessToken || !refreshToken) {
676
- return null;
677
- }
678
- return { accessToken, refreshToken };
679
- }
680
- async clear() {
681
- try {
682
- accessEntry().deletePassword();
683
- } catch {
684
- }
685
- try {
686
- refreshEntry().deletePassword();
687
- } catch {
688
- }
2072
+ function browserLikelyAvailable() {
2073
+ if (process.platform === "darwin" || process.platform === "win32") {
2074
+ return true;
689
2075
  }
690
- };
691
- function createTokenStore() {
692
- return new KeychainTokenStore();
2076
+ return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
693
2077
  }
694
2078
 
695
2079
  // src/commands/login.ts
@@ -738,16 +2122,37 @@ async function runLogin(options) {
738
2122
  keychainOk = false;
739
2123
  }
740
2124
  const expiresAt = typeof tokens.expires_in === "number" ? Date.now() + tokens.expires_in * 1e3 : null;
741
- await saveAuthMetadata({
742
- issuer: discovery.issuer,
743
- clientId: options.clientId,
744
- expiresAt
745
- });
2125
+ const previous = await loadAuthMetadata();
2126
+ const keepPrevious = !keychainOk && previous?.savedToStore === true;
2127
+ await saveAuthMetadata(
2128
+ keepPrevious ? previous : {
2129
+ issuer: discovery.issuer,
2130
+ clientId: options.clientId,
2131
+ expiresAt,
2132
+ savedToStore: keychainOk
2133
+ }
2134
+ );
746
2135
  printLoginSuccess(keychainOk);
747
2136
  printConnectGuidance(options.resource ?? discovery.issuer, tokens.access_token);
748
2137
  }
749
2138
 
750
2139
  // src/lib/revoke.ts
2140
+ var RevocationRefused = class extends Error {
2141
+ constructor(status) {
2142
+ super(`Token revocation failed (${status}).`);
2143
+ this.status = status;
2144
+ this.name = "RevocationRefused";
2145
+ }
2146
+ status;
2147
+ };
2148
+ var RevocationRedirected = class extends Error {
2149
+ constructor(status) {
2150
+ super(`Token revocation was redirected (${status}).`);
2151
+ this.status = status;
2152
+ this.name = "RevocationRedirected";
2153
+ }
2154
+ status;
2155
+ };
751
2156
  async function revokeToken(options) {
752
2157
  const fetchFn = options.fetchFn ?? fetch;
753
2158
  const params = new URLSearchParams({
@@ -758,47 +2163,142 @@ async function revokeToken(options) {
758
2163
  const res = await fetchFn(options.revocationEndpoint, {
759
2164
  method: "POST",
760
2165
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
761
- body: params.toString()
2166
+ body: params.toString(),
2167
+ redirect: "manual"
762
2168
  });
2169
+ if (res.type === "opaqueredirect" || res.status >= 300 && res.status < 400) {
2170
+ throw new RevocationRedirected(res.status);
2171
+ }
763
2172
  if (!res.ok) {
764
- throw new Error(`Token revocation failed (${res.status}).`);
2173
+ throw new RevocationRefused(res.status);
765
2174
  }
766
2175
  }
767
2176
 
768
2177
  // src/commands/logout.ts
769
- async function runLogout(options = {}) {
2178
+ function mayWorkLater(status) {
2179
+ return status === 408 || status === 425 || status === 429 || status >= 500;
2180
+ }
2181
+ function afterFailure(error) {
2182
+ if (error instanceof DiscoveryUnusable) return "refused";
2183
+ if (error instanceof DiscoveryAnswered || error instanceof RevocationRefused) {
2184
+ return mayWorkLater(error.status) ? "unanswered" : "refused";
2185
+ }
2186
+ return "unanswered";
2187
+ }
2188
+ function sameOrigin(address, issuer) {
2189
+ try {
2190
+ return new URL(address).origin === new URL(issuer).origin;
2191
+ } catch {
2192
+ return false;
2193
+ }
2194
+ }
2195
+ async function cancelAtServer(refreshToken, server, fetchFn) {
2196
+ try {
2197
+ assertHttpsIssuer(server.issuer);
2198
+ } catch {
2199
+ return "refused";
2200
+ }
2201
+ try {
2202
+ const { revocationEndpoint } = await discoverEndpoints(server.issuer, fetchFn);
2203
+ if (!revocationEndpoint || !sameOrigin(revocationEndpoint, server.issuer)) return "refused";
2204
+ await revokeToken({
2205
+ revocationEndpoint,
2206
+ clientId: server.clientId,
2207
+ token: refreshToken,
2208
+ tokenTypeHint: "refresh_token",
2209
+ fetchFn
2210
+ });
2211
+ return "cancelled";
2212
+ } catch (error) {
2213
+ return afterFailure(error);
2214
+ }
2215
+ }
2216
+ async function removeRecordAfterSignOut() {
2217
+ try {
2218
+ await clearAuthMetadata();
2219
+ } catch {
2220
+ }
2221
+ }
2222
+ function defaultServer(defaults) {
2223
+ try {
2224
+ return defaults();
2225
+ } catch {
2226
+ return null;
2227
+ }
2228
+ }
2229
+ async function endWithStoreUnopened(meta, options) {
2230
+ const platform = options.platform ?? process.platform;
2231
+ const mayForget = (platform === "linux" || platform === "freebsd") && meta?.savedToStore !== true;
2232
+ if (!options.forgetThisMachine) {
2233
+ printLogoutStoreLocked(mayForget);
2234
+ return { ok: false };
2235
+ }
2236
+ if (!mayForget) {
2237
+ printForgetRefused();
2238
+ return { ok: false };
2239
+ }
2240
+ try {
2241
+ await clearAuthMetadata();
2242
+ } catch {
2243
+ printForgetFailed(getConfigDir());
2244
+ return { ok: false };
2245
+ }
2246
+ printLogoutForgotten();
2247
+ return { ok: true };
2248
+ }
2249
+ async function runLogout(options) {
770
2250
  const tokenStore = options.tokenStore ?? createTokenStore();
771
2251
  const meta = await loadAuthMetadata();
772
- const tokens = await tokenStore.load().catch(() => null);
773
- let serverRevoked = true;
774
- if (meta && tokens) {
2252
+ let refreshToken;
2253
+ try {
2254
+ refreshToken = await tokenStore.loadRefreshToken();
2255
+ } catch {
2256
+ return endWithStoreUnopened(meta, options);
2257
+ }
2258
+ if (refreshToken === null) {
2259
+ let passRemoved = true;
775
2260
  try {
776
- const discovery = await discoverEndpoints(options.issuer ?? meta.issuer);
777
- if (!discovery.revocationEndpoint) {
778
- throw new Error("This authorization server does not support token revocation.");
779
- }
780
- const clientId = options.clientId ?? meta.clientId;
781
- await revokeToken({
782
- revocationEndpoint: discovery.revocationEndpoint,
783
- clientId,
784
- token: tokens.refreshToken,
785
- tokenTypeHint: "refresh_token",
786
- fetchFn: options.fetchFn
787
- });
788
- await revokeToken({
789
- revocationEndpoint: discovery.revocationEndpoint,
790
- clientId,
791
- token: tokens.accessToken,
792
- tokenTypeHint: "access_token",
793
- fetchFn: options.fetchFn
794
- });
2261
+ await tokenStore.clear();
795
2262
  } catch {
796
- serverRevoked = false;
2263
+ passRemoved = false;
2264
+ }
2265
+ await removeRecordAfterSignOut();
2266
+ if (!passRemoved) {
2267
+ printLogoutPassLeft();
2268
+ } else if (meta?.savedToStore === false) {
2269
+ printLogoutSuccess();
2270
+ } else {
2271
+ printLogoutNothingFound();
797
2272
  }
2273
+ return { ok: true };
2274
+ }
2275
+ if (options.forgetThisMachine) {
2276
+ printForgetRefused();
2277
+ return { ok: false };
2278
+ }
2279
+ const server = meta ?? defaultServer(options.defaults);
2280
+ if (!server) {
2281
+ printLogoutBadSettings();
2282
+ return { ok: false };
2283
+ }
2284
+ const atServer = await cancelAtServer(refreshToken, server, options.fetchFn ?? fetch);
2285
+ if (atServer === "unanswered") {
2286
+ printLogoutNotCancelledYet();
2287
+ return { ok: false };
2288
+ }
2289
+ try {
2290
+ await tokenStore.clear();
2291
+ } catch {
2292
+ printLogoutIncomplete(atServer === "cancelled");
2293
+ return { ok: false };
2294
+ }
2295
+ await removeRecordAfterSignOut();
2296
+ if (atServer === "refused") {
2297
+ printLogoutCannotCancel();
2298
+ } else {
2299
+ printLogoutSuccess();
798
2300
  }
799
- await tokenStore.clear();
800
- await clearAuthMetadata();
801
- printLogoutSuccess(serverRevoked);
2301
+ return { ok: true };
802
2302
  }
803
2303
 
804
2304
  // src/commands/status.ts
@@ -829,12 +2329,18 @@ async function runStatus(options) {
829
2329
  });
830
2330
  return;
831
2331
  }
832
- const result = await getValidAccessToken({
833
- discovery,
834
- clientId,
835
- tokenStore,
836
- resource: options.resource
837
- });
2332
+ let result;
2333
+ try {
2334
+ result = await getValidAccessToken({
2335
+ discovery,
2336
+ clientId,
2337
+ tokenStore,
2338
+ resource: options.resource
2339
+ });
2340
+ } catch {
2341
+ printAuthStatus({ state: "store-unreadable" });
2342
+ return;
2343
+ }
838
2344
  switch (result.status) {
839
2345
  case "signed-out":
840
2346
  printAuthStatus({ state: "signed-out" });
@@ -851,7 +2357,7 @@ async function runStatus(options) {
851
2357
  return;
852
2358
  case "refreshed": {
853
2359
  const expiresAt = result.expiresIn ? Date.now() + result.expiresIn * 1e3 : null;
854
- await saveAuthMetadata({ issuer: discovery.issuer, clientId, expiresAt });
2360
+ await saveAuthMetadata({ issuer: discovery.issuer, clientId, expiresAt, savedToStore: true });
855
2361
  printAuthStatus({ state: "signed-in", issuer: discovery.issuer, expiresAt });
856
2362
  return;
857
2363
  }
@@ -866,7 +2372,14 @@ var cliEnvSchema = z.object({
866
2372
  /** OAuth public client id. */
867
2373
  DAZZER_AUTH_CLIENT_ID: z.string().min(1).default("dazzer-cli-local"),
868
2374
  /** Resource indicator for JWT access tokens. Defaults to the production MCP server. */
869
- DAZZER_AUTH_RESOURCE: z.url().default("https://graph.dazzer.io/mcp")
2375
+ DAZZER_AUTH_RESOURCE: z.url().default("https://graph.dazzer.io/mcp"),
2376
+ /**
2377
+ * Where Dazzer itself is — the main service, as opposed to the sign-in server the other three
2378
+ * settings describe. It defaults to the same host the sign-in audience above already names,
2379
+ * because they are the same service seen from two sides; the default lives here, beside the
2380
+ * others, so no library and no command carries a production host of its own.
2381
+ */
2382
+ DAZZER_API_BASE_URL: z.url().default("https://graph.dazzer.io")
870
2383
  });
871
2384
  var cachedEnv = null;
872
2385
  function validateEnv() {
@@ -875,12 +2388,38 @@ function validateEnv() {
875
2388
  }
876
2389
  return cachedEnv;
877
2390
  }
2391
+ function signInServerDefaults() {
2392
+ const settings = cliEnvSchema.pick({ DAZZER_AUTH_ISSUER: true, DAZZER_AUTH_CLIENT_ID: true }).parse(process.env);
2393
+ return { issuer: settings.DAZZER_AUTH_ISSUER, clientId: settings.DAZZER_AUTH_CLIENT_ID };
2394
+ }
878
2395
 
879
2396
  // src/index.ts
880
2397
  var require2 = createRequire(import.meta.url);
881
- var { version } = require2("../package.json");
2398
+ var { name, version } = require2("../package.json");
2399
+ var client = `${name}/${version}`;
882
2400
  var program = new Command();
883
2401
  program.name("dazzer").description("Sign in to Dazzer from your terminal").version(version);
2402
+ program.command("add").description("Put a file, or a whole folder, into Dazzer").argument("<path>", "The file or folder to add").option("--space <name>", "The space to put it in \u2014 the name you were shown, or its number").option("--keep-to-myself", "Keep it to yourself, rather than putting it in a shared space").option("--why", "Show each document that was skipped, and why").option(
2403
+ "--described-in <file>",
2404
+ "A JSON file saying what each file is, keyed by its path inside the folder"
2405
+ ).action(
2406
+ async (file, opts) => {
2407
+ const env = validateEnv();
2408
+ const result = await runAdd({
2409
+ file,
2410
+ space: opts.space,
2411
+ keepToMyself: opts.keepToMyself === true,
2412
+ why: opts.why === true,
2413
+ ...opts.describedIn === void 0 ? {} : { describedIn: opts.describedIn },
2414
+ client,
2415
+ baseUrl: env.DAZZER_API_BASE_URL,
2416
+ issuer: env.DAZZER_AUTH_ISSUER,
2417
+ clientId: env.DAZZER_AUTH_CLIENT_ID,
2418
+ resource: env.DAZZER_AUTH_RESOURCE
2419
+ });
2420
+ if (!result.ok) process.exitCode = 1;
2421
+ }
2422
+ );
884
2423
  var auth = program.command("auth").description("Manage sign-in");
885
2424
  auth.command("login").description("Sign in through your browser; falls back to a device code when headless").option("--issuer <url>", "Authorization server URL").option("--client-id <id>", "OAuth client id").option("--resource <url>", "Resource indicator for access tokens").option("--no-browser", "Use the device-code flow instead of opening a browser").action(
886
2425
  async (opts) => {
@@ -893,8 +2432,15 @@ auth.command("login").description("Sign in through your browser; falls back to a
893
2432
  });
894
2433
  }
895
2434
  );
896
- auth.command("logout").description("Sign out and remove stored tokens").action(async () => {
897
- await runLogout();
2435
+ auth.command("logout").description("Cancel your sign-in and remove it from this machine").option(
2436
+ "--forget-this-machine",
2437
+ "On a machine with no credential store, remove its sign-in record without cancelling anything"
2438
+ ).action(async (opts) => {
2439
+ const result = await runLogout({
2440
+ defaults: signInServerDefaults,
2441
+ forgetThisMachine: opts.forgetThisMachine === true
2442
+ });
2443
+ if (!result.ok) process.exitCode = 1;
898
2444
  });
899
2445
  auth.command("status").description("Show sign-in status").option("--issuer <url>", "Authorization server URL").option("--client-id <id>", "OAuth client id").option("--resource <url>", "Resource indicator for access tokens").action(async (opts) => {
900
2446
  await runStatus({