dazzer-connect 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/dist/index.js +1512 -391
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -4,6 +4,390 @@
|
|
|
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
|
+
function readOfferedPlaces(offered) {
|
|
269
|
+
if (!Array.isArray(offered)) return [];
|
|
270
|
+
return offered.flatMap((place) => {
|
|
271
|
+
if (typeof place !== "object" || place === null) return [];
|
|
272
|
+
const { scope_id: scopeId, space } = place;
|
|
273
|
+
if (typeof scopeId !== "number" || !Number.isFinite(scopeId)) return [];
|
|
274
|
+
if (typeof space !== "string" || space.trim() === "") return [];
|
|
275
|
+
return [{ scopeId, space }];
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
async function readJson(res) {
|
|
279
|
+
try {
|
|
280
|
+
return await res.json();
|
|
281
|
+
} catch {
|
|
282
|
+
return void 0;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function refusalDetail(body) {
|
|
286
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
287
|
+
const flat = body.detail;
|
|
288
|
+
if (typeof flat === "string" && flat !== "") return flat;
|
|
289
|
+
const enveloped = body.error;
|
|
290
|
+
if (typeof enveloped === "object" && enveloped !== null) {
|
|
291
|
+
const message = enveloped.message;
|
|
292
|
+
if (typeof message === "string" && message !== "") return message;
|
|
293
|
+
}
|
|
294
|
+
return void 0;
|
|
295
|
+
}
|
|
296
|
+
function refusalCode(body) {
|
|
297
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
298
|
+
const flat = body.code;
|
|
299
|
+
if (typeof flat === "string" && flat !== "") return flat;
|
|
300
|
+
const enveloped = body.error;
|
|
301
|
+
if (typeof enveloped === "object" && enveloped !== null) {
|
|
302
|
+
const code = enveloped.code;
|
|
303
|
+
if (typeof code === "string" && code !== "") return code;
|
|
304
|
+
}
|
|
305
|
+
return void 0;
|
|
306
|
+
}
|
|
307
|
+
function refusalPlaces(body) {
|
|
308
|
+
if (typeof body !== "object" || body === null) return {};
|
|
309
|
+
const offered = body.choices;
|
|
310
|
+
const places = readOfferedPlaces(offered);
|
|
311
|
+
const keep = body.keep_to_yourself;
|
|
312
|
+
return {
|
|
313
|
+
...places.length > 0 ? { choices: places } : {},
|
|
314
|
+
...typeof keep === "boolean" ? { keepToYourself: keep } : {}
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
async function resolveCredential(auth2) {
|
|
318
|
+
let held;
|
|
319
|
+
try {
|
|
320
|
+
held = await auth2.tokenStore.load();
|
|
321
|
+
} catch {
|
|
322
|
+
return { status: "store-unreadable" };
|
|
323
|
+
}
|
|
324
|
+
if (!held) return { status: "signed-out" };
|
|
325
|
+
let state;
|
|
326
|
+
try {
|
|
327
|
+
state = await getValidAccessToken(auth2);
|
|
328
|
+
} catch {
|
|
329
|
+
return { status: "store-unreadable" };
|
|
330
|
+
}
|
|
331
|
+
switch (state.status) {
|
|
332
|
+
case "signed-out":
|
|
333
|
+
return { status: "signed-out" };
|
|
334
|
+
case "rejected":
|
|
335
|
+
return { status: "sign-in-invalid" };
|
|
336
|
+
case "unreachable":
|
|
337
|
+
return { held: held.accessToken };
|
|
338
|
+
case "refreshed": {
|
|
339
|
+
try {
|
|
340
|
+
const rotated = await auth2.tokenStore.load();
|
|
341
|
+
return rotated ? { held: rotated.accessToken } : { status: "signed-out" };
|
|
342
|
+
} catch {
|
|
343
|
+
return { status: "store-unreadable" };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async function callDazzer(request) {
|
|
349
|
+
const base = assertHttpsServiceAddress(request.baseUrl);
|
|
350
|
+
const credential = await resolveCredential(request.auth);
|
|
351
|
+
if (!("held" in credential)) {
|
|
352
|
+
if (credential.status === "sign-in-invalid") {
|
|
353
|
+
return { status: "sign-in-invalid", message: SIGN_IN_NO_LONGER_VALID };
|
|
354
|
+
}
|
|
355
|
+
return { status: credential.status };
|
|
356
|
+
}
|
|
357
|
+
const fetchFn = request.fetchFn ?? fetch;
|
|
358
|
+
const headers = {
|
|
359
|
+
accept: "application/json",
|
|
360
|
+
authorization: `Bearer ${credential.held}`
|
|
361
|
+
};
|
|
362
|
+
if (request.body !== void 0) headers["content-type"] = "application/json";
|
|
363
|
+
let res;
|
|
364
|
+
try {
|
|
365
|
+
res = await fetchFn(`${base}${request.path}`, {
|
|
366
|
+
method: request.method ?? "POST",
|
|
367
|
+
headers,
|
|
368
|
+
body: request.body === void 0 ? void 0 : JSON.stringify(request.body)
|
|
369
|
+
});
|
|
370
|
+
} catch {
|
|
371
|
+
return { status: "unreachable", message: CANNOT_REACH_DAZZER };
|
|
372
|
+
}
|
|
373
|
+
if (res.status === 401) {
|
|
374
|
+
return { status: "sign-in-invalid", message: SIGN_IN_NO_LONGER_VALID };
|
|
375
|
+
}
|
|
376
|
+
const body = await readJson(res);
|
|
377
|
+
if (!res.ok) {
|
|
378
|
+
const detail = refusalDetail(body);
|
|
379
|
+
const code = refusalCode(body);
|
|
380
|
+
return {
|
|
381
|
+
status: "refused",
|
|
382
|
+
httpStatus: res.status,
|
|
383
|
+
...detail === void 0 ? {} : { detail },
|
|
384
|
+
...code === void 0 ? {} : { code },
|
|
385
|
+
...refusalPlaces(body)
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
return { status: "ok", httpStatus: res.status, body };
|
|
389
|
+
}
|
|
390
|
+
|
|
7
391
|
// src/lib/config-store.ts
|
|
8
392
|
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
9
393
|
import { join } from "node:path";
|
|
@@ -22,12 +406,26 @@ function assertHttpsIssuer(issuer) {
|
|
|
22
406
|
}
|
|
23
407
|
return issuer.replace(/\/$/, "");
|
|
24
408
|
}
|
|
25
|
-
|
|
409
|
+
var DiscoveryAnswered = class extends Error {
|
|
410
|
+
constructor(status) {
|
|
411
|
+
super(`Could not reach the authorization server (${status}). Check --issuer.`);
|
|
412
|
+
this.status = status;
|
|
413
|
+
this.name = "DiscoveryAnswered";
|
|
414
|
+
}
|
|
415
|
+
status;
|
|
416
|
+
};
|
|
417
|
+
var DiscoveryUnusable = class extends Error {
|
|
418
|
+
constructor(message) {
|
|
419
|
+
super(message);
|
|
420
|
+
this.name = "DiscoveryUnusable";
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
async function discoverEndpoints(issuer, fetchFn = fetch) {
|
|
26
424
|
const normalized = assertHttpsIssuer(issuer);
|
|
27
425
|
const url = `${normalized}/.well-known/oauth-authorization-server`;
|
|
28
|
-
const res = await
|
|
426
|
+
const res = await fetchFn(url);
|
|
29
427
|
if (!res.ok) {
|
|
30
|
-
throw new
|
|
428
|
+
throw new DiscoveryAnswered(res.status);
|
|
31
429
|
}
|
|
32
430
|
const body = await res.json();
|
|
33
431
|
const authorizationEndpoint = body.authorization_endpoint;
|
|
@@ -35,13 +433,15 @@ async function discoverEndpoints(issuer) {
|
|
|
35
433
|
const tokenEndpoint = body.token_endpoint;
|
|
36
434
|
const revocationEndpoint = body.revocation_endpoint;
|
|
37
435
|
if (typeof tokenEndpoint !== "string" || tokenEndpoint === "") {
|
|
38
|
-
throw new
|
|
436
|
+
throw new DiscoveryUnusable("This authorization server is missing a token endpoint.");
|
|
39
437
|
}
|
|
40
438
|
const hasAuthorization = typeof authorizationEndpoint === "string" && authorizationEndpoint !== "";
|
|
41
439
|
const hasDevice = typeof deviceAuthorizationEndpoint === "string" && deviceAuthorizationEndpoint !== "";
|
|
42
440
|
const hasRevocation = typeof revocationEndpoint === "string" && revocationEndpoint !== "";
|
|
43
441
|
if (!hasAuthorization && !hasDevice) {
|
|
44
|
-
throw new
|
|
442
|
+
throw new DiscoveryUnusable(
|
|
443
|
+
"This authorization server does not support browser or device sign-in."
|
|
444
|
+
);
|
|
45
445
|
}
|
|
46
446
|
return {
|
|
47
447
|
issuer: typeof body.issuer === "string" ? body.issuer : normalized,
|
|
@@ -66,8 +466,9 @@ async function saveAuthMetadata(meta) {
|
|
|
66
466
|
throw new Error("The authorization server returned an unusable client id.");
|
|
67
467
|
}
|
|
68
468
|
const expiresAt = typeof meta.expiresAt === "number" && Number.isFinite(meta.expiresAt) ? meta.expiresAt : null;
|
|
469
|
+
const savedToStore = typeof meta.savedToStore === "boolean" ? meta.savedToStore : void 0;
|
|
69
470
|
await ensureConfigDir();
|
|
70
|
-
const checked = { issuer, clientId, expiresAt };
|
|
471
|
+
const checked = { issuer, clientId, expiresAt, savedToStore };
|
|
71
472
|
await writeFile(CONFIG_FILE, `${JSON.stringify(checked, null, 2)}
|
|
72
473
|
`, "utf8");
|
|
73
474
|
}
|
|
@@ -78,7 +479,8 @@ async function loadAuthMetadata() {
|
|
|
78
479
|
if (typeof parsed.issuer !== "string" || typeof parsed.clientId !== "string") {
|
|
79
480
|
return null;
|
|
80
481
|
}
|
|
81
|
-
|
|
482
|
+
const { savedToStore, ...rest } = parsed;
|
|
483
|
+
return typeof savedToStore === "boolean" ? { ...rest, savedToStore } : rest;
|
|
82
484
|
} catch {
|
|
83
485
|
return null;
|
|
84
486
|
}
|
|
@@ -86,7 +488,798 @@ async function loadAuthMetadata() {
|
|
|
86
488
|
async function clearAuthMetadata() {
|
|
87
489
|
try {
|
|
88
490
|
await unlink(CONFIG_FILE);
|
|
491
|
+
} catch (error) {
|
|
492
|
+
if (error.code !== "ENOENT") throw error;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function getConfigDir() {
|
|
496
|
+
return paths.config;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// src/lib/send.ts
|
|
500
|
+
var DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
501
|
+
var DEFAULT_ATTEMPTS = 5;
|
|
502
|
+
var BLOCK_BYTES = 256 * 1024;
|
|
503
|
+
var RESUMABLE_START = "x-goog-resumable";
|
|
504
|
+
function connectionDroppedLine(arrived, total) {
|
|
505
|
+
const percent = total > 0 ? Math.floor(arrived / total * 100) : 0;
|
|
506
|
+
return `Connection dropped at ${percent}% \u2014 picking up where it stopped.`;
|
|
507
|
+
}
|
|
508
|
+
function pieceEnd(start, total, chunkBytes) {
|
|
509
|
+
const proposed = Math.min(start + chunkBytes, total);
|
|
510
|
+
if (proposed >= total) return total;
|
|
511
|
+
const aligned = Math.floor(proposed / BLOCK_BYTES) * BLOCK_BYTES;
|
|
512
|
+
return aligned > start ? aligned : proposed;
|
|
513
|
+
}
|
|
514
|
+
function rangeReached(res) {
|
|
515
|
+
const range = res.headers.get("range");
|
|
516
|
+
if (range === null || range === "") return 0;
|
|
517
|
+
const match = /bytes=0-(\d+)/i.exec(range);
|
|
518
|
+
if (match?.[1] === void 0) return null;
|
|
519
|
+
return Number(match[1]) + 1;
|
|
520
|
+
}
|
|
521
|
+
async function openSession(uploadUrl, contentType, fetchFn) {
|
|
522
|
+
let res;
|
|
523
|
+
try {
|
|
524
|
+
res = await fetchFn(uploadUrl, {
|
|
525
|
+
method: "POST",
|
|
526
|
+
headers: { [RESUMABLE_START]: "start", "content-type": contentType },
|
|
527
|
+
// Never followed. The store answers this exchange with a 3xx-shaped status of its own that
|
|
528
|
+
// is not a redirect, and following it would send the session somewhere nobody signed.
|
|
529
|
+
redirect: "manual"
|
|
530
|
+
});
|
|
89
531
|
} catch {
|
|
532
|
+
return { started: null, reached: false };
|
|
533
|
+
}
|
|
534
|
+
if (res.status !== 200 && res.status !== 201) return { started: null, reached: true };
|
|
535
|
+
const location = res.headers.get("location");
|
|
536
|
+
return location === null || location === "" ? { started: null, reached: true } : { started: location };
|
|
537
|
+
}
|
|
538
|
+
async function askWhatArrived(session, total, fetchFn) {
|
|
539
|
+
let res;
|
|
540
|
+
try {
|
|
541
|
+
res = await fetchFn(session, {
|
|
542
|
+
method: "PUT",
|
|
543
|
+
headers: { "content-range": `bytes */${total}` },
|
|
544
|
+
redirect: "manual"
|
|
545
|
+
});
|
|
546
|
+
} catch {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
if (res.status === 200 || res.status === 201) return total;
|
|
550
|
+
if (res.status !== 308) return null;
|
|
551
|
+
return rangeReached(res);
|
|
552
|
+
}
|
|
553
|
+
async function sendFile(input) {
|
|
554
|
+
const fetchFn = input.fetchFn ?? fetch;
|
|
555
|
+
const chunkBytes = input.chunkBytes ?? DEFAULT_CHUNK_BYTES;
|
|
556
|
+
let attemptsLeft = input.attempts ?? DEFAULT_ATTEMPTS;
|
|
557
|
+
const session = await openSession(input.uploadUrl, input.contentType, fetchFn);
|
|
558
|
+
if (session.started === null) {
|
|
559
|
+
return { status: "not-started", reached: session.reached };
|
|
560
|
+
}
|
|
561
|
+
let arrived = 0;
|
|
562
|
+
while (arrived < input.byteSize) {
|
|
563
|
+
const end = pieceEnd(arrived, input.byteSize, chunkBytes);
|
|
564
|
+
const piece = await input.read(arrived, end - arrived);
|
|
565
|
+
let res;
|
|
566
|
+
try {
|
|
567
|
+
res = await fetchFn(session.started, {
|
|
568
|
+
method: "PUT",
|
|
569
|
+
headers: {
|
|
570
|
+
"content-type": input.contentType,
|
|
571
|
+
"content-range": `bytes ${arrived}-${end - 1}/${input.byteSize}`
|
|
572
|
+
},
|
|
573
|
+
body: piece,
|
|
574
|
+
redirect: "manual"
|
|
575
|
+
});
|
|
576
|
+
} catch {
|
|
577
|
+
attemptsLeft -= 1;
|
|
578
|
+
if (attemptsLeft <= 0) return { status: "incomplete", arrived };
|
|
579
|
+
const confirmed = await askWhatArrived(session.started, input.byteSize, fetchFn);
|
|
580
|
+
if (confirmed === null) return { status: "incomplete", arrived };
|
|
581
|
+
if (confirmed >= input.byteSize) return { status: "sent", arrived: input.byteSize };
|
|
582
|
+
input.onNote?.(connectionDroppedLine(confirmed, input.byteSize));
|
|
583
|
+
arrived = confirmed;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (res.status === 200 || res.status === 201)
|
|
587
|
+
return { status: "sent", arrived: input.byteSize };
|
|
588
|
+
if (res.status !== 308) return { status: "incomplete", arrived };
|
|
589
|
+
const reached = rangeReached(res);
|
|
590
|
+
if (reached === null || reached <= arrived) return { status: "incomplete", arrived };
|
|
591
|
+
arrived = reached;
|
|
592
|
+
}
|
|
593
|
+
return { status: "sent", arrived };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/lib/token-store.ts
|
|
597
|
+
import { Entry } from "@napi-rs/keyring";
|
|
598
|
+
var SERVICE = "dazzer-oauth-cli";
|
|
599
|
+
var ACCESS_ACCOUNT = "access-token";
|
|
600
|
+
var REFRESH_ACCOUNT = "refresh-token";
|
|
601
|
+
var KeychainTokenStore = class {
|
|
602
|
+
constructor(openEntry = (account) => new Entry(SERVICE, account)) {
|
|
603
|
+
this.openEntry = openEntry;
|
|
604
|
+
}
|
|
605
|
+
openEntry;
|
|
606
|
+
async save(tokens) {
|
|
607
|
+
this.openEntry(ACCESS_ACCOUNT).setPassword(tokens.accessToken);
|
|
608
|
+
this.openEntry(REFRESH_ACCOUNT).setPassword(tokens.refreshToken);
|
|
609
|
+
}
|
|
610
|
+
async load() {
|
|
611
|
+
const accessToken = this.openEntry(ACCESS_ACCOUNT).getPassword();
|
|
612
|
+
const refreshToken = this.openEntry(REFRESH_ACCOUNT).getPassword();
|
|
613
|
+
if (!accessToken || !refreshToken) {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
return { accessToken, refreshToken };
|
|
617
|
+
}
|
|
618
|
+
async loadRefreshToken() {
|
|
619
|
+
return this.openEntry(REFRESH_ACCOUNT).getPassword() || null;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* NOTHING SAVED IS NOT AN ERROR, AND A FAILED REMOVAL IS NEVER HIDDEN. A delete of nothing answers
|
|
623
|
+
* `false` rather than throwing, so there is no missing-entry error to swallow. What the keychain
|
|
624
|
+
* library does throw is a removal the store refused. Swallowing that told a person they were signed
|
|
625
|
+
* out while their sign-in was still saved on the machine.
|
|
626
|
+
*
|
|
627
|
+
* THE LONG-LIVED HALF GOES FIRST, AND IF IT WILL NOT, NOTHING ELSE IS TOUCHED. Both then stay, so a
|
|
628
|
+
* second sign-out still finds a whole sign-in to cancel. Removing the access token anyway would leave
|
|
629
|
+
* only the refresh token — which can still renew a sign-in for weeks. Once the refresh token is gone,
|
|
630
|
+
* an access token left behind by a failed second removal expires within the hour.
|
|
631
|
+
*/
|
|
632
|
+
async clear() {
|
|
633
|
+
this.openEntry(REFRESH_ACCOUNT).deletePassword();
|
|
634
|
+
this.openEntry(ACCESS_ACCOUNT).deletePassword();
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
function createTokenStore() {
|
|
638
|
+
return new KeychainTokenStore();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// src/lib/walk.ts
|
|
642
|
+
import { readdir, realpath, stat } from "node:fs/promises";
|
|
643
|
+
import { join as join2, sep } from "node:path";
|
|
644
|
+
function inside(base, candidate) {
|
|
645
|
+
return candidate === base || candidate.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
|
|
646
|
+
}
|
|
647
|
+
async function walkFolder(root) {
|
|
648
|
+
const base = await realpath(root);
|
|
649
|
+
if (!(await stat(base)).isDirectory()) {
|
|
650
|
+
throw new Error("walkFolder was given something that is not a folder");
|
|
651
|
+
}
|
|
652
|
+
const files = [];
|
|
653
|
+
const skipped = [];
|
|
654
|
+
const handedOver = /* @__PURE__ */ new Set();
|
|
655
|
+
const walked = /* @__PURE__ */ new Set([base]);
|
|
656
|
+
async function descend(dir, prefix, isRoot = false) {
|
|
657
|
+
let entries;
|
|
658
|
+
try {
|
|
659
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
660
|
+
} catch (err) {
|
|
661
|
+
if (isRoot) throw err;
|
|
662
|
+
skipped.push({ path: dir, relative: prefix.replace(/\/$/, ""), reason: "unreadable" });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
666
|
+
for (const entry of entries) {
|
|
667
|
+
if (entry.name.startsWith(".")) continue;
|
|
668
|
+
const full = join2(dir, entry.name);
|
|
669
|
+
const relative = `${prefix}${entry.name}`;
|
|
670
|
+
if (entry.isSymbolicLink()) {
|
|
671
|
+
await followShortcut(full, relative);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
if (entry.isDirectory()) {
|
|
675
|
+
await enter(full, relative);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
if (entry.isFile()) take(full, relative);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
async function enter(full, relative) {
|
|
682
|
+
let real;
|
|
683
|
+
try {
|
|
684
|
+
real = await realpath(full);
|
|
685
|
+
} catch {
|
|
686
|
+
skipped.push({ path: full, relative, reason: "unreadable" });
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (walked.has(real)) return;
|
|
690
|
+
walked.add(real);
|
|
691
|
+
await descend(real, `${relative}/`);
|
|
692
|
+
}
|
|
693
|
+
function take(full, relative, real) {
|
|
694
|
+
const identity = real ?? full;
|
|
695
|
+
if (handedOver.has(identity)) return;
|
|
696
|
+
handedOver.add(identity);
|
|
697
|
+
files.push({ path: full, relative });
|
|
698
|
+
}
|
|
699
|
+
async function followShortcut(full, relative) {
|
|
700
|
+
let real;
|
|
701
|
+
try {
|
|
702
|
+
real = await realpath(full);
|
|
703
|
+
} catch {
|
|
704
|
+
skipped.push({ path: full, relative, reason: "unreadable" });
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (!inside(base, real)) {
|
|
708
|
+
skipped.push({ path: full, relative, reason: "leads-out" });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
let kind;
|
|
712
|
+
try {
|
|
713
|
+
kind = await stat(real);
|
|
714
|
+
} catch {
|
|
715
|
+
skipped.push({ path: full, relative, reason: "unreadable" });
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (kind.isDirectory()) {
|
|
719
|
+
if (walked.has(real)) return;
|
|
720
|
+
walked.add(real);
|
|
721
|
+
await descend(real, `${relative}/`);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (kind.isFile()) take(real, relative, real);
|
|
725
|
+
}
|
|
726
|
+
await descend(base, "", true);
|
|
727
|
+
files.sort((a, b) => a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0);
|
|
728
|
+
skipped.sort((a, b) => a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0);
|
|
729
|
+
return { files, skipped };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// src/commands/add.ts
|
|
733
|
+
var SPACE_PATH = "/documents/v1/space";
|
|
734
|
+
var LANDING_PATH = "/documents/v1/landing";
|
|
735
|
+
var LANDED_PATH = "/documents/v1/landed";
|
|
736
|
+
var cannotOpen = (name) => `Couldn't open ${name} \u2014 check its permissions.`;
|
|
737
|
+
var SLOWING_DOWN = "Slowing down to stay within limits \u2014 still going.";
|
|
738
|
+
var ALREADY_IN = "That one's already in \u2014 nothing to do.";
|
|
739
|
+
var waitingOnYou = (many) => `${many} waiting on your answer.`;
|
|
740
|
+
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?`;
|
|
741
|
+
function listed(names) {
|
|
742
|
+
if (names.length <= 1) return names[0] ?? "";
|
|
743
|
+
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
744
|
+
}
|
|
745
|
+
var NUMBER_WORDS = [
|
|
746
|
+
"",
|
|
747
|
+
"one",
|
|
748
|
+
"two",
|
|
749
|
+
"three",
|
|
750
|
+
"four",
|
|
751
|
+
"five",
|
|
752
|
+
"six",
|
|
753
|
+
"seven",
|
|
754
|
+
"eight",
|
|
755
|
+
"nine",
|
|
756
|
+
"ten"
|
|
757
|
+
];
|
|
758
|
+
var inWords = (many) => NUMBER_WORDS[many] ?? String(many);
|
|
759
|
+
var cannotFind = (name) => `Couldn't find ${name}.`;
|
|
760
|
+
var didNotFinish = (name) => `The send didn't finish \u2014 ${name} is not in Dazzer. Try again shortly.`;
|
|
761
|
+
var wentWrong = (name) => `Something went wrong and ${name} is not in Dazzer. Try again shortly.`;
|
|
762
|
+
var choiceLine = (space, scopeId) => ` ${asPrinted(space)} \u2014 ${scopeId}`;
|
|
763
|
+
var answerWith = (keepToYourself, numbered) => {
|
|
764
|
+
const ways = [
|
|
765
|
+
'--space "<name>"',
|
|
766
|
+
...numbered ? ["--space <number>"] : [],
|
|
767
|
+
...keepToYourself ? ["--keep-to-myself"] : []
|
|
768
|
+
];
|
|
769
|
+
const last = ways[ways.length - 1] ?? "";
|
|
770
|
+
const said = ways.length === 1 ? last : `${ways.slice(0, -1).join(", ")}, or ${last}`;
|
|
771
|
+
return `Run this again with ${said}.`;
|
|
772
|
+
};
|
|
773
|
+
var DECLARED_BY_EXTENSION = {
|
|
774
|
+
".csv": "text/csv",
|
|
775
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
776
|
+
".gif": "image/gif",
|
|
777
|
+
".htm": "text/html",
|
|
778
|
+
".html": "text/html",
|
|
779
|
+
".jpeg": "image/jpeg",
|
|
780
|
+
".jpg": "image/jpeg",
|
|
781
|
+
".markdown": "text/markdown",
|
|
782
|
+
".md": "text/markdown",
|
|
783
|
+
".pdf": "application/pdf",
|
|
784
|
+
".png": "image/png",
|
|
785
|
+
".rtf": "application/rtf",
|
|
786
|
+
".text": "text/plain",
|
|
787
|
+
".txt": "text/plain",
|
|
788
|
+
".webp": "image/webp"
|
|
789
|
+
};
|
|
790
|
+
var UNDECLARED = "application/octet-stream";
|
|
791
|
+
function asPrinted(name) {
|
|
792
|
+
return name.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ").trim();
|
|
793
|
+
}
|
|
794
|
+
function readEnvelope(body) {
|
|
795
|
+
if (typeof body !== "object" || body === null) return null;
|
|
796
|
+
const data = body.data;
|
|
797
|
+
return typeof data === "object" && data !== null ? data : null;
|
|
798
|
+
}
|
|
799
|
+
function readChoices(data) {
|
|
800
|
+
return readOfferedPlaces(data.choices);
|
|
801
|
+
}
|
|
802
|
+
function readLanding(body) {
|
|
803
|
+
const data = readEnvelope(body);
|
|
804
|
+
if (data === null) return null;
|
|
805
|
+
const { ticket, upload_url: uploadUrl, content_type: contentType, space_name: space } = data;
|
|
806
|
+
if (typeof ticket !== "string" || ticket === "") return null;
|
|
807
|
+
if (typeof uploadUrl !== "string" || uploadUrl === "") return null;
|
|
808
|
+
if (typeof contentType !== "string" || contentType === "") return null;
|
|
809
|
+
return {
|
|
810
|
+
ticket,
|
|
811
|
+
upload_url: uploadUrl,
|
|
812
|
+
content_type: contentType,
|
|
813
|
+
space_name: typeof space === "string" && space.trim() !== "" ? space : null
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
function named(space) {
|
|
817
|
+
return space === null ? "that space" : space;
|
|
818
|
+
}
|
|
819
|
+
async function resolveAuth(options) {
|
|
820
|
+
const tokenStore = createTokenStore();
|
|
821
|
+
const meta = await loadAuthMetadata();
|
|
822
|
+
const issuer = meta?.issuer ?? options.issuer;
|
|
823
|
+
try {
|
|
824
|
+
const discovery = await discoverEndpoints(issuer);
|
|
825
|
+
return {
|
|
826
|
+
discovery,
|
|
827
|
+
clientId: meta?.clientId ?? options.clientId,
|
|
828
|
+
tokenStore,
|
|
829
|
+
resource: options.resource
|
|
830
|
+
};
|
|
831
|
+
} catch {
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
var FIRST_WAIT_MS = 1e3;
|
|
836
|
+
var LONGEST_WAIT_MS = 3e4;
|
|
837
|
+
var PACED_ATTEMPTS = 6;
|
|
838
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
839
|
+
function voiceFor(write) {
|
|
840
|
+
let said = false;
|
|
841
|
+
return {
|
|
842
|
+
write,
|
|
843
|
+
slowingDown: () => {
|
|
844
|
+
if (said) return;
|
|
845
|
+
said = true;
|
|
846
|
+
write(SLOWING_DOWN);
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
async function callPaced(request, deps, voice) {
|
|
851
|
+
const wait = deps.delay ?? sleep;
|
|
852
|
+
let waiting = FIRST_WAIT_MS;
|
|
853
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
854
|
+
const answer = await callDazzer(request);
|
|
855
|
+
if (answer.status !== "refused" || answer.httpStatus !== 429) return answer;
|
|
856
|
+
if (attempt >= PACED_ATTEMPTS) return answer;
|
|
857
|
+
voice.slowingDown();
|
|
858
|
+
await wait(waiting);
|
|
859
|
+
waiting = Math.min(waiting * 2, LONGEST_WAIT_MS);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
function askedWith(options) {
|
|
863
|
+
if (options.keepToMyself === true) return { keep_to_myself: true };
|
|
864
|
+
const answer = options.space?.trim();
|
|
865
|
+
if (answer === void 0 || answer === "") return {};
|
|
866
|
+
return { space: /^\d+$/.test(answer) ? Number(answer) : answer };
|
|
867
|
+
}
|
|
868
|
+
async function settleSpace(options, auth2, deps, voice, name) {
|
|
869
|
+
const answer = await callPaced(
|
|
870
|
+
{
|
|
871
|
+
baseUrl: options.baseUrl,
|
|
872
|
+
path: SPACE_PATH,
|
|
873
|
+
method: "POST",
|
|
874
|
+
body: askedWith(options),
|
|
875
|
+
auth: auth2,
|
|
876
|
+
fetchFn: deps.fetchFn
|
|
877
|
+
},
|
|
878
|
+
deps,
|
|
879
|
+
voice
|
|
880
|
+
);
|
|
881
|
+
if (answer.status !== "ok") {
|
|
882
|
+
say(voice.write, answer, name);
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
const data = readEnvelope(answer.body);
|
|
886
|
+
if (data === null) {
|
|
887
|
+
voice.write(wentWrong(name));
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
const space = typeof data.space === "string" && data.space.trim() !== "" ? data.space : null;
|
|
891
|
+
if (data.settled === true) {
|
|
892
|
+
return { scopeId: typeof data.scope_id === "number" ? data.scope_id : null, space };
|
|
893
|
+
}
|
|
894
|
+
const question = typeof data.question === "string" && data.question !== "" ? data.question : null;
|
|
895
|
+
if (question === null) {
|
|
896
|
+
voice.write(wentWrong(name));
|
|
897
|
+
return null;
|
|
898
|
+
}
|
|
899
|
+
voice.write(question);
|
|
900
|
+
const choices = readChoices(data);
|
|
901
|
+
for (const choice of choices) voice.write(choiceLine(choice.space, choice.scopeId));
|
|
902
|
+
voice.write(answerWith(data.keep_to_yourself === true, choices.length > 0));
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
var HELD_FOR_AN_ANSWER = "same_name_different_contents";
|
|
906
|
+
function fromAnswer(answer) {
|
|
907
|
+
if (answer.status === "refused") {
|
|
908
|
+
if (answer.code === HELD_FOR_AN_ANSWER && answer.detail !== void 0) {
|
|
909
|
+
return { kind: "held", question: answer.detail };
|
|
910
|
+
}
|
|
911
|
+
return {
|
|
912
|
+
kind: "refused",
|
|
913
|
+
...answer.detail === void 0 ? {} : { said: answer.detail },
|
|
914
|
+
...answer.code === void 0 ? {} : { code: answer.code },
|
|
915
|
+
// Carried, not filled in: absent stays absent, so nothing downstream can print an offer the
|
|
916
|
+
// service did not make.
|
|
917
|
+
...answer.choices === void 0 ? {} : { choices: answer.choices },
|
|
918
|
+
...answer.keepToYourself === void 0 ? {} : { keepToYourself: answer.keepToYourself }
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
return { kind: "stop", answer };
|
|
922
|
+
}
|
|
923
|
+
async function sendOne(input) {
|
|
924
|
+
const { deps, handle, options, voice } = input;
|
|
925
|
+
const described = await callPaced(
|
|
926
|
+
{
|
|
927
|
+
baseUrl: options.baseUrl,
|
|
928
|
+
path: LANDING_PATH,
|
|
929
|
+
method: "POST",
|
|
930
|
+
body: {
|
|
931
|
+
file_name: basename(input.path),
|
|
932
|
+
byte_size: input.byteSize,
|
|
933
|
+
mime_type: DECLARED_BY_EXTENSION[extname(input.path).toLowerCase()] ?? UNDECLARED,
|
|
934
|
+
...input.scopeId === null ? {} : { scope_id: input.scopeId }
|
|
935
|
+
},
|
|
936
|
+
auth: input.auth,
|
|
937
|
+
fetchFn: deps.fetchFn
|
|
938
|
+
},
|
|
939
|
+
deps,
|
|
940
|
+
voice
|
|
941
|
+
);
|
|
942
|
+
if (described.status !== "ok") return fromAnswer(described);
|
|
943
|
+
const landing = readLanding(described.body);
|
|
944
|
+
if (landing === null) return { kind: "went-wrong" };
|
|
945
|
+
let couldNotRead = false;
|
|
946
|
+
let sent;
|
|
947
|
+
try {
|
|
948
|
+
sent = await sendFile({
|
|
949
|
+
uploadUrl: landing.upload_url,
|
|
950
|
+
contentType: landing.content_type,
|
|
951
|
+
byteSize: input.byteSize,
|
|
952
|
+
read: async (start, length) => {
|
|
953
|
+
try {
|
|
954
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
955
|
+
let filled = 0;
|
|
956
|
+
while (filled < length) {
|
|
957
|
+
const { bytesRead } = await handle.read(
|
|
958
|
+
buffer,
|
|
959
|
+
filled,
|
|
960
|
+
length - filled,
|
|
961
|
+
start + filled
|
|
962
|
+
);
|
|
963
|
+
if (bytesRead === 0) break;
|
|
964
|
+
filled += bytesRead;
|
|
965
|
+
}
|
|
966
|
+
return filled === length ? buffer : buffer.subarray(0, filled);
|
|
967
|
+
} catch (err) {
|
|
968
|
+
couldNotRead = true;
|
|
969
|
+
throw err;
|
|
970
|
+
}
|
|
971
|
+
},
|
|
972
|
+
fetchFn: deps.uploadFetchFn,
|
|
973
|
+
chunkBytes: deps.chunkBytes,
|
|
974
|
+
attempts: deps.attempts,
|
|
975
|
+
onNote: voice.write
|
|
976
|
+
});
|
|
977
|
+
} catch (err) {
|
|
978
|
+
if (couldNotRead) return { kind: "cannot-read" };
|
|
979
|
+
throw err;
|
|
980
|
+
}
|
|
981
|
+
if (sent.status !== "sent") {
|
|
982
|
+
return sent.status === "not-started" && !sent.reached ? { kind: "not-reached" } : { kind: "did-not-finish" };
|
|
983
|
+
}
|
|
984
|
+
const landed = await callPaced(
|
|
985
|
+
{
|
|
986
|
+
baseUrl: options.baseUrl,
|
|
987
|
+
path: LANDED_PATH,
|
|
988
|
+
method: "POST",
|
|
989
|
+
body: { ticket: landing.ticket },
|
|
990
|
+
auth: input.auth,
|
|
991
|
+
fetchFn: deps.fetchFn
|
|
992
|
+
},
|
|
993
|
+
deps,
|
|
994
|
+
voice
|
|
995
|
+
);
|
|
996
|
+
if (landed.status !== "ok") return fromAnswer(landed);
|
|
997
|
+
const stored = readEnvelope(landed.body);
|
|
998
|
+
if (stored === null) return { kind: "went-wrong" };
|
|
999
|
+
if (stored.already_here === true) return { kind: "already-here" };
|
|
1000
|
+
return {
|
|
1001
|
+
kind: "added",
|
|
1002
|
+
fileName: typeof stored.file_name === "string" ? asPrinted(stored.file_name) : asPrinted(basename(input.path)),
|
|
1003
|
+
space: typeof stored.space_name === "string" ? stored.space_name : null
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
async function sendOneSafely(input) {
|
|
1007
|
+
try {
|
|
1008
|
+
return await sendOne(input);
|
|
1009
|
+
} catch {
|
|
1010
|
+
return { kind: "went-wrong" };
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
async function openOne(path) {
|
|
1014
|
+
try {
|
|
1015
|
+
return await open(path, "r");
|
|
1016
|
+
} catch (err) {
|
|
1017
|
+
return err?.code === "ENOENT" ? "missing" : "cannot-open";
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
var REASON_ORDER = [
|
|
1021
|
+
"type-not-readable",
|
|
1022
|
+
"too-large",
|
|
1023
|
+
"already-here",
|
|
1024
|
+
"refused",
|
|
1025
|
+
"cannot-open",
|
|
1026
|
+
"did-not-finish",
|
|
1027
|
+
"leads-out"
|
|
1028
|
+
];
|
|
1029
|
+
var FROM_THE_SERVICE = {
|
|
1030
|
+
type_not_readable: "type-not-readable",
|
|
1031
|
+
too_large: "too-large"
|
|
1032
|
+
};
|
|
1033
|
+
var COUNTED = {
|
|
1034
|
+
"type-not-readable": (many) => `${many} file type${many === 1 ? "" : "s"} I can't read`,
|
|
1035
|
+
"too-large": (many) => `${many} too large`,
|
|
1036
|
+
"already-here": (many) => `${many} already there`,
|
|
1037
|
+
refused: (many) => `${many} refused`,
|
|
1038
|
+
"cannot-open": (many) => `${many} I couldn't open`,
|
|
1039
|
+
"did-not-finish": (many) => `${many} that didn't finish`,
|
|
1040
|
+
"leads-out": (many) => `${many} shortcut${many === 1 ? "" : "s"} out of the folder`
|
|
1041
|
+
};
|
|
1042
|
+
var IN_DETAIL = {
|
|
1043
|
+
"type-not-readable": "a file type I can't read",
|
|
1044
|
+
"too-large": "too large",
|
|
1045
|
+
"already-here": "already there",
|
|
1046
|
+
refused: "refused",
|
|
1047
|
+
"cannot-open": "I couldn't open it",
|
|
1048
|
+
"did-not-finish": "the send didn't finish",
|
|
1049
|
+
"leads-out": "a shortcut out of the folder"
|
|
1050
|
+
};
|
|
1051
|
+
function countLine(added, space, skipped, why) {
|
|
1052
|
+
const documents = `${added} document${added === 1 ? "" : "s"} in ${named(space)}.`;
|
|
1053
|
+
if (skipped.length === 0) return documents;
|
|
1054
|
+
const reasons = REASON_ORDER.flatMap((reason) => {
|
|
1055
|
+
const many = skipped.filter((skip) => skip.reason === reason).length;
|
|
1056
|
+
return many === 0 ? [] : [COUNTED[reason](many)];
|
|
1057
|
+
}).join(", ");
|
|
1058
|
+
return `${documents} ${skipped.length} skipped \u2014 ${reasons}.${why ? "" : " Run with --why to see each."}`;
|
|
1059
|
+
}
|
|
1060
|
+
function whyLine(skip) {
|
|
1061
|
+
const because = skip.said === void 0 ? IN_DETAIL[skip.reason] : skip.reason === "refused" ? `refused: ${skip.said}` : skip.said;
|
|
1062
|
+
return `${asPrinted(skip.relative)} \u2014 ${because}`;
|
|
1063
|
+
}
|
|
1064
|
+
var FROM_THE_WALK = {
|
|
1065
|
+
"leads-out": "leads-out",
|
|
1066
|
+
unreadable: "cannot-open"
|
|
1067
|
+
};
|
|
1068
|
+
async function runAdd(options, deps = {}) {
|
|
1069
|
+
const voice = voiceFor(deps.write ?? ((line) => process.stdout.write(`${line}
|
|
1070
|
+
`)));
|
|
1071
|
+
const name = asPrinted(basename(options.file));
|
|
1072
|
+
let isFolder;
|
|
1073
|
+
try {
|
|
1074
|
+
isFolder = (await statPath(options.file)).isDirectory();
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
voice.write(
|
|
1077
|
+
err?.code === "ENOENT" ? cannotFind(name) : cannotOpen(name)
|
|
1078
|
+
);
|
|
1079
|
+
return { ok: false };
|
|
1080
|
+
}
|
|
1081
|
+
return isFolder ? await addFolder(options, deps, voice) : await addOneFile(options, deps, voice);
|
|
1082
|
+
}
|
|
1083
|
+
async function addOneFile(options, deps, voice) {
|
|
1084
|
+
const name = asPrinted(basename(options.file));
|
|
1085
|
+
const handle = await openOne(options.file);
|
|
1086
|
+
if (typeof handle === "string") {
|
|
1087
|
+
voice.write(handle === "missing" ? cannotFind(name) : cannotOpen(name));
|
|
1088
|
+
return { ok: false };
|
|
1089
|
+
}
|
|
1090
|
+
try {
|
|
1091
|
+
const byteSize = (await handle.stat()).size;
|
|
1092
|
+
const auth2 = deps.auth ?? await resolveAuth(options);
|
|
1093
|
+
if (auth2 === null) {
|
|
1094
|
+
voice.write(CANNOT_REACH_DAZZER);
|
|
1095
|
+
return { ok: false };
|
|
1096
|
+
}
|
|
1097
|
+
const settled = await settleSpace(options, auth2, deps, voice, name);
|
|
1098
|
+
if (settled === null) return { ok: false };
|
|
1099
|
+
const outcome = await sendOneSafely({
|
|
1100
|
+
path: options.file,
|
|
1101
|
+
handle,
|
|
1102
|
+
byteSize,
|
|
1103
|
+
scopeId: settled.scopeId,
|
|
1104
|
+
options,
|
|
1105
|
+
deps,
|
|
1106
|
+
auth: auth2,
|
|
1107
|
+
voice
|
|
1108
|
+
});
|
|
1109
|
+
switch (outcome.kind) {
|
|
1110
|
+
case "added":
|
|
1111
|
+
voice.write(`${outcome.fileName} is in Dazzer, in ${named(outcome.space)}.`);
|
|
1112
|
+
return { ok: true };
|
|
1113
|
+
case "already-here":
|
|
1114
|
+
voice.write(ALREADY_IN);
|
|
1115
|
+
return { ok: true };
|
|
1116
|
+
// ONE FILE, ONE QUESTION, IN THE SERVICE'S OWN WORDS. It is the only thing that knows what it
|
|
1117
|
+
// already holds and what the space is called, and rewriting its question here would be a
|
|
1118
|
+
// second wording of a question somebody approved. Nothing was added and nobody has answered,
|
|
1119
|
+
// so this run did not do what it was asked to — the same answer a run that asked where things
|
|
1120
|
+
// go gives.
|
|
1121
|
+
case "held":
|
|
1122
|
+
voice.write(outcome.question);
|
|
1123
|
+
return { ok: false };
|
|
1124
|
+
// ONE FILE, ONE REFUSAL, AND THE PLACES IT OFFERED. The sentence is the service's and is
|
|
1125
|
+
// printed as sent; the places under it are the service's too, and without them a refusal
|
|
1126
|
+
// that says to name one of them is a dead end for the person holding this one file.
|
|
1127
|
+
case "refused":
|
|
1128
|
+
voice.write(outcome.said ?? wentWrong(name));
|
|
1129
|
+
offerThePlaces(voice.write, outcome);
|
|
1130
|
+
return { ok: false };
|
|
1131
|
+
case "did-not-finish":
|
|
1132
|
+
voice.write(didNotFinish(name));
|
|
1133
|
+
return { ok: false };
|
|
1134
|
+
case "not-reached":
|
|
1135
|
+
voice.write(CANNOT_REACH_DAZZER);
|
|
1136
|
+
return { ok: false };
|
|
1137
|
+
case "went-wrong":
|
|
1138
|
+
voice.write(wentWrong(name));
|
|
1139
|
+
return { ok: false };
|
|
1140
|
+
// IT OPENED AND WOULD NOT READ — answered in the approved sentence for a file that cannot be
|
|
1141
|
+
// opened, because that is the same thing a person can act on, and never as a crash.
|
|
1142
|
+
case "cannot-read":
|
|
1143
|
+
voice.write(cannotOpen(name));
|
|
1144
|
+
return { ok: false };
|
|
1145
|
+
case "stop":
|
|
1146
|
+
say(voice.write, outcome.answer, name);
|
|
1147
|
+
return { ok: false };
|
|
1148
|
+
}
|
|
1149
|
+
} finally {
|
|
1150
|
+
await handle.close();
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
async function addFolder(options, deps, voice) {
|
|
1154
|
+
const name = asPrinted(basename(options.file));
|
|
1155
|
+
const walked = await (async () => {
|
|
1156
|
+
try {
|
|
1157
|
+
return await walkFolder(options.file);
|
|
1158
|
+
} catch {
|
|
1159
|
+
return null;
|
|
1160
|
+
}
|
|
1161
|
+
})();
|
|
1162
|
+
if (walked === null) {
|
|
1163
|
+
voice.write(cannotOpen(name));
|
|
1164
|
+
return { ok: false };
|
|
1165
|
+
}
|
|
1166
|
+
const auth2 = deps.auth ?? await resolveAuth(options);
|
|
1167
|
+
if (auth2 === null) {
|
|
1168
|
+
voice.write(CANNOT_REACH_DAZZER);
|
|
1169
|
+
return { ok: false };
|
|
1170
|
+
}
|
|
1171
|
+
const settled = await settleSpace(options, auth2, deps, voice, name);
|
|
1172
|
+
if (settled === null) return { ok: false };
|
|
1173
|
+
const skipped = walked.skipped.map((skip) => ({
|
|
1174
|
+
relative: skip.relative,
|
|
1175
|
+
reason: FROM_THE_WALK[skip.reason]
|
|
1176
|
+
}));
|
|
1177
|
+
const held = [];
|
|
1178
|
+
let added = 0;
|
|
1179
|
+
let ran = true;
|
|
1180
|
+
for (const file of walked.files) {
|
|
1181
|
+
const handle = await openOne(file.path);
|
|
1182
|
+
if (typeof handle === "string") {
|
|
1183
|
+
skipped.push({ relative: file.relative, reason: "cannot-open" });
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
let outcome;
|
|
1187
|
+
try {
|
|
1188
|
+
outcome = await sendOneSafely({
|
|
1189
|
+
path: file.path,
|
|
1190
|
+
handle,
|
|
1191
|
+
byteSize: (await handle.stat()).size,
|
|
1192
|
+
scopeId: settled.scopeId,
|
|
1193
|
+
options,
|
|
1194
|
+
deps,
|
|
1195
|
+
auth: auth2,
|
|
1196
|
+
voice
|
|
1197
|
+
});
|
|
1198
|
+
} finally {
|
|
1199
|
+
await handle.close();
|
|
1200
|
+
}
|
|
1201
|
+
if (outcome.kind === "added") {
|
|
1202
|
+
added += 1;
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
if (outcome.kind === "already-here") {
|
|
1206
|
+
skipped.push({ relative: file.relative, reason: "already-here" });
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
if (outcome.kind === "held") {
|
|
1210
|
+
held.push({
|
|
1211
|
+
relative: file.relative,
|
|
1212
|
+
name: basename(file.path),
|
|
1213
|
+
question: outcome.question
|
|
1214
|
+
});
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
if (outcome.kind === "refused") {
|
|
1218
|
+
skipped.push({
|
|
1219
|
+
relative: file.relative,
|
|
1220
|
+
// KEYED ON THE SERVICE'S CODE, never on the words it sent: the words are the person's and
|
|
1221
|
+
// may be reworded, the code is the label the service publishes.
|
|
1222
|
+
reason: (outcome.code === void 0 ? void 0 : FROM_THE_SERVICE[outcome.code]) ?? "refused",
|
|
1223
|
+
...outcome.said === void 0 ? {} : { said: outcome.said }
|
|
1224
|
+
});
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
if (outcome.kind === "cannot-read") {
|
|
1228
|
+
skipped.push({ relative: file.relative, reason: "cannot-open" });
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
if (outcome.kind === "did-not-finish" || outcome.kind === "went-wrong") {
|
|
1232
|
+
skipped.push({ relative: file.relative, reason: "did-not-finish" });
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
if (outcome.kind === "not-reached") voice.write(CANNOT_REACH_DAZZER);
|
|
1236
|
+
else say(voice.write, outcome.answer, name);
|
|
1237
|
+
ran = false;
|
|
1238
|
+
break;
|
|
1239
|
+
}
|
|
1240
|
+
skipped.sort((a, b) => a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0);
|
|
1241
|
+
held.sort((a, b) => a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0);
|
|
1242
|
+
voice.write(countLine(added, settled.space, skipped, options.why === true));
|
|
1243
|
+
if (held.length > 0) voice.write(waitingOnYou(held.length));
|
|
1244
|
+
if (options.why === true) for (const skip of skipped) voice.write(whyLine(skip));
|
|
1245
|
+
const onlyOne = held.length === 1 ? held[0] : void 0;
|
|
1246
|
+
if (onlyOne !== void 0) {
|
|
1247
|
+
voice.write(onlyOne.question);
|
|
1248
|
+
} else if (held.length > 1) {
|
|
1249
|
+
voice.write(
|
|
1250
|
+
collectedQuestion(
|
|
1251
|
+
held.map((one) => asPrinted(one.name)),
|
|
1252
|
+
named(settled.space)
|
|
1253
|
+
)
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
return { ok: ran };
|
|
1257
|
+
}
|
|
1258
|
+
function offerThePlaces(write, offer) {
|
|
1259
|
+
const places = offer.choices ?? [];
|
|
1260
|
+
const keepToYourself = offer.keepToYourself === true;
|
|
1261
|
+
if (places.length === 0 && !keepToYourself) return;
|
|
1262
|
+
for (const place of places) write(choiceLine(place.space, place.scopeId));
|
|
1263
|
+
write(answerWith(keepToYourself, places.length > 0));
|
|
1264
|
+
}
|
|
1265
|
+
function say(write, answer, name) {
|
|
1266
|
+
switch (answer.status) {
|
|
1267
|
+
case "refused":
|
|
1268
|
+
write(answer.detail ?? wentWrong(name));
|
|
1269
|
+
offerThePlaces(write, answer);
|
|
1270
|
+
return;
|
|
1271
|
+
case "sign-in-invalid":
|
|
1272
|
+
case "unreachable":
|
|
1273
|
+
write(answer.message);
|
|
1274
|
+
return;
|
|
1275
|
+
case "signed-out":
|
|
1276
|
+
printAuthStatus({ state: "signed-out" });
|
|
1277
|
+
return;
|
|
1278
|
+
case "store-unreadable":
|
|
1279
|
+
printAuthStatus({ state: "store-unreadable" });
|
|
1280
|
+
return;
|
|
1281
|
+
case "ok":
|
|
1282
|
+
return;
|
|
90
1283
|
}
|
|
91
1284
|
}
|
|
92
1285
|
|
|
@@ -134,12 +1327,12 @@ async function requestDeviceCode(options) {
|
|
|
134
1327
|
}
|
|
135
1328
|
async function pollForTokens(options) {
|
|
136
1329
|
const fetchFn = options.fetchFn ?? fetch;
|
|
137
|
-
const
|
|
1330
|
+
const sleep2 = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
138
1331
|
const baseIntervalMs = (options.deviceAuth.interval ?? 5) * 1e3;
|
|
139
1332
|
let intervalMs = options.pollIntervalMs ?? baseIntervalMs;
|
|
140
1333
|
const deadline = Date.now() + (options.maxWaitMs ?? options.deviceAuth.expires_in * 1e3);
|
|
141
1334
|
while (Date.now() < deadline) {
|
|
142
|
-
await
|
|
1335
|
+
await sleep2(intervalMs);
|
|
143
1336
|
const params = new URLSearchParams({
|
|
144
1337
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
145
1338
|
device_code: options.deviceCode,
|
|
@@ -327,281 +1520,113 @@ function buildAuthorizeUrl(endpoint, params) {
|
|
|
327
1520
|
}
|
|
328
1521
|
return url.toString();
|
|
329
1522
|
}
|
|
330
|
-
function statusPage(title, heading, body, mark) {
|
|
331
|
-
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
|
-
}
|
|
1523
|
+
function statusPage(title, heading, body, mark) {
|
|
1524
|
+
return renderDocument({
|
|
1525
|
+
title,
|
|
1526
|
+
centered: true,
|
|
1527
|
+
body: pageShell(`${mark}<h1>${heading}</h1><p>${body}</p>
|
|
1528
|
+
<div class="foot">Dazzer secure sign-in</div>`)
|
|
1529
|
+
});
|
|
575
1530
|
}
|
|
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."
|
|
1531
|
+
function successPage() {
|
|
1532
|
+
return statusPage(
|
|
1533
|
+
"Signed in to Dazzer",
|
|
1534
|
+
"You're signed in",
|
|
1535
|
+
"You can close this tab and return to your terminal.",
|
|
1536
|
+
successMark
|
|
583
1537
|
);
|
|
584
1538
|
}
|
|
585
|
-
function
|
|
586
|
-
|
|
587
|
-
|
|
1539
|
+
function errorPage(reason) {
|
|
1540
|
+
return statusPage(
|
|
1541
|
+
"Sign-in failed",
|
|
1542
|
+
"Something went wrong",
|
|
1543
|
+
`We couldn't complete sign-in (${escapeHtml(reason)}). Close this tab and run the command again.`,
|
|
1544
|
+
errorMark
|
|
1545
|
+
);
|
|
588
1546
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
this.name = "RefreshRejectedError";
|
|
596
|
-
}
|
|
597
|
-
oauthError;
|
|
598
|
-
};
|
|
599
|
-
async function refreshAccessToken(options) {
|
|
1547
|
+
function sendHtml(res, html) {
|
|
1548
|
+
res.statusCode = 200;
|
|
1549
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1550
|
+
res.end(html);
|
|
1551
|
+
}
|
|
1552
|
+
async function runLoopbackFlow(options) {
|
|
600
1553
|
const fetchFn = options.fetchFn ?? fetch;
|
|
1554
|
+
const { authorizationEndpoint } = options.discovery;
|
|
1555
|
+
if (!authorizationEndpoint) {
|
|
1556
|
+
throw new Error("This authorization server does not support browser sign-in.");
|
|
1557
|
+
}
|
|
1558
|
+
const { verifier, challenge } = createPkce();
|
|
1559
|
+
const state = base64url(randomBytes(16));
|
|
1560
|
+
const scope = options.scope ?? DEFAULT_SCOPE2;
|
|
1561
|
+
const { code, redirectUri } = await new Promise(
|
|
1562
|
+
(resolve, reject) => {
|
|
1563
|
+
let timer;
|
|
1564
|
+
const server = createServer((req, res2) => {
|
|
1565
|
+
const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1566
|
+
if (requestUrl.pathname !== CALLBACK_PATH) {
|
|
1567
|
+
res2.statusCode = 404;
|
|
1568
|
+
res2.end("Not found");
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
const addr = server.address();
|
|
1572
|
+
const redirect = `http://127.0.0.1:${addr.port}${CALLBACK_PATH}`;
|
|
1573
|
+
const returnedState = requestUrl.searchParams.get("state");
|
|
1574
|
+
const errorParam = requestUrl.searchParams.get("error");
|
|
1575
|
+
const returnedCode = requestUrl.searchParams.get("code");
|
|
1576
|
+
const finish = (err) => {
|
|
1577
|
+
if (timer) clearTimeout(timer);
|
|
1578
|
+
server.close();
|
|
1579
|
+
if (err) reject(err);
|
|
1580
|
+
};
|
|
1581
|
+
if (errorParam) {
|
|
1582
|
+
sendHtml(res2, errorPage(errorParam));
|
|
1583
|
+
finish(new Error(`Sign-in failed: ${errorParam}.`));
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
if (returnedState !== state) {
|
|
1587
|
+
sendHtml(res2, errorPage("state_mismatch"));
|
|
1588
|
+
finish(new Error("Sign-in failed: state mismatch."));
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
if (!returnedCode) {
|
|
1592
|
+
sendHtml(res2, errorPage("missing_code"));
|
|
1593
|
+
finish(new Error("Sign-in failed: no authorization code returned."));
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
sendHtml(res2, successPage());
|
|
1597
|
+
finish();
|
|
1598
|
+
resolve({ code: returnedCode, redirectUri: redirect });
|
|
1599
|
+
});
|
|
1600
|
+
server.on("error", (err) => {
|
|
1601
|
+
if (timer) clearTimeout(timer);
|
|
1602
|
+
reject(err);
|
|
1603
|
+
});
|
|
1604
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1605
|
+
const addr = server.address();
|
|
1606
|
+
const redirect = `http://127.0.0.1:${addr.port}${CALLBACK_PATH}`;
|
|
1607
|
+
const authorizeUrl = buildAuthorizeUrl(authorizationEndpoint, {
|
|
1608
|
+
clientId: options.clientId,
|
|
1609
|
+
redirectUri: redirect,
|
|
1610
|
+
scope,
|
|
1611
|
+
state,
|
|
1612
|
+
challenge,
|
|
1613
|
+
resource: options.resource
|
|
1614
|
+
});
|
|
1615
|
+
timer = setTimeout(() => {
|
|
1616
|
+
server.close();
|
|
1617
|
+
reject(new Error("Timed out waiting for sign-in. Run the command again."));
|
|
1618
|
+
}, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
1619
|
+
(options.openBrowser ?? launchBrowser)(authorizeUrl);
|
|
1620
|
+
options.onPrompt?.(authorizeUrl);
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
);
|
|
601
1624
|
const params = new URLSearchParams({
|
|
602
|
-
grant_type: "
|
|
1625
|
+
grant_type: "authorization_code",
|
|
1626
|
+
code,
|
|
1627
|
+
redirect_uri: redirectUri,
|
|
603
1628
|
client_id: options.clientId,
|
|
604
|
-
|
|
1629
|
+
code_verifier: verifier
|
|
605
1630
|
});
|
|
606
1631
|
if (options.resource) {
|
|
607
1632
|
params.set("resource", options.resource);
|
|
@@ -613,83 +1638,25 @@ async function refreshAccessToken(options) {
|
|
|
613
1638
|
});
|
|
614
1639
|
const body = await res.json();
|
|
615
1640
|
if (!res.ok) {
|
|
616
|
-
const error = typeof body.error === "string" ? body.error : "
|
|
617
|
-
throw new
|
|
1641
|
+
const error = typeof body.error === "string" ? body.error : "token_exchange_failed";
|
|
1642
|
+
throw new Error(`Token request failed: ${error}`);
|
|
618
1643
|
}
|
|
619
1644
|
const accessToken = body.access_token;
|
|
620
|
-
const refreshToken = body.refresh_token;
|
|
621
1645
|
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.");
|
|
1646
|
+
throw new Error("The authorization server returned an unexpected token response.");
|
|
647
1647
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
1648
|
+
return {
|
|
1649
|
+
access_token: accessToken,
|
|
1650
|
+
refresh_token: typeof body.refresh_token === "string" ? body.refresh_token : void 0,
|
|
1651
|
+
expires_in: typeof body.expires_in === "number" ? body.expires_in : void 0,
|
|
1652
|
+
token_type: typeof body.token_type === "string" ? body.token_type : void 0
|
|
651
1653
|
};
|
|
652
|
-
await tokenStore.save(stored);
|
|
653
|
-
return stored;
|
|
654
1654
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
}
|
|
667
|
-
var KeychainTokenStore = class {
|
|
668
|
-
async save(tokens) {
|
|
669
|
-
accessEntry().setPassword(tokens.accessToken);
|
|
670
|
-
refreshEntry().setPassword(tokens.refreshToken);
|
|
671
|
-
}
|
|
672
|
-
async load() {
|
|
673
|
-
const accessToken = accessEntry().getPassword();
|
|
674
|
-
const refreshToken = refreshEntry().getPassword();
|
|
675
|
-
if (!accessToken || !refreshToken) {
|
|
676
|
-
return null;
|
|
677
|
-
}
|
|
678
|
-
return { accessToken, refreshToken };
|
|
679
|
-
}
|
|
680
|
-
async clear() {
|
|
681
|
-
try {
|
|
682
|
-
accessEntry().deletePassword();
|
|
683
|
-
} catch {
|
|
684
|
-
}
|
|
685
|
-
try {
|
|
686
|
-
refreshEntry().deletePassword();
|
|
687
|
-
} catch {
|
|
688
|
-
}
|
|
1655
|
+
function browserLikelyAvailable() {
|
|
1656
|
+
if (process.platform === "darwin" || process.platform === "win32") {
|
|
1657
|
+
return true;
|
|
689
1658
|
}
|
|
690
|
-
|
|
691
|
-
function createTokenStore() {
|
|
692
|
-
return new KeychainTokenStore();
|
|
1659
|
+
return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
693
1660
|
}
|
|
694
1661
|
|
|
695
1662
|
// src/commands/login.ts
|
|
@@ -738,16 +1705,37 @@ async function runLogin(options) {
|
|
|
738
1705
|
keychainOk = false;
|
|
739
1706
|
}
|
|
740
1707
|
const expiresAt = typeof tokens.expires_in === "number" ? Date.now() + tokens.expires_in * 1e3 : null;
|
|
741
|
-
await
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1708
|
+
const previous = await loadAuthMetadata();
|
|
1709
|
+
const keepPrevious = !keychainOk && previous?.savedToStore === true;
|
|
1710
|
+
await saveAuthMetadata(
|
|
1711
|
+
keepPrevious ? previous : {
|
|
1712
|
+
issuer: discovery.issuer,
|
|
1713
|
+
clientId: options.clientId,
|
|
1714
|
+
expiresAt,
|
|
1715
|
+
savedToStore: keychainOk
|
|
1716
|
+
}
|
|
1717
|
+
);
|
|
746
1718
|
printLoginSuccess(keychainOk);
|
|
747
1719
|
printConnectGuidance(options.resource ?? discovery.issuer, tokens.access_token);
|
|
748
1720
|
}
|
|
749
1721
|
|
|
750
1722
|
// src/lib/revoke.ts
|
|
1723
|
+
var RevocationRefused = class extends Error {
|
|
1724
|
+
constructor(status) {
|
|
1725
|
+
super(`Token revocation failed (${status}).`);
|
|
1726
|
+
this.status = status;
|
|
1727
|
+
this.name = "RevocationRefused";
|
|
1728
|
+
}
|
|
1729
|
+
status;
|
|
1730
|
+
};
|
|
1731
|
+
var RevocationRedirected = class extends Error {
|
|
1732
|
+
constructor(status) {
|
|
1733
|
+
super(`Token revocation was redirected (${status}).`);
|
|
1734
|
+
this.status = status;
|
|
1735
|
+
this.name = "RevocationRedirected";
|
|
1736
|
+
}
|
|
1737
|
+
status;
|
|
1738
|
+
};
|
|
751
1739
|
async function revokeToken(options) {
|
|
752
1740
|
const fetchFn = options.fetchFn ?? fetch;
|
|
753
1741
|
const params = new URLSearchParams({
|
|
@@ -758,47 +1746,142 @@ async function revokeToken(options) {
|
|
|
758
1746
|
const res = await fetchFn(options.revocationEndpoint, {
|
|
759
1747
|
method: "POST",
|
|
760
1748
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
761
|
-
body: params.toString()
|
|
1749
|
+
body: params.toString(),
|
|
1750
|
+
redirect: "manual"
|
|
762
1751
|
});
|
|
1752
|
+
if (res.type === "opaqueredirect" || res.status >= 300 && res.status < 400) {
|
|
1753
|
+
throw new RevocationRedirected(res.status);
|
|
1754
|
+
}
|
|
763
1755
|
if (!res.ok) {
|
|
764
|
-
throw new
|
|
1756
|
+
throw new RevocationRefused(res.status);
|
|
765
1757
|
}
|
|
766
1758
|
}
|
|
767
1759
|
|
|
768
1760
|
// src/commands/logout.ts
|
|
769
|
-
|
|
1761
|
+
function mayWorkLater(status) {
|
|
1762
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
1763
|
+
}
|
|
1764
|
+
function afterFailure(error) {
|
|
1765
|
+
if (error instanceof DiscoveryUnusable) return "refused";
|
|
1766
|
+
if (error instanceof DiscoveryAnswered || error instanceof RevocationRefused) {
|
|
1767
|
+
return mayWorkLater(error.status) ? "unanswered" : "refused";
|
|
1768
|
+
}
|
|
1769
|
+
return "unanswered";
|
|
1770
|
+
}
|
|
1771
|
+
function sameOrigin(address, issuer) {
|
|
1772
|
+
try {
|
|
1773
|
+
return new URL(address).origin === new URL(issuer).origin;
|
|
1774
|
+
} catch {
|
|
1775
|
+
return false;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
async function cancelAtServer(refreshToken, server, fetchFn) {
|
|
1779
|
+
try {
|
|
1780
|
+
assertHttpsIssuer(server.issuer);
|
|
1781
|
+
} catch {
|
|
1782
|
+
return "refused";
|
|
1783
|
+
}
|
|
1784
|
+
try {
|
|
1785
|
+
const { revocationEndpoint } = await discoverEndpoints(server.issuer, fetchFn);
|
|
1786
|
+
if (!revocationEndpoint || !sameOrigin(revocationEndpoint, server.issuer)) return "refused";
|
|
1787
|
+
await revokeToken({
|
|
1788
|
+
revocationEndpoint,
|
|
1789
|
+
clientId: server.clientId,
|
|
1790
|
+
token: refreshToken,
|
|
1791
|
+
tokenTypeHint: "refresh_token",
|
|
1792
|
+
fetchFn
|
|
1793
|
+
});
|
|
1794
|
+
return "cancelled";
|
|
1795
|
+
} catch (error) {
|
|
1796
|
+
return afterFailure(error);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
async function removeRecordAfterSignOut() {
|
|
1800
|
+
try {
|
|
1801
|
+
await clearAuthMetadata();
|
|
1802
|
+
} catch {
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
function defaultServer(defaults) {
|
|
1806
|
+
try {
|
|
1807
|
+
return defaults();
|
|
1808
|
+
} catch {
|
|
1809
|
+
return null;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
async function endWithStoreUnopened(meta, options) {
|
|
1813
|
+
const platform = options.platform ?? process.platform;
|
|
1814
|
+
const mayForget = (platform === "linux" || platform === "freebsd") && meta?.savedToStore !== true;
|
|
1815
|
+
if (!options.forgetThisMachine) {
|
|
1816
|
+
printLogoutStoreLocked(mayForget);
|
|
1817
|
+
return { ok: false };
|
|
1818
|
+
}
|
|
1819
|
+
if (!mayForget) {
|
|
1820
|
+
printForgetRefused();
|
|
1821
|
+
return { ok: false };
|
|
1822
|
+
}
|
|
1823
|
+
try {
|
|
1824
|
+
await clearAuthMetadata();
|
|
1825
|
+
} catch {
|
|
1826
|
+
printForgetFailed(getConfigDir());
|
|
1827
|
+
return { ok: false };
|
|
1828
|
+
}
|
|
1829
|
+
printLogoutForgotten();
|
|
1830
|
+
return { ok: true };
|
|
1831
|
+
}
|
|
1832
|
+
async function runLogout(options) {
|
|
770
1833
|
const tokenStore = options.tokenStore ?? createTokenStore();
|
|
771
1834
|
const meta = await loadAuthMetadata();
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1835
|
+
let refreshToken;
|
|
1836
|
+
try {
|
|
1837
|
+
refreshToken = await tokenStore.loadRefreshToken();
|
|
1838
|
+
} catch {
|
|
1839
|
+
return endWithStoreUnopened(meta, options);
|
|
1840
|
+
}
|
|
1841
|
+
if (refreshToken === null) {
|
|
1842
|
+
let passRemoved = true;
|
|
775
1843
|
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
|
-
});
|
|
1844
|
+
await tokenStore.clear();
|
|
795
1845
|
} catch {
|
|
796
|
-
|
|
1846
|
+
passRemoved = false;
|
|
1847
|
+
}
|
|
1848
|
+
await removeRecordAfterSignOut();
|
|
1849
|
+
if (!passRemoved) {
|
|
1850
|
+
printLogoutPassLeft();
|
|
1851
|
+
} else if (meta?.savedToStore === false) {
|
|
1852
|
+
printLogoutSuccess();
|
|
1853
|
+
} else {
|
|
1854
|
+
printLogoutNothingFound();
|
|
797
1855
|
}
|
|
1856
|
+
return { ok: true };
|
|
1857
|
+
}
|
|
1858
|
+
if (options.forgetThisMachine) {
|
|
1859
|
+
printForgetRefused();
|
|
1860
|
+
return { ok: false };
|
|
1861
|
+
}
|
|
1862
|
+
const server = meta ?? defaultServer(options.defaults);
|
|
1863
|
+
if (!server) {
|
|
1864
|
+
printLogoutBadSettings();
|
|
1865
|
+
return { ok: false };
|
|
1866
|
+
}
|
|
1867
|
+
const atServer = await cancelAtServer(refreshToken, server, options.fetchFn ?? fetch);
|
|
1868
|
+
if (atServer === "unanswered") {
|
|
1869
|
+
printLogoutNotCancelledYet();
|
|
1870
|
+
return { ok: false };
|
|
798
1871
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1872
|
+
try {
|
|
1873
|
+
await tokenStore.clear();
|
|
1874
|
+
} catch {
|
|
1875
|
+
printLogoutIncomplete(atServer === "cancelled");
|
|
1876
|
+
return { ok: false };
|
|
1877
|
+
}
|
|
1878
|
+
await removeRecordAfterSignOut();
|
|
1879
|
+
if (atServer === "refused") {
|
|
1880
|
+
printLogoutCannotCancel();
|
|
1881
|
+
} else {
|
|
1882
|
+
printLogoutSuccess();
|
|
1883
|
+
}
|
|
1884
|
+
return { ok: true };
|
|
802
1885
|
}
|
|
803
1886
|
|
|
804
1887
|
// src/commands/status.ts
|
|
@@ -829,12 +1912,18 @@ async function runStatus(options) {
|
|
|
829
1912
|
});
|
|
830
1913
|
return;
|
|
831
1914
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1915
|
+
let result;
|
|
1916
|
+
try {
|
|
1917
|
+
result = await getValidAccessToken({
|
|
1918
|
+
discovery,
|
|
1919
|
+
clientId,
|
|
1920
|
+
tokenStore,
|
|
1921
|
+
resource: options.resource
|
|
1922
|
+
});
|
|
1923
|
+
} catch {
|
|
1924
|
+
printAuthStatus({ state: "store-unreadable" });
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
838
1927
|
switch (result.status) {
|
|
839
1928
|
case "signed-out":
|
|
840
1929
|
printAuthStatus({ state: "signed-out" });
|
|
@@ -851,7 +1940,7 @@ async function runStatus(options) {
|
|
|
851
1940
|
return;
|
|
852
1941
|
case "refreshed": {
|
|
853
1942
|
const expiresAt = result.expiresIn ? Date.now() + result.expiresIn * 1e3 : null;
|
|
854
|
-
await saveAuthMetadata({ issuer: discovery.issuer, clientId, expiresAt });
|
|
1943
|
+
await saveAuthMetadata({ issuer: discovery.issuer, clientId, expiresAt, savedToStore: true });
|
|
855
1944
|
printAuthStatus({ state: "signed-in", issuer: discovery.issuer, expiresAt });
|
|
856
1945
|
return;
|
|
857
1946
|
}
|
|
@@ -866,7 +1955,14 @@ var cliEnvSchema = z.object({
|
|
|
866
1955
|
/** OAuth public client id. */
|
|
867
1956
|
DAZZER_AUTH_CLIENT_ID: z.string().min(1).default("dazzer-cli-local"),
|
|
868
1957
|
/** Resource indicator for JWT access tokens. Defaults to the production MCP server. */
|
|
869
|
-
DAZZER_AUTH_RESOURCE: z.url().default("https://graph.dazzer.io/mcp")
|
|
1958
|
+
DAZZER_AUTH_RESOURCE: z.url().default("https://graph.dazzer.io/mcp"),
|
|
1959
|
+
/**
|
|
1960
|
+
* Where Dazzer itself is — the main service, as opposed to the sign-in server the other three
|
|
1961
|
+
* settings describe. It defaults to the same host the sign-in audience above already names,
|
|
1962
|
+
* because they are the same service seen from two sides; the default lives here, beside the
|
|
1963
|
+
* others, so no library and no command carries a production host of its own.
|
|
1964
|
+
*/
|
|
1965
|
+
DAZZER_API_BASE_URL: z.url().default("https://graph.dazzer.io")
|
|
870
1966
|
});
|
|
871
1967
|
var cachedEnv = null;
|
|
872
1968
|
function validateEnv() {
|
|
@@ -875,12 +1971,30 @@ function validateEnv() {
|
|
|
875
1971
|
}
|
|
876
1972
|
return cachedEnv;
|
|
877
1973
|
}
|
|
1974
|
+
function signInServerDefaults() {
|
|
1975
|
+
const settings = cliEnvSchema.pick({ DAZZER_AUTH_ISSUER: true, DAZZER_AUTH_CLIENT_ID: true }).parse(process.env);
|
|
1976
|
+
return { issuer: settings.DAZZER_AUTH_ISSUER, clientId: settings.DAZZER_AUTH_CLIENT_ID };
|
|
1977
|
+
}
|
|
878
1978
|
|
|
879
1979
|
// src/index.ts
|
|
880
1980
|
var require2 = createRequire(import.meta.url);
|
|
881
1981
|
var { version } = require2("../package.json");
|
|
882
1982
|
var program = new Command();
|
|
883
1983
|
program.name("dazzer").description("Sign in to Dazzer from your terminal").version(version);
|
|
1984
|
+
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").action(async (file, opts) => {
|
|
1985
|
+
const env = validateEnv();
|
|
1986
|
+
const result = await runAdd({
|
|
1987
|
+
file,
|
|
1988
|
+
space: opts.space,
|
|
1989
|
+
keepToMyself: opts.keepToMyself === true,
|
|
1990
|
+
why: opts.why === true,
|
|
1991
|
+
baseUrl: env.DAZZER_API_BASE_URL,
|
|
1992
|
+
issuer: env.DAZZER_AUTH_ISSUER,
|
|
1993
|
+
clientId: env.DAZZER_AUTH_CLIENT_ID,
|
|
1994
|
+
resource: env.DAZZER_AUTH_RESOURCE
|
|
1995
|
+
});
|
|
1996
|
+
if (!result.ok) process.exitCode = 1;
|
|
1997
|
+
});
|
|
884
1998
|
var auth = program.command("auth").description("Manage sign-in");
|
|
885
1999
|
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
2000
|
async (opts) => {
|
|
@@ -893,8 +2007,15 @@ auth.command("login").description("Sign in through your browser; falls back to a
|
|
|
893
2007
|
});
|
|
894
2008
|
}
|
|
895
2009
|
);
|
|
896
|
-
auth.command("logout").description("
|
|
897
|
-
|
|
2010
|
+
auth.command("logout").description("Cancel your sign-in and remove it from this machine").option(
|
|
2011
|
+
"--forget-this-machine",
|
|
2012
|
+
"On a machine with no credential store, remove its sign-in record without cancelling anything"
|
|
2013
|
+
).action(async (opts) => {
|
|
2014
|
+
const result = await runLogout({
|
|
2015
|
+
defaults: signInServerDefaults,
|
|
2016
|
+
forgetThisMachine: opts.forgetThisMachine === true
|
|
2017
|
+
});
|
|
2018
|
+
if (!result.ok) process.exitCode = 1;
|
|
898
2019
|
});
|
|
899
2020
|
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
2021
|
await runStatus({
|