@envpilot/cli 1.7.2 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.10.0" : "0.0.0",
11
11
  // Free tier: disable performance monitoring
12
12
  tracesSampleRate: 0,
13
13
  beforeSend(event) {
@@ -48,6 +48,195 @@ async function flushSentry() {
48
48
 
49
49
  // src/lib/config.ts
50
50
  import Conf from "conf";
51
+
52
+ // src/lib/roles.ts
53
+ var ROLE_LEVEL = {
54
+ owner: 4,
55
+ project_manager: 3,
56
+ team_lead: 2,
57
+ developer: 1
58
+ };
59
+ var ORG_ROLE_LABELS = {
60
+ owner: "Owner",
61
+ project_manager: "Project Manager",
62
+ team_lead: "Team Lead",
63
+ developer: "Developer"
64
+ };
65
+ function normalizeOrgRole(role) {
66
+ switch (role) {
67
+ case "admin":
68
+ return "owner";
69
+ case "member":
70
+ return "developer";
71
+ case "owner":
72
+ case "project_manager":
73
+ case "team_lead":
74
+ case "developer":
75
+ return role;
76
+ default:
77
+ return "developer";
78
+ }
79
+ }
80
+ function roleLevel(role) {
81
+ return ROLE_LEVEL[normalizeOrgRole(role)];
82
+ }
83
+ function formatRoleLabel(role) {
84
+ return ORG_ROLE_LABELS[normalizeOrgRole(role)];
85
+ }
86
+ function isFileWritable(access) {
87
+ if (access.role === "owner") return true;
88
+ if (!access.assigned) return false;
89
+ switch (access.role) {
90
+ case "project_manager":
91
+ case "team_lead":
92
+ return true;
93
+ case "developer":
94
+ return access.hasWriteAccess;
95
+ }
96
+ }
97
+
98
+ // src/lib/variables-cache.ts
99
+ import { createHash } from "crypto";
100
+ import {
101
+ readFileSync,
102
+ writeFileSync,
103
+ mkdirSync,
104
+ existsSync,
105
+ readdirSync,
106
+ unlinkSync,
107
+ rmdirSync,
108
+ statSync
109
+ } from "fs";
110
+ import { join, dirname } from "path";
111
+ var HARD_MAX_AGE_MS = 12 * 60 * 60 * 1e3;
112
+ function getCacheDir() {
113
+ return join(dirname(getConfigPath()), "run-cache");
114
+ }
115
+ function getCacheKey(projectId, environment, organizationId) {
116
+ const tokenSlice = (getAccessToken() ?? "").slice(0, 16);
117
+ return createHash("sha256").update(`${projectId}:${environment}:${organizationId}:${tokenSlice}`).digest("hex").slice(0, 20);
118
+ }
119
+ function getCachePath(key) {
120
+ return join(getCacheDir(), `${key}.json`);
121
+ }
122
+ function computeFingerprint(variables) {
123
+ return createHash("sha256").update(
124
+ variables.map((v) => `${v._id}:${v.version}:${v.updatedAt}`).sort().join("|")
125
+ ).digest("hex").slice(0, 16);
126
+ }
127
+ function readCache(projectId, environment, organizationId) {
128
+ try {
129
+ const key = getCacheKey(projectId, environment, organizationId);
130
+ const path = getCachePath(key);
131
+ if (!existsSync(path)) return null;
132
+ const raw = readFileSync(path, "utf-8");
133
+ const entry = JSON.parse(raw);
134
+ if (entry.apiUrl !== getApiUrl()) return null;
135
+ if (!isWithinHardMaxAge(entry)) {
136
+ try {
137
+ unlinkSync(path);
138
+ } catch {
139
+ }
140
+ return null;
141
+ }
142
+ return entry;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+ function probeCache(projectId, environment, organizationId, ttlSeconds) {
148
+ const entry = readCache(projectId, environment, organizationId);
149
+ if (!entry) return { hit: false };
150
+ const fresh = isFresh(entry, ttlSeconds);
151
+ return { hit: true, fresh, entry };
152
+ }
153
+ function writeCache(projectId, environment, organizationId, variables, serverFingerprint) {
154
+ try {
155
+ const cacheDir = getCacheDir();
156
+ mkdirSync(cacheDir, { recursive: true, mode: 448 });
157
+ const key = getCacheKey(projectId, environment, organizationId);
158
+ const path = getCachePath(key);
159
+ const now = Date.now();
160
+ const entry = {
161
+ variables,
162
+ fetchedAt: now,
163
+ firstFetchedAt: now,
164
+ projectId,
165
+ environment,
166
+ organizationId,
167
+ apiUrl: getApiUrl(),
168
+ fingerprint: serverFingerprint ?? computeFingerprint(variables)
169
+ };
170
+ writeFileSync(path, JSON.stringify(entry, null, 2), {
171
+ encoding: "utf-8",
172
+ mode: 384
173
+ // owner read/write only — same as .env
174
+ });
175
+ } catch {
176
+ }
177
+ }
178
+ function extendCacheFreshness(projectId, environment, organizationId) {
179
+ try {
180
+ const key = getCacheKey(projectId, environment, organizationId);
181
+ const path = getCachePath(key);
182
+ if (!existsSync(path)) return;
183
+ const raw = readFileSync(path, "utf-8");
184
+ const entry = JSON.parse(raw);
185
+ if (!isWithinHardMaxAge(entry)) return;
186
+ if (entry.firstFetchedAt === void 0)
187
+ entry.firstFetchedAt = entry.fetchedAt;
188
+ entry.fetchedAt = Date.now();
189
+ writeFileSync(path, JSON.stringify(entry, null, 2), {
190
+ encoding: "utf-8",
191
+ mode: 384
192
+ });
193
+ } catch {
194
+ }
195
+ }
196
+ function clearAllCache() {
197
+ let count = 0;
198
+ try {
199
+ const dir = getCacheDir();
200
+ if (!existsSync(dir)) return 0;
201
+ for (const file of readdirSync(dir)) {
202
+ if (!file.endsWith(".json")) continue;
203
+ try {
204
+ unlinkSync(join(dir, file));
205
+ count++;
206
+ } catch {
207
+ }
208
+ }
209
+ } catch {
210
+ }
211
+ return count;
212
+ }
213
+ function clearRunCache() {
214
+ const removed = clearAllCache();
215
+ try {
216
+ const dir = getCacheDir();
217
+ if (existsSync(dir) && readdirSync(dir).length === 0) {
218
+ rmdirSync(dir);
219
+ }
220
+ } catch {
221
+ }
222
+ return removed;
223
+ }
224
+ function isFresh(entry, ttlSeconds) {
225
+ return Date.now() - entry.fetchedAt < ttlSeconds * 1e3;
226
+ }
227
+ function isWithinHardMaxAge(entry) {
228
+ const anchor = entry.firstFetchedAt ?? entry.fetchedAt;
229
+ return Date.now() - anchor < HARD_MAX_AGE_MS;
230
+ }
231
+ function formatAge(fetchedAt) {
232
+ const ageSec = Math.floor((Date.now() - fetchedAt) / 1e3);
233
+ if (ageSec < 60) return `${ageSec}s ago`;
234
+ const ageMin = Math.floor(ageSec / 60);
235
+ if (ageMin < 60) return `${ageMin}m ago`;
236
+ return `${Math.floor(ageMin / 60)}h ago`;
237
+ }
238
+
239
+ // src/lib/config.ts
51
240
  var DEFAULT_API_URL = "https://www.envpilot.dev";
52
241
  var HOST_CANONICALIZATION = {
53
242
  "envpilot.dev": "www.envpilot.dev"
@@ -75,15 +264,116 @@ var config = new Conf({
75
264
  apiUrl: DEFAULT_API_URL
76
265
  }
77
266
  });
267
+ function migrateLegacyConfigData(data) {
268
+ const accounts = { ...data.accounts ?? {} };
269
+ const hasAccounts = Object.keys(accounts).length > 0;
270
+ let activeAccountId = data.activeAccountId;
271
+ if (!hasAccounts && data.accessToken) {
272
+ const legacyUser = data.user;
273
+ const id = legacyUser?.id ?? (data.accessToken.length >= 8 ? `legacy-${data.accessToken.slice(0, 8)}` : "default");
274
+ const user = legacyUser ?? { id, email: `${id}@cli.local` };
275
+ accounts[id] = {
276
+ id,
277
+ user,
278
+ accessToken: data.accessToken,
279
+ refreshToken: data.refreshToken,
280
+ role: data.role,
281
+ activeOrganizationId: data.activeOrganizationId,
282
+ activeProjectId: data.activeProjectId
283
+ };
284
+ activeAccountId = id;
285
+ }
286
+ const result = { ...data, accounts, activeAccountId };
287
+ delete result.accessToken;
288
+ delete result.refreshToken;
289
+ delete result.user;
290
+ delete result.role;
291
+ delete result.activeOrganizationId;
292
+ delete result.activeProjectId;
293
+ return result;
294
+ }
295
+ function migrateLegacyConfig() {
296
+ const data = config.store;
297
+ const hadLegacy = data.accessToken !== void 0 || data.refreshToken !== void 0 || data.user !== void 0 || data.role !== void 0 || data.activeOrganizationId !== void 0 || data.activeProjectId !== void 0;
298
+ if (!hadLegacy) return;
299
+ config.store = migrateLegacyConfigData(data);
300
+ }
301
+ function readAccounts() {
302
+ return config.get("accounts") ?? {};
303
+ }
304
+ function writeAccounts(accounts) {
305
+ config.set("accounts", accounts);
306
+ }
307
+ function listAccounts() {
308
+ migrateLegacyConfig();
309
+ return Object.values(readAccounts());
310
+ }
311
+ function getActiveAccount() {
312
+ migrateLegacyConfig();
313
+ const id = config.get("activeAccountId");
314
+ if (!id) return void 0;
315
+ return readAccounts()[id];
316
+ }
317
+ function getActiveAccountId() {
318
+ migrateLegacyConfig();
319
+ return config.get("activeAccountId");
320
+ }
321
+ function setActiveAccount(id) {
322
+ const accounts = readAccounts();
323
+ if (!accounts[id]) return false;
324
+ config.set("activeAccountId", id);
325
+ return true;
326
+ }
327
+ function upsertAccount(account) {
328
+ const accounts = readAccounts();
329
+ const isFirst = Object.keys(accounts).length === 0;
330
+ accounts[account.id] = account;
331
+ writeAccounts(accounts);
332
+ if (isFirst) {
333
+ config.set("activeAccountId", account.id);
334
+ }
335
+ }
336
+ function removeAccount(id) {
337
+ const accounts = readAccounts();
338
+ if (!accounts[id]) {
339
+ return { removedActive: false };
340
+ }
341
+ const wasActive = config.get("activeAccountId") === id;
342
+ delete accounts[id];
343
+ writeAccounts(accounts);
344
+ let newActiveId;
345
+ if (wasActive) {
346
+ newActiveId = Object.keys(accounts)[0];
347
+ if (newActiveId) {
348
+ config.set("activeAccountId", newActiveId);
349
+ } else {
350
+ config.delete("activeAccountId");
351
+ }
352
+ }
353
+ return { removedActive: wasActive, newActiveId };
354
+ }
355
+ function updateActiveAccount(patch) {
356
+ migrateLegacyConfig();
357
+ const id = config.get("activeAccountId");
358
+ if (!id) return;
359
+ const accounts = readAccounts();
360
+ const existing = accounts[id];
361
+ if (!existing) return;
362
+ accounts[id] = { ...existing, ...patch, id: existing.id };
363
+ writeAccounts(accounts);
364
+ }
78
365
  function getConfig() {
366
+ const active = getActiveAccount();
79
367
  return {
80
368
  apiUrl: normalizeApiUrl(config.get("apiUrl") ?? DEFAULT_API_URL),
81
- accessToken: config.get("accessToken"),
82
- refreshToken: config.get("refreshToken"),
83
- activeProjectId: config.get("activeProjectId"),
84
- activeOrganizationId: config.get("activeOrganizationId"),
85
- user: config.get("user"),
86
- role: config.get("role")
369
+ accounts: readAccounts(),
370
+ activeAccountId: config.get("activeAccountId"),
371
+ accessToken: active?.accessToken,
372
+ refreshToken: active?.refreshToken,
373
+ activeProjectId: active?.activeProjectId,
374
+ activeOrganizationId: active?.activeOrganizationId,
375
+ user: active?.user,
376
+ role: active?.role
87
377
  };
88
378
  }
89
379
  function getApiUrl() {
@@ -95,46 +385,49 @@ function setApiUrl(url) {
95
385
  config.set("apiUrl", normalizeApiUrl(url));
96
386
  }
97
387
  function getAccessToken() {
98
- return config.get("accessToken");
388
+ return getActiveAccount()?.accessToken;
99
389
  }
100
390
  function setAccessToken(token) {
101
- config.set("accessToken", token);
391
+ updateActiveAccount({ accessToken: token });
392
+ }
393
+ function getRefreshToken() {
394
+ return getActiveAccount()?.refreshToken;
102
395
  }
103
396
  function setRefreshToken(token) {
104
- config.set("refreshToken", token);
397
+ updateActiveAccount({ refreshToken: token });
105
398
  }
106
399
  function getActiveProjectId() {
107
- return config.get("activeProjectId");
400
+ return getActiveAccount()?.activeProjectId;
108
401
  }
109
402
  function setActiveProjectId(projectId) {
110
- config.set("activeProjectId", projectId);
403
+ updateActiveAccount({ activeProjectId: projectId });
111
404
  }
112
405
  function getActiveOrganizationId() {
113
- return config.get("activeOrganizationId");
406
+ return getActiveAccount()?.activeOrganizationId;
114
407
  }
115
408
  function setActiveOrganizationId(organizationId) {
116
- config.set("activeOrganizationId", organizationId);
409
+ updateActiveAccount({ activeOrganizationId: organizationId });
117
410
  }
118
411
  function getUser() {
119
- return config.get("user");
120
- }
121
- function setUser(user) {
122
- config.set("user", user);
412
+ return getActiveAccount()?.user;
123
413
  }
124
- function getRole() {
125
- return config.get("role");
414
+ function getUnifiedRole() {
415
+ return normalizeOrgRole(getActiveAccount()?.role);
126
416
  }
127
- function setRole(role) {
128
- config.set("role", role);
417
+ function setUnifiedRole(role) {
418
+ updateActiveAccount({ role });
129
419
  }
130
420
  function isAuthenticated() {
131
- return !!config.get("accessToken");
421
+ return getActiveAccount() !== void 0;
132
422
  }
133
423
  function clearAuth() {
134
- config.delete("accessToken");
135
- config.delete("refreshToken");
136
- config.delete("user");
137
- config.delete("role");
424
+ const id = getActiveAccountId();
425
+ const result = id ? removeAccount(id) : { newActiveId: void 0 };
426
+ try {
427
+ clearRunCache();
428
+ } catch {
429
+ }
430
+ return { newActiveId: result.newActiveId };
138
431
  }
139
432
  function clearConfig() {
140
433
  config.clear();
@@ -145,11 +438,11 @@ function getConfigPath() {
145
438
 
146
439
  // src/ui/execute-command.ts
147
440
  import { spawn } from "child_process";
148
- import { dirname, resolve } from "path";
441
+ import { dirname as dirname2, resolve } from "path";
149
442
  import { fileURLToPath } from "url";
150
443
  async function executeCommand(argv) {
151
444
  const scriptPath = resolve(
152
- dirname(fileURLToPath(import.meta.url)),
445
+ dirname2(fileURLToPath(import.meta.url)),
153
446
  "index.js"
154
447
  );
155
448
  if (process.stdin.isTTY && process.stdin.isRaw) {
@@ -176,7 +469,7 @@ import { jsx } from "react/jsx-runtime";
176
469
  async function openTUI() {
177
470
  const [{ render }, { CLIApp }, { PressAnyKey }] = await Promise.all([
178
471
  import("ink"),
179
- import("./app-QG4JFCWP.js"),
472
+ import("./app-UT26PMDR.js"),
180
473
  import("./press-any-key-64XFP4O2.js")
181
474
  ]);
182
475
  while (true) {
@@ -314,43 +607,38 @@ function blank() {
314
607
  console.log();
315
608
  }
316
609
  function formatRole(role) {
317
- switch (role) {
318
- case "admin":
319
- return chalk.green("Admin");
610
+ const label = formatRoleLabel(role);
611
+ switch (normalizeOrgRole(role)) {
612
+ case "owner":
613
+ return chalk.green(label);
614
+ case "project_manager":
615
+ return chalk.magenta(label);
320
616
  case "team_lead":
321
- return chalk.blue("Team Lead");
322
- case "member":
323
- return chalk.yellow("Member");
324
- default:
325
- return chalk.dim("Unknown");
617
+ return chalk.blue(label);
618
+ case "developer":
619
+ return chalk.yellow(label);
326
620
  }
327
621
  }
328
622
  function roleNotice(role) {
329
- if (role === "member") {
623
+ if (normalizeOrgRole(role) === "developer") {
330
624
  console.log(
331
625
  chalk.yellow(
332
- " You have Member access. Write operations will create pending requests for approval."
626
+ " You have Developer access. You can read the variables assigned to you and write only those you hold a write grant for; keys without a grant are skipped, not queued for approval."
333
627
  )
334
628
  );
335
629
  }
336
630
  }
337
631
  function formatProjectRole(role) {
338
- switch (role) {
339
- case "manager":
340
- return chalk.green("Manager");
341
- case "developer":
342
- return chalk.blue("Developer");
343
- case "viewer":
344
- return chalk.yellow("Viewer");
345
- default:
346
- return chalk.dim("-");
632
+ if (role === void 0 || role === null || role === "") {
633
+ return chalk.dim("-");
347
634
  }
635
+ return formatRole(role);
348
636
  }
349
- function projectRoleNotice(projectRole) {
350
- if (projectRole === "viewer") {
637
+ function projectRoleNotice(role) {
638
+ if (normalizeOrgRole(role) === "developer") {
351
639
  console.log(
352
640
  chalk.yellow(
353
- " You have Viewer access to this project. You can only view variables you have been explicitly granted access to."
641
+ " You have Developer access to this project. You can only see variables you have been granted, and your assignment may exclude some environments such as production."
354
642
  )
355
643
  );
356
644
  }
@@ -470,6 +758,9 @@ var APIError = class extends Error {
470
758
  var APIClient = class {
471
759
  baseUrl;
472
760
  accessToken;
761
+ // Re-entrancy guard: true while a token refresh is in flight so a 401 from
762
+ // the refresh endpoint itself can never trigger another refresh (no loops).
763
+ refreshing = false;
473
764
  constructor(options) {
474
765
  this.baseUrl = options?.baseUrl ?? getApiUrl();
475
766
  this.accessToken = options?.accessToken ?? getAccessToken();
@@ -548,70 +839,138 @@ var APIClient = class {
548
839
  "TOO_MANY_REDIRECTS"
549
840
  );
550
841
  }
842
+ /**
843
+ * Attempt a one-shot access-token refresh using the stored refresh token.
844
+ * Persists the rotated tokens and updates this client's in-memory token on
845
+ * success. Returns false (without throwing) when there is no refresh token,
846
+ * a refresh is already in flight, or the refresh call fails — the caller
847
+ * then treats the session as dead.
848
+ */
849
+ async tryRefreshToken() {
850
+ if (this.refreshing) return false;
851
+ const refreshToken = getRefreshToken();
852
+ if (!refreshToken) return false;
853
+ this.refreshing = true;
854
+ try {
855
+ const result = await this.refreshToken(refreshToken);
856
+ setAccessToken(result.accessToken);
857
+ setRefreshToken(result.refreshToken);
858
+ this.accessToken = result.accessToken;
859
+ return true;
860
+ } catch {
861
+ return false;
862
+ } finally {
863
+ this.refreshing = false;
864
+ }
865
+ }
866
+ /**
867
+ * Run a request thunk with automatic one-shot token refresh on a genuine 401.
868
+ *
869
+ * - An AUTH_REDIRECT (HTML sign-in page) is NOT a rejected token — rethrow
870
+ * without clearing creds or refreshing.
871
+ * - A genuine 401 triggers a single refresh attempt. On success the request
872
+ * is retried once with the rotated token; on failure (or a second 401) the
873
+ * local credentials are cleared so the user is prompted to log in again.
874
+ *
875
+ * The thunk rebuilds its headers on each call, so the retry automatically
876
+ * picks up the refreshed access token.
877
+ */
878
+ async withAuthRetry(exec) {
879
+ try {
880
+ return await exec();
881
+ } catch (err) {
882
+ if (!(err instanceof APIError) || err.statusCode !== 401 || err.code === "AUTH_REDIRECT" || this.refreshing) {
883
+ throw err;
884
+ }
885
+ const refreshed = await this.tryRefreshToken();
886
+ if (!refreshed) {
887
+ clearAuth();
888
+ throw err;
889
+ }
890
+ try {
891
+ return await exec();
892
+ } catch (retryErr) {
893
+ if (retryErr instanceof APIError && retryErr.statusCode === 401 && retryErr.code !== "AUTH_REDIRECT") {
894
+ clearAuth();
895
+ }
896
+ throw retryErr;
897
+ }
898
+ }
899
+ }
551
900
  /**
552
901
  * Make a GET request
553
902
  */
554
903
  async get(path, params) {
555
- const url = new URL(path, this.baseUrl);
556
- if (params) {
557
- for (const [key, value] of Object.entries(params)) {
558
- url.searchParams.set(key, value);
904
+ return this.withAuthRetry(async () => {
905
+ const url = new URL(path, this.baseUrl);
906
+ if (params) {
907
+ for (const [key, value] of Object.entries(params)) {
908
+ url.searchParams.set(key, value);
909
+ }
559
910
  }
560
- }
561
- const response = await this.fetchWithSafeRedirects(url.toString(), {
562
- method: "GET",
563
- headers: this.getHeaders()
911
+ const response = await this.fetchWithSafeRedirects(url.toString(), {
912
+ method: "GET",
913
+ headers: this.getHeaders()
914
+ });
915
+ return this.handleResponse(response);
564
916
  });
565
- return this.handleResponse(response);
566
917
  }
567
918
  /**
568
919
  * Make a POST request
569
920
  */
570
921
  async post(path, body) {
571
- const url = new URL(path, this.baseUrl);
572
- const response = await this.fetchWithSafeRedirects(url.toString(), {
573
- method: "POST",
574
- headers: this.getHeaders(),
575
- body: body ? JSON.stringify(body) : void 0
922
+ return this.withAuthRetry(async () => {
923
+ const url = new URL(path, this.baseUrl);
924
+ const response = await this.fetchWithSafeRedirects(url.toString(), {
925
+ method: "POST",
926
+ headers: this.getHeaders(),
927
+ body: body ? JSON.stringify(body) : void 0
928
+ });
929
+ return this.handleResponse(response);
576
930
  });
577
- return this.handleResponse(response);
578
931
  }
579
932
  /**
580
933
  * Make a PUT request
581
934
  */
582
935
  async put(path, body) {
583
- const url = new URL(path, this.baseUrl);
584
- const response = await this.fetchWithSafeRedirects(url.toString(), {
585
- method: "PUT",
586
- headers: this.getHeaders(),
587
- body: body ? JSON.stringify(body) : void 0
936
+ return this.withAuthRetry(async () => {
937
+ const url = new URL(path, this.baseUrl);
938
+ const response = await this.fetchWithSafeRedirects(url.toString(), {
939
+ method: "PUT",
940
+ headers: this.getHeaders(),
941
+ body: body ? JSON.stringify(body) : void 0
942
+ });
943
+ return this.handleResponse(response);
588
944
  });
589
- return this.handleResponse(response);
590
945
  }
591
946
  /**
592
947
  * Make a PATCH request
593
948
  */
594
949
  async patch(path, body) {
595
- const url = new URL(path, this.baseUrl);
596
- const response = await this.fetchWithSafeRedirects(url.toString(), {
597
- method: "PATCH",
598
- headers: this.getHeaders(),
599
- body: body ? JSON.stringify(body) : void 0
950
+ return this.withAuthRetry(async () => {
951
+ const url = new URL(path, this.baseUrl);
952
+ const response = await this.fetchWithSafeRedirects(url.toString(), {
953
+ method: "PATCH",
954
+ headers: this.getHeaders(),
955
+ body: body ? JSON.stringify(body) : void 0
956
+ });
957
+ return this.handleResponse(response);
600
958
  });
601
- return this.handleResponse(response);
602
959
  }
603
960
  /**
604
961
  * Make a DELETE request
605
962
  */
606
963
  async delete(path) {
607
- const url = new URL(path, this.baseUrl);
608
- const response = await this.fetchWithSafeRedirects(url.toString(), {
609
- method: "DELETE",
610
- headers: this.getHeaders()
964
+ return this.withAuthRetry(async () => {
965
+ const url = new URL(path, this.baseUrl);
966
+ const response = await this.fetchWithSafeRedirects(url.toString(), {
967
+ method: "DELETE",
968
+ headers: this.getHeaders()
969
+ });
970
+ if (!response.ok) {
971
+ await this.handleError(response);
972
+ }
611
973
  });
612
- if (!response.ok) {
613
- await this.handleError(response);
614
- }
615
974
  }
616
975
  /**
617
976
  * Handle API response
@@ -652,18 +1011,25 @@ var APIClient = class {
652
1011
  * Handle API errors
653
1012
  */
654
1013
  async handleError(response) {
1014
+ const bodyText = await response.text();
655
1015
  let message = `Request failed with status ${response.status}`;
656
1016
  let code;
657
1017
  try {
658
- const data = await response.json();
1018
+ const data = JSON.parse(bodyText);
659
1019
  message = data.error || data.message || message;
660
1020
  code = data.code;
661
1021
  } catch {
662
1022
  }
663
1023
  if (response.status === 401) {
664
- clearAuth();
1024
+ if (this.isAuthRedirect(response, bodyText)) {
1025
+ throw new APIError(
1026
+ "Your CLI session is not authorized for this endpoint. Please run `envpilot login` and try again.",
1027
+ 401,
1028
+ "AUTH_REDIRECT"
1029
+ );
1030
+ }
665
1031
  throw new APIError(
666
- "Authentication failed. Please run `envpilot login`.",
1032
+ message || "Authentication failed. Please run `envpilot login`.",
667
1033
  401,
668
1034
  code || "UNAUTHORIZED"
669
1035
  );
@@ -803,6 +1169,29 @@ var APIClient = class {
803
1169
  async bulkUpsertVariables(data) {
804
1170
  return this.post("/api/cli/variables/bulk", data);
805
1171
  }
1172
+ /**
1173
+ * Submit a variable request (developers only — owners/PMs/team leads
1174
+ * create variables directly and get a 403 from the server here).
1175
+ */
1176
+ async createVariableRequest(data) {
1177
+ const response = await this.post(
1178
+ "/api/cli/variable-requests",
1179
+ data
1180
+ );
1181
+ if (!response.data) {
1182
+ throw new APIError("No variable request returned by server", 500);
1183
+ }
1184
+ return response.data.request;
1185
+ }
1186
+ /**
1187
+ * List variable requests for a project, optionally filtered by status.
1188
+ */
1189
+ async listVariableRequests(projectId, status) {
1190
+ const params = { projectId };
1191
+ if (status) params.status = status;
1192
+ const response = await this.get("/api/cli/variable-requests", params);
1193
+ return response.data?.requests ?? [];
1194
+ }
806
1195
  // ============================================
807
1196
  // Authentication methods
808
1197
  // ============================================
@@ -894,15 +1283,19 @@ async function performLogin(options) {
894
1283
  "Authentication succeeded but the server did not return session tokens. This is likely a server-side issue. Please try again or contact support."
895
1284
  );
896
1285
  }
897
- setAccessToken(pollResponse.accessToken);
898
- setRefreshToken(pollResponse.refreshToken);
899
- if (pollResponse.user) {
900
- setUser({
1286
+ const accountId = pollResponse.user?.id ?? `session-${pollResponse.accessToken.slice(0, 8)}`;
1287
+ const account = {
1288
+ id: accountId,
1289
+ user: pollResponse.user ? {
901
1290
  id: pollResponse.user.id,
902
1291
  email: pollResponse.user.email,
903
1292
  name: pollResponse.user.name
904
- });
905
- }
1293
+ } : { id: accountId, email: `${accountId}@cli.local` },
1294
+ accessToken: pollResponse.accessToken,
1295
+ refreshToken: pollResponse.refreshToken
1296
+ };
1297
+ upsertAccount(account);
1298
+ setActiveAccount(account.id);
906
1299
  console.log();
907
1300
  success(`Logged in as ${chalk3.bold(pollResponse.user?.email)}`);
908
1301
  return { email: pollResponse.user?.email || "" };
@@ -926,6 +1319,16 @@ var loginCommand = new Command("login").description("Authenticate with Envpilot"
926
1319
  await performLogin({
927
1320
  browser: options.browser !== false
928
1321
  });
1322
+ const activeAccount = getActiveAccount();
1323
+ if (activeAccount) {
1324
+ success(`Signed in as ${activeAccount.user.email}.`);
1325
+ }
1326
+ const accounts = listAccounts();
1327
+ if (accounts.length > 1) {
1328
+ info(
1329
+ `You have ${accounts.length} accounts \u2014 use \`envpilot accounts\` to list/switch.`
1330
+ );
1331
+ }
929
1332
  console.log();
930
1333
  console.log("Next steps:");
931
1334
  info(" envpilot init Initialize a project in the current directory");
@@ -943,9 +1346,15 @@ import chalk4 from "chalk";
943
1346
  import inquirer from "inquirer";
944
1347
 
945
1348
  // src/lib/project-config.ts
946
- import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
1349
+ import {
1350
+ readFileSync as readFileSync2,
1351
+ writeFileSync as writeFileSync2,
1352
+ existsSync as existsSync2,
1353
+ unlinkSync as unlinkSync2,
1354
+ renameSync
1355
+ } from "fs";
947
1356
  import { execSync } from "child_process";
948
- import { join } from "path";
1357
+ import { join as join2 } from "path";
949
1358
 
950
1359
  // src/types/index.ts
951
1360
  import { z } from "zod";
@@ -954,12 +1363,25 @@ var userSchema = z.object({
954
1363
  email: z.string().email(),
955
1364
  name: z.string().optional()
956
1365
  });
1366
+ var accountSchema = z.object({
1367
+ id: z.string(),
1368
+ user: userSchema,
1369
+ accessToken: z.string(),
1370
+ refreshToken: z.string().optional(),
1371
+ // Stored role string (legacy or unified); normalized on read.
1372
+ role: z.string().optional(),
1373
+ // Per-account active org/project selection.
1374
+ activeOrganizationId: z.string().optional(),
1375
+ activeProjectId: z.string().optional()
1376
+ });
957
1377
  var organizationSchema = z.object({
958
1378
  _id: z.string(),
959
1379
  name: z.string(),
960
1380
  slug: z.string(),
961
1381
  tier: z.enum(["free", "pro"]),
962
- role: z.string().optional()
1382
+ role: z.string().optional(),
1383
+ // Unified org role (additive; legacy responses omit it and fall back to role).
1384
+ unifiedRole: z.string().nullable().optional()
963
1385
  });
964
1386
  var projectSchema = z.object({
965
1387
  _id: z.string(),
@@ -970,7 +1392,11 @@ var projectSchema = z.object({
970
1392
  icon: z.string().optional(),
971
1393
  color: z.string().optional(),
972
1394
  userRole: z.string().nullable().optional(),
973
- projectRole: z.string().nullable().optional()
1395
+ projectRole: z.string().nullable().optional(),
1396
+ // Unified-role fields (optional so legacy server responses still parse)
1397
+ unifiedRole: z.string().nullable().optional(),
1398
+ assigned: z.boolean().optional(),
1399
+ environmentScope: z.array(z.string()).nullable().optional()
974
1400
  });
975
1401
  var variableTagSchema = z.object({
976
1402
  _id: z.string(),
@@ -988,21 +1414,49 @@ var variableSchema = z.object({
988
1414
  version: z.number().optional(),
989
1415
  updatedAt: z.number().optional(),
990
1416
  createdAt: z.number().optional(),
991
- tags: z.array(variableTagSchema).optional()
1417
+ tags: z.array(variableTagSchema).optional(),
1418
+ // Per-variable effective access under the unified model. Optional so older
1419
+ // server responses (which omit it) still parse.
1420
+ access: z.enum(["read", "write"]).optional()
992
1421
  });
1422
+ var variablesMetaSchema = z.object({
1423
+ total: z.number().optional(),
1424
+ page: z.number().optional(),
1425
+ limit: z.number().optional(),
1426
+ decryptionFailures: z.array(z.string()).optional(),
1427
+ unifiedRole: z.string().nullable().optional(),
1428
+ assigned: z.boolean().optional(),
1429
+ grantOnly: z.boolean().optional(),
1430
+ environmentScope: z.array(z.string()).nullable().optional(),
1431
+ hasWriteAccess: z.boolean().optional(),
1432
+ scopeRestricted: z.boolean().optional()
1433
+ }).passthrough();
993
1434
  var environmentSchema = z.enum([
994
1435
  "development",
995
1436
  "staging",
996
1437
  "production"
997
1438
  ]);
998
1439
  var cliConfigSchema = z.object({
1440
+ // Global CLI setting — shared across all accounts.
999
1441
  apiUrl: z.string().url(),
1442
+ // Multi-account store. Keyed by account id (== user id).
1443
+ accounts: z.record(z.string(), accountSchema).optional(),
1444
+ // The currently active account id.
1445
+ activeAccountId: z.string().optional(),
1446
+ // --- Legacy single-account fields ---
1447
+ // Retained ONLY so pre-multi-account configs still parse and can be migrated
1448
+ // into `accounts` by migrateLegacyConfig(). After migration these are deleted
1449
+ // so `accounts` is the single source of truth. Do not read/write directly.
1000
1450
  accessToken: z.string().optional(),
1001
1451
  refreshToken: z.string().optional(),
1002
1452
  activeProjectId: z.string().optional(),
1003
1453
  activeOrganizationId: z.string().optional(),
1004
1454
  user: userSchema.optional(),
1005
- role: z.enum(["admin", "team_lead", "member"]).optional()
1455
+ // Stored role string. Kept as a free-form string so both legacy
1456
+ // ("admin"/"team_lead"/"member") and unified
1457
+ // ("owner"/"project_manager"/"team_lead"/"developer") values round-trip.
1458
+ // Normalized on read via getUnifiedRole(); no longer enum-enforced.
1459
+ role: z.string().optional()
1006
1460
  });
1007
1461
  var projectConfigSchema = z.object({
1008
1462
  projectId: z.string(),
@@ -1014,27 +1468,71 @@ var projectEntrySchema = z.object({
1014
1468
  organizationId: z.string(),
1015
1469
  projectName: z.string().default(""),
1016
1470
  organizationName: z.string().default(""),
1017
- environment: environmentSchema.default("development")
1471
+ environment: environmentSchema.default("development"),
1472
+ // How the pulled .env file's permissions are managed for this project:
1473
+ // auto → derive from the caller's resolved access (default)
1474
+ // always → force read-only (0o400)
1475
+ // never → force writable (0o600)
1476
+ fileProtection: z.enum(["auto", "always", "never"]).optional()
1018
1477
  });
1019
1478
  var projectConfigV2Schema = z.object({
1020
1479
  version: z.literal(1),
1021
1480
  activeProjectId: z.string(),
1022
1481
  projects: z.array(projectEntrySchema).min(1)
1023
1482
  });
1483
+ var variableRequestStatusSchema = z.enum([
1484
+ "pending",
1485
+ "approved",
1486
+ "rejected",
1487
+ "canceled"
1488
+ ]);
1489
+ var variableRequestUserSchema = z.object({
1490
+ _id: z.string(),
1491
+ email: z.string().optional(),
1492
+ name: z.string().optional()
1493
+ }).nullable().optional();
1494
+ var variableRequestSchema = z.object({
1495
+ _id: z.string(),
1496
+ key: z.string(),
1497
+ description: z.string().optional(),
1498
+ environments: z.array(z.string()),
1499
+ projectId: z.string(),
1500
+ organizationId: z.string().optional(),
1501
+ isSensitive: z.boolean().optional(),
1502
+ status: variableRequestStatusSchema,
1503
+ reviewReason: z.string().optional(),
1504
+ createdAt: z.number(),
1505
+ updatedAt: z.number().optional(),
1506
+ requester: variableRequestUserSchema,
1507
+ reviewer: variableRequestUserSchema
1508
+ });
1024
1509
 
1025
1510
  // src/lib/project-config.ts
1026
1511
  var CONFIG_FILE_NAME = ".envpilot";
1512
+ function atomicWriteFileSync(filePath, content) {
1513
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
1514
+ try {
1515
+ writeFileSync2(tmpPath, content, "utf-8");
1516
+ renameSync(tmpPath, filePath);
1517
+ } catch (err) {
1518
+ try {
1519
+ if (existsSync2(tmpPath)) unlinkSync2(tmpPath);
1520
+ } catch {
1521
+ }
1522
+ throw err;
1523
+ }
1524
+ }
1027
1525
  function getProjectConfigPath(directory = process.cwd()) {
1028
- return join(directory, CONFIG_FILE_NAME);
1526
+ return join2(directory, CONFIG_FILE_NAME);
1029
1527
  }
1030
1528
  function hasProjectConfig(directory = process.cwd()) {
1031
- return existsSync(getProjectConfigPath(directory));
1529
+ return existsSync2(getProjectConfigPath(directory));
1032
1530
  }
1033
1531
  function readRawConfig(directory = process.cwd()) {
1034
1532
  const configPath = getProjectConfigPath(directory);
1035
- if (!existsSync(configPath)) return null;
1533
+ if (!existsSync2(configPath)) return null;
1036
1534
  try {
1037
- return JSON.parse(readFileSync(configPath, "utf-8"));
1535
+ return JSON.parse(readFileSync2(configPath, "utf-8"));
1038
1536
  } catch {
1039
1537
  return null;
1040
1538
  }
@@ -1081,7 +1579,7 @@ function readProjectConfigV2(directory = process.cwd()) {
1081
1579
  }
1082
1580
  function writeProjectConfigV2(config2, directory = process.cwd()) {
1083
1581
  const configPath = getProjectConfigPath(directory);
1084
- writeFileSync(configPath, JSON.stringify(config2, null, 2) + "\n", "utf-8");
1582
+ atomicWriteFileSync(configPath, JSON.stringify(config2, null, 2) + "\n");
1085
1583
  }
1086
1584
  function getActiveProject(config2) {
1087
1585
  return config2.projects.find((p) => p.projectId === config2.activeProjectId) || config2.projects[0] || null;
@@ -1174,23 +1672,23 @@ function updateProjectConfig(updates, directory = process.cwd()) {
1174
1672
  }
1175
1673
  function deleteProjectConfig(directory = process.cwd()) {
1176
1674
  const configPath = getProjectConfigPath(directory);
1177
- if (!existsSync(configPath)) return false;
1178
- unlinkSync(configPath);
1675
+ if (!existsSync2(configPath)) return false;
1676
+ unlinkSync2(configPath);
1179
1677
  return true;
1180
1678
  }
1181
1679
  function ensureEnvInGitignore(directory = process.cwd()) {
1182
- const gitignorePath = join(directory, ".gitignore");
1183
- if (!existsSync(gitignorePath)) {
1184
- writeFileSync(gitignorePath, ".env\n.env.local\n", "utf-8");
1680
+ const gitignorePath = join2(directory, ".gitignore");
1681
+ if (!existsSync2(gitignorePath)) {
1682
+ atomicWriteFileSync(gitignorePath, ".env\n.env.local\n");
1185
1683
  return;
1186
1684
  }
1187
- const content = readFileSync(gitignorePath, "utf-8");
1685
+ const content = readFileSync2(gitignorePath, "utf-8");
1188
1686
  const lines = content.split("\n");
1189
1687
  if (lines.some((line2) => line2.trim() === ".env")) {
1190
1688
  return;
1191
1689
  }
1192
1690
  const newContent = content.endsWith("\n") ? content + ".env\n" : content + "\n.env\n";
1193
- writeFileSync(gitignorePath, newContent, "utf-8");
1691
+ atomicWriteFileSync(gitignorePath, newContent);
1194
1692
  }
1195
1693
  function getTrackedEnvFiles(directory = process.cwd()) {
1196
1694
  try {
@@ -1206,8 +1704,14 @@ function getTrackedEnvFiles(directory = process.cwd()) {
1206
1704
  }
1207
1705
 
1208
1706
  // src/lib/env-file.ts
1209
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, chmodSync } from "fs";
1210
- import { join as join2 } from "path";
1707
+ import {
1708
+ readFileSync as readFileSync3,
1709
+ writeFileSync as writeFileSync3,
1710
+ existsSync as existsSync3,
1711
+ chmodSync,
1712
+ renameSync as renameSync2
1713
+ } from "fs";
1714
+ import { join as join3 } from "path";
1211
1715
  function parseEnvFile(content) {
1212
1716
  const result = {};
1213
1717
  const lines = content.split("\n");
@@ -1220,7 +1724,7 @@ function parseEnvFile(content) {
1220
1724
  if (equalsIndex === -1) {
1221
1725
  continue;
1222
1726
  }
1223
- const key = line2.substring(0, equalsIndex).trim();
1727
+ const key = line2.substring(0, equalsIndex).trim().replace(/^export\s+/, "");
1224
1728
  let value = line2.substring(equalsIndex + 1);
1225
1729
  value = parseValue(value);
1226
1730
  if (isValidEnvKey(key)) {
@@ -1231,17 +1735,21 @@ function parseEnvFile(content) {
1231
1735
  }
1232
1736
  function parseValue(value) {
1233
1737
  value = value.trim();
1234
- if (value.startsWith('"') && value.endsWith('"')) {
1235
- value = value.slice(1, -1);
1236
- value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
1237
- } else if (value.startsWith("'") && value.endsWith("'")) {
1238
- value = value.slice(1, -1);
1239
- } else {
1240
- const commentIndex = value.indexOf(" #");
1241
- if (commentIndex !== -1) {
1242
- value = value.substring(0, commentIndex).trim();
1738
+ const quote = value[0];
1739
+ if (quote === '"' || quote === "'") {
1740
+ const closingIndex = value.indexOf(quote, 1);
1741
+ if (closingIndex !== -1) {
1742
+ const inner = value.slice(1, closingIndex);
1743
+ if (quote === '"') {
1744
+ return inner.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
1745
+ }
1746
+ return inner;
1243
1747
  }
1244
1748
  }
1749
+ const commentIndex = value.indexOf(" #");
1750
+ if (commentIndex !== -1) {
1751
+ value = value.substring(0, commentIndex).trim();
1752
+ }
1245
1753
  return value;
1246
1754
  }
1247
1755
  function isValidEnvKey(key) {
@@ -1293,33 +1801,53 @@ function diffEnvVars(local, remote) {
1293
1801
  return { added, removed, changed, unchanged };
1294
1802
  }
1295
1803
  function readEnvFile(filePath) {
1296
- if (!existsSync2(filePath)) {
1804
+ if (!existsSync3(filePath)) {
1297
1805
  return null;
1298
1806
  }
1299
- const content = readFileSync2(filePath, "utf-8");
1807
+ const content = readFileSync3(filePath, "utf-8");
1300
1808
  return parseEnvFile(content);
1301
1809
  }
1810
+ function atomicWriteFileSync2(filePath, content, mode) {
1811
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
1812
+ try {
1813
+ writeFileSync3(tmpPath, content, { encoding: "utf-8", mode });
1814
+ renameSync2(tmpPath, filePath);
1815
+ } catch (err) {
1816
+ try {
1817
+ if (existsSync3(tmpPath)) chmodSync(tmpPath, 384);
1818
+ } catch {
1819
+ }
1820
+ throw err;
1821
+ }
1822
+ }
1302
1823
  function writeEnvFile(filePath, vars, options) {
1303
1824
  const content = stringifyEnv(vars, options);
1304
- writeFileSync2(filePath, content, "utf-8");
1825
+ atomicWriteFileSync2(filePath, content, 384);
1305
1826
  }
1306
1827
  function getEnvPathForEnvironment(environment, directory = process.cwd()) {
1307
1828
  if (environment === "development") {
1308
- return join2(directory, ".env.local");
1829
+ return join3(directory, ".env.local");
1309
1830
  }
1310
- return join2(directory, `.env.${environment}`);
1831
+ return join3(directory, `.env.${environment}`);
1311
1832
  }
1312
- function applyFileProtection(filePath, role, projectRole) {
1313
- if (!existsSync2(filePath)) return;
1314
- const isWritable = role === "admin" || role === "team_lead" || projectRole === "manager";
1315
- if (isWritable) {
1316
- chmodSync(filePath, 420);
1317
- } else {
1318
- chmodSync(filePath, 292);
1833
+ function applyFileProtection(filePath, access, protection = "auto") {
1834
+ if (!existsSync3(filePath)) return;
1835
+ if (protection === "never") {
1836
+ chmodSync(filePath, 384);
1837
+ return;
1319
1838
  }
1839
+ if (protection === "always") {
1840
+ chmodSync(filePath, 256);
1841
+ return;
1842
+ }
1843
+ chmodSync(filePath, isFileWritable(access) ? 384 : 256);
1320
1844
  }
1321
1845
 
1322
1846
  // src/commands/init.ts
1847
+ function orgUnifiedRole(org) {
1848
+ const unified = org.unifiedRole;
1849
+ return normalizeOrgRole(unified ?? org.role);
1850
+ }
1323
1851
  var initCommand = new Command2("init").description("Initialize Envpilot in the current directory").option("-o, --organization <id>", "Organization ID").option("-p, --project <id>", "Project ID").option(
1324
1852
  "-e, --environment <env>",
1325
1853
  "Default environment (development, staging, production)"
@@ -1368,9 +1896,7 @@ var initCommand = new Command2("init").description("Initialize Envpilot in the c
1368
1896
  });
1369
1897
  setActiveOrganizationId(selectedOrg._id);
1370
1898
  setActiveProjectId(selectedProject._id);
1371
- if (selectedOrg.role) {
1372
- setRole(selectedOrg.role);
1373
- }
1899
+ setUnifiedRole(orgUnifiedRole(selectedOrg));
1374
1900
  ensureEnvInGitignore();
1375
1901
  warnTrackedFiles();
1376
1902
  console.log();
@@ -1382,21 +1908,23 @@ var initCommand = new Command2("init").description("Initialize Envpilot in the c
1382
1908
  });
1383
1909
  async function addProject(existingConfig, options) {
1384
1910
  const api = createAPIClient();
1385
- let role = getRole();
1386
- if (role !== "admin" && role !== "team_lead") {
1911
+ let role = getUnifiedRole();
1912
+ if (roleLevel(role) < ROLE_LEVEL.team_lead) {
1387
1913
  const orgs = await withSpinner("Checking permissions...", async () => {
1388
1914
  const response = await api.get("/api/cli/organizations");
1389
1915
  return response.data || [];
1390
1916
  });
1391
- const freshRole = orgs.find(
1917
+ const freshOrg = orgs.find(
1392
1918
  (o) => o._id === existingConfig.projects[0]?.organizationId
1393
- )?.role;
1394
- if (freshRole) {
1395
- setRole(freshRole);
1396
- role = freshRole;
1919
+ );
1920
+ if (freshOrg) {
1921
+ role = orgUnifiedRole(freshOrg);
1922
+ setUnifiedRole(role);
1397
1923
  }
1398
- if (role !== "admin" && role !== "team_lead") {
1399
- error("Only admins and team leads can link multiple projects.");
1924
+ if (roleLevel(role) < ROLE_LEVEL.team_lead) {
1925
+ error(
1926
+ `Only team leads and above can link multiple projects (your role: ${formatRoleLabel(role)}).`
1927
+ );
1400
1928
  info("Unlink the current project first with `envpilot unlink`.");
1401
1929
  process.exit(1);
1402
1930
  }
@@ -1589,17 +2117,12 @@ function warnTrackedFiles() {
1589
2117
  function printPostInit(selectedOrg, selectedProject) {
1590
2118
  console.log();
1591
2119
  console.log(chalk4.dim("Configuration saved to .envpilot"));
1592
- if (selectedOrg.role) {
1593
- console.log(chalk4.dim(` Org role: ${formatRole(selectedOrg.role)}`));
1594
- roleNotice(selectedOrg.role);
1595
- }
1596
- if (selectedProject.projectRole) {
1597
- console.log(
1598
- chalk4.dim(
1599
- ` Project role: ${formatProjectRole(selectedProject.projectRole)}`
1600
- )
1601
- );
1602
- projectRoleNotice(selectedProject.projectRole);
2120
+ console.log(
2121
+ chalk4.dim(` Org role: ${formatRoleLabel(orgUnifiedRole(selectedOrg))}`)
2122
+ );
2123
+ const scope = selectedProject.environmentScope;
2124
+ if (scope && scope.length > 0) {
2125
+ console.log(chalk4.dim(` Environment scope: ${scope.join(", ")}`));
1603
2126
  }
1604
2127
  console.log();
1605
2128
  console.log("Next steps:");
@@ -1932,7 +2455,7 @@ function parseNetlify(content) {
1932
2455
  for (const line2 of lines) {
1933
2456
  const trimmed = line2.trim();
1934
2457
  if (trimmed.startsWith("[")) {
1935
- inBuildEnv = trimmed === "[build.environment]" || trimmed === "[build.environment]";
2458
+ inBuildEnv = trimmed === "[build.environment]" || /^\[context\.[^.\]]+\.environment\]$/.test(trimmed);
1936
2459
  continue;
1937
2460
  }
1938
2461
  if (!inBuildEnv) continue;
@@ -1955,6 +2478,17 @@ function parseNetlify(content) {
1955
2478
  }
1956
2479
 
1957
2480
  // src/commands/pull.ts
2481
+ function accessFromMeta(meta, variables) {
2482
+ const role = normalizeOrgRole(meta?.unifiedRole ?? meta?.role);
2483
+ return {
2484
+ role,
2485
+ // Owners are implicitly assigned to every project. The legacy server sends
2486
+ // no `assigned`/`projectRole` for owners, so fall back to true for them.
2487
+ assigned: role === "owner" || (meta?.assigned ?? (meta?.projectRole !== null && meta?.projectRole !== void 0)),
2488
+ environmentScope: meta?.environmentScope ?? null,
2489
+ hasWriteAccess: meta?.hasWriteAccess ?? variables.some((v) => v.access === "write")
2490
+ };
2491
+ }
1958
2492
  var pullCommand = new Command3("pull").description("Download environment variables to local .env file").option(
1959
2493
  "-e, --env <environment>",
1960
2494
  "Environment (development, staging, production)"
@@ -1997,6 +2531,8 @@ var pullCommand = new Command3("pull").description("Download environment variabl
1997
2531
  const projectConfig = readProjectConfig();
1998
2532
  if (!projectConfig) throw notInitialized();
1999
2533
  checkTrackedFiles();
2534
+ const configV2ForActive = readProjectConfigV2();
2535
+ const activeEntry = configV2ForActive ? getActiveProject(configV2ForActive) : null;
2000
2536
  const environment = options.env || projectConfig.environment || "development";
2001
2537
  const fmt = options.format || "env";
2002
2538
  if (!ALL_FORMATS.includes(fmt)) {
@@ -2008,7 +2544,8 @@ var pullCommand = new Command3("pull").description("Download environment variabl
2008
2544
  {
2009
2545
  projectId: projectConfig.projectId,
2010
2546
  organizationId: projectConfig.organizationId,
2011
- environment
2547
+ environment,
2548
+ fileProtection: activeEntry?.fileProtection
2012
2549
  },
2013
2550
  outputPath,
2014
2551
  options
@@ -2037,7 +2574,8 @@ async function pullAllProjects(options) {
2037
2574
  {
2038
2575
  projectId: project.projectId,
2039
2576
  organizationId: project.organizationId,
2040
- environment: project.environment
2577
+ environment: project.environment,
2578
+ fileProtection: project.fileProtection
2041
2579
  },
2042
2580
  outputPath,
2043
2581
  options
@@ -2068,7 +2606,8 @@ async function pullSingleProject(project, options) {
2068
2606
  {
2069
2607
  projectId: project.projectId,
2070
2608
  organizationId: project.organizationId,
2071
- environment
2609
+ environment,
2610
+ fileProtection: project.fileProtection
2072
2611
  },
2073
2612
  outputPath,
2074
2613
  options
@@ -2076,7 +2615,7 @@ async function pullSingleProject(project, options) {
2076
2615
  }
2077
2616
  async function pullProject(project, outputPath, options) {
2078
2617
  const api = createAPIClient();
2079
- let metaProjectRole;
2618
+ let meta;
2080
2619
  const variables = await withSpinner(
2081
2620
  `Fetching ${chalk5.bold(project.environment)} variables...`,
2082
2621
  async () => {
@@ -2087,10 +2626,15 @@ async function pullProject(project, outputPath, options) {
2087
2626
  organizationId: project.organizationId
2088
2627
  }
2089
2628
  });
2090
- metaProjectRole = response.meta?.projectRole;
2629
+ meta = response.meta;
2091
2630
  return response.data || [];
2092
2631
  }
2093
2632
  );
2633
+ if (meta?.scopeRestricted && meta.environmentScope?.length) {
2634
+ info(
2635
+ `Your access is scoped to ${meta.environmentScope.join(", ")}; variables in other environments are withheld.`
2636
+ );
2637
+ }
2094
2638
  if (variables.length === 0) {
2095
2639
  warning(`No variables found for ${project.environment} environment.`);
2096
2640
  return;
@@ -2154,20 +2698,21 @@ async function pullProject(project, outputPath, options) {
2154
2698
  });
2155
2699
  fs.writeFileSync(outputPath, output, "utf-8");
2156
2700
  }
2157
- const role = getRole();
2158
- applyFileProtection(outputPath, role, metaProjectRole);
2701
+ const access = accessFromMeta(meta, variables);
2702
+ const protection = project.fileProtection ?? "auto";
2703
+ applyFileProtection(outputPath, access, protection);
2159
2704
  success(
2160
2705
  `Downloaded ${variables.length} variables to ${chalk5.bold(outputPath)}`
2161
2706
  );
2162
- if (metaProjectRole === "viewer") {
2707
+ if (meta?.grantOnly) {
2163
2708
  info(
2164
- "You have Viewer access to this project. You may only see variables you have been explicitly granted access to."
2709
+ "You have grant-only access to this project. You only see variables explicitly shared with you."
2165
2710
  );
2166
2711
  }
2167
- const isProtected = role !== "admin" && role !== "team_lead" && metaProjectRole !== "manager";
2168
- if (isProtected) {
2712
+ const writable = protection === "never" || protection !== "always" && isFileWritable(access);
2713
+ if (!writable) {
2169
2714
  info(
2170
- `File is read-only (your role: ${role || metaProjectRole || "member"}).`
2715
+ `File is read-only (your role: ${formatRoleLabel(access.role)}${access.assigned ? "" : ", not assigned to this project"}).`
2171
2716
  );
2172
2717
  }
2173
2718
  console.log();
@@ -2306,36 +2851,6 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2306
2851
  process.exit(1);
2307
2852
  }
2308
2853
  const api = createAPIClient();
2309
- const projects = await api.get("/api/cli/projects", { organizationId });
2310
- const currentProject = projects.data?.find((p) => p._id === projectId);
2311
- const projectRole = currentProject?.projectRole;
2312
- if (projectRole === "viewer") {
2313
- error(
2314
- "Unfortunately, as a member, you cannot push in any environment. Please access or request access to your team lead."
2315
- );
2316
- process.exit(1);
2317
- }
2318
- const role = getRole();
2319
- if (role === "member") {
2320
- warning(
2321
- "You have Member access. Push will create pending requests that require Admin or Team Lead approval."
2322
- );
2323
- console.log();
2324
- if (!options.force) {
2325
- const { proceed } = await inquirer3.prompt([
2326
- {
2327
- type: "confirm",
2328
- name: "proceed",
2329
- message: "Continue with creating approval requests?",
2330
- default: true
2331
- }
2332
- ]);
2333
- if (!proceed) {
2334
- info("Push cancelled.");
2335
- return;
2336
- }
2337
- }
2338
- }
2339
2854
  const environment = options.env || defaultEnvironment || "development";
2340
2855
  const fmt = options.format || "env";
2341
2856
  if (!ALL_FORMATS.includes(fmt)) {
@@ -2375,6 +2890,7 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2375
2890
  error("No valid variables to push.");
2376
2891
  return;
2377
2892
  }
2893
+ let meta;
2378
2894
  const remoteVariables = await withSpinner(
2379
2895
  "Fetching current variables...",
2380
2896
  async () => {
@@ -2386,9 +2902,21 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2386
2902
  params.organizationId = organizationId;
2387
2903
  }
2388
2904
  const response = await api.get("/api/cli/variables", params);
2905
+ meta = response.meta;
2389
2906
  return response.data || [];
2390
2907
  }
2391
2908
  );
2909
+ const scope = meta?.environmentScope;
2910
+ if (Array.isArray(scope) && !scope.includes(environment)) {
2911
+ error(
2912
+ `Your developer access is scoped to ${scope.join(", ")}. ${environment} variables are withheld.`
2913
+ );
2914
+ process.exit(1);
2915
+ }
2916
+ if (meta?.hasWriteAccess === false) {
2917
+ error(`You have read-only access to ${environment} in this project.`);
2918
+ process.exit(1);
2919
+ }
2392
2920
  const remoteVars = {};
2393
2921
  for (const variable of remoteVariables) {
2394
2922
  remoteVars[variable.key] = variable.value;
@@ -2459,32 +2987,28 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2459
2987
  return response.data;
2460
2988
  }
2461
2989
  );
2462
- if (result?.requested && result.requested > 0) {
2463
- success(
2464
- `Created ${result.requested} pending request(s) for ${chalk6.bold(environment)}`
2465
- );
2466
- console.log();
2467
- console.log(
2468
- chalk6.yellow(
2469
- " These changes require approval from an Admin or Team Lead."
2470
- )
2471
- );
2990
+ success(
2991
+ `Pushed ${result?.total || Object.keys(valid).length} variables to ${chalk6.bold(environment)}`
2992
+ );
2993
+ console.log();
2994
+ console.log(chalk6.dim(` Created: ${result?.created || 0}`));
2995
+ console.log(chalk6.dim(` Updated: ${result?.updated || 0}`));
2996
+ if (mode === "replace") {
2997
+ console.log(chalk6.dim(` Deleted: ${result?.deleted || 0}`));
2998
+ }
2999
+ const deniedKeys = result?.deniedKeys ?? [];
3000
+ if (deniedKeys.length > 0) {
2472
3001
  console.log();
2473
- console.log(chalk6.dim(` Requested: ${result.requested}`));
2474
- if (result?.skipped && result.skipped > 0) {
2475
- console.log(chalk6.dim(` Skipped: ${result.skipped}`));
2476
- }
2477
- } else {
2478
- success(
2479
- `Pushed ${result?.total || Object.keys(valid).length} variables to ${chalk6.bold(environment)}`
3002
+ warning(
3003
+ `${deniedKeys.length} variable(s) were NOT written (access denied):`
2480
3004
  );
2481
- console.log();
2482
- console.log(chalk6.dim(` Created: ${result?.created || 0}`));
2483
- console.log(chalk6.dim(` Updated: ${result?.updated || 0}`));
2484
- if (mode === "replace") {
2485
- console.log(chalk6.dim(` Deleted: ${result?.deleted || 0}`));
3005
+ for (const key of deniedKeys) {
3006
+ console.log(chalk6.red(` \u2717 ${key}`));
2486
3007
  }
2487
3008
  }
3009
+ if (result?.skipped && result.skipped > 0) {
3010
+ console.log(chalk6.dim(` Skipped: ${result.skipped}`));
3011
+ }
2488
3012
  } catch (err) {
2489
3013
  await handleError(err);
2490
3014
  }
@@ -2494,6 +3018,9 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2494
3018
  import { Command as Command5 } from "commander";
2495
3019
  import chalk7 from "chalk";
2496
3020
  import inquirer4 from "inquirer";
3021
+ function orgUnifiedRole2(org) {
3022
+ return normalizeOrgRole(org.unifiedRole ?? org.role);
3023
+ }
2497
3024
  var switchCommand = new Command5("switch").description("Switch project, environment, or active linked project").argument("[target]", "project slug or environment name").option("-o, --organization <id>", "Switch organization").option("-p, --project <id>", "Switch project").option(
2498
3025
  "-e, --env <environment>",
2499
3026
  "Switch environment (development, staging, production)"
@@ -2562,17 +3089,15 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2562
3089
  process.exit(1);
2563
3090
  }
2564
3091
  setActiveOrganizationId(org._id);
2565
- if (org.role) {
2566
- setRole(org.role);
2567
- }
3092
+ setUnifiedRole(orgUnifiedRole2(org));
2568
3093
  if (projectConfig) {
2569
3094
  updateProjectConfig({ organizationId: org._id });
2570
3095
  }
2571
3096
  success(`Switched to organization: ${chalk7.bold(org.name)}`);
2572
- if (org.role) {
2573
- console.log(chalk7.dim(` Role: ${formatRole(org.role)}`));
2574
- roleNotice(org.role);
2575
- }
3097
+ console.log(
3098
+ chalk7.dim(` Role: ${formatRoleLabel(orgUnifiedRole2(org))}`)
3099
+ );
3100
+ roleNotice(orgUnifiedRole2(org));
2576
3101
  return;
2577
3102
  }
2578
3103
  if (options.project || target) {
@@ -2611,9 +3136,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2611
3136
  }
2612
3137
  if (organizations.length === 1) {
2613
3138
  organizationId = organizations[0]._id;
2614
- if (organizations[0].role) {
2615
- setRole(organizations[0].role);
2616
- }
3139
+ setUnifiedRole(orgUnifiedRole2(organizations[0]));
2617
3140
  } else {
2618
3141
  const { orgId } = await inquirer4.prompt([
2619
3142
  {
@@ -2628,8 +3151,8 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2628
3151
  ]);
2629
3152
  organizationId = orgId;
2630
3153
  const selectedOrg = organizations.find((o) => o._id === orgId);
2631
- if (selectedOrg?.role) {
2632
- setRole(selectedOrg.role);
3154
+ if (selectedOrg) {
3155
+ setUnifiedRole(orgUnifiedRole2(selectedOrg));
2633
3156
  }
2634
3157
  }
2635
3158
  }
@@ -2768,19 +3291,17 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2768
3291
  if (switchType === "organization") {
2769
3292
  setActiveOrganizationId(orgId);
2770
3293
  const org = organizations.find((o) => o._id === orgId);
2771
- if (org.role) {
2772
- setRole(org.role);
2773
- }
3294
+ setUnifiedRole(orgUnifiedRole2(org));
2774
3295
  success(`Switched to organization: ${chalk7.bold(org.name)}`);
2775
- if (org.role) {
2776
- console.log(chalk7.dim(` Role: ${formatRole(org.role)}`));
2777
- roleNotice(org.role);
2778
- }
3296
+ console.log(
3297
+ chalk7.dim(` Role: ${formatRoleLabel(orgUnifiedRole2(org))}`)
3298
+ );
3299
+ roleNotice(orgUnifiedRole2(org));
2779
3300
  return;
2780
3301
  }
2781
3302
  const selectedOrg = organizations.find((o) => o._id === orgId);
2782
- if (selectedOrg?.role) {
2783
- setRole(selectedOrg.role);
3303
+ if (selectedOrg) {
3304
+ setUnifiedRole(orgUnifiedRole2(selectedOrg));
2784
3305
  }
2785
3306
  const projects = await withSpinner(
2786
3307
  "Fetching projects...",
@@ -2928,7 +3449,7 @@ async function listOrganizations(api, options) {
2928
3449
  name: org.name,
2929
3450
  slug: org.slug,
2930
3451
  tier: org.tier === "pro" ? chalk8.green("Pro") : chalk8.dim("Free"),
2931
- role: org.role
3452
+ role: formatRoleLabel(org.unifiedRole ?? org.role)
2932
3453
  })),
2933
3454
  [
2934
3455
  { key: "name", header: "Name" },
@@ -2986,7 +3507,9 @@ async function listProjects(api, projectConfig, options) {
2986
3507
  name: project.name,
2987
3508
  slug: project.slug,
2988
3509
  description: project.description || chalk8.dim("-"),
2989
- role: project.userRole === "admin" ? formatRole("admin") : formatProjectRole(project.projectRole),
3510
+ role: formatRoleLabel(
3511
+ project.unifiedRole ?? project.userRole ?? project.projectRole
3512
+ ),
2990
3513
  active: projectConfig?.projectId === project._id ? chalk8.green("\u2713") : ""
2991
3514
  })),
2992
3515
  [
@@ -2998,11 +3521,8 @@ async function listProjects(api, projectConfig, options) {
2998
3521
  { key: "active", header: "" }
2999
3522
  ]
3000
3523
  );
3001
- const role = getRole();
3002
- if (role) {
3003
- console.log();
3004
- console.log(chalk8.dim(`Your org role: ${formatRole(role)}`));
3005
- }
3524
+ console.log();
3525
+ console.log(chalk8.dim(`Your org role: ${formatRoleLabel(getUnifiedRole())}`));
3006
3526
  }
3007
3527
  async function listVariables(api, projectConfig, options) {
3008
3528
  const projectId = options.project || projectConfig?.projectId;
@@ -3011,14 +3531,14 @@ async function listVariables(api, projectConfig, options) {
3011
3531
  error("No project specified. Use --project or run `envpilot init` first.");
3012
3532
  process.exit(1);
3013
3533
  }
3014
- let metaProjectRole;
3534
+ let meta;
3015
3535
  const variables = await withSpinner("Fetching variables...", async () => {
3016
3536
  const params = { projectId };
3017
3537
  if (environment) {
3018
3538
  params.environment = environment;
3019
3539
  }
3020
3540
  const response = await api.get("/api/cli/variables", params);
3021
- metaProjectRole = response.meta?.projectRole;
3541
+ meta = response.meta;
3022
3542
  return response.data || [];
3023
3543
  });
3024
3544
  const tagFilter = options.tag?.toLowerCase();
@@ -3050,7 +3570,7 @@ async function listVariables(api, projectConfig, options) {
3050
3570
  value: options.showValues ? variable.value : maskValue(variable.value),
3051
3571
  sensitive: variable.isSensitive ? chalk8.yellow("\u25CF") : "",
3052
3572
  tags: hasTags ? variable.tags?.map((t) => t.name).join(", ") || chalk8.dim("-") : "",
3053
- version: `v${variable.version}`
3573
+ version: typeof variable.version === "number" ? `v${variable.version}` : ""
3054
3574
  })),
3055
3575
  [
3056
3576
  { key: "key", header: "Key" },
@@ -3062,16 +3582,14 @@ async function listVariables(api, projectConfig, options) {
3062
3582
  );
3063
3583
  console.log();
3064
3584
  console.log(chalk8.dim(`Total: ${filtered.length} variables`));
3065
- const role = getRole();
3066
- if (role) {
3067
- console.log(chalk8.dim(`Your org role: ${formatRole(role)}`));
3068
- }
3069
- if (metaProjectRole) {
3585
+ console.log(chalk8.dim(`Your org role: ${formatRoleLabel(getUnifiedRole())}`));
3586
+ if (meta?.scopeRestricted && meta.environmentScope?.length) {
3070
3587
  console.log(
3071
- chalk8.dim(`Your project role: ${formatProjectRole(metaProjectRole)}`)
3588
+ chalk8.dim(
3589
+ `Your access is scoped to ${meta.environmentScope.join(", ")}; variables in other environments are withheld.`
3590
+ )
3072
3591
  );
3073
- }
3074
- if (role === "member" || metaProjectRole === "viewer") {
3592
+ } else if (meta?.grantOnly) {
3075
3593
  console.log(
3076
3594
  chalk8.dim("You may only see variables you have been granted access to.")
3077
3595
  );
@@ -3084,6 +3602,23 @@ async function listVariables(api, projectConfig, options) {
3084
3602
  // src/commands/config.ts
3085
3603
  import { Command as Command7 } from "commander";
3086
3604
  import chalk9 from "chalk";
3605
+ var FILE_PROTECTION_VALUES = ["auto", "always", "never"];
3606
+ var ENVIRONMENT_VALUES = ["development", "staging", "production"];
3607
+ function updateActiveProjectEntry(updates) {
3608
+ const v2 = readProjectConfigV2();
3609
+ if (!v2) {
3610
+ error(
3611
+ "No project initialized in this directory. Run `envpilot init` first."
3612
+ );
3613
+ process.exit(1);
3614
+ }
3615
+ const active = getActiveProject(v2);
3616
+ if (!active) {
3617
+ error("No active project found in this directory.");
3618
+ process.exit(1);
3619
+ }
3620
+ writeProjectConfigV2(updateProjectInConfig(v2, active.projectId, updates));
3621
+ }
3087
3622
  var configCommand = new Command7("config").description("Manage CLI configuration").argument("[action]", "Action: get, set, list, path, reset").argument("[key]", "Config key (for get/set)").argument("[value]", "Config value (for set)").action(async (action, key, value) => {
3088
3623
  try {
3089
3624
  switch (action) {
@@ -3127,6 +3662,10 @@ async function handleGet(key) {
3127
3662
  console.log(" user Current authenticated user");
3128
3663
  console.log(" activeProjectId Currently active project");
3129
3664
  console.log(" activeOrganizationId Currently active organization");
3665
+ console.log(
3666
+ " fileProtection Active project .env protection (auto|always|never)"
3667
+ );
3668
+ console.log(" defaultEnvironment Active project's default environment");
3130
3669
  process.exit(1);
3131
3670
  }
3132
3671
  const config2 = getConfig();
@@ -3147,6 +3686,18 @@ async function handleGet(key) {
3147
3686
  case "activeOrganizationId":
3148
3687
  console.log(config2.activeOrganizationId || chalk9.dim("(not set)"));
3149
3688
  break;
3689
+ case "fileProtection": {
3690
+ const v2 = readProjectConfigV2();
3691
+ const active = v2 ? getActiveProject(v2) : null;
3692
+ console.log(active?.fileProtection ?? "auto");
3693
+ break;
3694
+ }
3695
+ case "defaultEnvironment": {
3696
+ const v2 = readProjectConfigV2();
3697
+ const active = v2 ? getActiveProject(v2) : null;
3698
+ console.log(active?.environment || chalk9.dim("(not set)"));
3699
+ break;
3700
+ }
3150
3701
  default:
3151
3702
  error(`Unknown key: ${key}`);
3152
3703
  process.exit(1);
@@ -3156,8 +3707,7 @@ async function handleSet(key, value) {
3156
3707
  if (!key || value === void 0) {
3157
3708
  error("Missing key or value. Usage: envpilot config set <key> <value>");
3158
3709
  console.log();
3159
- console.log("Settable keys:");
3160
- console.log(" apiUrl API endpoint URL");
3710
+ printSettableKeys();
3161
3711
  process.exit(1);
3162
3712
  }
3163
3713
  switch (key) {
@@ -3173,14 +3723,53 @@ async function handleSet(key, value) {
3173
3723
  success(`Set apiUrl to ${normalized}`);
3174
3724
  break;
3175
3725
  }
3726
+ case "fileProtection": {
3727
+ if (!FILE_PROTECTION_VALUES.includes(
3728
+ value
3729
+ )) {
3730
+ error(
3731
+ `Invalid fileProtection value: ${value}. Expected one of: ${FILE_PROTECTION_VALUES.join(", ")}`
3732
+ );
3733
+ process.exit(1);
3734
+ }
3735
+ updateActiveProjectEntry({
3736
+ fileProtection: value
3737
+ });
3738
+ success(`Set fileProtection to ${value} for the active project`);
3739
+ break;
3740
+ }
3741
+ case "defaultEnvironment": {
3742
+ if (!ENVIRONMENT_VALUES.includes(
3743
+ value
3744
+ )) {
3745
+ error(
3746
+ `Invalid defaultEnvironment value: ${value}. Expected one of: ${ENVIRONMENT_VALUES.join(", ")}`
3747
+ );
3748
+ process.exit(1);
3749
+ }
3750
+ updateActiveProjectEntry({
3751
+ environment: value
3752
+ });
3753
+ success(`Set defaultEnvironment to ${value} for the active project`);
3754
+ break;
3755
+ }
3176
3756
  default:
3177
3757
  error(`Cannot set key: ${key}`);
3178
3758
  console.log();
3179
- console.log("Settable keys:");
3180
- console.log(" apiUrl API endpoint URL");
3759
+ printSettableKeys();
3181
3760
  process.exit(1);
3182
3761
  }
3183
3762
  }
3763
+ function printSettableKeys() {
3764
+ console.log("Settable keys:");
3765
+ console.log(" apiUrl API endpoint URL");
3766
+ console.log(
3767
+ " fileProtection Active project .env protection (auto|always|never)"
3768
+ );
3769
+ console.log(
3770
+ " defaultEnvironment Active project default environment (development|staging|production)"
3771
+ );
3772
+ }
3184
3773
  async function handleList() {
3185
3774
  const config2 = getConfig();
3186
3775
  const projectConfig = readProjectConfig();
@@ -3217,8 +3806,8 @@ async function handlePath() {
3217
3806
  ]);
3218
3807
  }
3219
3808
  async function handleReset() {
3220
- const inquirer6 = await import("inquirer");
3221
- const { confirm } = await inquirer6.default.prompt([
3809
+ const inquirer7 = await import("inquirer");
3810
+ const { confirm } = await inquirer7.default.prompt([
3222
3811
  {
3223
3812
  type: "confirm",
3224
3813
  name: "confirm",
@@ -3236,20 +3825,43 @@ async function handleReset() {
3236
3825
 
3237
3826
  // src/commands/logout.ts
3238
3827
  import { Command as Command8 } from "commander";
3239
- var logoutCommand = new Command8("logout").description("Log out from Envpilot").action(async () => {
3828
+ var logoutCommand = new Command8("logout").description("Log out from Envpilot").option("--all", "Log out of every authenticated account").action(async (options) => {
3240
3829
  try {
3241
3830
  if (!isAuthenticated()) {
3242
3831
  info("You are not logged in.");
3243
3832
  return;
3244
3833
  }
3245
- const user = getUser();
3834
+ if (options.all) {
3835
+ const accounts = listAccounts();
3836
+ for (const account of accounts) {
3837
+ try {
3838
+ const api2 = new APIClient({ accessToken: account.accessToken });
3839
+ await api2.post("/api/cli/auth?action=revoke", {});
3840
+ } catch {
3841
+ }
3842
+ removeAccount(account.id);
3843
+ }
3844
+ clearAuth();
3845
+ success(`Logged out of all ${accounts.length} accounts.`);
3846
+ return;
3847
+ }
3848
+ const activeAccount = getActiveAccount();
3246
3849
  const api = createAPIClient();
3247
3850
  try {
3248
3851
  await api.post("/api/cli/auth?action=revoke", {});
3249
3852
  } catch {
3250
3853
  }
3251
- clearAuth();
3252
- success(`Logged out${user?.email ? ` from ${user.email}` : ""}`);
3854
+ const { newActiveId } = clearAuth();
3855
+ success(
3856
+ `Logged out${activeAccount?.user.email ? ` ${activeAccount.user.email}` : ""}.`
3857
+ );
3858
+ if (newActiveId) {
3859
+ const remaining = listAccounts();
3860
+ const newActive = remaining.find((a) => a.id === newActiveId);
3861
+ info(`Now active: ${newActive?.user.email ?? newActiveId}`);
3862
+ } else {
3863
+ info("No accounts remaining.");
3864
+ }
3253
3865
  } catch (err) {
3254
3866
  await handleError(err);
3255
3867
  }
@@ -3351,20 +3963,35 @@ import chalk11 from "chalk";
3351
3963
 
3352
3964
  // src/lib/commit-guard.ts
3353
3965
  import {
3354
- existsSync as existsSync3,
3355
- readFileSync as readFileSync3,
3356
- writeFileSync as writeFileSync3,
3357
- mkdirSync,
3966
+ existsSync as existsSync4,
3967
+ readFileSync as readFileSync4,
3968
+ writeFileSync as writeFileSync4,
3969
+ mkdirSync as mkdirSync2,
3358
3970
  chmodSync as chmodSync2,
3359
- unlinkSync as unlinkSync2,
3360
- statSync
3971
+ unlinkSync as unlinkSync3,
3972
+ renameSync as renameSync3,
3973
+ statSync as statSync2
3361
3974
  } from "fs";
3362
- import { join as join3, resolve as resolve2 } from "path";
3975
+ import { join as join4, resolve as resolve2 } from "path";
3363
3976
  import { execSync as execSync2 } from "child_process";
3977
+ function atomicWriteFileSync3(filePath, content, mode) {
3978
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
3979
+ try {
3980
+ writeFileSync4(tmpPath, content, "utf-8");
3981
+ chmodSync2(tmpPath, mode);
3982
+ renameSync3(tmpPath, filePath);
3983
+ } catch (err) {
3984
+ try {
3985
+ if (existsSync4(tmpPath)) unlinkSync3(tmpPath);
3986
+ } catch {
3987
+ }
3988
+ throw err;
3989
+ }
3990
+ }
3364
3991
  var HOOK_START_MARKER = "# ENVPILOT_GUARD_START";
3365
3992
  var HOOK_END_MARKER = "# ENVPILOT_GUARD_END";
3366
3993
  var HOOK_BLOCK = `${HOOK_START_MARKER} - Do not remove. Installed by Envpilot CLI.
3367
- ENV_FILES=$(git diff --cached --name-only | grep -E '(^|/)\\.env($|\\.)' || true)
3994
+ ENV_FILES=$(git diff --cached --name-only | grep -E '(^|/)\\.env($|\\.)' | grep -vE '\\.(example|sample|template|dist)$' || true)
3368
3995
  if [ -n "$ENV_FILES" ]; then
3369
3996
  echo ""
3370
3997
  echo "\\033[1;31mERROR:\\033[0m Envpilot commit guard blocked this commit."
@@ -3380,7 +4007,7 @@ ${HOOK_END_MARKER}`;
3380
4007
  function findGitRoot(startDir) {
3381
4008
  let dir = startDir || process.cwd();
3382
4009
  while (true) {
3383
- if (existsSync3(join3(dir, ".git"))) {
4010
+ if (existsSync4(join4(dir, ".git"))) {
3384
4011
  return dir;
3385
4012
  }
3386
4013
  const parent = resolve2(dir, "..");
@@ -3391,9 +4018,9 @@ function findGitRoot(startDir) {
3391
4018
  }
3392
4019
  }
3393
4020
  function resolveGitDir(repoRoot) {
3394
- const gitPath = join3(repoRoot, ".git");
4021
+ const gitPath = join4(repoRoot, ".git");
3395
4022
  try {
3396
- const stat = statSync(gitPath);
4023
+ const stat = statSync2(gitPath);
3397
4024
  if (stat.isDirectory()) {
3398
4025
  return gitPath;
3399
4026
  }
@@ -3401,12 +4028,12 @@ function resolveGitDir(repoRoot) {
3401
4028
  return gitPath;
3402
4029
  }
3403
4030
  try {
3404
- const content = readFileSync3(gitPath, "utf-8").trim();
4031
+ const content = readFileSync4(gitPath, "utf-8").trim();
3405
4032
  const match = content.match(/^gitdir:\s*(.+)$/);
3406
4033
  if (match) {
3407
4034
  const gitdir = resolve2(repoRoot, match[1]);
3408
4035
  const commonDir = resolve2(gitdir, "..", "..");
3409
- if (existsSync3(join3(commonDir, "hooks")) || existsSync3(commonDir)) {
4036
+ if (existsSync4(join4(commonDir, "hooks")) || existsSync4(commonDir)) {
3410
4037
  return commonDir;
3411
4038
  }
3412
4039
  return gitdir;
@@ -3428,7 +4055,7 @@ function getHooksDir(repoRoot) {
3428
4055
  } catch {
3429
4056
  }
3430
4057
  const gitDir = resolveGitDir(repoRoot);
3431
- return join3(gitDir, "hooks");
4058
+ return join4(gitDir, "hooks");
3432
4059
  }
3433
4060
  function installCommitGuard(repoRoot) {
3434
4061
  const root = repoRoot || findGitRoot();
@@ -3441,19 +4068,18 @@ function installCommitGuard(repoRoot) {
3441
4068
  }
3442
4069
  try {
3443
4070
  const hooksDir = getHooksDir(root);
3444
- const hookPath = join3(hooksDir, "pre-commit");
3445
- mkdirSync(hooksDir, { recursive: true });
4071
+ const hookPath = join4(hooksDir, "pre-commit");
4072
+ mkdirSync2(hooksDir, { recursive: true });
3446
4073
  let existingContent = "";
3447
4074
  try {
3448
- existingContent = readFileSync3(hookPath, "utf-8");
4075
+ existingContent = readFileSync4(hookPath, "utf-8");
3449
4076
  } catch {
3450
4077
  }
3451
4078
  if (existingContent.includes(HOOK_START_MARKER)) {
3452
4079
  const startIdx = existingContent.indexOf(HOOK_START_MARKER);
3453
4080
  const endIdx = existingContent.indexOf(HOOK_END_MARKER) + HOOK_END_MARKER.length;
3454
4081
  const updated = existingContent.substring(0, startIdx) + HOOK_BLOCK + existingContent.substring(endIdx);
3455
- writeFileSync3(hookPath, updated, "utf-8");
3456
- chmodSync2(hookPath, 493);
4082
+ atomicWriteFileSync3(hookPath, updated, 493);
3457
4083
  return {
3458
4084
  installed: true,
3459
4085
  hookPath,
@@ -3466,8 +4092,7 @@ function installCommitGuard(repoRoot) {
3466
4092
  } else {
3467
4093
  newContent = "#!/bin/sh\n\n" + HOOK_BLOCK + "\n";
3468
4094
  }
3469
- writeFileSync3(hookPath, newContent, "utf-8");
3470
- chmodSync2(hookPath, 493);
4095
+ atomicWriteFileSync3(hookPath, newContent, 493);
3471
4096
  return {
3472
4097
  installed: true,
3473
4098
  hookPath,
@@ -3483,6 +4108,21 @@ function installCommitGuard(repoRoot) {
3483
4108
  }
3484
4109
 
3485
4110
  // src/commands/sync.ts
4111
+ function accessFromMeta2(meta, variables) {
4112
+ const role = normalizeOrgRole(meta?.unifiedRole ?? meta?.role);
4113
+ return {
4114
+ role,
4115
+ // Owners are implicitly assigned to every project. The legacy server sends
4116
+ // no `assigned`/`projectRole` for owners, so fall back to true for them.
4117
+ assigned: role === "owner" || (meta?.assigned ?? (meta?.projectRole !== null && meta?.projectRole !== void 0)),
4118
+ environmentScope: meta?.environmentScope ?? null,
4119
+ hasWriteAccess: meta?.hasWriteAccess ?? variables.some((v) => v.access === "write")
4120
+ };
4121
+ }
4122
+ function orgUnifiedRole3(org) {
4123
+ const unified = org.unifiedRole;
4124
+ return normalizeOrgRole(unified ?? org.role);
4125
+ }
3486
4126
  var syncCommand = new Command10("sync").description(
3487
4127
  "Login, select project, pull variables, and protect files \u2014 all in one command"
3488
4128
  ).option("-o, --organization <id>", "Organization ID").option("-p, --project <id>", "Project ID or name").option(
@@ -3546,9 +4186,7 @@ var syncCommand = new Command10("sync").description(
3546
4186
  environment = selection.selectedEnvironment;
3547
4187
  projectName = selection.selectedProject.name;
3548
4188
  organizationName = selection.selectedOrg.name;
3549
- if (selection.selectedOrg.role) {
3550
- setRole(selection.selectedOrg.role);
3551
- }
4189
+ setUnifiedRole(orgUnifiedRole3(selection.selectedOrg));
3552
4190
  writeProjectConfigV2({
3553
4191
  version: 1,
3554
4192
  activeProjectId: projectId,
@@ -3577,7 +4215,7 @@ var syncCommand = new Command10("sync").description(
3577
4215
  setActiveProjectId(projectId);
3578
4216
  }
3579
4217
  console.log();
3580
- let metaProjectRole;
4218
+ let meta;
3581
4219
  const variables = await withSpinner(
3582
4220
  `Fetching ${chalk11.bold(environment)} variables...`,
3583
4221
  async () => {
@@ -3587,11 +4225,16 @@ var syncCommand = new Command10("sync").description(
3587
4225
  environment,
3588
4226
  ...organizationId && { organizationId }
3589
4227
  });
3590
- metaProjectRole = response.meta?.projectRole;
4228
+ meta = response.meta;
3591
4229
  return response.data || [];
3592
4230
  }
3593
4231
  );
3594
4232
  const outputPath = getEnvPathForEnvironment(environment);
4233
+ if (meta?.scopeRestricted && meta.environmentScope?.length) {
4234
+ info(
4235
+ `Your access is scoped to ${meta.environmentScope.join(", ")}; variables in other environments are withheld.`
4236
+ );
4237
+ }
3595
4238
  if (variables.length === 0) {
3596
4239
  warning(`No variables found for ${environment} environment.`);
3597
4240
  console.log();
@@ -3628,15 +4271,21 @@ var syncCommand = new Command10("sync").description(
3628
4271
  }
3629
4272
  }
3630
4273
  writeEnvFile(outputPath, remoteVars, { sort: true, comments });
3631
- const role = getRole();
3632
- applyFileProtection(outputPath, role, metaProjectRole);
4274
+ const access = accessFromMeta2(meta, variables);
4275
+ const protection = existingConfig?.projects.find((p) => p.projectId === projectId)?.fileProtection ?? "auto";
4276
+ applyFileProtection(outputPath, access, protection);
3633
4277
  success(
3634
4278
  `Synced ${variables.length} variables to ${chalk11.bold(outputPath)}`
3635
4279
  );
3636
- const isProtected = role !== "admin" && role !== "team_lead" && metaProjectRole !== "manager";
3637
- if (isProtected) {
4280
+ if (meta?.grantOnly) {
3638
4281
  info(
3639
- `File is read-only (your role: ${role || metaProjectRole || "member"}).`
4282
+ "You have grant-only access to this project. You only see variables explicitly shared with you."
4283
+ );
4284
+ }
4285
+ const writable = protection === "never" || protection !== "always" && isFileWritable(access);
4286
+ if (!writable) {
4287
+ info(
4288
+ `File is read-only (your role: ${formatRoleLabel(access.role)}${access.assigned ? "" : ", not assigned to this project"}).`
3640
4289
  );
3641
4290
  }
3642
4291
  console.log();
@@ -3784,6 +4433,7 @@ var whoamiCommand = new Command12("whoami").description("Show the current authen
3784
4433
  ["Name", remoteUser.name || localUser?.name],
3785
4434
  ["API URL", getApiUrl()],
3786
4435
  ["Active Organization", getActiveOrganizationId()],
4436
+ ["Org Role", formatRoleLabel(getUnifiedRole())],
3787
4437
  ["Active Project", getActiveProjectId()],
3788
4438
  [
3789
4439
  "Linked Project",
@@ -3791,6 +4441,15 @@ var whoamiCommand = new Command12("whoami").description("Show the current authen
3791
4441
  ],
3792
4442
  ["Environment", activeProject?.environment]
3793
4443
  ]);
4444
+ const accounts = listAccounts();
4445
+ if (accounts.length > 1) {
4446
+ blank();
4447
+ console.log(
4448
+ chalk13.dim(
4449
+ `Accounts: ${accounts.length} (use \`envpilot accounts\` to switch)`
4450
+ )
4451
+ );
4452
+ }
3794
4453
  blank();
3795
4454
  console.log(chalk13.dim("Token verified against the CLI auth endpoint."));
3796
4455
  } catch (err) {
@@ -3798,110 +4457,120 @@ var whoamiCommand = new Command12("whoami").description("Show the current authen
3798
4457
  }
3799
4458
  });
3800
4459
 
3801
- // src/commands/run.ts
4460
+ // src/commands/accounts.ts
3802
4461
  import { Command as Command13 } from "commander";
3803
- import { spawn as spawn2 } from "child_process";
3804
4462
  import chalk14 from "chalk";
3805
-
3806
- // src/lib/variables-cache.ts
3807
- import { createHash } from "crypto";
3808
- import {
3809
- readFileSync as readFileSync4,
3810
- writeFileSync as writeFileSync4,
3811
- mkdirSync as mkdirSync2,
3812
- existsSync as existsSync4,
3813
- readdirSync,
3814
- unlinkSync as unlinkSync3,
3815
- statSync as statSync2
3816
- } from "fs";
3817
- import { join as join4, dirname as dirname2 } from "path";
3818
- function getCacheDir() {
3819
- return join4(dirname2(getConfigPath()), "run-cache");
3820
- }
3821
- function getCacheKey(projectId, environment, organizationId) {
3822
- const tokenSlice = (getAccessToken() ?? "").slice(0, 16);
3823
- return createHash("sha256").update(`${projectId}:${environment}:${organizationId}:${tokenSlice}`).digest("hex").slice(0, 20);
4463
+ function resolveAccount(accounts, identifier) {
4464
+ const normalized = identifier.trim().toLowerCase();
4465
+ return accounts.find(
4466
+ (account) => account.id === identifier || account.user.email.toLowerCase() === normalized
4467
+ );
3824
4468
  }
3825
- function getCachePath(key) {
3826
- return join4(getCacheDir(), `${key}.json`);
4469
+ function listAvailableEmails(accounts) {
4470
+ console.log();
4471
+ console.log("Available accounts:");
4472
+ for (const account of accounts) {
4473
+ console.log(` ${account.user.email}`);
4474
+ }
3827
4475
  }
3828
- function computeFingerprint(variables) {
3829
- return createHash("sha256").update(
3830
- variables.map((v) => `${v._id}:${v.version ?? 0}:${v.updatedAt ?? 0}`).sort().join("|")
3831
- ).digest("hex").slice(0, 16);
4476
+ function printAccountsList() {
4477
+ const accounts = listAccounts();
4478
+ const activeId = getActiveAccountId();
4479
+ if (accounts.length === 0) {
4480
+ console.log("No accounts. Run `envpilot login`.");
4481
+ return;
4482
+ }
4483
+ table(
4484
+ accounts.map((account) => ({
4485
+ active: account.id === activeId ? chalk14.green("\u25CF") : "",
4486
+ email: account.user.email,
4487
+ name: account.user.name ?? "",
4488
+ organization: account.activeOrganizationId ?? ""
4489
+ })),
4490
+ [
4491
+ { key: "active", header: "" },
4492
+ { key: "email", header: "Email" },
4493
+ { key: "name", header: "Name" },
4494
+ { key: "organization", header: "Active Org" }
4495
+ ]
4496
+ );
4497
+ blank();
4498
+ if (accounts.length === 1) {
4499
+ console.log(chalk14.dim("1 account (log in again to add another)."));
4500
+ } else {
4501
+ console.log(chalk14.dim(`${accounts.length} accounts.`));
4502
+ }
3832
4503
  }
3833
- function readCache(projectId, environment, organizationId) {
3834
- try {
3835
- const key = getCacheKey(projectId, environment, organizationId);
3836
- const path = getCachePath(key);
3837
- if (!existsSync4(path)) return null;
3838
- const raw = readFileSync4(path, "utf-8");
3839
- const entry = JSON.parse(raw);
3840
- if (entry.apiUrl !== getApiUrl()) return null;
3841
- return entry;
3842
- } catch {
3843
- return null;
4504
+ function switchAccount(identifier) {
4505
+ const accounts = listAccounts();
4506
+ const target = resolveAccount(accounts, identifier);
4507
+ if (!target) {
4508
+ error(`Account not found: ${identifier}`);
4509
+ listAvailableEmails(accounts);
4510
+ process.exit(1);
3844
4511
  }
4512
+ setActiveAccount(target.id);
4513
+ success(`Switched to ${target.user.email}.`);
3845
4514
  }
3846
- function probeCache(projectId, environment, organizationId, ttlSeconds) {
3847
- const entry = readCache(projectId, environment, organizationId);
3848
- if (!entry) return { hit: false };
3849
- const fresh = isFresh(entry, ttlSeconds);
3850
- return { hit: true, fresh, entry };
4515
+ function removeAccountByIdentifier(identifier) {
4516
+ const accounts = listAccounts();
4517
+ const target = resolveAccount(accounts, identifier);
4518
+ if (!target) {
4519
+ error(`Account not found: ${identifier}`);
4520
+ listAvailableEmails(accounts);
4521
+ process.exit(1);
4522
+ }
4523
+ const result = removeAccount(target.id);
4524
+ success(`Removed ${target.user.email}.`);
4525
+ if (result.removedActive) {
4526
+ if (result.newActiveId) {
4527
+ const remaining = listAccounts();
4528
+ const newActive = remaining.find((a) => a.id === result.newActiveId);
4529
+ console.log(
4530
+ chalk14.dim(`Now active: ${newActive?.user.email ?? result.newActiveId}`)
4531
+ );
4532
+ } else {
4533
+ console.log(chalk14.dim("No accounts remaining."));
4534
+ }
4535
+ }
3851
4536
  }
3852
- function writeCache(projectId, environment, organizationId, variables) {
4537
+ var accountsCommand = new Command13("accounts").description("List and manage authenticated accounts").action(async () => {
3853
4538
  try {
3854
- const cacheDir = getCacheDir();
3855
- mkdirSync2(cacheDir, { recursive: true, mode: 448 });
3856
- const key = getCacheKey(projectId, environment, organizationId);
3857
- const path = getCachePath(key);
3858
- const entry = {
3859
- variables,
3860
- fetchedAt: Date.now(),
3861
- projectId,
3862
- environment,
3863
- organizationId,
3864
- apiUrl: getApiUrl(),
3865
- fingerprint: computeFingerprint(variables)
3866
- };
3867
- writeFileSync4(path, JSON.stringify(entry, null, 2), {
3868
- encoding: "utf-8",
3869
- mode: 384
3870
- // owner read/write only — same as .env
3871
- });
3872
- } catch {
4539
+ printAccountsList();
4540
+ } catch (err) {
4541
+ await handleError(err);
3873
4542
  }
3874
- }
3875
- function extendCacheFreshness(projectId, environment, organizationId) {
4543
+ });
4544
+ accountsCommand.command("list").description("List all authenticated accounts").action(async () => {
3876
4545
  try {
3877
- const key = getCacheKey(projectId, environment, organizationId);
3878
- const path = getCachePath(key);
3879
- if (!existsSync4(path)) return;
3880
- const raw = readFileSync4(path, "utf-8");
3881
- const entry = JSON.parse(raw);
3882
- entry.fetchedAt = Date.now();
3883
- writeFileSync4(path, JSON.stringify(entry, null, 2), {
3884
- encoding: "utf-8",
3885
- mode: 384
3886
- });
3887
- } catch {
4546
+ printAccountsList();
4547
+ } catch (err) {
4548
+ await handleError(err);
3888
4549
  }
3889
- }
3890
- function isFresh(entry, ttlSeconds) {
3891
- return Date.now() - entry.fetchedAt < ttlSeconds * 1e3;
3892
- }
3893
- function formatAge(fetchedAt) {
3894
- const ageSec = Math.floor((Date.now() - fetchedAt) / 1e3);
3895
- if (ageSec < 60) return `${ageSec}s ago`;
3896
- const ageMin = Math.floor(ageSec / 60);
3897
- if (ageMin < 60) return `${ageMin}m ago`;
3898
- return `${Math.floor(ageMin / 60)}h ago`;
3899
- }
4550
+ });
4551
+ accountsCommand.command("switch").description("Switch the active account").argument("<identifier>", "Account id or email").action(async (identifier) => {
4552
+ try {
4553
+ switchAccount(identifier);
4554
+ } catch (err) {
4555
+ await handleError(err);
4556
+ }
4557
+ });
4558
+ accountsCommand.command("remove").description("Remove an account").argument("<identifier>", "Account id or email").action(async (identifier) => {
4559
+ try {
4560
+ removeAccountByIdentifier(identifier);
4561
+ } catch (err) {
4562
+ await handleError(err);
4563
+ }
4564
+ });
3900
4565
 
3901
4566
  // src/commands/run.ts
4567
+ import { Command as Command14 } from "commander";
4568
+ import crossSpawn from "cross-spawn";
4569
+ import { constants as osConstants } from "os";
4570
+ import chalk15 from "chalk";
3902
4571
  var DEFAULT_TTL = 3600;
3903
- var runCommand = new Command13("run").description(
3904
- "Run a command with project secrets injected as environment variables (no .env file written)"
4572
+ var runCommand = new Command14("run").description(
4573
+ "Run a command with project secrets injected as environment variables. Secrets are injected into the child process in-memory (no .env file is written), but a decrypted copy is cached locally at ~/.config/envpilot/run-cache (mode 0600, same protection as a .env) to avoid re-fetching on every run. Use --no-cache to skip the cache, or `envpilot logout` to purge it."
3905
4574
  ).argument("[command...]", "Command and arguments to execute").option(
3906
4575
  "-e, --env <environment>",
3907
4576
  "Environment to load (development, staging, production)"
@@ -3919,10 +4588,10 @@ var runCommand = new Command13("run").description(
3919
4588
  "Print the variables that would be injected and exit (no command runs)"
3920
4589
  ).option(
3921
4590
  "--shell",
3922
- "Run the command through the user's shell (enables pipes, &&, $VAR expansion)"
4591
+ 'Run the command string through your shell ($SHELL or /bin/sh -c "..."), enabling pipes, &&, and $VAR expansion. Without this flag the command is executed directly with no shell, so arguments are passed through verbatim (safe from shell injection).'
3923
4592
  ).option("-q, --quiet", "Suppress informational messages").option("--no-cache", "Skip cache and always fetch fresh secrets from server").option(
3924
4593
  "--cache-ttl <seconds>",
3925
- "How long cached secrets stay fresh before a fingerprint check (default: 3600s / 1h)",
4594
+ "How long cached secrets stay fresh before a fingerprint check (default: 3600s / 1h; 0 = always fingerprint-check)",
3926
4595
  String(DEFAULT_TTL)
3927
4596
  ).passThroughOptions().allowExcessArguments().action(async (commandArgs, options) => {
3928
4597
  try {
@@ -3951,10 +4620,11 @@ var runCommand = new Command13("run").description(
3951
4620
  }
3952
4621
  const environment = options.env || project.environment;
3953
4622
  const organizationId = options.organization || project.organizationId;
3954
- const ttl = Math.max(
3955
- 1,
3956
- parseInt(options.cacheTtl ?? String(DEFAULT_TTL), 10) || DEFAULT_TTL
4623
+ const parsedTtl = Number.parseInt(
4624
+ options.cacheTtl ?? String(DEFAULT_TTL),
4625
+ 10
3957
4626
  );
4627
+ const ttl = Number.isFinite(parsedTtl) && parsedTtl >= 0 ? parsedTtl : DEFAULT_TTL;
3958
4628
  let variables;
3959
4629
  let cacheHit = false;
3960
4630
  let cacheAge = "";
@@ -4006,13 +4676,19 @@ var runCommand = new Command13("run").description(
4006
4676
  project.projectId,
4007
4677
  environment,
4008
4678
  organizationId,
4009
- variables
4679
+ variables,
4680
+ serverFingerprint
4010
4681
  );
4011
4682
  }
4012
4683
  } catch {
4013
4684
  variables = probe.entry.variables;
4014
4685
  cacheHit = true;
4015
4686
  cacheAge = formatAge(probe.entry.fetchedAt);
4687
+ if (!options.quiet) {
4688
+ warning(
4689
+ `Using offline cache (age ${cacheAge}) \u2014 could not reach the server to verify freshness.`
4690
+ );
4691
+ }
4016
4692
  }
4017
4693
  } else {
4018
4694
  variables = await doFetch(
@@ -4049,9 +4725,9 @@ var runCommand = new Command13("run").description(
4049
4725
  }
4050
4726
  if (!options.quiet) {
4051
4727
  const injectedCount = Object.keys(secrets).length;
4052
- const cacheTag = cacheHit ? chalk14.dim(` \u26A1 cache (${cacheAge})`) : "";
4728
+ const cacheTag = cacheHit ? chalk15.dim(` \u26A1 cache (${cacheAge})`) : "";
4053
4729
  info(
4054
- `Injected ${chalk14.bold(injectedCount)} ${injectedCount === 1 ? "variable" : "variables"} from ${chalk14.bold(`${project.projectName || project.projectId}/${environment}`)}${cacheTag}`
4730
+ `Injected ${chalk15.bold(injectedCount)} ${injectedCount === 1 ? "variable" : "variables"} from ${chalk15.bold(`${project.projectName || project.projectId}/${environment}`)}${cacheTag}`
4055
4731
  );
4056
4732
  if (overridden.length > 0) {
4057
4733
  warning(
@@ -4061,13 +4737,13 @@ var runCommand = new Command13("run").description(
4061
4737
  }
4062
4738
  console.log();
4063
4739
  }
4064
- await runChild(commandArgs, finalEnv, options);
4740
+ process.exitCode = await runChild(commandArgs, finalEnv, options);
4065
4741
  } catch (err) {
4066
4742
  await handleError(err);
4067
4743
  }
4068
4744
  });
4069
4745
  async function doFetch(project, environment, organizationId, quiet, labelPrefix = "Loading") {
4070
- const label = `${labelPrefix} ${chalk14.bold(environment)} secrets for ${chalk14.bold(project.projectName || project.projectId)}...`;
4746
+ const label = `${labelPrefix} ${chalk15.bold(environment)} secrets for ${chalk15.bold(project.projectName || project.projectId)}...`;
4071
4747
  const api = createAPIClient();
4072
4748
  const { variables, decryptionFailures } = quiet ? await api.listVariables(project.projectId, environment, organizationId) : await withSpinner(
4073
4749
  label,
@@ -4076,7 +4752,7 @@ async function doFetch(project, environment, organizationId, quiet, labelPrefix
4076
4752
  if (decryptionFailures.length > 0) {
4077
4753
  for (const key of decryptionFailures) {
4078
4754
  warning(
4079
- `Could not decrypt ${chalk14.bold(key)} \u2014 skipped (vault error, check server logs)`
4755
+ `Could not decrypt ${chalk15.bold(key)} \u2014 skipped (vault error, check server logs)`
4080
4756
  );
4081
4757
  }
4082
4758
  }
@@ -4086,46 +4762,49 @@ function printInjectionPreview(secrets, project, environment) {
4086
4762
  const keys = Object.keys(secrets).sort();
4087
4763
  console.log();
4088
4764
  console.log(
4089
- chalk14.bold(
4090
- `Would inject ${keys.length} ${keys.length === 1 ? "variable" : "variables"} from ${chalk14.cyan(`${project.projectName || project.projectId}/${environment}`)}:`
4765
+ chalk15.bold(
4766
+ `Would inject ${keys.length} ${keys.length === 1 ? "variable" : "variables"} from ${chalk15.cyan(`${project.projectName || project.projectId}/${environment}`)}:`
4091
4767
  )
4092
4768
  );
4093
4769
  console.log();
4094
4770
  if (keys.length === 0) {
4095
- console.log(chalk14.dim(" (no variables)"));
4771
+ console.log(chalk15.dim(" (no variables)"));
4096
4772
  } else {
4097
4773
  for (const key of keys) {
4098
4774
  const value = secrets[key];
4099
4775
  const masked = maskForPreview(value);
4100
- console.log(` ${chalk14.cyan(key)}=${chalk14.dim(masked)}`);
4776
+ console.log(` ${chalk15.cyan(key)}=${chalk15.dim(masked)}`);
4101
4777
  }
4102
4778
  }
4103
4779
  console.log();
4104
4780
  success("Dry run \u2014 no command executed.");
4105
4781
  }
4106
4782
  function maskForPreview(value) {
4107
- if (value.length <= 6) return "******";
4108
- return `${value.slice(0, 4)}\u2026${value.slice(-2)} (${value.length} chars)`;
4783
+ const len = value.length;
4784
+ const dots = "\u2022".repeat(6);
4785
+ if (len < 12) return `${dots} (${len} chars)`;
4786
+ return `${value.slice(0, 2)}${dots} (${len} chars)`;
4109
4787
  }
4110
4788
  function runChild(commandArgs, env, options) {
4111
4789
  return new Promise((resolve3) => {
4112
- const [command, ...args] = commandArgs;
4790
+ let command;
4791
+ let args;
4792
+ if (options.shell) {
4793
+ command = process.env.SHELL || "/bin/sh";
4794
+ args = ["-c", commandArgs.join(" ")];
4795
+ } else {
4796
+ [command, ...args] = commandArgs;
4797
+ }
4113
4798
  if (!command) {
4114
- process.exit(1);
4799
+ resolve3(1);
4800
+ return;
4115
4801
  }
4116
- const isWindows = process.platform === "win32";
4117
- const useShell = options.shell === true || isWindows;
4118
- const child = spawn2(command, args, {
4802
+ const child = crossSpawn(command, args, {
4119
4803
  stdio: "inherit",
4120
4804
  env,
4121
- shell: useShell
4805
+ shell: false
4122
4806
  });
4123
- const signals = [
4124
- "SIGINT",
4125
- "SIGTERM",
4126
- "SIGHUP",
4127
- "SIGQUIT"
4128
- ];
4807
+ const signals = ["SIGTERM", "SIGHUP"];
4129
4808
  const forward = {};
4130
4809
  for (const sig of signals) {
4131
4810
  const handler = () => {
@@ -4148,67 +4827,66 @@ function runChild(commandArgs, env, options) {
4148
4827
  child.on("error", (err) => {
4149
4828
  cleanup();
4150
4829
  const code = err.code;
4830
+ const displayCommand = options.shell ? commandArgs.join(" ") : command;
4151
4831
  if (code === "ENOENT") {
4152
4832
  error(
4153
- `Command not found: ${command}. Make sure it is installed and on your PATH.`
4833
+ `Command not found: ${displayCommand}. Make sure it is installed and on your PATH.`
4154
4834
  );
4835
+ resolve3(127);
4155
4836
  } else if (code === "EACCES") {
4156
- error(`Permission denied executing: ${command}`);
4837
+ error(`Permission denied executing: ${displayCommand}`);
4838
+ resolve3(126);
4157
4839
  } else {
4158
4840
  error(`Failed to spawn process: ${err.message}`);
4841
+ resolve3(1);
4159
4842
  }
4160
- process.exit(1);
4161
4843
  });
4162
4844
  child.on("exit", (code, signal) => {
4163
4845
  cleanup();
4164
4846
  if (signal) {
4165
- try {
4166
- process.kill(process.pid, signal);
4167
- } catch {
4168
- process.exit(1);
4169
- }
4847
+ const signum = osConstants.signals[signal] ?? 0;
4848
+ resolve3(128 + signum);
4170
4849
  } else {
4171
- process.exit(code ?? 0);
4850
+ resolve3(code ?? 0);
4172
4851
  }
4173
- resolve3();
4174
4852
  });
4175
4853
  });
4176
4854
  }
4177
4855
 
4178
4856
  // src/commands/man.ts
4179
- import { Command as Command14 } from "commander";
4180
- import chalk15 from "chalk";
4857
+ import { Command as Command15 } from "commander";
4858
+ import chalk16 from "chalk";
4181
4859
  function printCommandManual(commandName) {
4182
4860
  const command = findCommandDefinition(commandName);
4183
4861
  if (!command) {
4184
- console.log(chalk15.red(`Unknown command reference: ${commandName}`));
4862
+ console.log(chalk16.red(`Unknown command reference: ${commandName}`));
4185
4863
  console.log();
4186
4864
  console.log("Run `envpilot man` to see all supported commands.");
4187
4865
  process.exit(1);
4188
4866
  }
4189
- console.log(chalk15.bold(formatArgv(command.argv)));
4867
+ console.log(chalk16.bold(formatArgv(command.argv)));
4190
4868
  if (command.args) {
4191
- console.log(chalk15.dim(command.args));
4869
+ console.log(chalk16.dim(command.args));
4192
4870
  }
4193
4871
  blank();
4194
4872
  console.log(command.description);
4195
4873
  blank();
4196
- console.log(chalk15.green("Examples"));
4874
+ console.log(chalk16.green("Examples"));
4197
4875
  for (const example of command.examples) {
4198
- console.log(` ${chalk15.cyan(formatArgv(example))}`);
4876
+ console.log(` ${chalk16.cyan(formatArgv(example))}`);
4199
4877
  }
4200
4878
  blank();
4201
- console.log(chalk15.green("Notes"));
4879
+ console.log(chalk16.green("Notes"));
4202
4880
  for (const note of command.notes) {
4203
4881
  console.log(` - ${note}`);
4204
4882
  }
4205
4883
  }
4206
4884
  function printManualIndex(commands) {
4207
- console.log(chalk15.bold("ENVPILOT(1)"));
4885
+ console.log(chalk16.bold("ENVPILOT(1)"));
4208
4886
  console.log("TypeScript CLI manual");
4209
4887
  blank();
4210
4888
  console.log(
4211
- `Total supported top-level commands: ${chalk15.green(String(CLI_COMMAND_COUNT))}`
4889
+ `Total supported top-level commands: ${chalk16.green(String(CLI_COMMAND_COUNT))}`
4212
4890
  );
4213
4891
  blank();
4214
4892
  console.log(
@@ -4216,15 +4894,15 @@ function printManualIndex(commands) {
4216
4894
  );
4217
4895
  blank();
4218
4896
  line();
4219
- console.log(chalk15.green("Commands"));
4897
+ console.log(chalk16.green("Commands"));
4220
4898
  for (const command of commands) {
4221
4899
  console.log(
4222
- ` ${chalk15.cyan(formatArgv(command.argv).padEnd(24))} ${chalk15.dim(command.description)}`
4900
+ ` ${chalk16.cyan(formatArgv(command.argv).padEnd(24))} ${chalk16.dim(command.description)}`
4223
4901
  );
4224
4902
  }
4225
4903
  blank();
4226
4904
  line();
4227
- console.log(chalk15.green("Security"));
4905
+ console.log(chalk16.green("Security"));
4228
4906
  console.log(" - `.env*` is ignored by the repository `.gitignore`.");
4229
4907
  console.log(
4230
4908
  " - `envpilot init` and `envpilot sync` ensure env files are added to `.gitignore` locally."
@@ -4237,7 +4915,7 @@ function printManualIndex(commands) {
4237
4915
  );
4238
4916
  blank();
4239
4917
  line();
4240
- console.log(chalk15.green("Usage"));
4918
+ console.log(chalk16.green("Usage"));
4241
4919
  console.log(
4242
4920
  " - `envpilot` or `envpilot ui` opens the interactive terminal UI."
4243
4921
  );
@@ -4246,7 +4924,7 @@ function printManualIndex(commands) {
4246
4924
  );
4247
4925
  }
4248
4926
  function createManCommand(commands) {
4249
- return new Command14("man").description("Show the CLI manual page").argument("[command]", "Optional command to show detailed manual for").action((command) => {
4927
+ return new Command15("man").description("Show the CLI manual page").argument("[command]", "Optional command to show detailed manual for").action((command) => {
4250
4928
  if (command) {
4251
4929
  printCommandManual(command);
4252
4930
  return;
@@ -4256,9 +4934,9 @@ function createManCommand(commands) {
4256
4934
  }
4257
4935
 
4258
4936
  // src/commands/ui.ts
4259
- import { Command as Command15 } from "commander";
4937
+ import { Command as Command16 } from "commander";
4260
4938
  function createUICommand() {
4261
- return new Command15("ui").alias("dashboard").description("Open the interactive Ink-powered terminal UI").action(async () => {
4939
+ return new Command16("ui").alias("dashboard").description("Open the interactive Ink-powered terminal UI").action(async () => {
4262
4940
  if (process.env.ENVPILOT_TUI_CHILD === "1") {
4263
4941
  return;
4264
4942
  }
@@ -4266,6 +4944,443 @@ function createUICommand() {
4266
4944
  });
4267
4945
  }
4268
4946
 
4947
+ // src/commands/request.ts
4948
+ import { Command as Command17 } from "commander";
4949
+ import chalk17 from "chalk";
4950
+ import inquirer6 from "inquirer";
4951
+
4952
+ // src/lib/variable-requests.ts
4953
+ import { z as z3 } from "zod";
4954
+ var ALL_REQUEST_ENVIRONMENTS = [
4955
+ "development",
4956
+ "staging",
4957
+ "production"
4958
+ ];
4959
+ var requestKeySchema = z3.string().min(1, "Key is required").max(100, "Key must be 100 characters or less").regex(
4960
+ /^[A-Z][A-Z0-9_]*$/,
4961
+ "Key must be uppercase, start with a letter, and contain only letters, numbers, and underscores"
4962
+ );
4963
+ var requestValueSchema = z3.string().min(1, "Value is required");
4964
+ var requestDescriptionSchema = z3.string().max(500, "Description must be 500 characters or less");
4965
+ function validateRequestKey(key) {
4966
+ const result = requestKeySchema.safeParse(key);
4967
+ if (result.success) return { valid: true };
4968
+ return {
4969
+ valid: false,
4970
+ error: result.error.issues[0]?.message ?? "Invalid key"
4971
+ };
4972
+ }
4973
+ function validateRequestValue(value) {
4974
+ const result = requestValueSchema.safeParse(value);
4975
+ if (result.success) return { valid: true };
4976
+ return {
4977
+ valid: false,
4978
+ error: result.error.issues[0]?.message ?? "Invalid value"
4979
+ };
4980
+ }
4981
+ function validateRequestDescription(description) {
4982
+ if (description.length === 0) return { valid: true };
4983
+ const result = requestDescriptionSchema.safeParse(description);
4984
+ if (result.success) return { valid: true };
4985
+ return {
4986
+ valid: false,
4987
+ error: result.error.issues[0]?.message ?? "Invalid description"
4988
+ };
4989
+ }
4990
+ function allowedRequestEnvironments(environmentScope) {
4991
+ if (environmentScope == null) {
4992
+ return [...ALL_REQUEST_ENVIRONMENTS];
4993
+ }
4994
+ return ALL_REQUEST_ENVIRONMENTS.filter(
4995
+ (env) => environmentScope.includes(env)
4996
+ );
4997
+ }
4998
+ function buildCreateVariableRequestBody(input) {
4999
+ const description = input.description?.trim();
5000
+ return {
5001
+ projectId: input.projectId,
5002
+ key: input.key.trim(),
5003
+ value: input.value,
5004
+ ...description ? { description } : {},
5005
+ environments: input.environments,
5006
+ isSensitive: input.isSensitive ?? false
5007
+ };
5008
+ }
5009
+ function isRequestEligibleProject(project) {
5010
+ const role = normalizeOrgRole(project.unifiedRole ?? project.role);
5011
+ return role === "developer" && project.assigned === true;
5012
+ }
5013
+ function buildEligibleRequestTargets(projects, orgNameById) {
5014
+ return projects.filter(isRequestEligibleProject).map((p) => ({
5015
+ projectId: p._id,
5016
+ projectName: p.name,
5017
+ organizationId: p.organizationId,
5018
+ organizationName: orgNameById[p.organizationId] ?? p.organizationId,
5019
+ environmentScope: p.environmentScope ?? null
5020
+ }));
5021
+ }
5022
+ function formatProjectChoiceLabel(target) {
5023
+ return `${target.projectName} \u2014 ${target.organizationName}`;
5024
+ }
5025
+ function buildProjectChoices(targets) {
5026
+ return targets.map((t) => ({
5027
+ name: formatProjectChoiceLabel(t),
5028
+ value: t.projectId
5029
+ }));
5030
+ }
5031
+ function formatRequestContextBanner(input) {
5032
+ return `Requesting as ${input.email}
5033
+ Project: ${input.projectName} \xB7 Organization: ${input.organizationName}`;
5034
+ }
5035
+ var SUMMARY_LABEL_WIDTH = 15;
5036
+ function formatRequestSummary(input) {
5037
+ const rows = [
5038
+ ["Key:", input.key],
5039
+ [
5040
+ "Project:",
5041
+ formatProjectChoiceLabel({
5042
+ projectName: input.projectName,
5043
+ organizationName: input.organizationName
5044
+ })
5045
+ ],
5046
+ ["Environments:", input.environments.join(", ")],
5047
+ ["Sensitive:", input.isSensitive ? "yes" : "no"]
5048
+ ];
5049
+ return rows.map(([label, value]) => `${label.padEnd(SUMMARY_LABEL_WIDTH)}${value}`).join("\n");
5050
+ }
5051
+ function formatRequestSuccessMessage(input) {
5052
+ return `Request "${input.key}" submitted for ${input.projectName} (${input.organizationName}) \u2014 pending review.`;
5053
+ }
5054
+ function formatRequestsListHeader(input) {
5055
+ return `Requests for ${formatProjectChoiceLabel(input)}`;
5056
+ }
5057
+ function formatRequestRow(request) {
5058
+ return {
5059
+ key: request.key,
5060
+ environments: request.environments.join(", "),
5061
+ status: request.status,
5062
+ requested: new Date(request.createdAt).toLocaleDateString(),
5063
+ reason: request.reviewReason ?? ""
5064
+ };
5065
+ }
5066
+ function formatRequestRows(requests) {
5067
+ return requests.map(formatRequestRow);
5068
+ }
5069
+
5070
+ // src/commands/request.ts
5071
+ var requestCommand = new Command17("request").description(
5072
+ "Request creation of a new environment variable (developers only \u2014 owners, project managers, and team leads create variables directly)"
5073
+ ).option(
5074
+ "--project <name-or-id>",
5075
+ "Submit the request for a specific linked project"
5076
+ ).action(async (options) => {
5077
+ try {
5078
+ if (!isAuthenticated()) {
5079
+ throw notAuthenticated();
5080
+ }
5081
+ const email = getUser()?.email ?? "unknown account";
5082
+ const api = createAPIClient();
5083
+ const projectPassed = Boolean(options.project);
5084
+ const defaultEntry = resolveDefaultEntry(options.project);
5085
+ let target = null;
5086
+ let scope;
5087
+ if (defaultEntry) {
5088
+ const names = await resolveEntryNames(api, defaultEntry);
5089
+ const defaultTarget = {
5090
+ projectId: defaultEntry.projectId,
5091
+ projectName: names.projectName,
5092
+ organizationId: defaultEntry.organizationId,
5093
+ organizationName: names.organizationName
5094
+ };
5095
+ printBanner(email, defaultTarget);
5096
+ let useDefault = projectPassed;
5097
+ if (!projectPassed) {
5098
+ const { proceed } = await inquirer6.prompt([
5099
+ {
5100
+ type: "confirm",
5101
+ name: "proceed",
5102
+ message: "Request a variable for this project?",
5103
+ default: true
5104
+ }
5105
+ ]);
5106
+ useDefault = proceed;
5107
+ }
5108
+ if (useDefault) {
5109
+ const meta = await fetchProjectMeta(api, defaultEntry);
5110
+ const role = normalizeOrgRole(meta?.unifiedRole ?? meta?.role);
5111
+ if (role !== "developer") {
5112
+ warning(
5113
+ "You have direct write access to this project. Use `envpilot push` or create the variable directly instead of submitting a request."
5114
+ );
5115
+ return;
5116
+ }
5117
+ target = defaultTarget;
5118
+ scope = meta?.environmentScope;
5119
+ }
5120
+ }
5121
+ if (!target) {
5122
+ const picked = await pickRequestTarget(api);
5123
+ if (!picked) {
5124
+ info(
5125
+ "You have direct write access in your projects \u2014 create variables from the dashboard."
5126
+ );
5127
+ process.exit(0);
5128
+ }
5129
+ target = picked;
5130
+ scope = picked.environmentScope;
5131
+ printBanner(email, target);
5132
+ }
5133
+ const envChoices = allowedRequestEnvironments(scope);
5134
+ if (envChoices.length === 0) {
5135
+ error(
5136
+ "Your assignment does not include any environments in this project."
5137
+ );
5138
+ process.exit(1);
5139
+ }
5140
+ const answers = await inquirer6.prompt([
5141
+ {
5142
+ type: "input",
5143
+ name: "key",
5144
+ message: "Variable key (e.g. API_SECRET):",
5145
+ validate: (input) => {
5146
+ const result = validateRequestKey(input);
5147
+ return result.valid || result.error;
5148
+ }
5149
+ },
5150
+ {
5151
+ type: "password",
5152
+ name: "value",
5153
+ message: "Variable value:",
5154
+ mask: "*",
5155
+ validate: (input) => {
5156
+ const result = validateRequestValue(input);
5157
+ return result.valid || result.error;
5158
+ }
5159
+ },
5160
+ {
5161
+ type: "input",
5162
+ name: "description",
5163
+ message: "Description (optional):",
5164
+ default: "",
5165
+ validate: (input) => {
5166
+ const result = validateRequestDescription(input);
5167
+ return result.valid || result.error;
5168
+ }
5169
+ },
5170
+ {
5171
+ type: "checkbox",
5172
+ name: "environments",
5173
+ message: "Environments:",
5174
+ choices: envChoices,
5175
+ validate: (input) => input.length > 0 || "Select at least one environment"
5176
+ },
5177
+ {
5178
+ type: "confirm",
5179
+ name: "isSensitive",
5180
+ message: "Is this value sensitive?",
5181
+ default: true
5182
+ }
5183
+ ]);
5184
+ console.log();
5185
+ console.log(
5186
+ formatRequestSummary({
5187
+ key: answers.key.trim(),
5188
+ projectName: target.projectName,
5189
+ organizationName: target.organizationName,
5190
+ environments: answers.environments,
5191
+ isSensitive: answers.isSensitive
5192
+ })
5193
+ );
5194
+ console.log();
5195
+ const { submit } = await inquirer6.prompt([
5196
+ {
5197
+ type: "confirm",
5198
+ name: "submit",
5199
+ message: "Submit this request?",
5200
+ default: true
5201
+ }
5202
+ ]);
5203
+ if (!submit) {
5204
+ info("Request canceled.");
5205
+ return;
5206
+ }
5207
+ const body = buildCreateVariableRequestBody({
5208
+ projectId: target.projectId,
5209
+ key: answers.key,
5210
+ value: answers.value,
5211
+ description: answers.description,
5212
+ environments: answers.environments,
5213
+ isSensitive: answers.isSensitive
5214
+ });
5215
+ const created = await api.createVariableRequest(body);
5216
+ success(
5217
+ formatRequestSuccessMessage({
5218
+ key: created.key,
5219
+ projectName: target.projectName,
5220
+ organizationName: target.organizationName
5221
+ })
5222
+ );
5223
+ info(`Status: ${created.status} \xB7 Id: ${created._id}`);
5224
+ } catch (err) {
5225
+ if (err instanceof APIError && err.statusCode === 403) {
5226
+ error(err.message);
5227
+ return;
5228
+ }
5229
+ await handleError(err);
5230
+ }
5231
+ });
5232
+ function resolveDefaultEntry(projectOption) {
5233
+ const configV2 = readProjectConfigV2();
5234
+ if (!configV2) {
5235
+ if (projectOption) throw notInitialized();
5236
+ return null;
5237
+ }
5238
+ const project = resolveProject(configV2, projectOption);
5239
+ if (!project) {
5240
+ if (projectOption) {
5241
+ error(`Project not found: ${projectOption}`);
5242
+ console.log();
5243
+ console.log("Linked projects:");
5244
+ for (const p of configV2.projects) {
5245
+ console.log(` ${p.projectName || p.projectId} (${p.environment})`);
5246
+ }
5247
+ process.exit(1);
5248
+ }
5249
+ return null;
5250
+ }
5251
+ return project;
5252
+ }
5253
+ function printBanner(email, target) {
5254
+ console.log();
5255
+ console.log(
5256
+ chalk17.dim(
5257
+ formatRequestContextBanner({
5258
+ email,
5259
+ projectName: target.projectName,
5260
+ organizationName: target.organizationName
5261
+ })
5262
+ )
5263
+ );
5264
+ console.log();
5265
+ }
5266
+ async function resolveEntryNames(api, entry) {
5267
+ let projectName = entry.projectName;
5268
+ let organizationName = entry.organizationName;
5269
+ if (!projectName) {
5270
+ try {
5271
+ const project = await api.getProject(entry.projectId);
5272
+ projectName = project.name;
5273
+ } catch {
5274
+ }
5275
+ }
5276
+ if (!organizationName) {
5277
+ try {
5278
+ const orgs = await api.listOrganizations();
5279
+ organizationName = orgs.find((o) => o._id === entry.organizationId)?.name ?? "";
5280
+ } catch {
5281
+ }
5282
+ }
5283
+ return {
5284
+ projectName: projectName || entry.projectId,
5285
+ organizationName: organizationName || entry.organizationId
5286
+ };
5287
+ }
5288
+ async function fetchProjectMeta(api, entry) {
5289
+ const response = await api.get("/api/cli/variables", {
5290
+ projectId: entry.projectId,
5291
+ ...entry.organizationId && { organizationId: entry.organizationId }
5292
+ });
5293
+ return response.meta;
5294
+ }
5295
+ async function pickRequestTarget(api) {
5296
+ const targets = await withSpinner(
5297
+ "Finding projects you can request for...",
5298
+ async () => {
5299
+ const orgs = await api.listOrganizations();
5300
+ const orgNameById = Object.fromEntries(orgs.map((o) => [o._id, o.name]));
5301
+ const perOrg = await Promise.all(
5302
+ orgs.map((org) => api.listProjects(org._id))
5303
+ );
5304
+ return perOrg.flatMap(
5305
+ (projects) => buildEligibleRequestTargets(projects, orgNameById)
5306
+ );
5307
+ }
5308
+ );
5309
+ if (targets.length === 0) return null;
5310
+ const { projectId } = await inquirer6.prompt([
5311
+ {
5312
+ type: "list",
5313
+ name: "projectId",
5314
+ message: "Select a project to request for:",
5315
+ choices: buildProjectChoices(targets)
5316
+ }
5317
+ ]);
5318
+ return targets.find((t) => t.projectId === projectId) ?? null;
5319
+ }
5320
+
5321
+ // src/commands/requests.ts
5322
+ import { Command as Command18 } from "commander";
5323
+ var requestsCommand = new Command18("requests").description("List variable requests for a project").option(
5324
+ "--project <name-or-id>",
5325
+ "List requests for a specific linked project"
5326
+ ).option(
5327
+ "--status <status>",
5328
+ "Filter by status: pending, approved, rejected, canceled"
5329
+ ).action(async (options) => {
5330
+ try {
5331
+ if (!isAuthenticated()) {
5332
+ throw notAuthenticated();
5333
+ }
5334
+ const configV2 = readProjectConfigV2();
5335
+ if (!configV2) throw notInitialized();
5336
+ const project = resolveProject(configV2, options.project);
5337
+ if (!project) {
5338
+ error(`Project not found: ${options.project}`);
5339
+ console.log();
5340
+ console.log("Linked projects:");
5341
+ for (const p of configV2.projects) {
5342
+ console.log(` ${p.projectName || p.projectId} (${p.environment})`);
5343
+ }
5344
+ process.exit(1);
5345
+ }
5346
+ let status;
5347
+ if (options.status) {
5348
+ const parsed = variableRequestStatusSchema.safeParse(options.status);
5349
+ if (!parsed.success) {
5350
+ error(
5351
+ `Invalid status: ${options.status}. Must be one of pending, approved, rejected, canceled.`
5352
+ );
5353
+ process.exit(1);
5354
+ }
5355
+ status = parsed.data;
5356
+ }
5357
+ const api = createAPIClient();
5358
+ const requests = await withSpinner(
5359
+ "Fetching variable requests...",
5360
+ () => api.listVariableRequests(project.projectId, status)
5361
+ );
5362
+ header(
5363
+ formatRequestsListHeader({
5364
+ projectName: project.projectName || project.projectId,
5365
+ organizationName: project.organizationName || project.organizationId
5366
+ })
5367
+ );
5368
+ if (requests.length === 0) {
5369
+ info("No variable requests found.");
5370
+ return;
5371
+ }
5372
+ table(formatRequestRows(requests), [
5373
+ { key: "key", header: "KEY" },
5374
+ { key: "environments", header: "ENVIRONMENTS" },
5375
+ { key: "status", header: "STATUS" },
5376
+ { key: "requested", header: "REQUESTED" },
5377
+ { key: "reason", header: "REASON" }
5378
+ ]);
5379
+ } catch (err) {
5380
+ await handleError(err);
5381
+ }
5382
+ });
5383
+
4269
5384
  // src/lib/command-catalog.ts
4270
5385
  function formatArgv(argv) {
4271
5386
  return ["envpilot", ...argv].join(" ").trim();
@@ -4395,19 +5510,55 @@ var COMMAND_CATALOG = [
4395
5510
  id: "push",
4396
5511
  title: "Push variables",
4397
5512
  category: "Sync",
4398
- description: "Upload local variables back to Envpilot with approval-aware write behavior.",
5513
+ description: "Upload local variables back to Envpilot, writing only the keys you have access to.",
4399
5514
  argv: ["push"],
4400
5515
  args: "[--env <environment>] [--file <path>] [--merge|--replace]",
4401
5516
  examples: [["push"], ["push", "--replace"]],
4402
5517
  websiteSurface: "Maps to `/api/cli/variables` and `/api/cli/variables/bulk` for writes.",
4403
5518
  notes: [
4404
- "Managers/admins write directly; lower roles create requests for approval.",
5519
+ "Owners, project managers, and team leads write across the project; developers write only the variables they hold a write grant for.",
5520
+ "Keys you cannot write are skipped \u2014 push does not create approval requests.",
4405
5521
  "Compares local and remote variables before applying changes."
4406
5522
  ],
4407
5523
  keywords: ["upload", "bulk", "merge", "replace"],
4408
5524
  topLevel: true,
4409
5525
  createCommand: () => pushCommand
4410
5526
  },
5527
+ {
5528
+ id: "request",
5529
+ title: "Request a new variable",
5530
+ category: "Sync",
5531
+ description: "Submit a request to create a new environment variable for review (developers only).",
5532
+ argv: ["request"],
5533
+ args: "[--project <name-or-id>]",
5534
+ examples: [["request"], ["request", "--project", "api"]],
5535
+ websiteSurface: "Maps to `/api/cli/variable-requests` (POST).",
5536
+ notes: [
5537
+ "Only assigned developers can submit requests \u2014 owners, project managers, and team leads create variables directly.",
5538
+ "Environment choices are limited to the developer's assigned environment scope.",
5539
+ "An owner, project manager, or team lead must approve the request before the variable is created."
5540
+ ],
5541
+ keywords: ["request", "approval", "developer", "create"],
5542
+ topLevel: true,
5543
+ createCommand: () => requestCommand
5544
+ },
5545
+ {
5546
+ id: "requests",
5547
+ title: "List variable requests",
5548
+ category: "Browse",
5549
+ description: "List pending and past variable requests for a project.",
5550
+ argv: ["requests"],
5551
+ args: "[--project <name-or-id>] [--status <status>]",
5552
+ examples: [["requests"], ["requests", "--status", "pending"]],
5553
+ websiteSurface: "Maps to `/api/cli/variable-requests` (GET).",
5554
+ notes: [
5555
+ "Reviewers (owner, assigned project manager/team lead) see every request for the project.",
5556
+ "Developers see only their own requests."
5557
+ ],
5558
+ keywords: ["requests", "approval", "review", "pending"],
5559
+ topLevel: true,
5560
+ createCommand: () => requestsCommand
5561
+ },
4411
5562
  {
4412
5563
  id: "run",
4413
5564
  title: "Run command with secrets",
@@ -4577,6 +5728,27 @@ var COMMAND_CATALOG = [
4577
5728
  topLevel: true,
4578
5729
  createCommand: () => whoamiCommand
4579
5730
  },
5731
+ {
5732
+ id: "accounts",
5733
+ title: "Manage accounts",
5734
+ category: "Account",
5735
+ description: "List authenticated accounts and switch or remove them without logging out.",
5736
+ argv: ["accounts"],
5737
+ args: "[list|switch <identifier>|remove <identifier>]",
5738
+ examples: [
5739
+ ["accounts"],
5740
+ ["accounts", "switch", "you@example.com"],
5741
+ ["accounts", "remove", "you@example.com"]
5742
+ ],
5743
+ websiteSurface: "Local multi-account state \u2014 each `envpilot login` adds an account instead of replacing the current session.",
5744
+ notes: [
5745
+ "Identifiers can be an account id or the account's email (case-insensitive).",
5746
+ "Switching accounts does not log anyone out; use `envpilot logout` to remove the active session."
5747
+ ],
5748
+ keywords: ["accounts", "multi-account", "switch", "remove", "identity"],
5749
+ topLevel: true,
5750
+ createCommand: () => accountsCommand
5751
+ },
4580
5752
  {
4581
5753
  id: "config",
4582
5754
  title: "Config",