@envpilot/cli 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,8 +1,21 @@
1
- Copyright (c) 2024-present Envpilot. All rights reserved.
1
+ MIT License
2
2
 
3
- This software is proprietary and confidential. No part of this software may be
4
- reproduced, distributed, or transmitted in any form or by any means without the
5
- prior written permission of Envpilot.
3
+ Copyright (c) 2026 Syntax Lab Technology and Rafay
6
4
 
7
- For licensing inquiries, contact hello@envpilot.dev.
8
- See https://www.envpilot.dev/terms for the full Terms of Service.
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -59,26 +59,49 @@ The TUI returns after each command finishes, so you can run multiple commands in
59
59
 
60
60
  ### Syncing Variables
61
61
 
62
- | Command | Description |
63
- | ------------------------- | --------------------------------------- |
64
- | `envpilot pull [options]` | Pull variables into a local `.env` file |
65
- | `envpilot push [options]` | Push local `.env` changes to Envpilot |
62
+ | Command | Description |
63
+ | ----------------------------- | ---------------------------------------------------------------- |
64
+ | `envpilot pull [options]` | Pull variables into a local `.env` file |
65
+ | `envpilot push [options]` | Push local `.env` changes to Envpilot |
66
+ | `envpilot secrets set [key]` | Set ONE secret — key first, value prompted masked (alias: `var`) |
67
+ | `envpilot secrets rm <key>` | Delete one secret (env-scoped for shared variables) |
68
+ | `envpilot diff <envA> <envB>` | Compare variable keys between two environments |
69
+
70
+ ```bash
71
+ envpilot secrets set # guided: key → masked value → sensitive?
72
+ envpilot secrets set STRIPE_KEY -e production
73
+ envpilot secrets set API_URL=https://api.example.com # CI only — lands in shell history
74
+ envpilot secrets rm OLD_FLAG --yes
75
+ envpilot diff staging production # keys only (no decryption)
76
+ envpilot diff staging production --values # also compare values
77
+ ```
78
+
79
+ `secrets set` is role-aware: if your role can't write directly, the same
80
+ flow files a variable request for review instead of rejecting you. Updating
81
+ or deleting a value shared across environments asks for explicit
82
+ confirmation and only detaches the selected environment on `rm`.
66
83
 
67
84
  ### Variable Requests
68
85
 
69
86
  Developers don't have direct write access — instead they submit a request,
70
- and an owner, project manager, or team lead reviews it on the dashboard
71
- (choosing the final environments on approval).
87
+ and an owner, project manager, or team lead reviews it from the terminal or
88
+ the dashboard.
72
89
 
73
- | Command | Description |
74
- | ----------------------------- | ---------------------------------------------------------------------- |
75
- | `envpilot request [options]` | Request a new variable (interactive: key, value, environments) |
76
- | `envpilot requests [options]` | List variable requests for the linked project with their review status |
90
+ | Command | Description |
91
+ | -------------------------------- | ---------------------------------------------------------------------- |
92
+ | `envpilot request [options]` | Request a new variable (interactive: key, value, environments) |
93
+ | `envpilot requests [options]` | List variable requests for the linked project with their review status |
94
+ | `envpilot requests approve <id>` | Approve a pending request (machine requests prompt masked for a value) |
95
+ | `envpilot requests reject <id>` | Reject a pending request (`--reason` shown to the requester) |
96
+ | `envpilot requests cancel <id>` | Cancel a pending request (your own, or as a reviewer) |
77
97
 
78
98
  ```bash
79
99
  envpilot request # guided prompt: key → value → description → environments
80
- envpilot requests # all requests for the linked project
81
- envpilot requests --status pending # filter: pending | approved | rejected | canceled
100
+ envpilot requests # all requests for the linked project (ID column for review)
101
+ envpilot requests --status pending --json
102
+ envpilot requests approve k5738… # valueless machine request → masked value prompt
103
+ printf %s "$SECRET" | envpilot requests approve k5738… --value-stdin # CI-safe
104
+ envpilot requests reject k5738… --reason "use the shared key"
82
105
  ```
83
106
 
84
107
  Environment choices are limited to the environments you have access to —
@@ -4,7 +4,7 @@ import {
4
4
  getTopLevelCommandCatalog,
5
5
  getUser,
6
6
  isAuthenticated
7
- } from "./chunk-DYZH5VNC.js";
7
+ } from "./chunk-WLHMHDPX.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.18.0" : "0.0.0",
10
+ release: true ? "1.19.0" : "0.0.0",
11
11
  // All EnvPilot surfaces report to one Sentry project; the surface tag
12
12
  // is how dashboards tell web / cli / extension events apart.
13
13
  initialScope: { tags: { surface: "cli" } },
@@ -149,7 +149,7 @@ function probeCache(projectId, environment, organizationId, ttlSeconds) {
149
149
  const fresh = isFresh(entry, ttlSeconds);
150
150
  return { hit: true, fresh, entry };
151
151
  }
152
- function writeCache(projectId, environment, organizationId, variables, serverFingerprint) {
152
+ function writeCache(projectId, environment, organizationId, variables, serverFingerprint, meta) {
153
153
  try {
154
154
  const cacheDir = getCacheDir();
155
155
  mkdirSync(cacheDir, { recursive: true, mode: 448 });
@@ -164,7 +164,9 @@ function writeCache(projectId, environment, organizationId, variables, serverFin
164
164
  environment,
165
165
  organizationId,
166
166
  apiUrl: getApiUrl(),
167
- fingerprint: serverFingerprint ?? computeFingerprint(variables)
167
+ fingerprint: serverFingerprint ?? computeFingerprint(variables),
168
+ ...meta?.decryptionFailures?.length ? { decryptionFailures: meta.decryptionFailures } : {},
169
+ ...meta?.scopeRestricted ? { scopeRestricted: true } : {}
168
170
  };
169
171
  writeFileSync(path, JSON.stringify(entry, null, 2), {
170
172
  encoding: "utf-8",
@@ -470,7 +472,7 @@ function isInteractiveTerminal() {
470
472
  async function openTUI() {
471
473
  const [{ render }, { CLIApp }, { PressAnyKey }] = await Promise.all([
472
474
  import("ink"),
473
- import("./app-JYN7XJXA.js"),
475
+ import("./app-VAJPLVBA.js"),
474
476
  import("./press-any-key-64XFP4O2.js")
475
477
  ]);
476
478
  while (true) {
@@ -1082,7 +1084,18 @@ var refs = {
1082
1084
  ),
1083
1085
  pullValues: fnRef("features/variables/values:pullValues"),
1084
1086
  pushBulk: fnRef("features/variables/values:pushBulk"),
1085
- createVariableRequest: fnRef("features/variables/requests/actions:createWithValue")
1087
+ createVariableRequest: fnRef("features/variables/requests/actions:createWithValue"),
1088
+ reviewRequest: fnRef("features/variables/requests/mutations:review"),
1089
+ approveRequestWithValue: fnRef("features/variables/requests/actions:approveWithValue"),
1090
+ cancelRequest: fnRef(
1091
+ "features/variables/requests/mutations:cancel"
1092
+ ),
1093
+ removeVariable: fnRef(
1094
+ "features/variables/mutations:remove"
1095
+ ),
1096
+ removeVariableFromEnvironment: fnRef("features/variables/mutations:removeFromEnvironment"),
1097
+ resolveProjectRoles: fnRef("features/auth/queries:resolveLegacyRoles"),
1098
+ getVariableRequest: fnRef("features/variables/requests/queries:getById")
1086
1099
  };
1087
1100
  async function convexQuery(ref, ...args) {
1088
1101
  const client = await getConvexClient();
@@ -1225,20 +1238,31 @@ var APIClient = class {
1225
1238
  * Byte-compatible with the fingerprint writeCache stores from a full fetch:
1226
1239
  * both hash `${_id}:${version}:${updatedAt}` over the accessible variables in
1227
1240
  * the requested environment.
1241
+ *
1242
+ * Also reports how the environment filter carved up the accessible set, so
1243
+ * `run` can say "injected 8 of 12 — 4 exist only in staging" instead of
1244
+ * silently dropping variables that live in other environments.
1228
1245
  */
1229
1246
  async checkFingerprint(projectId, environment, _organizationId) {
1230
1247
  const rows = await convexQuery(refs.listVariablesWithAccess, { projectId });
1231
- const filtered = rows.filter(
1232
- (row) => row.hasAccess && (!environment || row.environments.includes(environment))
1248
+ const accessible = rows.filter((row) => row.hasAccess);
1249
+ const matching = accessible.filter(
1250
+ (row) => !environment || row.environments.includes(environment)
1233
1251
  );
1234
- const asVariables = filtered.map(
1252
+ const otherEnvKeys = accessible.filter((row) => environment && !row.environments.includes(environment)).map((row) => ({ key: row.key, environments: row.environments }));
1253
+ const asVariables = matching.map(
1235
1254
  (row) => ({
1236
1255
  _id: row._id,
1237
1256
  version: row.version,
1238
1257
  updatedAt: row.updatedAt
1239
1258
  })
1240
1259
  );
1241
- return computeFingerprint(asVariables);
1260
+ return {
1261
+ fingerprint: computeFingerprint(asVariables),
1262
+ totalAccessible: accessible.length,
1263
+ matchingCount: matching.length,
1264
+ otherEnvKeys
1265
+ };
1242
1266
  }
1243
1267
  /** List variable requests for a project (Convex). */
1244
1268
  async listVariableRequests(projectId, status) {
@@ -1365,6 +1389,125 @@ var APIClient = class {
1365
1389
  }
1366
1390
  return created;
1367
1391
  }
1392
+ /** Approve or reject a request that already carries a value (developer flow). */
1393
+ async reviewRequest(requestId, action, reviewReason) {
1394
+ await convexMutation(refs.reviewRequest, {
1395
+ requestId,
1396
+ action,
1397
+ reviewReason
1398
+ });
1399
+ }
1400
+ /**
1401
+ * Approve a VALUELESS (machine) request by supplying the secret value —
1402
+ * the server encrypts it into the vault at approval time.
1403
+ */
1404
+ async approveRequestWithValue(requestId, value, reviewReason) {
1405
+ await convexAction(refs.approveRequestWithValue, {
1406
+ requestId,
1407
+ value,
1408
+ reviewReason
1409
+ });
1410
+ }
1411
+ /** Cancel a pending request (requester or a reviewer). */
1412
+ async cancelRequest(requestId) {
1413
+ await convexMutation(refs.cancelRequest, { requestId });
1414
+ }
1415
+ /**
1416
+ * Role + capability snapshot for the caller in one project. Used by the
1417
+ * CLI ONLY to route flows (direct write vs request) and phrase denials —
1418
+ * the Convex mutations remain the enforcement boundary.
1419
+ */
1420
+ async resolveProjectRoles(projectId) {
1421
+ return convexQuery(refs.resolveProjectRoles, { projectId });
1422
+ }
1423
+ /**
1424
+ * One request by id — enough to know whether it is pending and whether it
1425
+ * carries a value (machine requests don't; approval must supply one).
1426
+ */
1427
+ async getVariableRequest(requestId) {
1428
+ return convexQuery(refs.getVariableRequest, { requestId });
1429
+ }
1430
+ /** Soft-delete a single variable by id (moves it to trash). */
1431
+ async removeVariable(variableId) {
1432
+ await convexMutation(refs.removeVariable, { variableId });
1433
+ }
1434
+ /**
1435
+ * Set a single variable's value in one environment — upsert via the bulk
1436
+ * vault path with merge semantics (creates if absent, updates if present;
1437
+ * leaves every other variable untouched).
1438
+ *
1439
+ * Throws when the server accepted the call but wrote nothing (the single
1440
+ * entry was skipped/denied by variable-level authorization) — a silent
1441
+ * "success" here would lie to the user.
1442
+ */
1443
+ async setVariable(projectId, environment, key, value, opts) {
1444
+ const result = await convexAction(refs.pushBulk, {
1445
+ projectId,
1446
+ environment,
1447
+ variables: [
1448
+ {
1449
+ key,
1450
+ value,
1451
+ description: opts?.description,
1452
+ isSensitive: opts?.isSensitive
1453
+ }
1454
+ ],
1455
+ mode: "merge"
1456
+ });
1457
+ if (result.deniedKeys?.includes(key)) {
1458
+ throw new APIError(
1459
+ `You do not have write access to ${key}.`,
1460
+ 403,
1461
+ "PERMISSION_DENIED"
1462
+ );
1463
+ }
1464
+ return {
1465
+ created: result.created,
1466
+ updated: result.updated,
1467
+ unchanged: result.created === 0 && result.updated === 0
1468
+ };
1469
+ }
1470
+ /**
1471
+ * Metadata for the active variable holding `key` in `environment` (id +
1472
+ * the FULL environments list, so single-env commands can detect that the
1473
+ * variable is shared across environments). Returns null if absent.
1474
+ */
1475
+ async findVariable(projectId, environment, key) {
1476
+ const rows = await convexQuery(refs.listVariablesWithAccess, {
1477
+ projectId,
1478
+ limit: 5e3
1479
+ });
1480
+ const match = rows.find(
1481
+ (row) => row.key === key && row.hasAccess && row.environments.includes(environment)
1482
+ );
1483
+ return match ? { _id: match._id, environments: match.environments } : null;
1484
+ }
1485
+ /**
1486
+ * Metadata-only variable listing for an environment (no vault reads, no
1487
+ * value-access audit entries). Used by `diff` without --values.
1488
+ */
1489
+ async listVariableKeys(projectId, environment) {
1490
+ const limit = 5e3;
1491
+ const rows = await convexQuery(refs.listVariablesWithAccess, {
1492
+ projectId,
1493
+ limit
1494
+ });
1495
+ return {
1496
+ keys: rows.filter((row) => row.environments.includes(environment)).map((row) => row.key),
1497
+ truncated: rows.length >= limit
1498
+ };
1499
+ }
1500
+ /**
1501
+ * Re-scope a shared variable: atomically detach `environment` from it
1502
+ * server-side (the mutation re-reads the row, so a concurrent edit can't
1503
+ * be clobbered by a stale client-side array).
1504
+ */
1505
+ async removeVariableFromEnvironment(variableId, environment) {
1506
+ await convexMutation(refs.removeVariableFromEnvironment, {
1507
+ variableId,
1508
+ environment
1509
+ });
1510
+ }
1368
1511
  };
1369
1512
  function createAPIClient() {
1370
1513
  return new APIClient();
@@ -2953,6 +3096,9 @@ function validateEnvVars(vars) {
2953
3096
  }
2954
3097
  return { valid, invalid };
2955
3098
  }
3099
+ function validateEnvironment(env) {
3100
+ return environmentSchema2.safeParse(env).success;
3101
+ }
2956
3102
 
2957
3103
  // src/commands/push.ts
2958
3104
  var pushCommand = new Command4("push").description("Upload local .env file to cloud").option(
@@ -3952,8 +4098,8 @@ async function handlePath() {
3952
4098
  ]);
3953
4099
  }
3954
4100
  async function handleReset() {
3955
- const inquirer7 = await import("inquirer");
3956
- const { confirm } = await inquirer7.default.prompt([
4101
+ const inquirer9 = await import("inquirer");
4102
+ const { confirm } = await inquirer9.default.prompt([
3957
4103
  {
3958
4104
  type: "confirm",
3959
4105
  name: "confirm",
@@ -4710,7 +4856,7 @@ import { Command as Command14 } from "commander";
4710
4856
  import crossSpawn from "cross-spawn";
4711
4857
  import { constants as osConstants } from "os";
4712
4858
  import chalk15 from "chalk";
4713
- var DEFAULT_TTL = 3600;
4859
+ var DEFAULT_TTL = 0;
4714
4860
  var runCommand = new Command14("run").description(
4715
4861
  "Run a command with project secrets injected as environment variables. Secrets are injected into the child process in-memory (no .env file is written), but a decrypted copy is cached locally at ~/.config/envpilot/run-cache (mode 0600, same protection as a .env) to avoid re-fetching on every run. Use --no-cache to skip the cache, or `envpilot logout` to purge it."
4716
4862
  ).argument("[command...]", "Command and arguments to execute").option(
@@ -4733,7 +4879,7 @@ var runCommand = new Command14("run").description(
4733
4879
  'Run the command string through your shell ($SHELL or /bin/sh -c "..."), enabling pipes, &&, and $VAR expansion. Without this flag the command is executed directly with no shell, so arguments are passed through verbatim (safe from shell injection).'
4734
4880
  ).option("-q, --quiet", "Suppress informational messages").option("--no-cache", "Skip cache and always fetch fresh secrets from server").option(
4735
4881
  "--cache-ttl <seconds>",
4736
- "How long cached secrets stay fresh before a fingerprint check (default: 3600s / 1h; 0 = always fingerprint-check)",
4882
+ "How long cached secrets are served without even a fingerprint check (default: 0 = verify freshness on every run; the check is one cheap metadata query, secrets are only re-decrypted when they changed)",
4737
4883
  String(DEFAULT_TTL)
4738
4884
  ).passThroughOptions().allowExcessArguments().action(async (commandArgs, options) => {
4739
4885
  try {
@@ -4760,7 +4906,12 @@ var runCommand = new Command14("run").description(
4760
4906
  }
4761
4907
  throw notInitialized();
4762
4908
  }
4763
- const environment = options.env || project.environment;
4909
+ const environment = options.env !== void 0 ? options.env : project.environment;
4910
+ if (options.env !== void 0 && !validateEnvironment(options.env)) {
4911
+ throw invalidInput(
4912
+ `Unknown environment "${options.env}". Valid environments: development, staging, production.`
4913
+ );
4914
+ }
4764
4915
  const organizationId = options.organization || project.organizationId;
4765
4916
  const parsedTtl = Number.parseInt(
4766
4917
  options.cacheTtl ?? String(DEFAULT_TTL),
@@ -4770,14 +4921,35 @@ var runCommand = new Command14("run").description(
4770
4921
  let variables;
4771
4922
  let cacheHit = false;
4772
4923
  let cacheAge = "";
4924
+ let otherEnvKeys = [];
4925
+ let decryptionFailures = [];
4926
+ let scopeRestricted = false;
4773
4927
  if (options.cache === false) {
4774
- variables = await doFetch(
4928
+ const fetched = await doFetch(
4775
4929
  project,
4776
4930
  environment,
4777
4931
  organizationId,
4778
4932
  options.quiet
4779
4933
  );
4780
- writeCache(project.projectId, environment, organizationId, variables);
4934
+ variables = fetched.variables;
4935
+ decryptionFailures = fetched.decryptionFailures;
4936
+ scopeRestricted = fetched.scopeRestricted;
4937
+ otherEnvKeys = await fetchOtherEnvKeys(
4938
+ project.projectId,
4939
+ environment,
4940
+ organizationId
4941
+ );
4942
+ writeCache(
4943
+ project.projectId,
4944
+ environment,
4945
+ organizationId,
4946
+ variables,
4947
+ void 0,
4948
+ {
4949
+ decryptionFailures,
4950
+ scopeRestricted
4951
+ }
4952
+ );
4781
4953
  } else {
4782
4954
  const probe = probeCache(
4783
4955
  project.projectId,
@@ -4789,15 +4961,18 @@ var runCommand = new Command14("run").description(
4789
4961
  variables = probe.entry.variables;
4790
4962
  cacheHit = true;
4791
4963
  cacheAge = formatAge(probe.entry.fetchedAt);
4792
- } else if (probe.hit && !probe.fresh) {
4964
+ decryptionFailures = probe.entry.decryptionFailures ?? [];
4965
+ scopeRestricted = probe.entry.scopeRestricted ?? false;
4966
+ } else {
4793
4967
  try {
4794
4968
  const api = createAPIClient();
4795
- const serverFingerprint = await api.checkFingerprint(
4969
+ const check = await api.checkFingerprint(
4796
4970
  project.projectId,
4797
4971
  environment,
4798
4972
  organizationId
4799
4973
  );
4800
- if (serverFingerprint === probe.entry.fingerprint) {
4974
+ otherEnvKeys = check.otherEnvKeys;
4975
+ if (probe.hit && check.fingerprint === probe.entry.fingerprint) {
4801
4976
  extendCacheFreshness(
4802
4977
  project.projectId,
4803
4978
  environment,
@@ -4806,27 +4981,35 @@ var runCommand = new Command14("run").description(
4806
4981
  variables = probe.entry.variables;
4807
4982
  cacheHit = true;
4808
4983
  cacheAge = formatAge(probe.entry.fetchedAt);
4984
+ decryptionFailures = probe.entry.decryptionFailures ?? [];
4985
+ scopeRestricted = probe.entry.scopeRestricted ?? false;
4809
4986
  } else {
4810
- variables = await doFetch(
4987
+ const fetched = await doFetch(
4811
4988
  project,
4812
4989
  environment,
4813
4990
  organizationId,
4814
4991
  options.quiet,
4815
- "Secrets updated, refreshing"
4992
+ probe.hit ? "Secrets updated, refreshing" : "Loading"
4816
4993
  );
4994
+ variables = fetched.variables;
4995
+ decryptionFailures = fetched.decryptionFailures;
4996
+ scopeRestricted = fetched.scopeRestricted;
4817
4997
  writeCache(
4818
4998
  project.projectId,
4819
4999
  environment,
4820
5000
  organizationId,
4821
5001
  variables,
4822
- serverFingerprint
5002
+ decryptionFailures.length > 0 ? void 0 : check.fingerprint,
5003
+ { decryptionFailures, scopeRestricted }
4823
5004
  );
4824
5005
  }
4825
5006
  } catch (err) {
4826
- if (isConnectivityError(err)) {
5007
+ if (probe.hit && isConnectivityError(err)) {
4827
5008
  variables = probe.entry.variables;
4828
5009
  cacheHit = true;
4829
5010
  cacheAge = formatAge(probe.entry.fetchedAt);
5011
+ decryptionFailures = probe.entry.decryptionFailures ?? [];
5012
+ scopeRestricted = probe.entry.scopeRestricted ?? false;
4830
5013
  if (!options.quiet) {
4831
5014
  warning(
4832
5015
  `Using offline cache (age ${cacheAge}) \u2014 could not reach the server to verify freshness.`
@@ -4837,14 +5020,6 @@ var runCommand = new Command14("run").description(
4837
5020
  throw err;
4838
5021
  }
4839
5022
  }
4840
- } else {
4841
- variables = await doFetch(
4842
- project,
4843
- environment,
4844
- organizationId,
4845
- options.quiet
4846
- );
4847
- writeCache(project.projectId, environment, organizationId, variables);
4848
5023
  }
4849
5024
  }
4850
5025
  if (variables.length === 0) {
@@ -4852,6 +5027,13 @@ var runCommand = new Command14("run").description(
4852
5027
  `No variables found for ${environment}. Running command without injected secrets.`
4853
5028
  );
4854
5029
  }
5030
+ if (!options.quiet && decryptionFailures.length > 0) {
5031
+ for (const key of decryptionFailures) {
5032
+ warning(
5033
+ `Could not decrypt ${chalk15.bold(key)} \u2014 skipped (vault error, check server logs)`
5034
+ );
5035
+ }
5036
+ }
4855
5037
  const secrets = {};
4856
5038
  for (const v of variables) {
4857
5039
  secrets[v.key] = v.value;
@@ -4873,9 +5055,24 @@ var runCommand = new Command14("run").description(
4873
5055
  if (!options.quiet) {
4874
5056
  const injectedCount = Object.keys(secrets).length;
4875
5057
  const cacheTag = cacheHit ? chalk15.dim(` \u26A1 cache (${cacheAge})`) : "";
5058
+ const total = injectedCount + decryptionFailures.length + otherEnvKeys.length;
5059
+ const ofTotal = otherEnvKeys.length > 0 ? chalk15.dim(` of ${total}`) : "";
4876
5060
  info(
4877
- `Injected ${chalk15.bold(injectedCount)} ${injectedCount === 1 ? "variable" : "variables"} from ${chalk15.bold(`${project.projectName || project.projectId}/${environment}`)}${cacheTag}`
5061
+ `Injected ${chalk15.bold(injectedCount)}${ofTotal} ${injectedCount === 1 ? "variable" : "variables"} from ${chalk15.bold(`${project.projectName || project.projectId}/${environment}`)}${cacheTag}`
4878
5062
  );
5063
+ if (otherEnvKeys.length > 0) {
5064
+ const names = otherEnvKeys.slice(0, 5).map((v) => v.key);
5065
+ const more = otherEnvKeys.length > 5 ? `, +${otherEnvKeys.length - 5} more` : "";
5066
+ warning(
5067
+ `${otherEnvKeys.length} variable${otherEnvKeys.length === 1 ? "" : "s"} not in ${chalk15.bold(environment)}, not injected: ${names.join(", ")}${more}`
5068
+ );
5069
+ info(`Use -e <environment> to load a different environment.`);
5070
+ }
5071
+ if (scopeRestricted) {
5072
+ warning(
5073
+ `Your role restricts which variables you can see \u2014 some may be withheld for ${chalk15.bold(environment)}.`
5074
+ );
5075
+ }
4879
5076
  if (overridden.length > 0) {
4880
5077
  warning(
4881
5078
  `Overriding ${overridden.length} shell var${overridden.length === 1 ? "" : "s"}: ${overridden.slice(0, 3).join(", ")}${overridden.length > 3 ? `, +${overridden.length - 3} more` : ""}`
@@ -4892,18 +5089,28 @@ var runCommand = new Command14("run").description(
4892
5089
  async function doFetch(project, environment, organizationId, quiet, labelPrefix = "Loading") {
4893
5090
  const label = `${labelPrefix} ${chalk15.bold(environment)} secrets for ${chalk15.bold(project.projectName || project.projectId)}...`;
4894
5091
  const api = createAPIClient();
4895
- const { variables, decryptionFailures } = quiet ? await api.listVariables(project.projectId, environment, organizationId) : await withSpinner(
5092
+ const { variables, meta, decryptionFailures } = quiet ? await api.listVariables(project.projectId, environment, organizationId) : await withSpinner(
4896
5093
  label,
4897
5094
  () => api.listVariables(project.projectId, environment, organizationId)
4898
5095
  );
4899
- if (decryptionFailures.length > 0) {
4900
- for (const key of decryptionFailures) {
4901
- warning(
4902
- `Could not decrypt ${chalk15.bold(key)} \u2014 skipped (vault error, check server logs)`
4903
- );
4904
- }
5096
+ return {
5097
+ variables,
5098
+ decryptionFailures,
5099
+ scopeRestricted: meta?.scopeRestricted ?? false
5100
+ };
5101
+ }
5102
+ async function fetchOtherEnvKeys(projectId, environment, organizationId) {
5103
+ try {
5104
+ const api = createAPIClient();
5105
+ const check = await api.checkFingerprint(
5106
+ projectId,
5107
+ environment,
5108
+ organizationId
5109
+ );
5110
+ return check.otherEnvKeys;
5111
+ } catch {
5112
+ return [];
4905
5113
  }
4906
- return variables;
4907
5114
  }
4908
5115
  function printInjectionPreview(secrets, project, environment) {
4909
5116
  const keys = Object.keys(secrets).sort();
@@ -5217,6 +5424,7 @@ function formatRequestsListHeader(input) {
5217
5424
  }
5218
5425
  function formatRequestRow(request) {
5219
5426
  return {
5427
+ id: request._id,
5220
5428
  key: request.key,
5221
5429
  environments: request.environments.join(", "),
5222
5430
  status: request.status,
@@ -5481,66 +5689,608 @@ async function pickRequestTarget(api) {
5481
5689
 
5482
5690
  // src/commands/requests.ts
5483
5691
  import { Command as Command18 } from "commander";
5484
- var requestsCommand = new Command18("requests").description("List variable requests for a project").option(
5692
+ import inquirer7 from "inquirer";
5693
+ async function readStdin() {
5694
+ const chunks = [];
5695
+ for await (const chunk of process.stdin) {
5696
+ chunks.push(Buffer.from(chunk));
5697
+ }
5698
+ return Buffer.concat(chunks).toString("utf-8");
5699
+ }
5700
+ async function runList(options) {
5701
+ if (!isAuthenticated()) throw notAuthenticated();
5702
+ const configV2 = readProjectConfigV2();
5703
+ if (!configV2) throw notInitialized();
5704
+ const project = resolveProject(configV2, options.project);
5705
+ if (!project) {
5706
+ error(`Project not found: ${options.project}`);
5707
+ console.log();
5708
+ console.log("Linked projects:");
5709
+ for (const p of configV2.projects) {
5710
+ console.log(` ${p.projectName || p.projectId} (${p.environment})`);
5711
+ }
5712
+ process.exit(1);
5713
+ }
5714
+ let status;
5715
+ if (options.status) {
5716
+ const parsed = variableRequestStatusSchema.safeParse(options.status);
5717
+ if (!parsed.success) {
5718
+ error(
5719
+ `Invalid status: ${options.status}. Must be one of pending, approved, rejected, canceled.`
5720
+ );
5721
+ process.exit(1);
5722
+ }
5723
+ status = parsed.data;
5724
+ }
5725
+ const api = createAPIClient();
5726
+ const requests = await withSpinner(
5727
+ "Fetching variable requests...",
5728
+ () => api.listVariableRequests(project.projectId, status)
5729
+ );
5730
+ if (options.json) {
5731
+ console.log(JSON.stringify(requests, null, 2));
5732
+ return;
5733
+ }
5734
+ header(
5735
+ formatRequestsListHeader({
5736
+ projectName: project.projectName || project.projectId,
5737
+ organizationName: project.organizationName || project.organizationId
5738
+ })
5739
+ );
5740
+ if (requests.length === 0) {
5741
+ info("No variable requests found.");
5742
+ return;
5743
+ }
5744
+ table(formatRequestRows(requests), [
5745
+ { key: "id", header: "ID" },
5746
+ { key: "key", header: "KEY" },
5747
+ { key: "environments", header: "ENVIRONMENTS" },
5748
+ { key: "status", header: "STATUS" },
5749
+ { key: "requested", header: "REQUESTED" },
5750
+ { key: "reason", header: "REASON" }
5751
+ ]);
5752
+ }
5753
+ var listSub = new Command18("list").description("List variable requests for a project").option(
5485
5754
  "--project <name-or-id>",
5486
5755
  "List requests for a specific linked project"
5756
+ ).option("--status <status>", "Filter: pending, approved, rejected, canceled").option("--json", "Output as JSON").action(async (options) => {
5757
+ try {
5758
+ await runList(options);
5759
+ } catch (err) {
5760
+ await handleError(err);
5761
+ }
5762
+ });
5763
+ var approveSub = new Command18("approve").description("Approve a pending request").argument("<id>", "Request id (from `envpilot requests`)").option(
5764
+ "--value <value>",
5765
+ "Secret value for a machine (valueless) request \u2014 CI only; interactively you are prompted MASKED instead so the value stays out of shell history"
5487
5766
  ).option(
5488
- "--status <status>",
5489
- "Filter by status: pending, approved, rejected, canceled"
5490
- ).action(async (options) => {
5767
+ "--value-stdin",
5768
+ 'Read the secret value from stdin (CI-safe: keeps it out of argv), e.g. printf %s "$SECRET" | envpilot requests approve <id> --value-stdin'
5769
+ ).option("--reason <text>", "Optional review note").action(
5770
+ async (id, options) => {
5771
+ try {
5772
+ if (!isAuthenticated()) throw notAuthenticated();
5773
+ const api = createAPIClient();
5774
+ const request = await withSpinner(
5775
+ "Loading request...",
5776
+ () => api.getVariableRequest(id)
5777
+ );
5778
+ if (!request) {
5779
+ error("Request not found (or you lack access to it).");
5780
+ process.exit(1);
5781
+ }
5782
+ if (request.status !== "pending") {
5783
+ error(
5784
+ `Only pending requests can be approved (current: ${request.status}).`
5785
+ );
5786
+ process.exit(1);
5787
+ }
5788
+ let value = options.value;
5789
+ if (value !== void 0 && process.stdin.isTTY) {
5790
+ error(
5791
+ "--value is for CI only \u2014 interactively you are prompted masked so the secret stays out of shell history. Re-run without --value."
5792
+ );
5793
+ process.exit(1);
5794
+ }
5795
+ if (options.valueStdin) {
5796
+ value = (await readStdin()).replace(/\r?\n$/, "");
5797
+ if (!value) {
5798
+ error("--value-stdin was passed but stdin was empty.");
5799
+ process.exit(1);
5800
+ }
5801
+ }
5802
+ if (request.vaultRef === void 0 && value === void 0) {
5803
+ if (!process.stdin.isTTY) {
5804
+ error(
5805
+ `Request ${request.key} carries no value \u2014 non-interactive approval needs --value.`
5806
+ );
5807
+ process.exit(1);
5808
+ }
5809
+ warning(
5810
+ `${request.key} is a machine request with no value \u2014 you supply it now (encrypted server-side).`
5811
+ );
5812
+ const answer = await inquirer7.prompt([
5813
+ {
5814
+ type: "password",
5815
+ name: "value",
5816
+ message: `Value for ${request.key}:`,
5817
+ mask: "*",
5818
+ validate: (input) => input.length > 0 || "Value cannot be empty."
5819
+ }
5820
+ ]);
5821
+ value = answer.value;
5822
+ }
5823
+ await withSpinner(
5824
+ "Approving request...",
5825
+ () => request.vaultRef === void 0 && value !== void 0 ? api.approveRequestWithValue(id, value, options.reason) : api.reviewRequest(id, "approve", options.reason)
5826
+ );
5827
+ success(`Request approved: ${request.key}.`);
5828
+ } catch (err) {
5829
+ await handleError(err);
5830
+ }
5831
+ }
5832
+ );
5833
+ var rejectSub = new Command18("reject").description("Reject a pending request").argument("<id>", "Request id (from `envpilot requests`)").option("--reason <text>", "Optional review note shown to the requester").action(async (id, options) => {
5491
5834
  try {
5492
- if (!isAuthenticated()) {
5493
- throw notAuthenticated();
5835
+ if (!isAuthenticated()) throw notAuthenticated();
5836
+ const api = createAPIClient();
5837
+ await withSpinner(
5838
+ "Rejecting request...",
5839
+ () => api.reviewRequest(id, "reject", options.reason)
5840
+ );
5841
+ success("Request rejected.");
5842
+ } catch (err) {
5843
+ await handleError(err);
5844
+ }
5845
+ });
5846
+ var cancelSub = new Command18("cancel").description("Cancel a pending request (your own, or as a reviewer)").argument("<id>", "Request id (from `envpilot requests`)").action(async (id) => {
5847
+ try {
5848
+ if (!isAuthenticated()) throw notAuthenticated();
5849
+ const api = createAPIClient();
5850
+ await withSpinner("Canceling request...", () => api.cancelRequest(id));
5851
+ success("Request canceled.");
5852
+ } catch (err) {
5853
+ await handleError(err);
5854
+ }
5855
+ });
5856
+ var requestsCommand = new Command18("requests").description("List and review variable requests").option(
5857
+ "--project <name-or-id>",
5858
+ "List requests for a specific linked project"
5859
+ ).option("--status <status>", "Filter: pending, approved, rejected, canceled").option("--json", "Output as JSON").action(async (options) => {
5860
+ try {
5861
+ await runList(options);
5862
+ } catch (err) {
5863
+ await handleError(err);
5864
+ }
5865
+ }).addCommand(listSub).addCommand(approveSub).addCommand(rejectSub).addCommand(cancelSub);
5866
+
5867
+ // src/commands/secrets.ts
5868
+ import { Command as Command19 } from "commander";
5869
+ import inquirer8 from "inquirer";
5870
+ import chalk18 from "chalk";
5871
+ var KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/;
5872
+ function parseAssignment(assignment) {
5873
+ const eq = assignment.indexOf("=");
5874
+ if (eq < 1) {
5875
+ return {
5876
+ ok: false,
5877
+ error: "Expected KEY=VALUE (e.g. envpilot secrets set API_URL=https://api.example.com)"
5878
+ };
5879
+ }
5880
+ const key = assignment.slice(0, eq);
5881
+ const value = assignment.slice(eq + 1);
5882
+ if (!KEY_PATTERN.test(key) || key.length > 100) {
5883
+ return {
5884
+ ok: false,
5885
+ error: "Variable key must be UPPER_SNAKE_CASE (A-Z, 0-9, _), start with a letter, max 100 chars."
5886
+ };
5887
+ }
5888
+ return { ok: true, key, value };
5889
+ }
5890
+ function resolveTarget(options) {
5891
+ if (!isAuthenticated()) throw notAuthenticated();
5892
+ const config2 = readProjectConfigV2();
5893
+ if (!config2) throw notInitialized();
5894
+ const project = resolveProject(config2, options.project);
5895
+ if (!project) {
5896
+ error(`Project not found: ${options.project ?? "(active)"}`);
5897
+ console.log();
5898
+ console.log("Linked projects:");
5899
+ for (const p of config2.projects) {
5900
+ console.log(` ${p.projectName || p.projectId} (${p.environment})`);
5494
5901
  }
5495
- const configV2 = readProjectConfigV2();
5496
- if (!configV2) throw notInitialized();
5497
- const project = resolveProject(configV2, options.project);
5498
- if (!project) {
5499
- error(`Project not found: ${options.project}`);
5500
- console.log();
5501
- console.log("Linked projects:");
5502
- for (const p of configV2.projects) {
5503
- console.log(` ${p.projectName || p.projectId} (${p.environment})`);
5902
+ process.exit(1);
5903
+ }
5904
+ const environment = options.env !== void 0 ? options.env : project.environment;
5905
+ if (!validateEnvironment(environment)) {
5906
+ throw invalidInput(
5907
+ `Unknown environment "${environment}". Valid environments: development, staging, production.`
5908
+ );
5909
+ }
5910
+ return {
5911
+ projectId: project.projectId,
5912
+ projectName: project.projectName || project.projectId,
5913
+ environment
5914
+ };
5915
+ }
5916
+ async function resolveWriteRoute(projectId) {
5917
+ const api = createAPIClient();
5918
+ const roles = await api.resolveProjectRoles(projectId);
5919
+ const assigned = roles.assigned;
5920
+ return {
5921
+ role: roles.role,
5922
+ canWrite: assigned && (roles.capabilities["project.variables.create"] === true || roles.capabilities["project.variables.update"] === true),
5923
+ canRequest: assigned && roles.capabilities["project.requests.submit"] === true,
5924
+ environmentScope: roles.environmentScope
5925
+ };
5926
+ }
5927
+ var setCommand = new Command19("set").description(
5928
+ "Set (create or update) a single secret. Two-step by default: the key is validated first, then the value is prompted MASKED so it never lands in shell history. KEY=VALUE inline works for CI."
5929
+ ).argument(
5930
+ "[key-or-assignment]",
5931
+ "KEY (value prompted masked) or KEY=VALUE (CI; lands in shell history)"
5932
+ ).option("-e, --env <environment>", "Environment (defaults to active)").option("-p, --project <name-or-id>", "Linked project (defaults to active)").option("-d, --description <text>", "Optional description").option("--sensitive", "Mark the secret as sensitive").option(
5933
+ "--all-envs",
5934
+ "Confirm updating a variable whose value is shared across multiple environments"
5935
+ ).action(
5936
+ async (keyOrAssignment, options) => {
5937
+ try {
5938
+ const { projectId, projectName, environment } = resolveTarget(options);
5939
+ const route = await withSpinner(
5940
+ "Checking role and plan...",
5941
+ () => resolveWriteRoute(projectId)
5942
+ );
5943
+ if (!route.canWrite && !route.canRequest) {
5944
+ error(
5945
+ `Your role (${route.role}) cannot write variables or file requests in this project.`
5946
+ );
5947
+ process.exit(3);
5504
5948
  }
5505
- process.exit(1);
5506
- }
5507
- let status;
5508
- if (options.status) {
5509
- const parsed = variableRequestStatusSchema.safeParse(options.status);
5510
- if (!parsed.success) {
5949
+ if (route.environmentScope && !route.environmentScope.includes(environment)) {
5511
5950
  error(
5512
- `Invalid status: ${options.status}. Must be one of pending, approved, rejected, canceled.`
5951
+ `Your assignment is scoped to [${route.environmentScope.join(", ")}] \u2014 ${environment} is outside it.`
5513
5952
  );
5514
- process.exit(1);
5953
+ process.exit(3);
5954
+ }
5955
+ let key;
5956
+ let inlineValue;
5957
+ if (keyOrAssignment && keyOrAssignment.includes("=")) {
5958
+ const parsed = parseAssignment(keyOrAssignment);
5959
+ if (!parsed.ok) throw invalidInput(parsed.error);
5960
+ key = parsed.key;
5961
+ inlineValue = parsed.value;
5962
+ warning(
5963
+ "Value passed on the command line \u2014 it is now in your shell history. Prefer `envpilot secrets set KEY` to be prompted masked."
5964
+ );
5965
+ } else if (keyOrAssignment) {
5966
+ if (!KEY_PATTERN.test(keyOrAssignment) || keyOrAssignment.length > 100) {
5967
+ throw invalidInput(
5968
+ "Variable key must be UPPER_SNAKE_CASE (A-Z, 0-9, _), start with a letter, max 100 chars."
5969
+ );
5970
+ }
5971
+ key = keyOrAssignment;
5972
+ } else {
5973
+ if (!process.stdin.isTTY) {
5974
+ throw invalidInput(
5975
+ "No key provided. Non-interactive usage: envpilot secrets set KEY=VALUE"
5976
+ );
5977
+ }
5978
+ const answer = await inquirer8.prompt([
5979
+ {
5980
+ type: "input",
5981
+ name: "key",
5982
+ message: "Key:",
5983
+ validate: (input) => KEY_PATTERN.test(input) && input.length <= 100 || "Must be UPPER_SNAKE_CASE (A-Z, 0-9, _), start with a letter, max 100 chars."
5984
+ }
5985
+ ]);
5986
+ key = answer.key;
5515
5987
  }
5516
- status = parsed.data;
5988
+ const existing = await withSpinner(
5989
+ `Checking ${key}...`,
5990
+ () => createAPIClient().findVariable(projectId, environment, key)
5991
+ );
5992
+ if (existing && existing.environments.length > 1 && !options.allEnvs) {
5993
+ const envList = existing.environments.join(", ");
5994
+ if (process.stdin.isTTY) {
5995
+ const { proceed } = await inquirer8.prompt([
5996
+ {
5997
+ type: "confirm",
5998
+ name: "proceed",
5999
+ message: `${key}'s value is shared across [${envList}] \u2014 updating it changes ALL of them. Continue?`,
6000
+ default: false
6001
+ }
6002
+ ]);
6003
+ if (!proceed) {
6004
+ info(
6005
+ "Nothing changed. To give this environment its own value, edit the variable's environments in the dashboard first."
6006
+ );
6007
+ return;
6008
+ }
6009
+ } else {
6010
+ error(
6011
+ `${key} is shared across [${envList}] \u2014 updating it changes all of them. Re-run with --all-envs to confirm.`
6012
+ );
6013
+ process.exit(1);
6014
+ }
6015
+ }
6016
+ let value;
6017
+ let isSensitive = options.sensitive ?? false;
6018
+ if (inlineValue !== void 0) {
6019
+ value = inlineValue;
6020
+ } else {
6021
+ if (!process.stdin.isTTY) {
6022
+ throw invalidInput(
6023
+ "No value provided. Non-interactive usage: envpilot secrets set KEY=VALUE"
6024
+ );
6025
+ }
6026
+ const answers = await inquirer8.prompt([
6027
+ {
6028
+ type: "password",
6029
+ name: "value",
6030
+ message: "Value:",
6031
+ mask: "*",
6032
+ validate: (input) => input.length > 0 || "Value cannot be empty."
6033
+ },
6034
+ ...options.sensitive === void 0 ? [
6035
+ {
6036
+ type: "confirm",
6037
+ name: "isSensitive",
6038
+ message: "Mark as sensitive?",
6039
+ default: false
6040
+ }
6041
+ ] : []
6042
+ ]);
6043
+ value = answers.value;
6044
+ if (options.sensitive === void 0) {
6045
+ isSensitive = answers.isSensitive ?? false;
6046
+ }
6047
+ }
6048
+ const api = createAPIClient();
6049
+ if (route.canWrite) {
6050
+ const result = await withSpinner(
6051
+ `Setting ${key} in ${environment}...`,
6052
+ () => api.setVariable(projectId, environment, key, value, {
6053
+ description: options.description,
6054
+ isSensitive
6055
+ })
6056
+ );
6057
+ if (result.unchanged) {
6058
+ success(
6059
+ `${chalk18.bold(key)} already has this value in ${projectName}/${environment} \u2014 nothing to change.`
6060
+ );
6061
+ } else {
6062
+ const verb = result.created > 0 ? "Created" : "Updated";
6063
+ success(
6064
+ `${verb} ${chalk18.bold(key)} in ${projectName}/${environment}.`
6065
+ );
6066
+ }
6067
+ return;
6068
+ }
6069
+ if (process.stdin.isTTY && inlineValue === void 0) {
6070
+ const { proceed } = await inquirer8.prompt([
6071
+ {
6072
+ type: "confirm",
6073
+ name: "proceed",
6074
+ message: "Your role can't write variables directly \u2014 file this as a request for review?",
6075
+ default: true
6076
+ }
6077
+ ]);
6078
+ if (!proceed) {
6079
+ info("Nothing submitted.");
6080
+ return;
6081
+ }
6082
+ } else {
6083
+ warning(
6084
+ "Your role can't write variables directly \u2014 filing this as a request for review."
6085
+ );
6086
+ }
6087
+ const request = await withSpinner(
6088
+ "Submitting request...",
6089
+ () => api.createVariableRequest({
6090
+ projectId,
6091
+ key,
6092
+ value,
6093
+ environments: [environment],
6094
+ isSensitive,
6095
+ description: options.description
6096
+ })
6097
+ );
6098
+ success(`Request submitted for ${chalk18.bold(key)} (${environment}).`);
6099
+ info(
6100
+ `A reviewer can approve it with: envpilot requests approve ${request._id}`
6101
+ );
6102
+ } catch (err) {
6103
+ await handleError(err);
5517
6104
  }
6105
+ }
6106
+ );
6107
+ var rmCommand = new Command19("rm").alias("delete").description("Delete a single secret from one environment (moves to trash)").argument("<key>", "Variable key to delete").option("-e, --env <environment>", "Environment (defaults to active)").option("-p, --project <name-or-id>", "Linked project (defaults to active)").option("-y, --yes", "Skip the confirmation prompt").action(async (key, options) => {
6108
+ try {
6109
+ const { projectId, projectName, environment } = resolveTarget(options);
5518
6110
  const api = createAPIClient();
5519
- const requests = await withSpinner(
5520
- "Fetching variable requests...",
5521
- () => api.listVariableRequests(project.projectId, status)
6111
+ const roles = await withSpinner(
6112
+ "Checking role...",
6113
+ () => api.resolveProjectRoles(projectId)
5522
6114
  );
5523
- header(
5524
- formatRequestsListHeader({
5525
- projectName: project.projectName || project.projectId,
5526
- organizationName: project.organizationName || project.organizationId
5527
- })
6115
+ if (!roles.assigned || roles.capabilities["project.variables.delete"] !== true) {
6116
+ error(
6117
+ `Your role (${roles.role}) cannot delete variables in this project.`
6118
+ );
6119
+ process.exit(3);
6120
+ }
6121
+ const found = await withSpinner(
6122
+ `Finding ${key}...`,
6123
+ () => api.findVariable(projectId, environment, key)
6124
+ );
6125
+ if (!found) {
6126
+ error(
6127
+ `No variable ${key} found in ${projectName}/${environment} (or you lack access).`
6128
+ );
6129
+ process.exit(1);
6130
+ }
6131
+ const shared = found.environments.length > 1;
6132
+ const consequence = shared ? `remove ${key} from ${environment} (still live in: ${found.environments.filter((e) => e !== environment).join(", ")})` : `move ${key} (${environment}) to trash`;
6133
+ if (!options.yes) {
6134
+ if (!process.stdin.isTTY) {
6135
+ warning(`This will ${consequence}. Re-run with --yes to confirm.`);
6136
+ return;
6137
+ }
6138
+ const { proceed } = await inquirer8.prompt([
6139
+ {
6140
+ type: "confirm",
6141
+ name: "proceed",
6142
+ message: `${consequence[0].toUpperCase()}${consequence.slice(1)} \u2014 continue?`,
6143
+ default: false
6144
+ }
6145
+ ]);
6146
+ if (!proceed) {
6147
+ info("Nothing deleted.");
6148
+ return;
6149
+ }
6150
+ }
6151
+ if (shared) {
6152
+ await withSpinner(
6153
+ `Removing ${key} from ${environment}...`,
6154
+ () => api.removeVariableFromEnvironment(found._id, environment)
6155
+ );
6156
+ success(
6157
+ `Removed ${chalk18.bold(key)} from ${environment} \u2014 still live in: ${found.environments.filter((e) => e !== environment).join(", ")}.`
6158
+ );
6159
+ } else {
6160
+ await withSpinner(
6161
+ `Deleting ${key}...`,
6162
+ () => api.removeVariable(found._id)
6163
+ );
6164
+ success(
6165
+ `Deleted ${chalk18.bold(key)} from ${projectName}/${environment}.`
6166
+ );
6167
+ info("Recover it from the dashboard trash if this was a mistake.");
6168
+ }
6169
+ } catch (err) {
6170
+ await handleError(err);
6171
+ }
6172
+ });
6173
+ var secretsCommand = new Command19("secrets").alias("var").description("Manage a single secret (set, delete)").addCommand(setCommand).addCommand(rmCommand);
6174
+
6175
+ // src/commands/diff.ts
6176
+ import { Command as Command20 } from "commander";
6177
+ import chalk19 from "chalk";
6178
+ var diffCommand = new Command20("diff").description("Compare variables between two environments").argument("<envA>", "First environment").argument("<envB>", "Second environment").option("-p, --project <name-or-id>", "Linked project (defaults to active)").option("--values", "Also compare values (decrypts both environments)").option("--json", "Output as JSON").action(async (envA, envB, options) => {
6179
+ try {
6180
+ if (!isAuthenticated()) throw notAuthenticated();
6181
+ for (const env of [envA, envB]) {
6182
+ if (!validateEnvironment(env)) {
6183
+ throw invalidInput(
6184
+ `Unknown environment "${env}". Valid: development, staging, production.`
6185
+ );
6186
+ }
6187
+ }
6188
+ if (envA === envB) {
6189
+ throw invalidInput("Pick two different environments to compare.");
6190
+ }
6191
+ const config2 = readProjectConfigV2();
6192
+ if (!config2) throw notInitialized();
6193
+ const project = resolveProject(config2, options.project);
6194
+ if (!project) {
6195
+ error(`Project not found: ${options.project ?? "(active)"}`);
6196
+ process.exit(1);
6197
+ }
6198
+ const api = createAPIClient();
6199
+ const [metaA, metaB] = await withSpinner(
6200
+ `Comparing ${envA} vs ${envB}...`,
6201
+ () => Promise.all([
6202
+ api.listVariableKeys(project.projectId, envA),
6203
+ api.listVariableKeys(project.projectId, envB)
6204
+ ])
5528
6205
  );
5529
- if (requests.length === 0) {
5530
- info("No variable requests found.");
6206
+ const keysA = new Set(metaA.keys);
6207
+ const keysB = new Set(metaB.keys);
6208
+ const truncated = metaA.truncated || metaB.truncated;
6209
+ let valsA = /* @__PURE__ */ new Map();
6210
+ let valsB = /* @__PURE__ */ new Map();
6211
+ let undecryptable = [];
6212
+ if (options.values) {
6213
+ const [a, b] = await withSpinner(
6214
+ `Decrypting values...`,
6215
+ () => Promise.all([
6216
+ api.listVariables(project.projectId, envA, project.organizationId),
6217
+ api.listVariables(project.projectId, envB, project.organizationId)
6218
+ ])
6219
+ );
6220
+ valsA = new Map(a.variables.map((v) => [v.key, v.value]));
6221
+ valsB = new Map(b.variables.map((v) => [v.key, v.value]));
6222
+ undecryptable = [
6223
+ .../* @__PURE__ */ new Set([...a.decryptionFailures, ...b.decryptionFailures])
6224
+ ].sort();
6225
+ }
6226
+ const allKeys = [.../* @__PURE__ */ new Set([...keysA, ...keysB])].sort();
6227
+ const unknown = new Set(undecryptable);
6228
+ const onlyA = [];
6229
+ const onlyB = [];
6230
+ const differing = [];
6231
+ const same = [];
6232
+ for (const key of allKeys) {
6233
+ const inA = keysA.has(key);
6234
+ const inB = keysB.has(key);
6235
+ if (inA && !inB) onlyA.push(key);
6236
+ else if (!inA && inB) onlyB.push(key);
6237
+ else if (unknown.has(key))
6238
+ continue;
6239
+ else if (options.values && valsA.has(key) && valsB.has(key) && valsA.get(key) !== valsB.get(key))
6240
+ differing.push(key);
6241
+ else same.push(key);
6242
+ }
6243
+ if (options.json) {
6244
+ console.log(
6245
+ JSON.stringify(
6246
+ {
6247
+ envA,
6248
+ envB,
6249
+ onlyA,
6250
+ onlyB,
6251
+ differing,
6252
+ same,
6253
+ undecryptable,
6254
+ truncated
6255
+ },
6256
+ null,
6257
+ 2
6258
+ )
6259
+ );
5531
6260
  return;
5532
6261
  }
5533
- table(formatRequestRows(requests), [
5534
- { key: "key", header: "KEY" },
5535
- { key: "environments", header: "ENVIRONMENTS" },
5536
- { key: "status", header: "STATUS" },
5537
- { key: "requested", header: "REQUESTED" },
5538
- { key: "reason", header: "REASON" }
5539
- ]);
6262
+ if (truncated) {
6263
+ warning(
6264
+ "Project exceeds the 5000-variable read window \u2014 this diff may be incomplete."
6265
+ );
6266
+ }
6267
+ header(`Diff: ${chalk19.bold(envA)} vs ${chalk19.bold(envB)}`);
6268
+ console.log();
6269
+ printGroup(`Only in ${envA}`, onlyA, chalk19.yellow);
6270
+ printGroup(`Only in ${envB}`, onlyB, chalk19.cyan);
6271
+ if (options.values) {
6272
+ printGroup("Different values", differing, chalk19.red);
6273
+ printGroup(
6274
+ "Could not decrypt (comparison unknown)",
6275
+ undecryptable,
6276
+ chalk19.magenta
6277
+ );
6278
+ }
6279
+ if (onlyA.length === 0 && onlyB.length === 0 && differing.length === 0 && undecryptable.length === 0) {
6280
+ info(
6281
+ options.values ? "Environments are identical (keys and values)." : "Same keys in both environments (run with --values to compare values)."
6282
+ );
6283
+ }
5540
6284
  } catch (err) {
5541
6285
  await handleError(err);
5542
6286
  }
5543
6287
  });
6288
+ function printGroup(title, keys, color) {
6289
+ if (keys.length === 0) return;
6290
+ console.log(color(`${title} (${keys.length}):`));
6291
+ for (const key of keys) console.log(` ${key}`);
6292
+ console.log();
6293
+ }
5544
6294
 
5545
6295
  // src/lib/command-catalog.ts
5546
6296
  function formatArgv(argv) {
@@ -5705,21 +6455,76 @@ var COMMAND_CATALOG = [
5705
6455
  },
5706
6456
  {
5707
6457
  id: "requests",
5708
- title: "List variable requests",
6458
+ title: "List and review variable requests",
5709
6459
  category: "Browse",
5710
- description: "List pending and past variable requests for a project.",
6460
+ description: "List variable requests, or approve/reject/cancel them without leaving the terminal.",
5711
6461
  argv: ["requests"],
5712
- args: "[--project <name-or-id>] [--status <status>]",
5713
- examples: [["requests"], ["requests", "--status", "pending"]],
5714
- websiteSurface: "Maps to `/api/cli/variable-requests` (GET).",
6462
+ args: "[list] [--project <p>] [--status <s>] [--json] | approve <id> [--value <v>|--value-stdin] [--reason <t>] | reject <id> [--reason <t>] | cancel <id>",
6463
+ examples: [
6464
+ ["requests"],
6465
+ ["requests", "--status", "pending"],
6466
+ ["requests", "approve", "<id>"],
6467
+ ["requests", "approve", "<id>", "--value", "sk_live_\u2026"],
6468
+ ["requests", "reject", "<id>", "--reason", "use the shared key"],
6469
+ ["requests", "cancel", "<id>"]
6470
+ ],
6471
+ websiteSurface: "Convex features/variables/requests (review/cancel).",
5715
6472
  notes: [
5716
- "Reviewers (owner, assigned project manager/team lead) see every request for the project.",
5717
- "Developers see only their own requests."
6473
+ "Reviewers (owner, assigned project manager/team lead) see every request; developers see only their own.",
6474
+ "--status/--json apply to listing only; --value/--value-stdin/--reason apply to review subcommands only.",
6475
+ "Approving a machine (valueless) request prompts MASKED for the value; --value-stdin reads it from stdin for CI (keeps it out of argv), --value is a last resort that lands in shell history.",
6476
+ "Get the <id> from the ID column of `envpilot requests`."
5718
6477
  ],
5719
- keywords: ["requests", "approval", "review", "pending"],
6478
+ keywords: ["requests", "approval", "review", "approve", "reject", "cancel"],
5720
6479
  topLevel: true,
5721
6480
  createCommand: () => requestsCommand
5722
6481
  },
6482
+ {
6483
+ id: "secrets",
6484
+ title: "Set or delete a single secret",
6485
+ category: "Sync",
6486
+ description: "Change one secret without pull/edit/push. Two-step by default: key first, value prompted MASKED (never in shell history).",
6487
+ argv: ["secrets"],
6488
+ aliases: [["var"]],
6489
+ args: "set [<key>|<key=value>] [-e <env>] [-p <project>] [-d <text>] [--sensitive] [--all-envs] | rm <key> [-e <env>] [-p <project>] [--yes]",
6490
+ examples: [
6491
+ ["secrets", "set"],
6492
+ ["secrets", "set", "STRIPE_SECRET_KEY", "--env", "production"],
6493
+ ["secrets", "set", "API_URL=https://api.example.com"],
6494
+ ["secrets", "rm", "OLD_FLAG", "--env", "staging", "--yes"]
6495
+ ],
6496
+ websiteSurface: "Convex features/variables (bulk upsert / remove).",
6497
+ notes: [
6498
+ "Interactive by default: the key is validated first, then the value is prompted masked so it never lands in shell history \u2014 KEY=VALUE inline is for CI and prints a history warning.",
6499
+ "Role-aware: direct-write roles set immediately; request-only roles are offered the request workflow instead (a reviewer approves with `requests approve`).",
6500
+ "Plan limits are enforced server-side and reported readably; check `envpilot usage` for your tier.",
6501
+ "set upserts one key in one environment (merge); updating a value shared across environments requires confirmation (--all-envs non-interactively).",
6502
+ "rm on a single-environment secret moves it to trash (recoverable from the dashboard); on a shared secret it only removes THIS environment \u2014 the value stays live in the others.",
6503
+ "`envpilot var \u2026` still works as an alias."
6504
+ ],
6505
+ keywords: ["secrets", "var", "set", "delete", "remove", "variable", "edit"],
6506
+ topLevel: true,
6507
+ createCommand: () => secretsCommand
6508
+ },
6509
+ {
6510
+ id: "diff",
6511
+ title: "Compare two environments",
6512
+ category: "Browse",
6513
+ description: "Show which variable keys differ between two environments (add --values to compare values).",
6514
+ argv: ["diff"],
6515
+ args: "<envA> <envB> [--values] [--project <name-or-id>] [--json]",
6516
+ examples: [
6517
+ ["diff", "staging", "production"],
6518
+ ["diff", "development", "staging", "--values"]
6519
+ ],
6520
+ websiteSurface: "Convex features/variables (client-side compare).",
6521
+ notes: [
6522
+ "Default is a metadata-only key comparison; --values decrypts both environments."
6523
+ ],
6524
+ keywords: ["diff", "compare", "environments", "drift"],
6525
+ topLevel: true,
6526
+ createCommand: () => diffCommand
6527
+ },
5723
6528
  {
5724
6529
  id: "run",
5725
6530
  title: "Run command with secrets",
package/dist/index.js CHANGED
@@ -7,13 +7,13 @@ import {
7
7
  initSentry,
8
8
  isInteractiveTerminal,
9
9
  openTUI
10
- } from "./chunk-DYZH5VNC.js";
10
+ } from "./chunk-WLHMHDPX.js";
11
11
 
12
12
  // src/lib/program.ts
13
13
  import { Command } from "commander";
14
14
 
15
15
  // src/lib/cli-version.ts
16
- var CLI_VERSION = true ? "1.18.0" : "0.0.0";
16
+ var CLI_VERSION = true ? "1.19.0" : "0.0.0";
17
17
 
18
18
  // src/lib/version-check.ts
19
19
  import chalk from "chalk";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envpilot/cli",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "Envpilot CLI — sync and manage environment variables from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,7 +60,7 @@
60
60
  "secrets"
61
61
  ],
62
62
  "author": "Envpilot <hello@envpilot.dev> (https://www.envpilot.dev)",
63
- "license": "UNLICENSED",
63
+ "license": "MIT",
64
64
  "repository": {
65
65
  "type": "git",
66
66
  "url": "https://github.com/rafay99-epic/envpilot.dev"