@envpilot/cli 1.17.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.
@@ -7,7 +7,7 @@ function initSentry() {
7
7
  Sentry.init({
8
8
  dsn,
9
9
  environment: "cli",
10
- release: true ? "1.17.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" } },
@@ -54,16 +54,12 @@ import Conf from "conf";
54
54
 
55
55
  // src/lib/roles.ts
56
56
  var ROLE_LEVEL = {
57
- owner: 4,
58
- project_manager: 3,
59
- team_lead: 2,
60
- developer: 1
61
- };
62
- var ORG_ROLE_LABELS = {
63
- owner: "Owner",
64
- project_manager: "Project Manager",
65
- team_lead: "Team Lead",
66
- developer: "Developer"
57
+ owner: 100,
58
+ project_manager: 80,
59
+ team_lead: 60,
60
+ editor: 50,
61
+ developer: 40,
62
+ viewer: 20
67
63
  };
68
64
  function normalizeOrgRole(role) {
69
65
  switch (role) {
@@ -71,20 +67,20 @@ function normalizeOrgRole(role) {
71
67
  return "owner";
72
68
  case "member":
73
69
  return "developer";
74
- case "owner":
75
- case "project_manager":
76
- case "team_lead":
77
- case "developer":
78
- return role;
79
- default:
70
+ case void 0:
71
+ case null:
72
+ case "":
80
73
  return "developer";
74
+ default:
75
+ return role;
81
76
  }
82
77
  }
83
78
  function roleLevel(role) {
84
- return ROLE_LEVEL[normalizeOrgRole(role)];
79
+ return ROLE_LEVEL[normalizeOrgRole(role)] ?? 0;
85
80
  }
86
- function formatRoleLabel(role) {
87
- return ORG_ROLE_LABELS[normalizeOrgRole(role)];
81
+ function formatRoleLabel(role, meta) {
82
+ if (meta?.displayName) return meta.displayName;
83
+ return normalizeOrgRole(role).split("_").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
88
84
  }
89
85
  function isFileWritable(access) {
90
86
  if (access.role === "owner") return true;
@@ -93,7 +89,7 @@ function isFileWritable(access) {
93
89
  case "project_manager":
94
90
  case "team_lead":
95
91
  return true;
96
- case "developer":
92
+ default:
97
93
  return access.hasWriteAccess;
98
94
  }
99
95
  }
@@ -153,7 +149,7 @@ function probeCache(projectId, environment, organizationId, ttlSeconds) {
153
149
  const fresh = isFresh(entry, ttlSeconds);
154
150
  return { hit: true, fresh, entry };
155
151
  }
156
- function writeCache(projectId, environment, organizationId, variables, serverFingerprint) {
152
+ function writeCache(projectId, environment, organizationId, variables, serverFingerprint, meta) {
157
153
  try {
158
154
  const cacheDir = getCacheDir();
159
155
  mkdirSync(cacheDir, { recursive: true, mode: 448 });
@@ -168,7 +164,9 @@ function writeCache(projectId, environment, organizationId, variables, serverFin
168
164
  environment,
169
165
  organizationId,
170
166
  apiUrl: getApiUrl(),
171
- fingerprint: serverFingerprint ?? computeFingerprint(variables)
167
+ fingerprint: serverFingerprint ?? computeFingerprint(variables),
168
+ ...meta?.decryptionFailures?.length ? { decryptionFailures: meta.decryptionFailures } : {},
169
+ ...meta?.scopeRestricted ? { scopeRestricted: true } : {}
172
170
  };
173
171
  writeFileSync(path, JSON.stringify(entry, null, 2), {
174
172
  encoding: "utf-8",
@@ -474,7 +472,7 @@ function isInteractiveTerminal() {
474
472
  async function openTUI() {
475
473
  const [{ render }, { CLIApp }, { PressAnyKey }] = await Promise.all([
476
474
  import("ink"),
477
- import("./app-7IL7GWIN.js"),
475
+ import("./app-VAJPLVBA.js"),
478
476
  import("./press-any-key-64XFP4O2.js")
479
477
  ]);
480
478
  while (true) {
@@ -622,6 +620,8 @@ function formatRole(role) {
622
620
  return chalk.blue(label);
623
621
  case "developer":
624
622
  return chalk.yellow(label);
623
+ default:
624
+ return label;
625
625
  }
626
626
  }
627
627
  function roleNotice(role) {
@@ -1084,7 +1084,18 @@ var refs = {
1084
1084
  ),
1085
1085
  pullValues: fnRef("features/variables/values:pullValues"),
1086
1086
  pushBulk: fnRef("features/variables/values:pushBulk"),
1087
- 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")
1088
1099
  };
1089
1100
  async function convexQuery(ref, ...args) {
1090
1101
  const client = await getConvexClient();
@@ -1227,20 +1238,31 @@ var APIClient = class {
1227
1238
  * Byte-compatible with the fingerprint writeCache stores from a full fetch:
1228
1239
  * both hash `${_id}:${version}:${updatedAt}` over the accessible variables in
1229
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.
1230
1245
  */
1231
1246
  async checkFingerprint(projectId, environment, _organizationId) {
1232
1247
  const rows = await convexQuery(refs.listVariablesWithAccess, { projectId });
1233
- const filtered = rows.filter(
1234
- (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)
1235
1251
  );
1236
- 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(
1237
1254
  (row) => ({
1238
1255
  _id: row._id,
1239
1256
  version: row.version,
1240
1257
  updatedAt: row.updatedAt
1241
1258
  })
1242
1259
  );
1243
- return computeFingerprint(asVariables);
1260
+ return {
1261
+ fingerprint: computeFingerprint(asVariables),
1262
+ totalAccessible: accessible.length,
1263
+ matchingCount: matching.length,
1264
+ otherEnvKeys
1265
+ };
1244
1266
  }
1245
1267
  /** List variable requests for a project (Convex). */
1246
1268
  async listVariableRequests(projectId, status) {
@@ -1330,7 +1352,8 @@ var APIClient = class {
1330
1352
  environmentScope: result.meta.environmentScope,
1331
1353
  hasWriteAccess: result.meta.hasWriteAccess,
1332
1354
  scopeRestricted: result.meta.scopeRestricted,
1333
- decryptionFailures: result.meta.decryptionFailures
1355
+ decryptionFailures: result.meta.decryptionFailures,
1356
+ capabilities: result.meta.capabilities
1334
1357
  };
1335
1358
  return {
1336
1359
  variables,
@@ -1366,6 +1389,125 @@ var APIClient = class {
1366
1389
  }
1367
1390
  return created;
1368
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
+ }
1369
1511
  };
1370
1512
  function createAPIClient() {
1371
1513
  return new APIClient();
@@ -1566,7 +1708,9 @@ var projectSchema = z2.object({
1566
1708
  // Unified-role fields (optional so legacy server responses still parse)
1567
1709
  unifiedRole: z2.string().nullable().optional(),
1568
1710
  assigned: z2.boolean().optional(),
1569
- environmentScope: z2.array(z2.string()).nullable().optional()
1711
+ environmentScope: z2.array(z2.string()).nullable().optional(),
1712
+ // Resolved capability map (additive; absent on older deployments).
1713
+ capabilities: z2.record(z2.string(), z2.boolean()).nullable().optional()
1570
1714
  });
1571
1715
  var variableTagSchema = z2.object({
1572
1716
  _id: z2.string(),
@@ -1599,7 +1743,9 @@ var variablesMetaSchema = z2.object({
1599
1743
  grantOnly: z2.boolean().optional(),
1600
1744
  environmentScope: z2.array(z2.string()).nullable().optional(),
1601
1745
  hasWriteAccess: z2.boolean().optional(),
1602
- scopeRestricted: z2.boolean().optional()
1746
+ scopeRestricted: z2.boolean().optional(),
1747
+ // Resolved capability map (additive; absent on older deployments).
1748
+ capabilities: z2.record(z2.string(), z2.boolean()).optional()
1603
1749
  }).passthrough();
1604
1750
  var environmentSchema = z2.enum([
1605
1751
  "development",
@@ -2950,6 +3096,9 @@ function validateEnvVars(vars) {
2950
3096
  }
2951
3097
  return { valid, invalid };
2952
3098
  }
3099
+ function validateEnvironment(env) {
3100
+ return environmentSchema2.safeParse(env).success;
3101
+ }
2953
3102
 
2954
3103
  // src/commands/push.ts
2955
3104
  var pushCommand = new Command4("push").description("Upload local .env file to cloud").option(
@@ -3949,8 +4098,8 @@ async function handlePath() {
3949
4098
  ]);
3950
4099
  }
3951
4100
  async function handleReset() {
3952
- const inquirer7 = await import("inquirer");
3953
- const { confirm } = await inquirer7.default.prompt([
4101
+ const inquirer9 = await import("inquirer");
4102
+ const { confirm } = await inquirer9.default.prompt([
3954
4103
  {
3955
4104
  type: "confirm",
3956
4105
  name: "confirm",
@@ -4707,7 +4856,7 @@ import { Command as Command14 } from "commander";
4707
4856
  import crossSpawn from "cross-spawn";
4708
4857
  import { constants as osConstants } from "os";
4709
4858
  import chalk15 from "chalk";
4710
- var DEFAULT_TTL = 3600;
4859
+ var DEFAULT_TTL = 0;
4711
4860
  var runCommand = new Command14("run").description(
4712
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."
4713
4862
  ).argument("[command...]", "Command and arguments to execute").option(
@@ -4730,7 +4879,7 @@ var runCommand = new Command14("run").description(
4730
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).'
4731
4880
  ).option("-q, --quiet", "Suppress informational messages").option("--no-cache", "Skip cache and always fetch fresh secrets from server").option(
4732
4881
  "--cache-ttl <seconds>",
4733
- "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)",
4734
4883
  String(DEFAULT_TTL)
4735
4884
  ).passThroughOptions().allowExcessArguments().action(async (commandArgs, options) => {
4736
4885
  try {
@@ -4757,7 +4906,12 @@ var runCommand = new Command14("run").description(
4757
4906
  }
4758
4907
  throw notInitialized();
4759
4908
  }
4760
- 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
+ }
4761
4915
  const organizationId = options.organization || project.organizationId;
4762
4916
  const parsedTtl = Number.parseInt(
4763
4917
  options.cacheTtl ?? String(DEFAULT_TTL),
@@ -4767,14 +4921,35 @@ var runCommand = new Command14("run").description(
4767
4921
  let variables;
4768
4922
  let cacheHit = false;
4769
4923
  let cacheAge = "";
4924
+ let otherEnvKeys = [];
4925
+ let decryptionFailures = [];
4926
+ let scopeRestricted = false;
4770
4927
  if (options.cache === false) {
4771
- variables = await doFetch(
4928
+ const fetched = await doFetch(
4772
4929
  project,
4773
4930
  environment,
4774
4931
  organizationId,
4775
4932
  options.quiet
4776
4933
  );
4777
- 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
+ );
4778
4953
  } else {
4779
4954
  const probe = probeCache(
4780
4955
  project.projectId,
@@ -4786,15 +4961,18 @@ var runCommand = new Command14("run").description(
4786
4961
  variables = probe.entry.variables;
4787
4962
  cacheHit = true;
4788
4963
  cacheAge = formatAge(probe.entry.fetchedAt);
4789
- } else if (probe.hit && !probe.fresh) {
4964
+ decryptionFailures = probe.entry.decryptionFailures ?? [];
4965
+ scopeRestricted = probe.entry.scopeRestricted ?? false;
4966
+ } else {
4790
4967
  try {
4791
4968
  const api = createAPIClient();
4792
- const serverFingerprint = await api.checkFingerprint(
4969
+ const check = await api.checkFingerprint(
4793
4970
  project.projectId,
4794
4971
  environment,
4795
4972
  organizationId
4796
4973
  );
4797
- if (serverFingerprint === probe.entry.fingerprint) {
4974
+ otherEnvKeys = check.otherEnvKeys;
4975
+ if (probe.hit && check.fingerprint === probe.entry.fingerprint) {
4798
4976
  extendCacheFreshness(
4799
4977
  project.projectId,
4800
4978
  environment,
@@ -4803,27 +4981,35 @@ var runCommand = new Command14("run").description(
4803
4981
  variables = probe.entry.variables;
4804
4982
  cacheHit = true;
4805
4983
  cacheAge = formatAge(probe.entry.fetchedAt);
4984
+ decryptionFailures = probe.entry.decryptionFailures ?? [];
4985
+ scopeRestricted = probe.entry.scopeRestricted ?? false;
4806
4986
  } else {
4807
- variables = await doFetch(
4987
+ const fetched = await doFetch(
4808
4988
  project,
4809
4989
  environment,
4810
4990
  organizationId,
4811
4991
  options.quiet,
4812
- "Secrets updated, refreshing"
4992
+ probe.hit ? "Secrets updated, refreshing" : "Loading"
4813
4993
  );
4994
+ variables = fetched.variables;
4995
+ decryptionFailures = fetched.decryptionFailures;
4996
+ scopeRestricted = fetched.scopeRestricted;
4814
4997
  writeCache(
4815
4998
  project.projectId,
4816
4999
  environment,
4817
5000
  organizationId,
4818
5001
  variables,
4819
- serverFingerprint
5002
+ decryptionFailures.length > 0 ? void 0 : check.fingerprint,
5003
+ { decryptionFailures, scopeRestricted }
4820
5004
  );
4821
5005
  }
4822
5006
  } catch (err) {
4823
- if (isConnectivityError(err)) {
5007
+ if (probe.hit && isConnectivityError(err)) {
4824
5008
  variables = probe.entry.variables;
4825
5009
  cacheHit = true;
4826
5010
  cacheAge = formatAge(probe.entry.fetchedAt);
5011
+ decryptionFailures = probe.entry.decryptionFailures ?? [];
5012
+ scopeRestricted = probe.entry.scopeRestricted ?? false;
4827
5013
  if (!options.quiet) {
4828
5014
  warning(
4829
5015
  `Using offline cache (age ${cacheAge}) \u2014 could not reach the server to verify freshness.`
@@ -4834,14 +5020,6 @@ var runCommand = new Command14("run").description(
4834
5020
  throw err;
4835
5021
  }
4836
5022
  }
4837
- } else {
4838
- variables = await doFetch(
4839
- project,
4840
- environment,
4841
- organizationId,
4842
- options.quiet
4843
- );
4844
- writeCache(project.projectId, environment, organizationId, variables);
4845
5023
  }
4846
5024
  }
4847
5025
  if (variables.length === 0) {
@@ -4849,6 +5027,13 @@ var runCommand = new Command14("run").description(
4849
5027
  `No variables found for ${environment}. Running command without injected secrets.`
4850
5028
  );
4851
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
+ }
4852
5037
  const secrets = {};
4853
5038
  for (const v of variables) {
4854
5039
  secrets[v.key] = v.value;
@@ -4870,9 +5055,24 @@ var runCommand = new Command14("run").description(
4870
5055
  if (!options.quiet) {
4871
5056
  const injectedCount = Object.keys(secrets).length;
4872
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}`) : "";
4873
5060
  info(
4874
- `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}`
4875
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
+ }
4876
5076
  if (overridden.length > 0) {
4877
5077
  warning(
4878
5078
  `Overriding ${overridden.length} shell var${overridden.length === 1 ? "" : "s"}: ${overridden.slice(0, 3).join(", ")}${overridden.length > 3 ? `, +${overridden.length - 3} more` : ""}`
@@ -4889,18 +5089,28 @@ var runCommand = new Command14("run").description(
4889
5089
  async function doFetch(project, environment, organizationId, quiet, labelPrefix = "Loading") {
4890
5090
  const label = `${labelPrefix} ${chalk15.bold(environment)} secrets for ${chalk15.bold(project.projectName || project.projectId)}...`;
4891
5091
  const api = createAPIClient();
4892
- 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(
4893
5093
  label,
4894
5094
  () => api.listVariables(project.projectId, environment, organizationId)
4895
5095
  );
4896
- if (decryptionFailures.length > 0) {
4897
- for (const key of decryptionFailures) {
4898
- warning(
4899
- `Could not decrypt ${chalk15.bold(key)} \u2014 skipped (vault error, check server logs)`
4900
- );
4901
- }
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 [];
4902
5113
  }
4903
- return variables;
4904
5114
  }
4905
5115
  function printInjectionPreview(secrets, project, environment) {
4906
5116
  const keys = Object.keys(secrets).sort();
@@ -5156,9 +5366,17 @@ function buildCreateVariableRequestBody(input) {
5156
5366
  isSensitive: input.isSensitive ?? false
5157
5367
  };
5158
5368
  }
5369
+ function canSubmitRequests(input) {
5370
+ if (input.capabilities) {
5371
+ return input.capabilities["project.requests.submit"] === true;
5372
+ }
5373
+ return normalizeOrgRole(input.role) === "developer";
5374
+ }
5159
5375
  function isRequestEligibleProject(project) {
5160
- const role = normalizeOrgRole(project.unifiedRole ?? project.role);
5161
- return role === "developer" && project.assigned === true;
5376
+ return canSubmitRequests({
5377
+ capabilities: project.capabilities,
5378
+ role: project.unifiedRole ?? project.role
5379
+ }) && project.assigned === true;
5162
5380
  }
5163
5381
  function buildEligibleRequestTargets(projects, orgNameById) {
5164
5382
  return projects.filter(isRequestEligibleProject).map((p) => ({
@@ -5206,6 +5424,7 @@ function formatRequestsListHeader(input) {
5206
5424
  }
5207
5425
  function formatRequestRow(request) {
5208
5426
  return {
5427
+ id: request._id,
5209
5428
  key: request.key,
5210
5429
  environments: request.environments.join(", "),
5211
5430
  status: request.status,
@@ -5257,8 +5476,11 @@ var requestCommand = new Command17("request").description(
5257
5476
  }
5258
5477
  if (useDefault) {
5259
5478
  const meta = await fetchProjectMeta(api, defaultEntry);
5260
- const role = normalizeOrgRole(meta?.unifiedRole ?? meta?.role);
5261
- if (role !== "developer") {
5479
+ const eligible = canSubmitRequests({
5480
+ capabilities: meta?.capabilities,
5481
+ role: meta?.unifiedRole ?? meta?.role
5482
+ });
5483
+ if (!eligible) {
5262
5484
  warning(
5263
5485
  "You have direct write access to this project. Use `envpilot push` or create the variable directly instead of submitting a request."
5264
5486
  );
@@ -5372,10 +5594,6 @@ var requestCommand = new Command17("request").description(
5372
5594
  );
5373
5595
  info(`Status: ${created.status} \xB7 Id: ${created._id}`);
5374
5596
  } catch (err) {
5375
- if (err instanceof APIError && err.statusCode === 403) {
5376
- error(err.message);
5377
- return;
5378
- }
5379
5597
  await handleError(err);
5380
5598
  }
5381
5599
  });
@@ -5471,66 +5689,608 @@ async function pickRequestTarget(api) {
5471
5689
 
5472
5690
  // src/commands/requests.ts
5473
5691
  import { Command as Command18 } from "commander";
5474
- 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(
5475
5754
  "--project <name-or-id>",
5476
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"
5477
5766
  ).option(
5478
- "--status <status>",
5479
- "Filter by status: pending, approved, rejected, canceled"
5480
- ).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) => {
5481
5834
  try {
5482
- if (!isAuthenticated()) {
5483
- 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})`);
5484
5901
  }
5485
- const configV2 = readProjectConfigV2();
5486
- if (!configV2) throw notInitialized();
5487
- const project = resolveProject(configV2, options.project);
5488
- if (!project) {
5489
- error(`Project not found: ${options.project}`);
5490
- console.log();
5491
- console.log("Linked projects:");
5492
- for (const p of configV2.projects) {
5493
- 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);
5494
5948
  }
5495
- process.exit(1);
5496
- }
5497
- let status;
5498
- if (options.status) {
5499
- const parsed = variableRequestStatusSchema.safeParse(options.status);
5500
- if (!parsed.success) {
5949
+ if (route.environmentScope && !route.environmentScope.includes(environment)) {
5501
5950
  error(
5502
- `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.`
5952
+ );
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;
5987
+ }
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."
5503
6085
  );
5504
- process.exit(1);
5505
6086
  }
5506
- status = parsed.data;
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);
5507
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);
5508
6110
  const api = createAPIClient();
5509
- const requests = await withSpinner(
5510
- "Fetching variable requests...",
5511
- () => api.listVariableRequests(project.projectId, status)
6111
+ const roles = await withSpinner(
6112
+ "Checking role...",
6113
+ () => api.resolveProjectRoles(projectId)
5512
6114
  );
5513
- header(
5514
- formatRequestsListHeader({
5515
- projectName: project.projectName || project.projectId,
5516
- organizationName: project.organizationName || project.organizationId
5517
- })
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)
5518
6124
  );
5519
- if (requests.length === 0) {
5520
- info("No variable requests found.");
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
+ ])
6205
+ );
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
+ );
5521
6260
  return;
5522
6261
  }
5523
- table(formatRequestRows(requests), [
5524
- { key: "key", header: "KEY" },
5525
- { key: "environments", header: "ENVIRONMENTS" },
5526
- { key: "status", header: "STATUS" },
5527
- { key: "requested", header: "REQUESTED" },
5528
- { key: "reason", header: "REASON" }
5529
- ]);
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
+ }
5530
6284
  } catch (err) {
5531
6285
  await handleError(err);
5532
6286
  }
5533
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
+ }
5534
6294
 
5535
6295
  // src/lib/command-catalog.ts
5536
6296
  function formatArgv(argv) {
@@ -5695,21 +6455,76 @@ var COMMAND_CATALOG = [
5695
6455
  },
5696
6456
  {
5697
6457
  id: "requests",
5698
- title: "List variable requests",
6458
+ title: "List and review variable requests",
5699
6459
  category: "Browse",
5700
- description: "List pending and past variable requests for a project.",
6460
+ description: "List variable requests, or approve/reject/cancel them without leaving the terminal.",
5701
6461
  argv: ["requests"],
5702
- args: "[--project <name-or-id>] [--status <status>]",
5703
- examples: [["requests"], ["requests", "--status", "pending"]],
5704
- 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).",
5705
6472
  notes: [
5706
- "Reviewers (owner, assigned project manager/team lead) see every request for the project.",
5707
- "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`."
5708
6477
  ],
5709
- keywords: ["requests", "approval", "review", "pending"],
6478
+ keywords: ["requests", "approval", "review", "approve", "reject", "cancel"],
5710
6479
  topLevel: true,
5711
6480
  createCommand: () => requestsCommand
5712
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
+ },
5713
6528
  {
5714
6529
  id: "run",
5715
6530
  title: "Run command with secrets",