@envpilot/cli 1.5.0 → 1.6.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.
@@ -4,7 +4,7 @@ import {
4
4
  getTopLevelCommandCatalog,
5
5
  getUser,
6
6
  isAuthenticated
7
- } from "./chunk-CIIQSYAS.js";
7
+ } from "./chunk-TPYA3DO2.js";
8
8
 
9
9
  // src/ui/app.tsx
10
10
  import {
@@ -7,7 +7,7 @@ function initSentry() {
7
7
  Sentry.init({
8
8
  dsn,
9
9
  environment: "cli",
10
- release: true ? "1.5.0" : "0.0.0",
10
+ release: true ? "1.6.1" : "0.0.0",
11
11
  // Free tier: disable performance monitoring
12
12
  tracesSampleRate: 0,
13
13
  beforeSend(event) {
@@ -49,6 +49,26 @@ async function flushSentry() {
49
49
  // src/lib/config.ts
50
50
  import Conf from "conf";
51
51
  var DEFAULT_API_URL = "https://www.envpilot.dev";
52
+ var HOST_CANONICALIZATION = {
53
+ "envpilot.dev": "www.envpilot.dev"
54
+ };
55
+ function normalizeApiUrl(raw) {
56
+ let value = raw.trim();
57
+ if (!/^https?:\/\//i.test(value)) {
58
+ value = `https://${value}`;
59
+ }
60
+ try {
61
+ const parsed = new URL(value);
62
+ const canonicalHost = HOST_CANONICALIZATION[parsed.hostname.toLowerCase()];
63
+ if (canonicalHost) {
64
+ parsed.hostname = canonicalHost;
65
+ }
66
+ parsed.pathname = parsed.pathname.replace(/\/+$/, "");
67
+ return parsed.toString().replace(/\/$/, "");
68
+ } catch {
69
+ return value;
70
+ }
71
+ }
52
72
  var config = new Conf({
53
73
  projectName: "envpilot",
54
74
  defaults: {
@@ -57,7 +77,7 @@ var config = new Conf({
57
77
  });
58
78
  function getConfig() {
59
79
  return {
60
- apiUrl: config.get("apiUrl") ?? DEFAULT_API_URL,
80
+ apiUrl: normalizeApiUrl(config.get("apiUrl") ?? DEFAULT_API_URL),
61
81
  accessToken: config.get("accessToken"),
62
82
  refreshToken: config.get("refreshToken"),
63
83
  activeProjectId: config.get("activeProjectId"),
@@ -67,10 +87,12 @@ function getConfig() {
67
87
  };
68
88
  }
69
89
  function getApiUrl() {
70
- return config.get("apiUrl") ?? DEFAULT_API_URL;
90
+ const stored = config.get("apiUrl");
91
+ const raw = stored ?? DEFAULT_API_URL;
92
+ return normalizeApiUrl(raw);
71
93
  }
72
94
  function setApiUrl(url) {
73
- config.set("apiUrl", url);
95
+ config.set("apiUrl", normalizeApiUrl(url));
74
96
  }
75
97
  function getAccessToken() {
76
98
  return config.get("accessToken");
@@ -157,7 +179,7 @@ import { jsx } from "react/jsx-runtime";
157
179
  async function openTUI() {
158
180
  const [{ render }, { CLIApp }] = await Promise.all([
159
181
  import("ink"),
160
- import("./app-KHDRTCOB.js")
182
+ import("./app-LNI6WTJZ.js")
161
183
  ]);
162
184
  while (true) {
163
185
  let selectedArgv = null;
@@ -429,6 +451,9 @@ function notInitialized() {
429
451
  function fileNotFound(path) {
430
452
  return new CLIError(`File not found: ${path}`, ErrorCodes.FILE_NOT_FOUND);
431
453
  }
454
+ function invalidInput(message) {
455
+ return new CLIError(message, ErrorCodes.INVALID_INPUT);
456
+ }
432
457
 
433
458
  // src/lib/auth-flow.ts
434
459
  import open from "open";
@@ -436,6 +461,12 @@ import chalk4 from "chalk";
436
461
  import { hostname } from "os";
437
462
 
438
463
  // src/lib/api.ts
464
+ function registrableDomain(hostname2) {
465
+ const parts = hostname2.toLowerCase().split(".").filter(Boolean);
466
+ if (parts.length <= 2) return parts.join(".");
467
+ return parts.slice(-2).join(".");
468
+ }
469
+ var MAX_MANUAL_REDIRECTS = 5;
439
470
  var APIError = class extends Error {
440
471
  constructor(message, statusCode, code) {
441
472
  super(message);
@@ -471,7 +502,59 @@ var APIClient = class {
471
502
  const finalUrl = response.url || "";
472
503
  const contentType = response.headers.get("content-type") || "";
473
504
  const preview = (bodyText || "").slice(0, 512).toLowerCase();
474
- return response.redirected || location.includes("authkit") || finalUrl.includes("authkit") || contentType.includes("text/html") && (preview.includes("authorization_session_id") || preview.includes("client_id=") || preview.includes("<!doctype html"));
505
+ return location.includes("authkit") || finalUrl.includes("authkit") || contentType.includes("text/html") && (preview.includes("authorization_session_id") || preview.includes("client_id=") || preview.includes("<!doctype html"));
506
+ }
507
+ /**
508
+ * Perform a fetch that follows 3xx redirects manually, re-attaching the
509
+ * Authorization header when the redirect stays inside the same registrable
510
+ * domain (eTLD+1). This defends against the apex→www redirect case where
511
+ * Node's default redirect follower drops the Authorization header on any
512
+ * hostname change and the resulting request comes back as a bogus 401 —
513
+ * which used to wipe the user's credentials.
514
+ *
515
+ * Cross-site redirects (different registrable domain) are followed without
516
+ * the Authorization header, matching browser/fetch security semantics.
517
+ */
518
+ async fetchWithSafeRedirects(initialUrl, init2) {
519
+ let currentUrl = initialUrl;
520
+ let currentInit = { ...init2, redirect: "manual" };
521
+ for (let hop = 0; hop < MAX_MANUAL_REDIRECTS; hop++) {
522
+ const response = await fetch(currentUrl, currentInit);
523
+ if (response.status < 300 || response.status >= 400) {
524
+ return response;
525
+ }
526
+ const location = response.headers.get("location");
527
+ if (!location) return response;
528
+ const nextUrl = new URL(location, currentUrl);
529
+ const prevHost = new URL(currentUrl).hostname;
530
+ const sameSite = registrableDomain(nextUrl.hostname) === registrableDomain(prevHost);
531
+ const headers = new Headers(currentInit.headers);
532
+ if (!sameSite) {
533
+ headers.delete("Authorization");
534
+ }
535
+ let nextMethod = (currentInit.method || "GET").toUpperCase();
536
+ let nextBody = currentInit.body;
537
+ if (response.status === 301 || response.status === 302 || response.status === 303) {
538
+ if (nextMethod !== "GET" && nextMethod !== "HEAD") {
539
+ nextMethod = "GET";
540
+ nextBody = void 0;
541
+ headers.delete("Content-Type");
542
+ }
543
+ }
544
+ currentUrl = nextUrl.toString();
545
+ currentInit = {
546
+ ...currentInit,
547
+ method: nextMethod,
548
+ headers,
549
+ body: nextBody,
550
+ redirect: "manual"
551
+ };
552
+ }
553
+ throw new APIError(
554
+ `Too many redirects while calling ${initialUrl}`,
555
+ 0,
556
+ "TOO_MANY_REDIRECTS"
557
+ );
475
558
  }
476
559
  /**
477
560
  * Make a GET request
@@ -483,10 +566,9 @@ var APIClient = class {
483
566
  url.searchParams.set(key, value);
484
567
  }
485
568
  }
486
- const response = await fetch(url.toString(), {
569
+ const response = await this.fetchWithSafeRedirects(url.toString(), {
487
570
  method: "GET",
488
- headers: this.getHeaders(),
489
- redirect: "follow"
571
+ headers: this.getHeaders()
490
572
  });
491
573
  return this.handleResponse(response);
492
574
  }
@@ -495,11 +577,10 @@ var APIClient = class {
495
577
  */
496
578
  async post(path, body) {
497
579
  const url = new URL(path, this.baseUrl);
498
- const response = await fetch(url.toString(), {
580
+ const response = await this.fetchWithSafeRedirects(url.toString(), {
499
581
  method: "POST",
500
582
  headers: this.getHeaders(),
501
- body: body ? JSON.stringify(body) : void 0,
502
- redirect: "follow"
583
+ body: body ? JSON.stringify(body) : void 0
503
584
  });
504
585
  return this.handleResponse(response);
505
586
  }
@@ -508,11 +589,10 @@ var APIClient = class {
508
589
  */
509
590
  async put(path, body) {
510
591
  const url = new URL(path, this.baseUrl);
511
- const response = await fetch(url.toString(), {
592
+ const response = await this.fetchWithSafeRedirects(url.toString(), {
512
593
  method: "PUT",
513
594
  headers: this.getHeaders(),
514
- body: body ? JSON.stringify(body) : void 0,
515
- redirect: "follow"
595
+ body: body ? JSON.stringify(body) : void 0
516
596
  });
517
597
  return this.handleResponse(response);
518
598
  }
@@ -521,11 +601,10 @@ var APIClient = class {
521
601
  */
522
602
  async patch(path, body) {
523
603
  const url = new URL(path, this.baseUrl);
524
- const response = await fetch(url.toString(), {
604
+ const response = await this.fetchWithSafeRedirects(url.toString(), {
525
605
  method: "PATCH",
526
606
  headers: this.getHeaders(),
527
- body: body ? JSON.stringify(body) : void 0,
528
- redirect: "follow"
607
+ body: body ? JSON.stringify(body) : void 0
529
608
  });
530
609
  return this.handleResponse(response);
531
610
  }
@@ -534,10 +613,9 @@ var APIClient = class {
534
613
  */
535
614
  async delete(path) {
536
615
  const url = new URL(path, this.baseUrl);
537
- const response = await fetch(url.toString(), {
616
+ const response = await this.fetchWithSafeRedirects(url.toString(), {
538
617
  method: "DELETE",
539
- headers: this.getHeaders(),
540
- redirect: "follow"
618
+ headers: this.getHeaders()
541
619
  });
542
620
  if (!response.ok) {
543
621
  await this.handleError(response);
@@ -554,7 +632,6 @@ var APIClient = class {
554
632
  if (!contentType.includes("application/json")) {
555
633
  const body2 = await response.text();
556
634
  if (this.isAuthRedirect(response, body2)) {
557
- clearAuth();
558
635
  throw new APIError(
559
636
  "Your CLI session is not authorized for this endpoint. Please run `envpilot login` and try again.",
560
637
  401,
@@ -591,20 +668,12 @@ var APIClient = class {
591
668
  code = data.code;
592
669
  } catch {
593
670
  }
594
- if (response.status >= 300 && response.status < 400 && this.isAuthRedirect(response)) {
595
- clearAuth();
596
- throw new APIError(
597
- "Your CLI session expired or the server redirected this request to browser sign-in. Please run `envpilot login`.",
598
- 401,
599
- "AUTH_REDIRECT"
600
- );
601
- }
602
671
  if (response.status === 401) {
603
672
  clearAuth();
604
673
  throw new APIError(
605
- "Authentication required. Please run `envpilot login`.",
674
+ "Authentication failed. Please run `envpilot login`.",
606
675
  401,
607
- "UNAUTHORIZED"
676
+ code || "UNAUTHORIZED"
608
677
  );
609
678
  }
610
679
  if (response.status === 403 && code === "TIER_LIMIT_REACHED") {
@@ -633,7 +702,7 @@ var APIClient = class {
633
702
  * Get current user info
634
703
  */
635
704
  async getCurrentUser() {
636
- return this.get("/api/cli/auth/me");
705
+ return this.get("/api/cli/auth", { action: "me" });
637
706
  }
638
707
  /**
639
708
  * Get tier info for the active organization
@@ -673,7 +742,12 @@ var APIClient = class {
673
742
  return this.get(`/api/cli/projects/${projectId}`);
674
743
  }
675
744
  /**
676
- * List variables in a project
745
+ * List variables in a project (with decrypted values).
746
+ *
747
+ * Returns both the variable list and any keys that failed vault decryption.
748
+ * Decryption failures are skipped server-side — they will NOT appear in
749
+ * `variables`. Callers should warn the user about `decryptionFailures`
750
+ * so they know those secrets weren't injected.
677
751
  */
678
752
  async listVariables(projectId, environment, organizationId) {
679
753
  const params = { projectId };
@@ -683,11 +757,29 @@ var APIClient = class {
683
757
  if (organizationId) {
684
758
  params.organizationId = organizationId;
685
759
  }
760
+ const response = await this.get("/api/cli/variables", params);
761
+ return {
762
+ variables: response.data || [],
763
+ decryptionFailures: response.meta?.decryptionFailures ?? []
764
+ };
765
+ }
766
+ /**
767
+ * Check the variable fingerprint for a project/environment.
768
+ *
769
+ * Returns a short hash of variable metadata (id + version + updatedAt)
770
+ * WITHOUT decrypting vault secrets. The CLI uses this to decide whether
771
+ * a cached variable set is still current before doing a full (expensive)
772
+ * fetch. If the fingerprint matches, the cache can be extended for free.
773
+ */
774
+ async checkFingerprint(projectId, environment, organizationId) {
775
+ const params = { projectId };
776
+ if (environment) params.environment = environment;
777
+ if (organizationId) params.organizationId = organizationId;
686
778
  const response = await this.get(
687
- "/api/cli/variables",
779
+ "/api/cli/variables/fingerprint",
688
780
  params
689
781
  );
690
- return response.data || [];
782
+ return response.fingerprint;
691
783
  }
692
784
  /**
693
785
  * Get a variable by ID (with decrypted value)
@@ -726,25 +818,25 @@ var APIClient = class {
726
818
  * Initiate CLI authentication flow
727
819
  */
728
820
  async initiateAuth(deviceName) {
729
- return this.post("/api/cli/auth/initiate", { deviceName });
821
+ return this.post("/api/cli/auth?action=initiate", { deviceName });
730
822
  }
731
823
  /**
732
824
  * Poll for authentication status
733
825
  */
734
826
  async pollAuth(code) {
735
- return this.get("/api/cli/auth/poll", { code });
827
+ return this.get("/api/cli/auth", { action: "poll", code });
736
828
  }
737
829
  /**
738
830
  * Refresh access token
739
831
  */
740
832
  async refreshToken(refreshToken) {
741
- return this.post("/api/cli/auth/refresh", { refreshToken });
833
+ return this.post("/api/cli/auth?action=refresh", { refreshToken });
742
834
  }
743
835
  /**
744
836
  * Revoke access token (logout)
745
837
  */
746
838
  async revokeToken() {
747
- return this.post("/api/cli/auth/revoke", {});
839
+ return this.post("/api/cli/auth?action=revoke", {});
748
840
  }
749
841
  };
750
842
  function createAPIClient() {
@@ -766,7 +858,7 @@ async function performLogin(options) {
766
858
  spinner.start();
767
859
  let initResponse;
768
860
  try {
769
- initResponse = await api.post("/api/cli/auth?action=initiate", { deviceName });
861
+ initResponse = await api.initiateAuth(deviceName);
770
862
  } catch (error2) {
771
863
  spinner.stop();
772
864
  throw error2;
@@ -792,10 +884,7 @@ async function performLogin(options) {
792
884
  await sleep(POLL_INTERVAL_MS);
793
885
  let pollResponse;
794
886
  try {
795
- pollResponse = await api.get("/api/cli/auth", {
796
- action: "poll",
797
- code: initResponse.code
798
- });
887
+ pollResponse = await api.pollAuth(initResponse.code);
799
888
  consecutiveErrors = 0;
800
889
  } catch {
801
890
  consecutiveErrors++;
@@ -808,12 +897,13 @@ async function performLogin(options) {
808
897
  }
809
898
  if (pollResponse.status === "authenticated") {
810
899
  pollSpinner.stop();
811
- if (pollResponse.accessToken) {
812
- setAccessToken(pollResponse.accessToken);
813
- }
814
- if (pollResponse.refreshToken) {
815
- setRefreshToken(pollResponse.refreshToken);
900
+ if (!pollResponse.accessToken || !pollResponse.refreshToken) {
901
+ throw new Error(
902
+ "Authentication succeeded but the server did not return session tokens. This is likely a server-side issue. Please try again or contact support."
903
+ );
816
904
  }
905
+ setAccessToken(pollResponse.accessToken);
906
+ setRefreshToken(pollResponse.refreshToken);
817
907
  if (pollResponse.user) {
818
908
  setUser({
819
909
  id: pollResponse.user.id,
@@ -836,7 +926,7 @@ async function performLogin(options) {
836
926
  }
837
927
 
838
928
  // src/commands/login.ts
839
- 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) => {
929
+ var loginCommand = new Command("login").description("Authenticate with Envpilot").option("--api-url <url>", "API URL (default: https://www.envpilot.dev)").option("--no-browser", "Do not automatically open the browser").action(async (options) => {
840
930
  try {
841
931
  if (options.apiUrl) {
842
932
  setApiUrl(options.apiUrl);
@@ -904,6 +994,8 @@ var variableSchema = z.object({
904
994
  description: z.string().optional(),
905
995
  isSensitive: z.boolean().optional(),
906
996
  version: z.number().optional(),
997
+ updatedAt: z.number().optional(),
998
+ createdAt: z.number().optional(),
907
999
  tags: z.array(variableTagSchema).optional()
908
1000
  });
909
1001
  var environmentSchema = z.enum([
@@ -2264,11 +2356,11 @@ var pushCommand = new Command4("push").description("Upload local .env file to cl
2264
2356
  if (fmt === "env") {
2265
2357
  localVars = readEnvFile(inputPath);
2266
2358
  } else {
2267
- const { readFileSync: readFileSync4, existsSync: existsSync4 } = await import("fs");
2268
- if (!existsSync4(inputPath)) {
2359
+ const { readFileSync: readFileSync5, existsSync: existsSync5 } = await import("fs");
2360
+ if (!existsSync5(inputPath)) {
2269
2361
  localVars = null;
2270
2362
  } else {
2271
- const content = readFileSync4(inputPath, "utf-8");
2363
+ const content = readFileSync5(inputPath, "utf-8");
2272
2364
  localVars = parse(content, fmt, { prefix: options.prefix });
2273
2365
  }
2274
2366
  }
@@ -3077,16 +3169,18 @@ async function handleSet(key, value) {
3077
3169
  process.exit(1);
3078
3170
  }
3079
3171
  switch (key) {
3080
- case "apiUrl":
3172
+ case "apiUrl": {
3173
+ const normalized = normalizeApiUrl(value);
3081
3174
  try {
3082
- new URL(value);
3175
+ new URL(normalized);
3083
3176
  } catch {
3084
3177
  error("Invalid URL format");
3085
3178
  process.exit(1);
3086
3179
  }
3087
3180
  setApiUrl(value);
3088
- success(`Set apiUrl to ${value}`);
3181
+ success(`Set apiUrl to ${normalized}`);
3089
3182
  break;
3183
+ }
3090
3184
  default:
3091
3185
  error(`Cannot set key: ${key}`);
3092
3186
  console.log();
@@ -3712,40 +3806,417 @@ var whoamiCommand = new Command12("whoami").description("Show the current authen
3712
3806
  }
3713
3807
  });
3714
3808
 
3715
- // src/commands/man.ts
3809
+ // src/commands/run.ts
3716
3810
  import { Command as Command13 } from "commander";
3811
+ import { spawn as spawn2 } from "child_process";
3717
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);
3832
+ }
3833
+ function getCachePath(key) {
3834
+ return join4(getCacheDir(), `${key}.json`);
3835
+ }
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);
3840
+ }
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;
3852
+ }
3853
+ }
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 };
3859
+ }
3860
+ function writeCache(projectId, environment, organizationId, variables) {
3861
+ 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 {
3881
+ }
3882
+ }
3883
+ function extendCacheFreshness(projectId, environment, organizationId) {
3884
+ 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 {
3896
+ }
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
+ }
3908
+
3909
+ // src/commands/run.ts
3910
+ 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)"
3913
+ ).argument("[command...]", "Command and arguments to execute").option(
3914
+ "-e, --env <environment>",
3915
+ "Environment to load (development, staging, production)"
3916
+ ).option(
3917
+ "-p, --project <name-or-id>",
3918
+ "Linked project to load secrets from (defaults to active)"
3919
+ ).option(
3920
+ "-o, --organization <id>",
3921
+ "Organization id (overrides linked project's org)"
3922
+ ).option(
3923
+ "--keep-existing",
3924
+ "Don't overwrite env vars that are already set in the parent shell"
3925
+ ).option(
3926
+ "--print",
3927
+ "Print the variables that would be injected and exit (no command runs)"
3928
+ ).option(
3929
+ "--shell",
3930
+ "Run the command through the user's shell (enables pipes, &&, $VAR expansion)"
3931
+ ).option("-q, --quiet", "Suppress informational messages").option("--no-cache", "Skip cache and always fetch fresh secrets from server").option(
3932
+ "--cache-ttl <seconds>",
3933
+ "How long cached secrets stay fresh before a fingerprint check (default: 3600s / 1h)",
3934
+ String(DEFAULT_TTL)
3935
+ ).passThroughOptions().allowExcessArguments().action(async (commandArgs, options) => {
3936
+ try {
3937
+ if (!options.print && (!commandArgs || commandArgs.length === 0)) {
3938
+ throw invalidInput(
3939
+ "No command provided. Usage: envpilot run -- <command> [args...]"
3940
+ );
3941
+ }
3942
+ if (!isAuthenticated()) {
3943
+ throw notAuthenticated();
3944
+ }
3945
+ const config2 = readProjectConfigV2();
3946
+ if (!config2) throw notInitialized();
3947
+ const project = options.project ? resolveProject(config2, options.project) : getActiveProject(config2);
3948
+ if (!project) {
3949
+ if (options.project) {
3950
+ error(`Project not found in this directory: ${options.project}`);
3951
+ console.log();
3952
+ console.log("Linked projects:");
3953
+ for (const p of config2.projects) {
3954
+ console.log(` ${p.projectName || p.projectId} (${p.environment})`);
3955
+ }
3956
+ process.exit(1);
3957
+ }
3958
+ throw notInitialized();
3959
+ }
3960
+ const environment = options.env || project.environment;
3961
+ const organizationId = options.organization || project.organizationId;
3962
+ const ttl = Math.max(
3963
+ 1,
3964
+ parseInt(options.cacheTtl ?? String(DEFAULT_TTL), 10) || DEFAULT_TTL
3965
+ );
3966
+ let variables;
3967
+ let cacheHit = false;
3968
+ let cacheAge = "";
3969
+ if (options.cache === false) {
3970
+ variables = await doFetch(
3971
+ project,
3972
+ environment,
3973
+ organizationId,
3974
+ options.quiet
3975
+ );
3976
+ writeCache(project.projectId, environment, organizationId, variables);
3977
+ } else {
3978
+ const probe = probeCache(
3979
+ project.projectId,
3980
+ environment,
3981
+ organizationId,
3982
+ ttl
3983
+ );
3984
+ if (probe.hit && probe.fresh) {
3985
+ variables = probe.entry.variables;
3986
+ cacheHit = true;
3987
+ cacheAge = formatAge(probe.entry.fetchedAt);
3988
+ } else if (probe.hit && !probe.fresh) {
3989
+ try {
3990
+ const api = createAPIClient();
3991
+ const serverFingerprint = await api.checkFingerprint(
3992
+ project.projectId,
3993
+ environment,
3994
+ organizationId
3995
+ );
3996
+ if (serverFingerprint === probe.entry.fingerprint) {
3997
+ extendCacheFreshness(
3998
+ project.projectId,
3999
+ environment,
4000
+ organizationId
4001
+ );
4002
+ variables = probe.entry.variables;
4003
+ cacheHit = true;
4004
+ cacheAge = formatAge(probe.entry.fetchedAt);
4005
+ } else {
4006
+ variables = await doFetch(
4007
+ project,
4008
+ environment,
4009
+ organizationId,
4010
+ options.quiet,
4011
+ "Secrets updated, refreshing"
4012
+ );
4013
+ writeCache(
4014
+ project.projectId,
4015
+ environment,
4016
+ organizationId,
4017
+ variables
4018
+ );
4019
+ }
4020
+ } catch {
4021
+ variables = probe.entry.variables;
4022
+ cacheHit = true;
4023
+ cacheAge = formatAge(probe.entry.fetchedAt);
4024
+ }
4025
+ } else {
4026
+ variables = await doFetch(
4027
+ project,
4028
+ environment,
4029
+ organizationId,
4030
+ options.quiet
4031
+ );
4032
+ writeCache(project.projectId, environment, organizationId, variables);
4033
+ }
4034
+ }
4035
+ if (variables.length === 0) {
4036
+ warning(
4037
+ `No variables found for ${environment}. Running command without injected secrets.`
4038
+ );
4039
+ }
4040
+ const secrets = {};
4041
+ for (const v of variables) {
4042
+ secrets[v.key] = v.value;
4043
+ }
4044
+ if (options.print) {
4045
+ printInjectionPreview(secrets, project, environment);
4046
+ return;
4047
+ }
4048
+ const baseEnv = { ...process.env };
4049
+ const finalEnv = options.keepExisting ? { ...secrets, ...baseEnv } : { ...baseEnv, ...secrets };
4050
+ const overridden = [];
4051
+ if (!options.keepExisting) {
4052
+ for (const key of Object.keys(secrets)) {
4053
+ if (process.env[key] !== void 0 && process.env[key] !== secrets[key]) {
4054
+ overridden.push(key);
4055
+ }
4056
+ }
4057
+ }
4058
+ if (!options.quiet) {
4059
+ const injectedCount = Object.keys(secrets).length;
4060
+ const cacheTag = cacheHit ? chalk15.dim(` \u26A1 cache (${cacheAge})`) : "";
4061
+ info(
4062
+ `Injected ${chalk15.bold(injectedCount)} ${injectedCount === 1 ? "variable" : "variables"} from ${chalk15.bold(`${project.projectName || project.projectId}/${environment}`)}${cacheTag}`
4063
+ );
4064
+ if (overridden.length > 0) {
4065
+ warning(
4066
+ `Overriding ${overridden.length} shell var${overridden.length === 1 ? "" : "s"}: ${overridden.slice(0, 3).join(", ")}${overridden.length > 3 ? `, +${overridden.length - 3} more` : ""}`
4067
+ );
4068
+ info("Use --keep-existing to keep your shell values instead.");
4069
+ }
4070
+ console.log();
4071
+ }
4072
+ await runChild(commandArgs, finalEnv, options);
4073
+ } catch (err) {
4074
+ await handleError(err);
4075
+ }
4076
+ });
4077
+ async function doFetch(project, environment, organizationId, quiet, labelPrefix = "Loading") {
4078
+ const label = `${labelPrefix} ${chalk15.bold(environment)} secrets for ${chalk15.bold(project.projectName || project.projectId)}...`;
4079
+ const api = createAPIClient();
4080
+ const { variables, decryptionFailures } = quiet ? await api.listVariables(project.projectId, environment, organizationId) : await withSpinner(
4081
+ label,
4082
+ () => api.listVariables(project.projectId, environment, organizationId)
4083
+ );
4084
+ if (decryptionFailures.length > 0) {
4085
+ for (const key of decryptionFailures) {
4086
+ warning(
4087
+ `Could not decrypt ${chalk15.bold(key)} \u2014 skipped (vault error, check server logs)`
4088
+ );
4089
+ }
4090
+ }
4091
+ return variables;
4092
+ }
4093
+ function printInjectionPreview(secrets, project, environment) {
4094
+ const keys = Object.keys(secrets).sort();
4095
+ console.log();
4096
+ console.log(
4097
+ chalk15.bold(
4098
+ `Would inject ${keys.length} ${keys.length === 1 ? "variable" : "variables"} from ${chalk15.cyan(`${project.projectName || project.projectId}/${environment}`)}:`
4099
+ )
4100
+ );
4101
+ console.log();
4102
+ if (keys.length === 0) {
4103
+ console.log(chalk15.dim(" (no variables)"));
4104
+ } else {
4105
+ for (const key of keys) {
4106
+ const value = secrets[key];
4107
+ const masked = maskForPreview(value);
4108
+ console.log(` ${chalk15.cyan(key)}=${chalk15.dim(masked)}`);
4109
+ }
4110
+ }
4111
+ console.log();
4112
+ success("Dry run \u2014 no command executed.");
4113
+ }
4114
+ function maskForPreview(value) {
4115
+ if (value.length <= 6) return "******";
4116
+ return `${value.slice(0, 4)}\u2026${value.slice(-2)} (${value.length} chars)`;
4117
+ }
4118
+ function runChild(commandArgs, env, options) {
4119
+ return new Promise((resolve3) => {
4120
+ const [command, ...args] = commandArgs;
4121
+ if (!command) {
4122
+ process.exit(1);
4123
+ }
4124
+ const isWindows = process.platform === "win32";
4125
+ const useShell = options.shell === true || isWindows;
4126
+ const child = spawn2(command, args, {
4127
+ stdio: "inherit",
4128
+ env,
4129
+ shell: useShell
4130
+ });
4131
+ const signals = [
4132
+ "SIGINT",
4133
+ "SIGTERM",
4134
+ "SIGHUP",
4135
+ "SIGQUIT"
4136
+ ];
4137
+ const forward = {};
4138
+ for (const sig of signals) {
4139
+ const handler = () => {
4140
+ if (!child.killed) {
4141
+ try {
4142
+ child.kill(sig);
4143
+ } catch {
4144
+ }
4145
+ }
4146
+ };
4147
+ forward[sig] = handler;
4148
+ process.on(sig, handler);
4149
+ }
4150
+ const cleanup = () => {
4151
+ for (const sig of signals) {
4152
+ const handler = forward[sig];
4153
+ if (handler) process.off(sig, handler);
4154
+ }
4155
+ };
4156
+ child.on("error", (err) => {
4157
+ cleanup();
4158
+ const code = err.code;
4159
+ if (code === "ENOENT") {
4160
+ error(
4161
+ `Command not found: ${command}. Make sure it is installed and on your PATH.`
4162
+ );
4163
+ } else if (code === "EACCES") {
4164
+ error(`Permission denied executing: ${command}`);
4165
+ } else {
4166
+ error(`Failed to spawn process: ${err.message}`);
4167
+ }
4168
+ process.exit(1);
4169
+ });
4170
+ child.on("exit", (code, signal) => {
4171
+ cleanup();
4172
+ if (signal) {
4173
+ try {
4174
+ process.kill(process.pid, signal);
4175
+ } catch {
4176
+ process.exit(1);
4177
+ }
4178
+ } else {
4179
+ process.exit(code ?? 0);
4180
+ }
4181
+ resolve3();
4182
+ });
4183
+ });
4184
+ }
4185
+
4186
+ // src/commands/man.ts
4187
+ import { Command as Command14 } from "commander";
4188
+ import chalk16 from "chalk";
3718
4189
  function printCommandManual(commandName) {
3719
4190
  const command = findCommandDefinition(commandName);
3720
4191
  if (!command) {
3721
- console.log(chalk15.red(`Unknown command reference: ${commandName}`));
4192
+ console.log(chalk16.red(`Unknown command reference: ${commandName}`));
3722
4193
  console.log();
3723
4194
  console.log("Run `envpilot man` to see all supported commands.");
3724
4195
  process.exit(1);
3725
4196
  }
3726
- console.log(chalk15.bold(formatArgv(command.argv)));
4197
+ console.log(chalk16.bold(formatArgv(command.argv)));
3727
4198
  if (command.args) {
3728
- console.log(chalk15.dim(command.args));
4199
+ console.log(chalk16.dim(command.args));
3729
4200
  }
3730
4201
  blank();
3731
4202
  console.log(command.description);
3732
4203
  blank();
3733
- console.log(chalk15.green("Examples"));
4204
+ console.log(chalk16.green("Examples"));
3734
4205
  for (const example of command.examples) {
3735
- console.log(` ${chalk15.cyan(formatArgv(example))}`);
4206
+ console.log(` ${chalk16.cyan(formatArgv(example))}`);
3736
4207
  }
3737
4208
  blank();
3738
- console.log(chalk15.green("Notes"));
4209
+ console.log(chalk16.green("Notes"));
3739
4210
  for (const note of command.notes) {
3740
4211
  console.log(` - ${note}`);
3741
4212
  }
3742
4213
  }
3743
4214
  function printManualIndex(commands) {
3744
- console.log(chalk15.bold("ENVPILOT(1)"));
4215
+ console.log(chalk16.bold("ENVPILOT(1)"));
3745
4216
  console.log("TypeScript CLI manual");
3746
4217
  blank();
3747
4218
  console.log(
3748
- `Total supported top-level commands: ${chalk15.green(String(CLI_COMMAND_COUNT))}`
4219
+ `Total supported top-level commands: ${chalk16.green(String(CLI_COMMAND_COUNT))}`
3749
4220
  );
3750
4221
  blank();
3751
4222
  console.log(
@@ -3753,15 +4224,15 @@ function printManualIndex(commands) {
3753
4224
  );
3754
4225
  blank();
3755
4226
  line();
3756
- console.log(chalk15.green("Commands"));
4227
+ console.log(chalk16.green("Commands"));
3757
4228
  for (const command of commands) {
3758
4229
  console.log(
3759
- ` ${chalk15.cyan(formatArgv(command.argv).padEnd(24))} ${chalk15.dim(command.description)}`
4230
+ ` ${chalk16.cyan(formatArgv(command.argv).padEnd(24))} ${chalk16.dim(command.description)}`
3760
4231
  );
3761
4232
  }
3762
4233
  blank();
3763
4234
  line();
3764
- console.log(chalk15.green("Security"));
4235
+ console.log(chalk16.green("Security"));
3765
4236
  console.log(" - `.env*` is ignored by the repository `.gitignore`.");
3766
4237
  console.log(
3767
4238
  " - `envpilot init` and `envpilot sync` ensure env files are added to `.gitignore` locally."
@@ -3774,7 +4245,7 @@ function printManualIndex(commands) {
3774
4245
  );
3775
4246
  blank();
3776
4247
  line();
3777
- console.log(chalk15.green("Usage"));
4248
+ console.log(chalk16.green("Usage"));
3778
4249
  console.log(
3779
4250
  " - `envpilot` or `envpilot ui` opens the interactive terminal UI."
3780
4251
  );
@@ -3783,7 +4254,7 @@ function printManualIndex(commands) {
3783
4254
  );
3784
4255
  }
3785
4256
  function createManCommand(commands) {
3786
- return new Command13("man").description("Show the CLI manual page").argument("[command]", "Optional command to show detailed manual for").action((command) => {
4257
+ return new Command14("man").description("Show the CLI manual page").argument("[command]", "Optional command to show detailed manual for").action((command) => {
3787
4258
  if (command) {
3788
4259
  printCommandManual(command);
3789
4260
  return;
@@ -3793,9 +4264,9 @@ function createManCommand(commands) {
3793
4264
  }
3794
4265
 
3795
4266
  // src/commands/ui.ts
3796
- import { Command as Command14 } from "commander";
4267
+ import { Command as Command15 } from "commander";
3797
4268
  function createUICommand() {
3798
- return new Command14("ui").alias("dashboard").description("Open the interactive Ink-powered terminal UI").action(async () => {
4269
+ return new Command15("ui").alias("dashboard").description("Open the interactive Ink-powered terminal UI").action(async () => {
3799
4270
  if (process.env.ENVPILOT_TUI_CHILD === "1") {
3800
4271
  return;
3801
4272
  }
@@ -3945,6 +4416,42 @@ var COMMAND_CATALOG = [
3945
4416
  topLevel: true,
3946
4417
  createCommand: () => pushCommand
3947
4418
  },
4419
+ {
4420
+ id: "run",
4421
+ title: "Run command with secrets",
4422
+ category: "Sync",
4423
+ description: "Inject project secrets into a child process without writing a .env file (envpilot run -- bun dev).",
4424
+ argv: ["run"],
4425
+ args: "[--env <environment>] [--project <name-or-id>] [--keep-existing] [--print] -- <command> [args...]",
4426
+ examples: [
4427
+ ["run", "--", "bun", "dev"],
4428
+ ["run", "--env", "production", "--", "node", "server.js"],
4429
+ ["run", "--project", "api", "--", "pnpm", "test"],
4430
+ ["run", "--print"],
4431
+ ["run", "--keep-existing", "--", "bun", "dev"]
4432
+ ],
4433
+ websiteSurface: "Maps to `/api/cli/variables` for read access.",
4434
+ notes: [
4435
+ "Use `--` to separate envpilot flags from the command to execute.",
4436
+ "Secrets override existing shell vars by default; pass --keep-existing to flip.",
4437
+ "On Windows, the command is run through the shell so .cmd / .bat files resolve.",
4438
+ "Signals (SIGINT, SIGTERM, SIGHUP, SIGQUIT) are forwarded to the child process.",
4439
+ "Use --print to inspect what would be injected without executing anything."
4440
+ ],
4441
+ keywords: [
4442
+ "run",
4443
+ "exec",
4444
+ "spawn",
4445
+ "inject",
4446
+ "secrets",
4447
+ "env",
4448
+ "ephemeral",
4449
+ "no-file",
4450
+ "doppler"
4451
+ ],
4452
+ topLevel: true,
4453
+ createCommand: () => runCommand
4454
+ },
3948
4455
  {
3949
4456
  id: "list",
3950
4457
  title: "List resources",
package/dist/index.js CHANGED
@@ -4,18 +4,18 @@ import {
4
4
  getTopLevelCommandCatalog,
5
5
  initSentry,
6
6
  openTUI
7
- } from "./chunk-CIIQSYAS.js";
7
+ } from "./chunk-TPYA3DO2.js";
8
8
 
9
9
  // src/lib/program.ts
10
10
  import { Command } from "commander";
11
11
 
12
12
  // src/lib/cli-version.ts
13
- var CLI_VERSION = true ? "1.5.0" : "0.0.0";
13
+ var CLI_VERSION = true ? "1.6.1" : "0.0.0";
14
14
 
15
15
  // src/lib/version-check.ts
16
16
  import chalk from "chalk";
17
17
  import Conf from "conf";
18
- var CLI_VERSION2 = true ? "1.5.0" : "0.0.0";
18
+ var CLI_VERSION2 = true ? "1.6.1" : "0.0.0";
19
19
  var CHECK_INTERVAL = 60 * 60 * 1e3;
20
20
  var _cache = null;
21
21
  function getCache() {
@@ -60,7 +60,7 @@ function checkForUpdate() {
60
60
  // src/lib/program.ts
61
61
  function createProgram() {
62
62
  const program = new Command();
63
- program.name("envpilot").description("Envpilot CLI - Sync, secure, and share environment variables").version(CLI_VERSION);
63
+ program.name("envpilot").description("Envpilot CLI - Sync, secure, and share environment variables").version(CLI_VERSION).enablePositionalOptions();
64
64
  for (const command of getTopLevelCommandCatalog()) {
65
65
  if (command.createCommand) {
66
66
  program.addCommand(command.createCommand());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envpilot/cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "description": "Envpilot CLI — sync and manage environment variables from the terminal",
5
5
  "type": "module",
6
6
  "bin": {