@tryarcanist/cli 0.1.361 → 0.1.363

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.
Files changed (3) hide show
  1. package/README.md +22 -0
  2. package/dist/index.js +549 -15
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -150,6 +150,28 @@ arcanist environments retry owner/repo --wait --json
150
150
  arcanist environments retry owner/repo --wait --poll-interval 5000 --json
151
151
  ```
152
152
 
153
+ ### `arcanist environments login`
154
+
155
+ Log into your app once in a throwaway local browser and save that login on the environment so Zeus sessions start signed in. Alias: `arcanist env login`. A write-scoped token is required. Pass an environment id or `owner/repo` outside a checkout; the environment must have an app runtime configured.
156
+
157
+ ```bash
158
+ arcanist environments login owner/repo
159
+ arcanist environments login owner/repo --validate-path /dashboard --yes
160
+ arcanist environments login owner/repo --url http://localhost:3000/login --no-network-policy
161
+ arcanist environments login owner/repo --clear
162
+ ```
163
+
164
+ The command opens a fresh Chrome window that shares nothing with your own browser profile. Log into your app and every provider it redirects through (Auth0, Google, and so on) using dedicated test accounts, keep the window open, then press Enter in the terminal. Closing the window or pressing Ctrl-C cancels without uploading anything.
165
+
166
+ What gets captured is the Playwright storage state for that window: cookies and localStorage. Cookies and storage on `localhost`, `127.0.0.1`, or `[::1]` are rewritten to `localhost` on the app runtime port, which is where the sandbox serves the app. The state is encrypted at rest and only ever handed to a live sandbox for the environment. Output is counts only (cookie domains, storage origins, byte size); cookie and storage values never reach the terminal.
167
+
168
+ - `--url <url>` opens this page first instead of the app runtime URL, for example a login page on another local port.
169
+ - `--validate-path <path>` saves a signed-in-only page (for example `/dashboard`) as the environment's `auth.validatePath`; every session opens it to verify the login still works. Without the flag the command prompts for one when the environment has none. Saving it changes the app runtime profile, so an environment rebuild is scheduled.
170
+ - `--yes` adds the provider hosts the login redirected through to the environment's trusted domains without asking, so the sandbox can follow the same redirect. `--no-network-policy` leaves the trusted domains alone; verification then reports the login as unavailable until those hosts are trusted.
171
+ - `--clear` removes the saved login and skips the browser entirely.
172
+
173
+ The capture needs an interactive terminal: stdin and stderr must be a TTY. Instructions and prompts go to stderr, so `--json --yes --validate-path /dashboard > result.json` works. JSON output is `{environmentId, version, counts: {cookieDomainCount, storageOriginCount}, providerHosts, networkPolicy: {added, rejected, skipped}, validatePath}`, or `{environmentId, deleted}` with `--clear`. `arcanist environments status --json` reports the saved login under `browserState`, including whether a re-login is needed.
174
+
153
175
  ### `arcanist setup`
154
176
 
155
177
  Connect Arcanist and prepare one or more repository environments. The command starts browser authentication when needed, lists only repositories where GitHub reports write access, and queues at most 20 builds.
package/dist/index.js CHANGED
@@ -7409,7 +7409,7 @@ function isTerminalPhase(phase, _sessionKind) {
7409
7409
  // package.json
7410
7410
  var package_default = {
7411
7411
  name: "@tryarcanist/cli",
7412
- version: "0.1.361",
7412
+ version: "0.1.363",
7413
7413
  description: "Command-line interface for Arcanist: inspect sessions, prepare Zeus environments, and trigger reviews",
7414
7414
  type: "module",
7415
7415
  bin: {
@@ -7438,7 +7438,8 @@ var package_default = {
7438
7438
  homepage: "https://www.tryarcanist.com",
7439
7439
  license: "MIT",
7440
7440
  dependencies: {
7441
- commander: "^12.1.0"
7441
+ commander: "^12.1.0",
7442
+ "playwright-core": "^1.61.1"
7442
7443
  },
7443
7444
  devDependencies: {
7444
7445
  tsup: "^8.0.0",
@@ -27450,6 +27451,494 @@ async function egressValidateCommand(path = EGRESS_ALLOWLIST_SOURCE_PATH, option
27450
27451
  });
27451
27452
  }
27452
27453
 
27454
+ // ../../shared/utils/type-guards.ts
27455
+ function isRecord(value) {
27456
+ return !!value && typeof value === "object" && !Array.isArray(value);
27457
+ }
27458
+ function asNonEmptyString(value) {
27459
+ if (typeof value !== "string") return void 0;
27460
+ const trimmed = value.trim();
27461
+ return trimmed.length > 0 ? trimmed : void 0;
27462
+ }
27463
+
27464
+ // ../../shared/browser-state/storage-state.ts
27465
+ var MAX_BROWSER_STATE_BYTES = 1024 * 1024;
27466
+ var BrowserStateValidationError = class extends Error {
27467
+ constructor(code, message) {
27468
+ super(message);
27469
+ this.code = code;
27470
+ this.name = "BrowserStateValidationError";
27471
+ }
27472
+ code;
27473
+ };
27474
+ var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
27475
+ var SAME_SITE_VALUES = /* @__PURE__ */ new Set(["Strict", "Lax", "None"]);
27476
+ function invalidShape(message) {
27477
+ return new BrowserStateValidationError("invalid_shape", message);
27478
+ }
27479
+ function isBrowserCookie(value) {
27480
+ if (!isRecord(value)) return false;
27481
+ if (typeof value.name !== "string" || typeof value.value !== "string" || typeof value.domain !== "string" || typeof value.path !== "string") {
27482
+ return false;
27483
+ }
27484
+ if (value.expires !== void 0 && !(typeof value.expires === "number" && Number.isFinite(value.expires))) {
27485
+ return false;
27486
+ }
27487
+ if (value.httpOnly !== void 0 && typeof value.httpOnly !== "boolean") return false;
27488
+ if (value.secure !== void 0 && typeof value.secure !== "boolean") return false;
27489
+ if (value.sameSite !== void 0 && !SAME_SITE_VALUES.has(value.sameSite)) {
27490
+ return false;
27491
+ }
27492
+ return true;
27493
+ }
27494
+ function isOriginString(value) {
27495
+ if (typeof value !== "string") return false;
27496
+ let url2;
27497
+ try {
27498
+ url2 = new URL(value);
27499
+ } catch {
27500
+ return false;
27501
+ }
27502
+ return url2.origin !== "null" && url2.origin === value;
27503
+ }
27504
+ function isLocalStorageItem(value) {
27505
+ return isRecord(value) && typeof value.name === "string" && typeof value.value === "string";
27506
+ }
27507
+ function parseStorageState(raw) {
27508
+ const bytes = new TextEncoder().encode(raw).byteLength;
27509
+ if (bytes > MAX_BROWSER_STATE_BYTES) {
27510
+ throw new BrowserStateValidationError("too_large", `Browser state is ${bytes} bytes; the limit is 1 MiB.`);
27511
+ }
27512
+ let parsed;
27513
+ try {
27514
+ parsed = JSON.parse(raw);
27515
+ } catch {
27516
+ throw new BrowserStateValidationError("invalid_json", "Browser state is not valid JSON.");
27517
+ }
27518
+ if (!isRecord(parsed) || !Array.isArray(parsed.cookies) || !Array.isArray(parsed.origins)) {
27519
+ throw invalidShape("Browser state must be a Playwright storage state with cookies[] and origins[].");
27520
+ }
27521
+ const cookies = [];
27522
+ parsed.cookies.forEach((cookie, index) => {
27523
+ if (!isBrowserCookie(cookie)) {
27524
+ throw invalidShape(
27525
+ `Browser state cookies[${index}] is not a Playwright cookie: name, value, domain, and path must be strings; expires a finite number; httpOnly and secure booleans; sameSite one of Strict, Lax, None.`
27526
+ );
27527
+ }
27528
+ cookies.push(cookie);
27529
+ });
27530
+ const origins = [];
27531
+ parsed.origins.forEach((entry, index) => {
27532
+ if (!isRecord(entry) || !Array.isArray(entry.localStorage)) {
27533
+ throw invalidShape(`Browser state origins[${index}] must carry an origin string and a localStorage[] array.`);
27534
+ }
27535
+ if (!isOriginString(entry.origin)) {
27536
+ throw invalidShape(
27537
+ `Browser state origins[${index}].origin must be a URL origin such as https://app.example.com with no path, query, or fragment.`
27538
+ );
27539
+ }
27540
+ const localStorage = [];
27541
+ entry.localStorage.forEach((item, itemIndex) => {
27542
+ if (!isLocalStorageItem(item)) {
27543
+ throw invalidShape(
27544
+ `Browser state origins[${index}].localStorage[${itemIndex}] must be a record with string name and value.`
27545
+ );
27546
+ }
27547
+ localStorage.push({ name: item.name, value: item.value });
27548
+ });
27549
+ origins.push({ origin: entry.origin, localStorage });
27550
+ });
27551
+ if (cookies.length === 0 && origins.every((o) => o.localStorage.length === 0)) {
27552
+ throw new BrowserStateValidationError("empty", "Browser state is empty; nothing was captured. Did you log in?");
27553
+ }
27554
+ return { state: { cookies, origins }, bytes };
27555
+ }
27556
+ function summarizeStorageState(state) {
27557
+ return {
27558
+ cookieDomainCount: new Set(state.cookies.map((c) => c.domain.replace(/^\./, "").toLowerCase())).size,
27559
+ storageOriginCount: state.origins.filter((o) => o.localStorage.length > 0).length
27560
+ };
27561
+ }
27562
+ function isLocalHost(host) {
27563
+ return LOCAL_HOSTS.has(host.replace(/^\./, "").toLowerCase());
27564
+ }
27565
+ function sandboxAppOrigin(hostPort) {
27566
+ return `http://localhost:${hostPort}`;
27567
+ }
27568
+ function normalizeLocalOrigins(state, sandboxOrigin2) {
27569
+ const sandbox2 = new URL(sandboxOrigin2);
27570
+ const merged = /* @__PURE__ */ new Map();
27571
+ for (const entry of state.origins) {
27572
+ let host;
27573
+ try {
27574
+ host = new URL(entry.origin).hostname;
27575
+ } catch {
27576
+ host = "";
27577
+ }
27578
+ const origin = isLocalHost(host) ? sandbox2.origin : entry.origin;
27579
+ let items = merged.get(origin);
27580
+ if (!items) {
27581
+ items = /* @__PURE__ */ new Map();
27582
+ merged.set(origin, items);
27583
+ }
27584
+ for (const item of entry.localStorage) {
27585
+ items.delete(item.name);
27586
+ items.set(item.name, item.value);
27587
+ }
27588
+ }
27589
+ return {
27590
+ cookies: state.cookies.map(
27591
+ (cookie) => isLocalHost(cookie.domain) ? { ...cookie, domain: sandbox2.hostname, secure: false } : cookie
27592
+ ),
27593
+ origins: [...merged].map(([origin, items]) => ({
27594
+ origin,
27595
+ localStorage: [...items].map(([name, value]) => ({ name, value }))
27596
+ }))
27597
+ };
27598
+ }
27599
+ function providerHostsFromNavigations(urls) {
27600
+ const hosts = /* @__PURE__ */ new Set();
27601
+ for (const value of urls) {
27602
+ let url2;
27603
+ try {
27604
+ url2 = new URL(value);
27605
+ } catch {
27606
+ continue;
27607
+ }
27608
+ if (url2.protocol !== "http:" && url2.protocol !== "https:") continue;
27609
+ if (isLocalHost(url2.hostname)) continue;
27610
+ hosts.add(url2.hostname.toLowerCase());
27611
+ }
27612
+ return [...hosts].sort();
27613
+ }
27614
+
27615
+ // src/browser-capture.ts
27616
+ import { mkdtempSync, rmSync as rmSync2 } from "fs";
27617
+ import { tmpdir as tmpdir2 } from "os";
27618
+ import { join as join5 } from "path";
27619
+ var WINDOW_CLOSED_MESSAGE = "The browser window was closed before Enter was pressed. Run the command again, log in, then press Enter here before closing the window.";
27620
+ function describeStartUrl(startUrl) {
27621
+ try {
27622
+ const { origin } = new URL(startUrl);
27623
+ if (origin && origin !== "null") return sanitizeTerminalText(origin);
27624
+ } catch {
27625
+ }
27626
+ return "the start page";
27627
+ }
27628
+ async function launch(userDataDir) {
27629
+ const { chromium } = await import("playwright-core");
27630
+ const common = {
27631
+ headless: false,
27632
+ viewport: null,
27633
+ handleSIGINT: false,
27634
+ args: ["--no-first-run", "--no-default-browser-check"]
27635
+ };
27636
+ try {
27637
+ return await chromium.launchPersistentContext(userDataDir, { ...common, channel: "chrome" });
27638
+ } catch {
27639
+ try {
27640
+ return await chromium.launchPersistentContext(userDataDir, common);
27641
+ } catch {
27642
+ throw new CliError("user", "No browser found for the login window.", {
27643
+ hint: "Install Google Chrome, or run `npx playwright install chromium` and try again."
27644
+ });
27645
+ }
27646
+ }
27647
+ }
27648
+ async function captureBrowserLogin(opts) {
27649
+ let userDataDir;
27650
+ let context;
27651
+ const navigations = [];
27652
+ const done = new AbortController();
27653
+ const cleanup = async () => {
27654
+ const open2 = context;
27655
+ context = void 0;
27656
+ await open2?.close().catch(() => {
27657
+ });
27658
+ if (userDataDir) {
27659
+ rmSync2(userDataDir, { recursive: true, force: true });
27660
+ userDataDir = void 0;
27661
+ }
27662
+ };
27663
+ const onInterrupt = () => {
27664
+ void cleanup().finally(() => process.exit(EXIT_CODE_INTERRUPTED));
27665
+ };
27666
+ process.once("SIGINT", onInterrupt);
27667
+ try {
27668
+ userDataDir = mkdtempSync(join5(tmpdir2(), "arcanist-login-"));
27669
+ const open2 = await launch(userDataDir);
27670
+ context = open2;
27671
+ const track = (page2) => {
27672
+ page2.on("framenavigated", (frame) => {
27673
+ if (frame === page2.mainFrame()) navigations.push(frame.url());
27674
+ });
27675
+ };
27676
+ for (const page2 of open2.pages()) track(page2);
27677
+ open2.on("page", track);
27678
+ const page = open2.pages()[0] ?? await open2.newPage();
27679
+ let closed = false;
27680
+ const closedEarly = new Promise((_, reject) => {
27681
+ open2.once("close", () => {
27682
+ closed = true;
27683
+ reject(new CliError("user", WINDOW_CLOSED_MESSAGE));
27684
+ });
27685
+ });
27686
+ closedEarly.catch(() => {
27687
+ });
27688
+ const opened = await Promise.race([
27689
+ closedEarly,
27690
+ page.goto(opts.startUrl).then(
27691
+ () => true,
27692
+ () => false
27693
+ )
27694
+ ]);
27695
+ if (closed) await closedEarly;
27696
+ if (!opened) {
27697
+ opts.onStatus?.(
27698
+ `Could not open ${describeStartUrl(opts.startUrl)}; the window is open, navigate to your app yourself.`
27699
+ );
27700
+ }
27701
+ await Promise.race([closedEarly, opts.waitForDone(done.signal)]);
27702
+ const state = await open2.storageState();
27703
+ return { rawState: JSON.stringify(state), navigations };
27704
+ } finally {
27705
+ done.abort();
27706
+ process.off("SIGINT", onInterrupt);
27707
+ await cleanup();
27708
+ }
27709
+ }
27710
+
27711
+ // src/utils/prompt-line.ts
27712
+ import { createInterface as createInterface2 } from "readline/promises";
27713
+ async function promptLine(question, options = {}) {
27714
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
27715
+ throw new CliError(
27716
+ "user",
27717
+ "An interactive terminal is required here. Re-run with the matching flag in non-interactive environments."
27718
+ );
27719
+ }
27720
+ const { signal } = options;
27721
+ if (signal?.aborted) throw new CliError("user", "Cancelled.");
27722
+ const rl = createInterface2({ input: process.stdin, output: process.stderr });
27723
+ const onAbort = () => rl.close();
27724
+ signal?.addEventListener("abort", onAbort, { once: true });
27725
+ try {
27726
+ return await new Promise((resolve3, reject) => {
27727
+ rl.once("SIGINT", () => reject(new CliError("user", "Cancelled.", { exitCode: EXIT_CODE_INTERRUPTED })));
27728
+ rl.once("close", () => reject(new CliError("user", "Cancelled.")));
27729
+ rl.question(question).then((answer) => resolve3(answer.trim()), reject);
27730
+ });
27731
+ } finally {
27732
+ signal?.removeEventListener("abort", onAbort);
27733
+ rl.close();
27734
+ }
27735
+ }
27736
+
27737
+ // src/commands/environment-login.ts
27738
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
27739
+ async function resolveEnvironment(config2, target) {
27740
+ const response = await apiFetch(
27741
+ config2,
27742
+ "/api/environments"
27743
+ );
27744
+ const environments2 = response.data.environments;
27745
+ if (target && UUID.test(target)) {
27746
+ const byId = environments2.find((e) => e.id === target);
27747
+ if (!byId) throw new CliError("user", `No environment with id ${target}.`);
27748
+ return byId;
27749
+ }
27750
+ const repo = parseRepoArgOrCurrent(target, "environment");
27751
+ const match = environments2.find(
27752
+ (e) => e.scope === "team" && e.repoOwner.toLowerCase() === repo.owner.toLowerCase() && e.repoName.toLowerCase() === repo.repo.toLowerCase()
27753
+ );
27754
+ if (!match) {
27755
+ throw new CliError("user", `No team environment for ${repo.owner}/${repo.repo}.`, {
27756
+ hint: "Run `arcanist environments status` first."
27757
+ });
27758
+ }
27759
+ return match;
27760
+ }
27761
+ function sandboxOrigin(environment) {
27762
+ const appRuntime = environment.provisioningConfig?.sandbox?.appRuntime;
27763
+ const hostPort = appRuntime?.url?.hostPort;
27764
+ if (!appRuntime || !hostPort) {
27765
+ throw new CliError(
27766
+ "user",
27767
+ `Environment "${sanitizeTerminalText(environment.name)}" has no app runtime, so there is nothing to log into.`,
27768
+ {
27769
+ hint: "Configure the app runtime on the Environment page first."
27770
+ }
27771
+ );
27772
+ }
27773
+ const origin = sandboxAppOrigin(hostPort);
27774
+ const path = appRuntime.url?.path ?? "/";
27775
+ return { origin, startUrl: `${origin}${path.startsWith("/") ? path : `/${path}`}`, appRuntime };
27776
+ }
27777
+ function normalizeCapture(rawState, origin) {
27778
+ let parsed;
27779
+ try {
27780
+ parsed = parseStorageState(rawState);
27781
+ } catch (error61) {
27782
+ if (error61 instanceof BrowserStateValidationError) {
27783
+ const hint = error61.code === "empty" ? "Log into your app in the browser window before pressing Enter." : error61.code === "too_large" ? "Log out of sites you do not need for this app and try again." : void 0;
27784
+ throw new CliError("user", error61.message, hint ? { hint } : {});
27785
+ }
27786
+ throw error61;
27787
+ }
27788
+ const normalized = normalizeLocalOrigins(parsed.state, origin);
27789
+ return { normalized, counts: summarizeStorageState(normalized), bytes: parsed.bytes };
27790
+ }
27791
+ function requireInteractiveTerminal() {
27792
+ if (process.stdin.isTTY && process.stderr.isTTY) return;
27793
+ throw new CliError("user", "environments login needs an interactive terminal to wait for you to finish logging in.", {
27794
+ hint: "Run it from a terminal (add --yes --validate-path <path> to skip the other prompts), or use --clear to remove a saved login."
27795
+ });
27796
+ }
27797
+ function requireAppRelativePath(validatePath) {
27798
+ if (validatePath.startsWith("/") && !validatePath.startsWith("//")) return;
27799
+ throw new CliError("user", "validatePath must be an app-relative path like /dashboard.", {
27800
+ hint: "Pass a path on your own app, not a full URL."
27801
+ });
27802
+ }
27803
+ function say(line) {
27804
+ console.error(line);
27805
+ }
27806
+ async function confirmHosts(hosts, options) {
27807
+ if (hosts.length === 0 || options.networkPolicy === false) return [];
27808
+ say(
27809
+ "These provider hosts will be added to the environment's trusted domains so the sandbox can follow the login redirect:"
27810
+ );
27811
+ for (const host of hosts) say(` ${host}`);
27812
+ if (options.yes) return hosts;
27813
+ const answer = (await promptLine("Add them? [Y/n] ")).toLowerCase();
27814
+ return answer === "" || answer === "y" || answer === "yes" ? hosts : [];
27815
+ }
27816
+ async function saveValidatePath(config2, environmentId, validatePath) {
27817
+ const fresh = await apiFetch(
27818
+ config2,
27819
+ `/api/environments/${encodeURIComponent(environmentId)}`
27820
+ );
27821
+ const environment = fresh.data;
27822
+ const appRuntime = environment.provisioningConfig?.sandbox?.appRuntime ?? {};
27823
+ if (appRuntime.auth?.validatePath === validatePath) return false;
27824
+ const provisioningConfig = {
27825
+ ...environment.provisioningConfig,
27826
+ sandbox: {
27827
+ ...environment.provisioningConfig?.sandbox,
27828
+ appRuntime: { ...appRuntime, auth: { ...appRuntime.auth, validatePath } }
27829
+ }
27830
+ };
27831
+ await apiFetch(config2, `/api/environments/${encodeURIComponent(environment.id)}`, {
27832
+ method: "PUT",
27833
+ body: JSON.stringify({
27834
+ name: environment.name,
27835
+ description: environment.description,
27836
+ provisioningKind: environment.provisioningKind,
27837
+ provisioningConfig,
27838
+ cache: environment.cache,
27839
+ repos: environment.repos,
27840
+ networkPolicy: environment.networkPolicy,
27841
+ ...environment.reviewConfig !== void 0 ? { reviewConfig: environment.reviewConfig } : {}
27842
+ })
27843
+ });
27844
+ return true;
27845
+ }
27846
+ async function environmentLoginCommand(target, options = {}, command) {
27847
+ const { config: config2 } = resolveBusinessContext(command, options);
27848
+ if (!options.clear) {
27849
+ requireInteractiveTerminal();
27850
+ if (options.validatePath) requireAppRelativePath(options.validatePath);
27851
+ }
27852
+ const environment = await resolveEnvironment(config2, target);
27853
+ const name = sanitizeTerminalText(environment.name);
27854
+ const statePath = `/api/environments/${encodeURIComponent(environment.id)}/browser-state`;
27855
+ if (options.clear) {
27856
+ const result = await apiFetch(config2, statePath, { method: "DELETE" });
27857
+ emit(
27858
+ command,
27859
+ options,
27860
+ { environmentId: environment.id, deleted: result.data.deleted },
27861
+ ({ deleted }) => console.log(deleted ? `Browser login removed from "${name}".` : `"${name}" had no browser login.`)
27862
+ );
27863
+ return;
27864
+ }
27865
+ const { origin, startUrl, appRuntime } = sandboxOrigin(environment);
27866
+ say(`Environment: ${name} (${sanitizeTerminalText(environment.id)})`);
27867
+ say(`Sandbox app origin: ${origin}`);
27868
+ say("");
27869
+ say("Opening a fresh Chrome window. Log into your app and every provider it uses (Auth0, Google, ...)");
27870
+ say("using dedicated TEST accounts only. Do not use personal, admin, or billing accounts.");
27871
+ say(`If your app is running locally, open it at ${origin} so the saved login matches the sandbox.`);
27872
+ say("Press Enter here when you are done. Keep the window open until you press Enter.");
27873
+ const capture = await captureBrowserLogin({
27874
+ startUrl: options.url ?? startUrl,
27875
+ waitForDone: async (signal) => {
27876
+ await promptLine("", { signal });
27877
+ },
27878
+ onStatus: say
27879
+ });
27880
+ const { normalized, counts, bytes } = normalizeCapture(capture.rawState, origin);
27881
+ const hosts = providerHostsFromNavigations(capture.navigations);
27882
+ say("");
27883
+ say(
27884
+ `Captured browser state: ${counts.cookieDomainCount} cookie domains, ${counts.storageOriginCount} storage origins, ${Math.round(bytes / 1024)} KB`
27885
+ );
27886
+ const providerHosts = await confirmHosts(hosts, options);
27887
+ const upload = await apiFetch(config2, statePath, {
27888
+ method: "PUT",
27889
+ body: JSON.stringify({ state: JSON.stringify(normalized), providerHosts })
27890
+ });
27891
+ let validatePath = options.validatePath ?? appRuntime.auth?.validatePath;
27892
+ if (!validatePath) {
27893
+ say("");
27894
+ say(
27895
+ "This environment has no auth.validatePath. Enter a page only a signed-in user can see, so Arcanist can verify the login on every session:"
27896
+ );
27897
+ validatePath = await promptLine("> ") || void 0;
27898
+ }
27899
+ if (validatePath && validatePath !== appRuntime.auth?.validatePath) {
27900
+ requireAppRelativePath(validatePath);
27901
+ if (await saveValidatePath(config2, environment.id, validatePath)) {
27902
+ say("Environment rebuild scheduled (the app runtime profile changed).");
27903
+ }
27904
+ }
27905
+ const { networkPolicy } = upload.data;
27906
+ emit(
27907
+ command,
27908
+ options,
27909
+ {
27910
+ environmentId: environment.id,
27911
+ version: upload.data.browserState.version,
27912
+ counts,
27913
+ providerHosts,
27914
+ networkPolicy,
27915
+ validatePath: validatePath ?? null
27916
+ },
27917
+ () => {
27918
+ console.log(`Saved. Environment "${name}" now has browser login v${upload.data.browserState.version}.`);
27919
+ if (networkPolicy.added.length > 0) {
27920
+ console.log(`Trusted domains added: ${networkPolicy.added.map(sanitizeTerminalText).join(", ")}`);
27921
+ }
27922
+ if (networkPolicy.rejected.length > 0) {
27923
+ console.log(
27924
+ `Not added (fix on the Environment page): ${networkPolicy.rejected.map(sanitizeTerminalText).join(", ")}`
27925
+ );
27926
+ }
27927
+ if (networkPolicy.skipped) console.log("Network policy is full access; nothing to add.");
27928
+ if (hosts.length > 0 && providerHosts.length === 0) {
27929
+ console.log(
27930
+ "Provider hosts were not added; verification reports unavailable until they are on the trusted domains."
27931
+ );
27932
+ }
27933
+ if (!validatePath) {
27934
+ console.log(
27935
+ "No validatePath saved; Arcanist cannot verify this login until one is set on the Environment page or with --validate-path."
27936
+ );
27937
+ }
27938
+ }
27939
+ );
27940
+ }
27941
+
27453
27942
  // ../../shared/utils/timing.ts
27454
27943
  function sleep(ms) {
27455
27944
  return new Promise((resolve3) => setTimeout(resolve3, ms));
@@ -27551,6 +28040,42 @@ function nextAction(state) {
27551
28040
  }
27552
28041
  return "wait";
27553
28042
  }
28043
+ function environmentCoversRepo(environment, repo) {
28044
+ const covered = environment.repos?.length ? environment.repos : [{ repoOwner: environment.repoOwner, repoName: environment.repoName }];
28045
+ return covered.some(
28046
+ (entry) => entry.repoOwner.toLowerCase() === repo.owner.toLowerCase() && entry.repoName.toLowerCase() === repo.repo.toLowerCase()
28047
+ );
28048
+ }
28049
+ async function fetchBrowserLoginStatus(config2, repo) {
28050
+ let environments2;
28051
+ try {
28052
+ const response = await apiFetch(
28053
+ config2,
28054
+ "/api/environments"
28055
+ );
28056
+ environments2 = response.data.environments;
28057
+ } catch {
28058
+ return null;
28059
+ }
28060
+ const environment = environments2.find((item) => item.scope === "team" && environmentCoversRepo(item, repo));
28061
+ if (!environment) return null;
28062
+ const command = `arcanist environments login ${environment.repoOwner}/${environment.repoName}`;
28063
+ const summary = environment.browserState;
28064
+ if (!summary) return { state: "none", capturedAt: null, command };
28065
+ if (summary.reloginNeeded) return { state: "expired", capturedAt: summary.capturedAt, command };
28066
+ if (!summary.lastVerifiedAt) return { state: "unverified", capturedAt: summary.capturedAt, command: null };
28067
+ return { state: "ready", capturedAt: summary.capturedAt, command: null };
28068
+ }
28069
+ function printBrowserLogin(status) {
28070
+ if (!status) return;
28071
+ const label = {
28072
+ none: "not captured",
28073
+ ready: "ready",
28074
+ expired: "expired",
28075
+ unverified: "captured, not verified yet"
28076
+ }[status.state];
28077
+ console.log(`browser login: ${label}${status.command ? ` (run: ${sanitizeTerminalText(status.command)})` : ""}`);
28078
+ }
27554
28079
  function printState(state) {
27555
28080
  const detail = state.failureReason ?? state.stage ?? state.buildStatus ?? state.nextAction;
27556
28081
  console.log(
@@ -27584,7 +28109,11 @@ async function environmentStatusCommand(repoArg, options = {}, command) {
27584
28109
  const { config: config2 } = resolveBusinessContext(command, options);
27585
28110
  await apiFetch(config2, "/api/repos");
27586
28111
  const state = options.wait ? await waitForSetup(config2, repo.fullName, pollIntervalMs ?? SETUP_POLL_INTERVAL_MS) : await fetchSetupState(config2, repo.fullName);
27587
- emit(command, options, { repository: state }, ({ repository }) => printState(repository));
28112
+ const browserLogin = await fetchBrowserLoginStatus(config2, repo);
28113
+ emit(command, options, { repository: state, browserLogin }, ({ repository, browserLogin: login }) => {
28114
+ printState(repository);
28115
+ printBrowserLogin(login);
28116
+ });
27588
28117
  }
27589
28118
  async function environmentPrepareCommand(repoArg, options = {}, command) {
27590
28119
  const pollIntervalMs = options.wait ? parsePollInterval(options.pollInterval, { defaultMs: SETUP_POLL_INTERVAL_MS, maxMs: null }) : null;
@@ -27715,16 +28244,6 @@ Code: ${action.userCode}
27715
28244
  var CODEX_AGENT_RUNTIME_BACKEND = "codex";
27716
28245
  var AGENT_RUNTIME_BACKENDS = [CODEX_AGENT_RUNTIME_BACKEND];
27717
28246
 
27718
- // ../../shared/utils/type-guards.ts
27719
- function isRecord(value) {
27720
- return !!value && typeof value === "object" && !Array.isArray(value);
27721
- }
27722
- function asNonEmptyString(value) {
27723
- if (typeof value !== "string") return void 0;
27724
- const trimmed = value.trim();
27725
- return trimmed.length > 0 ? trimmed : void 0;
27726
- }
27727
-
27728
28247
  // ../../shared/constants/models.ts
27729
28248
  var OpenAIModel = {
27730
28249
  GPT56: "gpt-5.6",
@@ -32083,7 +32602,7 @@ JSON:
32083
32602
  JSON mode returns {branches}
32084
32603
  `
32085
32604
  ).action((repo, options, command) => repoBranchesCommand(repo, options, command));
32086
- var environments = program.command("environments").description("Zeus environment setup commands");
32605
+ var environments = program.command("environments").alias("env").description("Zeus environment setup commands");
32087
32606
  environments.command("prepare").description("Prepare a repository for Zeus reviews").argument("[repo]", "Repository owner/name; defaults to current git remote").option("--wait", "Wait until setup is ready or needs attention").option("--poll-interval <ms>", "Polling interval while waiting (default 5000)").addHelpText(
32088
32607
  "after",
32089
32608
  `
@@ -32096,12 +32615,27 @@ environments.command("status").description("Show repository environment setup st
32096
32615
  "after",
32097
32616
  `
32098
32617
  JSON:
32099
- Returns {repository: {fullName, status, stage, nextAction, command, buildStatus?, failureReason?, secretRequest?, question?}}.
32618
+ Returns {repository: {fullName, status, stage, nextAction, command, buildStatus?, failureReason?, secretRequest?, question?}, browserLogin}.
32619
+ browserLogin is {state, capturedAt, command} for the repository's team environment, or null when there is none.
32620
+ The stable browserLogin state values are none, ready, expired, and unverified.
32100
32621
  command is the CLI argv to run, or null when the next step is the web UI or Arcanist.
32101
32622
  The stable status values are not_started, pending, running, escalated, succeeded, failed, and ready.
32102
32623
  `
32103
32624
  ).action((repo, options, command) => environmentStatusCommand(repo, options, command));
32104
32625
  environments.command("retry").description("Retry a failed repository environment setup").argument("[repo]", "Repository owner/name; defaults to current git remote").option("--wait", "Wait until setup is ready or needs attention").option("--poll-interval <ms>", "Polling interval while waiting (default 5000)").action((repo, options, command) => environmentRetryCommand(repo, options, command));
32626
+ environments.command("login").description("Log into your app once in a local browser and save the login for Zeus sessions").argument("[environment]", "Environment id or repository owner/name; defaults to current git remote").option("--url <url>", "Page to open first instead of the app runtime URL").option("--validate-path <path>", "Signed-in-only page Arcanist opens to verify the login, e.g. /dashboard").option("--yes", "Add captured provider hosts to trusted domains without asking").option("--no-network-policy", "Do not touch the environment's trusted domains").option("--clear", "Remove the saved browser login").addHelpText(
32627
+ "after",
32628
+ `
32629
+ Examples:
32630
+ arcanist env login owner/repo
32631
+ arcanist env login owner/repo --validate-path /dashboard --yes
32632
+ arcanist env login owner/repo --clear
32633
+
32634
+ JSON:
32635
+ JSON mode returns {environmentId, version, counts: {cookieDomainCount, storageOriginCount}, providerHosts, networkPolicy: {added, rejected, skipped}, validatePath}.
32636
+ With --clear it returns {environmentId, deleted}. Instructions and prompts go to stderr; the capture path needs an interactive terminal (pass --yes --validate-path to skip the other prompts).
32637
+ `
32638
+ ).action((environment, options, command) => environmentLoginCommand(environment, options, command));
32105
32639
  var models = program.command("models").description("Model discovery commands");
32106
32640
  models.command("list").description("List session-start models").addHelpText(
32107
32641
  "after",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryarcanist/cli",
3
- "version": "0.1.361",
3
+ "version": "0.1.363",
4
4
  "description": "Command-line interface for Arcanist: inspect sessions, prepare Zeus environments, and trigger reviews",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,7 +29,8 @@
29
29
  "homepage": "https://www.tryarcanist.com",
30
30
  "license": "MIT",
31
31
  "dependencies": {
32
- "commander": "^12.1.0"
32
+ "commander": "^12.1.0",
33
+ "playwright-core": "^1.61.1"
33
34
  },
34
35
  "devDependencies": {
35
36
  "tsup": "^8.0.0",