clearcote 0.39.0 → 0.40.1

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 CHANGED
@@ -140,6 +140,8 @@ npx clearcote cloud run|sessions|stop|events|recording|profile sync|webhooks ...
140
140
 
141
141
  `info` never downloads: it reports the SDK, the cached builds, which engine features the binary supports, a launch test (skipped with `--quick`), licence seats, fonts and missing system libraries.
142
142
 
143
+ Windows: when a cached build cannot start from the cache (`spawn UNKNOWN`, "the side-by-side configuration is incorrect"; this happens when the SDK runs inside an MSIX-packaged app, whose writes to `%LOCALAPPDATA%` Windows redirects), launches use one copy of that build in `~/.clearcote/recovered/` instead, made once and reused by every later launch from Python or Node. `clear-cache` removes those copies too.
144
+
143
145
  ### Engine options (PRO 152 r22+)
144
146
 
145
147
  | Option | Effect |
@@ -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,22 +4,26 @@
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";
26
+ import { clearRecovered, recoverRoot } from "./winlaunch.js";
23
27
  import { Cloud, CloudError, announceHandoff } from "./cloud.js";
24
28
  const SDK_VERSION = (() => {
25
29
  try {
@@ -37,6 +41,7 @@ USAGE
37
41
  clearcote update [--channel stable|preview]
38
42
  clearcote clear-cache
39
43
  clearcote login [key]
44
+ clearcote login --device (sign in from a browser; no key to paste)
40
45
  clearcote logout
41
46
  clearcote serve [--port 9222] [--host 127.0.0.1] [--idle-timeout <s>] [--data-dir <dir>]
42
47
  [--max-browsers 16] [--allow-origin <origin>]... [--allow-host <name>]... [--headed]
@@ -97,7 +102,72 @@ function missingSharedLibs(exe) {
97
102
  const r = spawnSync("ldd", ["--", exe], { encoding: "utf8" });
98
103
  return (r.stdout || "").split("\n").filter((l) => l.includes("not found")).map((l) => l.trim().split(" ")[0]);
99
104
  }
100
- export async function buildInfo(flags) {
105
+ /** Whether a cached licensed build's tag (pro-<version>-r<N>) is what `version` selects: a revision ("r30",
106
+ * "154.0.8037.57-r30"), an exact version (its newest revision), a major ("154"), or "latest". */
107
+ export function proTagMatches(tag, version) {
108
+ if (!tag.startsWith("pro-"))
109
+ return false;
110
+ const t = tag.slice(4).toLowerCase();
111
+ const v = String(version).trim().toLowerCase();
112
+ if (v === "latest" || v === "newest")
113
+ return true;
114
+ if (/^r\d+$/.test(v))
115
+ return t.endsWith(`-${v}`);
116
+ if (/^\d+$/.test(v))
117
+ return t.startsWith(`${v}.`);
118
+ return t === v || t.startsWith(`${v}-`);
119
+ }
120
+ /**
121
+ * The build launch() would run for the current licence state, from what is already cached (`info` never
122
+ * downloads). CLEARCOTE_BINARY wins. A version selector (CLEARCOTE_BROWSER_VERSION) is resolved the way
123
+ * launch() resolves it -- through the version catalog (the bundled copy when `offline`), a licensed revision
124
+ * straight to the licensed builds -- and picks the cached build it names. Otherwise, with a licence key,
125
+ * launch() runs a licensed build: the newest one cached (launch() itself asks the licence server, which may
126
+ * name a newer one). Without a key it runs the open build this SDK pins (the newest cached open build with
127
+ * CLEARCOTE_AUTO_UPDATE). A cached licensed build is never picked for a keyless setup: it cannot run without
128
+ * a key, which is what made the old "newest cached build" launch test fail for open-build users.
129
+ */
130
+ export async function launchBuild(cached, envBinary, licenseKey, autoUpdate = false, version, offline = false) {
131
+ if (envBinary)
132
+ return { pick: { path: envBinary }, selectedBy: "CLEARCOTE_BINARY" };
133
+ const sel = String(version ?? "").trim();
134
+ if (sel) {
135
+ const named = `CLEARCOTE_BROWSER_VERSION=${sel}`;
136
+ let plan;
137
+ try {
138
+ plan = licenseKey && isProRevisionSelector(sel)
139
+ ? { kind: "pro", version: sel }
140
+ : await resolveVersion(sel, !!licenseKey, true, offline ? CATALOG_FALLBACK : undefined);
141
+ }
142
+ catch (e) {
143
+ return { selectedBy: `${named}: ${e.message.split("\n")[0]}` };
144
+ }
145
+ if (plan.kind === "pro") {
146
+ const m = cached.find((c) => proTagMatches(c.tag, plan.version));
147
+ return m ? { pick: m, selectedBy: `licensed: the newest cached build ${named} selects` }
148
+ : { selectedBy: `no licensed build for ${named} is cached — run: clearcote install --version ${sel}` };
149
+ }
150
+ const hit = cached.find((c) => c.tag === plan.rel.tag);
151
+ return hit ? { pick: hit, selectedBy: `open build ${plan.rel.tag} (${named})` }
152
+ : { selectedBy: `the open build ${plan.rel.tag} (${named}) is not installed — run: clearcote install --version ${sel}` };
153
+ }
154
+ if (licenseKey) {
155
+ const licensed = cached.filter((c) => c.tag.startsWith("pro-"));
156
+ return licensed.length
157
+ ? { pick: licensed[0], selectedBy: "licensed: the newest cached licensed build (launch() asks the licence server, which may name a newer one)" }
158
+ : { selectedBy: "no licensed build is cached — run: clearcote install" };
159
+ }
160
+ if (autoUpdate) {
161
+ const open = cached.filter((c) => !c.tag.startsWith("pro-"));
162
+ if (open.length)
163
+ return { pick: open[0], selectedBy: "open build: the newest cached (CLEARCOTE_AUTO_UPDATE)" };
164
+ }
165
+ const pinned = cached.find((c) => c.tag === RELEASE.tag);
166
+ return pinned
167
+ ? { pick: pinned, selectedBy: `open build pinned by this SDK (${RELEASE.tag})` }
168
+ : { selectedBy: `the open build this SDK pins (${RELEASE.tag}) is not installed — run: clearcote install` };
169
+ }
170
+ export async function buildInfo(flags, launchFn = launch) {
101
171
  const src = licenseKeySource();
102
172
  let channel;
103
173
  try {
@@ -108,20 +178,24 @@ export async function buildInfo(flags) {
108
178
  }
109
179
  const cached = listCachedBuilds();
110
180
  const envBinary = process.env.CLEARCOTE_BINARY;
111
- const pick = envBinary ? { path: envBinary } : cached[0];
181
+ const { pick, selectedBy } = await launchBuild(cached, envBinary, resolveLicenseKey(), autoUpdateRequested(undefined), process.env.CLEARCOTE_BROWSER_VERSION, !!flags.quick);
112
182
  const report = {
113
183
  sdk: { version: SDK_VERSION, node: process.version, platform: `${process.platform}-${process.arch}` },
114
184
  license: { source: src.source, ...(src.masked ? { key: src.masked } : {}) },
115
185
  binary: {
116
186
  source: envBinary ? "CLEARCOTE_BINARY" : pick ? "cache" : "none",
117
187
  ...(pick ? { path: pick.path } : {}),
118
- ...(!envBinary && pick ? { tag: pick.tag } : {}),
188
+ ...(!envBinary && pick?.tag ? { tag: pick.tag } : {}),
119
189
  cached,
120
190
  pinnedFree: `${RELEASE.version} (${RELEASE.tag})`,
121
191
  releaseChannel: channel,
192
+ selectedBy,
122
193
  },
123
194
  geoip: { databaseCached: existsSync(geoDbPath()), path: geoDbPath() },
124
195
  };
196
+ const expiry = licenseExpiry(); // a local record only: no network, so --quick reports it too
197
+ if (expiry)
198
+ report.license.expiry = expiry;
125
199
  if (pick && existsSync(pick.path)) {
126
200
  report.engineFeatures = Object.fromEntries(["proxy-auth", "socks5-credentials", "socks5-udp", ...Object.keys(GATED_ENGINE_SWITCHES).map((s) => s.slice(2))]
127
201
  .map((name) => [name, engineSupportsSwitch(pick.path, name)]));
@@ -139,19 +213,19 @@ export async function buildInfo(flags) {
139
213
  report.launch = { tested: false, reason: "skipped (--quick)" };
140
214
  }
141
215
  else if (!pick) {
142
- report.launch = { tested: false, reason: "no binary installed — run: clearcote install" };
216
+ report.launch = { tested: false, reason: cached.length ? selectedBy : "no binary installed — run: clearcote install" };
143
217
  }
144
218
  else {
145
219
  try {
146
220
  // 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 });
221
+ const b = await launchFn({ executablePath: pick.path, headless: true, quiet: true, ephemeralProfile: false, cloud: false });
148
222
  const version = b.version();
149
223
  await b.close();
150
- report.launch = { tested: true, ok: true, version };
224
+ report.launch = { tested: true, ok: true, version, build: pick.tag ?? pick.path };
151
225
  }
152
226
  catch (e) {
153
227
  const libs = missingSharedLibs(pick.path);
154
- report.launch = { tested: true, ok: false, error: e.message.split("\n")[0], ...(libs.length ? { missingLibs: libs } : {}) };
228
+ report.launch = { tested: true, ok: false, error: e.message.split("\n")[0], build: pick.tag ?? pick.path, ...(libs.length ? { missingLibs: libs } : {}) };
155
229
  }
156
230
  }
157
231
  if (flags.proxy) {
@@ -166,6 +240,9 @@ function printInfo(r) {
166
240
  const ok = (b) => (b ? "yes" : "no");
167
241
  out(`clearcote SDK ${r.sdk.version} (node ${r.sdk.node}, ${r.sdk.platform})`);
168
242
  out(`Licence ${r.license.source === "none" ? "none (free build)" : `${r.license.key} from ${r.license.source}`}`);
243
+ const exp = r.license.expiry;
244
+ if (exp && exp.source === "device-login")
245
+ out(`Expires ${exp.expiresAt || "never"} (as reported at device login)`);
169
246
  const seats = r.license.seats;
170
247
  if (seats) {
171
248
  if (seats.state === "ok")
@@ -173,7 +250,16 @@ function printInfo(r) {
173
250
  else
174
251
  out(`Seats unavailable: ${seats.reason ?? seats.state}`);
175
252
  }
176
- out(`Binary ${r.binary.path ? `${r.binary.path}${r.binary.tag ? ` [${r.binary.tag}]` : ""} (${r.binary.source})` : "not installed — run: clearcote install"}`);
253
+ if (r.binary.path) {
254
+ out(`Binary ${r.binary.path}${r.binary.tag ? ` [${r.binary.tag}]` : ""} (${r.binary.source})`);
255
+ out(` launch() runs this one: ${r.binary.selectedBy}`);
256
+ }
257
+ else if (r.binary.cached.length) {
258
+ out(`Binary none for this setup: ${r.binary.selectedBy}`);
259
+ }
260
+ else {
261
+ out("Binary not installed — run: clearcote install");
262
+ }
177
263
  out(`Release channel ${r.binary.releaseChannel}`);
178
264
  out(`Free pin ${r.binary.pinnedFree}`);
179
265
  if (r.binary.cached.length > 1)
@@ -185,9 +271,9 @@ function printInfo(r) {
185
271
  if (!r.launch.tested)
186
272
  out(`Launch test ${r.launch.reason}`);
187
273
  else if (r.launch.ok)
188
- out(`Launch test ok (${r.launch.version})`);
274
+ out(`Launch test ok (${r.launch.version}) with ${r.launch.build}`);
189
275
  else {
190
- out(`Launch test FAILED: ${r.launch.error}`);
276
+ out(`Launch test FAILED with ${r.launch.build}: ${r.launch.error}`);
191
277
  for (const lib of r.launch.missingLibs ?? [])
192
278
  out(` missing library: ${lib}`);
193
279
  }
@@ -200,14 +286,166 @@ function printInfo(r) {
200
286
  out(p.error ? `Proxy geo FAILED: ${p.error}` : `Proxy geo exit ${p.exitIp} (${p.country}) timezone ${p.timezone} language ${p.acceptLanguage}`);
201
287
  }
202
288
  }
289
+ function err(line = "") {
290
+ process.stderr.write(line + "\n");
291
+ }
292
+ /**
293
+ * `clearcote login --device`: show a link and a short code, wait for the approval in the browser, then
294
+ * save the key it issues exactly as `clearcote login <key>` does. The key is never printed.
295
+ * Instructions go to stderr and the result to stdout, like the paste login's prompt. Output and exit
296
+ * codes match the Python CLI (test/device-login.test.ts).
297
+ *
298
+ * Ctrl-C is deterministic: it never tears down a request already on its way (its answer may be the key,
299
+ * which the server hands out once). Ctrl-C before the key arrives saves nothing (exit 130); one that lands
300
+ * while the request that brings the key is in flight still saves it, and says so.
301
+ */
302
+ export async function deviceLogin(opts = {}) {
303
+ const ctrl = new AbortController(); // Ctrl-C: stop between requests; one already sent is answered first
304
+ const hard = new AbortController(); // a second Ctrl-C: give up on the request in flight too
305
+ const onSigint = () => {
306
+ if (ctrl.signal.aborted) {
307
+ hard.abort();
308
+ return;
309
+ }
310
+ ctrl.abort();
311
+ err("");
312
+ err("stopping (a request already sent is answered first; press Ctrl-C again to stop at once)");
313
+ };
314
+ process.on("SIGINT", onSigint);
315
+ let grant;
316
+ try {
317
+ const code = await requestDeviceCode();
318
+ if (ctrl.signal.aborted)
319
+ throw new DeviceLoginError("cancelled", "cancelled");
320
+ err("To sign in, open this link in a browser and approve the code shown there:");
321
+ err("");
322
+ err(` ${code.verification_uri_complete}`);
323
+ err("");
324
+ err(`Code: ${code.user_code} (or enter it at ${code.verification_uri})`);
325
+ err("Waiting for the approval (Ctrl-C to cancel)...");
326
+ let warned = false;
327
+ grant = await pollForKey(code, {
328
+ ...opts,
329
+ signal: ctrl.signal,
330
+ abort: hard.signal,
331
+ onRetry: (reason) => {
332
+ if (!warned) { // once: a flaky network must not flood the terminal
333
+ warned = true;
334
+ err(`note: ${reason}; still waiting`);
335
+ }
336
+ },
337
+ });
338
+ }
339
+ catch (e) {
340
+ if (e instanceof DeviceLoginError) {
341
+ if (e.code === "cancelled")
342
+ fail("cancelled. Nothing was saved.", 130);
343
+ if (e.code === "aborted")
344
+ fail("stopped at once (second Ctrl-C). Nothing was saved.", 130);
345
+ const again = e.code === "expired_token" || e.code === "outcome_unknown" ? " Run `clearcote login --device` again." : "";
346
+ fail(`${e.message}. Nothing was saved.${again}`);
347
+ }
348
+ throw e;
349
+ }
350
+ finally {
351
+ process.off("SIGINT", onSigint);
352
+ }
353
+ const where = saveLicenseKey(grant.licenseKey);
354
+ saveLicenseMeta(grant.licenseKey, grant.plan, grant.expiresAt);
355
+ if (grant.accountEmail)
356
+ out(`Signed in as ${grant.accountEmail}`);
357
+ out(`saved to ${where}`);
358
+ out(`plan ${grant.plan || "unknown"}, ${grant.expiresAt ? `expires ${grant.expiresAt}` : "no expiry"}`);
359
+ if (ctrl.signal.aborted)
360
+ out("note: Ctrl-C came after the licence server had sent the key, so it was saved anyway");
361
+ if (process.env.CLEARCOTE_LICENSE_KEY)
362
+ out("note: CLEARCOTE_LICENSE_KEY is set in this environment and takes precedence over the saved key");
363
+ }
364
+ /**
365
+ * Read one line from a terminal without echoing it: raw mode, so the terminal shows nothing of what is typed
366
+ * or pasted (a screen share, a recording or the scrollback must not show a key). Enter ends it, Backspace
367
+ * edits, Ctrl-C rejects with "cancelled". Terminal escape sequences (bracketed paste) are dropped.
368
+ */
369
+ export function readHidden(input, output, prompt) {
370
+ return new Promise((resolve, reject) => {
371
+ output.write(prompt);
372
+ let value = "";
373
+ const finish = (err) => {
374
+ input.removeListener("data", onData);
375
+ input.setRawMode?.(false);
376
+ input.pause();
377
+ output.write("\n");
378
+ if (err)
379
+ reject(err);
380
+ else
381
+ resolve(value.replace(/\x1b\[[0-9;]*[~A-Za-z]/g, "").trim());
382
+ };
383
+ const onData = (chunk) => {
384
+ for (const ch of chunk) {
385
+ if (ch === "\r" || ch === "\n" || ch === "\u0004")
386
+ return finish();
387
+ if (ch === "\u0003")
388
+ return finish(new Error("cancelled"));
389
+ if (ch === "\u007f" || ch === "\b")
390
+ value = value.slice(0, -1);
391
+ else
392
+ value += ch;
393
+ }
394
+ };
395
+ input.setRawMode?.(true);
396
+ input.setEncoding("utf8");
397
+ input.on("data", onData);
398
+ input.resume();
399
+ });
400
+ }
203
401
  async function promptKey() {
204
402
  if (!process.stdin.isTTY) {
205
403
  fail("no key given. Run `clearcote login <key>`, or copy a key from https://www.clearcotelabs.com/dashboard/licenses");
206
404
  }
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();
405
+ try {
406
+ return await readHidden(process.stdin, process.stderr, "Paste your licence key (https://www.clearcotelabs.com/dashboard/licenses; it is not shown): ");
407
+ }
408
+ catch {
409
+ return fail("cancelled. Nothing was saved.", 130);
410
+ }
411
+ }
412
+ /** What `clearcote <command> --help` adds to that command's USAGE lines. */
413
+ const COMMAND_NOTES = {
414
+ login: "Saves a licence key to ~/.clearcote/license.key, where every launch finds it.\n"
415
+ + " <key> the key, checked with the licence server first\n"
416
+ + " (no key) asks for it on the terminal; what you paste is not shown\n"
417
+ + " --device sign in from a browser instead: shows a link and a code, and saves the key the\n"
418
+ + " site issues once you approve it there. Nothing to copy or paste.",
419
+ logout: "Removes the saved key (and what device login recorded about it).",
420
+ info: "Reports the SDK, the licence, the cached builds and what a launch would use. Never downloads.",
421
+ install: "Downloads and verifies the build a launch would use.",
422
+ update: "Fetches a newer build if one exists.",
423
+ "clear-cache": "Deletes every cached browser build (nothing else in the cache directory), and on Windows the " +
424
+ "recovered copies in ~/.clearcote/recovered.",
425
+ serve: "A CDP endpoint that gives every connection its own browser and identity.",
426
+ };
427
+ /** `clearcote <cmd> --help`: that command's lines from USAGE (with their flag sections) and a note. */
428
+ export function commandUsage(cmd) {
429
+ const name = cmd === "doctor" ? "info" : cmd;
430
+ const lines = USAGE.split("\n");
431
+ const picked = [];
432
+ let keep = false;
433
+ for (const line of lines.slice(lines.indexOf("USAGE") + 1)) {
434
+ if (!line.trim())
435
+ break;
436
+ if (line.startsWith(" clearcote "))
437
+ keep = line.trim().split(/\s+/)[1] === name;
438
+ if (keep)
439
+ picked.push(line);
440
+ }
441
+ let text = `USAGE\n${picked.join("\n")}`;
442
+ if (name === "info") {
443
+ const flags = lines.slice(lines.indexOf("INFO FLAGS"));
444
+ const end = flags.indexOf("");
445
+ text += `\n\n${flags.slice(0, end < 0 ? flags.length : end).join("\n")}`;
446
+ }
447
+ const note = COMMAND_NOTES[name];
448
+ return note ? `clearcote ${name} -- ${note}\n\n${text}` : text;
211
449
  }
212
450
  function dirSize(p) {
213
451
  let total = 0;
@@ -236,6 +474,11 @@ export async function main(argv = process.argv.slice(2)) {
236
474
  process.exitCode = code;
237
475
  return;
238
476
  }
477
+ const known = ["info", "doctor", "install", "update", "clear-cache", "login", "logout", "serve"];
478
+ if (known.includes(cmd) && rest.some((a) => a === "-h" || a === "--help")) {
479
+ out(commandUsage(cmd));
480
+ return;
481
+ }
239
482
  if (cmd === "info" || cmd === "doctor") {
240
483
  const { values } = parseArgs({ args: rest, options: { quick: { type: "boolean" }, "no-launch": { type: "boolean" }, json: { type: "boolean" }, proxy: { type: "string" } } });
241
484
  const report = await buildInfo({ quick: !!(values.quick || values["no-launch"]), proxy: values.proxy });
@@ -263,34 +506,49 @@ export async function main(argv = process.argv.slice(2)) {
263
506
  const root = defaultCacheRoot();
264
507
  if (!existsSync(root)) {
265
508
  out(`nothing to clear (${root} does not exist)`);
266
- return;
267
509
  }
268
- // Only build directories: a CLEARCOTE_CACHE pointing at $HOME or a shared directory must not
269
- // become `rm -rf` of that directory. A build dir carries a .verified marker once complete; a
270
- // half-finished download has the build-tag name but no marker yet.
271
- let bytes = 0;
272
- const removed = [];
273
- for (const name of readdirSync(root)) {
274
- const dir = join(root, name);
275
- let isDir = false;
276
- try {
277
- isDir = statSync(dir).isDirectory();
510
+ else {
511
+ // Only build directories: a CLEARCOTE_CACHE pointing at $HOME or a shared directory must not
512
+ // become `rm -rf` of that directory. A build dir carries a .verified marker once complete; a
513
+ // half-finished download has the build-tag name but no marker yet.
514
+ let bytes = 0;
515
+ const removed = [];
516
+ for (const name of readdirSync(root)) {
517
+ const dir = join(root, name);
518
+ let isDir = false;
519
+ try {
520
+ isDir = statSync(dir).isDirectory();
521
+ }
522
+ catch { /* vanished */ }
523
+ if (!isDir)
524
+ continue;
525
+ if (!existsSync(join(dir, ".verified")) && !/^(pro-\d|v\d)/.test(name))
526
+ continue;
527
+ bytes += dirSize(dir);
528
+ rmSync(dir, { recursive: true, force: true });
529
+ removed.push(name);
278
530
  }
279
- catch { /* vanished */ }
280
- if (!isDir)
281
- continue;
282
- if (!existsSync(join(dir, ".verified")) && !/^(pro-\d|v\d)/.test(name))
283
- continue;
284
- bytes += dirSize(dir);
285
- rmSync(dir, { recursive: true, force: true });
286
- removed.push(name);
531
+ out(removed.length
532
+ ? `removed ${removed.length} cached build${removed.length === 1 ? "" : "s"} from ${root} (${(bytes / 1e6).toFixed(0)} MB)`
533
+ : `no cached builds in ${root}`);
534
+ }
535
+ // Windows: the copies launches fall back to when a cached build cannot start in place.
536
+ const rec = clearRecovered();
537
+ if (rec.removed) {
538
+ out(`removed ${rec.removed} recovered build cop${rec.removed === 1 ? "y" : "ies"} from ${recoverRoot()} (${(rec.bytes / 1e6).toFixed(0)} MB)`);
539
+ }
540
+ if (rec.inUse) {
541
+ out(`kept ${rec.inUse} recovered build cop${rec.inUse === 1 ? "y" : "ies"} a running browser is using (in ${recoverRoot()})`);
287
542
  }
288
- out(removed.length
289
- ? `removed ${removed.length} cached build${removed.length === 1 ? "" : "s"} from ${root} (${(bytes / 1e6).toFixed(0)} MB)`
290
- : `no cached builds in ${root}`);
291
543
  return;
292
544
  }
293
545
  if (cmd === "login") {
546
+ if (rest.includes("--device")) {
547
+ if (rest.some((a) => a !== "--device"))
548
+ fail("pass a key or --device, not both", 2);
549
+ await deviceLogin();
550
+ return;
551
+ }
294
552
  const key = rest[0] ?? (await promptKey());
295
553
  if (!key)
296
554
  fail("empty key");
@@ -298,6 +556,7 @@ export async function main(argv = process.argv.slice(2)) {
298
556
  if (seats.state === "invalid")
299
557
  fail(`the licence server rejected this key (${seats.reason}). Nothing was saved.`);
300
558
  const where = saveLicenseKey(key);
559
+ removeLicenseMeta(key.trim()); // a device login's record of another key no longer applies
301
560
  out(`saved to ${where}`);
302
561
  if (seats.state === "ok")
303
562
  out(`valid: ${seats.used} of ${seats.limit ?? "unlimited"} seats in use${seats.plan ? `, plan ${seats.plan}` : ""}`);