@envpilot/cli 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1450 -514
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ function initSentry() {
9
9
  Sentry.init({
10
10
  dsn,
11
11
  environment: "cli",
12
- release: true ? "1.3.0" : "0.0.0",
12
+ release: true ? "1.3.2" : "0.0.0",
13
13
  // Free tier: disable performance monitoring
14
14
  tracesSampleRate: 0,
15
15
  beforeSend(event) {
@@ -49,12 +49,10 @@ async function flushSentry() {
49
49
  }
50
50
 
51
51
  // src/index.ts
52
- import { Command as Command10 } from "commander";
52
+ import { Command as Command12 } from "commander";
53
53
 
54
54
  // src/commands/login.ts
55
55
  import { Command } from "commander";
56
- import chalk3 from "chalk";
57
- import open from "open";
58
56
 
59
57
  // src/lib/ui.ts
60
58
  import chalk from "chalk";
@@ -189,6 +187,91 @@ function projectRoleNotice(projectRole) {
189
187
  }
190
188
  }
191
189
 
190
+ // src/lib/errors.ts
191
+ import chalk2 from "chalk";
192
+ var CLIError = class extends Error {
193
+ constructor(message, code, suggestion) {
194
+ super(message);
195
+ this.code = code;
196
+ this.suggestion = suggestion;
197
+ this.name = "CLIError";
198
+ }
199
+ };
200
+ var ErrorCodes = {
201
+ NOT_AUTHENTICATED: "NOT_AUTHENTICATED",
202
+ NOT_INITIALIZED: "NOT_INITIALIZED",
203
+ PROJECT_NOT_FOUND: "PROJECT_NOT_FOUND",
204
+ ORGANIZATION_NOT_FOUND: "ORGANIZATION_NOT_FOUND",
205
+ VARIABLE_NOT_FOUND: "VARIABLE_NOT_FOUND",
206
+ INVALID_CONFIG: "INVALID_CONFIG",
207
+ NETWORK_ERROR: "NETWORK_ERROR",
208
+ PERMISSION_DENIED: "PERMISSION_DENIED",
209
+ TIER_LIMIT_EXCEEDED: "TIER_LIMIT_EXCEEDED",
210
+ FILE_NOT_FOUND: "FILE_NOT_FOUND",
211
+ INVALID_INPUT: "INVALID_INPUT",
212
+ UNKNOWN_ERROR: "UNKNOWN_ERROR"
213
+ };
214
+ function formatError(error2) {
215
+ if (error2 instanceof CLIError) {
216
+ let message = chalk2.red(`Error: ${error2.message}`);
217
+ if (error2.suggestion) {
218
+ message += `
219
+ ${chalk2.yellow("Suggestion:")} ${error2.suggestion}`;
220
+ }
221
+ return message;
222
+ }
223
+ if (error2 instanceof Error) {
224
+ return chalk2.red(`Error: ${error2.message}`);
225
+ }
226
+ return chalk2.red(`Error: ${String(error2)}`);
227
+ }
228
+ async function handleError(error2) {
229
+ console.error(formatError(error2));
230
+ const skipCodes = /* @__PURE__ */ new Set([
231
+ ErrorCodes.NOT_AUTHENTICATED,
232
+ ErrorCodes.INVALID_INPUT,
233
+ ErrorCodes.NOT_INITIALIZED
234
+ ]);
235
+ if (error2 instanceof CLIError) {
236
+ if (!skipCodes.has(error2.code)) {
237
+ captureError(error2, { errorCode: error2.code });
238
+ }
239
+ } else {
240
+ captureError(error2);
241
+ }
242
+ await flushSentry();
243
+ if (error2 instanceof CLIError) {
244
+ switch (error2.code) {
245
+ case ErrorCodes.NOT_AUTHENTICATED:
246
+ process.exit(2);
247
+ case ErrorCodes.PERMISSION_DENIED:
248
+ process.exit(3);
249
+ case ErrorCodes.TIER_LIMIT_EXCEEDED:
250
+ process.exit(4);
251
+ default:
252
+ process.exit(1);
253
+ }
254
+ }
255
+ process.exit(1);
256
+ }
257
+ function notAuthenticated() {
258
+ return new CLIError(
259
+ "You are not authenticated.",
260
+ ErrorCodes.NOT_AUTHENTICATED,
261
+ "Run `envpilot login` to authenticate."
262
+ );
263
+ }
264
+ function notInitialized() {
265
+ return new CLIError(
266
+ "This directory is not initialized with Envpilot.",
267
+ ErrorCodes.NOT_INITIALIZED,
268
+ "Run `envpilot init` to initialize."
269
+ );
270
+ }
271
+ function fileNotFound(path) {
272
+ return new CLIError(`File not found: ${path}`, ErrorCodes.FILE_NOT_FOUND);
273
+ }
274
+
192
275
  // src/lib/config.ts
193
276
  import Conf from "conf";
194
277
  var DEFAULT_API_URL = "https://www.envpilot.dev";
@@ -261,6 +344,11 @@ function getConfigPath() {
261
344
  return config.path;
262
345
  }
263
346
 
347
+ // src/lib/auth-flow.ts
348
+ import open from "open";
349
+ import chalk3 from "chalk";
350
+ import { hostname } from "os";
351
+
264
352
  // src/lib/api.ts
265
353
  var APIError = class extends Error {
266
354
  constructor(message, statusCode, code) {
@@ -546,174 +634,84 @@ function createAPIClient() {
546
634
  return new APIClient();
547
635
  }
548
636
 
549
- // src/lib/errors.ts
550
- import chalk2 from "chalk";
551
- var CLIError = class extends Error {
552
- constructor(message, code, suggestion) {
553
- super(message);
554
- this.code = code;
555
- this.suggestion = suggestion;
556
- this.name = "CLIError";
557
- }
558
- };
559
- var ErrorCodes = {
560
- NOT_AUTHENTICATED: "NOT_AUTHENTICATED",
561
- NOT_INITIALIZED: "NOT_INITIALIZED",
562
- PROJECT_NOT_FOUND: "PROJECT_NOT_FOUND",
563
- ORGANIZATION_NOT_FOUND: "ORGANIZATION_NOT_FOUND",
564
- VARIABLE_NOT_FOUND: "VARIABLE_NOT_FOUND",
565
- INVALID_CONFIG: "INVALID_CONFIG",
566
- NETWORK_ERROR: "NETWORK_ERROR",
567
- PERMISSION_DENIED: "PERMISSION_DENIED",
568
- TIER_LIMIT_EXCEEDED: "TIER_LIMIT_EXCEEDED",
569
- FILE_NOT_FOUND: "FILE_NOT_FOUND",
570
- INVALID_INPUT: "INVALID_INPUT",
571
- UNKNOWN_ERROR: "UNKNOWN_ERROR"
572
- };
573
- function formatError(error2) {
574
- if (error2 instanceof CLIError) {
575
- let message = chalk2.red(`Error: ${error2.message}`);
576
- if (error2.suggestion) {
577
- message += `
578
- ${chalk2.yellow("Suggestion:")} ${error2.suggestion}`;
579
- }
580
- return message;
581
- }
582
- if (error2 instanceof Error) {
583
- return chalk2.red(`Error: ${error2.message}`);
584
- }
585
- return chalk2.red(`Error: ${String(error2)}`);
637
+ // src/lib/auth-flow.ts
638
+ var POLL_INTERVAL_MS = 2e3;
639
+ var MAX_POLL_ATTEMPTS = 150;
640
+ function sleep(ms) {
641
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
586
642
  }
587
- async function handleError(error2) {
588
- console.error(formatError(error2));
589
- const skipCodes = /* @__PURE__ */ new Set([
590
- ErrorCodes.NOT_AUTHENTICATED,
591
- ErrorCodes.INVALID_INPUT,
592
- ErrorCodes.NOT_INITIALIZED
593
- ]);
594
- if (error2 instanceof CLIError) {
595
- if (!skipCodes.has(error2.code)) {
596
- captureError(error2, { errorCode: error2.code });
643
+ async function performLogin(options) {
644
+ const api = createAPIClient();
645
+ const deviceName = `CLI - ${hostname()}`;
646
+ info("Starting authentication flow...");
647
+ const spinner = createSpinner("Generating authentication code...");
648
+ spinner.start();
649
+ const initResponse = await api.post("/api/cli/auth?action=initiate", { deviceName });
650
+ spinner.stop();
651
+ console.log();
652
+ console.log(chalk3.bold("Your authentication code:"));
653
+ console.log();
654
+ console.log(chalk3.cyan.bold(` ${initResponse.code}`));
655
+ console.log();
656
+ console.log(`Open this URL to authenticate:`);
657
+ console.log(chalk3.dim(initResponse.url));
658
+ console.log();
659
+ if (options?.browser !== false) {
660
+ info("Opening browser...");
661
+ await open(initResponse.url);
662
+ }
663
+ const pollSpinner = createSpinner("Waiting for authentication...");
664
+ pollSpinner.start();
665
+ for (let attempts = 0; attempts < MAX_POLL_ATTEMPTS; attempts++) {
666
+ await sleep(POLL_INTERVAL_MS);
667
+ const pollResponse = await api.get("/api/cli/auth", { action: "poll", code: initResponse.code });
668
+ if (pollResponse.status === "authenticated") {
669
+ pollSpinner.stop();
670
+ if (pollResponse.accessToken) {
671
+ setAccessToken(pollResponse.accessToken);
672
+ }
673
+ if (pollResponse.refreshToken) {
674
+ setRefreshToken(pollResponse.refreshToken);
675
+ }
676
+ if (pollResponse.user) {
677
+ setUser({
678
+ id: pollResponse.user.id,
679
+ email: pollResponse.user.email,
680
+ name: pollResponse.user.name
681
+ });
682
+ }
683
+ console.log();
684
+ success(`Logged in as ${chalk3.bold(pollResponse.user?.email)}`);
685
+ return { email: pollResponse.user?.email || "" };
597
686
  }
598
- } else {
599
- captureError(error2);
600
- }
601
- await flushSentry();
602
- if (error2 instanceof CLIError) {
603
- switch (error2.code) {
604
- case ErrorCodes.NOT_AUTHENTICATED:
605
- process.exit(2);
606
- case ErrorCodes.PERMISSION_DENIED:
607
- process.exit(3);
608
- case ErrorCodes.TIER_LIMIT_EXCEEDED:
609
- process.exit(4);
610
- default:
611
- process.exit(1);
687
+ if (pollResponse.status === "expired" || pollResponse.status === "not_found") {
688
+ pollSpinner.stop();
689
+ throw new Error("Authentication code expired. Please try again.");
612
690
  }
613
691
  }
614
- process.exit(1);
615
- }
616
- function notAuthenticated() {
617
- return new CLIError(
618
- "You are not authenticated.",
619
- ErrorCodes.NOT_AUTHENTICATED,
620
- "Run `envpilot login` to authenticate."
621
- );
622
- }
623
- function notInitialized() {
624
- return new CLIError(
625
- "This directory is not initialized with Envpilot.",
626
- ErrorCodes.NOT_INITIALIZED,
627
- "Run `envpilot init` to initialize."
628
- );
629
- }
630
- function fileNotFound(path) {
631
- return new CLIError(`File not found: ${path}`, ErrorCodes.FILE_NOT_FOUND);
692
+ pollSpinner.stop();
693
+ throw new Error("Authentication timed out. Please try again.");
632
694
  }
633
695
 
634
696
  // src/commands/login.ts
635
- import { hostname } from "os";
636
- var POLL_INTERVAL_MS = 2e3;
637
- var MAX_POLL_ATTEMPTS = 150;
638
697
  var loginCommand = new Command("login").description("Authenticate with Envpilot").option("--api-url <url>", "API URL (default: http://localhost:3000)").option("--no-browser", "Do not automatically open the browser").action(async (options) => {
639
698
  try {
640
699
  if (options.apiUrl) {
641
700
  setApiUrl(options.apiUrl);
642
701
  }
643
- const api = createAPIClient();
644
- const deviceName = `CLI - ${hostname()}`;
645
- info("Starting authentication flow...");
646
- const spinner = createSpinner("Generating authentication code...");
647
- spinner.start();
648
- const initResponse = await api.post("/api/cli/auth?action=initiate", { deviceName });
649
- spinner.stop();
650
- console.log();
651
- console.log(chalk3.bold("Your authentication code:"));
652
- console.log();
653
- console.log(chalk3.cyan.bold(` ${initResponse.code}`));
702
+ await performLogin({
703
+ browser: options.browser !== false
704
+ });
654
705
  console.log();
655
- console.log(`Open this URL to authenticate:`);
656
- console.log(chalk3.dim(initResponse.url));
706
+ console.log("Next steps:");
707
+ info(" envpilot init Initialize a project in the current directory");
708
+ info(" envpilot list List your projects and organizations");
709
+ info(" envpilot sync Login, select project, and pull \u2014 all at once");
657
710
  console.log();
658
- if (options.browser !== false) {
659
- info("Opening browser...");
660
- await open(initResponse.url);
661
- }
662
- const pollSpinner = createSpinner("Waiting for authentication...");
663
- pollSpinner.start();
664
- let authenticated = false;
665
- let attempts = 0;
666
- while (!authenticated && attempts < MAX_POLL_ATTEMPTS) {
667
- await sleep(POLL_INTERVAL_MS);
668
- const pollResponse = await api.get("/api/cli/auth", { action: "poll", code: initResponse.code });
669
- if (pollResponse.status === "authenticated") {
670
- pollSpinner.stop();
671
- if (pollResponse.accessToken) {
672
- setAccessToken(pollResponse.accessToken);
673
- }
674
- if (pollResponse.refreshToken) {
675
- setRefreshToken(pollResponse.refreshToken);
676
- }
677
- if (pollResponse.user) {
678
- setUser({
679
- id: pollResponse.user.id,
680
- email: pollResponse.user.email,
681
- name: pollResponse.user.name
682
- });
683
- }
684
- authenticated = true;
685
- console.log();
686
- success(`Logged in as ${chalk3.bold(pollResponse.user?.email)}`);
687
- console.log();
688
- console.log("Next steps:");
689
- console.log(
690
- ` ${chalk3.cyan("envpilot init")} Initialize a project in the current directory`
691
- );
692
- console.log(
693
- ` ${chalk3.cyan("envpilot list")} List your projects and organizations`
694
- );
695
- console.log();
696
- break;
697
- }
698
- if (pollResponse.status === "expired" || pollResponse.status === "not_found") {
699
- pollSpinner.stop();
700
- error("Authentication code expired. Please try again.");
701
- process.exit(1);
702
- }
703
- attempts++;
704
- }
705
- if (!authenticated) {
706
- pollSpinner.stop();
707
- error("Authentication timed out. Please try again.");
708
- process.exit(1);
709
- }
710
711
  } catch (err) {
711
712
  await handleError(err);
712
713
  }
713
714
  });
714
- function sleep(ms) {
715
- return new Promise((resolve) => setTimeout(resolve, ms));
716
- }
717
715
 
718
716
  // src/commands/init.ts
719
717
  import { Command as Command2 } from "commander";
@@ -779,6 +777,18 @@ var projectConfigSchema = z.object({
779
777
  organizationId: z.string(),
780
778
  environment: environmentSchema.default("development")
781
779
  });
780
+ var projectEntrySchema = z.object({
781
+ projectId: z.string(),
782
+ organizationId: z.string(),
783
+ projectName: z.string().default(""),
784
+ organizationName: z.string().default(""),
785
+ environment: environmentSchema.default("development")
786
+ });
787
+ var projectConfigV2Schema = z.object({
788
+ version: z.literal(1),
789
+ activeProjectId: z.string(),
790
+ projects: z.array(projectEntrySchema).min(1)
791
+ });
782
792
 
783
793
  // src/lib/project-config.ts
784
794
  var CONFIG_FILE_NAME = ".envpilot";
@@ -788,51 +798,173 @@ function getProjectConfigPath(directory = process.cwd()) {
788
798
  function hasProjectConfig(directory = process.cwd()) {
789
799
  return existsSync(getProjectConfigPath(directory));
790
800
  }
791
- function readProjectConfig(directory = process.cwd()) {
801
+ function readRawConfig(directory = process.cwd()) {
792
802
  const configPath = getProjectConfigPath(directory);
793
- if (!existsSync(configPath)) {
803
+ if (!existsSync(configPath)) return null;
804
+ try {
805
+ return JSON.parse(readFileSync(configPath, "utf-8"));
806
+ } catch {
794
807
  return null;
795
808
  }
809
+ }
810
+ function migrateV1toV2(v1) {
811
+ return {
812
+ version: 1,
813
+ activeProjectId: v1.projectId,
814
+ projects: [
815
+ {
816
+ projectId: v1.projectId,
817
+ organizationId: v1.organizationId,
818
+ projectName: "",
819
+ organizationName: "",
820
+ environment: v1.environment
821
+ }
822
+ ]
823
+ };
824
+ }
825
+ function readProjectConfigV2(directory = process.cwd()) {
826
+ const raw = readRawConfig(directory);
827
+ if (!raw || typeof raw !== "object") return null;
828
+ const rawVersion = raw.version;
829
+ if (rawVersion === 1 || rawVersion === 2) {
830
+ try {
831
+ const normalized = { ...raw, version: 1 };
832
+ const parsed = projectConfigV2Schema.parse(normalized);
833
+ if (rawVersion === 2) {
834
+ writeProjectConfigV2(parsed, directory);
835
+ }
836
+ return parsed;
837
+ } catch {
838
+ return null;
839
+ }
840
+ }
796
841
  try {
797
- const content = readFileSync(configPath, "utf-8");
798
- const parsed = JSON.parse(content);
799
- return projectConfigSchema.parse(parsed);
842
+ const v1 = projectConfigSchema.parse(raw);
843
+ const v2 = migrateV1toV2(v1);
844
+ writeProjectConfigV2(v2, directory);
845
+ return v2;
800
846
  } catch {
801
847
  return null;
802
848
  }
803
849
  }
804
- function writeProjectConfig(config2, directory = process.cwd()) {
850
+ function writeProjectConfigV2(config2, directory = process.cwd()) {
805
851
  const configPath = getProjectConfigPath(directory);
806
- const content = JSON.stringify(config2, null, 2) + "\n";
807
- writeFileSync(configPath, content, "utf-8");
852
+ writeFileSync(configPath, JSON.stringify(config2, null, 2) + "\n", "utf-8");
808
853
  }
809
- function updateProjectConfig(updates, directory = process.cwd()) {
810
- const existing = readProjectConfig(directory);
811
- if (!existing) {
812
- throw new Error("No project config found. Run `envpilot init` first.");
813
- }
814
- const updated = { ...existing, ...updates };
815
- writeProjectConfig(updated, directory);
854
+ function getActiveProject(config2) {
855
+ return config2.projects.find((p) => p.projectId === config2.activeProjectId) || config2.projects[0] || null;
816
856
  }
817
- function ensureEnvInGitignore(directory = process.cwd()) {
818
- const gitignorePath = join(directory, ".gitignore");
819
- if (!existsSync(gitignorePath)) {
820
- writeFileSync(gitignorePath, ".env\n.env.local\n", "utf-8");
821
- return;
857
+ function resolveProject(config2, identifier) {
858
+ if (!identifier) return getActiveProject(config2);
859
+ return config2.projects.find(
860
+ (p) => p.projectId === identifier || p.projectName.toLowerCase() === identifier.toLowerCase()
861
+ ) || null;
862
+ }
863
+ function addProjectToConfig(config2, entry) {
864
+ if (config2.projects.some((p) => p.projectId === entry.projectId)) {
865
+ throw new Error("Project already linked");
822
866
  }
823
- const content = readFileSync(gitignorePath, "utf-8");
824
- const lines = content.split("\n");
825
- if (lines.some((line) => line.trim() === ".env")) {
826
- return;
867
+ return { ...config2, projects: [...config2.projects, entry] };
868
+ }
869
+ function removeProjectFromConfig(config2, projectId) {
870
+ const filtered = config2.projects.filter((p) => p.projectId !== projectId);
871
+ if (filtered.length === 0) return null;
872
+ const activeId = config2.activeProjectId === projectId ? filtered[0].projectId : config2.activeProjectId;
873
+ return { ...config2, activeProjectId: activeId, projects: filtered };
874
+ }
875
+ function setActiveProjectInConfig(config2, projectId) {
876
+ if (!config2.projects.some((p) => p.projectId === projectId)) {
877
+ throw new Error("Project not found in config");
827
878
  }
828
- const newContent = content.endsWith("\n") ? content + ".env\n" : content + "\n.env\n";
829
- writeFileSync(gitignorePath, newContent, "utf-8");
879
+ return { ...config2, activeProjectId: projectId };
830
880
  }
831
- function getTrackedEnvFiles(directory = process.cwd()) {
832
- try {
833
- const result = execSync("git ls-files --cached .env .env.* .env.local", {
834
- cwd: directory,
835
- encoding: "utf-8",
881
+ function updateProjectInConfig(config2, projectId, updates) {
882
+ return {
883
+ ...config2,
884
+ projects: config2.projects.map(
885
+ (p) => p.projectId === projectId ? { ...p, ...updates } : p
886
+ )
887
+ };
888
+ }
889
+ function readProjectConfig(directory = process.cwd()) {
890
+ const v2 = readProjectConfigV2(directory);
891
+ if (!v2) return null;
892
+ const active = getActiveProject(v2);
893
+ if (!active) return null;
894
+ return {
895
+ projectId: active.projectId,
896
+ organizationId: active.organizationId,
897
+ environment: active.environment
898
+ };
899
+ }
900
+ function writeProjectConfig(config2, directory = process.cwd()) {
901
+ const existing = readProjectConfigV2(directory);
902
+ if (existing) {
903
+ const updated = updateProjectInConfig(existing, existing.activeProjectId, {
904
+ projectId: config2.projectId,
905
+ organizationId: config2.organizationId,
906
+ environment: config2.environment
907
+ });
908
+ writeProjectConfigV2(
909
+ { ...updated, activeProjectId: config2.projectId },
910
+ directory
911
+ );
912
+ } else {
913
+ writeProjectConfigV2(
914
+ {
915
+ version: 1,
916
+ activeProjectId: config2.projectId,
917
+ projects: [
918
+ {
919
+ projectId: config2.projectId,
920
+ organizationId: config2.organizationId,
921
+ projectName: "",
922
+ organizationName: "",
923
+ environment: config2.environment
924
+ }
925
+ ]
926
+ },
927
+ directory
928
+ );
929
+ }
930
+ }
931
+ function updateProjectConfig(updates, directory = process.cwd()) {
932
+ const v2 = readProjectConfigV2(directory);
933
+ if (!v2) {
934
+ throw new Error("No project config found. Run `envpilot init` first.");
935
+ }
936
+ const active = getActiveProject(v2);
937
+ if (!active) {
938
+ throw new Error("No active project found.");
939
+ }
940
+ const updated = updateProjectInConfig(v2, active.projectId, updates);
941
+ writeProjectConfigV2(updated, directory);
942
+ }
943
+ function deleteProjectConfig(directory = process.cwd()) {
944
+ const configPath = getProjectConfigPath(directory);
945
+ if (!existsSync(configPath)) return false;
946
+ unlinkSync(configPath);
947
+ return true;
948
+ }
949
+ function ensureEnvInGitignore(directory = process.cwd()) {
950
+ const gitignorePath = join(directory, ".gitignore");
951
+ if (!existsSync(gitignorePath)) {
952
+ writeFileSync(gitignorePath, ".env\n.env.local\n", "utf-8");
953
+ return;
954
+ }
955
+ const content = readFileSync(gitignorePath, "utf-8");
956
+ const lines = content.split("\n");
957
+ if (lines.some((line) => line.trim() === ".env")) {
958
+ return;
959
+ }
960
+ const newContent = content.endsWith("\n") ? content + ".env\n" : content + "\n.env\n";
961
+ writeFileSync(gitignorePath, newContent, "utf-8");
962
+ }
963
+ function getTrackedEnvFiles(directory = process.cwd()) {
964
+ try {
965
+ const result = execSync("git ls-files --cached .env .env.* .env.local", {
966
+ cwd: directory,
967
+ encoding: "utf-8",
836
968
  stdio: ["pipe", "pipe", "pipe"]
837
969
  });
838
970
  return result.trim().split("\n").filter((f) => f.length > 0);
@@ -841,199 +973,8 @@ function getTrackedEnvFiles(directory = process.cwd()) {
841
973
  }
842
974
  }
843
975
 
844
- // src/commands/init.ts
845
- 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(
846
- "-e, --environment <env>",
847
- "Default environment (development, staging, production)"
848
- ).option("-f, --force", "Overwrite existing configuration").action(async (options) => {
849
- try {
850
- if (!isAuthenticated()) {
851
- throw notAuthenticated();
852
- }
853
- if (hasProjectConfig() && !options.force) {
854
- warning("This directory is already initialized with Envpilot.");
855
- const { proceed } = await inquirer.prompt([
856
- {
857
- type: "confirm",
858
- name: "proceed",
859
- message: "Do you want to reinitialize?",
860
- default: false
861
- }
862
- ]);
863
- if (!proceed) {
864
- info("Initialization cancelled.");
865
- return;
866
- }
867
- }
868
- const api = createAPIClient();
869
- const organizations = await withSpinner(
870
- "Fetching organizations...",
871
- async () => {
872
- const response = await api.get("/api/cli/organizations");
873
- return response.data || [];
874
- }
875
- );
876
- if (organizations.length === 0) {
877
- error("No organizations found. Please create an organization first.");
878
- process.exit(1);
879
- }
880
- let selectedOrg;
881
- if (options.organization) {
882
- const org = organizations.find(
883
- (o) => o._id === options.organization || o.slug === options.organization
884
- );
885
- if (!org) {
886
- error(`Organization not found: ${options.organization}`);
887
- process.exit(1);
888
- }
889
- selectedOrg = org;
890
- } else if (organizations.length === 1) {
891
- selectedOrg = organizations[0];
892
- info(`Using organization: ${chalk4.bold(selectedOrg.name)}`);
893
- } else {
894
- const { orgId } = await inquirer.prompt([
895
- {
896
- type: "list",
897
- name: "orgId",
898
- message: "Select an organization:",
899
- choices: organizations.map((org) => ({
900
- name: `${org.name} ${org.tier === "pro" ? chalk4.green("(Pro)") : chalk4.dim("(Free)")}`,
901
- value: org._id
902
- }))
903
- }
904
- ]);
905
- selectedOrg = organizations.find((o) => o._id === orgId);
906
- }
907
- const projects = await withSpinner("Fetching projects...", async () => {
908
- const response = await api.get(
909
- "/api/cli/projects",
910
- { organizationId: selectedOrg._id }
911
- );
912
- return response.data || [];
913
- });
914
- if (projects.length === 0) {
915
- error("No projects found. Please create a project first.");
916
- process.exit(1);
917
- }
918
- let selectedProject;
919
- if (options.project) {
920
- const project = projects.find(
921
- (p) => p._id === options.project || p.slug === options.project
922
- );
923
- if (!project) {
924
- error(`Project not found: ${options.project}`);
925
- process.exit(1);
926
- }
927
- selectedProject = project;
928
- } else if (projects.length === 1) {
929
- selectedProject = projects[0];
930
- info(`Using project: ${chalk4.bold(selectedProject.name)}`);
931
- } else {
932
- const { projectId } = await inquirer.prompt([
933
- {
934
- type: "list",
935
- name: "projectId",
936
- message: "Select a project:",
937
- choices: projects.map((project) => ({
938
- name: `${project.icon || "\u{1F4E6}"} ${project.name}`,
939
- value: project._id
940
- }))
941
- }
942
- ]);
943
- selectedProject = projects.find((p) => p._id === projectId);
944
- }
945
- let selectedEnvironment = "development";
946
- if (options.environment) {
947
- if (!["development", "staging", "production"].includes(
948
- options.environment
949
- )) {
950
- error(
951
- "Invalid environment. Must be: development, staging, or production"
952
- );
953
- process.exit(1);
954
- }
955
- selectedEnvironment = options.environment;
956
- } else {
957
- const { environment } = await inquirer.prompt([
958
- {
959
- type: "list",
960
- name: "environment",
961
- message: "Select default environment:",
962
- choices: [
963
- { name: "Development", value: "development" },
964
- { name: "Staging", value: "staging" },
965
- { name: "Production", value: "production" }
966
- ],
967
- default: "development"
968
- }
969
- ]);
970
- selectedEnvironment = environment;
971
- }
972
- writeProjectConfig({
973
- projectId: selectedProject._id,
974
- organizationId: selectedOrg._id,
975
- environment: selectedEnvironment
976
- });
977
- setActiveOrganizationId(selectedOrg._id);
978
- setActiveProjectId(selectedProject._id);
979
- if (selectedOrg.role) {
980
- setRole(selectedOrg.role);
981
- }
982
- ensureEnvInGitignore();
983
- const trackedFiles = getTrackedEnvFiles();
984
- if (trackedFiles.length > 0) {
985
- console.log();
986
- warning("Security risk: .env files are tracked by git!");
987
- for (const file of trackedFiles) {
988
- console.log(chalk4.red(` tracked: ${file}`));
989
- }
990
- console.log();
991
- console.log(
992
- chalk4.yellow(
993
- " Run the following to untrack them (without deleting the files):"
994
- )
995
- );
996
- for (const file of trackedFiles) {
997
- console.log(chalk4.cyan(` git rm --cached ${file}`));
998
- }
999
- }
1000
- console.log();
1001
- success("Project initialized!");
1002
- console.log();
1003
- console.log(chalk4.dim("Configuration saved to .envpilot"));
1004
- if (selectedOrg.role) {
1005
- console.log(chalk4.dim(` Org role: ${formatRole(selectedOrg.role)}`));
1006
- roleNotice(selectedOrg.role);
1007
- }
1008
- if (selectedProject.projectRole) {
1009
- console.log(
1010
- chalk4.dim(
1011
- ` Project role: ${formatProjectRole(selectedProject.projectRole)}`
1012
- )
1013
- );
1014
- projectRoleNotice(selectedProject.projectRole);
1015
- }
1016
- console.log();
1017
- console.log("Next steps:");
1018
- console.log(
1019
- ` ${chalk4.cyan("envpilot pull")} Download environment variables`
1020
- );
1021
- console.log(
1022
- ` ${chalk4.cyan("envpilot push")} Upload local .env to cloud`
1023
- );
1024
- console.log();
1025
- } catch (err) {
1026
- await handleError(err);
1027
- }
1028
- });
1029
-
1030
- // src/commands/pull.ts
1031
- import { Command as Command3 } from "commander";
1032
- import chalk5 from "chalk";
1033
- import inquirer2 from "inquirer";
1034
-
1035
976
  // src/lib/env-file.ts
1036
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
977
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, chmodSync } from "fs";
1037
978
  import { join as join2 } from "path";
1038
979
  function parseEnvFile(content) {
1039
980
  const result = {};
@@ -1132,136 +1073,543 @@ function writeEnvFile(filePath, vars, options) {
1132
1073
  }
1133
1074
  function getEnvPathForEnvironment(environment, directory = process.cwd()) {
1134
1075
  if (environment === "development") {
1135
- return join2(directory, ".env");
1076
+ return join2(directory, ".env.local");
1136
1077
  }
1137
1078
  return join2(directory, `.env.${environment}`);
1138
1079
  }
1080
+ function applyFileProtection(filePath, role, projectRole) {
1081
+ if (!existsSync2(filePath)) return;
1082
+ const isWritable = role === "admin" || role === "team_lead" || projectRole === "manager";
1083
+ if (isWritable) {
1084
+ chmodSync(filePath, 420);
1085
+ } else {
1086
+ chmodSync(filePath, 292);
1087
+ }
1088
+ }
1139
1089
 
1140
- // src/commands/pull.ts
1141
- var pullCommand = new Command3("pull").description("Download environment variables to local .env file").option(
1142
- "-e, --env <environment>",
1143
- "Environment (development, staging, production)"
1144
- ).option("-f, --file <path>", "Output file path (default: .env)").option("--force", "Overwrite without confirmation").option("--format <format>", "Output format: env, json", "env").option("--dry-run", "Show what would be downloaded without writing").action(async (options) => {
1090
+ // src/commands/init.ts
1091
+ 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(
1092
+ "-e, --environment <env>",
1093
+ "Default environment (development, staging, production)"
1094
+ ).option("-f, --force", "Overwrite existing configuration").option("--add", "Add another project to existing config").action(async (options) => {
1145
1095
  try {
1146
1096
  if (!isAuthenticated()) {
1147
1097
  throw notAuthenticated();
1148
1098
  }
1149
- const projectConfig = readProjectConfig();
1150
- if (!projectConfig) {
1151
- throw notInitialized();
1152
- }
1153
- const trackedFiles = getTrackedEnvFiles();
1154
- if (trackedFiles.length > 0) {
1155
- error("Security risk: .env files are tracked by git!");
1156
- console.log();
1157
- for (const file of trackedFiles) {
1158
- console.log(chalk5.red(` tracked: ${file}`));
1099
+ const existingConfig = readProjectConfigV2();
1100
+ if (options.add) {
1101
+ if (!existingConfig) {
1102
+ error("No existing config. Run `envpilot init` first.");
1103
+ process.exit(1);
1159
1104
  }
1160
- console.log();
1161
- console.log(
1162
- chalk5.yellow(
1163
- " Run the following to untrack them (without deleting the files):"
1164
- )
1165
- );
1166
- for (const file of trackedFiles) {
1167
- console.log(chalk5.cyan(` git rm --cached ${file}`));
1105
+ await addProject(existingConfig, options);
1106
+ return;
1107
+ }
1108
+ if (hasProjectConfig() && !options.force) {
1109
+ warning("This directory is already initialized with Envpilot.");
1110
+ const { proceed } = await inquirer.prompt([
1111
+ {
1112
+ type: "confirm",
1113
+ name: "proceed",
1114
+ message: "Do you want to reinitialize?",
1115
+ default: false
1116
+ }
1117
+ ]);
1118
+ if (!proceed) {
1119
+ info("Initialization cancelled.");
1120
+ return;
1168
1121
  }
1169
- console.log();
1122
+ }
1123
+ const { selectedOrg, selectedProject, selectedEnvironment } = await selectOrgProjectEnv(options);
1124
+ writeProjectConfigV2({
1125
+ version: 1,
1126
+ activeProjectId: selectedProject._id,
1127
+ projects: [
1128
+ {
1129
+ projectId: selectedProject._id,
1130
+ organizationId: selectedOrg._id,
1131
+ projectName: selectedProject.name,
1132
+ organizationName: selectedOrg.name,
1133
+ environment: selectedEnvironment
1134
+ }
1135
+ ]
1136
+ });
1137
+ setActiveOrganizationId(selectedOrg._id);
1138
+ setActiveProjectId(selectedProject._id);
1139
+ if (selectedOrg.role) {
1140
+ setRole(selectedOrg.role);
1141
+ }
1142
+ ensureEnvInGitignore();
1143
+ warnTrackedFiles();
1144
+ console.log();
1145
+ success("Project initialized!");
1146
+ printPostInit(selectedOrg, selectedProject);
1147
+ } catch (err) {
1148
+ await handleError(err);
1149
+ }
1150
+ });
1151
+ async function addProject(existingConfig, options) {
1152
+ const api = createAPIClient();
1153
+ let role = getRole();
1154
+ if (role !== "admin" && role !== "team_lead") {
1155
+ const orgs = await withSpinner("Checking permissions...", async () => {
1156
+ const response = await api.get("/api/cli/organizations");
1157
+ return response.data || [];
1158
+ });
1159
+ const freshRole = orgs.find(
1160
+ (o) => o._id === existingConfig.projects[0]?.organizationId
1161
+ )?.role;
1162
+ if (freshRole) {
1163
+ setRole(freshRole);
1164
+ role = freshRole;
1165
+ }
1166
+ if (role !== "admin" && role !== "team_lead") {
1167
+ error("Only admins and team leads can link multiple projects.");
1168
+ info("Unlink the current project first with `envpilot unlink`.");
1170
1169
  process.exit(1);
1171
1170
  }
1172
- const environment = options.env || projectConfig.environment || "development";
1173
- const outputPath = options.file || getEnvPathForEnvironment(environment);
1174
- const api = createAPIClient();
1175
- let metaProjectRole;
1176
- const variables = await withSpinner(
1177
- `Fetching ${chalk5.bold(environment)} variables...`,
1178
- async () => {
1179
- const response = await api.get("/api/cli/variables", {
1180
- projectId: projectConfig.projectId,
1181
- environment,
1182
- ...projectConfig.organizationId && {
1183
- organizationId: projectConfig.organizationId
1184
- }
1185
- });
1186
- metaProjectRole = response.meta?.projectRole;
1187
- return response.data || [];
1188
- }
1171
+ }
1172
+ const { selectedOrg, selectedProject, selectedEnvironment } = await selectOrgProjectEnv(options);
1173
+ if (existingConfig.projects.some((p) => p.projectId === selectedProject._id)) {
1174
+ error(`"${selectedProject.name}" is already linked.`);
1175
+ process.exit(1);
1176
+ }
1177
+ const envFile = getEnvPathForEnvironment(selectedEnvironment);
1178
+ const conflicting = existingConfig.projects.find(
1179
+ (p) => p.environment === selectedEnvironment
1180
+ );
1181
+ if (conflicting) {
1182
+ warning(
1183
+ `"${conflicting.projectName || conflicting.projectId}" already syncs to ${envFile} (${selectedEnvironment}). Both projects will write to the same file \u2014 consider using a different environment.`
1189
1184
  );
1190
- if (variables.length === 0) {
1191
- warning(`No variables found for ${environment} environment.`);
1192
- return;
1185
+ }
1186
+ const newEntry = {
1187
+ projectId: selectedProject._id,
1188
+ organizationId: selectedOrg._id,
1189
+ projectName: selectedProject.name,
1190
+ organizationName: selectedOrg.name,
1191
+ environment: selectedEnvironment
1192
+ };
1193
+ let updatedConfig = addProjectToConfig(existingConfig, newEntry);
1194
+ const { setActive } = await inquirer.prompt([
1195
+ {
1196
+ type: "confirm",
1197
+ name: "setActive",
1198
+ message: `Set "${selectedProject.name}" as the active project?`,
1199
+ default: false
1193
1200
  }
1194
- const remoteVars = {};
1195
- for (const variable of variables) {
1196
- remoteVars[variable.key] = variable.value;
1201
+ ]);
1202
+ if (setActive) {
1203
+ updatedConfig = {
1204
+ ...updatedConfig,
1205
+ activeProjectId: selectedProject._id
1206
+ };
1207
+ setActiveProjectId(selectedProject._id);
1208
+ setActiveOrganizationId(selectedOrg._id);
1209
+ }
1210
+ updatedConfig = backfillNames(updatedConfig);
1211
+ writeProjectConfigV2(updatedConfig);
1212
+ console.log();
1213
+ success(`Added "${selectedProject.name}" to linked projects!`);
1214
+ console.log(
1215
+ chalk4.dim(` ${existingConfig.projects.length + 1} projects now linked`)
1216
+ );
1217
+ console.log();
1218
+ console.log("Next steps:");
1219
+ console.log(
1220
+ ` ${chalk4.cyan("envpilot pull --all")} Pull all projects`
1221
+ );
1222
+ console.log(
1223
+ ` ${chalk4.cyan(`envpilot pull --project "${selectedProject.name}"`)} Pull this project`
1224
+ );
1225
+ console.log(
1226
+ ` ${chalk4.cyan("envpilot list linked")} See all linked projects`
1227
+ );
1228
+ console.log();
1229
+ }
1230
+ async function selectOrgProjectEnv(options) {
1231
+ const api = createAPIClient();
1232
+ const organizations = await withSpinner(
1233
+ "Fetching organizations...",
1234
+ async () => {
1235
+ const response = await api.get("/api/cli/organizations");
1236
+ return response.data || [];
1197
1237
  }
1198
- const localVars = readEnvFile(outputPath) || {};
1199
- const diffResult = diffEnvVars(remoteVars, localVars);
1200
- const hasChanges = Object.keys(diffResult.added).length > 0 || Object.keys(diffResult.removed).length > 0 || Object.keys(diffResult.changed).length > 0;
1201
- if (!hasChanges) {
1202
- success("Local file is up to date.");
1203
- return;
1238
+ );
1239
+ if (organizations.length === 0) {
1240
+ error("No organizations found. Please create an organization first.");
1241
+ process.exit(1);
1242
+ }
1243
+ let selectedOrg;
1244
+ if (options.organization) {
1245
+ const org = organizations.find(
1246
+ (o) => o._id === options.organization || o.slug === options.organization
1247
+ );
1248
+ if (!org) {
1249
+ error(`Organization not found: ${options.organization}`);
1250
+ process.exit(1);
1204
1251
  }
1252
+ selectedOrg = org;
1253
+ } else if (organizations.length === 1) {
1254
+ selectedOrg = organizations[0];
1255
+ info(`Using organization: ${chalk4.bold(selectedOrg.name)}`);
1256
+ } else {
1257
+ const { orgId } = await inquirer.prompt([
1258
+ {
1259
+ type: "list",
1260
+ name: "orgId",
1261
+ message: "Select an organization:",
1262
+ choices: organizations.map((org) => ({
1263
+ name: `${org.name} ${org.tier === "pro" ? chalk4.green("(Pro)") : chalk4.dim("(Free)")}`,
1264
+ value: org._id
1265
+ }))
1266
+ }
1267
+ ]);
1268
+ selectedOrg = organizations.find((o) => o._id === orgId);
1269
+ }
1270
+ const projects = await withSpinner("Fetching projects...", async () => {
1271
+ const response = await api.get(
1272
+ "/api/cli/projects",
1273
+ { organizationId: selectedOrg._id }
1274
+ );
1275
+ return response.data || [];
1276
+ });
1277
+ if (projects.length === 0) {
1278
+ error("No projects found. Please create a project first.");
1279
+ process.exit(1);
1280
+ }
1281
+ let selectedProject;
1282
+ if (options.project) {
1283
+ const project = projects.find(
1284
+ (p) => p._id === options.project || p.slug === options.project
1285
+ );
1286
+ if (!project) {
1287
+ error(`Project not found: ${options.project}`);
1288
+ process.exit(1);
1289
+ }
1290
+ selectedProject = project;
1291
+ } else if (projects.length === 1) {
1292
+ selectedProject = projects[0];
1293
+ info(`Using project: ${chalk4.bold(selectedProject.name)}`);
1294
+ } else {
1295
+ const { projectId } = await inquirer.prompt([
1296
+ {
1297
+ type: "list",
1298
+ name: "projectId",
1299
+ message: "Select a project:",
1300
+ choices: projects.map((project) => ({
1301
+ name: `${project.icon || "\u{1F4E6}"} ${project.name}`,
1302
+ value: project._id
1303
+ }))
1304
+ }
1305
+ ]);
1306
+ selectedProject = projects.find((p) => p._id === projectId);
1307
+ }
1308
+ let selectedEnvironment = "development";
1309
+ if (options.environment) {
1310
+ if (!["development", "staging", "production"].includes(options.environment)) {
1311
+ error(
1312
+ "Invalid environment. Must be: development, staging, or production"
1313
+ );
1314
+ process.exit(1);
1315
+ }
1316
+ selectedEnvironment = options.environment;
1317
+ } else {
1318
+ const { environment } = await inquirer.prompt([
1319
+ {
1320
+ type: "list",
1321
+ name: "environment",
1322
+ message: "Select default environment:",
1323
+ choices: [
1324
+ { name: "Development", value: "development" },
1325
+ { name: "Staging", value: "staging" },
1326
+ { name: "Production", value: "production" }
1327
+ ],
1328
+ default: "development"
1329
+ }
1330
+ ]);
1331
+ selectedEnvironment = environment;
1332
+ }
1333
+ return { selectedOrg, selectedProject, selectedEnvironment };
1334
+ }
1335
+ function backfillNames(config2) {
1336
+ return config2;
1337
+ }
1338
+ function warnTrackedFiles() {
1339
+ const trackedFiles = getTrackedEnvFiles();
1340
+ if (trackedFiles.length > 0) {
1205
1341
  console.log();
1206
- console.log(chalk5.bold("Changes:"));
1207
- console.log();
1208
- diff(diffResult.added, diffResult.removed, diffResult.changed);
1342
+ warning("Security risk: .env files are tracked by git!");
1343
+ for (const file of trackedFiles) {
1344
+ console.log(chalk4.red(` tracked: ${file}`));
1345
+ }
1209
1346
  console.log();
1210
- if (options.dryRun) {
1211
- info("Dry run - no changes written.");
1347
+ console.log(
1348
+ chalk4.yellow(
1349
+ " Run the following to untrack them (without deleting the files):"
1350
+ )
1351
+ );
1352
+ for (const file of trackedFiles) {
1353
+ console.log(chalk4.cyan(` git rm --cached ${file}`));
1354
+ }
1355
+ }
1356
+ }
1357
+ function printPostInit(selectedOrg, selectedProject) {
1358
+ console.log();
1359
+ console.log(chalk4.dim("Configuration saved to .envpilot"));
1360
+ if (selectedOrg.role) {
1361
+ console.log(chalk4.dim(` Org role: ${formatRole(selectedOrg.role)}`));
1362
+ roleNotice(selectedOrg.role);
1363
+ }
1364
+ if (selectedProject.projectRole) {
1365
+ console.log(
1366
+ chalk4.dim(
1367
+ ` Project role: ${formatProjectRole(selectedProject.projectRole)}`
1368
+ )
1369
+ );
1370
+ projectRoleNotice(selectedProject.projectRole);
1371
+ }
1372
+ console.log();
1373
+ console.log("Next steps:");
1374
+ console.log(
1375
+ ` ${chalk4.cyan("envpilot pull")} Download environment variables`
1376
+ );
1377
+ console.log(
1378
+ ` ${chalk4.cyan("envpilot push")} Upload local .env to cloud`
1379
+ );
1380
+ console.log();
1381
+ }
1382
+
1383
+ // src/commands/pull.ts
1384
+ import { Command as Command3 } from "commander";
1385
+ import chalk5 from "chalk";
1386
+ import inquirer2 from "inquirer";
1387
+ var pullCommand = new Command3("pull").description("Download environment variables to local .env file").option(
1388
+ "-e, --env <environment>",
1389
+ "Environment (development, staging, production)"
1390
+ ).option("-f, --file <path>", "Output file path (default: .env)").option("--force", "Overwrite without confirmation").option("--format <format>", "Output format: env, json", "env").option("--dry-run", "Show what would be downloaded without writing").option("--project <name-or-id>", "Pull a specific linked project").option("--all", "Pull all linked projects").action(async (options) => {
1391
+ try {
1392
+ if (!isAuthenticated()) {
1393
+ throw notAuthenticated();
1394
+ }
1395
+ if (options.all) {
1396
+ if (options.env || options.file) {
1397
+ error("Cannot use --env or --file with --all.");
1398
+ process.exit(1);
1399
+ }
1400
+ await pullAllProjects(options);
1212
1401
  return;
1213
1402
  }
1214
- if (!options.force && Object.keys(localVars).length > 0) {
1215
- const { proceed } = await inquirer2.prompt([
1216
- {
1217
- type: "confirm",
1218
- name: "proceed",
1219
- message: `Overwrite ${outputPath}?`,
1220
- default: true
1403
+ if (options.project) {
1404
+ const configV2 = readProjectConfigV2();
1405
+ if (!configV2) throw notInitialized();
1406
+ const project = resolveProject(configV2, options.project);
1407
+ if (!project) {
1408
+ error(`Project not found: ${options.project}`);
1409
+ console.log();
1410
+ console.log("Linked projects:");
1411
+ for (const p of configV2.projects) {
1412
+ console.log(` ${p.projectName || p.projectId} (${p.environment})`);
1221
1413
  }
1222
- ]);
1223
- if (!proceed) {
1224
- info("Pull cancelled.");
1225
- return;
1414
+ process.exit(1);
1226
1415
  }
1416
+ await pullSingleProject(project, options);
1417
+ return;
1227
1418
  }
1228
- if (options.format === "json") {
1229
- const fs = await import("fs");
1230
- fs.writeFileSync(
1419
+ const projectConfig = readProjectConfig();
1420
+ if (!projectConfig) throw notInitialized();
1421
+ checkTrackedFiles();
1422
+ const environment = options.env || projectConfig.environment || "development";
1423
+ const outputPath = options.file || getEnvPathForEnvironment(environment);
1424
+ await pullProject(
1425
+ {
1426
+ projectId: projectConfig.projectId,
1427
+ organizationId: projectConfig.organizationId,
1428
+ environment
1429
+ },
1430
+ outputPath,
1431
+ options
1432
+ );
1433
+ } catch (err) {
1434
+ await handleError(err);
1435
+ }
1436
+ });
1437
+ async function pullAllProjects(options) {
1438
+ const configV2 = readProjectConfigV2();
1439
+ if (!configV2) throw notInitialized();
1440
+ checkTrackedFiles();
1441
+ let totalPulled = 0;
1442
+ let totalFailed = 0;
1443
+ for (const project of configV2.projects) {
1444
+ const outputPath = getEnvPathForEnvironment(project.environment);
1445
+ const displayName = project.projectName || project.projectId;
1446
+ console.log();
1447
+ console.log(
1448
+ chalk5.bold(
1449
+ `Pulling "${displayName}" (${project.environment}) \u2192 ${outputPath}`
1450
+ )
1451
+ );
1452
+ try {
1453
+ await pullProject(
1454
+ {
1455
+ projectId: project.projectId,
1456
+ organizationId: project.organizationId,
1457
+ environment: project.environment
1458
+ },
1231
1459
  outputPath,
1232
- JSON.stringify(remoteVars, null, 2) + "\n"
1460
+ options
1233
1461
  );
1234
- } else {
1235
- const comments = {};
1236
- for (const variable of variables) {
1237
- if (variable.description) {
1238
- comments[variable.key] = variable.description;
1462
+ totalPulled++;
1463
+ } catch (err) {
1464
+ totalFailed++;
1465
+ if (err instanceof Error) {
1466
+ error(` Failed: ${err.message}`);
1467
+ }
1468
+ }
1469
+ }
1470
+ console.log();
1471
+ if (totalFailed === 0) {
1472
+ success(`Pulled ${totalPulled} project${totalPulled !== 1 ? "s" : ""}`);
1473
+ } else {
1474
+ warning(
1475
+ `Pulled ${totalPulled}/${totalPulled + totalFailed} projects. ${totalFailed} failed.`
1476
+ );
1477
+ }
1478
+ }
1479
+ async function pullSingleProject(project, options) {
1480
+ checkTrackedFiles();
1481
+ const environment = options.env || project.environment;
1482
+ const outputPath = options.file || getEnvPathForEnvironment(environment);
1483
+ await pullProject(
1484
+ {
1485
+ projectId: project.projectId,
1486
+ organizationId: project.organizationId,
1487
+ environment
1488
+ },
1489
+ outputPath,
1490
+ options
1491
+ );
1492
+ }
1493
+ async function pullProject(project, outputPath, options) {
1494
+ const api = createAPIClient();
1495
+ let metaProjectRole;
1496
+ const variables = await withSpinner(
1497
+ `Fetching ${chalk5.bold(project.environment)} variables...`,
1498
+ async () => {
1499
+ const response = await api.get("/api/cli/variables", {
1500
+ projectId: project.projectId,
1501
+ environment: project.environment,
1502
+ ...project.organizationId && {
1503
+ organizationId: project.organizationId
1239
1504
  }
1505
+ });
1506
+ metaProjectRole = response.meta?.projectRole;
1507
+ return response.data || [];
1508
+ }
1509
+ );
1510
+ if (variables.length === 0) {
1511
+ warning(`No variables found for ${project.environment} environment.`);
1512
+ return;
1513
+ }
1514
+ const remoteVars = {};
1515
+ for (const variable of variables) {
1516
+ remoteVars[variable.key] = variable.value;
1517
+ }
1518
+ const localVars = readEnvFile(outputPath) || {};
1519
+ const diffResult = diffEnvVars(remoteVars, localVars);
1520
+ const hasChanges = Object.keys(diffResult.added).length > 0 || Object.keys(diffResult.removed).length > 0 || Object.keys(diffResult.changed).length > 0;
1521
+ if (!hasChanges) {
1522
+ success("Local file is up to date.");
1523
+ return;
1524
+ }
1525
+ console.log();
1526
+ console.log(chalk5.bold("Changes:"));
1527
+ console.log();
1528
+ diff(diffResult.added, diffResult.removed, diffResult.changed);
1529
+ console.log();
1530
+ if (options.dryRun) {
1531
+ info("Dry run - no changes written.");
1532
+ return;
1533
+ }
1534
+ if (!options.force && Object.keys(localVars).length > 0) {
1535
+ const { proceed } = await inquirer2.prompt([
1536
+ {
1537
+ type: "confirm",
1538
+ name: "proceed",
1539
+ message: `Overwrite ${outputPath}?`,
1540
+ default: true
1240
1541
  }
1241
- writeEnvFile(outputPath, remoteVars, { sort: true, comments });
1542
+ ]);
1543
+ if (!proceed) {
1544
+ info("Pull cancelled.");
1545
+ return;
1242
1546
  }
1243
- success(
1244
- `Downloaded ${variables.length} variables to ${chalk5.bold(outputPath)}`
1547
+ }
1548
+ try {
1549
+ const fs = await import("fs");
1550
+ if (fs.existsSync(outputPath)) {
1551
+ fs.chmodSync(outputPath, 420);
1552
+ }
1553
+ } catch {
1554
+ }
1555
+ if (options.format === "json") {
1556
+ const fs = await import("fs");
1557
+ fs.writeFileSync(outputPath, JSON.stringify(remoteVars, null, 2) + "\n");
1558
+ } else {
1559
+ const comments = {};
1560
+ for (const variable of variables) {
1561
+ if (variable.description) {
1562
+ comments[variable.key] = variable.description;
1563
+ }
1564
+ }
1565
+ writeEnvFile(outputPath, remoteVars, { sort: true, comments });
1566
+ }
1567
+ const role = getRole();
1568
+ applyFileProtection(outputPath, role, metaProjectRole);
1569
+ success(
1570
+ `Downloaded ${variables.length} variables to ${chalk5.bold(outputPath)}`
1571
+ );
1572
+ if (metaProjectRole === "viewer") {
1573
+ info(
1574
+ "You have Viewer access to this project. You may only see variables you have been explicitly granted access to."
1245
1575
  );
1246
- if (metaProjectRole === "viewer") {
1247
- info(
1248
- "You have Viewer access to this project. You may only see variables you have been explicitly granted access to."
1249
- );
1576
+ }
1577
+ const isProtected = role !== "admin" && role !== "team_lead" && metaProjectRole !== "manager";
1578
+ if (isProtected) {
1579
+ info(
1580
+ `File is read-only (your role: ${role || metaProjectRole || "member"}).`
1581
+ );
1582
+ }
1583
+ console.log();
1584
+ console.log(chalk5.dim(` Added: ${Object.keys(diffResult.added).length}`));
1585
+ console.log(
1586
+ chalk5.dim(` Changed: ${Object.keys(diffResult.changed).length}`)
1587
+ );
1588
+ console.log(
1589
+ chalk5.dim(` Removed: ${Object.keys(diffResult.removed).length}`)
1590
+ );
1591
+ }
1592
+ function checkTrackedFiles() {
1593
+ const trackedFiles = getTrackedEnvFiles();
1594
+ if (trackedFiles.length > 0) {
1595
+ error("Security risk: .env files are tracked by git!");
1596
+ console.log();
1597
+ for (const file of trackedFiles) {
1598
+ console.log(chalk5.red(` tracked: ${file}`));
1250
1599
  }
1251
1600
  console.log();
1252
1601
  console.log(
1253
- chalk5.dim(` Added: ${Object.keys(diffResult.added).length}`)
1254
- );
1255
- console.log(
1256
- chalk5.dim(` Changed: ${Object.keys(diffResult.changed).length}`)
1257
- );
1258
- console.log(
1259
- chalk5.dim(` Removed: ${Object.keys(diffResult.removed).length}`)
1602
+ chalk5.yellow(
1603
+ " Run the following to untrack them (without deleting the files):"
1604
+ )
1260
1605
  );
1261
- } catch (err) {
1262
- await handleError(err);
1606
+ for (const file of trackedFiles) {
1607
+ console.log(chalk5.cyan(` git rm --cached ${file}`));
1608
+ }
1609
+ console.log();
1610
+ process.exit(1);
1263
1611
  }
1264
- });
1612
+ }
1265
1613
 
1266
1614
  // src/commands/push.ts
1267
1615
  import { Command as Command4 } from "commander";
@@ -1310,14 +1658,36 @@ function validateEnvVars(vars) {
1310
1658
  var pushCommand = new Command4("push").description("Upload local .env file to cloud").option(
1311
1659
  "-e, --env <environment>",
1312
1660
  "Target environment (development, staging, production)"
1313
- ).option("-f, --file <path>", "Input file path (default: .env)").option("--merge", "Merge with existing variables (default)").option("--replace", "Replace all existing variables").option("--dry-run", "Show what would be uploaded without making changes").option("--force", "Skip confirmation").action(async (options) => {
1661
+ ).option("-f, --file <path>", "Input file path (default: .env)").option("--merge", "Merge with existing variables (default)").option("--replace", "Replace all existing variables").option("--dry-run", "Show what would be uploaded without making changes").option("--force", "Skip confirmation").option("--project <name-or-id>", "Push to a specific linked project").action(async (options) => {
1314
1662
  try {
1315
1663
  if (!isAuthenticated()) {
1316
1664
  throw notAuthenticated();
1317
1665
  }
1318
- const projectConfig = readProjectConfig();
1319
- if (!projectConfig) {
1320
- throw notInitialized();
1666
+ let projectId;
1667
+ let organizationId;
1668
+ let defaultEnvironment;
1669
+ if (options.project) {
1670
+ const configV2 = readProjectConfigV2();
1671
+ if (!configV2) throw notInitialized();
1672
+ const resolved = resolveProject(configV2, options.project);
1673
+ if (!resolved) {
1674
+ error(`Project not found: ${options.project}`);
1675
+ console.log();
1676
+ console.log("Linked projects:");
1677
+ for (const p of configV2.projects) {
1678
+ console.log(` ${p.projectName || p.projectId} (${p.environment})`);
1679
+ }
1680
+ process.exit(1);
1681
+ }
1682
+ projectId = resolved.projectId;
1683
+ organizationId = resolved.organizationId;
1684
+ defaultEnvironment = resolved.environment;
1685
+ } else {
1686
+ const projectConfig = readProjectConfig();
1687
+ if (!projectConfig) throw notInitialized();
1688
+ projectId = projectConfig.projectId;
1689
+ organizationId = projectConfig.organizationId;
1690
+ defaultEnvironment = projectConfig.environment;
1321
1691
  }
1322
1692
  const trackedFiles = getTrackedEnvFiles();
1323
1693
  if (trackedFiles.length > 0) {
@@ -1339,10 +1709,8 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
1339
1709
  process.exit(1);
1340
1710
  }
1341
1711
  const api = createAPIClient();
1342
- const projects = await api.get("/api/cli/projects", { organizationId: projectConfig.organizationId });
1343
- const currentProject = projects.data?.find(
1344
- (p) => p._id === projectConfig.projectId
1345
- );
1712
+ const projects = await api.get("/api/cli/projects", { organizationId });
1713
+ const currentProject = projects.data?.find((p) => p._id === projectId);
1346
1714
  const projectRole = currentProject?.projectRole;
1347
1715
  if (projectRole === "viewer") {
1348
1716
  error(
@@ -1371,7 +1739,7 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
1371
1739
  }
1372
1740
  }
1373
1741
  }
1374
- const environment = options.env || projectConfig.environment || "development";
1742
+ const environment = options.env || defaultEnvironment || "development";
1375
1743
  const inputPath = options.file || getEnvPathForEnvironment(environment);
1376
1744
  const mode = options.replace ? "replace" : "merge";
1377
1745
  const localVars = readEnvFile(inputPath);
@@ -1398,11 +1766,11 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
1398
1766
  "Fetching current variables...",
1399
1767
  async () => {
1400
1768
  const params = {
1401
- projectId: projectConfig.projectId,
1769
+ projectId,
1402
1770
  environment
1403
1771
  };
1404
- if (projectConfig.organizationId) {
1405
- params.organizationId = projectConfig.organizationId;
1772
+ if (organizationId) {
1773
+ params.organizationId = organizationId;
1406
1774
  }
1407
1775
  const response = await api.get("/api/cli/variables", params);
1408
1776
  return response.data || [];
@@ -1466,16 +1834,14 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
1466
1834
  `Pushing variables to ${chalk6.bold(environment)}...`,
1467
1835
  async () => {
1468
1836
  const response = await api.post("/api/cli/variables/bulk", {
1469
- projectId: projectConfig.projectId,
1837
+ projectId,
1470
1838
  environment,
1471
1839
  variables: Object.entries(valid).map(([key, value]) => ({
1472
1840
  key,
1473
1841
  value
1474
1842
  })),
1475
1843
  mode,
1476
- ...projectConfig.organizationId && {
1477
- organizationId: projectConfig.organizationId
1478
- }
1844
+ ...organizationId && { organizationId }
1479
1845
  });
1480
1846
  return response.data;
1481
1847
  }
@@ -1515,16 +1881,48 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
1515
1881
  import { Command as Command5 } from "commander";
1516
1882
  import chalk7 from "chalk";
1517
1883
  import inquirer4 from "inquirer";
1518
- var switchCommand = new Command5("switch").description("Switch project or environment").argument("[target]", "project slug or environment name").option("-o, --organization <id>", "Switch organization").option("-p, --project <id>", "Switch project").option(
1884
+ 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(
1519
1885
  "-e, --env <environment>",
1520
1886
  "Switch environment (development, staging, production)"
1521
- ).action(async (target, options) => {
1887
+ ).option("--active <name-or-id>", "Set a linked project as active").action(async (target, options) => {
1522
1888
  try {
1523
1889
  if (!isAuthenticated()) {
1524
1890
  throw notAuthenticated();
1525
1891
  }
1526
1892
  const api = createAPIClient();
1527
1893
  const projectConfig = readProjectConfig();
1894
+ if (options.active) {
1895
+ const configV2 = readProjectConfigV2();
1896
+ if (!configV2 || configV2.projects.length < 2) {
1897
+ error(
1898
+ "No multiple projects linked. Use `envpilot init --add` to link another project."
1899
+ );
1900
+ process.exit(1);
1901
+ }
1902
+ const target2 = configV2.projects.find(
1903
+ (p) => p.projectId === options.active || p.projectName.toLowerCase() === options.active.toLowerCase()
1904
+ );
1905
+ if (!target2) {
1906
+ error(`Project not found: ${options.active}`);
1907
+ console.log();
1908
+ console.log("Linked projects:");
1909
+ for (const p of configV2.projects) {
1910
+ const mark = p.projectId === configV2.activeProjectId ? chalk7.green(" *") : "";
1911
+ console.log(
1912
+ ` ${p.projectName || p.projectId} (${p.environment})${mark}`
1913
+ );
1914
+ }
1915
+ process.exit(1);
1916
+ }
1917
+ const updated = setActiveProjectInConfig(configV2, target2.projectId);
1918
+ writeProjectConfigV2(updated);
1919
+ setActiveProjectId(target2.projectId);
1920
+ setActiveOrganizationId(target2.organizationId);
1921
+ success(
1922
+ `Active project: ${chalk7.bold(target2.projectName || target2.projectId)}`
1923
+ );
1924
+ return;
1925
+ }
1528
1926
  if (options.env || target && ["development", "staging", "production"].includes(target)) {
1529
1927
  const environment = options.env || target;
1530
1928
  if (!projectConfig) {
@@ -1566,6 +1964,25 @@ var switchCommand = new Command5("switch").description("Switch project or enviro
1566
1964
  }
1567
1965
  if (options.project || target) {
1568
1966
  const projectIdentifier = options.project || target;
1967
+ const configV2 = readProjectConfigV2();
1968
+ if (configV2) {
1969
+ const linked = configV2.projects.find(
1970
+ (p) => p.projectId === projectIdentifier || p.projectName.toLowerCase() === projectIdentifier.toLowerCase()
1971
+ );
1972
+ if (linked) {
1973
+ const updated = setActiveProjectInConfig(
1974
+ configV2,
1975
+ linked.projectId
1976
+ );
1977
+ writeProjectConfigV2(updated);
1978
+ setActiveProjectId(linked.projectId);
1979
+ setActiveOrganizationId(linked.organizationId);
1980
+ success(
1981
+ `Switched to project: ${chalk7.bold(linked.projectName || linked.projectId)}`
1982
+ );
1983
+ return;
1984
+ }
1985
+ }
1569
1986
  let organizationId = projectConfig?.organizationId;
1570
1987
  if (!organizationId) {
1571
1988
  const organizations = await withSpinner(
@@ -1604,10 +2021,7 @@ var switchCommand = new Command5("switch").description("Switch project or enviro
1604
2021
  }
1605
2022
  }
1606
2023
  const projects = await withSpinner("Fetching projects...", async () => {
1607
- const response = await api.get(
1608
- "/api/cli/projects",
1609
- { organizationId }
1610
- );
2024
+ const response = await api.get("/api/cli/projects", { organizationId });
1611
2025
  return response.data || [];
1612
2026
  });
1613
2027
  const project = projects.find(
@@ -1641,19 +2055,57 @@ var switchCommand = new Command5("switch").description("Switch project or enviro
1641
2055
  }
1642
2056
  return;
1643
2057
  }
1644
- if (!target && !options.project && !options.organization && !options.env) {
2058
+ if (!target && !options.project && !options.organization && !options.env && !options.active) {
2059
+ const configV2 = readProjectConfigV2();
2060
+ const hasMultipleProjects = configV2 && configV2.projects.length > 1;
2061
+ const choices = [];
2062
+ if (hasMultipleProjects) {
2063
+ choices.push({
2064
+ name: "Active project",
2065
+ value: "active"
2066
+ });
2067
+ }
2068
+ choices.push(
2069
+ { name: "Environment", value: "environment" },
2070
+ { name: "Project", value: "project" },
2071
+ { name: "Organization", value: "organization" }
2072
+ );
1645
2073
  const { switchType } = await inquirer4.prompt([
1646
2074
  {
1647
2075
  type: "list",
1648
2076
  name: "switchType",
1649
2077
  message: "What would you like to switch?",
1650
- choices: [
1651
- { name: "Environment", value: "environment" },
1652
- { name: "Project", value: "project" },
1653
- { name: "Organization", value: "organization" }
1654
- ]
2078
+ choices
1655
2079
  }
1656
2080
  ]);
2081
+ if (switchType === "active" && configV2) {
2082
+ const { projectId } = await inquirer4.prompt([
2083
+ {
2084
+ type: "list",
2085
+ name: "projectId",
2086
+ message: "Select active project:",
2087
+ choices: configV2.projects.map((p) => {
2088
+ const isActive = p.projectId === configV2.activeProjectId;
2089
+ return {
2090
+ name: `${p.projectName || p.projectId} (${p.environment})${isActive ? chalk7.green(" *current") : ""}`,
2091
+ value: p.projectId
2092
+ };
2093
+ }),
2094
+ default: configV2.activeProjectId
2095
+ }
2096
+ ]);
2097
+ const selected = configV2.projects.find(
2098
+ (p) => p.projectId === projectId
2099
+ );
2100
+ const updated = setActiveProjectInConfig(configV2, projectId);
2101
+ writeProjectConfigV2(updated);
2102
+ setActiveProjectId(projectId);
2103
+ setActiveOrganizationId(selected.organizationId);
2104
+ success(
2105
+ `Active project: ${chalk7.bold(selected.projectName || selected.projectId)}`
2106
+ );
2107
+ return;
2108
+ }
1657
2109
  if (switchType === "environment") {
1658
2110
  if (!projectConfig) {
1659
2111
  error("No project initialized. Run `envpilot init` first.");
@@ -1770,7 +2222,7 @@ import { Command as Command6 } from "commander";
1770
2222
  import chalk8 from "chalk";
1771
2223
  var listCommand = new Command6("list").description("List resources").argument(
1772
2224
  "[resource]",
1773
- "Resource type: projects, organizations, variables",
2225
+ "Resource type: projects, organizations, variables, linked",
1774
2226
  "projects"
1775
2227
  ).option("-o, --organization <id>", "Organization ID (for projects/variables)").option("-p, --project <id>", "Project ID (for variables)").option("-e, --env <environment>", "Environment filter (for variables)").option("--show-values", "Show actual variable values (masked by default)").option("--json", "Output as JSON").action(async (resource, options) => {
1776
2228
  try {
@@ -1791,6 +2243,9 @@ var listCommand = new Command6("list").description("List resources").argument(
1791
2243
  case "variables":
1792
2244
  await listVariables(api, projectConfig, options);
1793
2245
  break;
2246
+ case "linked":
2247
+ listLinked();
2248
+ break;
1794
2249
  default:
1795
2250
  error(`Unknown resource: ${resource}`);
1796
2251
  console.log();
@@ -1800,12 +2255,43 @@ var listCommand = new Command6("list").description("List resources").argument(
1800
2255
  " projects List projects in an organization"
1801
2256
  );
1802
2257
  console.log(" variables (vars) List variables in a project");
2258
+ console.log(
2259
+ " linked List projects linked in this directory"
2260
+ );
1803
2261
  process.exit(1);
1804
2262
  }
1805
2263
  } catch (err) {
1806
2264
  await handleError(err);
1807
2265
  }
1808
2266
  });
2267
+ function listLinked() {
2268
+ const configV2 = readProjectConfigV2();
2269
+ if (!configV2) {
2270
+ info("No projects linked. Run `envpilot init` to get started.");
2271
+ return;
2272
+ }
2273
+ header(`Linked Projects (${configV2.projects.length})`);
2274
+ console.log();
2275
+ for (const project of configV2.projects) {
2276
+ const isActive = project.projectId === configV2.activeProjectId;
2277
+ const marker = isActive ? chalk8.green("*") : " ";
2278
+ const envFile = getEnvPathForEnvironment(project.environment);
2279
+ console.log(
2280
+ ` ${marker} ${chalk8.bold(project.projectName || project.projectId)} ${chalk8.dim(`(${project.organizationName || project.organizationId})`)}`
2281
+ );
2282
+ console.log(` ${project.environment} ${chalk8.dim("\u2192")} ${envFile}`);
2283
+ console.log();
2284
+ }
2285
+ if (configV2.projects.length > 1) {
2286
+ console.log(chalk8.dim(" (* = active project)"));
2287
+ console.log();
2288
+ console.log(
2289
+ chalk8.dim(
2290
+ ' Use `envpilot switch --active "<name>"` to change the active project'
2291
+ )
2292
+ );
2293
+ }
2294
+ }
1809
2295
  async function listOrganizations(api, options) {
1810
2296
  const organizations = await withSpinner(
1811
2297
  "Fetching organizations...",
@@ -2105,8 +2591,8 @@ async function handlePath() {
2105
2591
  ]);
2106
2592
  }
2107
2593
  async function handleReset() {
2108
- const inquirer5 = await import("inquirer");
2109
- const { confirm } = await inquirer5.default.prompt([
2594
+ const inquirer6 = await import("inquirer");
2595
+ const { confirm } = await inquirer6.default.prompt([
2110
2596
  {
2111
2597
  type: "confirm",
2112
2598
  name: "confirm",
@@ -2143,21 +2629,421 @@ var logoutCommand = new Command8("logout").description("Log out from Envpilot").
2143
2629
  }
2144
2630
  });
2145
2631
 
2146
- // src/commands/usage.ts
2632
+ // src/commands/unlink.ts
2147
2633
  import { Command as Command9 } from "commander";
2148
2634
  import chalk10 from "chalk";
2635
+ import inquirer5 from "inquirer";
2636
+ 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) => {
2637
+ try {
2638
+ if (!isAuthenticated()) {
2639
+ throw notAuthenticated();
2640
+ }
2641
+ const config2 = readProjectConfigV2();
2642
+ if (!config2 || config2.projects.length === 0) {
2643
+ error("No projects linked. Run `envpilot init` first.");
2644
+ process.exit(1);
2645
+ }
2646
+ let targetProject;
2647
+ if (projectArg) {
2648
+ targetProject = resolveProject(config2, projectArg);
2649
+ if (!targetProject) {
2650
+ error(`Project not found: ${projectArg}`);
2651
+ console.log();
2652
+ console.log("Linked projects:");
2653
+ for (const p of config2.projects) {
2654
+ console.log(
2655
+ ` ${p.projectName || p.projectId} (${p.organizationName || p.organizationId})`
2656
+ );
2657
+ }
2658
+ process.exit(1);
2659
+ }
2660
+ } else if (config2.projects.length > 1) {
2661
+ const { projectId } = await inquirer5.prompt([
2662
+ {
2663
+ type: "list",
2664
+ name: "projectId",
2665
+ message: "Select a project to unlink:",
2666
+ choices: config2.projects.map((p) => {
2667
+ const isActive = p.projectId === config2.activeProjectId;
2668
+ return {
2669
+ name: `${p.projectName || p.projectId} (${p.organizationName || p.organizationId})${isActive ? chalk10.green(" *active") : ""}`,
2670
+ value: p.projectId
2671
+ };
2672
+ })
2673
+ }
2674
+ ]);
2675
+ targetProject = config2.projects.find((p) => p.projectId === projectId);
2676
+ } else {
2677
+ targetProject = config2.projects[0];
2678
+ }
2679
+ const displayName = targetProject.projectName || targetProject.projectId;
2680
+ if (!options.force) {
2681
+ const { proceed } = await inquirer5.prompt([
2682
+ {
2683
+ type: "confirm",
2684
+ name: "proceed",
2685
+ message: `Unlink "${displayName}"? Your .env files won't be deleted.`,
2686
+ default: false
2687
+ }
2688
+ ]);
2689
+ if (!proceed) {
2690
+ info("Unlink cancelled.");
2691
+ return;
2692
+ }
2693
+ }
2694
+ const updated = removeProjectFromConfig(config2, targetProject.projectId);
2695
+ if (!updated) {
2696
+ deleteProjectConfig();
2697
+ success(`Unlinked "${displayName}". No projects remaining.`);
2698
+ info("Run `envpilot init` to link a new project.");
2699
+ } else {
2700
+ writeProjectConfigV2(updated);
2701
+ const newActive = getActiveProject(updated);
2702
+ if (newActive) {
2703
+ setActiveProjectId(newActive.projectId);
2704
+ }
2705
+ success(`Unlinked "${displayName}".`);
2706
+ if (config2.activeProjectId === targetProject.projectId && newActive) {
2707
+ info(
2708
+ `Active project switched to "${newActive.projectName || newActive.projectId}".`
2709
+ );
2710
+ }
2711
+ console.log(
2712
+ chalk10.dim(
2713
+ ` ${updated.projects.length} project${updated.projects.length !== 1 ? "s" : ""} remaining`
2714
+ )
2715
+ );
2716
+ }
2717
+ } catch (err) {
2718
+ await handleError(err);
2719
+ }
2720
+ });
2721
+
2722
+ // src/commands/sync.ts
2723
+ import { Command as Command10 } from "commander";
2724
+ import chalk11 from "chalk";
2725
+
2726
+ // src/lib/commit-guard.ts
2727
+ import {
2728
+ existsSync as existsSync3,
2729
+ readFileSync as readFileSync3,
2730
+ writeFileSync as writeFileSync3,
2731
+ mkdirSync,
2732
+ chmodSync as chmodSync2,
2733
+ unlinkSync as unlinkSync2,
2734
+ statSync
2735
+ } from "fs";
2736
+ import { join as join3, resolve } from "path";
2737
+ import { execSync as execSync2 } from "child_process";
2738
+ var HOOK_START_MARKER = "# ENVPILOT_GUARD_START";
2739
+ var HOOK_END_MARKER = "# ENVPILOT_GUARD_END";
2740
+ var HOOK_BLOCK = `${HOOK_START_MARKER} - Do not remove. Installed by Envpilot CLI.
2741
+ ENV_FILES=$(git diff --cached --name-only | grep -E '(^|/)\\.env($|\\.)' || true)
2742
+ if [ -n "$ENV_FILES" ]; then
2743
+ echo ""
2744
+ echo "\\033[1;31mERROR:\\033[0m Envpilot commit guard blocked this commit."
2745
+ echo ""
2746
+ echo "The following .env files were staged:"
2747
+ echo "$ENV_FILES" | while IFS= read -r f; do echo " - $f"; done
2748
+ echo ""
2749
+ echo "Remove them with: git reset HEAD <file>"
2750
+ echo "To bypass (not recommended): git commit --no-verify"
2751
+ exit 1
2752
+ fi
2753
+ ${HOOK_END_MARKER}`;
2754
+ function findGitRoot(startDir) {
2755
+ let dir = startDir || process.cwd();
2756
+ while (true) {
2757
+ if (existsSync3(join3(dir, ".git"))) {
2758
+ return dir;
2759
+ }
2760
+ const parent = resolve(dir, "..");
2761
+ if (parent === dir) {
2762
+ return null;
2763
+ }
2764
+ dir = parent;
2765
+ }
2766
+ }
2767
+ function resolveGitDir(repoRoot) {
2768
+ const gitPath = join3(repoRoot, ".git");
2769
+ try {
2770
+ const stat = statSync(gitPath);
2771
+ if (stat.isDirectory()) {
2772
+ return gitPath;
2773
+ }
2774
+ } catch {
2775
+ return gitPath;
2776
+ }
2777
+ try {
2778
+ const content = readFileSync3(gitPath, "utf-8").trim();
2779
+ const match = content.match(/^gitdir:\s*(.+)$/);
2780
+ if (match) {
2781
+ const gitdir = resolve(repoRoot, match[1]);
2782
+ const commonDir = resolve(gitdir, "..", "..");
2783
+ if (existsSync3(join3(commonDir, "hooks")) || existsSync3(commonDir)) {
2784
+ return commonDir;
2785
+ }
2786
+ return gitdir;
2787
+ }
2788
+ } catch {
2789
+ }
2790
+ return gitPath;
2791
+ }
2792
+ function getHooksDir(repoRoot) {
2793
+ try {
2794
+ const customPath = execSync2("git config core.hooksPath", {
2795
+ cwd: repoRoot,
2796
+ encoding: "utf-8",
2797
+ stdio: ["pipe", "pipe", "pipe"]
2798
+ }).trim();
2799
+ if (customPath) {
2800
+ return resolve(repoRoot, customPath);
2801
+ }
2802
+ } catch {
2803
+ }
2804
+ const gitDir = resolveGitDir(repoRoot);
2805
+ return join3(gitDir, "hooks");
2806
+ }
2807
+ function installCommitGuard(repoRoot) {
2808
+ const root = repoRoot || findGitRoot();
2809
+ if (!root) {
2810
+ return {
2811
+ installed: false,
2812
+ hookPath: null,
2813
+ message: "Not a git repository"
2814
+ };
2815
+ }
2816
+ try {
2817
+ const hooksDir = getHooksDir(root);
2818
+ const hookPath = join3(hooksDir, "pre-commit");
2819
+ mkdirSync(hooksDir, { recursive: true });
2820
+ let existingContent = "";
2821
+ try {
2822
+ existingContent = readFileSync3(hookPath, "utf-8");
2823
+ } catch {
2824
+ }
2825
+ if (existingContent.includes(HOOK_START_MARKER)) {
2826
+ const startIdx = existingContent.indexOf(HOOK_START_MARKER);
2827
+ const endIdx = existingContent.indexOf(HOOK_END_MARKER) + HOOK_END_MARKER.length;
2828
+ const updated = existingContent.substring(0, startIdx) + HOOK_BLOCK + existingContent.substring(endIdx);
2829
+ writeFileSync3(hookPath, updated, "utf-8");
2830
+ chmodSync2(hookPath, 493);
2831
+ return {
2832
+ installed: true,
2833
+ hookPath,
2834
+ message: "Pre-commit hook updated"
2835
+ };
2836
+ }
2837
+ let newContent;
2838
+ if (existingContent.trim()) {
2839
+ newContent = existingContent.trimEnd() + "\n\n" + HOOK_BLOCK + "\n";
2840
+ } else {
2841
+ newContent = "#!/bin/sh\n\n" + HOOK_BLOCK + "\n";
2842
+ }
2843
+ writeFileSync3(hookPath, newContent, "utf-8");
2844
+ chmodSync2(hookPath, 493);
2845
+ return {
2846
+ installed: true,
2847
+ hookPath,
2848
+ message: "Pre-commit hook installed"
2849
+ };
2850
+ } catch (err) {
2851
+ return {
2852
+ installed: false,
2853
+ hookPath: null,
2854
+ message: `Failed to install hook: ${err instanceof Error ? err.message : String(err)}`
2855
+ };
2856
+ }
2857
+ }
2858
+
2859
+ // src/commands/sync.ts
2860
+ var syncCommand = new Command10("sync").description(
2861
+ "Login, select project, pull variables, and protect files \u2014 all in one command"
2862
+ ).option("-o, --organization <id>", "Organization ID").option("-p, --project <id>", "Project ID or name").option(
2863
+ "-e, --env <environment>",
2864
+ "Environment (development, staging, production)"
2865
+ ).option("--force", "Overwrite without confirmation").option("--no-guard", "Skip pre-commit hook installation").action(async (options) => {
2866
+ try {
2867
+ if (!isAuthenticated()) {
2868
+ console.log();
2869
+ info("Not logged in. Starting authentication...");
2870
+ console.log();
2871
+ await performLogin();
2872
+ console.log();
2873
+ } else {
2874
+ info("Already authenticated.");
2875
+ }
2876
+ ensureEnvInGitignore();
2877
+ if (options.guard !== false) {
2878
+ const guardResult = installCommitGuard();
2879
+ if (guardResult.installed) {
2880
+ success(guardResult.message);
2881
+ info("Staged .env files will be blocked from commits.");
2882
+ } else if (guardResult.message !== "Not a git repository") {
2883
+ warning(guardResult.message);
2884
+ }
2885
+ }
2886
+ console.log();
2887
+ let projectId;
2888
+ let organizationId;
2889
+ let environment;
2890
+ let projectName;
2891
+ let organizationName;
2892
+ const existingConfig = readProjectConfigV2();
2893
+ if (existingConfig && !options.organization && !options.project && !options.env) {
2894
+ const active = getActiveProject(existingConfig);
2895
+ if (active) {
2896
+ projectId = active.projectId;
2897
+ organizationId = active.organizationId;
2898
+ environment = active.environment;
2899
+ projectName = active.projectName;
2900
+ organizationName = active.organizationName;
2901
+ info(
2902
+ `Using project ${chalk11.bold(projectName || projectId)} (${environment})`
2903
+ );
2904
+ } else {
2905
+ const selection = await selectOrgProjectEnv(options);
2906
+ projectId = selection.selectedProject._id;
2907
+ organizationId = selection.selectedOrg._id;
2908
+ environment = selection.selectedEnvironment;
2909
+ projectName = selection.selectedProject.name;
2910
+ organizationName = selection.selectedOrg.name;
2911
+ }
2912
+ } else {
2913
+ const selection = await selectOrgProjectEnv({
2914
+ organization: options.organization,
2915
+ project: options.project,
2916
+ environment: options.env
2917
+ });
2918
+ projectId = selection.selectedProject._id;
2919
+ organizationId = selection.selectedOrg._id;
2920
+ environment = selection.selectedEnvironment;
2921
+ projectName = selection.selectedProject.name;
2922
+ organizationName = selection.selectedOrg.name;
2923
+ if (selection.selectedOrg.role) {
2924
+ setRole(selection.selectedOrg.role);
2925
+ }
2926
+ writeProjectConfigV2({
2927
+ version: 1,
2928
+ activeProjectId: projectId,
2929
+ projects: existingConfig ? [
2930
+ ...existingConfig.projects.filter(
2931
+ (p) => p.projectId !== projectId
2932
+ ),
2933
+ {
2934
+ projectId,
2935
+ organizationId,
2936
+ projectName,
2937
+ organizationName,
2938
+ environment
2939
+ }
2940
+ ] : [
2941
+ {
2942
+ projectId,
2943
+ organizationId,
2944
+ projectName,
2945
+ organizationName,
2946
+ environment
2947
+ }
2948
+ ]
2949
+ });
2950
+ setActiveOrganizationId(organizationId);
2951
+ setActiveProjectId(projectId);
2952
+ }
2953
+ console.log();
2954
+ let metaProjectRole;
2955
+ const variables = await withSpinner(
2956
+ `Fetching ${chalk11.bold(environment)} variables...`,
2957
+ async () => {
2958
+ const api = createAPIClient();
2959
+ const response = await api.get("/api/cli/variables", {
2960
+ projectId,
2961
+ environment,
2962
+ ...organizationId && { organizationId }
2963
+ });
2964
+ metaProjectRole = response.meta?.projectRole;
2965
+ return response.data || [];
2966
+ }
2967
+ );
2968
+ const outputPath = getEnvPathForEnvironment(environment);
2969
+ if (variables.length === 0) {
2970
+ warning(`No variables found for ${environment} environment.`);
2971
+ console.log();
2972
+ return;
2973
+ }
2974
+ const remoteVars = {};
2975
+ for (const variable of variables) {
2976
+ remoteVars[variable.key] = variable.value;
2977
+ }
2978
+ const localVars = readEnvFile(outputPath) || {};
2979
+ const diffResult = diffEnvVars(remoteVars, localVars);
2980
+ const hasChanges = Object.keys(diffResult.added).length > 0 || Object.keys(diffResult.removed).length > 0 || Object.keys(diffResult.changed).length > 0;
2981
+ if (!hasChanges) {
2982
+ success(`${chalk11.bold(outputPath)} is up to date.`);
2983
+ console.log();
2984
+ return;
2985
+ }
2986
+ console.log();
2987
+ console.log(chalk11.bold("Changes:"));
2988
+ console.log();
2989
+ diff(diffResult.added, diffResult.removed, diffResult.changed);
2990
+ console.log();
2991
+ try {
2992
+ const fs = await import("fs");
2993
+ if (fs.existsSync(outputPath)) {
2994
+ fs.chmodSync(outputPath, 420);
2995
+ }
2996
+ } catch {
2997
+ }
2998
+ const comments = {};
2999
+ for (const variable of variables) {
3000
+ if (variable.description) {
3001
+ comments[variable.key] = variable.description;
3002
+ }
3003
+ }
3004
+ writeEnvFile(outputPath, remoteVars, { sort: true, comments });
3005
+ const role = getRole();
3006
+ applyFileProtection(outputPath, role, metaProjectRole);
3007
+ success(
3008
+ `Synced ${variables.length} variables to ${chalk11.bold(outputPath)}`
3009
+ );
3010
+ const isProtected = role !== "admin" && role !== "team_lead" && metaProjectRole !== "manager";
3011
+ if (isProtected) {
3012
+ info(
3013
+ `File is read-only (your role: ${role || metaProjectRole || "member"}).`
3014
+ );
3015
+ }
3016
+ console.log();
3017
+ console.log(
3018
+ chalk11.dim(` Added: ${Object.keys(diffResult.added).length}`)
3019
+ );
3020
+ console.log(
3021
+ chalk11.dim(` Changed: ${Object.keys(diffResult.changed).length}`)
3022
+ );
3023
+ console.log(
3024
+ chalk11.dim(` Removed: ${Object.keys(diffResult.removed).length}`)
3025
+ );
3026
+ console.log();
3027
+ } catch (err) {
3028
+ await handleError(err);
3029
+ }
3030
+ });
3031
+
3032
+ // src/commands/usage.ts
3033
+ import { Command as Command11 } from "commander";
3034
+ import chalk12 from "chalk";
2149
3035
  function formatUsage(current, limit) {
2150
3036
  const limitStr = limit === null ? "unlimited" : String(limit);
2151
3037
  const ratio = `${current}/${limitStr}`;
2152
- if (limit === null) return chalk10.green(ratio);
2153
- if (current >= limit) return chalk10.red(ratio);
2154
- if (current >= limit * 0.8) return chalk10.yellow(ratio);
2155
- return chalk10.green(ratio);
3038
+ if (limit === null) return chalk12.green(ratio);
3039
+ if (current >= limit) return chalk12.red(ratio);
3040
+ if (current >= limit * 0.8) return chalk12.yellow(ratio);
3041
+ return chalk12.green(ratio);
2156
3042
  }
2157
3043
  function featureStatus(enabled) {
2158
- return enabled ? chalk10.green("Enabled") : chalk10.dim("Disabled (Pro)");
3044
+ return enabled ? chalk12.green("Enabled") : chalk12.dim("Disabled (Pro)");
2159
3045
  }
2160
- var usageCommand = new Command9("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) => {
3046
+ 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) => {
2161
3047
  try {
2162
3048
  if (!isAuthenticated()) {
2163
3049
  throw notAuthenticated();
@@ -2200,7 +3086,7 @@ var usageCommand = new Command9("usage").description("Show plan usage and limits
2200
3086
  console.log(JSON.stringify(usage, null, 2));
2201
3087
  return;
2202
3088
  }
2203
- const tierLabel = usage.tier === "pro" ? chalk10.green("Pro") : chalk10.white("Free");
3089
+ const tierLabel = usage.tier === "pro" ? chalk12.green("Pro") : chalk12.white("Free");
2204
3090
  header(`Plan: ${tierLabel}`);
2205
3091
  blank();
2206
3092
  if (!usage.enforcementEnabled) {
@@ -2252,10 +3138,55 @@ var usageCommand = new Command9("usage").description("Show plan usage and limits
2252
3138
  }
2253
3139
  });
2254
3140
 
3141
+ // src/lib/version-check.ts
3142
+ import chalk13 from "chalk";
3143
+ import Conf2 from "conf";
3144
+ var CLI_VERSION = "1.3.1";
3145
+ var CHECK_INTERVAL = 60 * 60 * 1e3;
3146
+ var _cache = null;
3147
+ function getCache() {
3148
+ if (!_cache) {
3149
+ _cache = new Conf2({
3150
+ projectName: "envpilot",
3151
+ configName: "version-cache"
3152
+ });
3153
+ }
3154
+ return _cache;
3155
+ }
3156
+ function checkForUpdate() {
3157
+ const cache = getCache();
3158
+ const lastCheck = cache.get("lastVersionCheck");
3159
+ if (lastCheck && Date.now() - lastCheck < CHECK_INTERVAL) return;
3160
+ const apiUrl = getApiUrl();
3161
+ fetch(`${apiUrl}/api/version`, { signal: AbortSignal.timeout(5e3) }).then((res) => {
3162
+ if (!res.ok) return;
3163
+ return res.json();
3164
+ }).then((data) => {
3165
+ if (!data?.cli) return;
3166
+ cache.set("lastVersionCheck", Date.now());
3167
+ if (data.cli !== CLI_VERSION) {
3168
+ console.log();
3169
+ console.log(
3170
+ chalk13.yellow(" Update available:"),
3171
+ chalk13.dim(CLI_VERSION),
3172
+ chalk13.yellow("\u2192"),
3173
+ chalk13.green(data.cli)
3174
+ );
3175
+ console.log(
3176
+ chalk13.dim(" Run"),
3177
+ chalk13.cyan("npm update -g @envpilot/cli"),
3178
+ chalk13.dim("to update")
3179
+ );
3180
+ console.log();
3181
+ }
3182
+ }).catch(() => {
3183
+ });
3184
+ }
3185
+
2255
3186
  // src/index.ts
2256
3187
  initSentry();
2257
- var program = new Command10();
2258
- program.name("envpilot").description("Envpilot CLI - Sync, secure, and share environment variables").version("1.3.0");
3188
+ var program = new Command12();
3189
+ program.name("envpilot").description("Envpilot CLI - Sync, secure, and share environment variables").version("1.3.2");
2259
3190
  program.addCommand(loginCommand);
2260
3191
  program.addCommand(logoutCommand);
2261
3192
  program.addCommand(initCommand);
@@ -2264,5 +3195,10 @@ program.addCommand(pushCommand);
2264
3195
  program.addCommand(switchCommand);
2265
3196
  program.addCommand(listCommand);
2266
3197
  program.addCommand(configCommand);
3198
+ program.addCommand(unlinkCommand);
3199
+ program.addCommand(syncCommand);
2267
3200
  program.addCommand(usageCommand);
3201
+ program.hook("postAction", () => {
3202
+ checkForUpdate();
3203
+ });
2268
3204
  program.parse();