@allocator-one/harness-cli 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ ACTIVITY_MAX_LIMIT,
3
4
  CLI_VERSION,
4
5
  Client,
5
6
  CommandError,
@@ -11,6 +12,7 @@ import {
11
12
  TransportError,
12
13
  UpgradeRequiredError,
13
14
  UsageError,
15
+ activity,
14
16
  archive,
15
17
  attachImage,
16
18
  blocked,
@@ -80,7 +82,7 @@ import {
80
82
  version,
81
83
  workflowStates,
82
84
  writeCredentials
83
- } from "./chunk-NQVQNKMC.js";
85
+ } from "./chunk-KKAAKRKF.js";
84
86
 
85
87
  // src/cli.ts
86
88
  import { createInterface as createInterface2 } from "readline";
@@ -93,31 +95,15 @@ import { accessSync, constants as fsConstants } from "fs";
93
95
  import { createServer } from "http";
94
96
  import { delimiter, join } from "path";
95
97
  import { createInterface } from "readline";
96
-
97
- // src/core/login-page.ts
98
- var LOGO = '<svg width="210" height="34" viewBox="0 0 210 34" fill="none" role="img" aria-label="Infra One" xmlns="http://www.w3.org/2000/svg">\n <style>\n path {\n fill: #353535\n }\n\n @media (prefers-color-scheme: dark) {\n path {\n fill: white\n }\n }\n </style>\n <g id="I1_logo_full">\n <g id="Infra One Logo">\n <path fill-rule="evenodd" clip-rule="evenodd" d="M44.2202 29.5139V4.04761H49.617V29.5139H44.2202Z" />\n <path fill-rule="evenodd" clip-rule="evenodd" d="M121.159 11.873V13.998C119.877 12.4464 117.853 11.4345 115.29 11.4345C110.23 11.4345 106.284 15.3809 106.284 20.7103C106.284 26.0397 110.23 29.9861 115.29 29.9861C118.157 29.9861 119.877 28.9742 121.159 27.3214V29.4464H125.948V11.9067H121.159V11.873ZM116.099 25.8373C113.063 25.8373 110.938 23.7123 110.938 20.6091C110.938 17.5059 113.063 15.4484 116.099 15.4484C119.135 15.4484 121.327 17.5734 121.327 20.6091C121.327 23.6448 119.034 25.8373 116.099 25.8373Z" />\n <path d="M15.6508 0H27.8611V34H15.6508V0ZM0 11.131C0 7.92659 2.66468 5.2619 5.86905 5.2619H6.34127C9.54564 5.2619 12.2103 7.92659 12.2103 11.131C12.2103 14.3353 9.54564 17 6.34127 17H5.86905C2.66468 17 0 14.3353 0 11.131Z" />\n <path fill-rule="evenodd" clip-rule="evenodd" d="M88.4405 10.4901C87.6984 10.2203 87.3611 10.0179 86.3155 10.0179C84.8651 10.0179 84.123 10.7599 84.123 12.2103V13.627H89.4524V17.6746H84.123V29.4802H79.3333V17.6746H76.2976V13.627H79.3333V12.2103C79.3333 8.36511 81.1548 5.86908 85.4048 5.86908C87.631 5.86908 88.1706 6.13892 89.621 6.71233L88.4405 10.4901Z" />\n <path fill-rule="evenodd" clip-rule="evenodd" d="M98.0199 14.4365C99.133 12.4127 101.224 11.2321 104.091 11.6032V15.9206C100.246 15.381 98.0199 17.0337 98.0199 21.7897V29.4127H93.2302V11.873H98.0199V14.4365Z" />\n <path fill-rule="evenodd" clip-rule="evenodd" d="M65.5715 11.4008C70.3611 11.4008 72.756 14.5377 72.756 19.2262V29.5139H67.9663V20.0357C67.9663 17.371 66.6845 15.6171 64.1211 15.6171C61.5576 15.6171 59.7024 17.6409 59.7024 20.7778V29.5139H54.9127V11.873H59.7024V14.0655C60.9842 12.5139 62.9068 11.4008 65.5715 11.4008Z" />\n <path fill-rule="evenodd" clip-rule="evenodd" d="M151.01 4.621C143.387 4.621 137.687 10.0516 137.687 17.3035C137.687 24.5555 143.286 29.9861 151.01 29.9861C158.734 29.9861 164.232 24.4881 164.232 17.3035C164.232 10.119 158.633 4.621 151.01 4.621ZM151.01 25.5674C145.95 25.5674 142.746 21.992 142.746 17.3035C142.746 12.6151 145.95 9.10712 151.01 9.10712C156.069 9.10712 159.274 12.7837 159.274 17.3035C159.274 21.8234 155.968 25.5674 151.01 25.5674Z" />\n <path fill-rule="evenodd" clip-rule="evenodd" d="M179.309 11.4008C184.099 11.4008 186.494 14.5377 186.494 19.2262V29.5139H181.704V20.0357C181.704 17.371 180.423 15.6171 177.859 15.6171C175.296 15.6171 173.44 17.6409 173.44 20.7778V29.5139H168.651V11.873H173.44V14.0655C174.722 12.5139 176.645 11.4008 179.309 11.4008Z" />\n <path fill-rule="evenodd" clip-rule="evenodd" d="M209.565 20.2381C209.565 15.1786 206.26 11.3333 200.188 11.3333C194.117 11.3333 190.643 15.3809 190.643 20.6091C190.643 25.8373 194.69 29.8849 200.559 29.8849C206.429 29.8849 208.284 27.119 209.397 24.8254L205.079 23.6448C204.337 24.9266 202.954 25.9385 200.661 25.9385C197.355 25.9385 195.702 24.1845 195.331 21.8909H209.498C209.599 21.3512 209.599 20.7778 209.599 20.2381H209.565ZM195.298 19.125C195.567 17.1012 197.321 15.2797 200.155 15.2797C202.988 15.2797 204.742 17.0337 204.944 19.125H195.298Z" />\n </g>\n </g>\n</svg>';
99
- function escapeHtml(text) {
100
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
101
- }
102
- function loginPage(message) {
103
- return `<!doctype html>
104
- <html lang="en"><head><meta charset="utf-8">
105
- <meta name="viewport" content="width=device-width, initial-scale=1">
106
- <!-- The logo paints itself white under a dark preference; without this the
107
- canvas stays white and the logo disappears. -->
108
- <meta name="color-scheme" content="light dark">
109
- <title>harness login</title></head>
110
- <body style="margin: 0;">
111
- <main style="min-height: 100vh; padding: 1.5rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2rem; text-align: center; font-family: system-ui, sans-serif;">
112
- ${LOGO}
113
- <p style="margin: 0;">${escapeHtml(message)}</p>
114
- </main>
115
- </body></html>
116
- `;
117
- }
118
-
119
- // src/core/auth.ts
120
98
  var DEFAULT_TIMEOUT_MS = 18e4;
99
+ var RESPONSE_DRAIN_GRACE_MS = 100;
100
+ var RESPONSE_FLUSH_TIMEOUT_MS = 2e3;
101
+ var MAX_PAGE_RESPONSES = 4;
102
+ var loginPageImport;
103
+ function loadLoginPage() {
104
+ loginPageImport ??= import("./login-page-UVUKFFTT.js");
105
+ return loginPageImport;
106
+ }
121
107
  function generateState() {
122
108
  return randomBytes(24).toString("base64url");
123
109
  }
@@ -183,72 +169,313 @@ async function loopbackLogin(baseUrl, options) {
183
169
  });
184
170
  });
185
171
  if (port === null) {
186
- return { ok: false, message: "could not open a loopback port for the browser handoff" };
172
+ return {
173
+ ok: false,
174
+ message: "could not open a loopback port for the browser handoff"
175
+ };
187
176
  }
188
177
  try {
189
178
  const query = new URLSearchParams({ port: String(port), state: state2, challenge });
190
179
  const url = `${baseUrl}/cli/login?${query.toString()}`;
180
+ const callbackPromise = awaitCallback(server, state2, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
181
+ void loadLoginPage().catch(() => {
182
+ loginPageImport = void 0;
183
+ });
191
184
  const openBrowser = options.openBrowser ?? defaultOpenBrowser;
192
- openBrowser(url);
185
+ try {
186
+ openBrowser(url);
187
+ } catch (error) {
188
+ const detail = error instanceof Error ? error.message : String(error);
189
+ return { ok: false, message: `could not open a browser: ${detail}` };
190
+ }
193
191
  notify(`Opening ${url}`);
194
192
  notify("Waiting for the browser authorization (Ctrl-C to abort)\u2026");
195
- const code = await awaitCallback(server, state2, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
196
- if (!code.ok) return code;
197
- const token = await exchangeCode(baseUrl, code.code, verifier, options);
198
- if (!token.ok) return token;
199
- return await verifyAndStore(baseUrl, token.token, options);
193
+ const callback = await callbackPromise;
194
+ if (!callback.ok) return callback;
195
+ try {
196
+ const token = await exchangeCode(baseUrl, callback.code, verifier, options);
197
+ if (!token.ok) {
198
+ await callback.respond(
199
+ 400,
200
+ "Harness could not exchange the browser authorization. Return to the terminal for details.",
201
+ "error"
202
+ );
203
+ return token;
204
+ }
205
+ const result = await verifyAndStore(baseUrl, token.token, options);
206
+ if (result.ok) {
207
+ await callback.respond(200, "You can close this browser and return to the terminal.", "success");
208
+ } else {
209
+ await callback.respond(
210
+ 400,
211
+ "Harness could not verify and store this login. Return to the terminal for details.",
212
+ "error"
213
+ );
214
+ }
215
+ return result;
216
+ } catch (error) {
217
+ await callback.respond(500, "Harness could not finish the login. Return to the terminal for details.", "error");
218
+ const detail = error instanceof Error ? error.message : String(error);
219
+ return { ok: false, message: `could not finish the browser login: ${detail}` };
220
+ }
200
221
  } finally {
201
- server.closeAllConnections();
202
222
  server.close();
223
+ const forceClose = setTimeout(
224
+ () => server.closeAllConnections(),
225
+ RESPONSE_FLUSH_TIMEOUT_MS + RESPONSE_DRAIN_GRACE_MS
226
+ );
227
+ forceClose.unref();
203
228
  }
204
229
  }
205
230
  function awaitCallback(server, state2, timeoutMs) {
206
231
  return new Promise((resolve) => {
232
+ let phase = "waiting";
233
+ let finalResponse = null;
234
+ let callbackPageResponses = 0;
235
+ const rootPageResponses = /* @__PURE__ */ new Map();
236
+ const acceptedResponders = [];
237
+ const inFlightResponses = /* @__PURE__ */ new Set();
238
+ const trackResponse = (responsePromise) => {
239
+ inFlightResponses.add(responsePromise);
240
+ void responsePromise.finally(() => inFlightResponses.delete(responsePromise));
241
+ return responsePromise;
242
+ };
243
+ const drainTrackedResponses = async () => {
244
+ await new Promise((settle) => setTimeout(settle, RESPONSE_DRAIN_GRACE_MS));
245
+ const deadline = Date.now() + RESPONSE_FLUSH_TIMEOUT_MS;
246
+ while (inFlightResponses.size > 0 && Date.now() < deadline) {
247
+ const remaining = deadline - Date.now();
248
+ await Promise.race([
249
+ Promise.allSettled([...inFlightResponses]),
250
+ new Promise((settle) => setTimeout(settle, remaining))
251
+ ]);
252
+ }
253
+ };
207
254
  const timer = setTimeout(() => {
208
- resolve({ ok: false, message: "timed out waiting for the browser authorization" });
255
+ if (phase !== "waiting") return;
256
+ const timeoutResponse = {
257
+ status: 410,
258
+ message: "This login attempt has ended \u2014 run harness login again if needed.",
259
+ pageState: "error"
260
+ };
261
+ phase = "terminal";
262
+ finalResponse = timeoutResponse;
263
+ resolve({
264
+ ok: false,
265
+ message: "timed out waiting for the browser authorization"
266
+ });
267
+ void drainTrackedResponses();
209
268
  }, timeoutMs);
269
+ server.once("close", () => {
270
+ clearTimeout(timer);
271
+ if (phase !== "waiting") return;
272
+ phase = "terminal";
273
+ resolve({ ok: false, message: "the browser login listener closed before authorization completed" });
274
+ });
210
275
  server.on("request", (request, response) => {
211
- const respond = (status, message) => {
212
- response.writeHead(status, { "content-type": "text/html; charset=utf-8" });
213
- response.end(loginPage(message));
276
+ const respond = callbackResponder(response);
277
+ const respondWithPage = (status, message, pageState) => {
278
+ if (callbackPageResponses >= MAX_PAGE_RESPONSES) {
279
+ respondEmpty(response, 429);
280
+ return Promise.resolve();
281
+ }
282
+ callbackPageResponses += 1;
283
+ return respond(status, message, pageState);
284
+ };
285
+ const respondAtRoot = (status, message, pageState) => {
286
+ const rootPhase = phase === "terminal" ? "terminal" : "waiting";
287
+ const responses = rootPageResponses.get(rootPhase) ?? 0;
288
+ if (responses >= MAX_PAGE_RESPONSES) {
289
+ respondEmpty(response, 429);
290
+ return Promise.resolve();
291
+ }
292
+ rootPageResponses.set(rootPhase, responses + 1);
293
+ return respond(status, message, pageState);
214
294
  };
215
295
  let url;
216
296
  try {
217
297
  url = new URL(request.url ?? "/", "http://127.0.0.1");
218
298
  } catch {
219
- respond(404, "Waiting for the harness login callback.");
299
+ respondEmpty(response, 404);
300
+ return;
301
+ }
302
+ const path = url.pathname.replace(/\/+$/, "") || "/";
303
+ if (path !== "/callback") {
304
+ if (path === "/") {
305
+ const rootResponse = phase === "terminal" && finalResponse ? finalResponse : {
306
+ status: 200,
307
+ message: "Waiting for the Harness browser authorization to finish.",
308
+ pageState: "waiting"
309
+ };
310
+ void trackResponse(
311
+ respondAtRoot(rootResponse.status, rootResponse.message, rootResponse.pageState)
312
+ );
313
+ } else {
314
+ respondEmpty(response, path === "/favicon.ico" ? 204 : 404);
315
+ }
316
+ return;
317
+ }
318
+ const matchingState = url.searchParams.get("state") === state2;
319
+ if (!matchingState) {
320
+ respondEmpty(response, 400);
321
+ return;
322
+ }
323
+ if (phase === "terminal") {
324
+ if (!url.searchParams.has("code") && !url.searchParams.has("error")) {
325
+ respondEmpty(response, 400);
326
+ return;
327
+ }
328
+ const result = finalResponse ?? {
329
+ status: 410,
330
+ message: "This login attempt has ended \u2014 run harness login again if needed.",
331
+ pageState: "error"
332
+ };
333
+ void trackResponse(respondWithPage(result.status, result.message, result.pageState));
334
+ return;
335
+ }
336
+ if (phase === "processing") {
337
+ if (url.searchParams.has("error")) {
338
+ respondEmpty(response, 409);
339
+ return;
340
+ }
341
+ const duplicateCode = url.searchParams.get("code");
342
+ if (duplicateCode === null || duplicateCode === "") {
343
+ respondEmpty(response, 400);
344
+ return;
345
+ }
346
+ if (acceptedResponders.length >= MAX_PAGE_RESPONSES) {
347
+ respondEmpty(response, 429);
348
+ return;
349
+ }
350
+ acceptedResponders.push(respondWithPage);
220
351
  return;
221
352
  }
222
- if (url.pathname !== "/callback") {
223
- respond(404, "Waiting for the harness login callback.");
353
+ if (url.searchParams.has("error")) {
354
+ const denialResponse = {
355
+ status: 400,
356
+ message: "Browser authorization was denied. Return to the terminal to try again.",
357
+ pageState: "error"
358
+ };
359
+ phase = "terminal";
360
+ finalResponse = denialResponse;
361
+ clearTimeout(timer);
362
+ void (async () => {
363
+ await trackResponse(respondWithPage(denialResponse.status, denialResponse.message, denialResponse.pageState));
364
+ await drainTrackedResponses();
365
+ resolve({ ok: false, message: "browser authorization was denied" });
366
+ })();
224
367
  return;
225
368
  }
226
369
  const code = url.searchParams.get("code");
227
- if (url.searchParams.get("state") === state2 && code !== null && code !== "") {
228
- respond(200, "Signed in \u2014 you can close this tab and return to the terminal.");
370
+ if (code !== null && code !== "") {
371
+ phase = "processing";
372
+ acceptedResponders.push(respondWithPage);
229
373
  clearTimeout(timer);
230
- resolve({ ok: true, code });
374
+ resolve({
375
+ ok: true,
376
+ code,
377
+ respond: async (status, message, pageState) => {
378
+ finalResponse = { status, message, pageState };
379
+ phase = "terminal";
380
+ const responders = acceptedResponders.splice(0);
381
+ await Promise.all(
382
+ responders.map((acceptedRespond) => trackResponse(acceptedRespond(status, message, pageState)))
383
+ );
384
+ await drainTrackedResponses();
385
+ }
386
+ });
231
387
  } else {
232
- respond(400, "This is not the callback this login is waiting for \u2014 run harness login again if needed.");
388
+ respondEmpty(response, 400);
233
389
  }
234
390
  });
235
391
  });
236
392
  }
393
+ function callbackResponder(response) {
394
+ return async (status, message, state2) => {
395
+ if (response.writableEnded || response.destroyed) return;
396
+ return new Promise((resolve) => {
397
+ let flushTimer;
398
+ const armFlushTimeout = () => {
399
+ if (flushTimer !== void 0) clearTimeout(flushTimer);
400
+ flushTimer = setTimeout(() => {
401
+ response.destroy();
402
+ settle();
403
+ }, RESPONSE_FLUSH_TIMEOUT_MS);
404
+ flushTimer.unref();
405
+ };
406
+ const settle = () => {
407
+ if (flushTimer !== void 0) clearTimeout(flushTimer);
408
+ response.off("finish", settle);
409
+ response.off("close", settle);
410
+ response.off("error", settle);
411
+ response.off("drain", armFlushTimeout);
412
+ resolve();
413
+ };
414
+ response.once("finish", settle);
415
+ response.once("close", settle);
416
+ response.once("error", settle);
417
+ response.on("drain", armFlushTimeout);
418
+ void loadLoginPage().then(({ loginPage }) => {
419
+ if (response.writableEnded || response.destroyed) {
420
+ settle();
421
+ return;
422
+ }
423
+ try {
424
+ const html = loginPage(message, state2);
425
+ response.writeHead(status, {
426
+ "cache-control": "no-store",
427
+ connection: "close",
428
+ "content-length": String(Buffer.byteLength(html)),
429
+ "content-type": "text/html; charset=utf-8",
430
+ "x-content-type-options": "nosniff"
431
+ });
432
+ armFlushTimeout();
433
+ response.end(html);
434
+ } catch {
435
+ response.destroy();
436
+ settle();
437
+ }
438
+ }).catch(() => {
439
+ response.destroy();
440
+ settle();
441
+ });
442
+ });
443
+ };
444
+ }
445
+ function respondEmpty(response, status) {
446
+ response.once("error", () => void 0);
447
+ try {
448
+ response.writeHead(status, {
449
+ "cache-control": "no-store",
450
+ connection: "close",
451
+ "x-content-type-options": "nosniff"
452
+ });
453
+ response.end();
454
+ } catch {
455
+ response.destroy();
456
+ }
457
+ }
237
458
  async function exchangeCode(baseUrl, code, verifier, options) {
238
459
  try {
239
- const body = await bareClient(baseUrl, "", options).post(
240
- "/api/v1/cli/token-exchange",
241
- { code, code_verifier: verifier }
242
- );
460
+ const body = await bareClient(baseUrl, "", options).post("/api/v1/cli/token-exchange", { code, code_verifier: verifier });
243
461
  const token = body.data?.token;
244
462
  if (typeof token === "string" && token !== "") return { ok: true, token };
245
- return { ok: false, message: "the login-code exchange returned no token \u2014 aborting" };
463
+ return {
464
+ ok: false,
465
+ message: "the login-code exchange returned no token \u2014 aborting"
466
+ };
246
467
  } catch (error) {
247
468
  if (error instanceof HttpError) {
248
- return { ok: false, message: `the login-code exchange failed: ${error.message}` };
469
+ return {
470
+ ok: false,
471
+ message: `the login-code exchange failed: ${error.message}`
472
+ };
249
473
  }
250
474
  if (error instanceof TransportError) {
251
- return { ok: false, message: `the login-code exchange failed: cannot reach the API (${error.message})` };
475
+ return {
476
+ ok: false,
477
+ message: `the login-code exchange failed: cannot reach the API (${error.message})`
478
+ };
252
479
  }
253
480
  throw error;
254
481
  }
@@ -288,7 +515,10 @@ async function verifyAndStore(baseUrl, token, options) {
288
515
  } catch (error) {
289
516
  await client.delete("/api/v1/session").catch(() => void 0);
290
517
  const detail = error instanceof Error ? error.message : String(error);
291
- return { ok: false, message: `could not store credentials at ${path}: ${detail}` };
518
+ return {
519
+ ok: false,
520
+ message: `could not store credentials at ${path}: ${detail}`
521
+ };
292
522
  }
293
523
  if (superseded !== null) {
294
524
  await bareClient(superseded.url, superseded.token, options).delete("/api/v1/session").catch(() => void 0);
@@ -378,7 +608,10 @@ function defaultNotify(line) {
378
608
  process.stderr.write(line + "\n");
379
609
  }
380
610
  function defaultPrompt() {
381
- const readline = createInterface({ input: process.stdin, output: process.stderr });
611
+ const readline = createInterface({
612
+ input: process.stdin,
613
+ output: process.stderr
614
+ });
382
615
  return new Promise((resolve) => {
383
616
  readline.question("Token: ", (answer) => {
384
617
  readline.close();
@@ -1007,6 +1240,15 @@ function integer(value) {
1007
1240
  throw new InvalidArgumentError("expected a non-negative integer");
1008
1241
  return Number.parseInt(value, 10);
1009
1242
  }
1243
+ function activityLimit(value) {
1244
+ const limit = integer(value);
1245
+ if (limit < 1 || limit > ACTIVITY_MAX_LIMIT) {
1246
+ throw new InvalidArgumentError(
1247
+ `expected an integer from 1 to ${ACTIVITY_MAX_LIMIT}`
1248
+ );
1249
+ }
1250
+ return limit;
1251
+ }
1010
1252
  function collect(value, previous) {
1011
1253
  return [...previous, value];
1012
1254
  }
@@ -1193,6 +1435,17 @@ program.command("show").description("full issue detail; downloads attached image
1193
1435
  });
1194
1436
  }
1195
1437
  );
1438
+ program.command("activity").description("mixed issue activity: comments, creation, and state changes").argument("[id]").option(
1439
+ "--limit <n>",
1440
+ `entries per page (default 20, max ${ACTIVITY_MAX_LIMIT})`,
1441
+ activityLimit
1442
+ ).option("--all", "read all older activity").option("--json", "emit JSON").action(async (id, opts) => {
1443
+ await withConfig(opts.json === true, false, (ctx) => {
1444
+ if (id === void 0) throw new UsageError("usage: harness activity <id>");
1445
+ requireIdentifiers2([id]);
1446
+ return activity(ctx, id, opts);
1447
+ });
1448
+ });
1196
1449
  program.command("stats").description("per-team and total counts").option("--json", "emit JSON").action(async (opts) => {
1197
1450
  await withConfig(opts.json === true, false, (ctx) => stats(ctx));
1198
1451
  });
@@ -1658,7 +1911,7 @@ program.command("upload").description("upload a file; prints markdown to referen
1658
1911
  });
1659
1912
  });
1660
1913
  program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
1661
- const { startMcpServer } = await import("./server-FWNT3HMX.js");
1914
+ const { startMcpServer } = await import("./server-QVYSED3Z.js");
1662
1915
  await startMcpServer();
1663
1916
  });
1664
1917
  program.command("login").description("sign in via the browser (infra.one session)").option("--url <url>", "host to sign in to").option("--no-browser", "print the URL and prompt for a pasted token instead").option(