@better-update/cli 0.22.0 → 0.23.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/dist/index.mjs CHANGED
@@ -33,7 +33,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
33
33
 
34
34
  //#endregion
35
35
  //#region package.json
36
- var version = "0.22.0";
36
+ var version = "0.23.0";
37
37
 
38
38
  //#endregion
39
39
  //#region src/lib/interactive-mode.ts
@@ -356,11 +356,12 @@ const Ciphertext = Schema.String.pipe(Schema.minLength(1)).annotations({ descrip
356
356
  /** Base64 of the per-credential DEK wrapped under the org vault key (AAD-bound). */
357
357
  const WrappedDek = Schema.String.pipe(Schema.minLength(1)).annotations({ description: "Base64 of the DEK wrapped under the org vault key" });
358
358
  /**
359
- * The secret-credential kinds whose DEK is wrapped under the org vault key — the
360
- * five tables a rotation must re-wrap. Provisioning profiles are plaintext and
361
- * are deliberately absent.
359
+ * The secret kinds whose DEK is wrapped under the org vault key — the rows a
360
+ * rotation must re-wrap. Five signing-credential tables plus `envVarValue` (one
361
+ * row per environment variable value revision). Provisioning profiles are
362
+ * plaintext and are deliberately absent.
362
363
  */
363
- const CredentialType = Schema.Literal("appleDistributionCertificate", "applePushKey", "ascApiKey", "googleServiceAccountKey", "androidUploadKeystore").annotations({ description: "Which encrypted-credential table a vault-key DEK re-wrap targets" });
364
+ const CredentialType = Schema.Literal("appleDistributionCertificate", "applePushKey", "ascApiKey", "googleServiceAccountKey", "androidUploadKeystore", "envVarValue").annotations({ description: "Which encrypted-secret table a vault-key DEK re-wrap targets" });
364
365
  /**
365
366
  * The client-encrypted envelope. Spread into each secret credential's upload
366
367
  * body and download result alongside that credential's public metadata. The
@@ -383,6 +384,23 @@ const CredentialDekUpdate = Schema.Struct({
383
384
  wrappedDek: WrappedDek
384
385
  });
385
386
  /**
387
+ * One credential's currently-stored wrapped DEK — what the client fetches before
388
+ * a rotation so it can unwrap each DEK with the old vault key and re-wrap it. The
389
+ * wrapped DEK is opaque (decryptable only with the vault key), so the server may
390
+ * serve it to any vault reader.
391
+ */
392
+ const CredentialDekRef = Schema.Struct({
393
+ credentialType: CredentialType,
394
+ credentialId: Id,
395
+ wrappedDek: WrappedDek,
396
+ vaultVersion: VaultVersion
397
+ });
398
+ /** Every wrapped DEK in the org + the current vault version (the rotation source set). */
399
+ const VaultCredentialDeks = Schema.Struct({
400
+ vaultVersion: VaultVersion,
401
+ deks: Schema.Array(CredentialDekRef)
402
+ });
403
+ /**
386
404
  * Rotate (or revoke) the org vault key. The client generates a new vault key at
387
405
  * version `fromVersion + 1`, re-wraps every credential DEK under it, and
388
406
  * re-wraps the new key to each surviving recipient (a revoke just omits the
@@ -1245,47 +1263,64 @@ const EnvVarVisibility = Schema.Literal("plaintext", "sensitive");
1245
1263
  const EnvVarScope = Schema.Literal("project", "global");
1246
1264
  const EnvVarEnvironment = Schema.Literal("development", "preview", "production");
1247
1265
  const EnvVarListScope = Schema.Literal("all", "project", "global");
1266
+ /**
1267
+ * A client-sealed env var value. `id` is the revision UUID the CLI bound as the
1268
+ * AAD `credentialId` when sealing; the envelope fields are the opaque ciphertext,
1269
+ * wrapped DEK, and vault version. The server stores these and never decrypts —
1270
+ * env var values are end-to-end encrypted, like credentials.
1271
+ */
1272
+ const EnvVarValueEnvelope = Schema.Struct({
1273
+ id: Id,
1274
+ ...encryptedEnvelopeFields
1275
+ });
1276
+ /**
1277
+ * Env var metadata. The value is **not** here — it lives encrypted in the
1278
+ * revision pointed at by `currentRevisionId` and is only ever readable by the
1279
+ * CLI (which holds the org vault key). One entity per (scope, key, environment).
1280
+ */
1248
1281
  var EnvVar = class extends Schema.Class("EnvVar")({
1249
1282
  id: Id,
1250
1283
  organizationId: Id,
1251
1284
  projectId: Schema.NullOr(Id),
1252
1285
  scope: EnvVarScope,
1286
+ environment: EnvVarEnvironment,
1253
1287
  key: Schema.String,
1254
1288
  visibility: EnvVarVisibility,
1255
- value: Schema.NullOr(Schema.String),
1256
- environments: Schema.Array(EnvVarEnvironment),
1289
+ currentRevisionId: Schema.NullOr(Id),
1290
+ revisionNumber: Schema.NullOr(Schema.Number),
1291
+ revisionCount: Schema.Number,
1257
1292
  overridesGlobal: Schema.optional(Schema.Boolean),
1258
1293
  createdAt: DateTimeString,
1259
1294
  updatedAt: DateTimeString
1260
1295
  }) {};
1261
1296
  const EnvVarKey = Schema.String.pipe(Schema.pattern(/^[A-Z][A-Z0-9_]*$/u), Schema.maxLength(256));
1262
- const EnvVarValue = Schema.String.pipe(Schema.maxLength(32768));
1263
- const EnvVarEnvironmentArray = Schema.Array(EnvVarEnvironment).pipe(Schema.minItems(1));
1264
1297
  const CreateEnvVarBody = Schema.Struct({
1265
1298
  scope: EnvVarScope,
1266
1299
  projectId: Schema.optional(Id),
1267
- environments: EnvVarEnvironmentArray,
1300
+ environment: EnvVarEnvironment,
1268
1301
  key: EnvVarKey,
1269
- value: EnvVarValue,
1270
- visibility: EnvVarVisibility
1302
+ visibility: EnvVarVisibility,
1303
+ value: EnvVarValueEnvelope
1271
1304
  });
1272
1305
  const UpdateEnvVarBody = Schema.Struct({
1273
- value: Schema.optional(EnvVarValue),
1274
- visibility: Schema.optional(EnvVarVisibility),
1275
- environments: Schema.optional(EnvVarEnvironmentArray)
1306
+ value: Schema.optional(EnvVarValueEnvelope),
1307
+ visibility: Schema.optional(EnvVarVisibility)
1276
1308
  });
1277
1309
  const BulkImportEntry = Schema.Struct({
1278
1310
  key: EnvVarKey,
1279
- value: EnvVarValue,
1280
- visibility: Schema.optional(EnvVarVisibility)
1311
+ environment: EnvVarEnvironment,
1312
+ visibility: EnvVarVisibility,
1313
+ value: EnvVarValueEnvelope
1281
1314
  });
1315
+ /**
1316
+ * Bulk import already-sealed entries. The CLI parses the dotenv file, seals each
1317
+ * value per (key, environment) locally, and sends the envelopes — the server
1318
+ * cannot parse or encrypt plaintext itself.
1319
+ */
1282
1320
  const BulkImportEnvVarsBody = Schema.Struct({
1283
1321
  scope: EnvVarScope,
1284
1322
  projectId: Schema.optional(Id),
1285
- environments: EnvVarEnvironmentArray,
1286
- content: Schema.optional(Schema.String.pipe(Schema.maxLength(4e6))),
1287
- entries: Schema.optional(Schema.Array(BulkImportEntry).pipe(Schema.maxItems(100))),
1288
- visibility: Schema.optional(EnvVarVisibility)
1323
+ entries: Schema.Array(BulkImportEntry).pipe(Schema.minItems(1), Schema.maxItems(300))
1289
1324
  });
1290
1325
  const BulkImportResult = Schema.Struct({
1291
1326
  created: Schema.Number,
@@ -1293,51 +1328,71 @@ const BulkImportResult = Schema.Struct({
1293
1328
  skipped: Schema.Number
1294
1329
  });
1295
1330
  const DeleteEnvVarResult = Schema.Struct({ id: Id });
1331
+ /** One exported variable's sealed value envelope; the CLI decrypts it locally. */
1296
1332
  const EnvVarExportItem = Schema.Struct({
1297
1333
  key: Schema.String,
1298
- value: Schema.String,
1299
- visibility: EnvVarVisibility
1334
+ environment: EnvVarEnvironment,
1335
+ visibility: EnvVarVisibility,
1336
+ id: Id,
1337
+ ...encryptedEnvelopeFields
1300
1338
  });
1301
1339
  const EnvVarExportResult = Schema.Struct({
1302
1340
  items: Schema.Array(EnvVarExportItem),
1303
1341
  environment: EnvVarEnvironment
1304
1342
  });
1343
+ /** One entry in a variable's value history (metadata only — no ciphertext). */
1344
+ const EnvVarRevision = Schema.Struct({
1345
+ id: Id,
1346
+ revisionNumber: Schema.Number,
1347
+ vaultVersion: VaultVersion,
1348
+ isCurrent: Schema.Boolean,
1349
+ createdBy: Schema.NullOr(Id),
1350
+ createdAt: DateTimeString
1351
+ });
1352
+ const EnvVarRevisionsResult = Schema.Struct({ items: Schema.Array(EnvVarRevision) });
1353
+ const RollbackEnvVarBody = Schema.Struct({ toRevisionId: Id });
1305
1354
 
1306
1355
  //#endregion
1307
1356
  //#region ../../packages/api/src/groups/env-vars.ts
1308
- var EnvVarsGroup = class extends HttpApiGroup.make("env-vars").add(HttpApiEndpoint.post("create", "/api/env-vars").setPayload(CreateEnvVarBody).addSuccess(EnvVar, { status: 201 }).addError(BadRequest).addError(Conflict).annotateContext(OpenApi.annotations({
1357
+ var EnvVarsGroup = class extends HttpApiGroup.make("env-vars").add(HttpApiEndpoint.post("create", "/api/env-vars").setPayload(CreateEnvVarBody).addSuccess(EnvVar, { status: 201 }).annotateContext(OpenApi.annotations({
1309
1358
  title: "Create environment variable",
1310
- description: "Create a new environment variable. Scope can be 'project' (requires projectId) or 'global' (organization-wide)."
1359
+ description: "Create a new environment variable for one environment. The body carries the client-sealed value envelope; the server never sees plaintext. Scope can be 'project' (requires projectId) or 'global'."
1311
1360
  }))).add(HttpApiEndpoint.get("list", "/api/env-vars").setUrlParams(Schema.Struct({
1312
1361
  scope: Schema.optional(EnvVarListScope),
1313
1362
  projectId: Schema.optional(Id),
1314
1363
  environments: Schema.optional(Schema.String),
1315
1364
  search: Schema.optional(Schema.String),
1316
1365
  ...PaginationParams.fields
1317
- })).addSuccess(Schema.Struct({ items: Schema.Array(EnvVar) })).addError(BadRequest).annotateContext(OpenApi.annotations({
1366
+ })).addSuccess(Schema.Struct({ items: Schema.Array(EnvVar) })).annotateContext(OpenApi.annotations({
1318
1367
  title: "List environment variables",
1319
- description: "List environment variables. scope=all merges project + global vars with project overrides. environments is a comma-separated list. search matches key substring."
1368
+ description: "List environment variable metadata (no values — those are encrypted). scope=all merges project + global vars with project overrides. environments is a comma-separated list. search matches key substring."
1320
1369
  }))).add(HttpApiEndpoint.get("get")`/api/env-vars/${idParam}`.addSuccess(EnvVar).annotateContext(OpenApi.annotations({
1321
1370
  title: "Get environment variable",
1322
- description: "Get an environment variable by ID"
1323
- }))).add(HttpApiEndpoint.patch("update")`/api/env-vars/${idParam}`.setPayload(UpdateEnvVarBody).addSuccess(EnvVar).addError(BadRequest).annotateContext(OpenApi.annotations({
1371
+ description: "Get an environment variable's metadata by ID (no value)"
1372
+ }))).add(HttpApiEndpoint.patch("update")`/api/env-vars/${idParam}`.setPayload(UpdateEnvVarBody).addSuccess(EnvVar).annotateContext(OpenApi.annotations({
1324
1373
  title: "Update environment variable",
1325
- description: "Update value, visibility, or assigned environments"
1374
+ description: "Change the value (a new sealed revision) and/or the visibility tier. The environment is immutable."
1326
1375
  }))).add(HttpApiEndpoint.del("delete")`/api/env-vars/${idParam}`.addSuccess(DeleteEnvVarResult).annotateContext(OpenApi.annotations({
1327
1376
  title: "Delete environment variable",
1328
- description: "Delete an environment variable"
1329
- }))).add(HttpApiEndpoint.post("bulkImport", "/api/env-vars/bulk-import").setPayload(BulkImportEnvVarsBody).addSuccess(BulkImportResult).addError(BadRequest).annotateContext(OpenApi.annotations({
1377
+ description: "Delete an environment variable and all of its value revisions"
1378
+ }))).add(HttpApiEndpoint.get("revisions")`/api/env-vars/${idParam}/revisions`.addSuccess(EnvVarRevisionsResult).annotateContext(OpenApi.annotations({
1379
+ title: "List value revisions",
1380
+ description: "List a variable's value history (metadata only, newest first)"
1381
+ }))).add(HttpApiEndpoint.post("rollback")`/api/env-vars/${idParam}/rollback`.setPayload(RollbackEnvVarBody).addSuccess(EnvVar).annotateContext(OpenApi.annotations({
1382
+ title: "Roll back to a revision",
1383
+ description: "Re-point the active value at an earlier revision of this variable"
1384
+ }))).add(HttpApiEndpoint.post("bulkImport", "/api/env-vars/bulk-import").setPayload(BulkImportEnvVarsBody).addSuccess(BulkImportResult).annotateContext(OpenApi.annotations({
1330
1385
  title: "Bulk import environment variables",
1331
- description: "Import variables from a dotenv-formatted string. Applies to all selected environments. Supports KEY=VALUE format with # comments. Quoted values (single/double) are unquoted. Multiline values are not supported."
1386
+ description: "Upsert pre-sealed entries (one per key+environment). The CLI parses the dotenv file and seals each value locally before sending."
1332
1387
  }))).add(HttpApiEndpoint.get("export", "/api/env-vars/export").setUrlParams(Schema.Struct({
1333
1388
  projectId: Id,
1334
1389
  environment: EnvVarEnvironment
1335
1390
  })).addSuccess(EnvVarExportResult).addError(Forbidden).annotateContext(OpenApi.annotations({
1336
1391
  title: "Export environment variables",
1337
- description: "Export environment variables for a project environment. Global org-scoped vars are merged in; project values override globals on key collision."
1338
- }))).addError(NotFound).addError(Forbidden).addError(BadRequest).annotateContext(OpenApi.annotations({
1392
+ description: "Export sealed value envelopes for a project environment (CLI decrypts locally). Global org-scoped vars are merged in; project values override globals on key collision. Bearer (CLI/API-key) auth only."
1393
+ }))).addError(NotFound).addError(Forbidden).addError(BadRequest).addError(Conflict).annotateContext(OpenApi.annotations({
1339
1394
  title: "Environment Variables",
1340
- description: "Manage environment variables for project builds and deployments"
1395
+ description: "Manage end-to-end encrypted, versioned environment variables for project builds"
1341
1396
  })) {};
1342
1397
 
1343
1398
  //#endregion
@@ -1656,6 +1711,9 @@ var OrgVaultGroup = class extends HttpApiGroup.make("orgVault").add(HttpApiEndpo
1656
1711
  }))).add(HttpApiEndpoint.get("getWrap")`/api/vault/wraps/${keyIdParam}`.addSuccess(RecipientVaultKey).annotateContext(OpenApi.annotations({
1657
1712
  title: "Get vault wrap",
1658
1713
  description: "Fetch the wrapped vault key for a recipient to unwrap locally"
1714
+ }))).add(HttpApiEndpoint.get("listCredentialDeks", "/api/vault/credential-deks").addSuccess(VaultCredentialDeks).annotateContext(OpenApi.annotations({
1715
+ title: "List wrapped credential DEKs",
1716
+ description: "Every wrapped DEK in the org + the current vault version — the client fetches these to re-wrap under a new vault key during a rotation (the DEKs are opaque)"
1659
1717
  }))).add(HttpApiEndpoint.post("rotate", "/api/vault/rotate").setPayload(RotateVaultBody).addSuccess(OrgVault).annotateContext(OpenApi.annotations({
1660
1718
  title: "Rotate vault key",
1661
1719
  description: "Revoke or rotate (admin): bump the vault version, re-wrap every credential DEK, and re-wrap the new key to the surviving recipients — applied atomically with compare-and-swap"
@@ -2683,7 +2741,6 @@ const CALLBACK_PAGE = `<!doctype html>
2683
2741
  }
2684
2742
  window.history.replaceState({}, document.title, window.location.pathname);
2685
2743
  render("CLI login complete. You can close this tab.");
2686
- setTimeout(() => window.close(), 300);
2687
2744
  })
2688
2745
  .catch((error) => {
2689
2746
  render(error instanceof Error ? error.message : "Callback failed.");
@@ -2776,6 +2833,7 @@ const createBrowserLoginServer = async (options = {}) => {
2776
2833
  waitForToken: session.waitForToken,
2777
2834
  stop: () => {
2778
2835
  session.dispose();
2836
+ server.closeAllConnections();
2779
2837
  server.close();
2780
2838
  }
2781
2839
  };
@@ -20713,14 +20771,19 @@ const unlockActivePrivateKey = (passphrase) => Effect.gen(function* () {
20713
20771
  })).privateKey;
20714
20772
  });
20715
20773
  /**
20716
- * Turn a missing-wrap `NotFound` into actionable guidance by asking the server
20717
- * whether the org vault exists at all. A fresh org has no vault yet — the first
20718
- * member must run `credentials identity init` (which also mints the offline
20719
- * recovery key); an existing vault means this device simply isn't a recipient,
20720
- * so it needs an admin grant or a self-link from a device that already has it.
20774
+ * Actionable guidance for a device that can't reach the vault, branched on
20775
+ * whether the org vault exists yet. A fresh org has no vault — the first member
20776
+ * runs `credentials identity init` (which also mints the offline recovery key);
20777
+ * an existing vault means this device simply isn't a recipient, so it needs an
20778
+ * admin grant or a self-link from a device that already has it. Exported so the
20779
+ * post-`identity create` hint stays in sync with this error path.
20721
20780
  */
20781
+ const VAULT_NOT_RECIPIENT_GUIDANCE = "This device isn't a vault recipient yet. Ask an org admin to run `better-update credentials access grant`, or self-link from a device that already has access with `better-update credentials device link`.";
20782
+ const VAULT_NOT_SET_UP_GUIDANCE = "This organization's credential vault isn't set up yet. Run `better-update credentials identity init` to bootstrap it — you'll get a one-time offline recovery key to store safely.";
20783
+ /** `true` if the org vault has been bootstrapped; `false` on `NotFound` (a fresh org). */
20784
+ const orgVaultExists = (api) => api.orgVault.get().pipe(Effect.as(true), Effect.catchTag("NotFound", () => Effect.succeed(false)));
20722
20785
  const vaultAccessError = (api) => Effect.gen(function* () {
20723
- return yield* new IdentityError({ message: (yield* api.orgVault.get().pipe(Effect.as(true), Effect.catchTag("NotFound", () => Effect.succeed(false)))) ? "This device isn't a vault recipient yet. Ask an org admin to run `better-update credentials access grant`, or self-link from a device that already has access with `better-update credentials device link`." : "This organization's credential vault isn't set up yet. Run `better-update credentials identity init` to bootstrap it — you'll get a one-time offline recovery key to store safely." });
20786
+ return yield* new IdentityError({ message: (yield* orgVaultExists(api)) ? VAULT_NOT_RECIPIENT_GUIDANCE : VAULT_NOT_SET_UP_GUIDANCE });
20724
20787
  });
20725
20788
  /**
20726
20789
  * Unlock the org vault key for this device: find this device's recipient row,
@@ -24314,18 +24377,76 @@ const warnIfDevClientMissing = (projectRoot) => Effect.gen(function* () {
24314
24377
  //#endregion
24315
24378
  //#region src/lib/env-exporter.ts
24316
24379
  const coerceEnvironment = (raw) => raw === "development" || raw === "preview" || raw === "production" ? raw : void 0;
24380
+ /** Decrypt one sealed env var value, re-checking the sealed metadata against the row. */
24381
+ const decryptEnvVarValue = (session, item) => openFromDownload({
24382
+ session,
24383
+ credentialType: "envVarValue",
24384
+ downloaded: item
24385
+ }).pipe(Effect.mapError((cause) => new EnvExportError({ message: `Failed to decrypt env var "${item.key}": ${cause.message}` })), Effect.flatMap((secret) => requireSecretString(secret, "value", () => new EnvExportError({ message: `Decrypted env var "${item.key}" is missing its value.` }))));
24386
+ /** Decrypt a batch of sealed env vars into plaintext key/value/visibility entries. */
24387
+ const decryptEnvVars = (session, items) => Effect.forEach(items, (item) => Effect.map(decryptEnvVarValue(session, item), (value) => ({
24388
+ key: item.key,
24389
+ value,
24390
+ visibility: item.visibility
24391
+ })), { concurrency: 8 });
24392
+ /**
24393
+ * Export and decrypt every env var for a project + environment, in key order.
24394
+ * Skips unlocking the vault when the project has no variables; otherwise unlocks
24395
+ * once and decrypts every value locally (the server never sees plaintext).
24396
+ */
24397
+ const exportDecryptedEnvVars = (api, projectId, environment) => Effect.gen(function* () {
24398
+ const result = yield* api["env-vars"].export({ urlParams: {
24399
+ projectId,
24400
+ environment
24401
+ } }).pipe(Effect.mapError((cause) => new EnvExportError({ message: `Failed to export environment variables for "${environment}": ${String(cause)}` })));
24402
+ if (result.items.length === 0) return [];
24403
+ return yield* decryptEnvVars(yield* openVaultSessionInteractive(api).pipe(Effect.mapError((cause) => new EnvExportError({ message: `Could not unlock the credential vault to decrypt environment variables: ${cause.message}` }))), result.items);
24404
+ });
24317
24405
  /**
24318
- * Pull environment variables for a project + environment and flatten them into
24319
- * a key/value map. Returns an empty map when the project has no variables.
24406
+ * Pull + decrypt environment variables flattened into a key/value map for
24407
+ * injection into a build/subprocess.
24320
24408
  */
24321
- const pullEnvVars = (api, { projectId, environment }) => {
24409
+ const pullEnvVars = (api, { projectId, environment }) => Effect.gen(function* () {
24322
24410
  const validated = coerceEnvironment(environment);
24323
- if (!validated) return Effect.fail(new EnvExportError({ message: `Invalid environment "${environment}". Must be one of: development, preview, production.` }));
24324
- return api["env-vars"].export({ urlParams: {
24325
- projectId,
24326
- environment: validated
24327
- } }).pipe(Effect.map((result) => Object.fromEntries(result.items.map((item) => [item.key, item.value]))), Effect.mapError((cause) => new EnvExportError({ message: `Failed to export environment variables for "${environment}": ${String(cause)}` })));
24328
- };
24411
+ if (!validated) return yield* new EnvExportError({ message: `Invalid environment "${environment}". Must be one of: development, preview, production.` });
24412
+ const items = yield* exportDecryptedEnvVars(api, projectId, validated);
24413
+ return Object.fromEntries(items.map((item) => [item.key, item.value]));
24414
+ });
24415
+ /**
24416
+ * Seal each entry for every target environment and bulk-upsert them. Unlocks the
24417
+ * vault once; the server stores only the sealed envelopes (never the plaintext).
24418
+ */
24419
+ const uploadEnvVars = (api, params) => Effect.gen(function* () {
24420
+ const session = yield* openVaultSessionInteractive(api);
24421
+ const pairs = params.environments.flatMap((environment) => params.entries.map((entry) => ({
24422
+ ...entry,
24423
+ environment
24424
+ })));
24425
+ const sealed = yield* Effect.forEach(pairs, (pair) => Effect.map(sealForUpload({
24426
+ session,
24427
+ credentialType: "envVarValue",
24428
+ metadata: {
24429
+ key: pair.key,
24430
+ environment: pair.environment
24431
+ },
24432
+ secret: { value: pair.value }
24433
+ }), (envelope) => ({
24434
+ key: pair.key,
24435
+ environment: pair.environment,
24436
+ visibility: pair.visibility,
24437
+ value: {
24438
+ id: envelope.id,
24439
+ ciphertext: envelope.ciphertext,
24440
+ wrappedDek: envelope.wrappedDek,
24441
+ vaultVersion: envelope.vaultVersion
24442
+ }
24443
+ })), { concurrency: 8 });
24444
+ return yield* api["env-vars"].bulkImport({ payload: {
24445
+ scope: params.scope,
24446
+ ...params.projectId === void 0 ? {} : { projectId: params.projectId },
24447
+ entries: sealed
24448
+ } });
24449
+ });
24329
24450
 
24330
24451
  //#endregion
24331
24452
  //#region src/lib/fingerprint.ts
@@ -28211,6 +28332,68 @@ const runCredentialsManager = Effect.gen(function* () {
28211
28332
  yield* Console.log("Bye.");
28212
28333
  });
28213
28334
 
28335
+ //#endregion
28336
+ //#region src/application/vault-rotation.ts
28337
+ /**
28338
+ * Re-key the org vault to `recipients`: generate a new vault key (v+1), unwrap
28339
+ * every credential + env-var DEK with the old key and re-wrap it under the new
28340
+ * one, re-wrap the new vault key to each recipient, then submit the rotation
28341
+ * atomically (the server CAS-guards on the current version and requires a
28342
+ * recovery recipient in the set). Drops every recipient not in `recipients`.
28343
+ */
28344
+ const rotateVaultTo = (args) => Effect.gen(function* () {
28345
+ const orgId = yield* getActiveOrgId(args.api);
28346
+ const current = yield* unlockVaultKey(args.api, args.passphrase);
28347
+ const newVaultKey = generateVaultKey();
28348
+ const newVersion = current.vaultVersion + 1;
28349
+ const { deks } = yield* args.api.orgVault.listCredentialDeks();
28350
+ const credentialDeks = yield* Effect.forEach(deks, (dek) => Effect.try({
28351
+ try: () => {
28352
+ const raw = unwrapDek({
28353
+ wrappedDek: fromBase64(dek.wrappedDek),
28354
+ vaultKey: current.vaultKey,
28355
+ binding: {
28356
+ orgId,
28357
+ credentialId: dek.credentialId,
28358
+ vaultVersion: dek.vaultVersion
28359
+ }
28360
+ });
28361
+ return {
28362
+ credentialType: dek.credentialType,
28363
+ credentialId: dek.credentialId,
28364
+ wrappedDek: toBase64(wrapDek({
28365
+ dek: raw,
28366
+ vaultKey: newVaultKey,
28367
+ binding: {
28368
+ orgId,
28369
+ credentialId: dek.credentialId,
28370
+ vaultVersion: newVersion
28371
+ }
28372
+ }))
28373
+ };
28374
+ },
28375
+ catch: () => new IdentityError({ message: `Failed to re-wrap a ${dek.credentialType} DEK during rotation — re-unlock the vault and retry.` })
28376
+ }), { concurrency: "unbounded" });
28377
+ const recipientWraps = yield* Effect.forEach(args.recipients, (recipient) => Effect.promise(async () => ({
28378
+ userEncryptionKeyId: recipient.userEncryptionKeyId,
28379
+ wrappedKey: toBase64(await wrapVaultKey({
28380
+ vaultKey: newVaultKey,
28381
+ recipient: recipient.publicKey
28382
+ }))
28383
+ })), { concurrency: "unbounded" });
28384
+ return yield* args.api.orgVault.rotate({ payload: {
28385
+ fromVersion: current.vaultVersion,
28386
+ recipientWraps,
28387
+ credentialDeks
28388
+ } });
28389
+ });
28390
+ /** The encryption keys currently holding the vault key, joined with their public keys. */
28391
+ const currentRecipients = (api) => Effect.gen(function* () {
28392
+ const [wraps, keys] = yield* Effect.all([api.orgVault.listWraps(), api.userEncryptionKeys.list()]);
28393
+ const byId = new Map(keys.items.map((key) => [key.id, key]));
28394
+ return wraps.recipients.map((recipient) => byId.get(recipient.userEncryptionKeyId)).filter((key) => key !== void 0);
28395
+ });
28396
+
28214
28397
  //#endregion
28215
28398
  //#region src/commands/credentials/vault-session.ts
28216
28399
  /**
@@ -28245,6 +28428,11 @@ const confirmFingerprint = (target, skip) => Effect.gen(function* () {
28245
28428
 
28246
28429
  //#endregion
28247
28430
  //#region src/commands/credentials/access.ts
28431
+ const RECOVERY_LABEL$1 = "Offline recovery key";
28432
+ const toRotationRecipient = (key) => ({
28433
+ userEncryptionKeyId: key.id,
28434
+ publicKey: key.publicKey
28435
+ });
28248
28436
  const listCommand$5 = defineCommand({
28249
28437
  meta: {
28250
28438
  name: "list",
@@ -28299,14 +28487,153 @@ const grantCommand = defineCommand({
28299
28487
  yield* printHuman(`Granted vault access to ${target.label} (${target.fingerprint}).`);
28300
28488
  }))
28301
28489
  });
28490
+ const confirmRecipients = (recipients, skip) => Effect.forEach(recipients, (recipient) => confirmFingerprint(recipient, skip), { discard: true });
28491
+ const rotateCommand = defineCommand({
28492
+ meta: {
28493
+ name: "rotate",
28494
+ description: "Rotate the vault key, re-wrapping every credential to the same recipients (admin)"
28495
+ },
28496
+ args: { yes: {
28497
+ type: "boolean",
28498
+ description: "Skip the out-of-band fingerprint confirmation prompt"
28499
+ } },
28500
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
28501
+ const api = yield* apiClient;
28502
+ const recipients = yield* currentRecipients(api);
28503
+ yield* confirmRecipients(recipients, args.yes === true);
28504
+ const rotated = yield* rotateVaultTo({
28505
+ api,
28506
+ passphrase: yield* resolveVaultPassphrase,
28507
+ recipients: recipients.map(toRotationRecipient)
28508
+ });
28509
+ yield* printHuman(`Rotated the vault to version ${String(rotated.vaultVersion)} (${String(recipients.length)} recipients).`);
28510
+ }))
28511
+ });
28512
+ const revokeCommand$1 = defineCommand({
28513
+ meta: {
28514
+ name: "revoke",
28515
+ description: "Revoke a recipient and rotate the vault key so they can no longer decrypt (admin)"
28516
+ },
28517
+ args: {
28518
+ recipient: {
28519
+ type: "positional",
28520
+ required: false,
28521
+ description: "Key id or fingerprint of the recipient to revoke"
28522
+ },
28523
+ yes: {
28524
+ type: "boolean",
28525
+ description: "Skip the out-of-band fingerprint confirmation prompt"
28526
+ }
28527
+ },
28528
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
28529
+ const api = yield* apiClient;
28530
+ const target = yield* findRecipient(api, yield* resolveSelector(args.recipient, "Recipient key id or fingerprint to revoke:"));
28531
+ const recipients = yield* currentRecipients(api);
28532
+ const surviving = recipients.filter((recipient) => recipient.id !== target.id);
28533
+ if (surviving.length === recipients.length) return yield* new IdentityError({ message: `${target.label} (${target.fingerprint}) is not a current vault recipient.` });
28534
+ if (!surviving.some((recipient) => recipient.kind === "recovery")) return yield* new IdentityError({ message: "Refusing to revoke the offline recovery recipient — rotate it with `credentials access recovery rotate` instead." });
28535
+ yield* confirmRecipients(surviving, args.yes === true);
28536
+ const rotated = yield* rotateVaultTo({
28537
+ api,
28538
+ passphrase: yield* resolveVaultPassphrase,
28539
+ recipients: surviving.map(toRotationRecipient)
28540
+ });
28541
+ yield* printHuman(`Revoked ${target.label} and rotated the vault to version ${String(rotated.vaultVersion)}.`);
28542
+ }))
28543
+ });
28544
+ const recoverCommand = defineCommand({
28545
+ meta: {
28546
+ name: "recover",
28547
+ description: "Restore this device's vault access with the offline recovery private key"
28548
+ },
28549
+ args: { key: {
28550
+ type: "string",
28551
+ description: "The offline recovery private key (AGE-SECRET-KEY-1...); prompted if omitted"
28552
+ } },
28553
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
28554
+ const api = yield* apiClient;
28555
+ const recoveryPrivateKey = yield* resolveSelector(args.key, "Paste the offline recovery private key (AGE-SECRET-KEY-1...):");
28556
+ const { items } = yield* api.userEncryptionKeys.list();
28557
+ const recovery = items.find((key) => key.kind === "recovery" && key.revokedAt === null);
28558
+ if (!recovery) return yield* new IdentityError({ message: "This organization has no active recovery recipient to recover from." });
28559
+ const recipient = yield* activeRecipient;
28560
+ const own = items.find((key) => key.publicKey === recipient.publicKey);
28561
+ if (!own) return yield* new IdentityError({ message: "This device's encryption key is not registered. Run `better-update credentials identity register` first." });
28562
+ const wrap = yield* api.orgVault.getWrap({ path: { keyId: recovery.id } });
28563
+ const vaultKey = yield* Effect.tryPromise({
28564
+ try: async () => unwrapVaultKey({
28565
+ wrapped: fromBase64(wrap.wrappedKey),
28566
+ privateKey: recoveryPrivateKey
28567
+ }),
28568
+ catch: () => new IdentityError({ message: "Could not unwrap the vault key — the recovery private key is wrong." })
28569
+ });
28570
+ const wrapped = yield* Effect.promise(async () => wrapVaultKey({
28571
+ vaultKey,
28572
+ recipient: own.publicKey
28573
+ }));
28574
+ yield* api.orgVault.addWrap({ payload: {
28575
+ vaultVersion: wrap.vaultVersion,
28576
+ wrap: {
28577
+ userEncryptionKeyId: own.id,
28578
+ wrappedKey: toBase64(wrapped)
28579
+ }
28580
+ } });
28581
+ yield* printHuman(`Recovered vault access for this device (${own.label}).`);
28582
+ }))
28583
+ });
28584
+ const recoveryCommand = defineCommand({
28585
+ meta: {
28586
+ name: "recovery",
28587
+ description: "Manage the offline recovery recipient"
28588
+ },
28589
+ subCommands: { rotate: defineCommand({
28590
+ meta: {
28591
+ name: "rotate",
28592
+ description: "Mint a new offline recovery key and rotate the vault, revoking the old one (admin)"
28593
+ },
28594
+ args: { yes: {
28595
+ type: "boolean",
28596
+ description: "Skip the out-of-band fingerprint confirmation prompt"
28597
+ } },
28598
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
28599
+ const api = yield* apiClient;
28600
+ const recipients = yield* currentRecipients(api);
28601
+ const newRecovery = yield* Effect.promise(async () => generateIdentity());
28602
+ const registered = yield* api.userEncryptionKeys.register({ payload: {
28603
+ kind: "recovery",
28604
+ publicKey: newRecovery.publicKey,
28605
+ label: RECOVERY_LABEL$1,
28606
+ fingerprint: newRecovery.fingerprint
28607
+ } });
28608
+ const surviving = recipients.filter((recipient) => recipient.kind !== "recovery");
28609
+ yield* confirmRecipients(surviving, args.yes === true);
28610
+ const rotated = yield* rotateVaultTo({
28611
+ api,
28612
+ passphrase: yield* resolveVaultPassphrase,
28613
+ recipients: [...surviving.map(toRotationRecipient), {
28614
+ userEncryptionKeyId: registered.id,
28615
+ publicKey: newRecovery.publicKey
28616
+ }]
28617
+ });
28618
+ yield* printKeyValue([["New recovery fingerprint", newRecovery.fingerprint], ["Vault version", String(rotated.vaultVersion)]]);
28619
+ yield* printHuman("Store this offline recovery private key safely — it is shown once and never again:");
28620
+ yield* Console.log(newRecovery.privateKey);
28621
+ }))
28622
+ }) },
28623
+ default: "rotate"
28624
+ });
28302
28625
  const accessCommand = defineCommand({
28303
28626
  meta: {
28304
28627
  name: "access",
28305
- description: "Inspect and grant access to the org credential vault"
28628
+ description: "Inspect, grant, rotate, revoke, and recover access to the org credential vault"
28306
28629
  },
28307
28630
  subCommands: {
28308
28631
  list: listCommand$5,
28309
- grant: grantCommand
28632
+ grant: grantCommand,
28633
+ rotate: rotateCommand,
28634
+ revoke: revokeCommand$1,
28635
+ recover: recoverCommand,
28636
+ recovery: recoveryCommand
28310
28637
  },
28311
28638
  default: "list"
28312
28639
  });
@@ -29242,7 +29569,8 @@ const createCommand$1 = defineCommand({
29242
29569
  run: async ({ args }) => runEffect(Effect.gen(function* () {
29243
29570
  const label = yield* resolveLabel(args.label);
29244
29571
  const identity = yield* createLocalIdentity(yield* promptNewPassphrase);
29245
- yield* printRecipient(yield* registerRecipient(yield* apiClient, {
29572
+ const api = yield* apiClient;
29573
+ yield* printRecipient(yield* registerRecipient(api, {
29246
29574
  kind: "device",
29247
29575
  publicKey: identity.publicKey,
29248
29576
  fingerprint: identity.fingerprint,
@@ -29250,7 +29578,7 @@ const createCommand$1 = defineCommand({
29250
29578
  }));
29251
29579
  yield* printHuman("");
29252
29580
  yield* printHuman("Sealed at ~/.better-update/identity.json — the private key never leaves this machine.");
29253
- yield* printHuman("Before you can read or upload credentials, this device needs vault access — granted by an org admin, or self-linked from another device that already has it.");
29581
+ yield* printHuman(yield* orgVaultExists(api).pipe(Effect.map((exists) => exists ? VAULT_NOT_RECIPIENT_GUIDANCE : VAULT_NOT_SET_UP_GUIDANCE), Effect.orElseSucceed(() => VAULT_NOT_RECIPIENT_GUIDANCE)));
29254
29582
  }))
29255
29583
  });
29256
29584
  const registerCommand = defineCommand({
@@ -30969,29 +31297,70 @@ const parseSingleEnvironmentArg = (raw) => Effect.gen(function* () {
30969
31297
  return raw;
30970
31298
  });
30971
31299
  const formatEnvironments = (environments) => [...environments].toSorted((left, right) => left.localeCompare(right)).join(",");
31300
+ const DOTENV_LINE = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$/u;
31301
+ const stripQuotes = (raw) => {
31302
+ if (raw.length < 2) return raw;
31303
+ const [first] = raw;
31304
+ const last = raw.at(-1);
31305
+ return first === "\"" && last === "\"" || first === "'" && last === "'" ? raw.slice(1, -1) : raw;
31306
+ };
31307
+ const parseDotenvLine = (rawLine) => {
31308
+ const line = rawLine.trim();
31309
+ if (line === "" || line.startsWith("#")) return;
31310
+ const match = DOTENV_LINE.exec(line);
31311
+ if (!match) return;
31312
+ const [, key, rawValue] = match;
31313
+ if (key === void 0 || rawValue === void 0) return;
31314
+ return {
31315
+ key,
31316
+ value: stripQuotes(rawValue)
31317
+ };
31318
+ };
31319
+ /** Parse a dotenv file's `KEY=VALUE` lines (comments + blanks skipped, quotes stripped). */
31320
+ const parseDotenv = (content) => content.split(/\r?\n/u).map(parseDotenvLine).filter((entry) => entry !== void 0);
31321
+ /** Resolve a single project env var by (key, environment), or fail NotFound. */
31322
+ const findProjectEnvVar = (api, projectId, key, environment) => Effect.gen(function* () {
31323
+ const { items } = yield* api["env-vars"].list({ urlParams: {
31324
+ projectId,
31325
+ scope: "project",
31326
+ environments: environment
31327
+ } });
31328
+ const match = items.find((item) => item.key === key && item.environment === environment);
31329
+ if (!match) return yield* new EnvResourceNotFoundError({ message: `Env var "${key}" not found for environment "${environment}".` });
31330
+ return match;
31331
+ });
30972
31332
 
30973
31333
  //#endregion
30974
31334
  //#region src/commands/env/delete.ts
30975
31335
  const deleteCommand$2 = defineCommand({
30976
31336
  meta: {
30977
31337
  name: "delete",
30978
- description: "Delete a project env var by key"
31338
+ description: "Delete a project env var (one environment, or every environment by default)"
31339
+ },
31340
+ args: {
31341
+ key: {
31342
+ type: "positional",
31343
+ required: true,
31344
+ description: "Env var key"
31345
+ },
31346
+ environment: {
31347
+ type: "string",
31348
+ description: "Only delete this environment (default: every environment for the key)"
31349
+ }
30979
31350
  },
30980
- args: { key: {
30981
- type: "positional",
30982
- required: true,
30983
- description: "Env var key"
30984
- } },
30985
31351
  run: async ({ args }) => runEffect(Effect.gen(function* () {
31352
+ const environment = args.environment === void 0 ? void 0 : yield* parseSingleEnvironmentArg(args.environment);
30986
31353
  const projectId = yield* readProjectId;
30987
31354
  const api = yield* apiClient;
30988
- const match = (yield* api["env-vars"].list({ urlParams: {
31355
+ const { items } = yield* api["env-vars"].list({ urlParams: {
30989
31356
  projectId,
30990
- scope: "project"
30991
- } })).items.find((item) => item.key === args.key);
30992
- if (!match) return yield* new EnvResourceNotFoundError({ message: `Project env var "${args.key}" not found.` });
30993
- yield* api["env-vars"].delete({ path: { id: match.id } });
30994
- yield* Console.log(`Deleted ${args.key}`);
31357
+ scope: "project",
31358
+ ...environment ? { environments: environment } : {}
31359
+ } });
31360
+ const matches = items.filter((item) => item.key === args.key && (environment === void 0 || item.environment === environment));
31361
+ if (matches.length === 0) return yield* new EnvResourceNotFoundError({ message: `Project env var "${args.key}" not found${environment ? ` for environment "${environment}"` : ""}.` });
31362
+ yield* Effect.forEach(matches, (match) => api["env-vars"].delete({ path: { id: match.id } }), { concurrency: 4 });
31363
+ yield* Console.log(`Deleted ${args.key} (${String(matches.length)} environment${matches.length === 1 ? "" : "s"})`);
30995
31364
  }), envErrorExtras)
30996
31365
  });
30997
31366
 
@@ -31021,10 +31390,10 @@ const getExecTrailingArgv = () => trailing$1;
31021
31390
 
31022
31391
  //#endregion
31023
31392
  //#region src/commands/env/exec.ts
31024
- const pullForExec = (api, projectId, environment) => api["env-vars"].export({ urlParams: {
31393
+ const pullForExec = (api, projectId, environment) => pullEnvVars(api, {
31025
31394
  projectId,
31026
31395
  environment
31027
- } }).pipe(Effect.map((result) => Object.fromEntries(result.items.map((item) => [item.key, item.value]))), Effect.catchAll(() => Effect.succeed({})));
31396
+ }).pipe(Effect.catchAll(() => Effect.succeed({})));
31028
31397
  const splitTrailing = (trailing) => {
31029
31398
  if (!trailing || trailing.length === 0) return Effect.fail(new InvalidArgumentError({ message: "Pass the command after `--`. Example: `better-update env exec production -- bun run dev`." }));
31030
31399
  const [bin, ...rest] = trailing;
@@ -31073,11 +31442,8 @@ const exportCommand = defineCommand({
31073
31442
  run: async ({ args }) => runEffect(Effect.gen(function* () {
31074
31443
  const environment = yield* parseSingleEnvironmentArg(args.environment);
31075
31444
  const projectId = yield* readProjectId;
31076
- const result = yield* (yield* apiClient)["env-vars"].export({ urlParams: {
31077
- projectId,
31078
- environment
31079
- } });
31080
- for (const item of result.items) {
31445
+ const items = yield* exportDecryptedEnvVars(yield* apiClient, projectId, environment);
31446
+ for (const item of items) {
31081
31447
  const escaped = item.value.replaceAll("'", String.raw`'\''`);
31082
31448
  yield* Console.log(`${item.key}='${escaped}'`);
31083
31449
  }
@@ -31086,43 +31452,21 @@ const exportCommand = defineCommand({
31086
31452
 
31087
31453
  //#endregion
31088
31454
  //#region src/commands/env/get.ts
31089
- const resolveByKey = (api, key, environment) => Effect.gen(function* () {
31090
- const env = environment === void 0 ? void 0 : yield* parseSingleEnvironmentArg(environment);
31091
- const urlParams = {
31092
- projectId: yield* readProjectId,
31093
- scope: "all",
31094
- search: key,
31095
- ...compact({ environments: env })
31096
- };
31097
- const { items } = yield* api["env-vars"].list({ urlParams });
31098
- const matches = items.filter((item) => item.key === key);
31099
- if (matches.length === 0) return yield* new EnvResourceNotFoundError({ message: `No env var with key "${key}" found${env === void 0 ? "" : ` for environment "${env}"`}.` });
31100
- if (matches.length > 1) return yield* new EnvResourceNotFoundError({ message: `Multiple env vars match key "${key}". Disambiguate with --environment <${[...new Set(matches.flatMap((entry) => entry.environments))].join(", ")}>.` });
31101
- return matches[0];
31102
- });
31103
- const renderValue$1 = (envVar, includeSensitive) => {
31104
- if (envVar.visibility === "plaintext") return envVar.value ?? "";
31105
- if (includeSensitive) return envVar.value ?? "";
31106
- return "******";
31107
- };
31108
31455
  const getCommand$1 = defineCommand({
31109
31456
  meta: {
31110
31457
  name: "get",
31111
- description: "Show an environment variable by KEY (or --by-id)"
31458
+ description: "Show an environment variable's effective value for an environment (decrypted locally)"
31112
31459
  },
31113
31460
  args: {
31114
31461
  key: {
31115
31462
  type: "positional",
31116
31463
  required: true,
31117
- description: "Env var KEY (uppercase) — or ID when used with --by-id"
31464
+ description: "Env var KEY (uppercase)"
31118
31465
  },
31119
31466
  environment: {
31120
31467
  type: "string",
31121
- description: "Filter by environment when looking up by KEY"
31122
- },
31123
- "by-id": {
31124
- type: "boolean",
31125
- description: "Treat the argument as an ID instead of KEY"
31468
+ default: "production",
31469
+ description: "Target environment (development, preview, production)"
31126
31470
  },
31127
31471
  "include-sensitive": {
31128
31472
  type: "boolean",
@@ -31130,21 +31474,62 @@ const getCommand$1 = defineCommand({
31130
31474
  }
31131
31475
  },
31132
31476
  run: async ({ args }) => runEffect(Effect.gen(function* () {
31133
- const api = yield* apiClient;
31134
- const envVar = args["by-id"] ? yield* api["env-vars"].get({ path: { id: args.key } }) : yield* resolveByKey(api, args.key, args.environment);
31477
+ const environment = yield* parseSingleEnvironmentArg(args.environment);
31478
+ const projectId = yield* readProjectId;
31479
+ const match = (yield* exportDecryptedEnvVars(yield* apiClient, projectId, environment)).find((item) => item.key === args.key);
31480
+ if (!match) return yield* new EnvResourceNotFoundError({ message: `No env var "${args.key}" found for environment "${environment}".` });
31481
+ const includeSensitive = args["include-sensitive"] ?? false;
31482
+ const value = match.visibility === "sensitive" && !includeSensitive ? "******" : match.value;
31135
31483
  yield* printKeyValue([
31136
- ["ID", envVar.id],
31137
- ["Key", envVar.key],
31138
- ["Scope", envVar.scope],
31139
- ["Environments", formatEnvironments(envVar.environments)],
31140
- ["Visibility", envVar.visibility],
31141
- ["Value", renderValue$1(envVar, args["include-sensitive"] ?? false)],
31142
- ["Created", envVar.createdAt],
31143
- ["Updated", envVar.updatedAt]
31484
+ ["Key", match.key],
31485
+ ["Environment", environment],
31486
+ ["Visibility", match.visibility],
31487
+ ["Value", value]
31144
31488
  ]);
31145
31489
  }), envErrorExtras)
31146
31490
  });
31147
31491
 
31492
+ //#endregion
31493
+ //#region src/commands/env/history.ts
31494
+ const historyCommand = defineCommand({
31495
+ meta: {
31496
+ name: "history",
31497
+ description: "Show a project env var's value revision history (metadata only)"
31498
+ },
31499
+ args: {
31500
+ key: {
31501
+ type: "positional",
31502
+ required: true,
31503
+ description: "Env var key"
31504
+ },
31505
+ environment: {
31506
+ type: "string",
31507
+ default: "production",
31508
+ description: "Target environment (development, preview, production)"
31509
+ }
31510
+ },
31511
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
31512
+ const environment = yield* parseSingleEnvironmentArg(args.environment);
31513
+ const projectId = yield* readProjectId;
31514
+ const api = yield* apiClient;
31515
+ const match = yield* findProjectEnvVar(api, projectId, args.key, environment);
31516
+ const { items } = yield* api["env-vars"].revisions({ path: { id: match.id } });
31517
+ yield* printList([
31518
+ "Revision",
31519
+ "Active",
31520
+ "Vault",
31521
+ "Created",
31522
+ "By"
31523
+ ], items.map((revision) => [
31524
+ String(revision.revisionNumber),
31525
+ revision.isCurrent ? "current" : "",
31526
+ String(revision.vaultVersion),
31527
+ revision.createdAt,
31528
+ revision.createdBy ?? "-"
31529
+ ]), "No revisions found.");
31530
+ }), envErrorExtras)
31531
+ });
31532
+
31148
31533
  //#endregion
31149
31534
  //#region src/commands/env/import.ts
31150
31535
  const importCommand = defineCommand({
@@ -31171,31 +31556,33 @@ const importCommand = defineCommand({
31171
31556
  }
31172
31557
  },
31173
31558
  run: async ({ args }) => runEffect(Effect.gen(function* () {
31174
- const content = yield* (yield* FileSystem.FileSystem).readFileString(args.file);
31559
+ const entries = parseDotenv(yield* (yield* FileSystem.FileSystem).readFileString(args.file)).map((entry) => ({
31560
+ key: entry.key,
31561
+ value: entry.value,
31562
+ visibility: args.visibility
31563
+ }));
31564
+ if (entries.length === 0) {
31565
+ yield* Console.log(`No valid KEY=VALUE entries found in ${args.file}.`);
31566
+ return;
31567
+ }
31175
31568
  const environments = yield* parseEnvironmentsArg(args.environment);
31176
31569
  const projectId = yield* readProjectId;
31177
- const result = yield* (yield* apiClient)["env-vars"].bulkImport({ payload: {
31570
+ const result = yield* uploadEnvVars(yield* apiClient, {
31178
31571
  scope: "project",
31179
31572
  projectId,
31180
31573
  environments,
31181
- content,
31182
- visibility: args.visibility
31183
- } });
31574
+ entries
31575
+ });
31184
31576
  yield* Console.log(`Imported: ${String(result.created)} created, ${String(result.updated)} updated, ${String(result.skipped)} skipped`);
31185
31577
  }), envErrorExtras)
31186
31578
  });
31187
31579
 
31188
31580
  //#endregion
31189
31581
  //#region src/commands/env/list.ts
31190
- const renderValue = (item, includeSensitive) => {
31191
- if (item.visibility === "plaintext") return item.value ?? "";
31192
- if (includeSensitive) return item.value ?? "";
31193
- return "••••••";
31194
- };
31195
31582
  const listCommand$2 = defineCommand({
31196
31583
  meta: {
31197
31584
  name: "list",
31198
- description: "List environment variables"
31585
+ description: "List environment variable metadata. Values are end-to-end encrypted — read them with `env pull`, `env export`, or `env get`."
31199
31586
  },
31200
31587
  args: {
31201
31588
  environments: {
@@ -31214,10 +31601,6 @@ const listCommand$2 = defineCommand({
31214
31601
  search: {
31215
31602
  type: "string",
31216
31603
  description: "Filter by key substring (case-insensitive)"
31217
- },
31218
- "include-sensitive": {
31219
- type: "boolean",
31220
- description: "Reveal masked sensitive values (default: masked)"
31221
31604
  }
31222
31605
  },
31223
31606
  run: async ({ args }) => runEffect(Effect.gen(function* () {
@@ -31229,20 +31612,18 @@ const listCommand$2 = defineCommand({
31229
31612
  ...args.environments ? { environments: args.environments } : {},
31230
31613
  ...args.search ? { search: args.search } : {}
31231
31614
  };
31232
- const result = yield* api["env-vars"].list({ urlParams });
31233
- const includeSensitive = args["include-sensitive"] ?? false;
31234
31615
  yield* printList([
31235
31616
  "Key",
31236
- "Environments",
31617
+ "Environment",
31237
31618
  "Scope",
31238
31619
  "Visibility",
31239
- "Value"
31240
- ], result.items.map((item) => [
31620
+ "Revisions"
31621
+ ], (yield* api["env-vars"].list({ urlParams })).items.map((item) => [
31241
31622
  item.key,
31242
- formatEnvironments(item.environments),
31623
+ item.environment,
31243
31624
  item.overridesGlobal ? `${item.scope} (overrides global)` : item.scope,
31244
31625
  item.visibility,
31245
- renderValue(item, includeSensitive)
31626
+ String(item.revisionCount)
31246
31627
  ]), "No environment variables found.");
31247
31628
  }), envErrorExtras)
31248
31629
  });
@@ -31293,18 +31674,15 @@ const pullCommand = defineCommand({
31293
31674
  run: async ({ args }) => runEffect(Effect.gen(function* () {
31294
31675
  const environment = yield* parseSingleEnvironmentArg(args.environment);
31295
31676
  const projectId = yield* readProjectId;
31296
- const result = yield* (yield* apiClient)["env-vars"].export({ urlParams: {
31297
- projectId,
31298
- environment
31299
- } });
31677
+ const items = yield* exportDecryptedEnvVars(yield* apiClient, projectId, environment);
31300
31678
  if (args.stdout) {
31301
- yield* printStdout(result.items);
31679
+ yield* printStdout(items);
31302
31680
  return;
31303
31681
  }
31304
31682
  const cwd = yield* (yield* CliRuntime).cwd;
31305
31683
  yield* writeDotenvFile({
31306
31684
  targetPath: path.resolve(cwd, args.path ?? DEFAULT_PATH),
31307
- items: result.items,
31685
+ items,
31308
31686
  force: args.force ?? false
31309
31687
  });
31310
31688
  }), envErrorExtras)
@@ -31312,32 +31690,11 @@ const pullCommand = defineCommand({
31312
31690
 
31313
31691
  //#endregion
31314
31692
  //#region src/commands/env/push.ts
31315
- const LINE_PATTERN = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$/u;
31316
- const stripQuotes = (raw) => {
31317
- if (raw.length < 2) return raw;
31318
- const [first] = raw;
31319
- const last = raw.at(-1);
31320
- return first === "\"" && last === "\"" || first === "'" && last === "'" ? raw.slice(1, -1) : raw;
31321
- };
31322
31693
  const classifyVisibility = (key) => key.startsWith("EXPO_PUBLIC_") ? "plaintext" : "sensitive";
31323
- const parseLine = (rawLine) => {
31324
- const line = rawLine.trim();
31325
- if (line === "" || line.startsWith("#")) return;
31326
- const match = LINE_PATTERN.exec(line);
31327
- if (!match) return;
31328
- const [, key, rawValue] = match;
31329
- if (key === void 0 || rawValue === void 0) return;
31330
- return {
31331
- key,
31332
- value: stripQuotes(rawValue),
31333
- visibility: classifyVisibility(key)
31334
- };
31335
- };
31336
- const parseDotenv = (content) => content.split(/\r?\n/u).map(parseLine).filter((entry) => entry !== void 0);
31337
31694
  const pushCommand = defineCommand({
31338
31695
  meta: {
31339
31696
  name: "push",
31340
- description: "Push env vars from a dotenv file. Auto-classifies EXPO_PUBLIC_* as plaintext, others as sensitive."
31697
+ description: "Push (encrypt + upsert) env vars from a dotenv file. Auto-classifies EXPO_PUBLIC_* as plaintext, others as sensitive."
31341
31698
  },
31342
31699
  args: {
31343
31700
  file: {
@@ -31350,10 +31707,6 @@ const pushCommand = defineCommand({
31350
31707
  type: "string",
31351
31708
  default: "production",
31352
31709
  description: "Target environments (comma-separated, e.g. development,production). Default: production"
31353
- },
31354
- force: {
31355
- type: "boolean",
31356
- description: "Overwrite existing vars without prompting"
31357
31710
  }
31358
31711
  },
31359
31712
  run: async ({ args }) => runEffect(Effect.gen(function* () {
@@ -31364,49 +31717,57 @@ const pushCommand = defineCommand({
31364
31717
  }
31365
31718
  const environments = yield* parseEnvironmentsArg(args.environment);
31366
31719
  const projectId = yield* readProjectId;
31367
- const api = yield* apiClient;
31368
- const existingResp = yield* api["env-vars"].list({ urlParams: {
31369
- projectId,
31370
- scope: "project"
31371
- } });
31372
- const existingByKey = new Map(existingResp.items.map((item) => [item.key, item]));
31373
- const conflicts = parsed.filter((entry) => existingByKey.has(entry.key));
31374
- const newEntries = parsed.filter((entry) => !existingByKey.has(entry.key));
31375
- const entriesToOverwrite = yield* Effect.gen(function* () {
31376
- if (conflicts.length === 0 || args.force) return conflicts;
31377
- if (!(yield* InteractiveMode).allow) {
31378
- const conflictKeys = conflicts.map((conflict) => conflict.key).join(", ");
31379
- return yield* new InvalidArgumentError({ message: `${String(conflicts.length)} conflict(s): ${conflictKeys}. Pass --force to overwrite or run interactively.` });
31380
- }
31381
- const picked = yield* promptMultiSelect("Overwrite which existing vars?", conflicts.map((entry) => ({
31382
- value: entry.key,
31383
- label: `${entry.key} (${existingByKey.get(entry.key)?.visibility ?? "?"} → ${entry.visibility})`
31384
- })));
31385
- const pickedSet = new Set(picked);
31386
- return conflicts.filter((entry) => pickedSet.has(entry.key));
31387
- });
31388
- const skipped = conflicts.length - entriesToOverwrite.length;
31389
- yield* Effect.forEach(newEntries, (entry) => api["env-vars"].create({ payload: {
31720
+ const result = yield* uploadEnvVars(yield* apiClient, {
31390
31721
  scope: "project",
31391
31722
  projectId,
31392
31723
  environments,
31393
- key: entry.key,
31394
- value: entry.value,
31395
- visibility: entry.visibility
31396
- } }), { concurrency: 4 });
31397
- yield* Effect.forEach(entriesToOverwrite, (entry) => {
31398
- const existing = existingByKey.get(entry.key);
31399
- if (!existing) return Effect.succeed(void 0);
31400
- return api["env-vars"].update({
31401
- path: { id: existing.id },
31402
- payload: {
31403
- value: entry.value,
31404
- visibility: entry.visibility,
31405
- environments
31406
- }
31407
- });
31408
- }, { concurrency: 4 });
31409
- yield* printHuman(`Pushed to ${formatEnvironments(environments)}: ${String(newEntries.length)} created, ${String(entriesToOverwrite.length)} updated${skipped > 0 ? `, ${String(skipped)} skipped` : ""}.`);
31724
+ entries: parsed.map((entry) => ({
31725
+ key: entry.key,
31726
+ value: entry.value,
31727
+ visibility: classifyVisibility(entry.key)
31728
+ }))
31729
+ });
31730
+ yield* printHuman(`Pushed to ${formatEnvironments(environments)}: ${String(result.created)} created, ${String(result.updated)} updated${result.skipped > 0 ? `, ${String(result.skipped)} skipped` : ""}.`);
31731
+ }), envErrorExtras)
31732
+ });
31733
+
31734
+ //#endregion
31735
+ //#region src/commands/env/rollback.ts
31736
+ const rollbackCommand$1 = defineCommand({
31737
+ meta: {
31738
+ name: "rollback",
31739
+ description: "Roll a project env var back to an earlier value revision"
31740
+ },
31741
+ args: {
31742
+ key: {
31743
+ type: "positional",
31744
+ required: true,
31745
+ description: "Env var key"
31746
+ },
31747
+ to: {
31748
+ type: "string",
31749
+ required: true,
31750
+ description: "Target revision number (from `env history`) or revision id"
31751
+ },
31752
+ environment: {
31753
+ type: "string",
31754
+ default: "production",
31755
+ description: "Target environment (development, preview, production)"
31756
+ }
31757
+ },
31758
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
31759
+ const environment = yield* parseSingleEnvironmentArg(args.environment);
31760
+ const projectId = yield* readProjectId;
31761
+ const api = yield* apiClient;
31762
+ const match = yield* findProjectEnvVar(api, projectId, args.key, environment);
31763
+ const { items } = yield* api["env-vars"].revisions({ path: { id: match.id } });
31764
+ const target = items.find((revision) => revision.id === args.to || String(revision.revisionNumber) === args.to);
31765
+ if (!target) return yield* new InvalidArgumentError({ message: `Revision "${args.to}" not found for ${args.key} (${environment}). See \`env history\`.` });
31766
+ yield* api["env-vars"].rollback({
31767
+ path: { id: match.id },
31768
+ payload: { toRevisionId: target.id }
31769
+ });
31770
+ yield* Console.log(`Rolled back ${args.key} (${environment}) to revision ${String(target.revisionNumber)}.`);
31410
31771
  }), envErrorExtras)
31411
31772
  });
31412
31773
 
@@ -31432,7 +31793,7 @@ const setCommand$1 = defineCommand({
31432
31793
  type: "enum",
31433
31794
  options: ["plaintext", "sensitive"],
31434
31795
  default: "plaintext",
31435
- description: "Value visibility"
31796
+ description: "Value visibility (build-log redaction hint)"
31436
31797
  }
31437
31798
  },
31438
31799
  run: async ({ args }) => runEffect(Effect.gen(function* () {
@@ -31440,33 +31801,18 @@ const setCommand$1 = defineCommand({
31440
31801
  const environments = yield* parseEnvironmentsArg(args.environment);
31441
31802
  const { visibility } = args;
31442
31803
  const projectId = yield* readProjectId;
31443
- const api = yield* apiClient;
31444
- const match = (yield* api["env-vars"].list({ urlParams: {
31804
+ const result = yield* uploadEnvVars(yield* apiClient, {
31805
+ scope: "project",
31445
31806
  projectId,
31446
- scope: "project"
31447
- } })).items.find((item) => item.key === key);
31448
- const label = formatEnvironments(environments);
31449
- if (match) {
31450
- yield* api["env-vars"].update({
31451
- path: { id: match.id },
31452
- payload: {
31453
- value,
31454
- visibility,
31455
- environments
31456
- }
31457
- });
31458
- yield* Console.log(`Updated ${key} (environments: ${label})`);
31459
- } else {
31460
- yield* api["env-vars"].create({ payload: {
31461
- scope: "project",
31462
- projectId,
31463
- environments,
31807
+ environments,
31808
+ entries: [{
31464
31809
  key,
31465
31810
  value,
31466
31811
  visibility
31467
- } });
31468
- yield* Console.log(`Created ${key} (environments: ${label})`);
31469
- }
31812
+ }]
31813
+ });
31814
+ const label = formatEnvironments(environments);
31815
+ yield* Console.log(`Set ${key} (environments: ${label}; ${result.created} created, ${result.updated} updated)`);
31470
31816
  }), envErrorExtras)
31471
31817
  });
31472
31818
 
@@ -31475,7 +31821,7 @@ const setCommand$1 = defineCommand({
31475
31821
  const updateCommand$1 = defineCommand({
31476
31822
  meta: {
31477
31823
  name: "update",
31478
- description: "Update a project env var's value, visibility, or environments"
31824
+ description: "Update a project env var's value or visibility for an environment"
31479
31825
  },
31480
31826
  args: {
31481
31827
  key: {
@@ -31483,6 +31829,11 @@ const updateCommand$1 = defineCommand({
31483
31829
  required: true,
31484
31830
  description: "Env var key (e.g. API_KEY)"
31485
31831
  },
31832
+ environment: {
31833
+ type: "string",
31834
+ default: "production",
31835
+ description: "Target environment (development, preview, production)"
31836
+ },
31486
31837
  value: {
31487
31838
  type: "string",
31488
31839
  description: "New value (leave unset to keep current)"
@@ -31491,37 +31842,52 @@ const updateCommand$1 = defineCommand({
31491
31842
  type: "enum",
31492
31843
  options: ["plaintext", "sensitive"],
31493
31844
  description: "New visibility (leave unset to keep current)"
31494
- },
31495
- environments: {
31496
- type: "string",
31497
- description: "New environments assignment (comma-separated, e.g. development,production). Leave unset to keep current."
31498
31845
  }
31499
31846
  },
31500
31847
  run: async ({ args }) => runEffect(Effect.gen(function* () {
31501
- const { key, value, visibility, environments } = args;
31502
- if (value === void 0 && visibility === void 0 && environments === void 0) return yield* new InvalidArgumentError({ message: "Pass --value, --visibility, --environments (or any combination). Nothing to update otherwise." });
31848
+ const { key, value, visibility } = args;
31849
+ if (value === void 0 && visibility === void 0) return yield* new InvalidArgumentError({ message: "Pass --value and/or --visibility. Nothing to update otherwise." });
31850
+ const environment = yield* parseSingleEnvironmentArg(args.environment);
31503
31851
  const projectId = yield* readProjectId;
31504
31852
  const api = yield* apiClient;
31505
- const match = (yield* api["env-vars"].list({ urlParams: {
31853
+ const { items } = yield* api["env-vars"].list({ urlParams: {
31506
31854
  projectId,
31507
- scope: "project"
31508
- } })).items.find((item) => item.key === key);
31509
- if (!match) return yield* new EnvResourceNotFoundError({ message: `Env var "${key}" not found in project.` });
31510
- const envList = environments ? yield* parseEnvironmentsArg(environments) : void 0;
31511
- const payload = compact({
31512
- value,
31513
- visibility,
31514
- environments: envList
31515
- });
31516
- yield* api["env-vars"].update({
31855
+ scope: "project",
31856
+ environments: environment
31857
+ } });
31858
+ const match = items.find((item) => item.key === key && item.environment === environment);
31859
+ if (!match) return yield* new EnvResourceNotFoundError({ message: `Env var "${key}" not found for environment "${environment}".` });
31860
+ if (value === void 0) yield* api["env-vars"].update({
31517
31861
  path: { id: match.id },
31518
- payload
31862
+ payload: compact({ visibility })
31519
31863
  });
31864
+ else {
31865
+ const envelope = yield* sealForUpload({
31866
+ session: yield* openVaultSessionInteractive(api),
31867
+ credentialType: "envVarValue",
31868
+ metadata: {
31869
+ key,
31870
+ environment
31871
+ },
31872
+ secret: { value }
31873
+ });
31874
+ yield* api["env-vars"].update({
31875
+ path: { id: match.id },
31876
+ payload: {
31877
+ value: {
31878
+ id: envelope.id,
31879
+ ciphertext: envelope.ciphertext,
31880
+ wrappedDek: envelope.wrappedDek,
31881
+ vaultVersion: envelope.vaultVersion
31882
+ },
31883
+ ...compact({ visibility })
31884
+ }
31885
+ });
31886
+ }
31520
31887
  const changed = [];
31521
31888
  if (value !== void 0) changed.push("value");
31522
31889
  if (visibility !== void 0) changed.push("visibility");
31523
- if (envList) changed.push("environments");
31524
- yield* printHuman(`Updated ${changed.join(" + ")} for ${key}.`);
31890
+ yield* printHuman(`Updated ${changed.join(" + ")} for ${key} (${environment}).`);
31525
31891
  }), envErrorExtras)
31526
31892
  });
31527
31893
 
@@ -31538,6 +31904,8 @@ const envCommand = defineCommand({
31538
31904
  set: setCommand$1,
31539
31905
  update: updateCommand$1,
31540
31906
  delete: deleteCommand$2,
31907
+ history: historyCommand,
31908
+ rollback: rollbackCommand$1,
31541
31909
  import: importCommand,
31542
31910
  push: pushCommand,
31543
31911
  export: exportCommand,