@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.
- package/README.md +7 -3
- package/dist/cli.js +76 -78
- 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.
|
|
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 |
|
|
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
|
|
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
|
-
`${
|
|
7289
|
-
`${
|
|
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
|
|
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,
|
|
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(
|
|
7337
|
-
const res = await vercelApi("GET", `/v9/projects/${projectId}/env
|
|
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(
|
|
7345
|
-
const res = await vercelApi("DELETE", `/v9/projects/${projectId}/env/${envId}
|
|
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(
|
|
7354
|
-
const res = await vercelApi("POST", `/v10/projects/${projectId}/env`,
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
7404
|
+
await deleteEnvVar(client, conflict.id);
|
|
7399
7405
|
existing = existing.filter((e) => e.id !== conflict.id);
|
|
7400
7406
|
}
|
|
7401
|
-
await createEnvVar(
|
|
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(
|
|
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(
|
|
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
|
|
7433
|
-
|
|
7434
|
-
|
|
7439
|
+
const query = new URLSearchParams({
|
|
7440
|
+
projectId: client.projectId,
|
|
7441
|
+
target: env,
|
|
7442
|
+
state: "READY",
|
|
7443
|
+
limit: "1"
|
|
7435
7444
|
});
|
|
7436
|
-
const
|
|
7437
|
-
|
|
7438
|
-
|
|
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
|
|
7444
|
-
|
|
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
|
|
7449
|
-
|
|
7450
|
-
|
|
7451
|
-
|
|
7452
|
-
|
|
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
|
-
|
|
7463
|
-
|
|
7464
|
-
|
|
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
|
-
|
|
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
|
|
7481
|
-
|
|
7482
|
-
|
|
7483
|
-
|
|
7484
|
-
|
|
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
|
-
|
|
7493
|
-
console.warn(` \u26A0 Could not back up Vercel [${env}]: ${stderr.trim()}`);
|
|
7488
|
+
lines.push(`${e.key}="${escapeEnvValue(e.value)}"`);
|
|
7494
7489
|
}
|
|
7495
|
-
}
|
|
7496
|
-
|
|
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
|
|
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
|
}
|