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.
- package/README.md +61 -2
- package/dist/index.js +1940 -394
- 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
|
-
|
|
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
|
|
428
|
+
const res = await fetchFn(url);
|
|
29
429
|
if (!res.ok) {
|
|
30
|
-
throw new
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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: "
|
|
2042
|
+
grant_type: "authorization_code",
|
|
2043
|
+
code,
|
|
2044
|
+
redirect_uri: redirectUri,
|
|
603
2045
|
client_id: options.clientId,
|
|
604
|
-
|
|
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 : "
|
|
617
|
-
throw new
|
|
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
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
|
2173
|
+
throw new RevocationRefused(res.status);
|
|
765
2174
|
}
|
|
766
2175
|
}
|
|
767
2176
|
|
|
768
2177
|
// src/commands/logout.ts
|
|
769
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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("
|
|
897
|
-
|
|
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({
|