agentapprove 0.1.15 → 0.1.21

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 (2) hide show
  1. package/dist/cli.js +386 -61
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2630,8 +2630,204 @@ function shouldCreateFreshPairing(connectionMethod) {
2630
2630
  return connectionMethod === "qr" || connectionMethod === "copy";
2631
2631
  }
2632
2632
 
2633
+ // src/install-validation.ts
2634
+ async function safeReadText(response) {
2635
+ try {
2636
+ return await response.text();
2637
+ } catch {
2638
+ return "";
2639
+ }
2640
+ }
2641
+ function parseErrorBody(body) {
2642
+ if (!body)
2643
+ return null;
2644
+ try {
2645
+ const parsed = JSON.parse(body);
2646
+ if (parsed && typeof parsed === "object") {
2647
+ return parsed;
2648
+ }
2649
+ } catch {}
2650
+ return null;
2651
+ }
2652
+ async function validateExistingToken(token, apiUrl, apiVersion, options = {}) {
2653
+ const fetchImpl = options.fetchImpl || fetch;
2654
+ const filename = options.preflightFilename || "common.sh";
2655
+ const url = `${apiUrl}/${apiVersion}/hooks/${filename}?format=raw`;
2656
+ let response;
2657
+ try {
2658
+ response = await fetchImpl(url, {
2659
+ headers: {
2660
+ Authorization: `Bearer ${token}`
2661
+ }
2662
+ });
2663
+ } catch (err) {
2664
+ const message = err instanceof Error ? err.message : "network error";
2665
+ return {
2666
+ kind: "network_error",
2667
+ summary: "cannot reach Agent Approve from this network",
2668
+ serverMessage: message
2669
+ };
2670
+ }
2671
+ const status = response.status;
2672
+ if (response.ok) {
2673
+ const body2 = await safeReadText(response);
2674
+ return {
2675
+ kind: "valid",
2676
+ summary: "verified - token is valid",
2677
+ cachedContent: body2,
2678
+ status
2679
+ };
2680
+ }
2681
+ const body = await safeReadText(response);
2682
+ const parsed = parseErrorBody(body);
2683
+ const code = parsed?.code;
2684
+ const serverMessage = parsed?.error;
2685
+ if (status === 401 && code === "TOKEN_EXPIRED") {
2686
+ return {
2687
+ kind: "expired",
2688
+ summary: "expired (refresh required)",
2689
+ serverMessage,
2690
+ status
2691
+ };
2692
+ }
2693
+ if (status === 401 && code === "INVALID_TOKEN") {
2694
+ return {
2695
+ kind: "invalid",
2696
+ summary: "invalid (re-pair required)",
2697
+ serverMessage,
2698
+ status
2699
+ };
2700
+ }
2701
+ if (status === 403 && code === "AUTH_SCOPE_DENIED") {
2702
+ return {
2703
+ kind: "scope_denied",
2704
+ summary: "scope denied (re-pair required)",
2705
+ serverMessage,
2706
+ status
2707
+ };
2708
+ }
2709
+ if (status === 503) {
2710
+ return {
2711
+ kind: "unavailable",
2712
+ summary: "validation temporarily unavailable",
2713
+ serverMessage,
2714
+ status
2715
+ };
2716
+ }
2717
+ if (status === 403) {
2718
+ return {
2719
+ kind: "network_blocked",
2720
+ summary: "cannot reach service from this network",
2721
+ serverMessage,
2722
+ status
2723
+ };
2724
+ }
2725
+ if (status === 401) {
2726
+ return {
2727
+ kind: "invalid",
2728
+ summary: "invalid (re-pair required)",
2729
+ serverMessage,
2730
+ status
2731
+ };
2732
+ }
2733
+ return {
2734
+ kind: "unavailable",
2735
+ summary: "validation temporarily unavailable",
2736
+ serverMessage,
2737
+ status
2738
+ };
2739
+ }
2740
+ function classifyHookDownloadFailure(status, body) {
2741
+ const parsed = parseErrorBody(body);
2742
+ const code = parsed?.code;
2743
+ if (status === 401 && code === "TOKEN_EXPIRED")
2744
+ return "token_expired";
2745
+ if (status === 401 && code === "INVALID_TOKEN")
2746
+ return "token_invalid";
2747
+ if (status === 403 && code === "AUTH_SCOPE_DENIED")
2748
+ return "scope_denied";
2749
+ if (status === 503)
2750
+ return "validation_unavailable";
2751
+ if (status === 403)
2752
+ return "network_blocked";
2753
+ if (status === 401)
2754
+ return "token_invalid";
2755
+ return "recoverable";
2756
+ }
2757
+
2758
+ // src/copy-hook-scripts.ts
2759
+ import { writeFileSync as fsWriteFileSync } from "fs";
2760
+ import { join as pathJoin } from "path";
2761
+ async function copyHookScripts(hooksDir, token, files, options) {
2762
+ const fetchImpl = options.fetchImpl || fetch;
2763
+ const writeFile = options.writeFileSync || fsWriteFileSync;
2764
+ const join = options.joinPath || pathJoin;
2765
+ let downloaded = 0;
2766
+ const failed = [];
2767
+ for (const file of files) {
2768
+ try {
2769
+ const response = await fetchImpl(`${options.apiUrl}/${options.apiVersion}/hooks/${file}?format=raw`, {
2770
+ headers: {
2771
+ Authorization: `Bearer ${token}`
2772
+ }
2773
+ });
2774
+ if (response.ok) {
2775
+ const content = await response.text();
2776
+ const isShellScript = content.startsWith("#!/") || content.startsWith("# ");
2777
+ const isJsBundle = file.endsWith(".js") && (content.startsWith("//") || content.startsWith("import ") || content.startsWith("var ") || content.startsWith("const ") || content.startsWith("export "));
2778
+ if (isShellScript || isJsBundle) {
2779
+ const filePath = join(hooksDir, file);
2780
+ writeFile(filePath, content, { mode: isShellScript ? 493 : 420 });
2781
+ downloaded++;
2782
+ } else {
2783
+ failed.push(`${file} (invalid content)`);
2784
+ }
2785
+ } else {
2786
+ const body = await response.text().catch(() => "");
2787
+ const failureKind = classifyHookDownloadFailure(response.status, body);
2788
+ if (failureKind !== "recoverable") {
2789
+ return {
2790
+ downloaded,
2791
+ failed,
2792
+ terminalFailure: {
2793
+ kind: failureKind,
2794
+ file,
2795
+ status: response.status
2796
+ }
2797
+ };
2798
+ }
2799
+ failed.push(`${file} (${response.status})`);
2800
+ }
2801
+ } catch (err) {
2802
+ failed.push(`${file} (${err instanceof Error ? err.message : "network error"})`);
2803
+ }
2804
+ }
2805
+ return { downloaded, failed };
2806
+ }
2807
+
2808
+ // src/pairing-api-url.ts
2809
+ function looksLikeHostedAgentApproveApi(baseUrl) {
2810
+ try {
2811
+ const host = new URL(baseUrl).hostname.toLowerCase();
2812
+ return host === "agentapprove.com" || host.endsWith(".agentapprove.com");
2813
+ } catch {
2814
+ return false;
2815
+ }
2816
+ }
2817
+ function resolvePairingApiBaseUrl(options) {
2818
+ const fromEnv = options.agentapproveApiEnv?.trim();
2819
+ if (fromEnv) {
2820
+ return fromEnv;
2821
+ }
2822
+ const fromFile = options.savedApiUrl?.trim();
2823
+ if (fromFile) {
2824
+ return fromFile;
2825
+ }
2826
+ return options.processDefaultApiUrl;
2827
+ }
2828
+
2633
2829
  // src/cli.ts
2634
- var VERSION = "0.1.15";
2830
+ var VERSION = "0.1.21";
2635
2831
  function getApiUrl() {
2636
2832
  return process.env.AGENTAPPROVE_API || "https://api.agentapprove.com";
2637
2833
  }
@@ -2703,11 +2899,11 @@ function getCommand() {
2703
2899
  }
2704
2900
  return filtered[0] || "install";
2705
2901
  }
2706
- var OPENCODE_PLUGIN_VERSION = "0.1.12";
2902
+ var OPENCODE_PLUGIN_VERSION = "0.1.18";
2707
2903
  var OPENCODE_PLUGIN_SPEC = `@agentapprove/opencode@${OPENCODE_PLUGIN_VERSION}`;
2708
- var OPENCLAW_PLUGIN_VERSION = "0.2.10";
2904
+ var OPENCLAW_PLUGIN_VERSION = "0.2.11";
2709
2905
  var OPENCLAW_PLUGIN_SPEC = `@agentapprove/openclaw@${OPENCLAW_PLUGIN_VERSION}`;
2710
- var PI_PLUGIN_VERSION = "0.1.2";
2906
+ var PI_PLUGIN_VERSION = "0.1.6";
2711
2907
  var PI_PLUGIN_SPEC = `npm:@agentapprove/pi@${PI_PLUGIN_VERSION}`;
2712
2908
  var PI_STATUS_TIMEOUT_MS = 5000;
2713
2909
  var AGENTS = {
@@ -3590,10 +3786,10 @@ function disableCodexFeatureFlag(configPath = join(homedir(), ".codex", "config.
3590
3786
  writeFileSync(configPath, updated, { mode: 384 });
3591
3787
  return { updated: true, backupPath };
3592
3788
  }
3593
- async function createPairingSession(configuredAgents, e2eKeyId) {
3789
+ async function createPairingSession(configuredAgents, e2eKeyId, apiBaseUrl = API_URL) {
3594
3790
  try {
3595
3791
  const machineHostname = hostname();
3596
- const response = await fetch(`${API_URL}/${API_VERSION}/pair`, {
3792
+ const response = await fetch(`${apiBaseUrl}/${API_VERSION}/pair`, {
3597
3793
  method: "POST",
3598
3794
  headers: { "Content-Type": "application/json" },
3599
3795
  body: JSON.stringify({
@@ -3612,9 +3808,9 @@ async function createPairingSession(configuredAgents, e2eKeyId) {
3612
3808
  return { sessionCode: "", qrUrl: "", expiresIn: 0, error: String(err) };
3613
3809
  }
3614
3810
  }
3615
- async function pollPairingSession(sessionCode) {
3811
+ async function pollPairingSession(sessionCode, apiBaseUrl = API_URL) {
3616
3812
  try {
3617
- const response = await fetch(`${API_URL}/${API_VERSION}/pair/${sessionCode}`);
3813
+ const response = await fetch(`${apiBaseUrl}/${API_VERSION}/pair/${sessionCode}`);
3618
3814
  return await response.json();
3619
3815
  } catch {
3620
3816
  return { status: "error" };
@@ -3623,7 +3819,7 @@ async function pollPairingSession(sessionCode) {
3623
3819
  function sleep(ms) {
3624
3820
  return new Promise((resolve) => setTimeout(resolve, ms));
3625
3821
  }
3626
- async function waitForPairing(sessionCode, onProgress, onCancel) {
3822
+ async function waitForPairing(sessionCode, onProgress, onCancel, apiBaseUrl = API_URL) {
3627
3823
  let cancelled = false;
3628
3824
  const handleKeypress = (key) => {
3629
3825
  const char = key.toString();
@@ -3650,14 +3846,14 @@ async function waitForPairing(sessionCode, onProgress, onCancel) {
3650
3846
  cleanup();
3651
3847
  return "cancelled";
3652
3848
  }
3653
- const result = await pollPairingSession(sessionCode);
3849
+ const result = await pollPairingSession(sessionCode, apiBaseUrl);
3654
3850
  if (result.status === "completed" && result.token) {
3655
3851
  cleanup();
3656
3852
  return {
3657
3853
  token: result.token,
3658
3854
  privacy: result.privacy || "full",
3659
3855
  email: result.email || "",
3660
- apiUrl: result.apiUrl || API_URL,
3856
+ apiUrl: result.apiUrl || apiBaseUrl,
3661
3857
  e2eServerKey: result.e2eServerKey
3662
3858
  };
3663
3859
  }
@@ -4428,35 +4624,27 @@ codex_hooks = true`;
4428
4624
  }
4429
4625
  return "";
4430
4626
  }
4431
- async function copyHookScripts(hooksDir, token, files) {
4432
- let downloaded = 0;
4433
- const failed = [];
4434
- for (const file of files) {
4435
- try {
4436
- const response = await fetch(`${API_URL}/${API_VERSION}/hooks/${file}?format=raw`, {
4437
- headers: {
4438
- Authorization: `Bearer ${token}`
4439
- }
4440
- });
4441
- if (response.ok) {
4442
- const content = await response.text();
4443
- const isShellScript = content.startsWith("#!/") || content.startsWith("# ");
4444
- const isJsBundle = file.endsWith(".js") && (content.startsWith("//") || content.startsWith("import ") || content.startsWith("var ") || content.startsWith("const ") || content.startsWith("export "));
4445
- if (isShellScript || isJsBundle) {
4446
- const filePath = join(hooksDir, file);
4447
- writeFileSync(filePath, content, { mode: isShellScript ? 493 : 420 });
4448
- downloaded++;
4449
- } else {
4450
- failed.push(`${file} (invalid content)`);
4451
- }
4452
- } else {
4453
- failed.push(`${file} (${response.status})`);
4454
- }
4455
- } catch (err) {
4456
- failed.push(`${file} (${err instanceof Error ? err.message : "network error"})`);
4457
- }
4458
- }
4459
- return { downloaded, failed };
4627
+ function describeDownloadTerminalFailure(kind) {
4628
+ switch (kind) {
4629
+ case "token_expired":
4630
+ return "Hook download stopped: your saved token has expired. Run `npx agentapprove refresh` to re-pair, or run the installer again to choose Refresh / Re-pair.";
4631
+ case "token_invalid":
4632
+ return "Hook download stopped: your saved token is no longer valid. Run `npx agentapprove pair` to pair this computer again.";
4633
+ case "scope_denied":
4634
+ return "Hook download stopped: your saved token cannot download hooks. Run `npx agentapprove pair` to pair this computer again.";
4635
+ case "validation_unavailable":
4636
+ return "Hook download stopped: Agent Approve could not validate your token right now. Please try again in a few minutes.";
4637
+ case "network_blocked":
4638
+ return "Hook download stopped: Service temporarily unavailable. Try again later.";
4639
+ case "recoverable":
4640
+ return "Hook download stopped: encountered an unexpected response.";
4641
+ }
4642
+ }
4643
+ async function copyHookScripts2(hooksDir, token, files, options = {}) {
4644
+ return copyHookScripts(hooksDir, token, files, {
4645
+ apiUrl: options.apiUrl || API_URL,
4646
+ apiVersion: API_VERSION
4647
+ });
4460
4648
  }
4461
4649
  function readOpenClawInstalledVersion() {
4462
4650
  const packagePath = join(homedir(), ".openclaw", "extensions", "openclaw", "package.json");
@@ -4810,16 +4998,107 @@ async function checkSystemDependencies() {
4810
4998
  }
4811
4999
  return true;
4812
5000
  }
5001
+ async function runExistingTokenPreflight(token, apiUrlForPreflight) {
5002
+ const preflightSpinner = _2();
5003
+ preflightSpinner.start("Checking saved token");
5004
+ let result = await validateExistingToken(token, apiUrlForPreflight, API_VERSION);
5005
+ if (result.kind === "unavailable" || result.kind === "network_error") {
5006
+ const message = result.kind === "unavailable" ? "Token validation is temporarily unavailable." : "Could not reach Agent Approve from this network.";
5007
+ preflightSpinner.stop(message);
5008
+ const retryChoice = await le({
5009
+ message: "How would you like to continue?",
5010
+ options: [
5011
+ { value: "retry", label: "Retry" },
5012
+ { value: "cancel", label: "Cancel" }
5013
+ ]
5014
+ });
5015
+ if (lD(retryChoice) || retryChoice === "cancel") {
5016
+ he("Installation cancelled");
5017
+ process.exit(0);
5018
+ }
5019
+ preflightSpinner.start("Checking saved token");
5020
+ result = await validateExistingToken(token, apiUrlForPreflight, API_VERSION);
5021
+ }
5022
+ switch (result.kind) {
5023
+ case "valid":
5024
+ preflightSpinner.stop("Saved token verified");
5025
+ break;
5026
+ case "expired":
5027
+ preflightSpinner.stop("Saved token has expired");
5028
+ break;
5029
+ case "invalid":
5030
+ case "scope_denied":
5031
+ preflightSpinner.stop("Saved token is no longer valid");
5032
+ break;
5033
+ case "network_blocked":
5034
+ preflightSpinner.stop("Service temporarily unavailable");
5035
+ break;
5036
+ default:
5037
+ preflightSpinner.stop("Saved token check finished");
5038
+ }
5039
+ return result;
5040
+ }
5041
+ function describeExistingTokenStatus(preflight) {
5042
+ if (!preflight) {
5043
+ return "already linked to your Agent Approve account";
5044
+ }
5045
+ switch (preflight.kind) {
5046
+ case "valid":
5047
+ return "verified - linked to your Agent Approve account";
5048
+ case "expired":
5049
+ return "expired (refresh required)";
5050
+ case "invalid":
5051
+ return "invalid (re-pair required)";
5052
+ case "scope_denied":
5053
+ return "scope denied (re-pair required)";
5054
+ case "unavailable":
5055
+ return "validation temporarily unavailable";
5056
+ case "network_blocked":
5057
+ return "cannot reach service from this network";
5058
+ case "network_error":
5059
+ return "cannot reach Agent Approve from this network";
5060
+ }
5061
+ }
5062
+ async function promptTokenRecoveryAction(preflight) {
5063
+ if (preflight.kind === "expired") {
5064
+ const choice2 = await le({
5065
+ message: "Your saved token has expired. How would you like to continue?",
5066
+ options: [
5067
+ { value: "refresh", label: "Refresh token", hint: "Keeps the existing encryption key and current settings" },
5068
+ { value: "repair", label: "Pair this computer again", hint: "You can rotate the encryption key" },
5069
+ { value: "cancel", label: "Cancel", hint: "Exit the installer" }
5070
+ ]
5071
+ });
5072
+ if (lD(choice2))
5073
+ return "cancel";
5074
+ return choice2;
5075
+ }
5076
+ const choice = await le({
5077
+ message: "Your saved token is no longer valid. How would you like to continue?",
5078
+ options: [
5079
+ { value: "repair", label: "Pair this computer again" },
5080
+ { value: "cancel", label: "Cancel", hint: "Exit the installer" }
5081
+ ]
5082
+ });
5083
+ if (lD(choice))
5084
+ return "cancel";
5085
+ return choice;
5086
+ }
4813
5087
  async function installCommand() {
4814
5088
  console.clear();
4815
5089
  pe(source_default.bgCyan.black(" Agent Approve ") + source_default.gray(` Hooks Installer v${VERSION}`));
4816
5090
  migrateE2ERootKey();
4817
- const isCustomApi = !API_URL.includes("agentapprove.com");
4818
- if (isCustomApi) {
4819
- v2.warn(`Using custom API: ${API_URL}`);
4820
- }
4821
5091
  await checkSystemDependencies();
4822
5092
  const existingConfig = readExistingConfig();
5093
+ const pairingApiBase = resolvePairingApiBaseUrl({
5094
+ agentapproveApiEnv: process.env.AGENTAPPROVE_API,
5095
+ savedApiUrl: existingConfig?.apiUrl,
5096
+ processDefaultApiUrl: API_URL
5097
+ });
5098
+ const isCustomApi = !looksLikeHostedAgentApproveApi(pairingApiBase);
5099
+ if (isCustomApi) {
5100
+ v2.warn(`Using custom API: ${pairingApiBase}`);
5101
+ }
4823
5102
  const hasExistingToken = !!(existingConfig?.token && existingConfig.token.length > 10);
4824
5103
  let existingTokenPreview = "Not set";
4825
5104
  let existingKeyId = null;
@@ -4831,6 +5110,12 @@ async function installCommand() {
4831
5110
  existingKeyId = createHash("sha256").update(Buffer.from(keyHex, "hex")).digest("hex").slice(0, 8);
4832
5111
  }
4833
5112
  }
5113
+ const existingTokenPreflight = hasExistingToken ? await runExistingTokenPreflight(existingConfig.token, pairingApiBase) : null;
5114
+ if (existingTokenPreflight?.kind === "network_blocked") {
5115
+ v2.error("Service temporarily unavailable. Try again later.");
5116
+ he("Installation cancelled");
5117
+ process.exit(1);
5118
+ }
4834
5119
  me(`Approve AI agent actions from your iPhone or Apple Watch.
4835
5120
  Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw, and more.`, "About");
4836
5121
  const installedAgents = detectInstalledAgents();
@@ -4850,7 +5135,7 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
4850
5135
  process.exit(0);
4851
5136
  }
4852
5137
  const setupProfileSummary = existingConfig ? formatSetupProfileBlock("Existing config", getInitialInstallConfig("existing-config", existingConfig), [
4853
- `Connection token: ${existingTokenPreview} - already linked to your Agent Approve account.`,
5138
+ `Connection token: ${existingTokenPreview} - ${describeExistingTokenStatus(existingTokenPreflight)}.`,
4854
5139
  ...existingKeyId ? [`Encryption key ID: ${existingKeyId} - already installed for this computer.`] : []
4855
5140
  ]) : formatSetupProfileBlock("Recommended setup", getInitialInstallConfig("recommended", existingConfig));
4856
5141
  me(setupProfileSummary, "Setup profiles");
@@ -4862,13 +5147,23 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
4862
5147
  he("Installation cancelled");
4863
5148
  process.exit(0);
4864
5149
  }
5150
+ let pendingRecoveryAction = null;
5151
+ if (hasExistingToken && existingTokenPreflight && (existingTokenPreflight.kind === "expired" || existingTokenPreflight.kind === "invalid" || existingTokenPreflight.kind === "scope_denied")) {
5152
+ pendingRecoveryAction = await promptTokenRecoveryAction(existingTokenPreflight);
5153
+ if (pendingRecoveryAction === "cancel") {
5154
+ he("Installation cancelled");
5155
+ process.exit(0);
5156
+ }
5157
+ }
5158
+ const requiresFreshPair = pendingRecoveryAction !== null;
5159
+ const forceKeyReuseForRecovery = pendingRecoveryAction === "refresh";
4865
5160
  ensureAgentApproveDir();
4866
5161
  const hooksDir = join(getAgentApproveDir(), "hooks");
4867
5162
  const selectedInstallConfig = getInitialInstallConfig(setupProfile, existingConfig);
4868
5163
  let token = null;
4869
5164
  let finalPrivacy = selectedInstallConfig.privacy;
4870
5165
  let email = "";
4871
- let apiUrl = API_URL;
5166
+ let apiUrl = pairingApiBase;
4872
5167
  let debugLog = selectedInstallConfig.debugLog;
4873
5168
  let retentionDays = selectedInstallConfig.retentionDays;
4874
5169
  let failBehavior = selectedInstallConfig.failBehavior;
@@ -4956,7 +5251,8 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
4956
5251
  if (existsSync2(existingKeyPath)) {
4957
5252
  const oldKeyHex = readFileSync(existingKeyPath, "utf-8").trim();
4958
5253
  const oldKeyId = createHash("sha256").update(Buffer.from(oldKeyHex, "hex")).digest("hex").slice(0, 8);
4959
- if (setupProfile === "existing-config") {
5254
+ const autoReuseKey = forceKeyReuseForRecovery || setupProfile === "existing-config" && !requiresFreshPair;
5255
+ if (autoReuseKey) {
4960
5256
  e2eUserKey = oldKeyHex;
4961
5257
  } else {
4962
5258
  v2.info(`Existing E2E key found (Key ID: ${oldKeyId})`);
@@ -5014,9 +5310,10 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
5014
5310
  }
5015
5311
  debugLog = debugLogChoice;
5016
5312
  }
5017
- const silentlyReuseExistingToken = setupProfile === "existing-config" && hasExistingToken;
5313
+ const canReuseExistingToken = hasExistingToken && !requiresFreshPair;
5314
+ const silentlyReuseExistingToken = setupProfile === "existing-config" && canReuseExistingToken;
5018
5315
  const connectionOptions = [];
5019
- if (hasExistingToken) {
5316
+ if (canReuseExistingToken) {
5020
5317
  const tokenPreview = existingConfig.token.slice(0, 15) + "...";
5021
5318
  connectionOptions.push({
5022
5319
  value: "existing",
@@ -5024,10 +5321,20 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
5024
5321
  hint: tokenPreview
5025
5322
  });
5026
5323
  }
5027
- connectionOptions.push({ value: "qr", label: "Scan QR code", hint: hasExistingToken ? undefined : "Recommended" }, { value: "copy", label: "Copy and paste code", hint: "No camera" });
5324
+ connectionOptions.push({ value: "qr", label: "Scan QR code", hint: canReuseExistingToken ? undefined : "Recommended" }, { value: "copy", label: "Copy and paste code", hint: "No camera" });
5028
5325
  let connectionMethod;
5029
5326
  if (silentlyReuseExistingToken) {
5030
5327
  connectionMethod = "existing";
5328
+ } else if (requiresFreshPair) {
5329
+ const connectionChoice = await le({
5330
+ message: pendingRecoveryAction === "refresh" ? "How would you like to refresh the token?" : "How would you like to pair this computer?",
5331
+ options: connectionOptions
5332
+ });
5333
+ if (lD(connectionChoice)) {
5334
+ he("Installation cancelled");
5335
+ process.exit(0);
5336
+ }
5337
+ connectionMethod = connectionChoice;
5031
5338
  } else {
5032
5339
  const connectionChoice = await le({
5033
5340
  message: "Connect to iOS app (required for setup and any hook downloads)",
@@ -5041,7 +5348,7 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
5041
5348
  }
5042
5349
  if (connectionMethod === "existing") {
5043
5350
  token = existingConfig.token;
5044
- apiUrl = existingConfig.apiUrl || API_URL;
5351
+ apiUrl = pairingApiBase;
5045
5352
  if (setupProfile === "existing-config" && existingConfig?.configSetAt) {
5046
5353
  configSetAt = existingConfig.configSetAt;
5047
5354
  }
@@ -5067,7 +5374,7 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
5067
5374
  configSetAt,
5068
5375
  e2eEnabled: useE2E
5069
5376
  };
5070
- const session = await createPairingSession(selectedAgents, e2eKeyId);
5377
+ const session = await createPairingSession(selectedAgents, e2eKeyId, pairingApiBase);
5071
5378
  if (!session || session.error) {
5072
5379
  v2.error(`Failed to create pairing session: ${session?.error || "Unknown error"}`);
5073
5380
  he("Cannot continue without token");
@@ -5093,7 +5400,7 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
5093
5400
  const minutes = Math.floor(expiresIn / 60);
5094
5401
  const seconds = expiresIn % 60;
5095
5402
  pairingSpinner.message(`Waiting for iOS app... ${minutes}:${seconds.toString().padStart(2, "0")}`);
5096
- }, () => {});
5403
+ }, () => {}, pairingApiBase);
5097
5404
  if (result === "cancelled") {
5098
5405
  pairingSpinner.stop("Cancelled");
5099
5406
  he("Installation cancelled");
@@ -5102,6 +5409,7 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
5102
5409
  token = result.token;
5103
5410
  finalPrivacy = result.privacy;
5104
5411
  email = result.email;
5412
+ apiUrl = result.apiUrl || pairingApiBase;
5105
5413
  if (e2eUserKey && e2eKeyId) {
5106
5414
  const rootKeyPath = join(agentApproveDir, "e2e-root-key");
5107
5415
  writeFileSync(rootKeyPath, e2eUserKey, { mode: 384 });
@@ -5152,9 +5460,15 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
5152
5460
  if (hookDownloadPlan.files.length > 0) {
5153
5461
  const downloadSpinner = _2();
5154
5462
  downloadSpinner.start("Downloading hook scripts");
5155
- const downloadResult = await copyHookScripts(hooksDir, token, hookDownloadPlan.files);
5463
+ const downloadResult = await copyHookScripts2(hooksDir, token, hookDownloadPlan.files, { apiUrl });
5156
5464
  const summary = formatHookDownloadSummary(hookDownloadPlan);
5157
- if (downloadResult.failed.length > 0) {
5465
+ if (downloadResult.terminalFailure) {
5466
+ downloadSpinner.stop(`Downloaded ${downloadResult.downloaded} of ${hookDownloadPlan.files.length} hook files (${summary})`);
5467
+ const message = describeDownloadTerminalFailure(downloadResult.terminalFailure.kind);
5468
+ v2.error(message);
5469
+ he("Hook download stopped before completion.");
5470
+ process.exit(1);
5471
+ } else if (downloadResult.failed.length > 0) {
5158
5472
  downloadSpinner.stop(`Downloaded ${downloadResult.downloaded} of ${hookDownloadPlan.files.length} hook files (${summary})`);
5159
5473
  v2.warn(`Failed to download: ${downloadResult.failed.join(", ")}`);
5160
5474
  } else {
@@ -5697,10 +6011,15 @@ async function refreshCommand() {
5697
6011
  v2.error('No existing configuration found. Run "npx agentapprove install" first.');
5698
6012
  process.exit(1);
5699
6013
  }
6014
+ const pairingApiBase = resolvePairingApiBaseUrl({
6015
+ agentapproveApiEnv: process.env.AGENTAPPROVE_API,
6016
+ savedApiUrl: existingConfig?.apiUrl,
6017
+ processDefaultApiUrl: API_URL
6018
+ });
5700
6019
  me(`Your API token has expired (30 days). Tokens extend automatically on use,
5701
6020
  but if unused for 30 days they expire. Get a new one below.`, "Token Expired");
5702
6021
  let token = null;
5703
- let apiUrl = API_URL;
6022
+ let apiUrl = pairingApiBase;
5704
6023
  const installedAgents = detectInstalledAgents();
5705
6024
  const e2eEnabled = existingConfig.e2eEnabled !== false;
5706
6025
  const e2eKeyPath = join(getAgentApproveDir(), "e2e-key");
@@ -5727,7 +6046,7 @@ but if unused for 30 days they expire. Get a new one below.`, "Token Expired");
5727
6046
  configSetAt,
5728
6047
  e2eEnabled
5729
6048
  };
5730
- const session = await createPairingSession(installedAgents.length > 0 ? installedAgents : undefined, e2eKeyId);
6049
+ const session = await createPairingSession(installedAgents.length > 0 ? installedAgents : undefined, e2eKeyId, pairingApiBase);
5731
6050
  if (!session || session.error) {
5732
6051
  v2.error(`Failed to create pairing session: ${session?.error || "Unknown error"}`);
5733
6052
  process.exit(1);
@@ -5753,13 +6072,14 @@ but if unused for 30 days they expire. Get a new one below.`, "Token Expired");
5753
6072
  const minutes = Math.floor(expiresIn / 60);
5754
6073
  const seconds = expiresIn % 60;
5755
6074
  pairingSpinner.message(`Waiting for iOS app... ${minutes}:${seconds.toString().padStart(2, "0")}`);
5756
- }, () => {});
6075
+ }, () => {}, pairingApiBase);
5757
6076
  if (result === "cancelled") {
5758
6077
  pairingSpinner.stop("Cancelled");
5759
6078
  he("Refresh cancelled");
5760
6079
  process.exit(0);
5761
6080
  } else if (result) {
5762
6081
  token = result.token;
6082
+ apiUrl = result.apiUrl || pairingApiBase;
5763
6083
  pairingSpinner.stop(`Connected! ${result.email ? `Account: ${result.email}` : ""}`);
5764
6084
  } else {
5765
6085
  pairingSpinner.stop("Session expired");
@@ -5797,6 +6117,11 @@ async function pairCommand() {
5797
6117
  console.clear();
5798
6118
  pe(source_default.bgCyan.black(" Agent Approve ") + source_default.gray(" Device Pairing"));
5799
6119
  const existingConfig = readExistingConfig();
6120
+ const pairingApiBase = resolvePairingApiBaseUrl({
6121
+ agentapproveApiEnv: process.env.AGENTAPPROVE_API,
6122
+ savedApiUrl: existingConfig?.apiUrl,
6123
+ processDefaultApiUrl: API_URL
6124
+ });
5800
6125
  const keys = discoverE2EKeys();
5801
6126
  if (keys.length === 0 && !existingConfig) {
5802
6127
  v2.error("No E2E keys or configuration found.");
@@ -5887,7 +6212,7 @@ async function pairCommand() {
5887
6212
  configSetAt: pairingConfigSetAt,
5888
6213
  e2eEnabled: !!e2eUserKey
5889
6214
  };
5890
- const session = await createPairingSession(installedAgents.length > 0 ? installedAgents : undefined, e2eKeyId);
6215
+ const session = await createPairingSession(installedAgents.length > 0 ? installedAgents : undefined, e2eKeyId, pairingApiBase);
5891
6216
  if (!session || session.error) {
5892
6217
  v2.error(`Failed to create pairing session: ${session?.error || "Unknown error"}`);
5893
6218
  process.exit(1);
@@ -5913,7 +6238,7 @@ async function pairCommand() {
5913
6238
  const minutes = Math.floor(expiresIn / 60);
5914
6239
  const seconds = expiresIn % 60;
5915
6240
  pairingSpinner.message(`Waiting for iOS app... ${minutes}:${seconds.toString().padStart(2, "0")}`);
5916
- }, () => {});
6241
+ }, () => {}, pairingApiBase);
5917
6242
  if (result === "cancelled") {
5918
6243
  pairingSpinner.stop("Cancelled");
5919
6244
  he("Pairing cancelled");
@@ -5943,7 +6268,7 @@ async function pairCommand() {
5943
6268
  writeFileSync(serverKeyPath, result.e2eServerKey, { mode: 384 });
5944
6269
  }
5945
6270
  }
5946
- const apiUrl = API_URL;
6271
+ const apiUrl = result.apiUrl || pairingApiBase;
5947
6272
  const configSetAt = Math.floor(Date.now() / 1000);
5948
6273
  saveEnvConfig({
5949
6274
  apiUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentapprove",
3
- "version": "0.1.15",
3
+ "version": "0.1.21",
4
4
  "description": "Approve AI agent actions from your iPhone or Apple Watch",
5
5
  "type": "module",
6
6
  "bin": {