@envpilot/cli 1.6.1 → 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.6.1" : "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");
412
+ return getActiveAccount()?.user;
120
413
  }
121
- function setUser(user) {
122
- config.set("user", user);
414
+ function getUnifiedRole() {
415
+ return normalizeOrgRole(getActiveAccount()?.role);
123
416
  }
124
- function getRole() {
125
- return config.get("role");
126
- }
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();
@@ -143,16 +436,13 @@ function getConfigPath() {
143
436
  return config.path;
144
437
  }
145
438
 
146
- // src/ui/render-tui.tsx
147
- import chalk from "chalk";
148
-
149
439
  // src/ui/execute-command.ts
150
440
  import { spawn } from "child_process";
151
- import { dirname, resolve } from "path";
441
+ import { dirname as dirname2, resolve } from "path";
152
442
  import { fileURLToPath } from "url";
153
443
  async function executeCommand(argv) {
154
444
  const scriptPath = resolve(
155
- dirname(fileURLToPath(import.meta.url)),
445
+ dirname2(fileURLToPath(import.meta.url)),
156
446
  "index.js"
157
447
  );
158
448
  if (process.stdin.isTTY && process.stdin.isRaw) {
@@ -177,9 +467,10 @@ async function executeCommand(argv) {
177
467
  // src/ui/render-tui.tsx
178
468
  import { jsx } from "react/jsx-runtime";
179
469
  async function openTUI() {
180
- const [{ render }, { CLIApp }] = await Promise.all([
470
+ const [{ render }, { CLIApp }, { PressAnyKey }] = await Promise.all([
181
471
  import("ink"),
182
- import("./app-LNI6WTJZ.js")
472
+ import("./app-3LJYJ4RK.js"),
473
+ import("./press-any-key-64XFP4O2.js")
183
474
  ]);
184
475
  while (true) {
185
476
  let selectedArgv = null;
@@ -201,24 +492,18 @@ async function openTUI() {
201
492
  if (code !== 0) {
202
493
  process.exitCode = code;
203
494
  }
204
- console.log();
205
- console.log(
206
- chalk.dim(" Press any key to return to the TUI\u2026 (q to quit)")
207
- );
208
- const quit = await new Promise((resolve3) => {
209
- if (process.stdin.isTTY) {
210
- process.stdin.setRawMode(true);
211
- }
212
- process.stdin.resume();
213
- process.stdin.once("data", (data) => {
214
- const ch = data.toString();
215
- if (process.stdin.isTTY) {
216
- process.stdin.setRawMode(false);
495
+ let quit = false;
496
+ const prompt = render(
497
+ /* @__PURE__ */ jsx(
498
+ PressAnyKey,
499
+ {
500
+ onResolve: (q) => {
501
+ quit = q;
502
+ }
217
503
  }
218
- process.stdin.pause();
219
- resolve3(ch === "q" || ch === "Q");
220
- });
221
- });
504
+ )
505
+ );
506
+ await prompt.waitUntilExit();
222
507
  if (quit) {
223
508
  break;
224
509
  }
@@ -229,7 +514,7 @@ async function openTUI() {
229
514
  import { Command } from "commander";
230
515
 
231
516
  // src/lib/ui.ts
232
- import chalk2 from "chalk";
517
+ import chalk from "chalk";
233
518
  import ora from "ora";
234
519
  function createSpinner(text) {
235
520
  return ora({
@@ -250,25 +535,25 @@ async function withSpinner(text, operation, options) {
250
535
  }
251
536
  }
252
537
  function success(message) {
253
- console.log(chalk2.green("\u2713"), message);
538
+ console.log(chalk.green("\u2713"), message);
254
539
  }
255
540
  function info(message) {
256
- console.log(chalk2.blue("\u2139"), message);
541
+ console.log(chalk.blue("\u2139"), message);
257
542
  }
258
543
  function warning(message) {
259
- console.log(chalk2.yellow("\u26A0"), message);
544
+ console.log(chalk.yellow("\u26A0"), message);
260
545
  }
261
546
  function error(message) {
262
- console.log(chalk2.red("\u2717"), message);
547
+ console.log(chalk.red("\u2717"), message);
263
548
  }
264
549
  function header(text) {
265
550
  console.log();
266
- console.log(chalk2.bold(text));
267
- console.log(chalk2.dim("\u2500".repeat(text.length)));
551
+ console.log(chalk.bold(text));
552
+ console.log(chalk.dim("\u2500".repeat(text.length)));
268
553
  }
269
554
  function table(data, columns) {
270
555
  if (data.length === 0) {
271
- console.log(chalk2.dim("No data to display"));
556
+ console.log(chalk.dim("No data to display"));
272
557
  return;
273
558
  }
274
559
  const widths = columns.map((col) => {
@@ -279,8 +564,8 @@ function table(data, columns) {
279
564
  return col.width ?? Math.max(headerWidth, maxDataWidth);
280
565
  });
281
566
  const headerLine = columns.map((col, i) => col.header.padEnd(widths[i])).join(" ");
282
- console.log(chalk2.bold(headerLine));
283
- console.log(chalk2.dim("\u2500".repeat(headerLine.length)));
567
+ console.log(chalk.bold(headerLine));
568
+ console.log(chalk.dim("\u2500".repeat(headerLine.length)));
284
569
  for (const row of data) {
285
570
  const line2 = columns.map((col, i) => String(row[col.key] ?? "").padEnd(widths[i])).join(" ");
286
571
  console.log(line2);
@@ -290,23 +575,23 @@ function keyValue(pairs) {
290
575
  const maxKeyLength = Math.max(...pairs.map(([key]) => key.length));
291
576
  for (const [key, value] of pairs) {
292
577
  const paddedKey = key.padEnd(maxKeyLength);
293
- console.log(`${chalk2.dim(paddedKey)} ${value ?? chalk2.dim("(not set)")}`);
578
+ console.log(`${chalk.dim(paddedKey)} ${value ?? chalk.dim("(not set)")}`);
294
579
  }
295
580
  }
296
581
  function diff(added, removed, changed) {
297
582
  if (Object.keys(added).length === 0 && Object.keys(removed).length === 0 && Object.keys(changed).length === 0) {
298
- console.log(chalk2.dim("No changes"));
583
+ console.log(chalk.dim("No changes"));
299
584
  return;
300
585
  }
301
586
  for (const [key, value] of Object.entries(added)) {
302
- console.log(chalk2.green(`+ ${key}=${maskValue(value)}`));
587
+ console.log(chalk.green(`+ ${key}=${maskValue(value)}`));
303
588
  }
304
589
  for (const [key, value] of Object.entries(removed)) {
305
- console.log(chalk2.red(`- ${key}=${maskValue(value)}`));
590
+ console.log(chalk.red(`- ${key}=${maskValue(value)}`));
306
591
  }
307
592
  for (const [key, { local, remote }] of Object.entries(changed)) {
308
- console.log(chalk2.red(`- ${key}=${maskValue(remote)}`));
309
- console.log(chalk2.green(`+ ${key}=${maskValue(local)}`));
593
+ console.log(chalk.red(`- ${key}=${maskValue(remote)}`));
594
+ console.log(chalk.green(`+ ${key}=${maskValue(local)}`));
310
595
  }
311
596
  }
312
597
  function maskValue(value, showChars = 4) {
@@ -316,56 +601,51 @@ function maskValue(value, showChars = 4) {
316
601
  return value.slice(0, showChars) + "****" + value.slice(-showChars);
317
602
  }
318
603
  function line() {
319
- console.log(chalk2.dim("\u2500".repeat(50)));
604
+ console.log(chalk.dim("\u2500".repeat(50)));
320
605
  }
321
606
  function blank() {
322
607
  console.log();
323
608
  }
324
609
  function formatRole(role) {
325
- switch (role) {
326
- case "admin":
327
- return chalk2.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);
328
616
  case "team_lead":
329
- return chalk2.blue("Team Lead");
330
- case "member":
331
- return chalk2.yellow("Member");
332
- default:
333
- return chalk2.dim("Unknown");
617
+ return chalk.blue(label);
618
+ case "developer":
619
+ return chalk.yellow(label);
334
620
  }
335
621
  }
336
622
  function roleNotice(role) {
337
- if (role === "member") {
623
+ if (normalizeOrgRole(role) === "developer") {
338
624
  console.log(
339
- chalk2.yellow(
340
- " You have Member access. Write operations will create pending requests for approval."
625
+ chalk.yellow(
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."
341
627
  )
342
628
  );
343
629
  }
344
630
  }
345
631
  function formatProjectRole(role) {
346
- switch (role) {
347
- case "manager":
348
- return chalk2.green("Manager");
349
- case "developer":
350
- return chalk2.blue("Developer");
351
- case "viewer":
352
- return chalk2.yellow("Viewer");
353
- default:
354
- return chalk2.dim("-");
632
+ if (role === void 0 || role === null || role === "") {
633
+ return chalk.dim("-");
355
634
  }
635
+ return formatRole(role);
356
636
  }
357
- function projectRoleNotice(projectRole) {
358
- if (projectRole === "viewer") {
637
+ function projectRoleNotice(role) {
638
+ if (normalizeOrgRole(role) === "developer") {
359
639
  console.log(
360
- chalk2.yellow(
361
- " You have Viewer access to this project. You can only view variables you have been explicitly granted access to."
640
+ chalk.yellow(
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."
362
642
  )
363
643
  );
364
644
  }
365
645
  }
366
646
 
367
647
  // src/lib/errors.ts
368
- import chalk3 from "chalk";
648
+ import chalk2 from "chalk";
369
649
  var CLIError = class extends Error {
370
650
  constructor(message, code, suggestion) {
371
651
  super(message);
@@ -390,17 +670,17 @@ var ErrorCodes = {
390
670
  };
391
671
  function formatError(error2) {
392
672
  if (error2 instanceof CLIError) {
393
- let message = chalk3.red(`Error: ${error2.message}`);
673
+ let message = chalk2.red(`Error: ${error2.message}`);
394
674
  if (error2.suggestion) {
395
675
  message += `
396
- ${chalk3.yellow("Suggestion:")} ${error2.suggestion}`;
676
+ ${chalk2.yellow("Suggestion:")} ${error2.suggestion}`;
397
677
  }
398
678
  return message;
399
679
  }
400
680
  if (error2 instanceof Error) {
401
- return chalk3.red(`Error: ${error2.message}`);
681
+ return chalk2.red(`Error: ${error2.message}`);
402
682
  }
403
- return chalk3.red(`Error: ${String(error2)}`);
683
+ return chalk2.red(`Error: ${String(error2)}`);
404
684
  }
405
685
  async function handleError(error2) {
406
686
  console.error(formatError(error2));
@@ -457,7 +737,7 @@ function invalidInput(message) {
457
737
 
458
738
  // src/lib/auth-flow.ts
459
739
  import open from "open";
460
- import chalk4 from "chalk";
740
+ import chalk3 from "chalk";
461
741
  import { hostname } from "os";
462
742
 
463
743
  // src/lib/api.ts
@@ -478,6 +758,9 @@ var APIError = class extends Error {
478
758
  var APIClient = class {
479
759
  baseUrl;
480
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;
481
764
  constructor(options) {
482
765
  this.baseUrl = options?.baseUrl ?? getApiUrl();
483
766
  this.accessToken = options?.accessToken ?? getAccessToken();
@@ -556,70 +839,138 @@ var APIClient = class {
556
839
  "TOO_MANY_REDIRECTS"
557
840
  );
558
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
+ }
559
900
  /**
560
901
  * Make a GET request
561
902
  */
562
903
  async get(path, params) {
563
- const url = new URL(path, this.baseUrl);
564
- if (params) {
565
- for (const [key, value] of Object.entries(params)) {
566
- 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
+ }
567
910
  }
568
- }
569
- const response = await this.fetchWithSafeRedirects(url.toString(), {
570
- method: "GET",
571
- headers: this.getHeaders()
911
+ const response = await this.fetchWithSafeRedirects(url.toString(), {
912
+ method: "GET",
913
+ headers: this.getHeaders()
914
+ });
915
+ return this.handleResponse(response);
572
916
  });
573
- return this.handleResponse(response);
574
917
  }
575
918
  /**
576
919
  * Make a POST request
577
920
  */
578
921
  async post(path, body) {
579
- const url = new URL(path, this.baseUrl);
580
- const response = await this.fetchWithSafeRedirects(url.toString(), {
581
- method: "POST",
582
- headers: this.getHeaders(),
583
- 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);
584
930
  });
585
- return this.handleResponse(response);
586
931
  }
587
932
  /**
588
933
  * Make a PUT request
589
934
  */
590
935
  async put(path, body) {
591
- const url = new URL(path, this.baseUrl);
592
- const response = await this.fetchWithSafeRedirects(url.toString(), {
593
- method: "PUT",
594
- headers: this.getHeaders(),
595
- 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);
596
944
  });
597
- return this.handleResponse(response);
598
945
  }
599
946
  /**
600
947
  * Make a PATCH request
601
948
  */
602
949
  async patch(path, body) {
603
- const url = new URL(path, this.baseUrl);
604
- const response = await this.fetchWithSafeRedirects(url.toString(), {
605
- method: "PATCH",
606
- headers: this.getHeaders(),
607
- 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);
608
958
  });
609
- return this.handleResponse(response);
610
959
  }
611
960
  /**
612
961
  * Make a DELETE request
613
962
  */
614
963
  async delete(path) {
615
- const url = new URL(path, this.baseUrl);
616
- const response = await this.fetchWithSafeRedirects(url.toString(), {
617
- method: "DELETE",
618
- 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
+ }
619
973
  });
620
- if (!response.ok) {
621
- await this.handleError(response);
622
- }
623
974
  }
624
975
  /**
625
976
  * Handle API response
@@ -660,18 +1011,25 @@ var APIClient = class {
660
1011
  * Handle API errors
661
1012
  */
662
1013
  async handleError(response) {
1014
+ const bodyText = await response.text();
663
1015
  let message = `Request failed with status ${response.status}`;
664
1016
  let code;
665
1017
  try {
666
- const data = await response.json();
1018
+ const data = JSON.parse(bodyText);
667
1019
  message = data.error || data.message || message;
668
1020
  code = data.code;
669
1021
  } catch {
670
1022
  }
671
1023
  if (response.status === 401) {
672
- 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
+ }
673
1031
  throw new APIError(
674
- "Authentication failed. Please run `envpilot login`.",
1032
+ message || "Authentication failed. Please run `envpilot login`.",
675
1033
  401,
676
1034
  code || "UNAUTHORIZED"
677
1035
  );
@@ -865,12 +1223,12 @@ async function performLogin(options) {
865
1223
  }
866
1224
  spinner.stop();
867
1225
  console.log();
868
- console.log(chalk4.bold("Your authentication code:"));
1226
+ console.log(chalk3.bold("Your authentication code:"));
869
1227
  console.log();
870
- console.log(chalk4.cyan.bold(` ${initResponse.code}`));
1228
+ console.log(chalk3.cyan.bold(` ${initResponse.code}`));
871
1229
  console.log();
872
1230
  console.log(`Open this URL to authenticate:`);
873
- console.log(chalk4.dim(initResponse.url));
1231
+ console.log(chalk3.dim(initResponse.url));
874
1232
  console.log();
875
1233
  if (options?.browser !== false) {
876
1234
  info("Opening browser...");
@@ -902,17 +1260,21 @@ async function performLogin(options) {
902
1260
  "Authentication succeeded but the server did not return session tokens. This is likely a server-side issue. Please try again or contact support."
903
1261
  );
904
1262
  }
905
- setAccessToken(pollResponse.accessToken);
906
- setRefreshToken(pollResponse.refreshToken);
907
- if (pollResponse.user) {
908
- setUser({
1263
+ const accountId = pollResponse.user?.id ?? `session-${pollResponse.accessToken.slice(0, 8)}`;
1264
+ const account = {
1265
+ id: accountId,
1266
+ user: pollResponse.user ? {
909
1267
  id: pollResponse.user.id,
910
1268
  email: pollResponse.user.email,
911
1269
  name: pollResponse.user.name
912
- });
913
- }
1270
+ } : { id: accountId, email: `${accountId}@cli.local` },
1271
+ accessToken: pollResponse.accessToken,
1272
+ refreshToken: pollResponse.refreshToken
1273
+ };
1274
+ upsertAccount(account);
1275
+ setActiveAccount(account.id);
914
1276
  console.log();
915
- success(`Logged in as ${chalk4.bold(pollResponse.user?.email)}`);
1277
+ success(`Logged in as ${chalk3.bold(pollResponse.user?.email)}`);
916
1278
  return { email: pollResponse.user?.email || "" };
917
1279
  }
918
1280
  if (pollResponse.status === "expired" || pollResponse.status === "not_found") {
@@ -934,6 +1296,16 @@ var loginCommand = new Command("login").description("Authenticate with Envpilot"
934
1296
  await performLogin({
935
1297
  browser: options.browser !== false
936
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
+ }
937
1309
  console.log();
938
1310
  console.log("Next steps:");
939
1311
  info(" envpilot init Initialize a project in the current directory");
@@ -947,13 +1319,19 @@ var loginCommand = new Command("login").description("Authenticate with Envpilot"
947
1319
 
948
1320
  // src/commands/init.ts
949
1321
  import { Command as Command2 } from "commander";
950
- import chalk5 from "chalk";
1322
+ import chalk4 from "chalk";
951
1323
  import inquirer from "inquirer";
952
1324
 
953
1325
  // src/lib/project-config.ts
954
- 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";
955
1333
  import { execSync } from "child_process";
956
- import { join } from "path";
1334
+ import { join as join2 } from "path";
957
1335
 
958
1336
  // src/types/index.ts
959
1337
  import { z } from "zod";
@@ -962,12 +1340,25 @@ var userSchema = z.object({
962
1340
  email: z.string().email(),
963
1341
  name: z.string().optional()
964
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
+ });
965
1354
  var organizationSchema = z.object({
966
1355
  _id: z.string(),
967
1356
  name: z.string(),
968
1357
  slug: z.string(),
969
1358
  tier: z.enum(["free", "pro"]),
970
- 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()
971
1362
  });
972
1363
  var projectSchema = z.object({
973
1364
  _id: z.string(),
@@ -978,7 +1369,11 @@ var projectSchema = z.object({
978
1369
  icon: z.string().optional(),
979
1370
  color: z.string().optional(),
980
1371
  userRole: z.string().nullable().optional(),
981
- 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()
982
1377
  });
983
1378
  var variableTagSchema = z.object({
984
1379
  _id: z.string(),
@@ -996,21 +1391,49 @@ var variableSchema = z.object({
996
1391
  version: z.number().optional(),
997
1392
  updatedAt: z.number().optional(),
998
1393
  createdAt: z.number().optional(),
999
- 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()
1000
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();
1001
1411
  var environmentSchema = z.enum([
1002
1412
  "development",
1003
1413
  "staging",
1004
1414
  "production"
1005
1415
  ]);
1006
1416
  var cliConfigSchema = z.object({
1417
+ // Global CLI setting — shared across all accounts.
1007
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.
1008
1427
  accessToken: z.string().optional(),
1009
1428
  refreshToken: z.string().optional(),
1010
1429
  activeProjectId: z.string().optional(),
1011
1430
  activeOrganizationId: z.string().optional(),
1012
1431
  user: userSchema.optional(),
1013
- 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()
1014
1437
  });
1015
1438
  var projectConfigSchema = z.object({
1016
1439
  projectId: z.string(),
@@ -1022,7 +1445,12 @@ var projectEntrySchema = z.object({
1022
1445
  organizationId: z.string(),
1023
1446
  projectName: z.string().default(""),
1024
1447
  organizationName: z.string().default(""),
1025
- 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()
1026
1454
  });
1027
1455
  var projectConfigV2Schema = z.object({
1028
1456
  version: z.literal(1),
@@ -1032,17 +1460,30 @@ var projectConfigV2Schema = z.object({
1032
1460
 
1033
1461
  // src/lib/project-config.ts
1034
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
+ }
1035
1476
  function getProjectConfigPath(directory = process.cwd()) {
1036
- return join(directory, CONFIG_FILE_NAME);
1477
+ return join2(directory, CONFIG_FILE_NAME);
1037
1478
  }
1038
1479
  function hasProjectConfig(directory = process.cwd()) {
1039
- return existsSync(getProjectConfigPath(directory));
1480
+ return existsSync2(getProjectConfigPath(directory));
1040
1481
  }
1041
1482
  function readRawConfig(directory = process.cwd()) {
1042
1483
  const configPath = getProjectConfigPath(directory);
1043
- if (!existsSync(configPath)) return null;
1484
+ if (!existsSync2(configPath)) return null;
1044
1485
  try {
1045
- return JSON.parse(readFileSync(configPath, "utf-8"));
1486
+ return JSON.parse(readFileSync2(configPath, "utf-8"));
1046
1487
  } catch {
1047
1488
  return null;
1048
1489
  }
@@ -1089,7 +1530,7 @@ function readProjectConfigV2(directory = process.cwd()) {
1089
1530
  }
1090
1531
  function writeProjectConfigV2(config2, directory = process.cwd()) {
1091
1532
  const configPath = getProjectConfigPath(directory);
1092
- writeFileSync(configPath, JSON.stringify(config2, null, 2) + "\n", "utf-8");
1533
+ atomicWriteFileSync(configPath, JSON.stringify(config2, null, 2) + "\n");
1093
1534
  }
1094
1535
  function getActiveProject(config2) {
1095
1536
  return config2.projects.find((p) => p.projectId === config2.activeProjectId) || config2.projects[0] || null;
@@ -1182,23 +1623,23 @@ function updateProjectConfig(updates, directory = process.cwd()) {
1182
1623
  }
1183
1624
  function deleteProjectConfig(directory = process.cwd()) {
1184
1625
  const configPath = getProjectConfigPath(directory);
1185
- if (!existsSync(configPath)) return false;
1186
- unlinkSync(configPath);
1626
+ if (!existsSync2(configPath)) return false;
1627
+ unlinkSync2(configPath);
1187
1628
  return true;
1188
1629
  }
1189
1630
  function ensureEnvInGitignore(directory = process.cwd()) {
1190
- const gitignorePath = join(directory, ".gitignore");
1191
- if (!existsSync(gitignorePath)) {
1192
- 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");
1193
1634
  return;
1194
1635
  }
1195
- const content = readFileSync(gitignorePath, "utf-8");
1636
+ const content = readFileSync2(gitignorePath, "utf-8");
1196
1637
  const lines = content.split("\n");
1197
1638
  if (lines.some((line2) => line2.trim() === ".env")) {
1198
1639
  return;
1199
1640
  }
1200
1641
  const newContent = content.endsWith("\n") ? content + ".env\n" : content + "\n.env\n";
1201
- writeFileSync(gitignorePath, newContent, "utf-8");
1642
+ atomicWriteFileSync(gitignorePath, newContent);
1202
1643
  }
1203
1644
  function getTrackedEnvFiles(directory = process.cwd()) {
1204
1645
  try {
@@ -1214,8 +1655,14 @@ function getTrackedEnvFiles(directory = process.cwd()) {
1214
1655
  }
1215
1656
 
1216
1657
  // src/lib/env-file.ts
1217
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, chmodSync } from "fs";
1218
- 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";
1219
1666
  function parseEnvFile(content) {
1220
1667
  const result = {};
1221
1668
  const lines = content.split("\n");
@@ -1228,7 +1675,7 @@ function parseEnvFile(content) {
1228
1675
  if (equalsIndex === -1) {
1229
1676
  continue;
1230
1677
  }
1231
- const key = line2.substring(0, equalsIndex).trim();
1678
+ const key = line2.substring(0, equalsIndex).trim().replace(/^export\s+/, "");
1232
1679
  let value = line2.substring(equalsIndex + 1);
1233
1680
  value = parseValue(value);
1234
1681
  if (isValidEnvKey(key)) {
@@ -1239,17 +1686,21 @@ function parseEnvFile(content) {
1239
1686
  }
1240
1687
  function parseValue(value) {
1241
1688
  value = value.trim();
1242
- if (value.startsWith('"') && value.endsWith('"')) {
1243
- value = value.slice(1, -1);
1244
- value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
1245
- } else if (value.startsWith("'") && value.endsWith("'")) {
1246
- value = value.slice(1, -1);
1247
- } else {
1248
- const commentIndex = value.indexOf(" #");
1249
- if (commentIndex !== -1) {
1250
- 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;
1251
1698
  }
1252
1699
  }
1700
+ const commentIndex = value.indexOf(" #");
1701
+ if (commentIndex !== -1) {
1702
+ value = value.substring(0, commentIndex).trim();
1703
+ }
1253
1704
  return value;
1254
1705
  }
1255
1706
  function isValidEnvKey(key) {
@@ -1301,33 +1752,53 @@ function diffEnvVars(local, remote) {
1301
1752
  return { added, removed, changed, unchanged };
1302
1753
  }
1303
1754
  function readEnvFile(filePath) {
1304
- if (!existsSync2(filePath)) {
1755
+ if (!existsSync3(filePath)) {
1305
1756
  return null;
1306
1757
  }
1307
- const content = readFileSync2(filePath, "utf-8");
1758
+ const content = readFileSync3(filePath, "utf-8");
1308
1759
  return parseEnvFile(content);
1309
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
+ }
1310
1774
  function writeEnvFile(filePath, vars, options) {
1311
1775
  const content = stringifyEnv(vars, options);
1312
- writeFileSync2(filePath, content, "utf-8");
1776
+ atomicWriteFileSync2(filePath, content, 384);
1313
1777
  }
1314
1778
  function getEnvPathForEnvironment(environment, directory = process.cwd()) {
1315
1779
  if (environment === "development") {
1316
- return join2(directory, ".env.local");
1780
+ return join3(directory, ".env.local");
1317
1781
  }
1318
- return join2(directory, `.env.${environment}`);
1782
+ return join3(directory, `.env.${environment}`);
1319
1783
  }
1320
- function applyFileProtection(filePath, role, projectRole) {
1321
- if (!existsSync2(filePath)) return;
1322
- const isWritable = role === "admin" || role === "team_lead" || projectRole === "manager";
1323
- if (isWritable) {
1324
- chmodSync(filePath, 420);
1325
- } else {
1326
- 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;
1327
1793
  }
1794
+ chmodSync(filePath, isFileWritable(access) ? 384 : 256);
1328
1795
  }
1329
1796
 
1330
1797
  // src/commands/init.ts
1798
+ function orgUnifiedRole(org) {
1799
+ const unified = org.unifiedRole;
1800
+ return normalizeOrgRole(unified ?? org.role);
1801
+ }
1331
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(
1332
1803
  "-e, --environment <env>",
1333
1804
  "Default environment (development, staging, production)"
@@ -1376,9 +1847,7 @@ var initCommand = new Command2("init").description("Initialize Envpilot in the c
1376
1847
  });
1377
1848
  setActiveOrganizationId(selectedOrg._id);
1378
1849
  setActiveProjectId(selectedProject._id);
1379
- if (selectedOrg.role) {
1380
- setRole(selectedOrg.role);
1381
- }
1850
+ setUnifiedRole(orgUnifiedRole(selectedOrg));
1382
1851
  ensureEnvInGitignore();
1383
1852
  warnTrackedFiles();
1384
1853
  console.log();
@@ -1390,21 +1859,23 @@ var initCommand = new Command2("init").description("Initialize Envpilot in the c
1390
1859
  });
1391
1860
  async function addProject(existingConfig, options) {
1392
1861
  const api = createAPIClient();
1393
- let role = getRole();
1394
- if (role !== "admin" && role !== "team_lead") {
1862
+ let role = getUnifiedRole();
1863
+ if (roleLevel(role) < ROLE_LEVEL.team_lead) {
1395
1864
  const orgs = await withSpinner("Checking permissions...", async () => {
1396
1865
  const response = await api.get("/api/cli/organizations");
1397
1866
  return response.data || [];
1398
1867
  });
1399
- const freshRole = orgs.find(
1868
+ const freshOrg = orgs.find(
1400
1869
  (o) => o._id === existingConfig.projects[0]?.organizationId
1401
- )?.role;
1402
- if (freshRole) {
1403
- setRole(freshRole);
1404
- role = freshRole;
1870
+ );
1871
+ if (freshOrg) {
1872
+ role = orgUnifiedRole(freshOrg);
1873
+ setUnifiedRole(role);
1405
1874
  }
1406
- if (role !== "admin" && role !== "team_lead") {
1407
- 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
+ );
1408
1879
  info("Unlink the current project first with `envpilot unlink`.");
1409
1880
  process.exit(1);
1410
1881
  }
@@ -1452,18 +1923,18 @@ async function addProject(existingConfig, options) {
1452
1923
  console.log();
1453
1924
  success(`Added "${selectedProject.name}" to linked projects!`);
1454
1925
  console.log(
1455
- chalk5.dim(` ${existingConfig.projects.length + 1} projects now linked`)
1926
+ chalk4.dim(` ${existingConfig.projects.length + 1} projects now linked`)
1456
1927
  );
1457
1928
  console.log();
1458
1929
  console.log("Next steps:");
1459
1930
  console.log(
1460
- ` ${chalk5.cyan("envpilot pull --all")} Pull all projects`
1931
+ ` ${chalk4.cyan("envpilot pull --all")} Pull all projects`
1461
1932
  );
1462
1933
  console.log(
1463
- ` ${chalk5.cyan(`envpilot pull --project "${selectedProject.name}"`)} Pull this project`
1934
+ ` ${chalk4.cyan(`envpilot pull --project "${selectedProject.name}"`)} Pull this project`
1464
1935
  );
1465
1936
  console.log(
1466
- ` ${chalk5.cyan("envpilot list linked")} See all linked projects`
1937
+ ` ${chalk4.cyan("envpilot list linked")} See all linked projects`
1467
1938
  );
1468
1939
  console.log();
1469
1940
  }
@@ -1492,7 +1963,7 @@ async function selectOrgProjectEnv(options) {
1492
1963
  selectedOrg = org;
1493
1964
  } else if (organizations.length === 1) {
1494
1965
  selectedOrg = organizations[0];
1495
- info(`Using organization: ${chalk5.bold(selectedOrg.name)}`);
1966
+ info(`Using organization: ${chalk4.bold(selectedOrg.name)}`);
1496
1967
  } else {
1497
1968
  const { orgId } = await inquirer.prompt([
1498
1969
  {
@@ -1500,7 +1971,7 @@ async function selectOrgProjectEnv(options) {
1500
1971
  name: "orgId",
1501
1972
  message: "Select an organization:",
1502
1973
  choices: organizations.map((org) => ({
1503
- name: `${org.name} ${org.tier === "pro" ? chalk5.green("(Pro)") : chalk5.dim("(Free)")}`,
1974
+ name: `${org.name} ${org.tier === "pro" ? chalk4.green("(Pro)") : chalk4.dim("(Free)")}`,
1504
1975
  value: org._id
1505
1976
  }))
1506
1977
  }
@@ -1530,7 +2001,7 @@ async function selectOrgProjectEnv(options) {
1530
2001
  selectedProject = project;
1531
2002
  } else if (projects.length === 1) {
1532
2003
  selectedProject = projects[0];
1533
- info(`Using project: ${chalk5.bold(selectedProject.name)}`);
2004
+ info(`Using project: ${chalk4.bold(selectedProject.name)}`);
1534
2005
  } else {
1535
2006
  const { projectId } = await inquirer.prompt([
1536
2007
  {
@@ -1581,48 +2052,43 @@ function warnTrackedFiles() {
1581
2052
  console.log();
1582
2053
  warning("Security risk: .env files are tracked by git!");
1583
2054
  for (const file of trackedFiles) {
1584
- console.log(chalk5.red(` tracked: ${file}`));
2055
+ console.log(chalk4.red(` tracked: ${file}`));
1585
2056
  }
1586
2057
  console.log();
1587
2058
  console.log(
1588
- chalk5.yellow(
2059
+ chalk4.yellow(
1589
2060
  " Run the following to untrack them (without deleting the files):"
1590
2061
  )
1591
2062
  );
1592
2063
  for (const file of trackedFiles) {
1593
- console.log(chalk5.cyan(` git rm --cached ${file}`));
2064
+ console.log(chalk4.cyan(` git rm --cached ${file}`));
1594
2065
  }
1595
2066
  }
1596
2067
  }
1597
2068
  function printPostInit(selectedOrg, selectedProject) {
1598
2069
  console.log();
1599
- console.log(chalk5.dim("Configuration saved to .envpilot"));
1600
- if (selectedOrg.role) {
1601
- console.log(chalk5.dim(` Org role: ${formatRole(selectedOrg.role)}`));
1602
- roleNotice(selectedOrg.role);
1603
- }
1604
- if (selectedProject.projectRole) {
1605
- console.log(
1606
- chalk5.dim(
1607
- ` Project role: ${formatProjectRole(selectedProject.projectRole)}`
1608
- )
1609
- );
1610
- projectRoleNotice(selectedProject.projectRole);
2070
+ console.log(chalk4.dim("Configuration saved to .envpilot"));
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(", ")}`));
1611
2077
  }
1612
2078
  console.log();
1613
2079
  console.log("Next steps:");
1614
2080
  console.log(
1615
- ` ${chalk5.cyan("envpilot pull")} Download environment variables`
2081
+ ` ${chalk4.cyan("envpilot pull")} Download environment variables`
1616
2082
  );
1617
2083
  console.log(
1618
- ` ${chalk5.cyan("envpilot push")} Upload local .env to cloud`
2084
+ ` ${chalk4.cyan("envpilot push")} Upload local .env to cloud`
1619
2085
  );
1620
2086
  console.log();
1621
2087
  }
1622
2088
 
1623
2089
  // src/commands/pull.ts
1624
2090
  import { Command as Command3 } from "commander";
1625
- import chalk6 from "chalk";
2091
+ import chalk5 from "chalk";
1626
2092
  import inquirer2 from "inquirer";
1627
2093
 
1628
2094
  // src/lib/format-converter.ts
@@ -1940,7 +2406,7 @@ function parseNetlify(content) {
1940
2406
  for (const line2 of lines) {
1941
2407
  const trimmed = line2.trim();
1942
2408
  if (trimmed.startsWith("[")) {
1943
- inBuildEnv = trimmed === "[build.environment]" || trimmed === "[build.environment]";
2409
+ inBuildEnv = trimmed === "[build.environment]" || /^\[context\.[^.\]]+\.environment\]$/.test(trimmed);
1944
2410
  continue;
1945
2411
  }
1946
2412
  if (!inBuildEnv) continue;
@@ -1963,6 +2429,17 @@ function parseNetlify(content) {
1963
2429
  }
1964
2430
 
1965
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
+ }
1966
2443
  var pullCommand = new Command3("pull").description("Download environment variables to local .env file").option(
1967
2444
  "-e, --env <environment>",
1968
2445
  "Environment (development, staging, production)"
@@ -2005,6 +2482,8 @@ var pullCommand = new Command3("pull").description("Download environment variabl
2005
2482
  const projectConfig = readProjectConfig();
2006
2483
  if (!projectConfig) throw notInitialized();
2007
2484
  checkTrackedFiles();
2485
+ const configV2ForActive = readProjectConfigV2();
2486
+ const activeEntry = configV2ForActive ? getActiveProject(configV2ForActive) : null;
2008
2487
  const environment = options.env || projectConfig.environment || "development";
2009
2488
  const fmt = options.format || "env";
2010
2489
  if (!ALL_FORMATS.includes(fmt)) {
@@ -2016,7 +2495,8 @@ var pullCommand = new Command3("pull").description("Download environment variabl
2016
2495
  {
2017
2496
  projectId: projectConfig.projectId,
2018
2497
  organizationId: projectConfig.organizationId,
2019
- environment
2498
+ environment,
2499
+ fileProtection: activeEntry?.fileProtection
2020
2500
  },
2021
2501
  outputPath,
2022
2502
  options
@@ -2036,7 +2516,7 @@ async function pullAllProjects(options) {
2036
2516
  const displayName = project.projectName || project.projectId;
2037
2517
  console.log();
2038
2518
  console.log(
2039
- chalk6.bold(
2519
+ chalk5.bold(
2040
2520
  `Pulling "${displayName}" (${project.environment}) \u2192 ${outputPath}`
2041
2521
  )
2042
2522
  );
@@ -2045,7 +2525,8 @@ async function pullAllProjects(options) {
2045
2525
  {
2046
2526
  projectId: project.projectId,
2047
2527
  organizationId: project.organizationId,
2048
- environment: project.environment
2528
+ environment: project.environment,
2529
+ fileProtection: project.fileProtection
2049
2530
  },
2050
2531
  outputPath,
2051
2532
  options
@@ -2076,7 +2557,8 @@ async function pullSingleProject(project, options) {
2076
2557
  {
2077
2558
  projectId: project.projectId,
2078
2559
  organizationId: project.organizationId,
2079
- environment
2560
+ environment,
2561
+ fileProtection: project.fileProtection
2080
2562
  },
2081
2563
  outputPath,
2082
2564
  options
@@ -2084,9 +2566,9 @@ async function pullSingleProject(project, options) {
2084
2566
  }
2085
2567
  async function pullProject(project, outputPath, options) {
2086
2568
  const api = createAPIClient();
2087
- let metaProjectRole;
2569
+ let meta;
2088
2570
  const variables = await withSpinner(
2089
- `Fetching ${chalk6.bold(project.environment)} variables...`,
2571
+ `Fetching ${chalk5.bold(project.environment)} variables...`,
2090
2572
  async () => {
2091
2573
  const response = await api.get("/api/cli/variables", {
2092
2574
  projectId: project.projectId,
@@ -2095,10 +2577,15 @@ async function pullProject(project, outputPath, options) {
2095
2577
  organizationId: project.organizationId
2096
2578
  }
2097
2579
  });
2098
- metaProjectRole = response.meta?.projectRole;
2580
+ meta = response.meta;
2099
2581
  return response.data || [];
2100
2582
  }
2101
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
+ }
2102
2589
  if (variables.length === 0) {
2103
2590
  warning(`No variables found for ${project.environment} environment.`);
2104
2591
  return;
@@ -2115,7 +2602,7 @@ async function pullProject(project, outputPath, options) {
2115
2602
  return;
2116
2603
  }
2117
2604
  console.log();
2118
- console.log(chalk6.bold("Changes:"));
2605
+ console.log(chalk5.bold("Changes:"));
2119
2606
  console.log();
2120
2607
  diff(diffResult.added, diffResult.removed, diffResult.changed);
2121
2608
  console.log();
@@ -2162,29 +2649,30 @@ async function pullProject(project, outputPath, options) {
2162
2649
  });
2163
2650
  fs.writeFileSync(outputPath, output, "utf-8");
2164
2651
  }
2165
- const role = getRole();
2166
- applyFileProtection(outputPath, role, metaProjectRole);
2652
+ const access = accessFromMeta(meta, variables);
2653
+ const protection = project.fileProtection ?? "auto";
2654
+ applyFileProtection(outputPath, access, protection);
2167
2655
  success(
2168
- `Downloaded ${variables.length} variables to ${chalk6.bold(outputPath)}`
2656
+ `Downloaded ${variables.length} variables to ${chalk5.bold(outputPath)}`
2169
2657
  );
2170
- if (metaProjectRole === "viewer") {
2658
+ if (meta?.grantOnly) {
2171
2659
  info(
2172
- "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."
2173
2661
  );
2174
2662
  }
2175
- const isProtected = role !== "admin" && role !== "team_lead" && metaProjectRole !== "manager";
2176
- if (isProtected) {
2663
+ const writable = protection === "never" || protection !== "always" && isFileWritable(access);
2664
+ if (!writable) {
2177
2665
  info(
2178
- `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"}).`
2179
2667
  );
2180
2668
  }
2181
2669
  console.log();
2182
- console.log(chalk6.dim(` Added: ${Object.keys(diffResult.added).length}`));
2670
+ console.log(chalk5.dim(` Added: ${Object.keys(diffResult.added).length}`));
2183
2671
  console.log(
2184
- chalk6.dim(` Changed: ${Object.keys(diffResult.changed).length}`)
2672
+ chalk5.dim(` Changed: ${Object.keys(diffResult.changed).length}`)
2185
2673
  );
2186
2674
  console.log(
2187
- chalk6.dim(` Removed: ${Object.keys(diffResult.removed).length}`)
2675
+ chalk5.dim(` Removed: ${Object.keys(diffResult.removed).length}`)
2188
2676
  );
2189
2677
  }
2190
2678
  function checkTrackedFiles() {
@@ -2193,16 +2681,16 @@ function checkTrackedFiles() {
2193
2681
  error("Security risk: .env files are tracked by git!");
2194
2682
  console.log();
2195
2683
  for (const file of trackedFiles) {
2196
- console.log(chalk6.red(` tracked: ${file}`));
2684
+ console.log(chalk5.red(` tracked: ${file}`));
2197
2685
  }
2198
2686
  console.log();
2199
2687
  console.log(
2200
- chalk6.yellow(
2688
+ chalk5.yellow(
2201
2689
  " Run the following to untrack them (without deleting the files):"
2202
2690
  )
2203
2691
  );
2204
2692
  for (const file of trackedFiles) {
2205
- console.log(chalk6.cyan(` git rm --cached ${file}`));
2693
+ console.log(chalk5.cyan(` git rm --cached ${file}`));
2206
2694
  }
2207
2695
  console.log();
2208
2696
  process.exit(1);
@@ -2211,7 +2699,7 @@ function checkTrackedFiles() {
2211
2699
 
2212
2700
  // src/commands/push.ts
2213
2701
  import { Command as Command4 } from "commander";
2214
- import chalk7 from "chalk";
2702
+ import chalk6 from "chalk";
2215
2703
  import inquirer3 from "inquirer";
2216
2704
 
2217
2705
  // src/lib/validators.ts
@@ -2299,51 +2787,21 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2299
2787
  error("Security risk: .env files are tracked by git!");
2300
2788
  console.log();
2301
2789
  for (const file of trackedFiles) {
2302
- console.log(chalk7.red(` tracked: ${file}`));
2790
+ console.log(chalk6.red(` tracked: ${file}`));
2303
2791
  }
2304
2792
  console.log();
2305
2793
  console.log(
2306
- chalk7.yellow(
2794
+ chalk6.yellow(
2307
2795
  " Run the following to untrack them (without deleting the files):"
2308
2796
  )
2309
2797
  );
2310
2798
  for (const file of trackedFiles) {
2311
- console.log(chalk7.cyan(` git rm --cached ${file}`));
2799
+ console.log(chalk6.cyan(` git rm --cached ${file}`));
2312
2800
  }
2313
2801
  console.log();
2314
2802
  process.exit(1);
2315
2803
  }
2316
2804
  const api = createAPIClient();
2317
- const projects = await api.get("/api/cli/projects", { organizationId });
2318
- const currentProject = projects.data?.find((p) => p._id === projectId);
2319
- const projectRole = currentProject?.projectRole;
2320
- if (projectRole === "viewer") {
2321
- error(
2322
- "Unfortunately, as a member, you cannot push in any environment. Please access or request access to your team lead."
2323
- );
2324
- process.exit(1);
2325
- }
2326
- const role = getRole();
2327
- if (role === "member") {
2328
- warning(
2329
- "You have Member access. Push will create pending requests that require Admin or Team Lead approval."
2330
- );
2331
- console.log();
2332
- if (!options.force) {
2333
- const { proceed } = await inquirer3.prompt([
2334
- {
2335
- type: "confirm",
2336
- name: "proceed",
2337
- message: "Continue with creating approval requests?",
2338
- default: true
2339
- }
2340
- ]);
2341
- if (!proceed) {
2342
- info("Push cancelled.");
2343
- return;
2344
- }
2345
- }
2346
- }
2347
2805
  const environment = options.env || defaultEnvironment || "development";
2348
2806
  const fmt = options.format || "env";
2349
2807
  if (!ALL_FORMATS.includes(fmt)) {
@@ -2375,7 +2833,7 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2375
2833
  if (invalid.length > 0) {
2376
2834
  warning("Some variables have invalid keys and will be skipped:");
2377
2835
  for (const { key, error: err } of invalid) {
2378
- console.log(chalk7.red(` ${key}: ${err}`));
2836
+ console.log(chalk6.red(` ${key}: ${err}`));
2379
2837
  }
2380
2838
  console.log();
2381
2839
  }
@@ -2383,6 +2841,7 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2383
2841
  error("No valid variables to push.");
2384
2842
  return;
2385
2843
  }
2844
+ let meta;
2386
2845
  const remoteVariables = await withSpinner(
2387
2846
  "Fetching current variables...",
2388
2847
  async () => {
@@ -2394,9 +2853,21 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2394
2853
  params.organizationId = organizationId;
2395
2854
  }
2396
2855
  const response = await api.get("/api/cli/variables", params);
2856
+ meta = response.meta;
2397
2857
  return response.data || [];
2398
2858
  }
2399
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
+ }
2400
2871
  const remoteVars = {};
2401
2872
  for (const variable of remoteVariables) {
2402
2873
  remoteVars[variable.key] = variable.value;
@@ -2408,14 +2879,14 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2408
2879
  return;
2409
2880
  }
2410
2881
  console.log();
2411
- console.log(chalk7.bold("Changes to push:"));
2882
+ console.log(chalk6.bold("Changes to push:"));
2412
2883
  console.log();
2413
2884
  const removedToShow = mode === "replace" ? diffResult.removed : {};
2414
2885
  diff(diffResult.added, removedToShow, diffResult.changed);
2415
2886
  if (mode === "merge" && Object.keys(diffResult.removed).length > 0) {
2416
2887
  console.log();
2417
2888
  console.log(
2418
- chalk7.dim(
2889
+ chalk6.dim(
2419
2890
  `Note: ${Object.keys(diffResult.removed).length} remote variables not in local file will be preserved (use --replace to remove them)`
2420
2891
  )
2421
2892
  );
@@ -2452,7 +2923,7 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2452
2923
  }
2453
2924
  }
2454
2925
  const result = await withSpinner(
2455
- `Pushing variables to ${chalk7.bold(environment)}...`,
2926
+ `Pushing variables to ${chalk6.bold(environment)}...`,
2456
2927
  async () => {
2457
2928
  const response = await api.post("/api/cli/variables/bulk", {
2458
2929
  projectId,
@@ -2467,32 +2938,28 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2467
2938
  return response.data;
2468
2939
  }
2469
2940
  );
2470
- if (result?.requested && result.requested > 0) {
2471
- success(
2472
- `Created ${result.requested} pending request(s) for ${chalk7.bold(environment)}`
2473
- );
2474
- console.log();
2475
- console.log(
2476
- chalk7.yellow(
2477
- " These changes require approval from an Admin or Team Lead."
2478
- )
2479
- );
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) {
2480
2952
  console.log();
2481
- console.log(chalk7.dim(` Requested: ${result.requested}`));
2482
- if (result?.skipped && result.skipped > 0) {
2483
- console.log(chalk7.dim(` Skipped: ${result.skipped}`));
2484
- }
2485
- } else {
2486
- success(
2487
- `Pushed ${result?.total || Object.keys(valid).length} variables to ${chalk7.bold(environment)}`
2953
+ warning(
2954
+ `${deniedKeys.length} variable(s) were NOT written (access denied):`
2488
2955
  );
2489
- console.log();
2490
- console.log(chalk7.dim(` Created: ${result?.created || 0}`));
2491
- console.log(chalk7.dim(` Updated: ${result?.updated || 0}`));
2492
- if (mode === "replace") {
2493
- console.log(chalk7.dim(` Deleted: ${result?.deleted || 0}`));
2956
+ for (const key of deniedKeys) {
2957
+ console.log(chalk6.red(` \u2717 ${key}`));
2494
2958
  }
2495
2959
  }
2960
+ if (result?.skipped && result.skipped > 0) {
2961
+ console.log(chalk6.dim(` Skipped: ${result.skipped}`));
2962
+ }
2496
2963
  } catch (err) {
2497
2964
  await handleError(err);
2498
2965
  }
@@ -2500,8 +2967,11 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2500
2967
 
2501
2968
  // src/commands/switch.ts
2502
2969
  import { Command as Command5 } from "commander";
2503
- import chalk8 from "chalk";
2970
+ import chalk7 from "chalk";
2504
2971
  import inquirer4 from "inquirer";
2972
+ function orgUnifiedRole2(org) {
2973
+ return normalizeOrgRole(org.unifiedRole ?? org.role);
2974
+ }
2505
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(
2506
2976
  "-e, --env <environment>",
2507
2977
  "Switch environment (development, staging, production)"
@@ -2528,7 +2998,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2528
2998
  console.log();
2529
2999
  console.log("Linked projects:");
2530
3000
  for (const p of configV2.projects) {
2531
- const mark = p.projectId === configV2.activeProjectId ? chalk8.green(" *") : "";
3001
+ const mark = p.projectId === configV2.activeProjectId ? chalk7.green(" *") : "";
2532
3002
  console.log(
2533
3003
  ` ${p.projectName || p.projectId} (${p.environment})${mark}`
2534
3004
  );
@@ -2540,7 +3010,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2540
3010
  setActiveProjectId(target2.projectId);
2541
3011
  setActiveOrganizationId(target2.organizationId);
2542
3012
  success(
2543
- `Active project: ${chalk8.bold(target2.projectName || target2.projectId)}`
3013
+ `Active project: ${chalk7.bold(target2.projectName || target2.projectId)}`
2544
3014
  );
2545
3015
  return;
2546
3016
  }
@@ -2551,7 +3021,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2551
3021
  process.exit(1);
2552
3022
  }
2553
3023
  updateProjectConfig({ environment });
2554
- success(`Switched to ${chalk8.bold(environment)} environment`);
3024
+ success(`Switched to ${chalk7.bold(environment)} environment`);
2555
3025
  return;
2556
3026
  }
2557
3027
  if (options.organization) {
@@ -2570,17 +3040,15 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2570
3040
  process.exit(1);
2571
3041
  }
2572
3042
  setActiveOrganizationId(org._id);
2573
- if (org.role) {
2574
- setRole(org.role);
2575
- }
3043
+ setUnifiedRole(orgUnifiedRole2(org));
2576
3044
  if (projectConfig) {
2577
3045
  updateProjectConfig({ organizationId: org._id });
2578
3046
  }
2579
- success(`Switched to organization: ${chalk8.bold(org.name)}`);
2580
- if (org.role) {
2581
- console.log(chalk8.dim(` Role: ${formatRole(org.role)}`));
2582
- roleNotice(org.role);
2583
- }
3047
+ success(`Switched to organization: ${chalk7.bold(org.name)}`);
3048
+ console.log(
3049
+ chalk7.dim(` Role: ${formatRoleLabel(orgUnifiedRole2(org))}`)
3050
+ );
3051
+ roleNotice(orgUnifiedRole2(org));
2584
3052
  return;
2585
3053
  }
2586
3054
  if (options.project || target) {
@@ -2599,7 +3067,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2599
3067
  setActiveProjectId(linked.projectId);
2600
3068
  setActiveOrganizationId(linked.organizationId);
2601
3069
  success(
2602
- `Switched to project: ${chalk8.bold(linked.projectName || linked.projectId)}`
3070
+ `Switched to project: ${chalk7.bold(linked.projectName || linked.projectId)}`
2603
3071
  );
2604
3072
  return;
2605
3073
  }
@@ -2619,9 +3087,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2619
3087
  }
2620
3088
  if (organizations.length === 1) {
2621
3089
  organizationId = organizations[0]._id;
2622
- if (organizations[0].role) {
2623
- setRole(organizations[0].role);
2624
- }
3090
+ setUnifiedRole(orgUnifiedRole2(organizations[0]));
2625
3091
  } else {
2626
3092
  const { orgId } = await inquirer4.prompt([
2627
3093
  {
@@ -2629,15 +3095,15 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2629
3095
  name: "orgId",
2630
3096
  message: "Select an organization:",
2631
3097
  choices: organizations.map((org) => ({
2632
- name: `${org.name} ${org.tier === "pro" ? chalk8.green("(Pro)") : chalk8.dim("(Free)")}`,
3098
+ name: `${org.name} ${org.tier === "pro" ? chalk7.green("(Pro)") : chalk7.dim("(Free)")}`,
2633
3099
  value: org._id
2634
3100
  }))
2635
3101
  }
2636
3102
  ]);
2637
3103
  organizationId = orgId;
2638
3104
  const selectedOrg = organizations.find((o) => o._id === orgId);
2639
- if (selectedOrg?.role) {
2640
- setRole(selectedOrg.role);
3105
+ if (selectedOrg) {
3106
+ setUnifiedRole(orgUnifiedRole2(selectedOrg));
2641
3107
  }
2642
3108
  }
2643
3109
  }
@@ -2665,10 +3131,10 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2665
3131
  organizationId,
2666
3132
  environment
2667
3133
  });
2668
- success(`Switched to project: ${chalk8.bold(project.name)}`);
3134
+ success(`Switched to project: ${chalk7.bold(project.name)}`);
2669
3135
  if (project.projectRole) {
2670
3136
  console.log(
2671
- chalk8.dim(
3137
+ chalk7.dim(
2672
3138
  ` Project role: ${formatProjectRole(project.projectRole)}`
2673
3139
  )
2674
3140
  );
@@ -2708,7 +3174,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2708
3174
  choices: configV2.projects.map((p) => {
2709
3175
  const isActive = p.projectId === configV2.activeProjectId;
2710
3176
  return {
2711
- name: `${p.projectName || p.projectId} (${p.environment})${isActive ? chalk8.green(" *current") : ""}`,
3177
+ name: `${p.projectName || p.projectId} (${p.environment})${isActive ? chalk7.green(" *current") : ""}`,
2712
3178
  value: p.projectId
2713
3179
  };
2714
3180
  }),
@@ -2723,7 +3189,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2723
3189
  setActiveProjectId(projectId);
2724
3190
  setActiveOrganizationId(selected.organizationId);
2725
3191
  success(
2726
- `Active project: ${chalk8.bold(selected.projectName || selected.projectId)}`
3192
+ `Active project: ${chalk7.bold(selected.projectName || selected.projectId)}`
2727
3193
  );
2728
3194
  return;
2729
3195
  }
@@ -2746,7 +3212,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2746
3212
  }
2747
3213
  ]);
2748
3214
  updateProjectConfig({ environment });
2749
- success(`Switched to ${chalk8.bold(environment)} environment`);
3215
+ success(`Switched to ${chalk7.bold(environment)} environment`);
2750
3216
  return;
2751
3217
  }
2752
3218
  if (switchType === "organization" || switchType === "project") {
@@ -2767,7 +3233,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2767
3233
  name: "orgId",
2768
3234
  message: "Select an organization:",
2769
3235
  choices: organizations.map((org) => ({
2770
- name: `${org.name} ${org.tier === "pro" ? chalk8.green("(Pro)") : chalk8.dim("(Free)")}`,
3236
+ name: `${org.name} ${org.tier === "pro" ? chalk7.green("(Pro)") : chalk7.dim("(Free)")}`,
2771
3237
  value: org._id
2772
3238
  })),
2773
3239
  default: projectConfig?.organizationId
@@ -2776,19 +3242,17 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2776
3242
  if (switchType === "organization") {
2777
3243
  setActiveOrganizationId(orgId);
2778
3244
  const org = organizations.find((o) => o._id === orgId);
2779
- if (org.role) {
2780
- setRole(org.role);
2781
- }
2782
- success(`Switched to organization: ${chalk8.bold(org.name)}`);
2783
- if (org.role) {
2784
- console.log(chalk8.dim(` Role: ${formatRole(org.role)}`));
2785
- roleNotice(org.role);
2786
- }
3245
+ setUnifiedRole(orgUnifiedRole2(org));
3246
+ success(`Switched to organization: ${chalk7.bold(org.name)}`);
3247
+ console.log(
3248
+ chalk7.dim(` Role: ${formatRoleLabel(orgUnifiedRole2(org))}`)
3249
+ );
3250
+ roleNotice(orgUnifiedRole2(org));
2787
3251
  return;
2788
3252
  }
2789
3253
  const selectedOrg = organizations.find((o) => o._id === orgId);
2790
- if (selectedOrg?.role) {
2791
- setRole(selectedOrg.role);
3254
+ if (selectedOrg) {
3255
+ setUnifiedRole(orgUnifiedRole2(selectedOrg));
2792
3256
  }
2793
3257
  const projects = await withSpinner(
2794
3258
  "Fetching projects...",
@@ -2822,10 +3286,10 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2822
3286
  organizationId: orgId,
2823
3287
  environment
2824
3288
  });
2825
- success(`Switched to project: ${chalk8.bold(project.name)}`);
3289
+ success(`Switched to project: ${chalk7.bold(project.name)}`);
2826
3290
  if (project.projectRole) {
2827
3291
  console.log(
2828
- chalk8.dim(
3292
+ chalk7.dim(
2829
3293
  ` Project role: ${formatProjectRole(project.projectRole)}`
2830
3294
  )
2831
3295
  );
@@ -2840,7 +3304,7 @@ var switchCommand = new Command5("switch").description("Switch project, environm
2840
3304
 
2841
3305
  // src/commands/list.ts
2842
3306
  import { Command as Command6 } from "commander";
2843
- import chalk9 from "chalk";
3307
+ import chalk8 from "chalk";
2844
3308
  var listCommand = new Command6("list").description("List resources").argument(
2845
3309
  "[resource]",
2846
3310
  "Resource type: projects, organizations, variables, linked",
@@ -2895,19 +3359,19 @@ function listLinked() {
2895
3359
  console.log();
2896
3360
  for (const project of configV2.projects) {
2897
3361
  const isActive = project.projectId === configV2.activeProjectId;
2898
- const marker = isActive ? chalk9.green("*") : " ";
3362
+ const marker = isActive ? chalk8.green("*") : " ";
2899
3363
  const envFile = getEnvPathForEnvironment(project.environment);
2900
3364
  console.log(
2901
- ` ${marker} ${chalk9.bold(project.projectName || project.projectId)} ${chalk9.dim(`(${project.organizationName || project.organizationId})`)}`
3365
+ ` ${marker} ${chalk8.bold(project.projectName || project.projectId)} ${chalk8.dim(`(${project.organizationName || project.organizationId})`)}`
2902
3366
  );
2903
- console.log(` ${project.environment} ${chalk9.dim("\u2192")} ${envFile}`);
3367
+ console.log(` ${project.environment} ${chalk8.dim("\u2192")} ${envFile}`);
2904
3368
  console.log();
2905
3369
  }
2906
3370
  if (configV2.projects.length > 1) {
2907
- console.log(chalk9.dim(" (* = active project)"));
3371
+ console.log(chalk8.dim(" (* = active project)"));
2908
3372
  console.log();
2909
3373
  console.log(
2910
- chalk9.dim(
3374
+ chalk8.dim(
2911
3375
  ' Use `envpilot switch --active "<name>"` to change the active project'
2912
3376
  )
2913
3377
  );
@@ -2935,8 +3399,8 @@ async function listOrganizations(api, options) {
2935
3399
  organizations.map((org) => ({
2936
3400
  name: org.name,
2937
3401
  slug: org.slug,
2938
- tier: org.tier === "pro" ? chalk9.green("Pro") : chalk9.dim("Free"),
2939
- role: org.role
3402
+ tier: org.tier === "pro" ? chalk8.green("Pro") : chalk8.dim("Free"),
3403
+ role: formatRoleLabel(org.unifiedRole ?? org.role)
2940
3404
  })),
2941
3405
  [
2942
3406
  { key: "name", header: "Name" },
@@ -2993,9 +3457,11 @@ async function listProjects(api, projectConfig, options) {
2993
3457
  icon: project.icon || "\u{1F4E6}",
2994
3458
  name: project.name,
2995
3459
  slug: project.slug,
2996
- description: project.description || chalk9.dim("-"),
2997
- role: project.userRole === "admin" ? formatRole("admin") : formatProjectRole(project.projectRole),
2998
- active: projectConfig?.projectId === project._id ? chalk9.green("\u2713") : ""
3460
+ description: project.description || chalk8.dim("-"),
3461
+ role: formatRoleLabel(
3462
+ project.unifiedRole ?? project.userRole ?? project.projectRole
3463
+ ),
3464
+ active: projectConfig?.projectId === project._id ? chalk8.green("\u2713") : ""
2999
3465
  })),
3000
3466
  [
3001
3467
  { key: "icon", header: "" },
@@ -3006,11 +3472,8 @@ async function listProjects(api, projectConfig, options) {
3006
3472
  { key: "active", header: "" }
3007
3473
  ]
3008
3474
  );
3009
- const role = getRole();
3010
- if (role) {
3011
- console.log();
3012
- console.log(chalk9.dim(`Your org role: ${formatRole(role)}`));
3013
- }
3475
+ console.log();
3476
+ console.log(chalk8.dim(`Your org role: ${formatRoleLabel(getUnifiedRole())}`));
3014
3477
  }
3015
3478
  async function listVariables(api, projectConfig, options) {
3016
3479
  const projectId = options.project || projectConfig?.projectId;
@@ -3019,14 +3482,14 @@ async function listVariables(api, projectConfig, options) {
3019
3482
  error("No project specified. Use --project or run `envpilot init` first.");
3020
3483
  process.exit(1);
3021
3484
  }
3022
- let metaProjectRole;
3485
+ let meta;
3023
3486
  const variables = await withSpinner("Fetching variables...", async () => {
3024
3487
  const params = { projectId };
3025
3488
  if (environment) {
3026
3489
  params.environment = environment;
3027
3490
  }
3028
3491
  const response = await api.get("/api/cli/variables", params);
3029
- metaProjectRole = response.meta?.projectRole;
3492
+ meta = response.meta;
3030
3493
  return response.data || [];
3031
3494
  });
3032
3495
  const tagFilter = options.tag?.toLowerCase();
@@ -3056,9 +3519,9 @@ async function listVariables(api, projectConfig, options) {
3056
3519
  filtered.map((variable) => ({
3057
3520
  key: variable.key,
3058
3521
  value: options.showValues ? variable.value : maskValue(variable.value),
3059
- sensitive: variable.isSensitive ? chalk9.yellow("\u25CF") : "",
3060
- tags: hasTags ? variable.tags?.map((t) => t.name).join(", ") || chalk9.dim("-") : "",
3061
- version: `v${variable.version}`
3522
+ sensitive: variable.isSensitive ? chalk8.yellow("\u25CF") : "",
3523
+ tags: hasTags ? variable.tags?.map((t) => t.name).join(", ") || chalk8.dim("-") : "",
3524
+ version: typeof variable.version === "number" ? `v${variable.version}` : ""
3062
3525
  })),
3063
3526
  [
3064
3527
  { key: "key", header: "Key" },
@@ -3069,29 +3532,44 @@ async function listVariables(api, projectConfig, options) {
3069
3532
  ]
3070
3533
  );
3071
3534
  console.log();
3072
- console.log(chalk9.dim(`Total: ${filtered.length} variables`));
3073
- const role = getRole();
3074
- if (role) {
3075
- console.log(chalk9.dim(`Your org role: ${formatRole(role)}`));
3076
- }
3077
- if (metaProjectRole) {
3535
+ console.log(chalk8.dim(`Total: ${filtered.length} variables`));
3536
+ console.log(chalk8.dim(`Your org role: ${formatRoleLabel(getUnifiedRole())}`));
3537
+ if (meta?.scopeRestricted && meta.environmentScope?.length) {
3078
3538
  console.log(
3079
- chalk9.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
+ )
3080
3542
  );
3081
- }
3082
- if (role === "member" || metaProjectRole === "viewer") {
3543
+ } else if (meta?.grantOnly) {
3083
3544
  console.log(
3084
- chalk9.dim("You may only see variables you have been granted access to.")
3545
+ chalk8.dim("You may only see variables you have been granted access to.")
3085
3546
  );
3086
3547
  }
3087
3548
  if (!options.showValues) {
3088
- console.log(chalk9.dim("Use --show-values to see actual values"));
3549
+ console.log(chalk8.dim("Use --show-values to see actual values"));
3089
3550
  }
3090
3551
  }
3091
3552
 
3092
3553
  // src/commands/config.ts
3093
3554
  import { Command as Command7 } from "commander";
3094
- import chalk10 from "chalk";
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
+ }
3095
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) => {
3096
3574
  try {
3097
3575
  switch (action) {
@@ -3135,6 +3613,10 @@ async function handleGet(key) {
3135
3613
  console.log(" user Current authenticated user");
3136
3614
  console.log(" activeProjectId Currently active project");
3137
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");
3138
3620
  process.exit(1);
3139
3621
  }
3140
3622
  const config2 = getConfig();
@@ -3146,15 +3628,27 @@ async function handleGet(key) {
3146
3628
  if (config2.user) {
3147
3629
  console.log(JSON.stringify(config2.user, null, 2));
3148
3630
  } else {
3149
- console.log(chalk10.dim("(not set)"));
3631
+ console.log(chalk9.dim("(not set)"));
3150
3632
  }
3151
3633
  break;
3152
3634
  case "activeProjectId":
3153
- console.log(config2.activeProjectId || chalk10.dim("(not set)"));
3635
+ console.log(config2.activeProjectId || chalk9.dim("(not set)"));
3154
3636
  break;
3155
3637
  case "activeOrganizationId":
3156
- console.log(config2.activeOrganizationId || chalk10.dim("(not set)"));
3638
+ console.log(config2.activeOrganizationId || chalk9.dim("(not set)"));
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)"));
3157
3650
  break;
3651
+ }
3158
3652
  default:
3159
3653
  error(`Unknown key: ${key}`);
3160
3654
  process.exit(1);
@@ -3164,8 +3658,7 @@ async function handleSet(key, value) {
3164
3658
  if (!key || value === void 0) {
3165
3659
  error("Missing key or value. Usage: envpilot config set <key> <value>");
3166
3660
  console.log();
3167
- console.log("Settable keys:");
3168
- console.log(" apiUrl API endpoint URL");
3661
+ printSettableKeys();
3169
3662
  process.exit(1);
3170
3663
  }
3171
3664
  switch (key) {
@@ -3181,14 +3674,53 @@ async function handleSet(key, value) {
3181
3674
  success(`Set apiUrl to ${normalized}`);
3182
3675
  break;
3183
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
+ }
3184
3707
  default:
3185
3708
  error(`Cannot set key: ${key}`);
3186
3709
  console.log();
3187
- console.log("Settable keys:");
3188
- console.log(" apiUrl API endpoint URL");
3710
+ printSettableKeys();
3189
3711
  process.exit(1);
3190
3712
  }
3191
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
+ }
3192
3724
  async function handleList() {
3193
3725
  const config2 = getConfig();
3194
3726
  const projectConfig = readProjectConfig();
@@ -3196,7 +3728,7 @@ async function handleList() {
3196
3728
  console.log();
3197
3729
  keyValue([
3198
3730
  ["API URL", config2.apiUrl],
3199
- ["Authenticated", isAuthenticated() ? chalk10.green("Yes") : chalk10.red("No")],
3731
+ ["Authenticated", isAuthenticated() ? chalk9.green("Yes") : chalk9.red("No")],
3200
3732
  ["User", config2.user?.email],
3201
3733
  ["Active Organization", config2.activeOrganizationId],
3202
3734
  ["Active Project", config2.activeProjectId]
@@ -3244,20 +3776,43 @@ async function handleReset() {
3244
3776
 
3245
3777
  // src/commands/logout.ts
3246
3778
  import { Command as Command8 } from "commander";
3247
- 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) => {
3248
3780
  try {
3249
3781
  if (!isAuthenticated()) {
3250
3782
  info("You are not logged in.");
3251
3783
  return;
3252
3784
  }
3253
- 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();
3254
3800
  const api = createAPIClient();
3255
3801
  try {
3256
3802
  await api.post("/api/cli/auth?action=revoke", {});
3257
3803
  } catch {
3258
3804
  }
3259
- clearAuth();
3260
- 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
+ }
3261
3816
  } catch (err) {
3262
3817
  await handleError(err);
3263
3818
  }
@@ -3265,7 +3820,7 @@ var logoutCommand = new Command8("logout").description("Log out from Envpilot").
3265
3820
 
3266
3821
  // src/commands/unlink.ts
3267
3822
  import { Command as Command9 } from "commander";
3268
- import chalk11 from "chalk";
3823
+ import chalk10 from "chalk";
3269
3824
  import inquirer5 from "inquirer";
3270
3825
  var unlinkCommand = new Command9("unlink").description("Remove a linked project from this directory").argument("[project]", "Project name or ID to unlink").option("--force", "Skip confirmation").action(async (projectArg, options) => {
3271
3826
  try {
@@ -3300,7 +3855,7 @@ var unlinkCommand = new Command9("unlink").description("Remove a linked project
3300
3855
  choices: config2.projects.map((p) => {
3301
3856
  const isActive = p.projectId === config2.activeProjectId;
3302
3857
  return {
3303
- name: `${p.projectName || p.projectId} (${p.organizationName || p.organizationId})${isActive ? chalk11.green(" *active") : ""}`,
3858
+ name: `${p.projectName || p.projectId} (${p.organizationName || p.organizationId})${isActive ? chalk10.green(" *active") : ""}`,
3304
3859
  value: p.projectId
3305
3860
  };
3306
3861
  })
@@ -3343,7 +3898,7 @@ var unlinkCommand = new Command9("unlink").description("Remove a linked project
3343
3898
  );
3344
3899
  }
3345
3900
  console.log(
3346
- chalk11.dim(
3901
+ chalk10.dim(
3347
3902
  ` ${updated.projects.length} project${updated.projects.length !== 1 ? "s" : ""} remaining`
3348
3903
  )
3349
3904
  );
@@ -3355,24 +3910,39 @@ var unlinkCommand = new Command9("unlink").description("Remove a linked project
3355
3910
 
3356
3911
  // src/commands/sync.ts
3357
3912
  import { Command as Command10 } from "commander";
3358
- import chalk12 from "chalk";
3913
+ import chalk11 from "chalk";
3359
3914
 
3360
3915
  // src/lib/commit-guard.ts
3361
3916
  import {
3362
- existsSync as existsSync3,
3363
- readFileSync as readFileSync3,
3364
- writeFileSync as writeFileSync3,
3365
- mkdirSync,
3917
+ existsSync as existsSync4,
3918
+ readFileSync as readFileSync4,
3919
+ writeFileSync as writeFileSync4,
3920
+ mkdirSync as mkdirSync2,
3366
3921
  chmodSync as chmodSync2,
3367
- unlinkSync as unlinkSync2,
3368
- statSync
3922
+ unlinkSync as unlinkSync3,
3923
+ renameSync as renameSync3,
3924
+ statSync as statSync2
3369
3925
  } from "fs";
3370
- import { join as join3, resolve as resolve2 } from "path";
3926
+ import { join as join4, resolve as resolve2 } from "path";
3371
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
+ }
3372
3942
  var HOOK_START_MARKER = "# ENVPILOT_GUARD_START";
3373
3943
  var HOOK_END_MARKER = "# ENVPILOT_GUARD_END";
3374
3944
  var HOOK_BLOCK = `${HOOK_START_MARKER} - Do not remove. Installed by Envpilot CLI.
3375
- 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)
3376
3946
  if [ -n "$ENV_FILES" ]; then
3377
3947
  echo ""
3378
3948
  echo "\\033[1;31mERROR:\\033[0m Envpilot commit guard blocked this commit."
@@ -3388,7 +3958,7 @@ ${HOOK_END_MARKER}`;
3388
3958
  function findGitRoot(startDir) {
3389
3959
  let dir = startDir || process.cwd();
3390
3960
  while (true) {
3391
- if (existsSync3(join3(dir, ".git"))) {
3961
+ if (existsSync4(join4(dir, ".git"))) {
3392
3962
  return dir;
3393
3963
  }
3394
3964
  const parent = resolve2(dir, "..");
@@ -3399,9 +3969,9 @@ function findGitRoot(startDir) {
3399
3969
  }
3400
3970
  }
3401
3971
  function resolveGitDir(repoRoot) {
3402
- const gitPath = join3(repoRoot, ".git");
3972
+ const gitPath = join4(repoRoot, ".git");
3403
3973
  try {
3404
- const stat = statSync(gitPath);
3974
+ const stat = statSync2(gitPath);
3405
3975
  if (stat.isDirectory()) {
3406
3976
  return gitPath;
3407
3977
  }
@@ -3409,12 +3979,12 @@ function resolveGitDir(repoRoot) {
3409
3979
  return gitPath;
3410
3980
  }
3411
3981
  try {
3412
- const content = readFileSync3(gitPath, "utf-8").trim();
3982
+ const content = readFileSync4(gitPath, "utf-8").trim();
3413
3983
  const match = content.match(/^gitdir:\s*(.+)$/);
3414
3984
  if (match) {
3415
3985
  const gitdir = resolve2(repoRoot, match[1]);
3416
3986
  const commonDir = resolve2(gitdir, "..", "..");
3417
- if (existsSync3(join3(commonDir, "hooks")) || existsSync3(commonDir)) {
3987
+ if (existsSync4(join4(commonDir, "hooks")) || existsSync4(commonDir)) {
3418
3988
  return commonDir;
3419
3989
  }
3420
3990
  return gitdir;
@@ -3436,7 +4006,7 @@ function getHooksDir(repoRoot) {
3436
4006
  } catch {
3437
4007
  }
3438
4008
  const gitDir = resolveGitDir(repoRoot);
3439
- return join3(gitDir, "hooks");
4009
+ return join4(gitDir, "hooks");
3440
4010
  }
3441
4011
  function installCommitGuard(repoRoot) {
3442
4012
  const root = repoRoot || findGitRoot();
@@ -3449,19 +4019,18 @@ function installCommitGuard(repoRoot) {
3449
4019
  }
3450
4020
  try {
3451
4021
  const hooksDir = getHooksDir(root);
3452
- const hookPath = join3(hooksDir, "pre-commit");
3453
- mkdirSync(hooksDir, { recursive: true });
4022
+ const hookPath = join4(hooksDir, "pre-commit");
4023
+ mkdirSync2(hooksDir, { recursive: true });
3454
4024
  let existingContent = "";
3455
4025
  try {
3456
- existingContent = readFileSync3(hookPath, "utf-8");
4026
+ existingContent = readFileSync4(hookPath, "utf-8");
3457
4027
  } catch {
3458
4028
  }
3459
4029
  if (existingContent.includes(HOOK_START_MARKER)) {
3460
4030
  const startIdx = existingContent.indexOf(HOOK_START_MARKER);
3461
4031
  const endIdx = existingContent.indexOf(HOOK_END_MARKER) + HOOK_END_MARKER.length;
3462
4032
  const updated = existingContent.substring(0, startIdx) + HOOK_BLOCK + existingContent.substring(endIdx);
3463
- writeFileSync3(hookPath, updated, "utf-8");
3464
- chmodSync2(hookPath, 493);
4033
+ atomicWriteFileSync3(hookPath, updated, 493);
3465
4034
  return {
3466
4035
  installed: true,
3467
4036
  hookPath,
@@ -3474,8 +4043,7 @@ function installCommitGuard(repoRoot) {
3474
4043
  } else {
3475
4044
  newContent = "#!/bin/sh\n\n" + HOOK_BLOCK + "\n";
3476
4045
  }
3477
- writeFileSync3(hookPath, newContent, "utf-8");
3478
- chmodSync2(hookPath, 493);
4046
+ atomicWriteFileSync3(hookPath, newContent, 493);
3479
4047
  return {
3480
4048
  installed: true,
3481
4049
  hookPath,
@@ -3491,6 +4059,21 @@ function installCommitGuard(repoRoot) {
3491
4059
  }
3492
4060
 
3493
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
+ }
3494
4077
  var syncCommand = new Command10("sync").description(
3495
4078
  "Login, select project, pull variables, and protect files \u2014 all in one command"
3496
4079
  ).option("-o, --organization <id>", "Organization ID").option("-p, --project <id>", "Project ID or name").option(
@@ -3533,7 +4116,7 @@ var syncCommand = new Command10("sync").description(
3533
4116
  projectName = active.projectName;
3534
4117
  organizationName = active.organizationName;
3535
4118
  info(
3536
- `Using project ${chalk12.bold(projectName || projectId)} (${environment})`
4119
+ `Using project ${chalk11.bold(projectName || projectId)} (${environment})`
3537
4120
  );
3538
4121
  } else {
3539
4122
  const selection = await selectOrgProjectEnv(options);
@@ -3554,9 +4137,7 @@ var syncCommand = new Command10("sync").description(
3554
4137
  environment = selection.selectedEnvironment;
3555
4138
  projectName = selection.selectedProject.name;
3556
4139
  organizationName = selection.selectedOrg.name;
3557
- if (selection.selectedOrg.role) {
3558
- setRole(selection.selectedOrg.role);
3559
- }
4140
+ setUnifiedRole(orgUnifiedRole3(selection.selectedOrg));
3560
4141
  writeProjectConfigV2({
3561
4142
  version: 1,
3562
4143
  activeProjectId: projectId,
@@ -3585,9 +4166,9 @@ var syncCommand = new Command10("sync").description(
3585
4166
  setActiveProjectId(projectId);
3586
4167
  }
3587
4168
  console.log();
3588
- let metaProjectRole;
4169
+ let meta;
3589
4170
  const variables = await withSpinner(
3590
- `Fetching ${chalk12.bold(environment)} variables...`,
4171
+ `Fetching ${chalk11.bold(environment)} variables...`,
3591
4172
  async () => {
3592
4173
  const api = createAPIClient();
3593
4174
  const response = await api.get("/api/cli/variables", {
@@ -3595,11 +4176,16 @@ var syncCommand = new Command10("sync").description(
3595
4176
  environment,
3596
4177
  ...organizationId && { organizationId }
3597
4178
  });
3598
- metaProjectRole = response.meta?.projectRole;
4179
+ meta = response.meta;
3599
4180
  return response.data || [];
3600
4181
  }
3601
4182
  );
3602
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
+ }
3603
4189
  if (variables.length === 0) {
3604
4190
  warning(`No variables found for ${environment} environment.`);
3605
4191
  console.log();
@@ -3613,12 +4199,12 @@ var syncCommand = new Command10("sync").description(
3613
4199
  const diffResult = diffEnvVars(remoteVars, localVars);
3614
4200
  const hasChanges = Object.keys(diffResult.added).length > 0 || Object.keys(diffResult.removed).length > 0 || Object.keys(diffResult.changed).length > 0;
3615
4201
  if (!hasChanges) {
3616
- success(`${chalk12.bold(outputPath)} is up to date.`);
4202
+ success(`${chalk11.bold(outputPath)} is up to date.`);
3617
4203
  console.log();
3618
4204
  return;
3619
4205
  }
3620
4206
  console.log();
3621
- console.log(chalk12.bold("Changes:"));
4207
+ console.log(chalk11.bold("Changes:"));
3622
4208
  console.log();
3623
4209
  diff(diffResult.added, diffResult.removed, diffResult.changed);
3624
4210
  console.log();
@@ -3636,26 +4222,32 @@ var syncCommand = new Command10("sync").description(
3636
4222
  }
3637
4223
  }
3638
4224
  writeEnvFile(outputPath, remoteVars, { sort: true, comments });
3639
- const role = getRole();
3640
- 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);
3641
4228
  success(
3642
- `Synced ${variables.length} variables to ${chalk12.bold(outputPath)}`
4229
+ `Synced ${variables.length} variables to ${chalk11.bold(outputPath)}`
3643
4230
  );
3644
- const isProtected = role !== "admin" && role !== "team_lead" && metaProjectRole !== "manager";
3645
- if (isProtected) {
4231
+ if (meta?.grantOnly) {
3646
4232
  info(
3647
- `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"}).`
3648
4240
  );
3649
4241
  }
3650
4242
  console.log();
3651
4243
  console.log(
3652
- chalk12.dim(` Added: ${Object.keys(diffResult.added).length}`)
4244
+ chalk11.dim(` Added: ${Object.keys(diffResult.added).length}`)
3653
4245
  );
3654
4246
  console.log(
3655
- chalk12.dim(` Changed: ${Object.keys(diffResult.changed).length}`)
4247
+ chalk11.dim(` Changed: ${Object.keys(diffResult.changed).length}`)
3656
4248
  );
3657
4249
  console.log(
3658
- chalk12.dim(` Removed: ${Object.keys(diffResult.removed).length}`)
4250
+ chalk11.dim(` Removed: ${Object.keys(diffResult.removed).length}`)
3659
4251
  );
3660
4252
  console.log();
3661
4253
  } catch (err) {
@@ -3665,17 +4257,17 @@ var syncCommand = new Command10("sync").description(
3665
4257
 
3666
4258
  // src/commands/usage.ts
3667
4259
  import { Command as Command11 } from "commander";
3668
- import chalk13 from "chalk";
4260
+ import chalk12 from "chalk";
3669
4261
  function formatUsage(current, limit) {
3670
4262
  const limitStr = limit === null ? "unlimited" : String(limit);
3671
4263
  const ratio = `${current}/${limitStr}`;
3672
- if (limit === null) return chalk13.green(ratio);
3673
- if (current >= limit) return chalk13.red(ratio);
3674
- if (current >= limit * 0.8) return chalk13.yellow(ratio);
3675
- return chalk13.green(ratio);
4264
+ if (limit === null) return chalk12.green(ratio);
4265
+ if (current >= limit) return chalk12.red(ratio);
4266
+ if (current >= limit * 0.8) return chalk12.yellow(ratio);
4267
+ return chalk12.green(ratio);
3676
4268
  }
3677
4269
  function featureStatus(enabled) {
3678
- return enabled ? chalk13.green("Enabled") : chalk13.dim("Disabled (Pro)");
4270
+ return enabled ? chalk12.green("Enabled") : chalk12.dim("Disabled (Pro)");
3679
4271
  }
3680
4272
  var usageCommand = new Command11("usage").description("Show plan usage and limits for the active organization").option("-o, --organization <id>", "Organization ID").option("--json", "Output as JSON").action(async (options) => {
3681
4273
  try {
@@ -3720,7 +4312,7 @@ var usageCommand = new Command11("usage").description("Show plan usage and limit
3720
4312
  console.log(JSON.stringify(usage, null, 2));
3721
4313
  return;
3722
4314
  }
3723
- const tierLabel = usage.tier === "pro" ? chalk13.green("Pro") : chalk13.white("Free");
4315
+ const tierLabel = usage.tier === "pro" ? chalk12.green("Pro") : chalk12.white("Free");
3724
4316
  header(`Plan: ${tierLabel}`);
3725
4317
  blank();
3726
4318
  if (!usage.enforcementEnabled) {
@@ -3774,7 +4366,7 @@ var usageCommand = new Command11("usage").description("Show plan usage and limit
3774
4366
 
3775
4367
  // src/commands/whoami.ts
3776
4368
  import { Command as Command12 } from "commander";
3777
- import chalk14 from "chalk";
4369
+ import chalk13 from "chalk";
3778
4370
  var whoamiCommand = new Command12("whoami").description("Show the current authenticated user and active CLI context").action(async () => {
3779
4371
  try {
3780
4372
  if (!isAuthenticated()) {
@@ -3792,6 +4384,7 @@ var whoamiCommand = new Command12("whoami").description("Show the current authen
3792
4384
  ["Name", remoteUser.name || localUser?.name],
3793
4385
  ["API URL", getApiUrl()],
3794
4386
  ["Active Organization", getActiveOrganizationId()],
4387
+ ["Org Role", formatRoleLabel(getUnifiedRole())],
3795
4388
  ["Active Project", getActiveProjectId()],
3796
4389
  [
3797
4390
  "Linked Project",
@@ -3799,117 +4392,136 @@ var whoamiCommand = new Command12("whoami").description("Show the current authen
3799
4392
  ],
3800
4393
  ["Environment", activeProject?.environment]
3801
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
+ }
3802
4404
  blank();
3803
- console.log(chalk14.dim("Token verified against the CLI auth endpoint."));
4405
+ console.log(chalk13.dim("Token verified against the CLI auth endpoint."));
3804
4406
  } catch (err) {
3805
4407
  await handleError(err);
3806
4408
  }
3807
4409
  });
3808
4410
 
3809
- // src/commands/run.ts
4411
+ // src/commands/accounts.ts
3810
4412
  import { Command as Command13 } from "commander";
3811
- import { spawn as spawn2 } from "child_process";
3812
- import chalk15 from "chalk";
3813
-
3814
- // src/lib/variables-cache.ts
3815
- import { createHash } from "crypto";
3816
- import {
3817
- readFileSync as readFileSync4,
3818
- writeFileSync as writeFileSync4,
3819
- mkdirSync as mkdirSync2,
3820
- existsSync as existsSync4,
3821
- readdirSync,
3822
- unlinkSync as unlinkSync3,
3823
- statSync as statSync2
3824
- } from "fs";
3825
- import { join as join4, dirname as dirname2 } from "path";
3826
- function getCacheDir() {
3827
- return join4(dirname2(getConfigPath()), "run-cache");
3828
- }
3829
- function getCacheKey(projectId, environment, organizationId) {
3830
- const tokenSlice = (getAccessToken() ?? "").slice(0, 16);
3831
- return createHash("sha256").update(`${projectId}:${environment}:${organizationId}:${tokenSlice}`).digest("hex").slice(0, 20);
4413
+ import chalk14 from "chalk";
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
+ );
3832
4419
  }
3833
- function getCachePath(key) {
3834
- 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
+ }
3835
4426
  }
3836
- function computeFingerprint(variables) {
3837
- return createHash("sha256").update(
3838
- variables.map((v) => `${v._id}:${v.version ?? 0}:${v.updatedAt ?? 0}`).sort().join("|")
3839
- ).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
+ }
3840
4454
  }
3841
- function readCache(projectId, environment, organizationId) {
3842
- try {
3843
- const key = getCacheKey(projectId, environment, organizationId);
3844
- const path = getCachePath(key);
3845
- if (!existsSync4(path)) return null;
3846
- const raw = readFileSync4(path, "utf-8");
3847
- const entry = JSON.parse(raw);
3848
- if (entry.apiUrl !== getApiUrl()) return null;
3849
- return entry;
3850
- } catch {
3851
- 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);
3852
4462
  }
4463
+ setActiveAccount(target.id);
4464
+ success(`Switched to ${target.user.email}.`);
3853
4465
  }
3854
- function probeCache(projectId, environment, organizationId, ttlSeconds) {
3855
- const entry = readCache(projectId, environment, organizationId);
3856
- if (!entry) return { hit: false };
3857
- const fresh = isFresh(entry, ttlSeconds);
3858
- 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
+ }
3859
4487
  }
3860
- function writeCache(projectId, environment, organizationId, variables) {
4488
+ var accountsCommand = new Command13("accounts").description("List and manage authenticated accounts").action(async () => {
3861
4489
  try {
3862
- const cacheDir = getCacheDir();
3863
- mkdirSync2(cacheDir, { recursive: true, mode: 448 });
3864
- const key = getCacheKey(projectId, environment, organizationId);
3865
- const path = getCachePath(key);
3866
- const entry = {
3867
- variables,
3868
- fetchedAt: Date.now(),
3869
- projectId,
3870
- environment,
3871
- organizationId,
3872
- apiUrl: getApiUrl(),
3873
- fingerprint: computeFingerprint(variables)
3874
- };
3875
- writeFileSync4(path, JSON.stringify(entry, null, 2), {
3876
- encoding: "utf-8",
3877
- mode: 384
3878
- // owner read/write only — same as .env
3879
- });
3880
- } catch {
4490
+ printAccountsList();
4491
+ } catch (err) {
4492
+ await handleError(err);
3881
4493
  }
3882
- }
3883
- function extendCacheFreshness(projectId, environment, organizationId) {
4494
+ });
4495
+ accountsCommand.command("list").description("List all authenticated accounts").action(async () => {
3884
4496
  try {
3885
- const key = getCacheKey(projectId, environment, organizationId);
3886
- const path = getCachePath(key);
3887
- if (!existsSync4(path)) return;
3888
- const raw = readFileSync4(path, "utf-8");
3889
- const entry = JSON.parse(raw);
3890
- entry.fetchedAt = Date.now();
3891
- writeFileSync4(path, JSON.stringify(entry, null, 2), {
3892
- encoding: "utf-8",
3893
- mode: 384
3894
- });
3895
- } catch {
4497
+ printAccountsList();
4498
+ } catch (err) {
4499
+ await handleError(err);
3896
4500
  }
3897
- }
3898
- function isFresh(entry, ttlSeconds) {
3899
- return Date.now() - entry.fetchedAt < ttlSeconds * 1e3;
3900
- }
3901
- function formatAge(fetchedAt) {
3902
- const ageSec = Math.floor((Date.now() - fetchedAt) / 1e3);
3903
- if (ageSec < 60) return `${ageSec}s ago`;
3904
- const ageMin = Math.floor(ageSec / 60);
3905
- if (ageMin < 60) return `${ageMin}m ago`;
3906
- return `${Math.floor(ageMin / 60)}h ago`;
3907
- }
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
+ });
3908
4516
 
3909
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";
3910
4522
  var DEFAULT_TTL = 3600;
3911
- var runCommand = new Command13("run").description(
3912
- "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."
3913
4525
  ).argument("[command...]", "Command and arguments to execute").option(
3914
4526
  "-e, --env <environment>",
3915
4527
  "Environment to load (development, staging, production)"
@@ -3927,10 +4539,10 @@ var runCommand = new Command13("run").description(
3927
4539
  "Print the variables that would be injected and exit (no command runs)"
3928
4540
  ).option(
3929
4541
  "--shell",
3930
- "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).'
3931
4543
  ).option("-q, --quiet", "Suppress informational messages").option("--no-cache", "Skip cache and always fetch fresh secrets from server").option(
3932
4544
  "--cache-ttl <seconds>",
3933
- "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)",
3934
4546
  String(DEFAULT_TTL)
3935
4547
  ).passThroughOptions().allowExcessArguments().action(async (commandArgs, options) => {
3936
4548
  try {
@@ -3959,10 +4571,11 @@ var runCommand = new Command13("run").description(
3959
4571
  }
3960
4572
  const environment = options.env || project.environment;
3961
4573
  const organizationId = options.organization || project.organizationId;
3962
- const ttl = Math.max(
3963
- 1,
3964
- parseInt(options.cacheTtl ?? String(DEFAULT_TTL), 10) || DEFAULT_TTL
4574
+ const parsedTtl = Number.parseInt(
4575
+ options.cacheTtl ?? String(DEFAULT_TTL),
4576
+ 10
3965
4577
  );
4578
+ const ttl = Number.isFinite(parsedTtl) && parsedTtl >= 0 ? parsedTtl : DEFAULT_TTL;
3966
4579
  let variables;
3967
4580
  let cacheHit = false;
3968
4581
  let cacheAge = "";
@@ -4014,13 +4627,19 @@ var runCommand = new Command13("run").description(
4014
4627
  project.projectId,
4015
4628
  environment,
4016
4629
  organizationId,
4017
- variables
4630
+ variables,
4631
+ serverFingerprint
4018
4632
  );
4019
4633
  }
4020
4634
  } catch {
4021
4635
  variables = probe.entry.variables;
4022
4636
  cacheHit = true;
4023
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
+ }
4024
4643
  }
4025
4644
  } else {
4026
4645
  variables = await doFetch(
@@ -4069,7 +4688,7 @@ var runCommand = new Command13("run").description(
4069
4688
  }
4070
4689
  console.log();
4071
4690
  }
4072
- await runChild(commandArgs, finalEnv, options);
4691
+ process.exitCode = await runChild(commandArgs, finalEnv, options);
4073
4692
  } catch (err) {
4074
4693
  await handleError(err);
4075
4694
  }
@@ -4112,28 +4731,31 @@ function printInjectionPreview(secrets, project, environment) {
4112
4731
  success("Dry run \u2014 no command executed.");
4113
4732
  }
4114
4733
  function maskForPreview(value) {
4115
- if (value.length <= 6) return "******";
4116
- 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)`;
4117
4738
  }
4118
4739
  function runChild(commandArgs, env, options) {
4119
4740
  return new Promise((resolve3) => {
4120
- 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
+ }
4121
4749
  if (!command) {
4122
- process.exit(1);
4750
+ resolve3(1);
4751
+ return;
4123
4752
  }
4124
- const isWindows = process.platform === "win32";
4125
- const useShell = options.shell === true || isWindows;
4126
- const child = spawn2(command, args, {
4753
+ const child = crossSpawn(command, args, {
4127
4754
  stdio: "inherit",
4128
4755
  env,
4129
- shell: useShell
4756
+ shell: false
4130
4757
  });
4131
- const signals = [
4132
- "SIGINT",
4133
- "SIGTERM",
4134
- "SIGHUP",
4135
- "SIGQUIT"
4136
- ];
4758
+ const signals = ["SIGTERM", "SIGHUP"];
4137
4759
  const forward = {};
4138
4760
  for (const sig of signals) {
4139
4761
  const handler = () => {
@@ -4156,35 +4778,34 @@ function runChild(commandArgs, env, options) {
4156
4778
  child.on("error", (err) => {
4157
4779
  cleanup();
4158
4780
  const code = err.code;
4781
+ const displayCommand = options.shell ? commandArgs.join(" ") : command;
4159
4782
  if (code === "ENOENT") {
4160
4783
  error(
4161
- `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.`
4162
4785
  );
4786
+ resolve3(127);
4163
4787
  } else if (code === "EACCES") {
4164
- error(`Permission denied executing: ${command}`);
4788
+ error(`Permission denied executing: ${displayCommand}`);
4789
+ resolve3(126);
4165
4790
  } else {
4166
4791
  error(`Failed to spawn process: ${err.message}`);
4792
+ resolve3(1);
4167
4793
  }
4168
- process.exit(1);
4169
4794
  });
4170
4795
  child.on("exit", (code, signal) => {
4171
4796
  cleanup();
4172
4797
  if (signal) {
4173
- try {
4174
- process.kill(process.pid, signal);
4175
- } catch {
4176
- process.exit(1);
4177
- }
4798
+ const signum = osConstants.signals[signal] ?? 0;
4799
+ resolve3(128 + signum);
4178
4800
  } else {
4179
- process.exit(code ?? 0);
4801
+ resolve3(code ?? 0);
4180
4802
  }
4181
- resolve3();
4182
4803
  });
4183
4804
  });
4184
4805
  }
4185
4806
 
4186
4807
  // src/commands/man.ts
4187
- import { Command as Command14 } from "commander";
4808
+ import { Command as Command15 } from "commander";
4188
4809
  import chalk16 from "chalk";
4189
4810
  function printCommandManual(commandName) {
4190
4811
  const command = findCommandDefinition(commandName);
@@ -4254,7 +4875,7 @@ function printManualIndex(commands) {
4254
4875
  );
4255
4876
  }
4256
4877
  function createManCommand(commands) {
4257
- 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) => {
4258
4879
  if (command) {
4259
4880
  printCommandManual(command);
4260
4881
  return;
@@ -4264,9 +4885,9 @@ function createManCommand(commands) {
4264
4885
  }
4265
4886
 
4266
4887
  // src/commands/ui.ts
4267
- import { Command as Command15 } from "commander";
4888
+ import { Command as Command16 } from "commander";
4268
4889
  function createUICommand() {
4269
- 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 () => {
4270
4891
  if (process.env.ENVPILOT_TUI_CHILD === "1") {
4271
4892
  return;
4272
4893
  }
@@ -4403,13 +5024,14 @@ var COMMAND_CATALOG = [
4403
5024
  id: "push",
4404
5025
  title: "Push variables",
4405
5026
  category: "Sync",
4406
- 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.",
4407
5028
  argv: ["push"],
4408
5029
  args: "[--env <environment>] [--file <path>] [--merge|--replace]",
4409
5030
  examples: [["push"], ["push", "--replace"]],
4410
5031
  websiteSurface: "Maps to `/api/cli/variables` and `/api/cli/variables/bulk` for writes.",
4411
5032
  notes: [
4412
- "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.",
4413
5035
  "Compares local and remote variables before applying changes."
4414
5036
  ],
4415
5037
  keywords: ["upload", "bulk", "merge", "replace"],
@@ -4585,6 +5207,27 @@ var COMMAND_CATALOG = [
4585
5207
  topLevel: true,
4586
5208
  createCommand: () => whoamiCommand
4587
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
+ },
4588
5231
  {
4589
5232
  id: "config",
4590
5233
  title: "Config",