@envpilot/cli 1.7.2 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ function initSentry() {
7
7
  Sentry.init({
8
8
  dsn,
9
9
  environment: "cli",
10
- release: true ? "1.11.0" : "0.0.0",
10
+ release: true ? "1.9.1" : "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-3LJYJ4RK.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
  );
@@ -894,15 +1260,19 @@ async function performLogin(options) {
894
1260
  "Authentication succeeded but the server did not return session tokens. This is likely a server-side issue. Please try again or contact support."
895
1261
  );
896
1262
  }
897
- setAccessToken(pollResponse.accessToken);
898
- setRefreshToken(pollResponse.refreshToken);
899
- if (pollResponse.user) {
900
- setUser({
1263
+ const accountId = pollResponse.user?.id ?? `session-${pollResponse.accessToken.slice(0, 8)}`;
1264
+ const account = {
1265
+ id: accountId,
1266
+ user: pollResponse.user ? {
901
1267
  id: pollResponse.user.id,
902
1268
  email: pollResponse.user.email,
903
1269
  name: pollResponse.user.name
904
- });
905
- }
1270
+ } : { id: accountId, email: `${accountId}@cli.local` },
1271
+ accessToken: pollResponse.accessToken,
1272
+ refreshToken: pollResponse.refreshToken
1273
+ };
1274
+ upsertAccount(account);
1275
+ setActiveAccount(account.id);
906
1276
  console.log();
907
1277
  success(`Logged in as ${chalk3.bold(pollResponse.user?.email)}`);
908
1278
  return { email: pollResponse.user?.email || "" };
@@ -926,6 +1296,16 @@ var loginCommand = new Command("login").description("Authenticate with Envpilot"
926
1296
  await performLogin({
927
1297
  browser: options.browser !== false
928
1298
  });
1299
+ const activeAccount = getActiveAccount();
1300
+ if (activeAccount) {
1301
+ success(`Signed in as ${activeAccount.user.email}.`);
1302
+ }
1303
+ const accounts = listAccounts();
1304
+ if (accounts.length > 1) {
1305
+ info(
1306
+ `You have ${accounts.length} accounts \u2014 use \`envpilot accounts\` to list/switch.`
1307
+ );
1308
+ }
929
1309
  console.log();
930
1310
  console.log("Next steps:");
931
1311
  info(" envpilot init Initialize a project in the current directory");
@@ -943,9 +1323,15 @@ import chalk4 from "chalk";
943
1323
  import inquirer from "inquirer";
944
1324
 
945
1325
  // src/lib/project-config.ts
946
- import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
1326
+ import {
1327
+ readFileSync as readFileSync2,
1328
+ writeFileSync as writeFileSync2,
1329
+ existsSync as existsSync2,
1330
+ unlinkSync as unlinkSync2,
1331
+ renameSync
1332
+ } from "fs";
947
1333
  import { execSync } from "child_process";
948
- import { join } from "path";
1334
+ import { join as join2 } from "path";
949
1335
 
950
1336
  // src/types/index.ts
951
1337
  import { z } from "zod";
@@ -954,12 +1340,25 @@ var userSchema = z.object({
954
1340
  email: z.string().email(),
955
1341
  name: z.string().optional()
956
1342
  });
1343
+ var accountSchema = z.object({
1344
+ id: z.string(),
1345
+ user: userSchema,
1346
+ accessToken: z.string(),
1347
+ refreshToken: z.string().optional(),
1348
+ // Stored role string (legacy or unified); normalized on read.
1349
+ role: z.string().optional(),
1350
+ // Per-account active org/project selection.
1351
+ activeOrganizationId: z.string().optional(),
1352
+ activeProjectId: z.string().optional()
1353
+ });
957
1354
  var organizationSchema = z.object({
958
1355
  _id: z.string(),
959
1356
  name: z.string(),
960
1357
  slug: z.string(),
961
1358
  tier: z.enum(["free", "pro"]),
962
- role: z.string().optional()
1359
+ role: z.string().optional(),
1360
+ // Unified org role (additive; legacy responses omit it and fall back to role).
1361
+ unifiedRole: z.string().nullable().optional()
963
1362
  });
964
1363
  var projectSchema = z.object({
965
1364
  _id: z.string(),
@@ -970,7 +1369,11 @@ var projectSchema = z.object({
970
1369
  icon: z.string().optional(),
971
1370
  color: z.string().optional(),
972
1371
  userRole: z.string().nullable().optional(),
973
- projectRole: z.string().nullable().optional()
1372
+ projectRole: z.string().nullable().optional(),
1373
+ // Unified-role fields (optional so legacy server responses still parse)
1374
+ unifiedRole: z.string().nullable().optional(),
1375
+ assigned: z.boolean().optional(),
1376
+ environmentScope: z.array(z.string()).nullable().optional()
974
1377
  });
975
1378
  var variableTagSchema = z.object({
976
1379
  _id: z.string(),
@@ -988,21 +1391,49 @@ var variableSchema = z.object({
988
1391
  version: z.number().optional(),
989
1392
  updatedAt: z.number().optional(),
990
1393
  createdAt: z.number().optional(),
991
- tags: z.array(variableTagSchema).optional()
1394
+ tags: z.array(variableTagSchema).optional(),
1395
+ // Per-variable effective access under the unified model. Optional so older
1396
+ // server responses (which omit it) still parse.
1397
+ access: z.enum(["read", "write"]).optional()
992
1398
  });
1399
+ var variablesMetaSchema = z.object({
1400
+ total: z.number().optional(),
1401
+ page: z.number().optional(),
1402
+ limit: z.number().optional(),
1403
+ decryptionFailures: z.array(z.string()).optional(),
1404
+ unifiedRole: z.string().nullable().optional(),
1405
+ assigned: z.boolean().optional(),
1406
+ grantOnly: z.boolean().optional(),
1407
+ environmentScope: z.array(z.string()).nullable().optional(),
1408
+ hasWriteAccess: z.boolean().optional(),
1409
+ scopeRestricted: z.boolean().optional()
1410
+ }).passthrough();
993
1411
  var environmentSchema = z.enum([
994
1412
  "development",
995
1413
  "staging",
996
1414
  "production"
997
1415
  ]);
998
1416
  var cliConfigSchema = z.object({
1417
+ // Global CLI setting — shared across all accounts.
999
1418
  apiUrl: z.string().url(),
1419
+ // Multi-account store. Keyed by account id (== user id).
1420
+ accounts: z.record(z.string(), accountSchema).optional(),
1421
+ // The currently active account id.
1422
+ activeAccountId: z.string().optional(),
1423
+ // --- Legacy single-account fields ---
1424
+ // Retained ONLY so pre-multi-account configs still parse and can be migrated
1425
+ // into `accounts` by migrateLegacyConfig(). After migration these are deleted
1426
+ // so `accounts` is the single source of truth. Do not read/write directly.
1000
1427
  accessToken: z.string().optional(),
1001
1428
  refreshToken: z.string().optional(),
1002
1429
  activeProjectId: z.string().optional(),
1003
1430
  activeOrganizationId: z.string().optional(),
1004
1431
  user: userSchema.optional(),
1005
- role: z.enum(["admin", "team_lead", "member"]).optional()
1432
+ // Stored role string. Kept as a free-form string so both legacy
1433
+ // ("admin"/"team_lead"/"member") and unified
1434
+ // ("owner"/"project_manager"/"team_lead"/"developer") values round-trip.
1435
+ // Normalized on read via getUnifiedRole(); no longer enum-enforced.
1436
+ role: z.string().optional()
1006
1437
  });
1007
1438
  var projectConfigSchema = z.object({
1008
1439
  projectId: z.string(),
@@ -1014,7 +1445,12 @@ var projectEntrySchema = z.object({
1014
1445
  organizationId: z.string(),
1015
1446
  projectName: z.string().default(""),
1016
1447
  organizationName: z.string().default(""),
1017
- environment: environmentSchema.default("development")
1448
+ environment: environmentSchema.default("development"),
1449
+ // How the pulled .env file's permissions are managed for this project:
1450
+ // auto → derive from the caller's resolved access (default)
1451
+ // always → force read-only (0o400)
1452
+ // never → force writable (0o600)
1453
+ fileProtection: z.enum(["auto", "always", "never"]).optional()
1018
1454
  });
1019
1455
  var projectConfigV2Schema = z.object({
1020
1456
  version: z.literal(1),
@@ -1024,17 +1460,30 @@ var projectConfigV2Schema = z.object({
1024
1460
 
1025
1461
  // src/lib/project-config.ts
1026
1462
  var CONFIG_FILE_NAME = ".envpilot";
1463
+ function atomicWriteFileSync(filePath, content) {
1464
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
1465
+ try {
1466
+ writeFileSync2(tmpPath, content, "utf-8");
1467
+ renameSync(tmpPath, filePath);
1468
+ } catch (err) {
1469
+ try {
1470
+ if (existsSync2(tmpPath)) unlinkSync2(tmpPath);
1471
+ } catch {
1472
+ }
1473
+ throw err;
1474
+ }
1475
+ }
1027
1476
  function getProjectConfigPath(directory = process.cwd()) {
1028
- return join(directory, CONFIG_FILE_NAME);
1477
+ return join2(directory, CONFIG_FILE_NAME);
1029
1478
  }
1030
1479
  function hasProjectConfig(directory = process.cwd()) {
1031
- return existsSync(getProjectConfigPath(directory));
1480
+ return existsSync2(getProjectConfigPath(directory));
1032
1481
  }
1033
1482
  function readRawConfig(directory = process.cwd()) {
1034
1483
  const configPath = getProjectConfigPath(directory);
1035
- if (!existsSync(configPath)) return null;
1484
+ if (!existsSync2(configPath)) return null;
1036
1485
  try {
1037
- return JSON.parse(readFileSync(configPath, "utf-8"));
1486
+ return JSON.parse(readFileSync2(configPath, "utf-8"));
1038
1487
  } catch {
1039
1488
  return null;
1040
1489
  }
@@ -1081,7 +1530,7 @@ function readProjectConfigV2(directory = process.cwd()) {
1081
1530
  }
1082
1531
  function writeProjectConfigV2(config2, directory = process.cwd()) {
1083
1532
  const configPath = getProjectConfigPath(directory);
1084
- writeFileSync(configPath, JSON.stringify(config2, null, 2) + "\n", "utf-8");
1533
+ atomicWriteFileSync(configPath, JSON.stringify(config2, null, 2) + "\n");
1085
1534
  }
1086
1535
  function getActiveProject(config2) {
1087
1536
  return config2.projects.find((p) => p.projectId === config2.activeProjectId) || config2.projects[0] || null;
@@ -1174,23 +1623,23 @@ function updateProjectConfig(updates, directory = process.cwd()) {
1174
1623
  }
1175
1624
  function deleteProjectConfig(directory = process.cwd()) {
1176
1625
  const configPath = getProjectConfigPath(directory);
1177
- if (!existsSync(configPath)) return false;
1178
- unlinkSync(configPath);
1626
+ if (!existsSync2(configPath)) return false;
1627
+ unlinkSync2(configPath);
1179
1628
  return true;
1180
1629
  }
1181
1630
  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");
1631
+ const gitignorePath = join2(directory, ".gitignore");
1632
+ if (!existsSync2(gitignorePath)) {
1633
+ atomicWriteFileSync(gitignorePath, ".env\n.env.local\n");
1185
1634
  return;
1186
1635
  }
1187
- const content = readFileSync(gitignorePath, "utf-8");
1636
+ const content = readFileSync2(gitignorePath, "utf-8");
1188
1637
  const lines = content.split("\n");
1189
1638
  if (lines.some((line2) => line2.trim() === ".env")) {
1190
1639
  return;
1191
1640
  }
1192
1641
  const newContent = content.endsWith("\n") ? content + ".env\n" : content + "\n.env\n";
1193
- writeFileSync(gitignorePath, newContent, "utf-8");
1642
+ atomicWriteFileSync(gitignorePath, newContent);
1194
1643
  }
1195
1644
  function getTrackedEnvFiles(directory = process.cwd()) {
1196
1645
  try {
@@ -1206,8 +1655,14 @@ function getTrackedEnvFiles(directory = process.cwd()) {
1206
1655
  }
1207
1656
 
1208
1657
  // 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";
1658
+ import {
1659
+ readFileSync as readFileSync3,
1660
+ writeFileSync as writeFileSync3,
1661
+ existsSync as existsSync3,
1662
+ chmodSync,
1663
+ renameSync as renameSync2
1664
+ } from "fs";
1665
+ import { join as join3 } from "path";
1211
1666
  function parseEnvFile(content) {
1212
1667
  const result = {};
1213
1668
  const lines = content.split("\n");
@@ -1220,7 +1675,7 @@ function parseEnvFile(content) {
1220
1675
  if (equalsIndex === -1) {
1221
1676
  continue;
1222
1677
  }
1223
- const key = line2.substring(0, equalsIndex).trim();
1678
+ const key = line2.substring(0, equalsIndex).trim().replace(/^export\s+/, "");
1224
1679
  let value = line2.substring(equalsIndex + 1);
1225
1680
  value = parseValue(value);
1226
1681
  if (isValidEnvKey(key)) {
@@ -1231,17 +1686,21 @@ function parseEnvFile(content) {
1231
1686
  }
1232
1687
  function parseValue(value) {
1233
1688
  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();
1689
+ const quote = value[0];
1690
+ if (quote === '"' || quote === "'") {
1691
+ const closingIndex = value.indexOf(quote, 1);
1692
+ if (closingIndex !== -1) {
1693
+ const inner = value.slice(1, closingIndex);
1694
+ if (quote === '"') {
1695
+ return inner.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
1696
+ }
1697
+ return inner;
1243
1698
  }
1244
1699
  }
1700
+ const commentIndex = value.indexOf(" #");
1701
+ if (commentIndex !== -1) {
1702
+ value = value.substring(0, commentIndex).trim();
1703
+ }
1245
1704
  return value;
1246
1705
  }
1247
1706
  function isValidEnvKey(key) {
@@ -1293,33 +1752,53 @@ function diffEnvVars(local, remote) {
1293
1752
  return { added, removed, changed, unchanged };
1294
1753
  }
1295
1754
  function readEnvFile(filePath) {
1296
- if (!existsSync2(filePath)) {
1755
+ if (!existsSync3(filePath)) {
1297
1756
  return null;
1298
1757
  }
1299
- const content = readFileSync2(filePath, "utf-8");
1758
+ const content = readFileSync3(filePath, "utf-8");
1300
1759
  return parseEnvFile(content);
1301
1760
  }
1761
+ function atomicWriteFileSync2(filePath, content, mode) {
1762
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
1763
+ try {
1764
+ writeFileSync3(tmpPath, content, { encoding: "utf-8", mode });
1765
+ renameSync2(tmpPath, filePath);
1766
+ } catch (err) {
1767
+ try {
1768
+ if (existsSync3(tmpPath)) chmodSync(tmpPath, 384);
1769
+ } catch {
1770
+ }
1771
+ throw err;
1772
+ }
1773
+ }
1302
1774
  function writeEnvFile(filePath, vars, options) {
1303
1775
  const content = stringifyEnv(vars, options);
1304
- writeFileSync2(filePath, content, "utf-8");
1776
+ atomicWriteFileSync2(filePath, content, 384);
1305
1777
  }
1306
1778
  function getEnvPathForEnvironment(environment, directory = process.cwd()) {
1307
1779
  if (environment === "development") {
1308
- return join2(directory, ".env.local");
1780
+ return join3(directory, ".env.local");
1309
1781
  }
1310
- return join2(directory, `.env.${environment}`);
1782
+ return join3(directory, `.env.${environment}`);
1311
1783
  }
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);
1784
+ function applyFileProtection(filePath, access, protection = "auto") {
1785
+ if (!existsSync3(filePath)) return;
1786
+ if (protection === "never") {
1787
+ chmodSync(filePath, 384);
1788
+ return;
1789
+ }
1790
+ if (protection === "always") {
1791
+ chmodSync(filePath, 256);
1792
+ return;
1319
1793
  }
1794
+ chmodSync(filePath, isFileWritable(access) ? 384 : 256);
1320
1795
  }
1321
1796
 
1322
1797
  // src/commands/init.ts
1798
+ function orgUnifiedRole(org) {
1799
+ const unified = org.unifiedRole;
1800
+ return normalizeOrgRole(unified ?? org.role);
1801
+ }
1323
1802
  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
1803
  "-e, --environment <env>",
1325
1804
  "Default environment (development, staging, production)"
@@ -1368,9 +1847,7 @@ var initCommand = new Command2("init").description("Initialize Envpilot in the c
1368
1847
  });
1369
1848
  setActiveOrganizationId(selectedOrg._id);
1370
1849
  setActiveProjectId(selectedProject._id);
1371
- if (selectedOrg.role) {
1372
- setRole(selectedOrg.role);
1373
- }
1850
+ setUnifiedRole(orgUnifiedRole(selectedOrg));
1374
1851
  ensureEnvInGitignore();
1375
1852
  warnTrackedFiles();
1376
1853
  console.log();
@@ -1382,21 +1859,23 @@ var initCommand = new Command2("init").description("Initialize Envpilot in the c
1382
1859
  });
1383
1860
  async function addProject(existingConfig, options) {
1384
1861
  const api = createAPIClient();
1385
- let role = getRole();
1386
- if (role !== "admin" && role !== "team_lead") {
1862
+ let role = getUnifiedRole();
1863
+ if (roleLevel(role) < ROLE_LEVEL.team_lead) {
1387
1864
  const orgs = await withSpinner("Checking permissions...", async () => {
1388
1865
  const response = await api.get("/api/cli/organizations");
1389
1866
  return response.data || [];
1390
1867
  });
1391
- const freshRole = orgs.find(
1868
+ const freshOrg = orgs.find(
1392
1869
  (o) => o._id === existingConfig.projects[0]?.organizationId
1393
- )?.role;
1394
- if (freshRole) {
1395
- setRole(freshRole);
1396
- role = freshRole;
1870
+ );
1871
+ if (freshOrg) {
1872
+ role = orgUnifiedRole(freshOrg);
1873
+ setUnifiedRole(role);
1397
1874
  }
1398
- if (role !== "admin" && role !== "team_lead") {
1399
- error("Only admins and team leads can link multiple projects.");
1875
+ if (roleLevel(role) < ROLE_LEVEL.team_lead) {
1876
+ error(
1877
+ `Only team leads and above can link multiple projects (your role: ${formatRoleLabel(role)}).`
1878
+ );
1400
1879
  info("Unlink the current project first with `envpilot unlink`.");
1401
1880
  process.exit(1);
1402
1881
  }
@@ -1589,17 +2068,12 @@ function warnTrackedFiles() {
1589
2068
  function printPostInit(selectedOrg, selectedProject) {
1590
2069
  console.log();
1591
2070
  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);
2071
+ console.log(
2072
+ chalk4.dim(` Org role: ${formatRoleLabel(orgUnifiedRole(selectedOrg))}`)
2073
+ );
2074
+ const scope = selectedProject.environmentScope;
2075
+ if (scope && scope.length > 0) {
2076
+ console.log(chalk4.dim(` Environment scope: ${scope.join(", ")}`));
1603
2077
  }
1604
2078
  console.log();
1605
2079
  console.log("Next steps:");
@@ -1932,7 +2406,7 @@ function parseNetlify(content) {
1932
2406
  for (const line2 of lines) {
1933
2407
  const trimmed = line2.trim();
1934
2408
  if (trimmed.startsWith("[")) {
1935
- inBuildEnv = trimmed === "[build.environment]" || trimmed === "[build.environment]";
2409
+ inBuildEnv = trimmed === "[build.environment]" || /^\[context\.[^.\]]+\.environment\]$/.test(trimmed);
1936
2410
  continue;
1937
2411
  }
1938
2412
  if (!inBuildEnv) continue;
@@ -1955,6 +2429,17 @@ function parseNetlify(content) {
1955
2429
  }
1956
2430
 
1957
2431
  // src/commands/pull.ts
2432
+ function accessFromMeta(meta, variables) {
2433
+ const role = normalizeOrgRole(meta?.unifiedRole ?? meta?.role);
2434
+ return {
2435
+ role,
2436
+ // Owners are implicitly assigned to every project. The legacy server sends
2437
+ // no `assigned`/`projectRole` for owners, so fall back to true for them.
2438
+ assigned: role === "owner" || (meta?.assigned ?? (meta?.projectRole !== null && meta?.projectRole !== void 0)),
2439
+ environmentScope: meta?.environmentScope ?? null,
2440
+ hasWriteAccess: meta?.hasWriteAccess ?? variables.some((v) => v.access === "write")
2441
+ };
2442
+ }
1958
2443
  var pullCommand = new Command3("pull").description("Download environment variables to local .env file").option(
1959
2444
  "-e, --env <environment>",
1960
2445
  "Environment (development, staging, production)"
@@ -1997,6 +2482,8 @@ var pullCommand = new Command3("pull").description("Download environment variabl
1997
2482
  const projectConfig = readProjectConfig();
1998
2483
  if (!projectConfig) throw notInitialized();
1999
2484
  checkTrackedFiles();
2485
+ const configV2ForActive = readProjectConfigV2();
2486
+ const activeEntry = configV2ForActive ? getActiveProject(configV2ForActive) : null;
2000
2487
  const environment = options.env || projectConfig.environment || "development";
2001
2488
  const fmt = options.format || "env";
2002
2489
  if (!ALL_FORMATS.includes(fmt)) {
@@ -2008,7 +2495,8 @@ var pullCommand = new Command3("pull").description("Download environment variabl
2008
2495
  {
2009
2496
  projectId: projectConfig.projectId,
2010
2497
  organizationId: projectConfig.organizationId,
2011
- environment
2498
+ environment,
2499
+ fileProtection: activeEntry?.fileProtection
2012
2500
  },
2013
2501
  outputPath,
2014
2502
  options
@@ -2037,7 +2525,8 @@ async function pullAllProjects(options) {
2037
2525
  {
2038
2526
  projectId: project.projectId,
2039
2527
  organizationId: project.organizationId,
2040
- environment: project.environment
2528
+ environment: project.environment,
2529
+ fileProtection: project.fileProtection
2041
2530
  },
2042
2531
  outputPath,
2043
2532
  options
@@ -2068,7 +2557,8 @@ async function pullSingleProject(project, options) {
2068
2557
  {
2069
2558
  projectId: project.projectId,
2070
2559
  organizationId: project.organizationId,
2071
- environment
2560
+ environment,
2561
+ fileProtection: project.fileProtection
2072
2562
  },
2073
2563
  outputPath,
2074
2564
  options
@@ -2076,7 +2566,7 @@ async function pullSingleProject(project, options) {
2076
2566
  }
2077
2567
  async function pullProject(project, outputPath, options) {
2078
2568
  const api = createAPIClient();
2079
- let metaProjectRole;
2569
+ let meta;
2080
2570
  const variables = await withSpinner(
2081
2571
  `Fetching ${chalk5.bold(project.environment)} variables...`,
2082
2572
  async () => {
@@ -2087,10 +2577,15 @@ async function pullProject(project, outputPath, options) {
2087
2577
  organizationId: project.organizationId
2088
2578
  }
2089
2579
  });
2090
- metaProjectRole = response.meta?.projectRole;
2580
+ meta = response.meta;
2091
2581
  return response.data || [];
2092
2582
  }
2093
2583
  );
2584
+ if (meta?.scopeRestricted && meta.environmentScope?.length) {
2585
+ info(
2586
+ `Your access is scoped to ${meta.environmentScope.join(", ")}; variables in other environments are withheld.`
2587
+ );
2588
+ }
2094
2589
  if (variables.length === 0) {
2095
2590
  warning(`No variables found for ${project.environment} environment.`);
2096
2591
  return;
@@ -2154,20 +2649,21 @@ async function pullProject(project, outputPath, options) {
2154
2649
  });
2155
2650
  fs.writeFileSync(outputPath, output, "utf-8");
2156
2651
  }
2157
- const role = getRole();
2158
- applyFileProtection(outputPath, role, metaProjectRole);
2652
+ const access = accessFromMeta(meta, variables);
2653
+ const protection = project.fileProtection ?? "auto";
2654
+ applyFileProtection(outputPath, access, protection);
2159
2655
  success(
2160
2656
  `Downloaded ${variables.length} variables to ${chalk5.bold(outputPath)}`
2161
2657
  );
2162
- if (metaProjectRole === "viewer") {
2658
+ if (meta?.grantOnly) {
2163
2659
  info(
2164
- "You have Viewer access to this project. You may only see variables you have been explicitly granted access to."
2660
+ "You have grant-only access to this project. You only see variables explicitly shared with you."
2165
2661
  );
2166
2662
  }
2167
- const isProtected = role !== "admin" && role !== "team_lead" && metaProjectRole !== "manager";
2168
- if (isProtected) {
2663
+ const writable = protection === "never" || protection !== "always" && isFileWritable(access);
2664
+ if (!writable) {
2169
2665
  info(
2170
- `File is read-only (your role: ${role || metaProjectRole || "member"}).`
2666
+ `File is read-only (your role: ${formatRoleLabel(access.role)}${access.assigned ? "" : ", not assigned to this project"}).`
2171
2667
  );
2172
2668
  }
2173
2669
  console.log();
@@ -2306,40 +2802,10 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2306
2802
  process.exit(1);
2307
2803
  }
2308
2804
  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
- const environment = options.env || defaultEnvironment || "development";
2340
- const fmt = options.format || "env";
2341
- if (!ALL_FORMATS.includes(fmt)) {
2342
- error(`Unknown format: ${fmt}. Supported: ${ALL_FORMATS.join(", ")}`);
2805
+ const environment = options.env || defaultEnvironment || "development";
2806
+ const fmt = options.format || "env";
2807
+ if (!ALL_FORMATS.includes(fmt)) {
2808
+ error(`Unknown format: ${fmt}. Supported: ${ALL_FORMATS.join(", ")}`);
2343
2809
  process.exit(1);
2344
2810
  }
2345
2811
  const inputPath = options.file || (fmt === "env" ? getEnvPathForEnvironment(environment) : getDefaultFilename(fmt, environment));
@@ -2375,6 +2841,7 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2375
2841
  error("No valid variables to push.");
2376
2842
  return;
2377
2843
  }
2844
+ let meta;
2378
2845
  const remoteVariables = await withSpinner(
2379
2846
  "Fetching current variables...",
2380
2847
  async () => {
@@ -2386,9 +2853,21 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2386
2853
  params.organizationId = organizationId;
2387
2854
  }
2388
2855
  const response = await api.get("/api/cli/variables", params);
2856
+ meta = response.meta;
2389
2857
  return response.data || [];
2390
2858
  }
2391
2859
  );
2860
+ const scope = meta?.environmentScope;
2861
+ if (Array.isArray(scope) && !scope.includes(environment)) {
2862
+ error(
2863
+ `Your developer access is scoped to ${scope.join(", ")}. ${environment} variables are withheld.`
2864
+ );
2865
+ process.exit(1);
2866
+ }
2867
+ if (meta?.hasWriteAccess === false) {
2868
+ error(`You have read-only access to ${environment} in this project.`);
2869
+ process.exit(1);
2870
+ }
2392
2871
  const remoteVars = {};
2393
2872
  for (const variable of remoteVariables) {
2394
2873
  remoteVars[variable.key] = variable.value;
@@ -2459,32 +2938,28 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2459
2938
  return response.data;
2460
2939
  }
2461
2940
  );
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
- );
2941
+ success(
2942
+ `Pushed ${result?.total || Object.keys(valid).length} variables to ${chalk6.bold(environment)}`
2943
+ );
2944
+ console.log();
2945
+ console.log(chalk6.dim(` Created: ${result?.created || 0}`));
2946
+ console.log(chalk6.dim(` Updated: ${result?.updated || 0}`));
2947
+ if (mode === "replace") {
2948
+ console.log(chalk6.dim(` Deleted: ${result?.deleted || 0}`));
2949
+ }
2950
+ const deniedKeys = result?.deniedKeys ?? [];
2951
+ if (deniedKeys.length > 0) {
2472
2952
  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)}`
2953
+ warning(
2954
+ `${deniedKeys.length} variable(s) were NOT written (access denied):`
2480
2955
  );
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}`));
2956
+ for (const key of deniedKeys) {
2957
+ console.log(chalk6.red(` \u2717 ${key}`));
2486
2958
  }
2487
2959
  }
2960
+ if (result?.skipped && result.skipped > 0) {
2961
+ console.log(chalk6.dim(` Skipped: ${result.skipped}`));
2962
+ }
2488
2963
  } catch (err) {
2489
2964
  await handleError(err);
2490
2965
  }
@@ -2494,6 +2969,9 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2494
2969
  import { Command as Command5 } from "commander";
2495
2970
  import chalk7 from "chalk";
2496
2971
  import inquirer4 from "inquirer";
2972
+ function orgUnifiedRole2(org) {
2973
+ return normalizeOrgRole(org.unifiedRole ?? org.role);
2974
+ }
2497
2975
  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
2976
  "-e, --env <environment>",
2499
2977
  "Switch environment (development, staging, production)"
@@ -2562,17 +3040,15 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2562
3040
  process.exit(1);
2563
3041
  }
2564
3042
  setActiveOrganizationId(org._id);
2565
- if (org.role) {
2566
- setRole(org.role);
2567
- }
3043
+ setUnifiedRole(orgUnifiedRole2(org));
2568
3044
  if (projectConfig) {
2569
3045
  updateProjectConfig({ organizationId: org._id });
2570
3046
  }
2571
3047
  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
- }
3048
+ console.log(
3049
+ chalk7.dim(` Role: ${formatRoleLabel(orgUnifiedRole2(org))}`)
3050
+ );
3051
+ roleNotice(orgUnifiedRole2(org));
2576
3052
  return;
2577
3053
  }
2578
3054
  if (options.project || target) {
@@ -2611,9 +3087,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2611
3087
  }
2612
3088
  if (organizations.length === 1) {
2613
3089
  organizationId = organizations[0]._id;
2614
- if (organizations[0].role) {
2615
- setRole(organizations[0].role);
2616
- }
3090
+ setUnifiedRole(orgUnifiedRole2(organizations[0]));
2617
3091
  } else {
2618
3092
  const { orgId } = await inquirer4.prompt([
2619
3093
  {
@@ -2628,8 +3102,8 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2628
3102
  ]);
2629
3103
  organizationId = orgId;
2630
3104
  const selectedOrg = organizations.find((o) => o._id === orgId);
2631
- if (selectedOrg?.role) {
2632
- setRole(selectedOrg.role);
3105
+ if (selectedOrg) {
3106
+ setUnifiedRole(orgUnifiedRole2(selectedOrg));
2633
3107
  }
2634
3108
  }
2635
3109
  }
@@ -2768,19 +3242,17 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2768
3242
  if (switchType === "organization") {
2769
3243
  setActiveOrganizationId(orgId);
2770
3244
  const org = organizations.find((o) => o._id === orgId);
2771
- if (org.role) {
2772
- setRole(org.role);
2773
- }
3245
+ setUnifiedRole(orgUnifiedRole2(org));
2774
3246
  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
- }
3247
+ console.log(
3248
+ chalk7.dim(` Role: ${formatRoleLabel(orgUnifiedRole2(org))}`)
3249
+ );
3250
+ roleNotice(orgUnifiedRole2(org));
2779
3251
  return;
2780
3252
  }
2781
3253
  const selectedOrg = organizations.find((o) => o._id === orgId);
2782
- if (selectedOrg?.role) {
2783
- setRole(selectedOrg.role);
3254
+ if (selectedOrg) {
3255
+ setUnifiedRole(orgUnifiedRole2(selectedOrg));
2784
3256
  }
2785
3257
  const projects = await withSpinner(
2786
3258
  "Fetching projects...",
@@ -2928,7 +3400,7 @@ async function listOrganizations(api, options) {
2928
3400
  name: org.name,
2929
3401
  slug: org.slug,
2930
3402
  tier: org.tier === "pro" ? chalk8.green("Pro") : chalk8.dim("Free"),
2931
- role: org.role
3403
+ role: formatRoleLabel(org.unifiedRole ?? org.role)
2932
3404
  })),
2933
3405
  [
2934
3406
  { key: "name", header: "Name" },
@@ -2986,7 +3458,9 @@ async function listProjects(api, projectConfig, options) {
2986
3458
  name: project.name,
2987
3459
  slug: project.slug,
2988
3460
  description: project.description || chalk8.dim("-"),
2989
- role: project.userRole === "admin" ? formatRole("admin") : formatProjectRole(project.projectRole),
3461
+ role: formatRoleLabel(
3462
+ project.unifiedRole ?? project.userRole ?? project.projectRole
3463
+ ),
2990
3464
  active: projectConfig?.projectId === project._id ? chalk8.green("\u2713") : ""
2991
3465
  })),
2992
3466
  [
@@ -2998,11 +3472,8 @@ async function listProjects(api, projectConfig, options) {
2998
3472
  { key: "active", header: "" }
2999
3473
  ]
3000
3474
  );
3001
- const role = getRole();
3002
- if (role) {
3003
- console.log();
3004
- console.log(chalk8.dim(`Your org role: ${formatRole(role)}`));
3005
- }
3475
+ console.log();
3476
+ console.log(chalk8.dim(`Your org role: ${formatRoleLabel(getUnifiedRole())}`));
3006
3477
  }
3007
3478
  async function listVariables(api, projectConfig, options) {
3008
3479
  const projectId = options.project || projectConfig?.projectId;
@@ -3011,14 +3482,14 @@ async function listVariables(api, projectConfig, options) {
3011
3482
  error("No project specified. Use --project or run `envpilot init` first.");
3012
3483
  process.exit(1);
3013
3484
  }
3014
- let metaProjectRole;
3485
+ let meta;
3015
3486
  const variables = await withSpinner("Fetching variables...", async () => {
3016
3487
  const params = { projectId };
3017
3488
  if (environment) {
3018
3489
  params.environment = environment;
3019
3490
  }
3020
3491
  const response = await api.get("/api/cli/variables", params);
3021
- metaProjectRole = response.meta?.projectRole;
3492
+ meta = response.meta;
3022
3493
  return response.data || [];
3023
3494
  });
3024
3495
  const tagFilter = options.tag?.toLowerCase();
@@ -3050,7 +3521,7 @@ async function listVariables(api, projectConfig, options) {
3050
3521
  value: options.showValues ? variable.value : maskValue(variable.value),
3051
3522
  sensitive: variable.isSensitive ? chalk8.yellow("\u25CF") : "",
3052
3523
  tags: hasTags ? variable.tags?.map((t) => t.name).join(", ") || chalk8.dim("-") : "",
3053
- version: `v${variable.version}`
3524
+ version: typeof variable.version === "number" ? `v${variable.version}` : ""
3054
3525
  })),
3055
3526
  [
3056
3527
  { key: "key", header: "Key" },
@@ -3062,16 +3533,14 @@ async function listVariables(api, projectConfig, options) {
3062
3533
  );
3063
3534
  console.log();
3064
3535
  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) {
3536
+ console.log(chalk8.dim(`Your org role: ${formatRoleLabel(getUnifiedRole())}`));
3537
+ if (meta?.scopeRestricted && meta.environmentScope?.length) {
3070
3538
  console.log(
3071
- chalk8.dim(`Your project role: ${formatProjectRole(metaProjectRole)}`)
3539
+ chalk8.dim(
3540
+ `Your access is scoped to ${meta.environmentScope.join(", ")}; variables in other environments are withheld.`
3541
+ )
3072
3542
  );
3073
- }
3074
- if (role === "member" || metaProjectRole === "viewer") {
3543
+ } else if (meta?.grantOnly) {
3075
3544
  console.log(
3076
3545
  chalk8.dim("You may only see variables you have been granted access to.")
3077
3546
  );
@@ -3084,6 +3553,23 @@ async function listVariables(api, projectConfig, options) {
3084
3553
  // src/commands/config.ts
3085
3554
  import { Command as Command7 } from "commander";
3086
3555
  import chalk9 from "chalk";
3556
+ var FILE_PROTECTION_VALUES = ["auto", "always", "never"];
3557
+ var ENVIRONMENT_VALUES = ["development", "staging", "production"];
3558
+ function updateActiveProjectEntry(updates) {
3559
+ const v2 = readProjectConfigV2();
3560
+ if (!v2) {
3561
+ error(
3562
+ "No project initialized in this directory. Run `envpilot init` first."
3563
+ );
3564
+ process.exit(1);
3565
+ }
3566
+ const active = getActiveProject(v2);
3567
+ if (!active) {
3568
+ error("No active project found in this directory.");
3569
+ process.exit(1);
3570
+ }
3571
+ writeProjectConfigV2(updateProjectInConfig(v2, active.projectId, updates));
3572
+ }
3087
3573
  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
3574
  try {
3089
3575
  switch (action) {
@@ -3127,6 +3613,10 @@ async function handleGet(key) {
3127
3613
  console.log(" user Current authenticated user");
3128
3614
  console.log(" activeProjectId Currently active project");
3129
3615
  console.log(" activeOrganizationId Currently active organization");
3616
+ console.log(
3617
+ " fileProtection Active project .env protection (auto|always|never)"
3618
+ );
3619
+ console.log(" defaultEnvironment Active project's default environment");
3130
3620
  process.exit(1);
3131
3621
  }
3132
3622
  const config2 = getConfig();
@@ -3147,6 +3637,18 @@ async function handleGet(key) {
3147
3637
  case "activeOrganizationId":
3148
3638
  console.log(config2.activeOrganizationId || chalk9.dim("(not set)"));
3149
3639
  break;
3640
+ case "fileProtection": {
3641
+ const v2 = readProjectConfigV2();
3642
+ const active = v2 ? getActiveProject(v2) : null;
3643
+ console.log(active?.fileProtection ?? "auto");
3644
+ break;
3645
+ }
3646
+ case "defaultEnvironment": {
3647
+ const v2 = readProjectConfigV2();
3648
+ const active = v2 ? getActiveProject(v2) : null;
3649
+ console.log(active?.environment || chalk9.dim("(not set)"));
3650
+ break;
3651
+ }
3150
3652
  default:
3151
3653
  error(`Unknown key: ${key}`);
3152
3654
  process.exit(1);
@@ -3156,8 +3658,7 @@ async function handleSet(key, value) {
3156
3658
  if (!key || value === void 0) {
3157
3659
  error("Missing key or value. Usage: envpilot config set <key> <value>");
3158
3660
  console.log();
3159
- console.log("Settable keys:");
3160
- console.log(" apiUrl API endpoint URL");
3661
+ printSettableKeys();
3161
3662
  process.exit(1);
3162
3663
  }
3163
3664
  switch (key) {
@@ -3173,14 +3674,53 @@ async function handleSet(key, value) {
3173
3674
  success(`Set apiUrl to ${normalized}`);
3174
3675
  break;
3175
3676
  }
3677
+ case "fileProtection": {
3678
+ if (!FILE_PROTECTION_VALUES.includes(
3679
+ value
3680
+ )) {
3681
+ error(
3682
+ `Invalid fileProtection value: ${value}. Expected one of: ${FILE_PROTECTION_VALUES.join(", ")}`
3683
+ );
3684
+ process.exit(1);
3685
+ }
3686
+ updateActiveProjectEntry({
3687
+ fileProtection: value
3688
+ });
3689
+ success(`Set fileProtection to ${value} for the active project`);
3690
+ break;
3691
+ }
3692
+ case "defaultEnvironment": {
3693
+ if (!ENVIRONMENT_VALUES.includes(
3694
+ value
3695
+ )) {
3696
+ error(
3697
+ `Invalid defaultEnvironment value: ${value}. Expected one of: ${ENVIRONMENT_VALUES.join(", ")}`
3698
+ );
3699
+ process.exit(1);
3700
+ }
3701
+ updateActiveProjectEntry({
3702
+ environment: value
3703
+ });
3704
+ success(`Set defaultEnvironment to ${value} for the active project`);
3705
+ break;
3706
+ }
3176
3707
  default:
3177
3708
  error(`Cannot set key: ${key}`);
3178
3709
  console.log();
3179
- console.log("Settable keys:");
3180
- console.log(" apiUrl API endpoint URL");
3710
+ printSettableKeys();
3181
3711
  process.exit(1);
3182
3712
  }
3183
3713
  }
3714
+ function printSettableKeys() {
3715
+ console.log("Settable keys:");
3716
+ console.log(" apiUrl API endpoint URL");
3717
+ console.log(
3718
+ " fileProtection Active project .env protection (auto|always|never)"
3719
+ );
3720
+ console.log(
3721
+ " defaultEnvironment Active project default environment (development|staging|production)"
3722
+ );
3723
+ }
3184
3724
  async function handleList() {
3185
3725
  const config2 = getConfig();
3186
3726
  const projectConfig = readProjectConfig();
@@ -3236,20 +3776,43 @@ async function handleReset() {
3236
3776
 
3237
3777
  // src/commands/logout.ts
3238
3778
  import { Command as Command8 } from "commander";
3239
- var logoutCommand = new Command8("logout").description("Log out from Envpilot").action(async () => {
3779
+ var logoutCommand = new Command8("logout").description("Log out from Envpilot").option("--all", "Log out of every authenticated account").action(async (options) => {
3240
3780
  try {
3241
3781
  if (!isAuthenticated()) {
3242
3782
  info("You are not logged in.");
3243
3783
  return;
3244
3784
  }
3245
- const user = getUser();
3785
+ if (options.all) {
3786
+ const accounts = listAccounts();
3787
+ for (const account of accounts) {
3788
+ try {
3789
+ const api2 = new APIClient({ accessToken: account.accessToken });
3790
+ await api2.post("/api/cli/auth?action=revoke", {});
3791
+ } catch {
3792
+ }
3793
+ removeAccount(account.id);
3794
+ }
3795
+ clearAuth();
3796
+ success(`Logged out of all ${accounts.length} accounts.`);
3797
+ return;
3798
+ }
3799
+ const activeAccount = getActiveAccount();
3246
3800
  const api = createAPIClient();
3247
3801
  try {
3248
3802
  await api.post("/api/cli/auth?action=revoke", {});
3249
3803
  } catch {
3250
3804
  }
3251
- clearAuth();
3252
- success(`Logged out${user?.email ? ` from ${user.email}` : ""}`);
3805
+ const { newActiveId } = clearAuth();
3806
+ success(
3807
+ `Logged out${activeAccount?.user.email ? ` ${activeAccount.user.email}` : ""}.`
3808
+ );
3809
+ if (newActiveId) {
3810
+ const remaining = listAccounts();
3811
+ const newActive = remaining.find((a) => a.id === newActiveId);
3812
+ info(`Now active: ${newActive?.user.email ?? newActiveId}`);
3813
+ } else {
3814
+ info("No accounts remaining.");
3815
+ }
3253
3816
  } catch (err) {
3254
3817
  await handleError(err);
3255
3818
  }
@@ -3351,20 +3914,35 @@ import chalk11 from "chalk";
3351
3914
 
3352
3915
  // src/lib/commit-guard.ts
3353
3916
  import {
3354
- existsSync as existsSync3,
3355
- readFileSync as readFileSync3,
3356
- writeFileSync as writeFileSync3,
3357
- mkdirSync,
3917
+ existsSync as existsSync4,
3918
+ readFileSync as readFileSync4,
3919
+ writeFileSync as writeFileSync4,
3920
+ mkdirSync as mkdirSync2,
3358
3921
  chmodSync as chmodSync2,
3359
- unlinkSync as unlinkSync2,
3360
- statSync
3922
+ unlinkSync as unlinkSync3,
3923
+ renameSync as renameSync3,
3924
+ statSync as statSync2
3361
3925
  } from "fs";
3362
- import { join as join3, resolve as resolve2 } from "path";
3926
+ import { join as join4, resolve as resolve2 } from "path";
3363
3927
  import { execSync as execSync2 } from "child_process";
3928
+ function atomicWriteFileSync3(filePath, content, mode) {
3929
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
3930
+ try {
3931
+ writeFileSync4(tmpPath, content, "utf-8");
3932
+ chmodSync2(tmpPath, mode);
3933
+ renameSync3(tmpPath, filePath);
3934
+ } catch (err) {
3935
+ try {
3936
+ if (existsSync4(tmpPath)) unlinkSync3(tmpPath);
3937
+ } catch {
3938
+ }
3939
+ throw err;
3940
+ }
3941
+ }
3364
3942
  var HOOK_START_MARKER = "# ENVPILOT_GUARD_START";
3365
3943
  var HOOK_END_MARKER = "# ENVPILOT_GUARD_END";
3366
3944
  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)
3945
+ ENV_FILES=$(git diff --cached --name-only | grep -E '(^|/)\\.env($|\\.)' | grep -vE '\\.(example|sample|template|dist)$' || true)
3368
3946
  if [ -n "$ENV_FILES" ]; then
3369
3947
  echo ""
3370
3948
  echo "\\033[1;31mERROR:\\033[0m Envpilot commit guard blocked this commit."
@@ -3380,7 +3958,7 @@ ${HOOK_END_MARKER}`;
3380
3958
  function findGitRoot(startDir) {
3381
3959
  let dir = startDir || process.cwd();
3382
3960
  while (true) {
3383
- if (existsSync3(join3(dir, ".git"))) {
3961
+ if (existsSync4(join4(dir, ".git"))) {
3384
3962
  return dir;
3385
3963
  }
3386
3964
  const parent = resolve2(dir, "..");
@@ -3391,9 +3969,9 @@ function findGitRoot(startDir) {
3391
3969
  }
3392
3970
  }
3393
3971
  function resolveGitDir(repoRoot) {
3394
- const gitPath = join3(repoRoot, ".git");
3972
+ const gitPath = join4(repoRoot, ".git");
3395
3973
  try {
3396
- const stat = statSync(gitPath);
3974
+ const stat = statSync2(gitPath);
3397
3975
  if (stat.isDirectory()) {
3398
3976
  return gitPath;
3399
3977
  }
@@ -3401,12 +3979,12 @@ function resolveGitDir(repoRoot) {
3401
3979
  return gitPath;
3402
3980
  }
3403
3981
  try {
3404
- const content = readFileSync3(gitPath, "utf-8").trim();
3982
+ const content = readFileSync4(gitPath, "utf-8").trim();
3405
3983
  const match = content.match(/^gitdir:\s*(.+)$/);
3406
3984
  if (match) {
3407
3985
  const gitdir = resolve2(repoRoot, match[1]);
3408
3986
  const commonDir = resolve2(gitdir, "..", "..");
3409
- if (existsSync3(join3(commonDir, "hooks")) || existsSync3(commonDir)) {
3987
+ if (existsSync4(join4(commonDir, "hooks")) || existsSync4(commonDir)) {
3410
3988
  return commonDir;
3411
3989
  }
3412
3990
  return gitdir;
@@ -3428,7 +4006,7 @@ function getHooksDir(repoRoot) {
3428
4006
  } catch {
3429
4007
  }
3430
4008
  const gitDir = resolveGitDir(repoRoot);
3431
- return join3(gitDir, "hooks");
4009
+ return join4(gitDir, "hooks");
3432
4010
  }
3433
4011
  function installCommitGuard(repoRoot) {
3434
4012
  const root = repoRoot || findGitRoot();
@@ -3441,19 +4019,18 @@ function installCommitGuard(repoRoot) {
3441
4019
  }
3442
4020
  try {
3443
4021
  const hooksDir = getHooksDir(root);
3444
- const hookPath = join3(hooksDir, "pre-commit");
3445
- mkdirSync(hooksDir, { recursive: true });
4022
+ const hookPath = join4(hooksDir, "pre-commit");
4023
+ mkdirSync2(hooksDir, { recursive: true });
3446
4024
  let existingContent = "";
3447
4025
  try {
3448
- existingContent = readFileSync3(hookPath, "utf-8");
4026
+ existingContent = readFileSync4(hookPath, "utf-8");
3449
4027
  } catch {
3450
4028
  }
3451
4029
  if (existingContent.includes(HOOK_START_MARKER)) {
3452
4030
  const startIdx = existingContent.indexOf(HOOK_START_MARKER);
3453
4031
  const endIdx = existingContent.indexOf(HOOK_END_MARKER) + HOOK_END_MARKER.length;
3454
4032
  const updated = existingContent.substring(0, startIdx) + HOOK_BLOCK + existingContent.substring(endIdx);
3455
- writeFileSync3(hookPath, updated, "utf-8");
3456
- chmodSync2(hookPath, 493);
4033
+ atomicWriteFileSync3(hookPath, updated, 493);
3457
4034
  return {
3458
4035
  installed: true,
3459
4036
  hookPath,
@@ -3466,8 +4043,7 @@ function installCommitGuard(repoRoot) {
3466
4043
  } else {
3467
4044
  newContent = "#!/bin/sh\n\n" + HOOK_BLOCK + "\n";
3468
4045
  }
3469
- writeFileSync3(hookPath, newContent, "utf-8");
3470
- chmodSync2(hookPath, 493);
4046
+ atomicWriteFileSync3(hookPath, newContent, 493);
3471
4047
  return {
3472
4048
  installed: true,
3473
4049
  hookPath,
@@ -3483,6 +4059,21 @@ function installCommitGuard(repoRoot) {
3483
4059
  }
3484
4060
 
3485
4061
  // src/commands/sync.ts
4062
+ function accessFromMeta2(meta, variables) {
4063
+ const role = normalizeOrgRole(meta?.unifiedRole ?? meta?.role);
4064
+ return {
4065
+ role,
4066
+ // Owners are implicitly assigned to every project. The legacy server sends
4067
+ // no `assigned`/`projectRole` for owners, so fall back to true for them.
4068
+ assigned: role === "owner" || (meta?.assigned ?? (meta?.projectRole !== null && meta?.projectRole !== void 0)),
4069
+ environmentScope: meta?.environmentScope ?? null,
4070
+ hasWriteAccess: meta?.hasWriteAccess ?? variables.some((v) => v.access === "write")
4071
+ };
4072
+ }
4073
+ function orgUnifiedRole3(org) {
4074
+ const unified = org.unifiedRole;
4075
+ return normalizeOrgRole(unified ?? org.role);
4076
+ }
3486
4077
  var syncCommand = new Command10("sync").description(
3487
4078
  "Login, select project, pull variables, and protect files \u2014 all in one command"
3488
4079
  ).option("-o, --organization <id>", "Organization ID").option("-p, --project <id>", "Project ID or name").option(
@@ -3546,9 +4137,7 @@ var syncCommand = new Command10("sync").description(
3546
4137
  environment = selection.selectedEnvironment;
3547
4138
  projectName = selection.selectedProject.name;
3548
4139
  organizationName = selection.selectedOrg.name;
3549
- if (selection.selectedOrg.role) {
3550
- setRole(selection.selectedOrg.role);
3551
- }
4140
+ setUnifiedRole(orgUnifiedRole3(selection.selectedOrg));
3552
4141
  writeProjectConfigV2({
3553
4142
  version: 1,
3554
4143
  activeProjectId: projectId,
@@ -3577,7 +4166,7 @@ var syncCommand = new Command10("sync").description(
3577
4166
  setActiveProjectId(projectId);
3578
4167
  }
3579
4168
  console.log();
3580
- let metaProjectRole;
4169
+ let meta;
3581
4170
  const variables = await withSpinner(
3582
4171
  `Fetching ${chalk11.bold(environment)} variables...`,
3583
4172
  async () => {
@@ -3587,11 +4176,16 @@ var syncCommand = new Command10("sync").description(
3587
4176
  environment,
3588
4177
  ...organizationId && { organizationId }
3589
4178
  });
3590
- metaProjectRole = response.meta?.projectRole;
4179
+ meta = response.meta;
3591
4180
  return response.data || [];
3592
4181
  }
3593
4182
  );
3594
4183
  const outputPath = getEnvPathForEnvironment(environment);
4184
+ if (meta?.scopeRestricted && meta.environmentScope?.length) {
4185
+ info(
4186
+ `Your access is scoped to ${meta.environmentScope.join(", ")}; variables in other environments are withheld.`
4187
+ );
4188
+ }
3595
4189
  if (variables.length === 0) {
3596
4190
  warning(`No variables found for ${environment} environment.`);
3597
4191
  console.log();
@@ -3628,15 +4222,21 @@ var syncCommand = new Command10("sync").description(
3628
4222
  }
3629
4223
  }
3630
4224
  writeEnvFile(outputPath, remoteVars, { sort: true, comments });
3631
- const role = getRole();
3632
- applyFileProtection(outputPath, role, metaProjectRole);
4225
+ const access = accessFromMeta2(meta, variables);
4226
+ const protection = existingConfig?.projects.find((p) => p.projectId === projectId)?.fileProtection ?? "auto";
4227
+ applyFileProtection(outputPath, access, protection);
3633
4228
  success(
3634
4229
  `Synced ${variables.length} variables to ${chalk11.bold(outputPath)}`
3635
4230
  );
3636
- const isProtected = role !== "admin" && role !== "team_lead" && metaProjectRole !== "manager";
3637
- if (isProtected) {
4231
+ if (meta?.grantOnly) {
3638
4232
  info(
3639
- `File is read-only (your role: ${role || metaProjectRole || "member"}).`
4233
+ "You have grant-only access to this project. You only see variables explicitly shared with you."
4234
+ );
4235
+ }
4236
+ const writable = protection === "never" || protection !== "always" && isFileWritable(access);
4237
+ if (!writable) {
4238
+ info(
4239
+ `File is read-only (your role: ${formatRoleLabel(access.role)}${access.assigned ? "" : ", not assigned to this project"}).`
3640
4240
  );
3641
4241
  }
3642
4242
  console.log();
@@ -3784,6 +4384,7 @@ var whoamiCommand = new Command12("whoami").description("Show the current authen
3784
4384
  ["Name", remoteUser.name || localUser?.name],
3785
4385
  ["API URL", getApiUrl()],
3786
4386
  ["Active Organization", getActiveOrganizationId()],
4387
+ ["Org Role", formatRoleLabel(getUnifiedRole())],
3787
4388
  ["Active Project", getActiveProjectId()],
3788
4389
  [
3789
4390
  "Linked Project",
@@ -3791,6 +4392,15 @@ var whoamiCommand = new Command12("whoami").description("Show the current authen
3791
4392
  ],
3792
4393
  ["Environment", activeProject?.environment]
3793
4394
  ]);
4395
+ const accounts = listAccounts();
4396
+ if (accounts.length > 1) {
4397
+ blank();
4398
+ console.log(
4399
+ chalk13.dim(
4400
+ `Accounts: ${accounts.length} (use \`envpilot accounts\` to switch)`
4401
+ )
4402
+ );
4403
+ }
3794
4404
  blank();
3795
4405
  console.log(chalk13.dim("Token verified against the CLI auth endpoint."));
3796
4406
  } catch (err) {
@@ -3798,110 +4408,120 @@ var whoamiCommand = new Command12("whoami").description("Show the current authen
3798
4408
  }
3799
4409
  });
3800
4410
 
3801
- // src/commands/run.ts
4411
+ // src/commands/accounts.ts
3802
4412
  import { Command as Command13 } from "commander";
3803
- import { spawn as spawn2 } from "child_process";
3804
4413
  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);
4414
+ function resolveAccount(accounts, identifier) {
4415
+ const normalized = identifier.trim().toLowerCase();
4416
+ return accounts.find(
4417
+ (account) => account.id === identifier || account.user.email.toLowerCase() === normalized
4418
+ );
3824
4419
  }
3825
- function getCachePath(key) {
3826
- return join4(getCacheDir(), `${key}.json`);
4420
+ function listAvailableEmails(accounts) {
4421
+ console.log();
4422
+ console.log("Available accounts:");
4423
+ for (const account of accounts) {
4424
+ console.log(` ${account.user.email}`);
4425
+ }
3827
4426
  }
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);
4427
+ function printAccountsList() {
4428
+ const accounts = listAccounts();
4429
+ const activeId = getActiveAccountId();
4430
+ if (accounts.length === 0) {
4431
+ console.log("No accounts. Run `envpilot login`.");
4432
+ return;
4433
+ }
4434
+ table(
4435
+ accounts.map((account) => ({
4436
+ active: account.id === activeId ? chalk14.green("\u25CF") : "",
4437
+ email: account.user.email,
4438
+ name: account.user.name ?? "",
4439
+ organization: account.activeOrganizationId ?? ""
4440
+ })),
4441
+ [
4442
+ { key: "active", header: "" },
4443
+ { key: "email", header: "Email" },
4444
+ { key: "name", header: "Name" },
4445
+ { key: "organization", header: "Active Org" }
4446
+ ]
4447
+ );
4448
+ blank();
4449
+ if (accounts.length === 1) {
4450
+ console.log(chalk14.dim("1 account (log in again to add another)."));
4451
+ } else {
4452
+ console.log(chalk14.dim(`${accounts.length} accounts.`));
4453
+ }
3832
4454
  }
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;
4455
+ function switchAccount(identifier) {
4456
+ const accounts = listAccounts();
4457
+ const target = resolveAccount(accounts, identifier);
4458
+ if (!target) {
4459
+ error(`Account not found: ${identifier}`);
4460
+ listAvailableEmails(accounts);
4461
+ process.exit(1);
3844
4462
  }
4463
+ setActiveAccount(target.id);
4464
+ success(`Switched to ${target.user.email}.`);
3845
4465
  }
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 };
4466
+ function removeAccountByIdentifier(identifier) {
4467
+ const accounts = listAccounts();
4468
+ const target = resolveAccount(accounts, identifier);
4469
+ if (!target) {
4470
+ error(`Account not found: ${identifier}`);
4471
+ listAvailableEmails(accounts);
4472
+ process.exit(1);
4473
+ }
4474
+ const result = removeAccount(target.id);
4475
+ success(`Removed ${target.user.email}.`);
4476
+ if (result.removedActive) {
4477
+ if (result.newActiveId) {
4478
+ const remaining = listAccounts();
4479
+ const newActive = remaining.find((a) => a.id === result.newActiveId);
4480
+ console.log(
4481
+ chalk14.dim(`Now active: ${newActive?.user.email ?? result.newActiveId}`)
4482
+ );
4483
+ } else {
4484
+ console.log(chalk14.dim("No accounts remaining."));
4485
+ }
4486
+ }
3851
4487
  }
3852
- function writeCache(projectId, environment, organizationId, variables) {
4488
+ var accountsCommand = new Command13("accounts").description("List and manage authenticated accounts").action(async () => {
3853
4489
  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 {
4490
+ printAccountsList();
4491
+ } catch (err) {
4492
+ await handleError(err);
3873
4493
  }
3874
- }
3875
- function extendCacheFreshness(projectId, environment, organizationId) {
4494
+ });
4495
+ accountsCommand.command("list").description("List all authenticated accounts").action(async () => {
3876
4496
  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 {
4497
+ printAccountsList();
4498
+ } catch (err) {
4499
+ await handleError(err);
3888
4500
  }
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
- }
4501
+ });
4502
+ accountsCommand.command("switch").description("Switch the active account").argument("<identifier>", "Account id or email").action(async (identifier) => {
4503
+ try {
4504
+ switchAccount(identifier);
4505
+ } catch (err) {
4506
+ await handleError(err);
4507
+ }
4508
+ });
4509
+ accountsCommand.command("remove").description("Remove an account").argument("<identifier>", "Account id or email").action(async (identifier) => {
4510
+ try {
4511
+ removeAccountByIdentifier(identifier);
4512
+ } catch (err) {
4513
+ await handleError(err);
4514
+ }
4515
+ });
3900
4516
 
3901
4517
  // src/commands/run.ts
4518
+ import { Command as Command14 } from "commander";
4519
+ import crossSpawn from "cross-spawn";
4520
+ import { constants as osConstants } from "os";
4521
+ import chalk15 from "chalk";
3902
4522
  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)"
4523
+ var runCommand = new Command14("run").description(
4524
+ "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
4525
  ).argument("[command...]", "Command and arguments to execute").option(
3906
4526
  "-e, --env <environment>",
3907
4527
  "Environment to load (development, staging, production)"
@@ -3919,10 +4539,10 @@ var runCommand = new Command13("run").description(
3919
4539
  "Print the variables that would be injected and exit (no command runs)"
3920
4540
  ).option(
3921
4541
  "--shell",
3922
- "Run the command through the user's shell (enables pipes, &&, $VAR expansion)"
4542
+ '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
4543
  ).option("-q, --quiet", "Suppress informational messages").option("--no-cache", "Skip cache and always fetch fresh secrets from server").option(
3924
4544
  "--cache-ttl <seconds>",
3925
- "How long cached secrets stay fresh before a fingerprint check (default: 3600s / 1h)",
4545
+ "How long cached secrets stay fresh before a fingerprint check (default: 3600s / 1h; 0 = always fingerprint-check)",
3926
4546
  String(DEFAULT_TTL)
3927
4547
  ).passThroughOptions().allowExcessArguments().action(async (commandArgs, options) => {
3928
4548
  try {
@@ -3951,10 +4571,11 @@ var runCommand = new Command13("run").description(
3951
4571
  }
3952
4572
  const environment = options.env || project.environment;
3953
4573
  const organizationId = options.organization || project.organizationId;
3954
- const ttl = Math.max(
3955
- 1,
3956
- parseInt(options.cacheTtl ?? String(DEFAULT_TTL), 10) || DEFAULT_TTL
4574
+ const parsedTtl = Number.parseInt(
4575
+ options.cacheTtl ?? String(DEFAULT_TTL),
4576
+ 10
3957
4577
  );
4578
+ const ttl = Number.isFinite(parsedTtl) && parsedTtl >= 0 ? parsedTtl : DEFAULT_TTL;
3958
4579
  let variables;
3959
4580
  let cacheHit = false;
3960
4581
  let cacheAge = "";
@@ -4006,13 +4627,19 @@ var runCommand = new Command13("run").description(
4006
4627
  project.projectId,
4007
4628
  environment,
4008
4629
  organizationId,
4009
- variables
4630
+ variables,
4631
+ serverFingerprint
4010
4632
  );
4011
4633
  }
4012
4634
  } catch {
4013
4635
  variables = probe.entry.variables;
4014
4636
  cacheHit = true;
4015
4637
  cacheAge = formatAge(probe.entry.fetchedAt);
4638
+ if (!options.quiet) {
4639
+ warning(
4640
+ `Using offline cache (age ${cacheAge}) \u2014 could not reach the server to verify freshness.`
4641
+ );
4642
+ }
4016
4643
  }
4017
4644
  } else {
4018
4645
  variables = await doFetch(
@@ -4049,9 +4676,9 @@ var runCommand = new Command13("run").description(
4049
4676
  }
4050
4677
  if (!options.quiet) {
4051
4678
  const injectedCount = Object.keys(secrets).length;
4052
- const cacheTag = cacheHit ? chalk14.dim(` \u26A1 cache (${cacheAge})`) : "";
4679
+ const cacheTag = cacheHit ? chalk15.dim(` \u26A1 cache (${cacheAge})`) : "";
4053
4680
  info(
4054
- `Injected ${chalk14.bold(injectedCount)} ${injectedCount === 1 ? "variable" : "variables"} from ${chalk14.bold(`${project.projectName || project.projectId}/${environment}`)}${cacheTag}`
4681
+ `Injected ${chalk15.bold(injectedCount)} ${injectedCount === 1 ? "variable" : "variables"} from ${chalk15.bold(`${project.projectName || project.projectId}/${environment}`)}${cacheTag}`
4055
4682
  );
4056
4683
  if (overridden.length > 0) {
4057
4684
  warning(
@@ -4061,13 +4688,13 @@ var runCommand = new Command13("run").description(
4061
4688
  }
4062
4689
  console.log();
4063
4690
  }
4064
- await runChild(commandArgs, finalEnv, options);
4691
+ process.exitCode = await runChild(commandArgs, finalEnv, options);
4065
4692
  } catch (err) {
4066
4693
  await handleError(err);
4067
4694
  }
4068
4695
  });
4069
4696
  async function doFetch(project, environment, organizationId, quiet, labelPrefix = "Loading") {
4070
- const label = `${labelPrefix} ${chalk14.bold(environment)} secrets for ${chalk14.bold(project.projectName || project.projectId)}...`;
4697
+ const label = `${labelPrefix} ${chalk15.bold(environment)} secrets for ${chalk15.bold(project.projectName || project.projectId)}...`;
4071
4698
  const api = createAPIClient();
4072
4699
  const { variables, decryptionFailures } = quiet ? await api.listVariables(project.projectId, environment, organizationId) : await withSpinner(
4073
4700
  label,
@@ -4076,7 +4703,7 @@ async function doFetch(project, environment, organizationId, quiet, labelPrefix
4076
4703
  if (decryptionFailures.length > 0) {
4077
4704
  for (const key of decryptionFailures) {
4078
4705
  warning(
4079
- `Could not decrypt ${chalk14.bold(key)} \u2014 skipped (vault error, check server logs)`
4706
+ `Could not decrypt ${chalk15.bold(key)} \u2014 skipped (vault error, check server logs)`
4080
4707
  );
4081
4708
  }
4082
4709
  }
@@ -4086,46 +4713,49 @@ function printInjectionPreview(secrets, project, environment) {
4086
4713
  const keys = Object.keys(secrets).sort();
4087
4714
  console.log();
4088
4715
  console.log(
4089
- chalk14.bold(
4090
- `Would inject ${keys.length} ${keys.length === 1 ? "variable" : "variables"} from ${chalk14.cyan(`${project.projectName || project.projectId}/${environment}`)}:`
4716
+ chalk15.bold(
4717
+ `Would inject ${keys.length} ${keys.length === 1 ? "variable" : "variables"} from ${chalk15.cyan(`${project.projectName || project.projectId}/${environment}`)}:`
4091
4718
  )
4092
4719
  );
4093
4720
  console.log();
4094
4721
  if (keys.length === 0) {
4095
- console.log(chalk14.dim(" (no variables)"));
4722
+ console.log(chalk15.dim(" (no variables)"));
4096
4723
  } else {
4097
4724
  for (const key of keys) {
4098
4725
  const value = secrets[key];
4099
4726
  const masked = maskForPreview(value);
4100
- console.log(` ${chalk14.cyan(key)}=${chalk14.dim(masked)}`);
4727
+ console.log(` ${chalk15.cyan(key)}=${chalk15.dim(masked)}`);
4101
4728
  }
4102
4729
  }
4103
4730
  console.log();
4104
4731
  success("Dry run \u2014 no command executed.");
4105
4732
  }
4106
4733
  function maskForPreview(value) {
4107
- if (value.length <= 6) return "******";
4108
- return `${value.slice(0, 4)}\u2026${value.slice(-2)} (${value.length} chars)`;
4734
+ const len = value.length;
4735
+ const dots = "\u2022".repeat(6);
4736
+ if (len < 12) return `${dots} (${len} chars)`;
4737
+ return `${value.slice(0, 2)}${dots} (${len} chars)`;
4109
4738
  }
4110
4739
  function runChild(commandArgs, env, options) {
4111
4740
  return new Promise((resolve3) => {
4112
- const [command, ...args] = commandArgs;
4741
+ let command;
4742
+ let args;
4743
+ if (options.shell) {
4744
+ command = process.env.SHELL || "/bin/sh";
4745
+ args = ["-c", commandArgs.join(" ")];
4746
+ } else {
4747
+ [command, ...args] = commandArgs;
4748
+ }
4113
4749
  if (!command) {
4114
- process.exit(1);
4750
+ resolve3(1);
4751
+ return;
4115
4752
  }
4116
- const isWindows = process.platform === "win32";
4117
- const useShell = options.shell === true || isWindows;
4118
- const child = spawn2(command, args, {
4753
+ const child = crossSpawn(command, args, {
4119
4754
  stdio: "inherit",
4120
4755
  env,
4121
- shell: useShell
4756
+ shell: false
4122
4757
  });
4123
- const signals = [
4124
- "SIGINT",
4125
- "SIGTERM",
4126
- "SIGHUP",
4127
- "SIGQUIT"
4128
- ];
4758
+ const signals = ["SIGTERM", "SIGHUP"];
4129
4759
  const forward = {};
4130
4760
  for (const sig of signals) {
4131
4761
  const handler = () => {
@@ -4148,67 +4778,66 @@ function runChild(commandArgs, env, options) {
4148
4778
  child.on("error", (err) => {
4149
4779
  cleanup();
4150
4780
  const code = err.code;
4781
+ const displayCommand = options.shell ? commandArgs.join(" ") : command;
4151
4782
  if (code === "ENOENT") {
4152
4783
  error(
4153
- `Command not found: ${command}. Make sure it is installed and on your PATH.`
4784
+ `Command not found: ${displayCommand}. Make sure it is installed and on your PATH.`
4154
4785
  );
4786
+ resolve3(127);
4155
4787
  } else if (code === "EACCES") {
4156
- error(`Permission denied executing: ${command}`);
4788
+ error(`Permission denied executing: ${displayCommand}`);
4789
+ resolve3(126);
4157
4790
  } else {
4158
4791
  error(`Failed to spawn process: ${err.message}`);
4792
+ resolve3(1);
4159
4793
  }
4160
- process.exit(1);
4161
4794
  });
4162
4795
  child.on("exit", (code, signal) => {
4163
4796
  cleanup();
4164
4797
  if (signal) {
4165
- try {
4166
- process.kill(process.pid, signal);
4167
- } catch {
4168
- process.exit(1);
4169
- }
4798
+ const signum = osConstants.signals[signal] ?? 0;
4799
+ resolve3(128 + signum);
4170
4800
  } else {
4171
- process.exit(code ?? 0);
4801
+ resolve3(code ?? 0);
4172
4802
  }
4173
- resolve3();
4174
4803
  });
4175
4804
  });
4176
4805
  }
4177
4806
 
4178
4807
  // src/commands/man.ts
4179
- import { Command as Command14 } from "commander";
4180
- import chalk15 from "chalk";
4808
+ import { Command as Command15 } from "commander";
4809
+ import chalk16 from "chalk";
4181
4810
  function printCommandManual(commandName) {
4182
4811
  const command = findCommandDefinition(commandName);
4183
4812
  if (!command) {
4184
- console.log(chalk15.red(`Unknown command reference: ${commandName}`));
4813
+ console.log(chalk16.red(`Unknown command reference: ${commandName}`));
4185
4814
  console.log();
4186
4815
  console.log("Run `envpilot man` to see all supported commands.");
4187
4816
  process.exit(1);
4188
4817
  }
4189
- console.log(chalk15.bold(formatArgv(command.argv)));
4818
+ console.log(chalk16.bold(formatArgv(command.argv)));
4190
4819
  if (command.args) {
4191
- console.log(chalk15.dim(command.args));
4820
+ console.log(chalk16.dim(command.args));
4192
4821
  }
4193
4822
  blank();
4194
4823
  console.log(command.description);
4195
4824
  blank();
4196
- console.log(chalk15.green("Examples"));
4825
+ console.log(chalk16.green("Examples"));
4197
4826
  for (const example of command.examples) {
4198
- console.log(` ${chalk15.cyan(formatArgv(example))}`);
4827
+ console.log(` ${chalk16.cyan(formatArgv(example))}`);
4199
4828
  }
4200
4829
  blank();
4201
- console.log(chalk15.green("Notes"));
4830
+ console.log(chalk16.green("Notes"));
4202
4831
  for (const note of command.notes) {
4203
4832
  console.log(` - ${note}`);
4204
4833
  }
4205
4834
  }
4206
4835
  function printManualIndex(commands) {
4207
- console.log(chalk15.bold("ENVPILOT(1)"));
4836
+ console.log(chalk16.bold("ENVPILOT(1)"));
4208
4837
  console.log("TypeScript CLI manual");
4209
4838
  blank();
4210
4839
  console.log(
4211
- `Total supported top-level commands: ${chalk15.green(String(CLI_COMMAND_COUNT))}`
4840
+ `Total supported top-level commands: ${chalk16.green(String(CLI_COMMAND_COUNT))}`
4212
4841
  );
4213
4842
  blank();
4214
4843
  console.log(
@@ -4216,15 +4845,15 @@ function printManualIndex(commands) {
4216
4845
  );
4217
4846
  blank();
4218
4847
  line();
4219
- console.log(chalk15.green("Commands"));
4848
+ console.log(chalk16.green("Commands"));
4220
4849
  for (const command of commands) {
4221
4850
  console.log(
4222
- ` ${chalk15.cyan(formatArgv(command.argv).padEnd(24))} ${chalk15.dim(command.description)}`
4851
+ ` ${chalk16.cyan(formatArgv(command.argv).padEnd(24))} ${chalk16.dim(command.description)}`
4223
4852
  );
4224
4853
  }
4225
4854
  blank();
4226
4855
  line();
4227
- console.log(chalk15.green("Security"));
4856
+ console.log(chalk16.green("Security"));
4228
4857
  console.log(" - `.env*` is ignored by the repository `.gitignore`.");
4229
4858
  console.log(
4230
4859
  " - `envpilot init` and `envpilot sync` ensure env files are added to `.gitignore` locally."
@@ -4237,7 +4866,7 @@ function printManualIndex(commands) {
4237
4866
  );
4238
4867
  blank();
4239
4868
  line();
4240
- console.log(chalk15.green("Usage"));
4869
+ console.log(chalk16.green("Usage"));
4241
4870
  console.log(
4242
4871
  " - `envpilot` or `envpilot ui` opens the interactive terminal UI."
4243
4872
  );
@@ -4246,7 +4875,7 @@ function printManualIndex(commands) {
4246
4875
  );
4247
4876
  }
4248
4877
  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) => {
4878
+ return new Command15("man").description("Show the CLI manual page").argument("[command]", "Optional command to show detailed manual for").action((command) => {
4250
4879
  if (command) {
4251
4880
  printCommandManual(command);
4252
4881
  return;
@@ -4256,9 +4885,9 @@ function createManCommand(commands) {
4256
4885
  }
4257
4886
 
4258
4887
  // src/commands/ui.ts
4259
- import { Command as Command15 } from "commander";
4888
+ import { Command as Command16 } from "commander";
4260
4889
  function createUICommand() {
4261
- return new Command15("ui").alias("dashboard").description("Open the interactive Ink-powered terminal UI").action(async () => {
4890
+ return new Command16("ui").alias("dashboard").description("Open the interactive Ink-powered terminal UI").action(async () => {
4262
4891
  if (process.env.ENVPILOT_TUI_CHILD === "1") {
4263
4892
  return;
4264
4893
  }
@@ -4395,13 +5024,14 @@ var COMMAND_CATALOG = [
4395
5024
  id: "push",
4396
5025
  title: "Push variables",
4397
5026
  category: "Sync",
4398
- description: "Upload local variables back to Envpilot with approval-aware write behavior.",
5027
+ description: "Upload local variables back to Envpilot, writing only the keys you have access to.",
4399
5028
  argv: ["push"],
4400
5029
  args: "[--env <environment>] [--file <path>] [--merge|--replace]",
4401
5030
  examples: [["push"], ["push", "--replace"]],
4402
5031
  websiteSurface: "Maps to `/api/cli/variables` and `/api/cli/variables/bulk` for writes.",
4403
5032
  notes: [
4404
- "Managers/admins write directly; lower roles create requests for approval.",
5033
+ "Owners, project managers, and team leads write across the project; developers write only the variables they hold a write grant for.",
5034
+ "Keys you cannot write are skipped \u2014 push does not create approval requests.",
4405
5035
  "Compares local and remote variables before applying changes."
4406
5036
  ],
4407
5037
  keywords: ["upload", "bulk", "merge", "replace"],
@@ -4577,6 +5207,27 @@ var COMMAND_CATALOG = [
4577
5207
  topLevel: true,
4578
5208
  createCommand: () => whoamiCommand
4579
5209
  },
5210
+ {
5211
+ id: "accounts",
5212
+ title: "Manage accounts",
5213
+ category: "Account",
5214
+ description: "List authenticated accounts and switch or remove them without logging out.",
5215
+ argv: ["accounts"],
5216
+ args: "[list|switch <identifier>|remove <identifier>]",
5217
+ examples: [
5218
+ ["accounts"],
5219
+ ["accounts", "switch", "you@example.com"],
5220
+ ["accounts", "remove", "you@example.com"]
5221
+ ],
5222
+ websiteSurface: "Local multi-account state \u2014 each `envpilot login` adds an account instead of replacing the current session.",
5223
+ notes: [
5224
+ "Identifiers can be an account id or the account's email (case-insensitive).",
5225
+ "Switching accounts does not log anyone out; use `envpilot logout` to remove the active session."
5226
+ ],
5227
+ keywords: ["accounts", "multi-account", "switch", "remove", "identity"],
5228
+ topLevel: true,
5229
+ createCommand: () => accountsCommand
5230
+ },
4580
5231
  {
4581
5232
  id: "config",
4582
5233
  title: "Config",