clearcote 0.39.0 → 0.40.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.
@@ -1,4 +1,5 @@
1
1
  import { type SessionSeats } from "./index.js";
2
+ import { type LicenseExpiry } from "./license.js";
2
3
  import { type Json } from "./cloud.js";
3
4
  export declare const USAGE: string;
4
5
  /** `clearcote cloud --help`. Kept byte-identical to CLOUD_USAGE in the Python SDK's _commands.py. */
@@ -13,6 +14,7 @@ export interface InfoReport {
13
14
  license: {
14
15
  source: string;
15
16
  key?: string;
17
+ expiry?: LicenseExpiry;
16
18
  seats?: SessionSeats;
17
19
  };
18
20
  binary: {
@@ -25,12 +27,15 @@ export interface InfoReport {
25
27
  }>;
26
28
  pinnedFree: string;
27
29
  releaseChannel: string;
30
+ /** Why `path` is the build launch() would run for the current licence state (or why there is none). */
31
+ selectedBy?: string;
28
32
  };
29
33
  engineFeatures?: Record<string, boolean>;
30
34
  launch?: {
31
35
  tested: boolean;
32
36
  ok?: boolean;
33
37
  version?: string;
38
+ build?: string;
34
39
  error?: string;
35
40
  missingLibs?: string[];
36
41
  reason?: string;
@@ -51,10 +56,70 @@ export interface InfoReport {
51
56
  };
52
57
  };
53
58
  }
59
+ /** Whether a cached licensed build's tag (pro-<version>-r<N>) is what `version` selects: a revision ("r30",
60
+ * "154.0.8037.57-r30"), an exact version (its newest revision), a major ("154"), or "latest". */
61
+ export declare function proTagMatches(tag: string, version: string): boolean;
62
+ /**
63
+ * The build launch() would run for the current licence state, from what is already cached (`info` never
64
+ * downloads). CLEARCOTE_BINARY wins. A version selector (CLEARCOTE_BROWSER_VERSION) is resolved the way
65
+ * launch() resolves it -- through the version catalog (the bundled copy when `offline`), a licensed revision
66
+ * straight to the licensed builds -- and picks the cached build it names. Otherwise, with a licence key,
67
+ * launch() runs a licensed build: the newest one cached (launch() itself asks the licence server, which may
68
+ * name a newer one). Without a key it runs the open build this SDK pins (the newest cached open build with
69
+ * CLEARCOTE_AUTO_UPDATE). A cached licensed build is never picked for a keyless setup: it cannot run without
70
+ * a key, which is what made the old "newest cached build" launch test fail for open-build users.
71
+ */
72
+ export declare function launchBuild(cached: Array<{
73
+ tag: string;
74
+ path: string;
75
+ }>, envBinary?: string, licenseKey?: string, autoUpdate?: boolean, version?: string, offline?: boolean): Promise<{
76
+ pick?: {
77
+ path: string;
78
+ tag?: string;
79
+ };
80
+ selectedBy: string;
81
+ }>;
54
82
  export declare function buildInfo(flags: {
55
83
  quick?: boolean;
56
84
  proxy?: string;
57
- }): Promise<InfoReport>;
85
+ }, launchFn?: (o: Record<string, unknown>) => Promise<{
86
+ version(): string;
87
+ close(): Promise<void>;
88
+ }>): Promise<InfoReport>;
89
+ /**
90
+ * `clearcote login --device`: show a link and a short code, wait for the approval in the browser, then
91
+ * save the key it issues exactly as `clearcote login <key>` does. The key is never printed.
92
+ * Instructions go to stderr and the result to stdout, like the paste login's prompt. Output and exit
93
+ * codes match the Python CLI (test/device-login.test.ts).
94
+ *
95
+ * Ctrl-C is deterministic: it never tears down a request already on its way (its answer may be the key,
96
+ * which the server hands out once). Ctrl-C before the key arrives saves nothing (exit 130); one that lands
97
+ * while the request that brings the key is in flight still saves it, and says so.
98
+ */
99
+ export declare function deviceLogin(opts?: {
100
+ sleep?: (s: number, signal?: AbortSignal) => Promise<void>;
101
+ clock?: () => number;
102
+ }): Promise<void>;
103
+ /** The parts of a TTY input stream readHidden uses. */
104
+ export interface HiddenInput {
105
+ isTTY?: boolean;
106
+ setRawMode?(mode: boolean): unknown;
107
+ setEncoding(enc: BufferEncoding): unknown;
108
+ on(ev: "data", fn: (chunk: string) => void): unknown;
109
+ removeListener(ev: "data", fn: (chunk: string) => void): unknown;
110
+ resume(): unknown;
111
+ pause(): unknown;
112
+ }
113
+ /**
114
+ * Read one line from a terminal without echoing it: raw mode, so the terminal shows nothing of what is typed
115
+ * or pasted (a screen share, a recording or the scrollback must not show a key). Enter ends it, Backspace
116
+ * edits, Ctrl-C rejects with "cancelled". Terminal escape sequences (bracketed paste) are dropped.
117
+ */
118
+ export declare function readHidden(input: HiddenInput, output: {
119
+ write(s: string): unknown;
120
+ }, prompt: string): Promise<string>;
121
+ /** `clearcote <cmd> --help`: that command's lines from USAGE (with their flag sections) and a note. */
122
+ export declare function commandUsage(cmd: string): string;
58
123
  export declare function main(argv?: string[]): Promise<void>;
59
124
  /** The human summary `clearcote cloud run` prints. */
60
125
  export declare function formatRun(run: Json): string[];
@@ -1 +1 @@
1
- {"version":3,"file":"cli-commands.d.ts","sourceRoot":"","sources":["../src/cli-commands.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cli-commands.d.ts","sourceRoot":"","sources":["../src/cli-commands.ts"],"names":[],"mappings":"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"}
@@ -4,19 +4,22 @@
4
4
  // clearcote info [--quick] [--json] [--proxy URL] diagnostics (alias: doctor)
5
5
  // clearcote update [--channel preview] fetch a newer build if one exists
6
6
  // clearcote clear-cache delete every cached binary
7
- // clearcote login [key] save a licence key (validated first)
7
+ // clearcote login [key] | --device save a licence key (validated first), or sign in
8
+ // from a browser and save the key it issues
8
9
  // clearcote logout remove the saved key
9
10
  // clearcote serve [--port 9222] [--idle-timeout 300] … multi-identity CDP endpoint
10
11
  // clearcote cloud run|sessions|stop|events|recording|profile sync|webhooks … the hosted API
11
12
  //
12
13
  // `info` never downloads: it reports what is already cached and what a launch would resolve to.
13
14
  import { parseArgs } from "node:util";
14
- import * as readline from "node:readline";
15
15
  import { existsSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
16
16
  import { spawnSync } from "node:child_process";
17
17
  import { dirname, join } from "node:path";
18
18
  import { download, launch, getSessionSeats, saveLicenseKey, removeLicenseKey, licenseKeySource, licenseKeyPath, resolveLicenseKey, resolveGeoDetailed, resolveReleaseChannel, engineSupportsSwitch, serveMultiplex, RELEASE, } from "./index.js";
19
- import { defaultCacheRoot, listCachedBuilds } from "./download.js";
19
+ import { autoUpdateRequested, defaultCacheRoot, isProRevisionSelector, listCachedBuilds, resolveVersion } from "./download.js";
20
+ import { CATALOG_FALLBACK } from "./release.js";
21
+ import { DeviceLoginError, pollForKey, requestDeviceCode } from "./devicelogin.js";
22
+ import { licenseExpiry, removeLicenseMeta, saveLicenseMeta } from "./license.js";
20
23
  import { geoCacheRoot } from "./geoip.js";
21
24
  import { GATED_ENGINE_SWITCHES } from "./launchopts.js";
22
25
  import { toProxySpec } from "./net.js";
@@ -37,6 +40,7 @@ USAGE
37
40
  clearcote update [--channel stable|preview]
38
41
  clearcote clear-cache
39
42
  clearcote login [key]
43
+ clearcote login --device (sign in from a browser; no key to paste)
40
44
  clearcote logout
41
45
  clearcote serve [--port 9222] [--host 127.0.0.1] [--idle-timeout <s>] [--data-dir <dir>]
42
46
  [--max-browsers 16] [--allow-origin <origin>]... [--allow-host <name>]... [--headed]
@@ -97,7 +101,72 @@ function missingSharedLibs(exe) {
97
101
  const r = spawnSync("ldd", ["--", exe], { encoding: "utf8" });
98
102
  return (r.stdout || "").split("\n").filter((l) => l.includes("not found")).map((l) => l.trim().split(" ")[0]);
99
103
  }
100
- export async function buildInfo(flags) {
104
+ /** Whether a cached licensed build's tag (pro-<version>-r<N>) is what `version` selects: a revision ("r30",
105
+ * "154.0.8037.57-r30"), an exact version (its newest revision), a major ("154"), or "latest". */
106
+ export function proTagMatches(tag, version) {
107
+ if (!tag.startsWith("pro-"))
108
+ return false;
109
+ const t = tag.slice(4).toLowerCase();
110
+ const v = String(version).trim().toLowerCase();
111
+ if (v === "latest" || v === "newest")
112
+ return true;
113
+ if (/^r\d+$/.test(v))
114
+ return t.endsWith(`-${v}`);
115
+ if (/^\d+$/.test(v))
116
+ return t.startsWith(`${v}.`);
117
+ return t === v || t.startsWith(`${v}-`);
118
+ }
119
+ /**
120
+ * The build launch() would run for the current licence state, from what is already cached (`info` never
121
+ * downloads). CLEARCOTE_BINARY wins. A version selector (CLEARCOTE_BROWSER_VERSION) is resolved the way
122
+ * launch() resolves it -- through the version catalog (the bundled copy when `offline`), a licensed revision
123
+ * straight to the licensed builds -- and picks the cached build it names. Otherwise, with a licence key,
124
+ * launch() runs a licensed build: the newest one cached (launch() itself asks the licence server, which may
125
+ * name a newer one). Without a key it runs the open build this SDK pins (the newest cached open build with
126
+ * CLEARCOTE_AUTO_UPDATE). A cached licensed build is never picked for a keyless setup: it cannot run without
127
+ * a key, which is what made the old "newest cached build" launch test fail for open-build users.
128
+ */
129
+ export async function launchBuild(cached, envBinary, licenseKey, autoUpdate = false, version, offline = false) {
130
+ if (envBinary)
131
+ return { pick: { path: envBinary }, selectedBy: "CLEARCOTE_BINARY" };
132
+ const sel = String(version ?? "").trim();
133
+ if (sel) {
134
+ const named = `CLEARCOTE_BROWSER_VERSION=${sel}`;
135
+ let plan;
136
+ try {
137
+ plan = licenseKey && isProRevisionSelector(sel)
138
+ ? { kind: "pro", version: sel }
139
+ : await resolveVersion(sel, !!licenseKey, true, offline ? CATALOG_FALLBACK : undefined);
140
+ }
141
+ catch (e) {
142
+ return { selectedBy: `${named}: ${e.message.split("\n")[0]}` };
143
+ }
144
+ if (plan.kind === "pro") {
145
+ const m = cached.find((c) => proTagMatches(c.tag, plan.version));
146
+ return m ? { pick: m, selectedBy: `licensed: the newest cached build ${named} selects` }
147
+ : { selectedBy: `no licensed build for ${named} is cached — run: clearcote install --version ${sel}` };
148
+ }
149
+ const hit = cached.find((c) => c.tag === plan.rel.tag);
150
+ return hit ? { pick: hit, selectedBy: `open build ${plan.rel.tag} (${named})` }
151
+ : { selectedBy: `the open build ${plan.rel.tag} (${named}) is not installed — run: clearcote install --version ${sel}` };
152
+ }
153
+ if (licenseKey) {
154
+ const licensed = cached.filter((c) => c.tag.startsWith("pro-"));
155
+ return licensed.length
156
+ ? { pick: licensed[0], selectedBy: "licensed: the newest cached licensed build (launch() asks the licence server, which may name a newer one)" }
157
+ : { selectedBy: "no licensed build is cached — run: clearcote install" };
158
+ }
159
+ if (autoUpdate) {
160
+ const open = cached.filter((c) => !c.tag.startsWith("pro-"));
161
+ if (open.length)
162
+ return { pick: open[0], selectedBy: "open build: the newest cached (CLEARCOTE_AUTO_UPDATE)" };
163
+ }
164
+ const pinned = cached.find((c) => c.tag === RELEASE.tag);
165
+ return pinned
166
+ ? { pick: pinned, selectedBy: `open build pinned by this SDK (${RELEASE.tag})` }
167
+ : { selectedBy: `the open build this SDK pins (${RELEASE.tag}) is not installed — run: clearcote install` };
168
+ }
169
+ export async function buildInfo(flags, launchFn = launch) {
101
170
  const src = licenseKeySource();
102
171
  let channel;
103
172
  try {
@@ -108,20 +177,24 @@ export async function buildInfo(flags) {
108
177
  }
109
178
  const cached = listCachedBuilds();
110
179
  const envBinary = process.env.CLEARCOTE_BINARY;
111
- const pick = envBinary ? { path: envBinary } : cached[0];
180
+ const { pick, selectedBy } = await launchBuild(cached, envBinary, resolveLicenseKey(), autoUpdateRequested(undefined), process.env.CLEARCOTE_BROWSER_VERSION, !!flags.quick);
112
181
  const report = {
113
182
  sdk: { version: SDK_VERSION, node: process.version, platform: `${process.platform}-${process.arch}` },
114
183
  license: { source: src.source, ...(src.masked ? { key: src.masked } : {}) },
115
184
  binary: {
116
185
  source: envBinary ? "CLEARCOTE_BINARY" : pick ? "cache" : "none",
117
186
  ...(pick ? { path: pick.path } : {}),
118
- ...(!envBinary && pick ? { tag: pick.tag } : {}),
187
+ ...(!envBinary && pick?.tag ? { tag: pick.tag } : {}),
119
188
  cached,
120
189
  pinnedFree: `${RELEASE.version} (${RELEASE.tag})`,
121
190
  releaseChannel: channel,
191
+ selectedBy,
122
192
  },
123
193
  geoip: { databaseCached: existsSync(geoDbPath()), path: geoDbPath() },
124
194
  };
195
+ const expiry = licenseExpiry(); // a local record only: no network, so --quick reports it too
196
+ if (expiry)
197
+ report.license.expiry = expiry;
125
198
  if (pick && existsSync(pick.path)) {
126
199
  report.engineFeatures = Object.fromEntries(["proxy-auth", "socks5-credentials", "socks5-udp", ...Object.keys(GATED_ENGINE_SWITCHES).map((s) => s.slice(2))]
127
200
  .map((name) => [name, engineSupportsSwitch(pick.path, name)]));
@@ -139,19 +212,19 @@ export async function buildInfo(flags) {
139
212
  report.launch = { tested: false, reason: "skipped (--quick)" };
140
213
  }
141
214
  else if (!pick) {
142
- report.launch = { tested: false, reason: "no binary installed — run: clearcote install" };
215
+ report.launch = { tested: false, reason: cached.length ? selectedBy : "no binary installed — run: clearcote install" };
143
216
  }
144
217
  else {
145
218
  try {
146
219
  // cloud: false — this tests the LOCAL install, whatever CLEARCOTE_CLOUD says.
147
- const b = await launch({ executablePath: pick.path, headless: true, quiet: true, ephemeralProfile: false, cloud: false });
220
+ const b = await launchFn({ executablePath: pick.path, headless: true, quiet: true, ephemeralProfile: false, cloud: false });
148
221
  const version = b.version();
149
222
  await b.close();
150
- report.launch = { tested: true, ok: true, version };
223
+ report.launch = { tested: true, ok: true, version, build: pick.tag ?? pick.path };
151
224
  }
152
225
  catch (e) {
153
226
  const libs = missingSharedLibs(pick.path);
154
- report.launch = { tested: true, ok: false, error: e.message.split("\n")[0], ...(libs.length ? { missingLibs: libs } : {}) };
227
+ report.launch = { tested: true, ok: false, error: e.message.split("\n")[0], build: pick.tag ?? pick.path, ...(libs.length ? { missingLibs: libs } : {}) };
155
228
  }
156
229
  }
157
230
  if (flags.proxy) {
@@ -166,6 +239,9 @@ function printInfo(r) {
166
239
  const ok = (b) => (b ? "yes" : "no");
167
240
  out(`clearcote SDK ${r.sdk.version} (node ${r.sdk.node}, ${r.sdk.platform})`);
168
241
  out(`Licence ${r.license.source === "none" ? "none (free build)" : `${r.license.key} from ${r.license.source}`}`);
242
+ const exp = r.license.expiry;
243
+ if (exp && exp.source === "device-login")
244
+ out(`Expires ${exp.expiresAt || "never"} (as reported at device login)`);
169
245
  const seats = r.license.seats;
170
246
  if (seats) {
171
247
  if (seats.state === "ok")
@@ -173,7 +249,16 @@ function printInfo(r) {
173
249
  else
174
250
  out(`Seats unavailable: ${seats.reason ?? seats.state}`);
175
251
  }
176
- out(`Binary ${r.binary.path ? `${r.binary.path}${r.binary.tag ? ` [${r.binary.tag}]` : ""} (${r.binary.source})` : "not installed — run: clearcote install"}`);
252
+ if (r.binary.path) {
253
+ out(`Binary ${r.binary.path}${r.binary.tag ? ` [${r.binary.tag}]` : ""} (${r.binary.source})`);
254
+ out(` launch() runs this one: ${r.binary.selectedBy}`);
255
+ }
256
+ else if (r.binary.cached.length) {
257
+ out(`Binary none for this setup: ${r.binary.selectedBy}`);
258
+ }
259
+ else {
260
+ out("Binary not installed — run: clearcote install");
261
+ }
177
262
  out(`Release channel ${r.binary.releaseChannel}`);
178
263
  out(`Free pin ${r.binary.pinnedFree}`);
179
264
  if (r.binary.cached.length > 1)
@@ -185,9 +270,9 @@ function printInfo(r) {
185
270
  if (!r.launch.tested)
186
271
  out(`Launch test ${r.launch.reason}`);
187
272
  else if (r.launch.ok)
188
- out(`Launch test ok (${r.launch.version})`);
273
+ out(`Launch test ok (${r.launch.version}) with ${r.launch.build}`);
189
274
  else {
190
- out(`Launch test FAILED: ${r.launch.error}`);
275
+ out(`Launch test FAILED with ${r.launch.build}: ${r.launch.error}`);
191
276
  for (const lib of r.launch.missingLibs ?? [])
192
277
  out(` missing library: ${lib}`);
193
278
  }
@@ -200,14 +285,165 @@ function printInfo(r) {
200
285
  out(p.error ? `Proxy geo FAILED: ${p.error}` : `Proxy geo exit ${p.exitIp} (${p.country}) timezone ${p.timezone} language ${p.acceptLanguage}`);
201
286
  }
202
287
  }
288
+ function err(line = "") {
289
+ process.stderr.write(line + "\n");
290
+ }
291
+ /**
292
+ * `clearcote login --device`: show a link and a short code, wait for the approval in the browser, then
293
+ * save the key it issues exactly as `clearcote login <key>` does. The key is never printed.
294
+ * Instructions go to stderr and the result to stdout, like the paste login's prompt. Output and exit
295
+ * codes match the Python CLI (test/device-login.test.ts).
296
+ *
297
+ * Ctrl-C is deterministic: it never tears down a request already on its way (its answer may be the key,
298
+ * which the server hands out once). Ctrl-C before the key arrives saves nothing (exit 130); one that lands
299
+ * while the request that brings the key is in flight still saves it, and says so.
300
+ */
301
+ export async function deviceLogin(opts = {}) {
302
+ const ctrl = new AbortController(); // Ctrl-C: stop between requests; one already sent is answered first
303
+ const hard = new AbortController(); // a second Ctrl-C: give up on the request in flight too
304
+ const onSigint = () => {
305
+ if (ctrl.signal.aborted) {
306
+ hard.abort();
307
+ return;
308
+ }
309
+ ctrl.abort();
310
+ err("");
311
+ err("stopping (a request already sent is answered first; press Ctrl-C again to stop at once)");
312
+ };
313
+ process.on("SIGINT", onSigint);
314
+ let grant;
315
+ try {
316
+ const code = await requestDeviceCode();
317
+ if (ctrl.signal.aborted)
318
+ throw new DeviceLoginError("cancelled", "cancelled");
319
+ err("To sign in, open this link in a browser and approve the code shown there:");
320
+ err("");
321
+ err(` ${code.verification_uri_complete}`);
322
+ err("");
323
+ err(`Code: ${code.user_code} (or enter it at ${code.verification_uri})`);
324
+ err("Waiting for the approval (Ctrl-C to cancel)...");
325
+ let warned = false;
326
+ grant = await pollForKey(code, {
327
+ ...opts,
328
+ signal: ctrl.signal,
329
+ abort: hard.signal,
330
+ onRetry: (reason) => {
331
+ if (!warned) { // once: a flaky network must not flood the terminal
332
+ warned = true;
333
+ err(`note: ${reason}; still waiting`);
334
+ }
335
+ },
336
+ });
337
+ }
338
+ catch (e) {
339
+ if (e instanceof DeviceLoginError) {
340
+ if (e.code === "cancelled")
341
+ fail("cancelled. Nothing was saved.", 130);
342
+ if (e.code === "aborted")
343
+ fail("stopped at once (second Ctrl-C). Nothing was saved.", 130);
344
+ const again = e.code === "expired_token" || e.code === "outcome_unknown" ? " Run `clearcote login --device` again." : "";
345
+ fail(`${e.message}. Nothing was saved.${again}`);
346
+ }
347
+ throw e;
348
+ }
349
+ finally {
350
+ process.off("SIGINT", onSigint);
351
+ }
352
+ const where = saveLicenseKey(grant.licenseKey);
353
+ saveLicenseMeta(grant.licenseKey, grant.plan, grant.expiresAt);
354
+ if (grant.accountEmail)
355
+ out(`Signed in as ${grant.accountEmail}`);
356
+ out(`saved to ${where}`);
357
+ out(`plan ${grant.plan || "unknown"}, ${grant.expiresAt ? `expires ${grant.expiresAt}` : "no expiry"}`);
358
+ if (ctrl.signal.aborted)
359
+ out("note: Ctrl-C came after the licence server had sent the key, so it was saved anyway");
360
+ if (process.env.CLEARCOTE_LICENSE_KEY)
361
+ out("note: CLEARCOTE_LICENSE_KEY is set in this environment and takes precedence over the saved key");
362
+ }
363
+ /**
364
+ * Read one line from a terminal without echoing it: raw mode, so the terminal shows nothing of what is typed
365
+ * or pasted (a screen share, a recording or the scrollback must not show a key). Enter ends it, Backspace
366
+ * edits, Ctrl-C rejects with "cancelled". Terminal escape sequences (bracketed paste) are dropped.
367
+ */
368
+ export function readHidden(input, output, prompt) {
369
+ return new Promise((resolve, reject) => {
370
+ output.write(prompt);
371
+ let value = "";
372
+ const finish = (err) => {
373
+ input.removeListener("data", onData);
374
+ input.setRawMode?.(false);
375
+ input.pause();
376
+ output.write("\n");
377
+ if (err)
378
+ reject(err);
379
+ else
380
+ resolve(value.replace(/\x1b\[[0-9;]*[~A-Za-z]/g, "").trim());
381
+ };
382
+ const onData = (chunk) => {
383
+ for (const ch of chunk) {
384
+ if (ch === "\r" || ch === "\n" || ch === "\u0004")
385
+ return finish();
386
+ if (ch === "\u0003")
387
+ return finish(new Error("cancelled"));
388
+ if (ch === "\u007f" || ch === "\b")
389
+ value = value.slice(0, -1);
390
+ else
391
+ value += ch;
392
+ }
393
+ };
394
+ input.setRawMode?.(true);
395
+ input.setEncoding("utf8");
396
+ input.on("data", onData);
397
+ input.resume();
398
+ });
399
+ }
203
400
  async function promptKey() {
204
401
  if (!process.stdin.isTTY) {
205
402
  fail("no key given. Run `clearcote login <key>`, or copy a key from https://www.clearcotelabs.com/dashboard/licenses");
206
403
  }
207
- const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
208
- const key = await new Promise((resolve) => rl.question("Paste your licence key (https://www.clearcotelabs.com/dashboard/licenses): ", (a) => resolve(a)));
209
- rl.close();
210
- return key.trim();
404
+ try {
405
+ return await readHidden(process.stdin, process.stderr, "Paste your licence key (https://www.clearcotelabs.com/dashboard/licenses; it is not shown): ");
406
+ }
407
+ catch {
408
+ return fail("cancelled. Nothing was saved.", 130);
409
+ }
410
+ }
411
+ /** What `clearcote <command> --help` adds to that command's USAGE lines. */
412
+ const COMMAND_NOTES = {
413
+ login: "Saves a licence key to ~/.clearcote/license.key, where every launch finds it.\n"
414
+ + " <key> the key, checked with the licence server first\n"
415
+ + " (no key) asks for it on the terminal; what you paste is not shown\n"
416
+ + " --device sign in from a browser instead: shows a link and a code, and saves the key the\n"
417
+ + " site issues once you approve it there. Nothing to copy or paste.",
418
+ logout: "Removes the saved key (and what device login recorded about it).",
419
+ info: "Reports the SDK, the licence, the cached builds and what a launch would use. Never downloads.",
420
+ install: "Downloads and verifies the build a launch would use.",
421
+ update: "Fetches a newer build if one exists.",
422
+ "clear-cache": "Deletes every cached browser build (nothing else in the cache directory).",
423
+ serve: "A CDP endpoint that gives every connection its own browser and identity.",
424
+ };
425
+ /** `clearcote <cmd> --help`: that command's lines from USAGE (with their flag sections) and a note. */
426
+ export function commandUsage(cmd) {
427
+ const name = cmd === "doctor" ? "info" : cmd;
428
+ const lines = USAGE.split("\n");
429
+ const picked = [];
430
+ let keep = false;
431
+ for (const line of lines.slice(lines.indexOf("USAGE") + 1)) {
432
+ if (!line.trim())
433
+ break;
434
+ if (line.startsWith(" clearcote "))
435
+ keep = line.trim().split(/\s+/)[1] === name;
436
+ if (keep)
437
+ picked.push(line);
438
+ }
439
+ let text = `USAGE\n${picked.join("\n")}`;
440
+ if (name === "info") {
441
+ const flags = lines.slice(lines.indexOf("INFO FLAGS"));
442
+ const end = flags.indexOf("");
443
+ text += `\n\n${flags.slice(0, end < 0 ? flags.length : end).join("\n")}`;
444
+ }
445
+ const note = COMMAND_NOTES[name];
446
+ return note ? `clearcote ${name} -- ${note}\n\n${text}` : text;
211
447
  }
212
448
  function dirSize(p) {
213
449
  let total = 0;
@@ -236,6 +472,11 @@ export async function main(argv = process.argv.slice(2)) {
236
472
  process.exitCode = code;
237
473
  return;
238
474
  }
475
+ const known = ["info", "doctor", "install", "update", "clear-cache", "login", "logout", "serve"];
476
+ if (known.includes(cmd) && rest.some((a) => a === "-h" || a === "--help")) {
477
+ out(commandUsage(cmd));
478
+ return;
479
+ }
239
480
  if (cmd === "info" || cmd === "doctor") {
240
481
  const { values } = parseArgs({ args: rest, options: { quick: { type: "boolean" }, "no-launch": { type: "boolean" }, json: { type: "boolean" }, proxy: { type: "string" } } });
241
482
  const report = await buildInfo({ quick: !!(values.quick || values["no-launch"]), proxy: values.proxy });
@@ -291,6 +532,12 @@ export async function main(argv = process.argv.slice(2)) {
291
532
  return;
292
533
  }
293
534
  if (cmd === "login") {
535
+ if (rest.includes("--device")) {
536
+ if (rest.some((a) => a !== "--device"))
537
+ fail("pass a key or --device, not both", 2);
538
+ await deviceLogin();
539
+ return;
540
+ }
294
541
  const key = rest[0] ?? (await promptKey());
295
542
  if (!key)
296
543
  fail("empty key");
@@ -298,6 +545,7 @@ export async function main(argv = process.argv.slice(2)) {
298
545
  if (seats.state === "invalid")
299
546
  fail(`the licence server rejected this key (${seats.reason}). Nothing was saved.`);
300
547
  const where = saveLicenseKey(key);
548
+ removeLicenseMeta(key.trim()); // a device login's record of another key no longer applies
301
549
  out(`saved to ${where}`);
302
550
  if (seats.state === "ok")
303
551
  out(`valid: ${seats.used} of ${seats.limit ?? "unlimited"} seats in use${seats.plan ? `, plan ${seats.plan}` : ""}`);