@psg2/env-sync 1.0.2 → 1.1.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.
Files changed (3) hide show
  1. package/README.md +7 -3
  2. package/dist/cli.js +76 -78
  3. package/package.json +5 -2
package/README.md CHANGED
@@ -78,10 +78,12 @@ targets:
78
78
 
79
79
  #### Vercel
80
80
 
81
- Pushes vars to Vercel environment(s) via the Vercel REST API. Backs up current env vars before overwriting.
81
+ Pushes vars to Vercel environment(s) via the Vercel REST API. Before overwriting, the current variables of each environment are backed up to `.env-sync-backups/vercel-<env>.<timestamp>.env`, in `KEY="value"` format. Sensitive variables can't be read back through the API (nor by `vercel env pull`), so the backup records only their key and type as a comment.
82
82
 
83
83
  Values resolved from `op://` references are stored as **Sensitive** variables (write-only on Vercel; the value can never be read back). Literal values stay as regular readable variables. Re-running the sync converts existing variables to the right type.
84
84
 
85
+ With `redeploy: true`, the latest READY deployment of the environment is redeployed through the API — the same effect as `vercel redeploy`. Skipped for `development`, which has no deployments.
86
+
85
87
  ```yaml
86
88
  targets:
87
89
  vercel-prod:
@@ -92,6 +94,8 @@ targets:
92
94
  redeploy: true # Optional (default: false)
93
95
  ```
94
96
 
97
+ **Authentication:** the token is read from `VERCEL_TOKEN` first, then from the Vercel CLI auth store written by `vercel login` (e.g. `~/Library/Application Support/com.vercel.cli/auth.json` on macOS, `~/.local/share/com.vercel.cli/auth.json` on Linux, `%APPDATA%/com.vercel.cli/auth.json` on Windows). The project and team ids come from `.vercel/project.json`, created by `vercel link` (or written by hand with `projectId` and `orgId`). The `vercel` CLI itself is optional at runtime — it's only needed once, to produce the token and the linked project file.
98
+
95
99
  #### GitHub
96
100
 
97
101
  Pushes vars as GitHub repository secrets via the GitHub CLI.
@@ -125,10 +129,10 @@ Options:
125
129
  | Feature | Requires |
126
130
  |---------|----------|
127
131
  | 1Password secrets | [`op` CLI](https://developer.1password.com/docs/cli) + `op signin` |
128
- | Vercel targets | [`vercel` CLI](https://vercel.com/docs/cli) |
132
+ | Vercel targets | `VERCEL_TOKEN` env var, or `vercel login` (CLI optional) |
129
133
  | GitHub targets | [`gh` CLI](https://cli.github.com) |
130
134
 
131
- The CLI checks for required tools before syncing and gives clear error messages.
135
+ The CLI checks for `op` and `gh` before syncing and gives clear error messages; Vercel credentials are validated when the target runs.
132
136
 
133
137
  ## Examples
134
138
 
package/dist/cli.js CHANGED
@@ -7284,9 +7284,13 @@ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as rea
7284
7284
  import { homedir } from "os";
7285
7285
  import { resolve as resolve3 } from "path";
7286
7286
  function getVercelAuthToken() {
7287
+ const fromEnv = process.env.VERCEL_TOKEN;
7288
+ if (fromEnv)
7289
+ return fromEnv;
7290
+ const home = process.env.HOME || homedir();
7287
7291
  const candidates = [
7288
- `${homedir()}/Library/Application Support/com.vercel.cli/auth.json`,
7289
- `${homedir()}/.local/share/com.vercel.cli/auth.json`,
7292
+ `${home}/Library/Application Support/com.vercel.cli/auth.json`,
7293
+ `${home}/.local/share/com.vercel.cli/auth.json`,
7290
7294
  process.env.XDG_CONFIG_HOME ? `${process.env.XDG_CONFIG_HOME}/com.vercel.cli/auth.json` : undefined,
7291
7295
  process.env.APPDATA ? `${process.env.APPDATA}/com.vercel.cli/auth.json` : undefined
7292
7296
  ].filter(Boolean);
@@ -7299,7 +7303,7 @@ function getVercelAuthToken() {
7299
7303
  return raw.token;
7300
7304
  } catch {}
7301
7305
  }
7302
- throw new Error("Vercel auth token not found in any of the standard CLI store paths. Run `vercel login` first.");
7306
+ throw new Error("Vercel token not found. Set VERCEL_TOKEN, or run `vercel login` so the token is available in the CLI auth store.");
7303
7307
  }
7304
7308
  function getProjectInfo(configDir, override) {
7305
7309
  const path = resolve3(configDir, ".vercel/project.json");
@@ -7312,10 +7316,10 @@ function getProjectInfo(configDir, override) {
7312
7316
  }
7313
7317
  return { projectId: data.projectId, teamId: data.orgId };
7314
7318
  }
7315
- async function vercelApi(method, endpoint, teamId, token, body) {
7316
- const url = `https://api.vercel.com${endpoint}${endpoint.includes("?") ? "&" : "?"}teamId=${encodeURIComponent(teamId)}`;
7319
+ async function vercelApi(client, method, endpoint, body) {
7320
+ const url = `https://api.vercel.com${endpoint}${endpoint.includes("?") ? "&" : "?"}teamId=${encodeURIComponent(client.teamId)}`;
7317
7321
  const headers = {
7318
- Authorization: `Bearer ${token}`
7322
+ Authorization: `Bearer ${client.token}`
7319
7323
  };
7320
7324
  if (body !== undefined)
7321
7325
  headers["Content-Type"] = "application/json";
@@ -7333,16 +7337,16 @@ async function vercelApi(method, endpoint, teamId, token, body) {
7333
7337
  }
7334
7338
  return { ok: res.ok, status: res.status, body: parsed };
7335
7339
  }
7336
- async function listExistingEnvVars(projectId, teamId, token) {
7337
- const res = await vercelApi("GET", `/v9/projects/${projectId}/env`, teamId, token);
7340
+ async function listExistingEnvVars(client) {
7341
+ const res = await vercelApi(client, "GET", `/v9/projects/${client.projectId}/env?decrypt=true`);
7338
7342
  if (!res.ok) {
7339
7343
  throw new Error(`Failed to list env vars: ${res.status} ${JSON.stringify(res.body)}`);
7340
7344
  }
7341
7345
  const body = res.body;
7342
7346
  return body.envs ?? [];
7343
7347
  }
7344
- async function deleteEnvVar(projectId, teamId, token, envId) {
7345
- const res = await vercelApi("DELETE", `/v9/projects/${projectId}/env/${envId}`, teamId, token);
7348
+ async function deleteEnvVar(client, envId) {
7349
+ const res = await vercelApi(client, "DELETE", `/v9/projects/${client.projectId}/env/${envId}`);
7346
7350
  if (!res.ok) {
7347
7351
  throw new Error(`Delete env var ${envId} failed: ${res.status} ${JSON.stringify(res.body)}`);
7348
7352
  }
@@ -7350,8 +7354,8 @@ async function deleteEnvVar(projectId, teamId, token, envId) {
7350
7354
  function vercelEnvType(v) {
7351
7355
  return v.source.startsWith("op://") ? "sensitive" : "encrypted";
7352
7356
  }
7353
- async function createEnvVar(projectId, teamId, token, payload) {
7354
- const res = await vercelApi("POST", `/v10/projects/${projectId}/env`, teamId, token, {
7357
+ async function createEnvVar(client, payload) {
7358
+ const res = await vercelApi(client, "POST", `/v10/projects/${client.projectId}/env`, {
7355
7359
  key: payload.key,
7356
7360
  value: payload.value,
7357
7361
  target: payload.target,
@@ -7360,6 +7364,11 @@ async function createEnvVar(projectId, teamId, token, payload) {
7360
7364
  if (!res.ok) {
7361
7365
  throw new Error(`Create env var ${payload.key} failed: ${res.status} ${JSON.stringify(res.body)}`);
7362
7366
  }
7367
+ const { failed } = res.body;
7368
+ if (failed && failed.length > 0) {
7369
+ const reason = failed[0].error.message ?? failed[0].error.code ?? JSON.stringify(failed[0]);
7370
+ throw new Error(`Create env var ${payload.key} failed: ${reason}`);
7371
+ }
7363
7372
  }
7364
7373
  async function syncVercel(name, target, vars, configDir, opts) {
7365
7374
  const errors = [];
@@ -7368,37 +7377,34 @@ async function syncVercel(name, target, vars, configDir, opts) {
7368
7377
  console.log(` Would back up and push ${vars.length} vars to Vercel [${envLabel}]`);
7369
7378
  return { target: name, type: "vercel", vars: vars.length, errors };
7370
7379
  }
7371
- let token;
7372
- let projectId;
7373
- let teamId;
7380
+ let client;
7374
7381
  try {
7375
- token = getVercelAuthToken();
7382
+ const token = getVercelAuthToken();
7376
7383
  const info = getProjectInfo(configDir, target.project);
7377
- projectId = info.projectId;
7378
- teamId = info.teamId;
7384
+ client = { token, projectId: info.projectId, teamId: info.teamId };
7379
7385
  } catch (err) {
7380
7386
  errors.push(` \u2717 Vercel API setup: ${err instanceof Error ? err.message : err}`);
7381
7387
  return { target: name, type: "vercel", vars: vars.length, errors };
7382
7388
  }
7383
- for (const env of target.environments) {
7384
- await backupVercelEnv(env, target.project, configDir);
7385
- }
7386
7389
  let existing;
7387
7390
  try {
7388
- existing = await listExistingEnvVars(projectId, teamId, token);
7391
+ existing = await listExistingEnvVars(client);
7389
7392
  } catch (err) {
7390
7393
  errors.push(` \u2717 List env failed: ${err instanceof Error ? err.message : err}`);
7391
7394
  return { target: name, type: "vercel", vars: vars.length, errors };
7392
7395
  }
7396
+ for (const env of target.environments) {
7397
+ backupVercelEnv(env, existing, configDir);
7398
+ }
7393
7399
  for (const v of vars) {
7394
7400
  for (const env of target.environments) {
7395
7401
  try {
7396
7402
  const conflict = existing.find((e) => e.key === v.key && e.target.includes(env) && !e.gitBranch);
7397
7403
  if (conflict) {
7398
- await deleteEnvVar(projectId, teamId, token, conflict.id);
7404
+ await deleteEnvVar(client, conflict.id);
7399
7405
  existing = existing.filter((e) => e.id !== conflict.id);
7400
7406
  }
7401
- await createEnvVar(projectId, teamId, token, {
7407
+ await createEnvVar(client, {
7402
7408
  key: v.key,
7403
7409
  value: v.value,
7404
7410
  target: [env],
@@ -7418,82 +7424,77 @@ async function syncVercel(name, target, vars, configDir, opts) {
7418
7424
  console.log(` Done: ${succeeded}/${vars.length * target.environments.length} vars pushed to Vercel [${envLabel}]`);
7419
7425
  if (target.redeploy && errors.length === 0) {
7420
7426
  for (const env of target.environments) {
7421
- await redeployVercel(env, target.project, errors);
7427
+ await redeployVercel(client, env, errors);
7422
7428
  }
7423
7429
  }
7424
7430
  return { target: name, type: "vercel", vars: vars.length, errors };
7425
7431
  }
7426
- async function redeployVercel(env, project, errors) {
7432
+ async function redeployVercel(client, env, errors) {
7433
+ if (env === "development") {
7434
+ console.log(" Skipping redeploy [development]: environment has no deployments");
7435
+ return;
7436
+ }
7427
7437
  console.log(` Triggering redeploy [${env}]...`);
7428
- const listArgs = ["list", "--environment", env, "--format", "json", "--yes"];
7429
- if (project)
7430
- listArgs.push("--project", project);
7431
7438
  try {
7432
- const listProc = Bun.spawn(["vercel", ...listArgs], {
7433
- stdout: "pipe",
7434
- stderr: "pipe"
7439
+ const query = new URLSearchParams({
7440
+ projectId: client.projectId,
7441
+ target: env,
7442
+ state: "READY",
7443
+ limit: "1"
7435
7444
  });
7436
- const listStdout = await new Response(listProc.stdout).text();
7437
- const listExit = await listProc.exited;
7438
- if (listExit !== 0) {
7439
- const stderr = await new Response(listProc.stderr).text();
7440
- errors.push(` \u2717 Could not list deployments [${env}]: ${stderr.trim()}`);
7445
+ const listRes = await vercelApi(client, "GET", `/v6/deployments?${query}`);
7446
+ if (!listRes.ok) {
7447
+ errors.push(` \u2717 Could not list deployments [${env}]: ${listRes.status} ${JSON.stringify(listRes.body)}`);
7441
7448
  return;
7442
7449
  }
7443
- const jsonStart = listStdout.indexOf("{");
7444
- if (jsonStart === -1) {
7450
+ const { deployments } = listRes.body;
7451
+ const latest = deployments?.[0];
7452
+ if (!latest) {
7445
7453
  errors.push(` \u2717 No deployments found for [${env}]`);
7446
7454
  return;
7447
7455
  }
7448
- const data = JSON.parse(listStdout.slice(jsonStart));
7449
- const deployments = data.deployments;
7450
- if (!deployments || deployments.length === 0) {
7451
- errors.push(` \u2717 No deployments found for [${env}]`);
7452
- return;
7453
- }
7454
- const latestUrl = deployments[0].url;
7455
- const redeployArgs = ["redeploy", latestUrl, "--no-wait", "--yes"];
7456
- if (project)
7457
- redeployArgs.push("--project", project);
7458
- const redeployProc = Bun.spawn(["vercel", ...redeployArgs], {
7459
- stdout: "pipe",
7460
- stderr: "pipe"
7456
+ const redeployRes = await vercelApi(client, "POST", "/v13/deployments?forceNew=1", {
7457
+ deploymentId: latest.uid,
7458
+ name: latest.name,
7459
+ target: env === "production" ? "production" : undefined,
7460
+ meta: { action: "redeploy" }
7461
7461
  });
7462
- const redeployStderr = await new Response(redeployProc.stderr).text();
7463
- const redeployExit = await redeployProc.exited;
7464
- if (redeployExit !== 0) {
7465
- errors.push(` \u2717 Redeploy failed [${env}]: ${redeployStderr.trim()}`);
7466
- } else {
7467
- console.log(` \u2713 Redeploy triggered [${env}]`);
7462
+ if (!redeployRes.ok) {
7463
+ errors.push(` \u2717 Redeploy failed [${env}]: ${redeployRes.status} ${JSON.stringify(redeployRes.body)}`);
7464
+ return;
7468
7465
  }
7466
+ const created = redeployRes.body;
7467
+ console.log(` \u2713 Redeploy triggered [${env}]${created.url ? ` \u2192 https://${created.url}` : ""}`);
7469
7468
  } catch (err) {
7470
- errors.push(` \u2717 Redeploy failed [${env}]: ${err}`);
7469
+ errors.push(` \u2717 Redeploy failed [${env}]: ${err instanceof Error ? err.message : err}`);
7471
7470
  }
7472
7471
  }
7473
- async function backupVercelEnv(env, project, configDir) {
7472
+ function escapeEnvValue(value) {
7473
+ return value.replace(/\n/g, "\\n").replace(/\r/g, "\\r");
7474
+ }
7475
+ function backupVercelEnv(env, existing, configDir) {
7474
7476
  const backupDir = resolve3(configDir, ".env-sync-backups");
7475
7477
  if (!existsSync3(backupDir)) {
7476
7478
  mkdirSync2(backupDir, { recursive: true });
7477
7479
  }
7478
7480
  const timestamp = new Date().toISOString().replace(/[:.]/g, "").replace("T", "-").slice(0, 15);
7479
7481
  const backupFile = resolve3(backupDir, `vercel-${env}.${timestamp}.env`);
7480
- const args = ["env", "pull", backupFile, "--environment", env, "--yes"];
7481
- if (project)
7482
- args.push("--project", project);
7483
- try {
7484
- const proc = Bun.spawn(["vercel", ...args], {
7485
- stdout: "pipe",
7486
- stderr: "pipe"
7487
- });
7488
- const exitCode = await proc.exited;
7489
- if (exitCode === 0) {
7490
- console.log(` Backed up Vercel [${env}] \u2192 .env-sync-backups/vercel-${env}.${timestamp}.env`);
7482
+ const entries = existing.filter((e) => e.target.includes(env) && !e.gitBranch).sort((a, b) => a.key.localeCompare(b.key));
7483
+ const lines = [`# Created by env-sync \u2014 Vercel [${env}] backup`];
7484
+ for (const e of entries) {
7485
+ if (e.value === undefined || e.type === "sensitive") {
7486
+ lines.push(`# ${e.key} (${e.type ?? "unknown"}: value not readable via API)`);
7491
7487
  } else {
7492
- const stderr = await new Response(proc.stderr).text();
7493
- console.warn(` \u26A0 Could not back up Vercel [${env}]: ${stderr.trim()}`);
7488
+ lines.push(`${e.key}="${escapeEnvValue(e.value)}"`);
7494
7489
  }
7495
- } catch {
7496
- console.warn(` \u26A0 Could not back up Vercel [${env}]`);
7490
+ }
7491
+ try {
7492
+ writeFileSync2(backupFile, `${lines.join(`
7493
+ `)}
7494
+ `, { encoding: "utf-8", mode: 384 });
7495
+ console.log(` Backed up Vercel [${env}] \u2192 .env-sync-backups/vercel-${env}.${timestamp}.env`);
7496
+ } catch (err) {
7497
+ console.warn(` \u26A0 Could not back up Vercel [${env}]: ${err instanceof Error ? err.message : err}`);
7497
7498
  }
7498
7499
  }
7499
7500
 
@@ -7692,12 +7693,9 @@ async function checkPrerequisites(config, targetFilter) {
7692
7693
  }
7693
7694
  return false;
7694
7695
  });
7695
- const needsVercel = targets.some((t) => t.type === "vercel");
7696
7696
  const needsGh = targets.some((t) => t.type === "github");
7697
7697
  if (needsOp)
7698
7698
  await assertCommand("op", "1Password CLI (https://developer.1password.com/docs/cli)");
7699
- if (needsVercel)
7700
- await assertCommand("vercel", "Vercel CLI (npm i -g vercel)");
7701
7699
  if (needsGh)
7702
7700
  await assertCommand("gh", "GitHub CLI (https://cli.github.com)");
7703
7701
  if (needsOp) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@psg2/env-sync",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Declarative env var management — 1Password → local files, Vercel, GitHub secrets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,8 @@
12
12
  "lint": "bunx --bun biome check src/",
13
13
  "lint:fix": "bunx --bun biome check --write src/",
14
14
  "format": "bunx --bun biome format --write src/",
15
+ "format:check": "bunx --bun biome format src/",
16
+ "typecheck": "tsc --noEmit",
15
17
  "test": "bun test",
16
18
  "prepublishOnly": "bun run build"
17
19
  },
@@ -47,6 +49,7 @@
47
49
  },
48
50
  "devDependencies": {
49
51
  "@biomejs/biome": "^2.4.7",
50
- "@types/bun": "^1.2.4"
52
+ "@types/bun": "^1.2.4",
53
+ "typescript": "^5"
51
54
  }
52
55
  }