@envpilot/cli 1.11.0 → 1.12.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.
@@ -7,7 +7,7 @@ function initSentry() {
7
7
  Sentry.init({
8
8
  dsn,
9
9
  environment: "cli",
10
- release: true ? "1.11.0" : "0.0.0",
10
+ release: true ? "1.12.1" : "0.0.0",
11
11
  // All EnvPilot surfaces report to one Sentry project; the surface tag
12
12
  // is how dashboards tell web / cli / extension events apart.
13
13
  initialScope: { tags: { surface: "cli" } },
@@ -390,15 +390,6 @@ function setApiUrl(url) {
390
390
  function getAccessToken() {
391
391
  return getActiveAccount()?.accessToken;
392
392
  }
393
- function setAccessToken(token) {
394
- updateActiveAccount({ accessToken: token });
395
- }
396
- function getRefreshToken() {
397
- return getActiveAccount()?.refreshToken;
398
- }
399
- function setRefreshToken(token) {
400
- updateActiveAccount({ refreshToken: token });
401
- }
402
393
  function getActiveProjectId() {
403
394
  return getActiveAccount()?.activeProjectId;
404
395
  }
@@ -472,7 +463,7 @@ import { jsx } from "react/jsx-runtime";
472
463
  async function openTUI() {
473
464
  const [{ render }, { CLIApp }, { PressAnyKey }] = await Promise.all([
474
465
  import("ink"),
475
- import("./app-SRLCVHPE.js"),
466
+ import("./app-KEDPMGXA.js"),
476
467
  import("./press-any-key-64XFP4O2.js")
477
468
  ]);
478
469
  while (true) {
@@ -743,13 +734,221 @@ import open from "open";
743
734
  import chalk3 from "chalk";
744
735
  import { hostname } from "os";
745
736
 
746
- // src/lib/api.ts
747
- function registrableDomain(hostname2) {
748
- const parts = hostname2.toLowerCase().split(".").filter(Boolean);
749
- if (parts.length <= 2) return parts.join(".");
750
- return parts.slice(-2).join(".");
737
+ // src/lib/workos.ts
738
+ import { z } from "zod";
739
+
740
+ // src/lib/env.ts
741
+ var WORKOS_CLIENT_ID = "" ? "" : process.env.WORKOS_CLIENT_ID ?? "";
742
+ var CONVEX_URL = "" ? "" : process.env.NEXT_PUBLIC_CONVEX_URL ?? "";
743
+
744
+ // src/lib/workos.ts
745
+ var WORKOS_BASE = "https://api.workos.com";
746
+ var DEVICE_AUTHORIZE_URL = `${WORKOS_BASE}/user_management/authorize/device`;
747
+ var AUTHENTICATE_URL = `${WORKOS_BASE}/user_management/authenticate`;
748
+ var DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
749
+ var deviceCodeSchema = z.object({
750
+ device_code: z.string(),
751
+ user_code: z.string(),
752
+ verification_uri: z.string(),
753
+ verification_uri_complete: z.string(),
754
+ expires_in: z.number().default(300),
755
+ interval: z.number().default(5)
756
+ });
757
+ var workosUserSchema = z.object({
758
+ id: z.string(),
759
+ email: z.string(),
760
+ first_name: z.string().nullish(),
761
+ last_name: z.string().nullish()
762
+ });
763
+ var tokenSchema = z.object({
764
+ access_token: z.string(),
765
+ refresh_token: z.string(),
766
+ user: workosUserSchema.optional(),
767
+ organization_id: z.string().nullish()
768
+ });
769
+ var refreshSchema = z.object({
770
+ access_token: z.string(),
771
+ // WorkOS MAY rotate the refresh token — persist whichever it returns.
772
+ refresh_token: z.string()
773
+ });
774
+ var WorkosAuthError = class extends Error {
775
+ constructor(message, code) {
776
+ super(message);
777
+ this.code = code;
778
+ this.name = "WorkosAuthError";
779
+ }
780
+ };
781
+ function assertConfigured() {
782
+ if (!WORKOS_CLIENT_ID) {
783
+ throw new WorkosAuthError(
784
+ "This CLI build has no WorkOS client id embedded. Rebuild with WORKOS_CLIENT_ID set.",
785
+ "not_configured"
786
+ );
787
+ }
751
788
  }
752
- var MAX_MANUAL_REDIRECTS = 5;
789
+ async function postForm(url, form) {
790
+ const res = await fetch(url, {
791
+ method: "POST",
792
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
793
+ body: new URLSearchParams(form).toString()
794
+ });
795
+ let body = null;
796
+ try {
797
+ body = await res.json();
798
+ } catch {
799
+ body = null;
800
+ }
801
+ return { status: res.status, body };
802
+ }
803
+ async function requestDeviceCode() {
804
+ assertConfigured();
805
+ let result;
806
+ try {
807
+ result = await postForm(DEVICE_AUTHORIZE_URL, {
808
+ client_id: WORKOS_CLIENT_ID
809
+ });
810
+ } catch (err) {
811
+ throw new WorkosAuthError(
812
+ `Could not reach WorkOS to start authentication: ${err.message}`,
813
+ "network"
814
+ );
815
+ }
816
+ if (result.status >= 400) {
817
+ const message = extractErrorMessage(result.body);
818
+ throw new WorkosAuthError(
819
+ `WorkOS rejected the device-code request${message ? `: ${message}` : ""}.`,
820
+ "invalid_response"
821
+ );
822
+ }
823
+ const parsed = deviceCodeSchema.safeParse(result.body);
824
+ if (!parsed.success) {
825
+ throw new WorkosAuthError(
826
+ "WorkOS returned an unexpected device-code response.",
827
+ "invalid_response"
828
+ );
829
+ }
830
+ return parsed.data;
831
+ }
832
+ async function pollForToken(deviceCode) {
833
+ assertConfigured();
834
+ let result;
835
+ try {
836
+ result = await postForm(AUTHENTICATE_URL, {
837
+ client_id: WORKOS_CLIENT_ID,
838
+ grant_type: DEVICE_CODE_GRANT,
839
+ device_code: deviceCode
840
+ });
841
+ } catch {
842
+ return { status: "network" };
843
+ }
844
+ if (result.status === 200) {
845
+ const parsed = tokenSchema.safeParse(result.body);
846
+ if (!parsed.success) {
847
+ throw new WorkosAuthError(
848
+ "WorkOS returned an unexpected token response.",
849
+ "invalid_response"
850
+ );
851
+ }
852
+ return { status: "complete", token: parsed.data };
853
+ }
854
+ const errorCode = extractOauthError(result.body);
855
+ switch (errorCode) {
856
+ case "authorization_pending":
857
+ return { status: "pending" };
858
+ case "slow_down":
859
+ return { status: "slow_down" };
860
+ case "access_denied":
861
+ return { status: "denied" };
862
+ case "expired_token":
863
+ return { status: "expired" };
864
+ default:
865
+ return { status: "network" };
866
+ }
867
+ }
868
+ async function refreshAccessToken(refreshToken) {
869
+ assertConfigured();
870
+ let result;
871
+ try {
872
+ result = await postForm(AUTHENTICATE_URL, {
873
+ client_id: WORKOS_CLIENT_ID,
874
+ grant_type: "refresh_token",
875
+ refresh_token: refreshToken
876
+ });
877
+ } catch (err) {
878
+ throw new WorkosAuthError(
879
+ `Could not reach WorkOS to refresh the session: ${err.message}`,
880
+ "network"
881
+ );
882
+ }
883
+ if (result.status >= 400) {
884
+ const message = extractOauthError(result.body) ?? extractErrorMessage(result.body);
885
+ const transient = result.status >= 500 || result.status === 429;
886
+ throw new WorkosAuthError(
887
+ `Session refresh failed${message ? `: ${message}` : ""}.`,
888
+ transient ? "network" : "access_denied"
889
+ );
890
+ }
891
+ const parsed = refreshSchema.safeParse(result.body);
892
+ if (!parsed.success) {
893
+ throw new WorkosAuthError(
894
+ "WorkOS returned an unexpected refresh response.",
895
+ "invalid_response"
896
+ );
897
+ }
898
+ return parsed.data;
899
+ }
900
+ function extractOauthError(body) {
901
+ if (body && typeof body === "object" && "error" in body) {
902
+ const e = body.error;
903
+ return typeof e === "string" ? e : null;
904
+ }
905
+ return null;
906
+ }
907
+ function extractErrorMessage(body) {
908
+ if (body && typeof body === "object") {
909
+ const obj = body;
910
+ const msg = obj.error_description ?? obj.message ?? obj.error;
911
+ return typeof msg === "string" ? msg : null;
912
+ }
913
+ return null;
914
+ }
915
+
916
+ // src/lib/jwt.ts
917
+ function decodeJwtPayload(token) {
918
+ try {
919
+ const parts = token.split(".");
920
+ if (parts.length < 2) return null;
921
+ const payload = parts[1];
922
+ const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
923
+ const json = Buffer.from(base64, "base64").toString("utf-8");
924
+ const parsed = JSON.parse(json);
925
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
926
+ } catch {
927
+ return null;
928
+ }
929
+ }
930
+ function getJwtExp(token) {
931
+ const payload = decodeJwtPayload(token);
932
+ const exp = payload?.exp;
933
+ return typeof exp === "number" ? exp : null;
934
+ }
935
+ function getJwtSessionId(token) {
936
+ const payload = decodeJwtPayload(token);
937
+ const sid = payload?.sid;
938
+ return typeof sid === "string" ? sid : null;
939
+ }
940
+ function isTokenExpiring(token, skewSeconds = 60) {
941
+ const exp = getJwtExp(token);
942
+ if (exp === null) return true;
943
+ const nowSeconds = Date.now() / 1e3;
944
+ return exp - nowSeconds <= skewSeconds;
945
+ }
946
+
947
+ // src/lib/api.ts
948
+ import { ConvexHttpClient } from "convex/browser";
949
+ import {
950
+ makeFunctionReference
951
+ } from "convex/server";
753
952
  var APIError = class extends Error {
754
953
  constructor(message, statusCode, code) {
755
954
  super(message);
@@ -758,48 +957,139 @@ var APIError = class extends Error {
758
957
  this.name = "APIError";
759
958
  }
760
959
  };
960
+ var refreshInFlight = null;
961
+ async function ensureFreshAccessToken() {
962
+ const account = getActiveAccount();
963
+ if (!account?.accessToken) {
964
+ throw new APIError(
965
+ "You are not logged in. Run `envpilot login`.",
966
+ 401,
967
+ "NOT_AUTHENTICATED"
968
+ );
969
+ }
970
+ if (!isTokenExpiring(account.accessToken)) {
971
+ return account.accessToken;
972
+ }
973
+ if (refreshInFlight) return refreshInFlight;
974
+ const refreshToken = account.refreshToken;
975
+ if (!refreshToken) {
976
+ throw new APIError(
977
+ "Your session has expired. Run `envpilot login`.",
978
+ 401,
979
+ "SESSION_EXPIRED"
980
+ );
981
+ }
982
+ refreshInFlight = (async () => {
983
+ try {
984
+ const result = await refreshAccessToken(refreshToken);
985
+ updateActiveAccount({
986
+ accessToken: result.access_token,
987
+ refreshToken: result.refresh_token
988
+ });
989
+ return result.access_token;
990
+ } catch (err) {
991
+ if (err instanceof WorkosAuthError && err.code === "access_denied") {
992
+ clearAuth();
993
+ throw new APIError(
994
+ "Your session has expired. Run `envpilot login`.",
995
+ 401,
996
+ "SESSION_EXPIRED"
997
+ );
998
+ }
999
+ throw err;
1000
+ } finally {
1001
+ refreshInFlight = null;
1002
+ }
1003
+ })();
1004
+ return refreshInFlight;
1005
+ }
1006
+ async function getConvexClient() {
1007
+ if (!CONVEX_URL) {
1008
+ throw new APIError(
1009
+ "This CLI build has no Convex URL embedded. Rebuild with NEXT_PUBLIC_CONVEX_URL set.",
1010
+ 0,
1011
+ "NOT_CONFIGURED"
1012
+ );
1013
+ }
1014
+ const token = await ensureFreshAccessToken();
1015
+ const client = new ConvexHttpClient(CONVEX_URL);
1016
+ client.setAuth(token);
1017
+ return client;
1018
+ }
1019
+ var fnRef = makeFunctionReference;
1020
+ var refs = {
1021
+ listOrganizations: fnRef(
1022
+ "organizations:listForUser"
1023
+ ),
1024
+ getMembership: fnRef(
1025
+ "organizations:getMembership"
1026
+ ),
1027
+ listProjectsWithStats: fnRef("projects:listWithStats"),
1028
+ getProject: fnRef(
1029
+ "projects:getById"
1030
+ ),
1031
+ getProjectMembership: fnRef("projectMembers:getProjectMembership"),
1032
+ listVariablesWithAccess: fnRef("variables:listWithAccess"),
1033
+ listVariableRequests: fnRef("variableRequests:listForProject"),
1034
+ getResolvedFeatures: fnRef("featureRegistry:getResolvedFeatures"),
1035
+ getResolvedFeaturesBatch: fnRef("featureRegistry:getResolvedFeaturesBatch"),
1036
+ getOrganizationUsage: fnRef("tierLimits:getOrganizationUsage"),
1037
+ isEnforcementEnabled: fnRef(
1038
+ "tierLimits:isEnforcementEnabled"
1039
+ ),
1040
+ deviceSessionRecord: fnRef("deviceSessions:record"),
1041
+ deviceSessionRevoke: fnRef(
1042
+ "deviceSessions:revoke"
1043
+ )
1044
+ };
1045
+ async function convexQuery(ref, ...args) {
1046
+ const client = await getConvexClient();
1047
+ return client.query(ref, ...args);
1048
+ }
1049
+ async function convexMutation(ref, ...args) {
1050
+ const client = await getConvexClient();
1051
+ return client.mutation(ref, ...args);
1052
+ }
1053
+ async function recordDeviceSession(deviceName, sessionId) {
1054
+ try {
1055
+ await convexMutation(refs.deviceSessionRecord, {
1056
+ deviceName,
1057
+ clientType: "cli",
1058
+ sessionId
1059
+ });
1060
+ } catch {
1061
+ }
1062
+ }
1063
+ async function revokeDeviceSession(sessionId) {
1064
+ try {
1065
+ await convexMutation(refs.deviceSessionRevoke, { sessionId });
1066
+ } catch {
1067
+ }
1068
+ }
1069
+ function numericFeature(resolved, key, fallback = null) {
1070
+ const value = resolved?.features?.[key]?.value;
1071
+ return typeof value === "number" ? value : fallback;
1072
+ }
1073
+ function booleanFeature(resolved, key, fallback = false) {
1074
+ const value = resolved?.features?.[key]?.value;
1075
+ return typeof value === "boolean" ? value : fallback;
1076
+ }
1077
+ function registrableDomain(hostname2) {
1078
+ const parts = hostname2.toLowerCase().split(".").filter(Boolean);
1079
+ if (parts.length <= 2) return parts.join(".");
1080
+ return parts.slice(-2).join(".");
1081
+ }
1082
+ var MAX_MANUAL_REDIRECTS = 5;
761
1083
  var APIClient = class {
762
1084
  baseUrl;
763
- accessToken;
764
- // Re-entrancy guard: true while a token refresh is in flight so a 401 from
765
- // the refresh endpoint itself can never trigger another refresh (no loops).
766
- refreshing = false;
767
1085
  constructor(options) {
768
1086
  this.baseUrl = options?.baseUrl ?? getApiUrl();
769
- this.accessToken = options?.accessToken ?? getAccessToken();
770
- }
771
- /**
772
- * Get headers for API requests
773
- */
774
- getHeaders() {
775
- const headers = {
776
- "Content-Type": "application/json"
777
- };
778
- if (this.accessToken) {
779
- headers["Authorization"] = `Bearer ${this.accessToken}`;
780
- }
781
- return headers;
782
- }
783
- /**
784
- * Detect auth middleware redirects that returned HTML instead of CLI JSON.
785
- */
786
- isAuthRedirect(response, bodyText) {
787
- const location = response.headers.get("location") || "";
788
- const finalUrl = response.url || "";
789
- const contentType = response.headers.get("content-type") || "";
790
- const preview = (bodyText || "").slice(0, 512).toLowerCase();
791
- return location.includes("authkit") || finalUrl.includes("authkit") || contentType.includes("text/html") && (preview.includes("authorization_session_id") || preview.includes("client_id=") || preview.includes("<!doctype html"));
792
1087
  }
1088
+ // ── Vault HTTP path (decrypted secret values) ──────────────────────────
793
1089
  /**
794
- * Perform a fetch that follows 3xx redirects manually, re-attaching the
795
- * Authorization header when the redirect stays inside the same registrable
796
- * domain (eTLD+1). This defends against the apex→www redirect case where
797
- * Node's default redirect follower drops the Authorization header on any
798
- * hostname change and the resulting request comes back as a bogus 401 —
799
- * which used to wipe the user's credentials.
800
- *
801
- * Cross-site redirects (different registrable domain) are followed without
802
- * the Authorization header, matching browser/fetch security semantics.
1090
+ * Follow 3xx redirects manually, re-attaching Authorization only within the
1091
+ * same registrable domain (defends against the apex→www redirect that would
1092
+ * otherwise drop the header and yield a bogus 401).
803
1093
  */
804
1094
  async fetchWithSafeRedirects(initialUrl, init2) {
805
1095
  let currentUrl = initialUrl;
@@ -815,9 +1105,7 @@ var APIClient = class {
815
1105
  const prevHost = new URL(currentUrl).hostname;
816
1106
  const sameSite = registrableDomain(nextUrl.hostname) === registrableDomain(prevHost);
817
1107
  const headers = new Headers(currentInit.headers);
818
- if (!sameSite) {
819
- headers.delete("Authorization");
820
- }
1108
+ if (!sameSite) headers.delete("Authorization");
821
1109
  let nextMethod = (currentInit.method || "GET").toUpperCase();
822
1110
  let nextBody = currentInit.body;
823
1111
  if (response.status === 301 || response.status === 302 || response.status === 303) {
@@ -842,166 +1130,54 @@ var APIClient = class {
842
1130
  "TOO_MANY_REDIRECTS"
843
1131
  );
844
1132
  }
845
- /**
846
- * Attempt a one-shot access-token refresh using the stored refresh token.
847
- * Persists the rotated tokens and updates this client's in-memory token on
848
- * success. Returns false (without throwing) when there is no refresh token,
849
- * a refresh is already in flight, or the refresh call fails — the caller
850
- * then treats the session as dead.
851
- */
852
- async tryRefreshToken() {
853
- if (this.refreshing) return false;
854
- const refreshToken = getRefreshToken();
855
- if (!refreshToken) return false;
856
- this.refreshing = true;
857
- try {
858
- const result = await this.refreshToken(refreshToken);
859
- setAccessToken(result.accessToken);
860
- setRefreshToken(result.refreshToken);
861
- this.accessToken = result.accessToken;
862
- return true;
863
- } catch {
864
- return false;
865
- } finally {
866
- this.refreshing = false;
867
- }
868
- }
869
- /**
870
- * Run a request thunk with automatic one-shot token refresh on a genuine 401.
871
- *
872
- * - An AUTH_REDIRECT (HTML sign-in page) is NOT a rejected token — rethrow
873
- * without clearing creds or refreshing.
874
- * - A genuine 401 triggers a single refresh attempt. On success the request
875
- * is retried once with the rotated token; on failure (or a second 401) the
876
- * local credentials are cleared so the user is prompted to log in again.
877
- *
878
- * The thunk rebuilds its headers on each call, so the retry automatically
879
- * picks up the refreshed access token.
880
- */
881
- async withAuthRetry(exec) {
882
- try {
883
- return await exec();
884
- } catch (err) {
885
- if (!(err instanceof APIError) || err.statusCode !== 401 || err.code === "AUTH_REDIRECT" || this.refreshing) {
886
- throw err;
887
- }
888
- const refreshed = await this.tryRefreshToken();
889
- if (!refreshed) {
890
- clearAuth();
891
- throw err;
892
- }
893
- try {
894
- return await exec();
895
- } catch (retryErr) {
896
- if (retryErr instanceof APIError && retryErr.statusCode === 401 && retryErr.code !== "AUTH_REDIRECT") {
897
- clearAuth();
898
- }
899
- throw retryErr;
1133
+ /** Perform an authed vault request carrying a fresh WorkOS JWT bearer. */
1134
+ async vaultRequest(method, path, options) {
1135
+ const token = await ensureFreshAccessToken();
1136
+ const url = new URL(path, this.baseUrl);
1137
+ if (options?.params) {
1138
+ for (const [key, value] of Object.entries(options.params)) {
1139
+ url.searchParams.set(key, value);
900
1140
  }
901
1141
  }
902
- }
903
- /**
904
- * Make a GET request
905
- */
906
- async get(path, params) {
907
- return this.withAuthRetry(async () => {
908
- const url = new URL(path, this.baseUrl);
909
- if (params) {
910
- for (const [key, value] of Object.entries(params)) {
911
- url.searchParams.set(key, value);
912
- }
913
- }
914
- const response = await this.fetchWithSafeRedirects(url.toString(), {
915
- method: "GET",
916
- headers: this.getHeaders()
917
- });
918
- return this.handleResponse(response);
919
- });
920
- }
921
- /**
922
- * Make a POST request
923
- */
924
- async post(path, body) {
925
- return this.withAuthRetry(async () => {
926
- const url = new URL(path, this.baseUrl);
927
- const response = await this.fetchWithSafeRedirects(url.toString(), {
928
- method: "POST",
929
- headers: this.getHeaders(),
930
- body: body ? JSON.stringify(body) : void 0
931
- });
932
- return this.handleResponse(response);
933
- });
934
- }
935
- /**
936
- * Make a PUT request
937
- */
938
- async put(path, body) {
939
- return this.withAuthRetry(async () => {
940
- const url = new URL(path, this.baseUrl);
941
- const response = await this.fetchWithSafeRedirects(url.toString(), {
942
- method: "PUT",
943
- headers: this.getHeaders(),
944
- body: body ? JSON.stringify(body) : void 0
945
- });
946
- return this.handleResponse(response);
947
- });
948
- }
949
- /**
950
- * Make a PATCH request
951
- */
952
- async patch(path, body) {
953
- return this.withAuthRetry(async () => {
954
- const url = new URL(path, this.baseUrl);
955
- const response = await this.fetchWithSafeRedirects(url.toString(), {
956
- method: "PATCH",
957
- headers: this.getHeaders(),
958
- body: body ? JSON.stringify(body) : void 0
959
- });
960
- return this.handleResponse(response);
1142
+ const response = await this.fetchWithSafeRedirects(url.toString(), {
1143
+ method,
1144
+ headers: {
1145
+ "Content-Type": "application/json",
1146
+ Authorization: `Bearer ${token}`
1147
+ },
1148
+ body: options?.body ? JSON.stringify(options.body) : void 0
961
1149
  });
1150
+ return this.handleResponse(response);
962
1151
  }
963
- /**
964
- * Make a DELETE request
965
- */
966
- async delete(path) {
967
- return this.withAuthRetry(async () => {
968
- const url = new URL(path, this.baseUrl);
969
- const response = await this.fetchWithSafeRedirects(url.toString(), {
970
- method: "DELETE",
971
- headers: this.getHeaders()
972
- });
973
- if (!response.ok) {
974
- await this.handleError(response);
975
- }
976
- });
1152
+ isAuthRedirect(response, bodyText) {
1153
+ const location = response.headers.get("location") || "";
1154
+ const finalUrl = response.url || "";
1155
+ const contentType = response.headers.get("content-type") || "";
1156
+ const preview = (bodyText || "").slice(0, 512).toLowerCase();
1157
+ return location.includes("authkit") || finalUrl.includes("authkit") || contentType.includes("text/html") && (preview.includes("authorization_session_id") || preview.includes("client_id=") || preview.includes("<!doctype html"));
977
1158
  }
978
- /**
979
- * Handle API response
980
- */
981
1159
  async handleResponse(response) {
982
1160
  if (!response.ok) {
983
1161
  await this.handleError(response);
984
1162
  }
985
1163
  const contentType = response.headers.get("content-type") || "";
1164
+ const body = await response.text();
986
1165
  if (!contentType.includes("application/json")) {
987
- const body2 = await response.text();
988
- if (this.isAuthRedirect(response, body2)) {
1166
+ if (this.isAuthRedirect(response, body)) {
989
1167
  throw new APIError(
990
1168
  "Your CLI session is not authorized for this endpoint. Please run `envpilot login` and try again.",
991
1169
  401,
992
1170
  "AUTH_REDIRECT"
993
1171
  );
994
1172
  }
995
- const preview = body2.replace(/\s+/g, " ").slice(0, 160);
1173
+ const preview = body.replace(/\s+/g, " ").slice(0, 160);
996
1174
  throw new APIError(
997
1175
  `Expected JSON but got ${contentType || "unknown content type"} from ${response.url}. Response starts with: ${preview}`,
998
1176
  response.status || 500
999
1177
  );
1000
1178
  }
1001
- const body = await response.text();
1002
1179
  try {
1003
- const data = JSON.parse(body);
1004
- return data;
1180
+ return JSON.parse(body);
1005
1181
  } catch {
1006
1182
  const preview = body.replace(/\s+/g, " ").slice(0, 160);
1007
1183
  throw new APIError(
@@ -1010,9 +1186,6 @@ var APIClient = class {
1010
1186
  );
1011
1187
  }
1012
1188
  }
1013
- /**
1014
- * Handle API errors
1015
- */
1016
1189
  async handleError(response) {
1017
1190
  const bodyText = await response.text();
1018
1191
  let message = `Request failed with status ${response.status}`;
@@ -1057,192 +1230,230 @@ var APIClient = class {
1057
1230
  throw new APIError(message, response.status, code);
1058
1231
  }
1059
1232
  // ============================================
1060
- // High-level API methods
1233
+ // High-level methods — DIRECT to Convex
1061
1234
  // ============================================
1062
1235
  /**
1063
- * Get current user info
1236
+ * Current authenticated user. Verifies the session is live server-side (an
1237
+ * authed Convex query) and returns the identity captured at login.
1064
1238
  */
1065
1239
  async getCurrentUser() {
1066
- return this.get("/api/cli/auth", { action: "me" });
1067
- }
1068
- /**
1069
- * Get tier info for the active organization
1070
- */
1071
- async getTierInfo(organizationId) {
1072
- return this.get("/api/cli/tier", { organizationId });
1073
- }
1074
- /**
1075
- * Get usage info for the active organization
1076
- */
1077
- async getUsage(organizationId) {
1078
- return this.get("/api/cli/usage", { organizationId });
1240
+ const account = getActiveAccount();
1241
+ if (!account) {
1242
+ throw new APIError(
1243
+ "You are not logged in. Run `envpilot login`.",
1244
+ 401,
1245
+ "NOT_AUTHENTICATED"
1246
+ );
1247
+ }
1248
+ await convexQuery(refs.listOrganizations, {});
1249
+ return account.user;
1079
1250
  }
1080
- /**
1081
- * List organizations the user has access to
1082
- */
1251
+ /** List organizations the user belongs to (with tier + role). */
1083
1252
  async listOrganizations() {
1084
- const response = await this.get(
1085
- "/api/cli/organizations"
1086
- );
1087
- return response.data || [];
1253
+ const orgs = await convexQuery(refs.listOrganizations, {});
1254
+ const orgIds = orgs.map((o) => o._id);
1255
+ let tiers = [];
1256
+ if (orgIds.length > 0) {
1257
+ try {
1258
+ tiers = await convexQuery(refs.getResolvedFeaturesBatch, {
1259
+ organizationIds: orgIds
1260
+ });
1261
+ } catch {
1262
+ tiers = [];
1263
+ }
1264
+ }
1265
+ return orgs.map((org, index) => ({
1266
+ _id: org._id,
1267
+ name: org.name,
1268
+ slug: org.slug,
1269
+ tier: tiers[index]?.tierName ?? "free",
1270
+ role: org.role,
1271
+ unifiedRole: normalizeOrgRole(org.role),
1272
+ description: org.description,
1273
+ logoUrl: org.logoUrl
1274
+ }));
1088
1275
  }
1089
- /**
1090
- * List projects in an organization
1091
- */
1276
+ /** List projects in an organization, with unified-role + assignment info. */
1092
1277
  async listProjects(organizationId) {
1093
- const response = await this.get(
1094
- "/api/cli/projects",
1095
- { organizationId }
1278
+ const [projects, membership] = await Promise.all([
1279
+ convexQuery(refs.listProjectsWithStats, { organizationId }),
1280
+ convexQuery(refs.getMembership, { organizationId })
1281
+ ]);
1282
+ const unifiedRole = normalizeOrgRole(membership?.role);
1283
+ const isOwner = unifiedRole === "owner";
1284
+ const isDeveloper = unifiedRole === "developer";
1285
+ const legacyProjectRole = isOwner ? null : isDeveloper ? "developer" : "manager";
1286
+ return Promise.all(
1287
+ projects.map(async (project) => {
1288
+ let assigned = isOwner;
1289
+ let environmentScope = null;
1290
+ if (!isOwner) {
1291
+ const projectMembership = await convexQuery(
1292
+ refs.getProjectMembership,
1293
+ { projectId: project._id }
1294
+ );
1295
+ assigned = projectMembership !== null;
1296
+ environmentScope = isDeveloper ? projectMembership?.environments ?? null : null;
1297
+ }
1298
+ return {
1299
+ _id: project._id,
1300
+ name: project.name,
1301
+ slug: project.slug,
1302
+ description: project.description,
1303
+ icon: project.icon,
1304
+ color: project.color,
1305
+ organizationId: project.organizationId,
1306
+ userRole: unifiedRole,
1307
+ projectRole: legacyProjectRole,
1308
+ unifiedRole,
1309
+ assigned,
1310
+ environmentScope
1311
+ };
1312
+ })
1096
1313
  );
1097
- return response.data || [];
1098
1314
  }
1099
- /**
1100
- * Get a project by ID
1101
- */
1315
+ /** Get a single project (metadata) by id. */
1102
1316
  async getProject(projectId) {
1103
- return this.get(`/api/cli/projects/${projectId}`);
1317
+ const project = await convexQuery(refs.getProject, { projectId });
1318
+ if (!project) {
1319
+ throw new APIError("Project not found", 404, "PROJECT_NOT_FOUND");
1320
+ }
1321
+ return {
1322
+ _id: project._id,
1323
+ name: project.name,
1324
+ slug: project.slug,
1325
+ organizationId: project.organizationId,
1326
+ description: project.description,
1327
+ icon: project.icon,
1328
+ color: project.color
1329
+ };
1104
1330
  }
1105
1331
  /**
1106
- * List variables in a project (with decrypted values).
1107
- *
1108
- * Returns both the variable list and any keys that failed vault decryption.
1109
- * Decryption failures are skipped server-side — they will NOT appear in
1110
- * `variables`. Callers should warn the user about `decryptionFailures`
1111
- * so they know those secrets weren't injected.
1332
+ * Compute the variable fingerprint from Convex METADATA (no vault decryption).
1333
+ * Byte-compatible with the fingerprint writeCache stores from a full fetch:
1334
+ * both hash `${_id}:${version}:${updatedAt}` over the accessible variables in
1335
+ * the requested environment.
1112
1336
  */
1113
- async listVariables(projectId, environment, organizationId) {
1114
- const params = { projectId };
1115
- if (environment) {
1116
- params.environment = environment;
1337
+ async checkFingerprint(projectId, environment, _organizationId) {
1338
+ const rows = await convexQuery(refs.listVariablesWithAccess, { projectId });
1339
+ const filtered = rows.filter(
1340
+ (row) => row.hasAccess && (!environment || row.environments.includes(environment))
1341
+ );
1342
+ const asVariables = filtered.map(
1343
+ (row) => ({
1344
+ _id: row._id,
1345
+ version: row.version,
1346
+ updatedAt: row.updatedAt
1347
+ })
1348
+ );
1349
+ return computeFingerprint(asVariables);
1350
+ }
1351
+ /** List variable requests for a project (Convex). */
1352
+ async listVariableRequests(projectId, status) {
1353
+ return convexQuery(refs.listVariableRequests, { projectId, status });
1354
+ }
1355
+ /** Tier + usage snapshot for an organization (Convex). */
1356
+ async getUsage(organizationId) {
1357
+ const membership = await convexQuery(refs.getMembership, {
1358
+ organizationId
1359
+ });
1360
+ if (!membership) {
1361
+ throw new APIError(
1362
+ "You are not a member of this organization",
1363
+ 403,
1364
+ "FORBIDDEN"
1365
+ );
1117
1366
  }
1118
- if (organizationId) {
1119
- params.organizationId = organizationId;
1367
+ const [usageData, enforcementEnabled, resolved] = await Promise.all([
1368
+ convexQuery(refs.getOrganizationUsage, { organizationId }),
1369
+ convexQuery(refs.isEnforcementEnabled, {}),
1370
+ convexQuery(refs.getResolvedFeatures, { organizationId })
1371
+ ]);
1372
+ if (!usageData) {
1373
+ throw new APIError(
1374
+ "Organization not found",
1375
+ 404,
1376
+ "ORGANIZATION_NOT_FOUND"
1377
+ );
1120
1378
  }
1121
- const response = await this.get("/api/cli/variables", params);
1122
1379
  return {
1123
- variables: response.data || [],
1124
- decryptionFailures: response.meta?.decryptionFailures ?? []
1380
+ tier: resolved?.tierName ?? usageData.tier,
1381
+ enforcementEnabled,
1382
+ limits: {
1383
+ projects: numericFeature(resolved, "max_projects"),
1384
+ variablesPerProject: numericFeature(
1385
+ resolved,
1386
+ "max_variables_per_project"
1387
+ ),
1388
+ teamMembers: numericFeature(resolved, "max_team_members")
1389
+ },
1390
+ usage: usageData.usage,
1391
+ features: {
1392
+ versionHistory: booleanFeature(resolved, "variable_version_history"),
1393
+ bulkImport: booleanFeature(resolved, "bulk_import"),
1394
+ extensionAccess: booleanFeature(resolved, "extension_access", true),
1395
+ granularPermissions: booleanFeature(resolved, "granular_permissions"),
1396
+ auditLogRetentionDays: numericFeature(resolved, "audit_log_retention_days") ?? 7
1397
+ }
1125
1398
  };
1126
1399
  }
1400
+ // ============================================
1401
+ // High-level methods — VAULT (over HTTP)
1402
+ // ============================================
1127
1403
  /**
1128
- * Check the variable fingerprint for a project/environment.
1129
- *
1130
- * Returns a short hash of variable metadata (id + version + updatedAt)
1131
- * WITHOUT decrypting vault secrets. The CLI uses this to decide whether
1132
- * a cached variable set is still current before doing a full (expensive)
1133
- * fetch. If the fingerprint matches, the cache can be extended for free.
1404
+ * List variables in a project WITH decrypted values (vault path).
1405
+ * Returns the variable list, the response meta (unified role / scope info),
1406
+ * and any keys that failed vault decryption (skipped server-side).
1134
1407
  */
1135
- async checkFingerprint(projectId, environment, organizationId) {
1408
+ async listVariables(projectId, environment, organizationId) {
1136
1409
  const params = { projectId };
1137
1410
  if (environment) params.environment = environment;
1138
1411
  if (organizationId) params.organizationId = organizationId;
1139
- const response = await this.get(
1140
- "/api/cli/variables/fingerprint",
1141
- params
1142
- );
1143
- return response.fingerprint;
1144
- }
1145
- /**
1146
- * Get a variable by ID (with decrypted value)
1147
- */
1148
- async getVariable(variableId) {
1149
- return this.get(`/api/cli/variables/${variableId}`);
1150
- }
1151
- /**
1152
- * Create a new variable
1153
- */
1154
- async createVariable(data) {
1155
- return this.post("/api/cli/variables", data);
1156
- }
1157
- /**
1158
- * Update a variable
1159
- */
1160
- async updateVariable(variableId, data) {
1161
- return this.patch(`/api/cli/variables/${variableId}`, data);
1162
- }
1163
- /**
1164
- * Delete a variable
1165
- */
1166
- async deleteVariable(variableId) {
1167
- return this.delete(`/api/cli/variables/${variableId}`);
1412
+ const response = await this.vaultRequest("GET", "/api/cli/variables", { params });
1413
+ return {
1414
+ variables: response.data || [],
1415
+ meta: response.meta,
1416
+ decryptionFailures: response.meta?.decryptionFailures ?? []
1417
+ };
1168
1418
  }
1169
- /**
1170
- * Bulk create/update variables
1171
- */
1419
+ /** Bulk create/update variables (vault path — encrypts values server-side). */
1172
1420
  async bulkUpsertVariables(data) {
1173
- return this.post("/api/cli/variables/bulk", data);
1421
+ const response = await this.vaultRequest("POST", "/api/cli/variables/bulk", { body: data });
1422
+ if (!response.data) {
1423
+ throw new APIError("No result returned by server", 500);
1424
+ }
1425
+ return response.data;
1174
1426
  }
1175
1427
  /**
1176
- * Submit a variable request (developers only owners/PMs/team leads
1177
- * create variables directly and get a 403 from the server here).
1428
+ * Submit a variable request (developers only). Goes over the vault path
1429
+ * because the requested value is encrypted into WorkOS Vault server-side;
1430
+ * owners/PMs/team leads get a 403 here and create variables directly.
1178
1431
  */
1179
1432
  async createVariableRequest(data) {
1180
- const response = await this.post(
1181
- "/api/cli/variable-requests",
1182
- data
1183
- );
1433
+ const response = await this.vaultRequest("POST", "/api/cli/variable-requests", { body: data });
1184
1434
  if (!response.data) {
1185
1435
  throw new APIError("No variable request returned by server", 500);
1186
1436
  }
1187
1437
  return response.data.request;
1188
1438
  }
1189
- /**
1190
- * List variable requests for a project, optionally filtered by status.
1191
- */
1192
- async listVariableRequests(projectId, status) {
1193
- const params = { projectId };
1194
- if (status) params.status = status;
1195
- const response = await this.get("/api/cli/variable-requests", params);
1196
- return response.data?.requests ?? [];
1197
- }
1198
- // ============================================
1199
- // Authentication methods
1200
- // ============================================
1201
- /**
1202
- * Initiate CLI authentication flow
1203
- */
1204
- async initiateAuth(deviceName) {
1205
- return this.post("/api/cli/auth?action=initiate", { deviceName });
1206
- }
1207
- /**
1208
- * Poll for authentication status
1209
- */
1210
- async pollAuth(code) {
1211
- return this.get("/api/cli/auth", { action: "poll", code });
1212
- }
1213
- /**
1214
- * Refresh access token
1215
- */
1216
- async refreshToken(refreshToken) {
1217
- return this.post("/api/cli/auth?action=refresh", { refreshToken });
1218
- }
1219
- /**
1220
- * Revoke access token (logout)
1221
- */
1222
- async revokeToken() {
1223
- return this.post("/api/cli/auth?action=revoke", {});
1224
- }
1225
1439
  };
1226
1440
  function createAPIClient() {
1227
1441
  return new APIClient();
1228
1442
  }
1229
1443
 
1230
1444
  // src/lib/auth-flow.ts
1231
- var POLL_INTERVAL_MS = 2e3;
1232
- var MAX_POLL_ATTEMPTS = 150;
1233
1445
  var MAX_CONSECUTIVE_ERRORS = 5;
1234
1446
  function sleep(ms) {
1235
1447
  return new Promise((resolve3) => setTimeout(resolve3, ms));
1236
1448
  }
1237
1449
  async function performLogin(options) {
1238
- const api = createAPIClient();
1239
1450
  const deviceName = `CLI - ${hostname()}`;
1240
1451
  info("Starting authentication flow...");
1241
- const spinner = createSpinner("Generating authentication code...");
1452
+ const spinner = createSpinner("Requesting device code...");
1242
1453
  spinner.start();
1243
- let initResponse;
1454
+ let device;
1244
1455
  try {
1245
- initResponse = await api.initiateAuth(deviceName);
1456
+ device = await requestDeviceCode();
1246
1457
  } catch (error2) {
1247
1458
  spinner.stop();
1248
1459
  throw error2;
@@ -1251,60 +1462,77 @@ async function performLogin(options) {
1251
1462
  console.log();
1252
1463
  console.log(chalk3.bold("Your authentication code:"));
1253
1464
  console.log();
1254
- console.log(chalk3.cyan.bold(` ${initResponse.code}`));
1465
+ console.log(chalk3.cyan.bold(` ${device.user_code}`));
1255
1466
  console.log();
1256
- console.log(`Open this URL to authenticate:`);
1257
- console.log(chalk3.dim(initResponse.url));
1467
+ console.log("Open this URL to authenticate:");
1468
+ console.log(chalk3.dim(device.verification_uri_complete));
1258
1469
  console.log();
1259
1470
  if (options?.browser !== false) {
1260
1471
  info("Opening browser...");
1261
- await open(initResponse.url);
1472
+ try {
1473
+ await open(device.verification_uri_complete);
1474
+ } catch {
1475
+ }
1262
1476
  }
1263
1477
  const pollSpinner = createSpinner("Waiting for authentication...");
1264
1478
  pollSpinner.start();
1479
+ let intervalMs = device.interval * 1e3;
1480
+ const deadline = Date.now() + device.expires_in * 1e3;
1265
1481
  let consecutiveErrors = 0;
1266
1482
  try {
1267
- for (let attempts = 0; attempts < MAX_POLL_ATTEMPTS; attempts++) {
1268
- await sleep(POLL_INTERVAL_MS);
1269
- let pollResponse;
1270
- try {
1271
- pollResponse = await api.pollAuth(initResponse.code);
1272
- consecutiveErrors = 0;
1273
- } catch {
1274
- consecutiveErrors++;
1275
- if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
1276
- throw new Error(
1277
- "Too many consecutive network errors while polling. Please check your connection and try again."
1278
- );
1279
- }
1280
- continue;
1281
- }
1282
- if (pollResponse.status === "authenticated") {
1483
+ while (Date.now() < deadline) {
1484
+ await sleep(intervalMs);
1485
+ const result = await pollForToken(device.device_code);
1486
+ if (result.status === "complete") {
1283
1487
  pollSpinner.stop();
1284
- if (!pollResponse.accessToken || !pollResponse.refreshToken) {
1285
- throw new Error(
1286
- "Authentication succeeded but the server did not return session tokens. This is likely a server-side issue. Please try again or contact support."
1287
- );
1288
- }
1289
- const accountId = pollResponse.user?.id ?? `session-${pollResponse.accessToken.slice(0, 8)}`;
1488
+ const token = result.token;
1489
+ const email = token.user?.email ?? "";
1490
+ const userId = token.user?.id ?? `session-${token.access_token.slice(0, 8)}`;
1491
+ const name = [token.user?.first_name, token.user?.last_name].filter(Boolean).join(" ") || void 0;
1492
+ const sessionId = getJwtSessionId(token.access_token) ?? void 0;
1290
1493
  const account = {
1291
- id: accountId,
1292
- user: pollResponse.user ? {
1293
- id: pollResponse.user.id,
1294
- email: pollResponse.user.email,
1295
- name: pollResponse.user.name
1296
- } : { id: accountId, email: `${accountId}@cli.local` },
1297
- accessToken: pollResponse.accessToken,
1298
- refreshToken: pollResponse.refreshToken
1494
+ id: userId,
1495
+ user: {
1496
+ id: userId,
1497
+ email: email || `${userId}@cli.local`,
1498
+ name
1499
+ },
1500
+ accessToken: token.access_token,
1501
+ refreshToken: token.refresh_token,
1502
+ sessionId,
1503
+ deviceName
1299
1504
  };
1300
1505
  upsertAccount(account);
1301
1506
  setActiveAccount(account.id);
1507
+ if (sessionId) {
1508
+ await recordDeviceSession(deviceName, sessionId);
1509
+ }
1302
1510
  console.log();
1303
- success(`Logged in as ${chalk3.bold(pollResponse.user?.email)}`);
1304
- return { email: pollResponse.user?.email || "" };
1511
+ success(`Logged in as ${chalk3.bold(email || userId)}`);
1512
+ return { email };
1305
1513
  }
1306
- if (pollResponse.status === "expired" || pollResponse.status === "not_found") {
1307
- throw new Error("Authentication code expired. Please try again.");
1514
+ switch (result.status) {
1515
+ case "pending":
1516
+ consecutiveErrors = 0;
1517
+ break;
1518
+ case "slow_down":
1519
+ consecutiveErrors = 0;
1520
+ intervalMs += 5e3;
1521
+ break;
1522
+ case "network":
1523
+ consecutiveErrors++;
1524
+ if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
1525
+ throw new Error(
1526
+ "Too many consecutive network errors while polling. Please check your connection and try again."
1527
+ );
1528
+ }
1529
+ break;
1530
+ case "denied":
1531
+ throw new Error("Authentication was denied. Please try again.");
1532
+ case "expired":
1533
+ throw new Error(
1534
+ "Authentication code expired. Please run `envpilot login` again."
1535
+ );
1308
1536
  }
1309
1537
  }
1310
1538
  throw new Error("Authentication timed out. Please try again.");
@@ -1360,152 +1588,161 @@ import { execSync } from "child_process";
1360
1588
  import { join as join2 } from "path";
1361
1589
 
1362
1590
  // src/types/index.ts
1363
- import { z } from "zod";
1364
- var userSchema = z.object({
1365
- id: z.string(),
1366
- email: z.string().email(),
1367
- name: z.string().optional()
1591
+ import { z as z2 } from "zod";
1592
+ var userSchema = z2.object({
1593
+ id: z2.string(),
1594
+ email: z2.string().email(),
1595
+ name: z2.string().optional()
1368
1596
  });
1369
- var accountSchema = z.object({
1370
- id: z.string(),
1597
+ var accountSchema = z2.object({
1598
+ id: z2.string(),
1371
1599
  user: userSchema,
1372
- accessToken: z.string(),
1373
- refreshToken: z.string().optional(),
1600
+ // WorkOS AuthKit tokens. accessToken is a short-lived (5 min) JWT refreshed
1601
+ // on demand via the WorkOS refresh token. The JWT itself is never stored
1602
+ // server-side — identity is proven per-request.
1603
+ accessToken: z2.string(),
1604
+ refreshToken: z2.string().optional(),
1605
+ // WorkOS session id (`sid` claim), decoded from the access token at login.
1606
+ // Recorded server-side (deviceSessions.record) so the session appears in the
1607
+ // active-sessions UI and can be remotely revoked.
1608
+ sessionId: z2.string().optional(),
1609
+ // Human-friendly device label shown in the sessions UI (e.g. "CLI - host").
1610
+ deviceName: z2.string().optional(),
1374
1611
  // Stored role string (legacy or unified); normalized on read.
1375
- role: z.string().optional(),
1612
+ role: z2.string().optional(),
1376
1613
  // Per-account active org/project selection.
1377
- activeOrganizationId: z.string().optional(),
1378
- activeProjectId: z.string().optional()
1614
+ activeOrganizationId: z2.string().optional(),
1615
+ activeProjectId: z2.string().optional()
1379
1616
  });
1380
- var organizationSchema = z.object({
1381
- _id: z.string(),
1382
- name: z.string(),
1383
- slug: z.string(),
1384
- tier: z.enum(["free", "pro"]),
1385
- role: z.string().optional(),
1617
+ var organizationSchema = z2.object({
1618
+ _id: z2.string(),
1619
+ name: z2.string(),
1620
+ slug: z2.string(),
1621
+ tier: z2.enum(["free", "pro"]),
1622
+ role: z2.string().optional(),
1386
1623
  // Unified org role (additive; legacy responses omit it and fall back to role).
1387
- unifiedRole: z.string().nullable().optional()
1624
+ unifiedRole: z2.string().nullable().optional()
1388
1625
  });
1389
- var projectSchema = z.object({
1390
- _id: z.string(),
1391
- name: z.string(),
1392
- slug: z.string(),
1393
- organizationId: z.string(),
1394
- description: z.string().optional(),
1395
- icon: z.string().optional(),
1396
- color: z.string().optional(),
1397
- userRole: z.string().nullable().optional(),
1398
- projectRole: z.string().nullable().optional(),
1626
+ var projectSchema = z2.object({
1627
+ _id: z2.string(),
1628
+ name: z2.string(),
1629
+ slug: z2.string(),
1630
+ organizationId: z2.string(),
1631
+ description: z2.string().optional(),
1632
+ icon: z2.string().optional(),
1633
+ color: z2.string().optional(),
1634
+ userRole: z2.string().nullable().optional(),
1635
+ projectRole: z2.string().nullable().optional(),
1399
1636
  // Unified-role fields (optional so legacy server responses still parse)
1400
- unifiedRole: z.string().nullable().optional(),
1401
- assigned: z.boolean().optional(),
1402
- environmentScope: z.array(z.string()).nullable().optional()
1637
+ unifiedRole: z2.string().nullable().optional(),
1638
+ assigned: z2.boolean().optional(),
1639
+ environmentScope: z2.array(z2.string()).nullable().optional()
1403
1640
  });
1404
- var variableTagSchema = z.object({
1405
- _id: z.string(),
1406
- name: z.string(),
1407
- color: z.string()
1641
+ var variableTagSchema = z2.object({
1642
+ _id: z2.string(),
1643
+ name: z2.string(),
1644
+ color: z2.string()
1408
1645
  });
1409
- var variableSchema = z.object({
1410
- _id: z.string(),
1411
- key: z.string(),
1412
- value: z.string(),
1413
- environment: z.enum(["development", "staging", "production"]),
1414
- projectId: z.string(),
1415
- description: z.string().optional(),
1416
- isSensitive: z.boolean().optional(),
1417
- version: z.number().optional(),
1418
- updatedAt: z.number().optional(),
1419
- createdAt: z.number().optional(),
1420
- tags: z.array(variableTagSchema).optional(),
1646
+ var variableSchema = z2.object({
1647
+ _id: z2.string(),
1648
+ key: z2.string(),
1649
+ value: z2.string(),
1650
+ environment: z2.enum(["development", "staging", "production"]),
1651
+ projectId: z2.string(),
1652
+ description: z2.string().optional(),
1653
+ isSensitive: z2.boolean().optional(),
1654
+ version: z2.number().optional(),
1655
+ updatedAt: z2.number().optional(),
1656
+ createdAt: z2.number().optional(),
1657
+ tags: z2.array(variableTagSchema).optional(),
1421
1658
  // Per-variable effective access under the unified model. Optional so older
1422
1659
  // server responses (which omit it) still parse.
1423
- access: z.enum(["read", "write"]).optional()
1660
+ access: z2.enum(["read", "write"]).optional()
1424
1661
  });
1425
- var variablesMetaSchema = z.object({
1426
- total: z.number().optional(),
1427
- page: z.number().optional(),
1428
- limit: z.number().optional(),
1429
- decryptionFailures: z.array(z.string()).optional(),
1430
- unifiedRole: z.string().nullable().optional(),
1431
- assigned: z.boolean().optional(),
1432
- grantOnly: z.boolean().optional(),
1433
- environmentScope: z.array(z.string()).nullable().optional(),
1434
- hasWriteAccess: z.boolean().optional(),
1435
- scopeRestricted: z.boolean().optional()
1662
+ var variablesMetaSchema = z2.object({
1663
+ total: z2.number().optional(),
1664
+ page: z2.number().optional(),
1665
+ limit: z2.number().optional(),
1666
+ decryptionFailures: z2.array(z2.string()).optional(),
1667
+ unifiedRole: z2.string().nullable().optional(),
1668
+ assigned: z2.boolean().optional(),
1669
+ grantOnly: z2.boolean().optional(),
1670
+ environmentScope: z2.array(z2.string()).nullable().optional(),
1671
+ hasWriteAccess: z2.boolean().optional(),
1672
+ scopeRestricted: z2.boolean().optional()
1436
1673
  }).passthrough();
1437
- var environmentSchema = z.enum([
1674
+ var environmentSchema = z2.enum([
1438
1675
  "development",
1439
1676
  "staging",
1440
1677
  "production"
1441
1678
  ]);
1442
- var cliConfigSchema = z.object({
1679
+ var cliConfigSchema = z2.object({
1443
1680
  // Global CLI setting — shared across all accounts.
1444
- apiUrl: z.string().url(),
1681
+ apiUrl: z2.string().url(),
1445
1682
  // Multi-account store. Keyed by account id (== user id).
1446
- accounts: z.record(z.string(), accountSchema).optional(),
1683
+ accounts: z2.record(z2.string(), accountSchema).optional(),
1447
1684
  // The currently active account id.
1448
- activeAccountId: z.string().optional(),
1685
+ activeAccountId: z2.string().optional(),
1449
1686
  // --- Legacy single-account fields ---
1450
1687
  // Retained ONLY so pre-multi-account configs still parse and can be migrated
1451
1688
  // into `accounts` by migrateLegacyConfig(). After migration these are deleted
1452
1689
  // so `accounts` is the single source of truth. Do not read/write directly.
1453
- accessToken: z.string().optional(),
1454
- refreshToken: z.string().optional(),
1455
- activeProjectId: z.string().optional(),
1456
- activeOrganizationId: z.string().optional(),
1690
+ accessToken: z2.string().optional(),
1691
+ refreshToken: z2.string().optional(),
1692
+ activeProjectId: z2.string().optional(),
1693
+ activeOrganizationId: z2.string().optional(),
1457
1694
  user: userSchema.optional(),
1458
1695
  // Stored role string. Kept as a free-form string so both legacy
1459
1696
  // ("admin"/"team_lead"/"member") and unified
1460
1697
  // ("owner"/"project_manager"/"team_lead"/"developer") values round-trip.
1461
1698
  // Normalized on read via getUnifiedRole(); no longer enum-enforced.
1462
- role: z.string().optional()
1699
+ role: z2.string().optional()
1463
1700
  });
1464
- var projectConfigSchema = z.object({
1465
- projectId: z.string(),
1466
- organizationId: z.string(),
1701
+ var projectConfigSchema = z2.object({
1702
+ projectId: z2.string(),
1703
+ organizationId: z2.string(),
1467
1704
  environment: environmentSchema.default("development")
1468
1705
  });
1469
- var projectEntrySchema = z.object({
1470
- projectId: z.string(),
1471
- organizationId: z.string(),
1472
- projectName: z.string().default(""),
1473
- organizationName: z.string().default(""),
1706
+ var projectEntrySchema = z2.object({
1707
+ projectId: z2.string(),
1708
+ organizationId: z2.string(),
1709
+ projectName: z2.string().default(""),
1710
+ organizationName: z2.string().default(""),
1474
1711
  environment: environmentSchema.default("development"),
1475
1712
  // How the pulled .env file's permissions are managed for this project:
1476
1713
  // auto → derive from the caller's resolved access (default)
1477
1714
  // always → force read-only (0o400)
1478
1715
  // never → force writable (0o600)
1479
- fileProtection: z.enum(["auto", "always", "never"]).optional()
1716
+ fileProtection: z2.enum(["auto", "always", "never"]).optional()
1480
1717
  });
1481
- var projectConfigV2Schema = z.object({
1482
- version: z.literal(1),
1483
- activeProjectId: z.string(),
1484
- projects: z.array(projectEntrySchema).min(1)
1718
+ var projectConfigV2Schema = z2.object({
1719
+ version: z2.literal(1),
1720
+ activeProjectId: z2.string(),
1721
+ projects: z2.array(projectEntrySchema).min(1)
1485
1722
  });
1486
- var variableRequestStatusSchema = z.enum([
1723
+ var variableRequestStatusSchema = z2.enum([
1487
1724
  "pending",
1488
1725
  "approved",
1489
1726
  "rejected",
1490
1727
  "canceled"
1491
1728
  ]);
1492
- var variableRequestUserSchema = z.object({
1493
- _id: z.string(),
1494
- email: z.string().optional(),
1495
- name: z.string().optional()
1729
+ var variableRequestUserSchema = z2.object({
1730
+ _id: z2.string(),
1731
+ email: z2.string().optional(),
1732
+ name: z2.string().optional()
1496
1733
  }).nullable().optional();
1497
- var variableRequestSchema = z.object({
1498
- _id: z.string(),
1499
- key: z.string(),
1500
- description: z.string().optional(),
1501
- environments: z.array(z.string()),
1502
- projectId: z.string(),
1503
- organizationId: z.string().optional(),
1504
- isSensitive: z.boolean().optional(),
1734
+ var variableRequestSchema = z2.object({
1735
+ _id: z2.string(),
1736
+ key: z2.string(),
1737
+ description: z2.string().optional(),
1738
+ environments: z2.array(z2.string()),
1739
+ projectId: z2.string(),
1740
+ organizationId: z2.string().optional(),
1741
+ isSensitive: z2.boolean().optional(),
1505
1742
  status: variableRequestStatusSchema,
1506
- reviewReason: z.string().optional(),
1507
- createdAt: z.number(),
1508
- updatedAt: z.number().optional(),
1743
+ reviewReason: z2.string().optional(),
1744
+ createdAt: z2.number(),
1745
+ updatedAt: z2.number().optional(),
1509
1746
  requester: variableRequestUserSchema,
1510
1747
  reviewer: variableRequestUserSchema
1511
1748
  });
@@ -1914,8 +2151,7 @@ async function addProject(existingConfig, options) {
1914
2151
  let role = getUnifiedRole();
1915
2152
  if (roleLevel(role) < ROLE_LEVEL.team_lead) {
1916
2153
  const orgs = await withSpinner("Checking permissions...", async () => {
1917
- const response = await api.get("/api/cli/organizations");
1918
- return response.data || [];
2154
+ return api.listOrganizations();
1919
2155
  });
1920
2156
  const freshOrg = orgs.find(
1921
2157
  (o) => o._id === existingConfig.projects[0]?.organizationId
@@ -1995,8 +2231,7 @@ async function selectOrgProjectEnv(options) {
1995
2231
  const organizations = await withSpinner(
1996
2232
  "Fetching organizations...",
1997
2233
  async () => {
1998
- const response = await api.get("/api/cli/organizations");
1999
- return response.data || [];
2234
+ return api.listOrganizations();
2000
2235
  }
2001
2236
  );
2002
2237
  if (organizations.length === 0) {
@@ -2031,11 +2266,7 @@ async function selectOrgProjectEnv(options) {
2031
2266
  selectedOrg = organizations.find((o) => o._id === orgId);
2032
2267
  }
2033
2268
  const projects = await withSpinner("Fetching projects...", async () => {
2034
- const response = await api.get(
2035
- "/api/cli/projects",
2036
- { organizationId: selectedOrg._id }
2037
- );
2038
- return response.data || [];
2269
+ return api.listProjects(selectedOrg._id);
2039
2270
  });
2040
2271
  if (projects.length === 0) {
2041
2272
  error("No projects found. Please create a project first.");
@@ -2622,15 +2853,13 @@ async function pullProject(project, outputPath, options) {
2622
2853
  const variables = await withSpinner(
2623
2854
  `Fetching ${chalk5.bold(project.environment)} variables...`,
2624
2855
  async () => {
2625
- const response = await api.get("/api/cli/variables", {
2626
- projectId: project.projectId,
2627
- environment: project.environment,
2628
- ...project.organizationId && {
2629
- organizationId: project.organizationId
2630
- }
2631
- });
2856
+ const response = await api.listVariables(
2857
+ project.projectId,
2858
+ project.environment,
2859
+ project.organizationId
2860
+ );
2632
2861
  meta = response.meta;
2633
- return response.data || [];
2862
+ return response.variables;
2634
2863
  }
2635
2864
  );
2636
2865
  if (meta?.scopeRestricted && meta.environmentScope?.length) {
@@ -2755,24 +2984,24 @@ import chalk6 from "chalk";
2755
2984
  import inquirer3 from "inquirer";
2756
2985
 
2757
2986
  // src/lib/validators.ts
2758
- import { z as z2 } from "zod";
2759
- var envKeySchema = z2.string().min(1, "Key cannot be empty").max(256, "Key cannot exceed 256 characters").regex(
2987
+ import { z as z3 } from "zod";
2988
+ var envKeySchema = z3.string().min(1, "Key cannot be empty").max(256, "Key cannot exceed 256 characters").regex(
2760
2989
  /^[A-Za-z_][A-Za-z0-9_]*$/,
2761
2990
  "Key must start with a letter or underscore, followed by letters, numbers, or underscores"
2762
2991
  );
2763
- var envValueSchema = z2.string().max(65536, "Value cannot exceed 64KB");
2764
- var environmentSchema2 = z2.enum([
2992
+ var envValueSchema = z3.string().max(65536, "Value cannot exceed 64KB");
2993
+ var environmentSchema2 = z3.enum([
2765
2994
  "development",
2766
2995
  "staging",
2767
2996
  "production"
2768
2997
  ]);
2769
- var projectSlugSchema = z2.string().min(1, "Slug cannot be empty").max(128, "Slug cannot exceed 128 characters").regex(
2998
+ var projectSlugSchema = z3.string().min(1, "Slug cannot be empty").max(128, "Slug cannot exceed 128 characters").regex(
2770
2999
  /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/,
2771
3000
  "Slug must be lowercase alphanumeric with hyphens, cannot start or end with hyphen"
2772
3001
  );
2773
- var urlSchema = z2.string().url("Must be a valid URL");
2774
- var tokenSchema = z2.string().min(1, "Token cannot be empty").regex(/^env_[A-Za-z0-9]{48}$/, "Invalid token format");
2775
- var filePathSchema = z2.string().min(1, "File path cannot be empty");
3002
+ var urlSchema = z3.string().url("Must be a valid URL");
3003
+ var tokenSchema2 = z3.string().min(1, "Token cannot be empty").regex(/^env_[A-Za-z0-9]{48}$/, "Invalid token format");
3004
+ var filePathSchema = z3.string().min(1, "File path cannot be empty");
2776
3005
  function validateEnvVars(vars) {
2777
3006
  const valid = {};
2778
3007
  const invalid = [];
@@ -2897,16 +3126,13 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2897
3126
  const remoteVariables = await withSpinner(
2898
3127
  "Fetching current variables...",
2899
3128
  async () => {
2900
- const params = {
3129
+ const response = await api.listVariables(
2901
3130
  projectId,
2902
- environment
2903
- };
2904
- if (organizationId) {
2905
- params.organizationId = organizationId;
2906
- }
2907
- const response = await api.get("/api/cli/variables", params);
3131
+ environment,
3132
+ organizationId
3133
+ );
2908
3134
  meta = response.meta;
2909
- return response.data || [];
3135
+ return response.variables;
2910
3136
  }
2911
3137
  );
2912
3138
  const scope = meta?.environmentScope;
@@ -2977,7 +3203,7 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2977
3203
  const result = await withSpinner(
2978
3204
  `Pushing variables to ${chalk6.bold(environment)}...`,
2979
3205
  async () => {
2980
- const response = await api.post("/api/cli/variables/bulk", {
3206
+ return api.bulkUpsertVariables({
2981
3207
  projectId,
2982
3208
  environment,
2983
3209
  variables: Object.entries(valid).map(([key, value]) => ({
@@ -2987,7 +3213,6 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2987
3213
  mode,
2988
3214
  ...organizationId && { organizationId }
2989
3215
  });
2990
- return response.data;
2991
3216
  }
2992
3217
  );
2993
3218
  success(
@@ -3080,8 +3305,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
3080
3305
  const organizations = await withSpinner(
3081
3306
  "Fetching organizations...",
3082
3307
  async () => {
3083
- const response = await api.get("/api/cli/organizations");
3084
- return response.data || [];
3308
+ return api.listOrganizations();
3085
3309
  }
3086
3310
  );
3087
3311
  const org = organizations.find(
@@ -3129,8 +3353,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
3129
3353
  const organizations = await withSpinner(
3130
3354
  "Fetching organizations...",
3131
3355
  async () => {
3132
- const response = await api.get("/api/cli/organizations");
3133
- return response.data || [];
3356
+ return api.listOrganizations();
3134
3357
  }
3135
3358
  );
3136
3359
  if (organizations.length === 0) {
@@ -3160,8 +3383,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
3160
3383
  }
3161
3384
  }
3162
3385
  const projects = await withSpinner("Fetching projects...", async () => {
3163
- const response = await api.get("/api/cli/projects", { organizationId });
3164
- return response.data || [];
3386
+ return api.listProjects(organizationId);
3165
3387
  });
3166
3388
  const project = projects.find(
3167
3389
  (p) => p._id === projectIdentifier || p.slug === projectIdentifier
@@ -3271,8 +3493,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
3271
3493
  const organizations = await withSpinner(
3272
3494
  "Fetching organizations...",
3273
3495
  async () => {
3274
- const response = await api.get("/api/cli/organizations");
3275
- return response.data || [];
3496
+ return api.listOrganizations();
3276
3497
  }
3277
3498
  );
3278
3499
  if (organizations.length === 0) {
@@ -3309,8 +3530,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
3309
3530
  const projects = await withSpinner(
3310
3531
  "Fetching projects...",
3311
3532
  async () => {
3312
- const response = await api.get("/api/cli/projects", { organizationId: orgId });
3313
- return response.data || [];
3533
+ return api.listProjects(orgId);
3314
3534
  }
3315
3535
  );
3316
3536
  if (projects.length === 0) {
@@ -3433,8 +3653,7 @@ async function listOrganizations(api, options) {
3433
3653
  const organizations = await withSpinner(
3434
3654
  "Fetching organizations...",
3435
3655
  async () => {
3436
- const response = await api.get("/api/cli/organizations");
3437
- return response.data || [];
3656
+ return api.listOrganizations();
3438
3657
  }
3439
3658
  );
3440
3659
  if (organizations.length === 0) {
@@ -3468,8 +3687,7 @@ async function listProjects(api, projectConfig, options) {
3468
3687
  const organizations = await withSpinner(
3469
3688
  "Fetching organizations...",
3470
3689
  async () => {
3471
- const response = await api.get("/api/cli/organizations");
3472
- return response.data || [];
3690
+ return api.listOrganizations();
3473
3691
  }
3474
3692
  );
3475
3693
  if (organizations.length === 0) {
@@ -3488,11 +3706,7 @@ async function listProjects(api, projectConfig, options) {
3488
3706
  }
3489
3707
  }
3490
3708
  const projects = await withSpinner("Fetching projects...", async () => {
3491
- const response = await api.get(
3492
- "/api/cli/projects",
3493
- { organizationId }
3494
- );
3495
- return response.data || [];
3709
+ return api.listProjects(organizationId);
3496
3710
  });
3497
3711
  if (projects.length === 0) {
3498
3712
  info("No projects found.");
@@ -3536,13 +3750,9 @@ async function listVariables(api, projectConfig, options) {
3536
3750
  }
3537
3751
  let meta;
3538
3752
  const variables = await withSpinner("Fetching variables...", async () => {
3539
- const params = { projectId };
3540
- if (environment) {
3541
- params.environment = environment;
3542
- }
3543
- const response = await api.get("/api/cli/variables", params);
3753
+ const response = await api.listVariables(projectId, environment);
3544
3754
  meta = response.meta;
3545
- return response.data || [];
3755
+ return response.variables;
3546
3756
  });
3547
3757
  const tagFilter = options.tag?.toLowerCase();
3548
3758
  const filtered = tagFilter ? variables.filter(
@@ -3837,10 +4047,9 @@ var logoutCommand = new Command8("logout").description("Log out from Envpilot").
3837
4047
  if (options.all) {
3838
4048
  const accounts = listAccounts();
3839
4049
  for (const account of accounts) {
3840
- try {
3841
- const api2 = new APIClient({ accessToken: account.accessToken });
3842
- await api2.post("/api/cli/auth?action=revoke", {});
3843
- } catch {
4050
+ if (account.sessionId) {
4051
+ setActiveAccount(account.id);
4052
+ await revokeDeviceSession(account.sessionId);
3844
4053
  }
3845
4054
  removeAccount(account.id);
3846
4055
  }
@@ -3849,10 +4058,8 @@ var logoutCommand = new Command8("logout").description("Log out from Envpilot").
3849
4058
  return;
3850
4059
  }
3851
4060
  const activeAccount = getActiveAccount();
3852
- const api = createAPIClient();
3853
- try {
3854
- await api.post("/api/cli/auth?action=revoke", {});
3855
- } catch {
4061
+ if (activeAccount?.sessionId) {
4062
+ await revokeDeviceSession(activeAccount.sessionId);
3856
4063
  }
3857
4064
  const { newActiveId } = clearAuth();
3858
4065
  success(
@@ -4223,13 +4430,13 @@ var syncCommand = new Command10("sync").description(
4223
4430
  `Fetching ${chalk11.bold(environment)} variables...`,
4224
4431
  async () => {
4225
4432
  const api = createAPIClient();
4226
- const response = await api.get("/api/cli/variables", {
4433
+ const response = await api.listVariables(
4227
4434
  projectId,
4228
4435
  environment,
4229
- ...organizationId && { organizationId }
4230
- });
4436
+ organizationId
4437
+ );
4231
4438
  meta = response.meta;
4232
- return response.data || [];
4439
+ return response.variables;
4233
4440
  }
4234
4441
  );
4235
4442
  const outputPath = getEnvPathForEnvironment(environment);
@@ -4333,8 +4540,7 @@ var usageCommand = new Command11("usage").description("Show plan usage and limit
4333
4540
  const orgs = await withSpinner(
4334
4541
  "Fetching organizations...",
4335
4542
  async () => {
4336
- const response = await api.get("/api/cli/organizations");
4337
- return response.data || [];
4543
+ return api.listOrganizations();
4338
4544
  }
4339
4545
  );
4340
4546
  if (orgs.length === 0) {
@@ -4454,7 +4660,7 @@ var whoamiCommand = new Command12("whoami").description("Show the current authen
4454
4660
  );
4455
4661
  }
4456
4662
  blank();
4457
- console.log(chalk13.dim("Token verified against the CLI auth endpoint."));
4663
+ console.log(chalk13.dim("Session verified against Convex."));
4458
4664
  } catch (err) {
4459
4665
  await handleError(err);
4460
4666
  }
@@ -4953,18 +5159,18 @@ import chalk17 from "chalk";
4953
5159
  import inquirer6 from "inquirer";
4954
5160
 
4955
5161
  // src/lib/variable-requests.ts
4956
- import { z as z3 } from "zod";
5162
+ import { z as z4 } from "zod";
4957
5163
  var ALL_REQUEST_ENVIRONMENTS = [
4958
5164
  "development",
4959
5165
  "staging",
4960
5166
  "production"
4961
5167
  ];
4962
- var requestKeySchema = z3.string().min(1, "Key is required").max(100, "Key must be 100 characters or less").regex(
5168
+ var requestKeySchema = z4.string().min(1, "Key is required").max(100, "Key must be 100 characters or less").regex(
4963
5169
  /^[A-Z][A-Z0-9_]*$/,
4964
5170
  "Key must be uppercase, start with a letter, and contain only letters, numbers, and underscores"
4965
5171
  );
4966
- var requestValueSchema = z3.string().min(1, "Value is required");
4967
- var requestDescriptionSchema = z3.string().max(500, "Description must be 500 characters or less");
5172
+ var requestValueSchema = z4.string().min(1, "Value is required");
5173
+ var requestDescriptionSchema = z4.string().max(500, "Description must be 500 characters or less");
4968
5174
  function validateRequestKey(key) {
4969
5175
  const result = requestKeySchema.safeParse(key);
4970
5176
  if (result.success) return { valid: true };
@@ -5289,10 +5495,11 @@ async function resolveEntryNames(api, entry) {
5289
5495
  };
5290
5496
  }
5291
5497
  async function fetchProjectMeta(api, entry) {
5292
- const response = await api.get("/api/cli/variables", {
5293
- projectId: entry.projectId,
5294
- ...entry.organizationId && { organizationId: entry.organizationId }
5295
- });
5498
+ const response = await api.listVariables(
5499
+ entry.projectId,
5500
+ void 0,
5501
+ entry.organizationId
5502
+ );
5296
5503
  return response.meta;
5297
5504
  }
5298
5505
  async function pickRequestTarget(api) {