cfenv-kv-sync 0.1.0-beta.1
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 +63 -0
- package/dist/index.js +664 -0
- package/dist/lib/cloudflare-api.js +252 -0
- package/dist/lib/encryption.js +93 -0
- package/dist/lib/env-file.js +60 -0
- package/dist/lib/fs-utils.js +22 -0
- package/dist/lib/hash.js +17 -0
- package/dist/lib/kv-keys.js +21 -0
- package/dist/lib/local-config.js +180 -0
- package/dist/lib/paths.js +21 -0
- package/dist/lib/profiles.js +86 -0
- package/dist/lib/wrangler-auth.js +159 -0
- package/dist/sdk/hot-update.js +161 -0
- package/dist/sdk/index.js +1 -0
- package/dist/types.js +1 -0
- package/package.json +40 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { CloudflareApiClient } from "./lib/cloudflare-api.js";
|
|
7
|
+
import { decryptSnapshotPayload, encryptSnapshotPayload, generateEncryptionSecret, isEncryptedSnapshotPayload } from "./lib/encryption.js";
|
|
8
|
+
import { exists } from "./lib/fs-utils.js";
|
|
9
|
+
import { parseEnvFile, serializeEnvFile, writeEnvFileAtomic, writeTextFileAtomic } from "./lib/env-file.js";
|
|
10
|
+
import { checksumEntries, makeVersionId } from "./lib/hash.js";
|
|
11
|
+
import { currentPointerKey, flatEnvMetaKey, flatEnvVarKey, flatEnvVarsPrefix, versionKey, versionsPrefix } from "./lib/kv-keys.js";
|
|
12
|
+
import { listLocalLinks, loadLocalConfig, requireLocalConfig, setDefaultLocalLink, upsertLocalLink } from "./lib/local-config.js";
|
|
13
|
+
import { getProfile, listProfileNames, upsertProfile } from "./lib/profiles.js";
|
|
14
|
+
import { getWranglerAccessToken, getWranglerAccountId } from "./lib/wrangler-auth.js";
|
|
15
|
+
const VERSION = "0.1.0";
|
|
16
|
+
const MAX_KV_VALUE_SIZE_BYTES = 25 * 1024 * 1024;
|
|
17
|
+
function nowIso() {
|
|
18
|
+
return new Date().toISOString();
|
|
19
|
+
}
|
|
20
|
+
function resolveUpdatedBy(input) {
|
|
21
|
+
if (input?.trim()) {
|
|
22
|
+
return input.trim();
|
|
23
|
+
}
|
|
24
|
+
const user = os.userInfo().username;
|
|
25
|
+
const host = os.hostname();
|
|
26
|
+
return `${user}@${host}`;
|
|
27
|
+
}
|
|
28
|
+
function resolveEncryptionSecret(secretOption) {
|
|
29
|
+
return secretOption ?? process.env.CFENV_ENCRYPTION_KEY;
|
|
30
|
+
}
|
|
31
|
+
function parseStorageMode(raw, defaultValue) {
|
|
32
|
+
const value = (raw ?? defaultValue).trim().toLowerCase();
|
|
33
|
+
if (value === "flat" || value === "snapshot") {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`Invalid storage mode "${raw}". Use "flat" or "snapshot".`);
|
|
37
|
+
}
|
|
38
|
+
function parseExportFormat(raw) {
|
|
39
|
+
const value = (raw ?? "dotenv").trim().toLowerCase();
|
|
40
|
+
if (value === "dotenv" || value === "json") {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`Invalid export format "${raw}". Use "dotenv" or "json".`);
|
|
44
|
+
}
|
|
45
|
+
async function resolveCloudflareAuth(input) {
|
|
46
|
+
const fromWrangler = Boolean(input.fromWrangler);
|
|
47
|
+
const accountId = input.accountId ?? (fromWrangler ? await getWranglerAccountId() : undefined);
|
|
48
|
+
if (!accountId) {
|
|
49
|
+
throw new Error("Missing account ID. Pass --account-id or use Wrangler auth.");
|
|
50
|
+
}
|
|
51
|
+
const apiToken = fromWrangler
|
|
52
|
+
? await getWranglerAccessToken()
|
|
53
|
+
: input.apiToken ?? process.env.CLOUDFLARE_API_TOKEN;
|
|
54
|
+
if (!apiToken) {
|
|
55
|
+
throw new Error("Missing API token. Pass --api-token, set CLOUDFLARE_API_TOKEN, or use Wrangler auth.");
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
accountId,
|
|
59
|
+
apiToken,
|
|
60
|
+
authSource: fromWrangler ? "wrangler" : "api-token"
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function validateCloudflareAuth(input) {
|
|
64
|
+
if (input.authSource === "api-token") {
|
|
65
|
+
const tokenStatus = await input.client.verifyToken();
|
|
66
|
+
if (tokenStatus.status.toLowerCase() !== "active") {
|
|
67
|
+
throw new Error(`Cloudflare token is not active (status: ${tokenStatus.status}).`);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// Wrangler OAuth tokens are valid for API calls but are not compatible with /user/tokens/verify.
|
|
72
|
+
await input.client.listNamespaces(1);
|
|
73
|
+
}
|
|
74
|
+
function resolveOperationMode(link, modeOverride) {
|
|
75
|
+
const defaultMode = parseStorageMode(link.storageMode, "flat");
|
|
76
|
+
return parseStorageMode(modeOverride, defaultMode);
|
|
77
|
+
}
|
|
78
|
+
async function pushFlatEntries(input) {
|
|
79
|
+
const prefix = flatEnvVarsPrefix(input.link);
|
|
80
|
+
const existingKeys = await input.client.listKeys(input.link.namespaceId, prefix);
|
|
81
|
+
const existingVarNames = new Set(existingKeys
|
|
82
|
+
.map((item) => item.name)
|
|
83
|
+
.filter((name) => name.startsWith(prefix))
|
|
84
|
+
.map((name) => name.slice(prefix.length))
|
|
85
|
+
.filter(Boolean));
|
|
86
|
+
const nextVarNames = new Set(Object.keys(input.entries));
|
|
87
|
+
for (const [envVarName, envVarValue] of Object.entries(input.entries)) {
|
|
88
|
+
await input.client.putValue(input.link.namespaceId, flatEnvVarKey(input.link, envVarName), envVarValue);
|
|
89
|
+
}
|
|
90
|
+
for (const envVarName of existingVarNames) {
|
|
91
|
+
if (!nextVarNames.has(envVarName)) {
|
|
92
|
+
await input.client.deleteValue(input.link.namespaceId, flatEnvVarKey(input.link, envVarName));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const metadata = {
|
|
96
|
+
schema: 1,
|
|
97
|
+
mode: "flat",
|
|
98
|
+
checksum: input.checksum,
|
|
99
|
+
updatedAt: input.updatedAt,
|
|
100
|
+
updatedBy: input.updatedBy,
|
|
101
|
+
entriesCount: Object.keys(input.entries).length
|
|
102
|
+
};
|
|
103
|
+
await input.client.putValue(input.link.namespaceId, flatEnvMetaKey(input.link), JSON.stringify(metadata));
|
|
104
|
+
// Cleanup legacy snapshot-mode keys for the same project/environment to keep KV layout simple.
|
|
105
|
+
const snapshotPointer = currentPointerKey(input.link);
|
|
106
|
+
await input.client.deleteValue(input.link.namespaceId, snapshotPointer).catch(() => undefined);
|
|
107
|
+
const snapshotVersionKeys = await input.client.listKeys(input.link.namespaceId, versionsPrefix(input.link));
|
|
108
|
+
for (const item of snapshotVersionKeys) {
|
|
109
|
+
await input.client.deleteValue(input.link.namespaceId, item.name);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function pullFlatEntries(input) {
|
|
113
|
+
const prefix = flatEnvVarsPrefix(input.link);
|
|
114
|
+
const keys = await input.client.listKeys(input.link.namespaceId, prefix);
|
|
115
|
+
const envVarKeys = keys
|
|
116
|
+
.map((item) => item.name)
|
|
117
|
+
.filter((name) => name.startsWith(prefix))
|
|
118
|
+
.sort((a, b) => a.localeCompare(b));
|
|
119
|
+
if (!envVarKeys.length) {
|
|
120
|
+
throw new Error("No env variables found in KV for flat storage mode.");
|
|
121
|
+
}
|
|
122
|
+
const entries = {};
|
|
123
|
+
for (const fullKey of envVarKeys) {
|
|
124
|
+
const envVarName = fullKey.slice(prefix.length);
|
|
125
|
+
const envVarValue = await input.client.getValue(input.link.namespaceId, fullKey);
|
|
126
|
+
if (envVarValue !== null) {
|
|
127
|
+
entries[envVarName] = envVarValue;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!Object.keys(entries).length) {
|
|
131
|
+
throw new Error("No env variable values found in KV for flat storage mode.");
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
entries,
|
|
135
|
+
encrypted: false
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
async function pushSnapshotEntries(input) {
|
|
139
|
+
if (input.encrypt && !input.encryptionSecret?.trim()) {
|
|
140
|
+
throw new Error("Missing encryption secret. Pass --encryption-key or set CFENV_ENCRYPTION_KEY. Use --no-encrypt to bypass.");
|
|
141
|
+
}
|
|
142
|
+
const versionId = makeVersionId();
|
|
143
|
+
const snapshot = {
|
|
144
|
+
schema: 1,
|
|
145
|
+
versionId,
|
|
146
|
+
project: input.link.project,
|
|
147
|
+
environment: input.link.environment,
|
|
148
|
+
checksum: input.checksum,
|
|
149
|
+
updatedAt: input.updatedAt,
|
|
150
|
+
updatedBy: input.updatedBy,
|
|
151
|
+
entries: input.entries
|
|
152
|
+
};
|
|
153
|
+
const pointer = {
|
|
154
|
+
schema: 1,
|
|
155
|
+
versionId,
|
|
156
|
+
checksum: input.checksum,
|
|
157
|
+
updatedAt: input.updatedAt,
|
|
158
|
+
updatedBy: input.updatedBy,
|
|
159
|
+
entriesCount: Object.keys(input.entries).length,
|
|
160
|
+
encrypted: input.encrypt
|
|
161
|
+
};
|
|
162
|
+
const snapshotText = JSON.stringify(snapshot);
|
|
163
|
+
const storagePayload = input.encrypt ? encryptSnapshotPayload(snapshotText, input.encryptionSecret ?? "") : snapshotText;
|
|
164
|
+
if (Buffer.byteLength(storagePayload, "utf8") > MAX_KV_VALUE_SIZE_BYTES) {
|
|
165
|
+
throw new Error("Snapshot exceeds Cloudflare KV 25 MiB value limit.");
|
|
166
|
+
}
|
|
167
|
+
await input.client.putValue(input.link.namespaceId, versionKey(input.link, versionId), storagePayload);
|
|
168
|
+
await input.client.putValue(input.link.namespaceId, currentPointerKey(input.link), JSON.stringify(pointer));
|
|
169
|
+
return { versionId };
|
|
170
|
+
}
|
|
171
|
+
async function pullSnapshotEntries(input) {
|
|
172
|
+
let versionId = input.versionId;
|
|
173
|
+
if (!versionId) {
|
|
174
|
+
const currentRaw = await input.client.getValue(input.link.namespaceId, currentPointerKey(input.link));
|
|
175
|
+
if (!currentRaw) {
|
|
176
|
+
throw new Error("No current pointer found in KV. Push once first.");
|
|
177
|
+
}
|
|
178
|
+
const pointer = JSON.parse(currentRaw);
|
|
179
|
+
versionId = pointer.versionId;
|
|
180
|
+
}
|
|
181
|
+
const snapshotRaw = await input.client.getValue(input.link.namespaceId, versionKey(input.link, versionId));
|
|
182
|
+
if (!snapshotRaw) {
|
|
183
|
+
throw new Error(`Snapshot version "${versionId}" not found.`);
|
|
184
|
+
}
|
|
185
|
+
const encrypted = isEncryptedSnapshotPayload(snapshotRaw);
|
|
186
|
+
const snapshotPayload = decryptSnapshotPayload(snapshotRaw, input.encryptionSecret);
|
|
187
|
+
const snapshot = JSON.parse(snapshotPayload);
|
|
188
|
+
const computedChecksum = checksumEntries(snapshot.entries);
|
|
189
|
+
if (computedChecksum !== snapshot.checksum) {
|
|
190
|
+
throw new Error("Snapshot checksum mismatch. Refusing to write potentially corrupted env file.");
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
entries: snapshot.entries,
|
|
194
|
+
versionId: snapshot.versionId,
|
|
195
|
+
encrypted,
|
|
196
|
+
project: snapshot.project,
|
|
197
|
+
environment: snapshot.environment
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function unwrapError(error) {
|
|
201
|
+
if (error instanceof Error) {
|
|
202
|
+
return error.message;
|
|
203
|
+
}
|
|
204
|
+
return String(error);
|
|
205
|
+
}
|
|
206
|
+
function runAction(fn) {
|
|
207
|
+
return async (options) => {
|
|
208
|
+
try {
|
|
209
|
+
await fn(options);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
console.error(`Error: ${unwrapError(error)}`);
|
|
213
|
+
process.exitCode = 1;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
async function getApiClient(profileName) {
|
|
218
|
+
const profile = await getProfile(profileName);
|
|
219
|
+
const authSource = profile.authSource ?? "api-token";
|
|
220
|
+
const apiToken = authSource === "wrangler"
|
|
221
|
+
? await getWranglerAccessToken()
|
|
222
|
+
: profile.apiToken ?? process.env.CLOUDFLARE_API_TOKEN;
|
|
223
|
+
if (!apiToken) {
|
|
224
|
+
throw new Error(`Profile "${profile.name}" has no API token configured.`);
|
|
225
|
+
}
|
|
226
|
+
const client = new CloudflareApiClient({
|
|
227
|
+
accountId: profile.accountId,
|
|
228
|
+
apiToken
|
|
229
|
+
});
|
|
230
|
+
return { profile, client };
|
|
231
|
+
}
|
|
232
|
+
const program = new Command();
|
|
233
|
+
program
|
|
234
|
+
.name("cfenv")
|
|
235
|
+
.description("Cloudflare KV-backed environment sync tool")
|
|
236
|
+
.version(VERSION);
|
|
237
|
+
program
|
|
238
|
+
.command("keygen")
|
|
239
|
+
.description("Generate a strong CFENV_ENCRYPTION_KEY value")
|
|
240
|
+
.option("--length <bytes>", "Random bytes before base64url encoding", "32")
|
|
241
|
+
.option("--raw", "Print only the key value", false)
|
|
242
|
+
.action(runAction(async (options) => {
|
|
243
|
+
const byteLength = Number(options.length);
|
|
244
|
+
if (!Number.isInteger(byteLength)) {
|
|
245
|
+
throw new Error("--length must be an integer.");
|
|
246
|
+
}
|
|
247
|
+
const secret = generateEncryptionSecret(byteLength);
|
|
248
|
+
if (options.raw) {
|
|
249
|
+
console.log(secret);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
console.log(`export CFENV_ENCRYPTION_KEY='${secret}'`);
|
|
253
|
+
}));
|
|
254
|
+
program
|
|
255
|
+
.command("setup")
|
|
256
|
+
.description("One-step setup: auth profile + namespace + local project link")
|
|
257
|
+
.requiredOption("--project <name>", "Project name")
|
|
258
|
+
.requiredOption("--env <name>", "Environment name (development, preview, production)")
|
|
259
|
+
.option("--profile <name>", "Profile name", "default")
|
|
260
|
+
.option("--key-prefix <prefix>", "KV key prefix", "cfenv")
|
|
261
|
+
.option("--mode <mode>", "Storage mode: flat or snapshot", "flat")
|
|
262
|
+
.option("--namespace-id <id>", "Existing Cloudflare KV namespace ID")
|
|
263
|
+
.option("--namespace-name <name>", "KV namespace title (auto-create if missing)")
|
|
264
|
+
.option("--account-id <id>", "Cloudflare account ID")
|
|
265
|
+
.option("--api-token <token>", "Cloudflare API token")
|
|
266
|
+
.option("--no-from-wrangler", "Use API token/CLOUDFLARE_API_TOKEN instead of Wrangler auth")
|
|
267
|
+
.option("--no-set-default", "Do not set this profile as default")
|
|
268
|
+
.action(runAction(async (options) => {
|
|
269
|
+
const { accountId, apiToken, authSource } = await resolveCloudflareAuth({
|
|
270
|
+
accountId: options.accountId,
|
|
271
|
+
apiToken: options.apiToken,
|
|
272
|
+
fromWrangler: options.fromWrangler
|
|
273
|
+
});
|
|
274
|
+
const storageMode = parseStorageMode(options.mode, "flat");
|
|
275
|
+
const client = new CloudflareApiClient({
|
|
276
|
+
accountId,
|
|
277
|
+
apiToken
|
|
278
|
+
});
|
|
279
|
+
await validateCloudflareAuth({ client, authSource });
|
|
280
|
+
const profile = {
|
|
281
|
+
name: options.profile,
|
|
282
|
+
accountId,
|
|
283
|
+
apiToken: authSource === "api-token" ? apiToken : undefined,
|
|
284
|
+
authSource,
|
|
285
|
+
createdAt: nowIso(),
|
|
286
|
+
updatedAt: nowIso()
|
|
287
|
+
};
|
|
288
|
+
await upsertProfile(profile, options.setDefault);
|
|
289
|
+
const namespaceTitle = options.namespaceName ?? `cfenv-${options.project}`;
|
|
290
|
+
let namespaceId = options.namespaceId;
|
|
291
|
+
let namespaceCreated = false;
|
|
292
|
+
if (!namespaceId) {
|
|
293
|
+
const namespaces = await client.listNamespaces();
|
|
294
|
+
const existing = namespaces.find((item) => item.title === namespaceTitle);
|
|
295
|
+
if (existing) {
|
|
296
|
+
namespaceId = existing.id;
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
const created = await client.createNamespace(namespaceTitle);
|
|
300
|
+
namespaceId = created.id;
|
|
301
|
+
namespaceCreated = true;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (!namespaceId) {
|
|
305
|
+
throw new Error("Unable to resolve KV namespace ID.");
|
|
306
|
+
}
|
|
307
|
+
const link = {
|
|
308
|
+
version: 1,
|
|
309
|
+
profile: options.profile,
|
|
310
|
+
namespaceId,
|
|
311
|
+
keyPrefix: options.keyPrefix,
|
|
312
|
+
project: options.project,
|
|
313
|
+
environment: options.env,
|
|
314
|
+
storageMode
|
|
315
|
+
};
|
|
316
|
+
await upsertLocalLink(link, { setAsDefault: options.setDefault });
|
|
317
|
+
console.log(`Setup complete for ${options.project}/${options.env}.`);
|
|
318
|
+
console.log(`Profile: ${options.profile} (auth=${authSource})`);
|
|
319
|
+
console.log(`Storage mode: ${storageMode}`);
|
|
320
|
+
console.log(`Account: ${accountId}`);
|
|
321
|
+
console.log(`Namespace: ${namespaceId}${options.namespaceId ? " (provided)" : namespaceCreated ? " (created)" : " (existing)"}`);
|
|
322
|
+
if (!options.namespaceId) {
|
|
323
|
+
console.log(`Namespace title: ${namespaceTitle}`);
|
|
324
|
+
}
|
|
325
|
+
console.log(`Local config: ${path.join(process.cwd(), ".cfenv", "config.json")}`);
|
|
326
|
+
}));
|
|
327
|
+
program
|
|
328
|
+
.command("login")
|
|
329
|
+
.description("Store Cloudflare auth profile for cfenv")
|
|
330
|
+
.option("--profile <name>", "Profile name", "default")
|
|
331
|
+
.option("--account-id <id>", "Cloudflare account ID")
|
|
332
|
+
.option("--api-token <token>", "Cloudflare API token")
|
|
333
|
+
.option("--from-wrangler", "Use current Wrangler auth session for this profile", false)
|
|
334
|
+
.option("--no-set-default", "Do not set this profile as default")
|
|
335
|
+
.action(runAction(async (options) => {
|
|
336
|
+
const { accountId, apiToken, authSource } = await resolveCloudflareAuth({
|
|
337
|
+
accountId: options.accountId,
|
|
338
|
+
apiToken: options.apiToken,
|
|
339
|
+
fromWrangler: options.fromWrangler
|
|
340
|
+
});
|
|
341
|
+
const client = new CloudflareApiClient({
|
|
342
|
+
accountId,
|
|
343
|
+
apiToken
|
|
344
|
+
});
|
|
345
|
+
await validateCloudflareAuth({ client, authSource });
|
|
346
|
+
const profile = {
|
|
347
|
+
name: options.profile,
|
|
348
|
+
accountId,
|
|
349
|
+
apiToken: authSource === "api-token" ? apiToken : undefined,
|
|
350
|
+
authSource,
|
|
351
|
+
createdAt: nowIso(),
|
|
352
|
+
updatedAt: nowIso()
|
|
353
|
+
};
|
|
354
|
+
await upsertProfile(profile, options.setDefault);
|
|
355
|
+
console.log(`Saved profile "${options.profile}" for account ${accountId} (auth=${authSource}).`);
|
|
356
|
+
}));
|
|
357
|
+
program
|
|
358
|
+
.command("profiles")
|
|
359
|
+
.description("List configured cfenv profiles")
|
|
360
|
+
.action(runAction(async () => {
|
|
361
|
+
const names = await listProfileNames();
|
|
362
|
+
if (!names.length) {
|
|
363
|
+
console.log("No profiles configured. Run `cfenv login`.");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
for (const name of names) {
|
|
367
|
+
console.log(name);
|
|
368
|
+
}
|
|
369
|
+
}));
|
|
370
|
+
program
|
|
371
|
+
.command("targets")
|
|
372
|
+
.description("List local project/environment targets configured in this repository")
|
|
373
|
+
.action(runAction(async () => {
|
|
374
|
+
const config = await loadLocalConfig();
|
|
375
|
+
const links = await listLocalLinks();
|
|
376
|
+
if (!links.length) {
|
|
377
|
+
console.log("No local targets configured. Run `cfenv setup` or `cfenv link` first.");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
for (const link of links) {
|
|
381
|
+
const key = `${link.project}:${link.environment}`;
|
|
382
|
+
const marker = config?.defaultLinkKey === key ? "*" : " ";
|
|
383
|
+
console.log(`${marker} ${link.project}/${link.environment} | mode=${link.storageMode ?? "flat"} | ns=${link.namespaceId} | profile=${link.profile}`);
|
|
384
|
+
}
|
|
385
|
+
console.log("* = default target");
|
|
386
|
+
}));
|
|
387
|
+
program
|
|
388
|
+
.command("use")
|
|
389
|
+
.description("Set the default local environment target")
|
|
390
|
+
.requiredOption("--env <name>", "Environment name")
|
|
391
|
+
.option("--project <name>", "Optional project name when env is ambiguous")
|
|
392
|
+
.action(runAction(async (options) => {
|
|
393
|
+
const selected = await setDefaultLocalLink({
|
|
394
|
+
environment: options.env,
|
|
395
|
+
project: options.project
|
|
396
|
+
});
|
|
397
|
+
console.log(`Default target set to ${selected.project}/${selected.environment}.`);
|
|
398
|
+
}));
|
|
399
|
+
program
|
|
400
|
+
.command("link")
|
|
401
|
+
.description("Link this directory to a Cloudflare KV namespace/project/env")
|
|
402
|
+
.requiredOption("--project <name>", "Project name")
|
|
403
|
+
.requiredOption("--env <name>", "Environment name (development, preview, production)")
|
|
404
|
+
.requiredOption("--namespace-id <id>", "Cloudflare KV namespace ID")
|
|
405
|
+
.option("--profile <name>", "Profile to use", "default")
|
|
406
|
+
.option("--key-prefix <prefix>", "KV key prefix", "cfenv")
|
|
407
|
+
.option("--mode <mode>", "Storage mode: flat or snapshot", "flat")
|
|
408
|
+
.option("--no-set-default", "Do not set this target as default")
|
|
409
|
+
.action(runAction(async (options) => {
|
|
410
|
+
await getProfile(options.profile);
|
|
411
|
+
const storageMode = parseStorageMode(options.mode, "flat");
|
|
412
|
+
const link = {
|
|
413
|
+
version: 1,
|
|
414
|
+
profile: options.profile,
|
|
415
|
+
namespaceId: options.namespaceId,
|
|
416
|
+
keyPrefix: options.keyPrefix,
|
|
417
|
+
project: options.project,
|
|
418
|
+
environment: options.env,
|
|
419
|
+
storageMode
|
|
420
|
+
};
|
|
421
|
+
await upsertLocalLink(link, { setAsDefault: options.setDefault });
|
|
422
|
+
console.log(`Linked ${options.project}/${options.env} to namespace ${options.namespaceId}.`);
|
|
423
|
+
console.log(`Storage mode: ${storageMode}`);
|
|
424
|
+
console.log(`Config saved to ${path.join(process.cwd(), ".cfenv", "config.json")}.`);
|
|
425
|
+
}));
|
|
426
|
+
program
|
|
427
|
+
.command("push")
|
|
428
|
+
.description("Push a local .env file to Cloudflare KV")
|
|
429
|
+
.option("--profile <name>", "Profile override")
|
|
430
|
+
.option("--project <name>", "Project override from local config")
|
|
431
|
+
.option("--env <name>", "Environment override from local config")
|
|
432
|
+
.option("--file <path>", "Path to source env file", ".env")
|
|
433
|
+
.option("--mode <mode>", "Storage mode override: flat or snapshot")
|
|
434
|
+
.option("--updated-by <name>", "Actor label for metadata")
|
|
435
|
+
.option("--encryption-key <secret>", "Encryption secret (snapshot mode only). Defaults to CFENV_ENCRYPTION_KEY")
|
|
436
|
+
.option("--no-encrypt", "Store snapshot in plaintext (not recommended)")
|
|
437
|
+
.action(runAction(async (options) => {
|
|
438
|
+
const link = await requireLocalConfig({
|
|
439
|
+
project: options.project,
|
|
440
|
+
environment: options.env
|
|
441
|
+
});
|
|
442
|
+
const { profile, client } = await getApiClient(options.profile ?? link.profile);
|
|
443
|
+
const entries = await parseEnvFile(options.file);
|
|
444
|
+
const mode = resolveOperationMode(link, options.mode);
|
|
445
|
+
const updatedAt = nowIso();
|
|
446
|
+
const updatedBy = resolveUpdatedBy(options.updatedBy);
|
|
447
|
+
const checksum = checksumEntries(entries);
|
|
448
|
+
const encryptionSecret = resolveEncryptionSecret(options.encryptionKey);
|
|
449
|
+
if (mode === "flat") {
|
|
450
|
+
await pushFlatEntries({
|
|
451
|
+
client,
|
|
452
|
+
link,
|
|
453
|
+
entries,
|
|
454
|
+
checksum,
|
|
455
|
+
updatedAt,
|
|
456
|
+
updatedBy
|
|
457
|
+
});
|
|
458
|
+
console.log([
|
|
459
|
+
`Pushed ${Object.keys(entries).length} keys`,
|
|
460
|
+
`project=${link.project}`,
|
|
461
|
+
`env=${link.environment}`,
|
|
462
|
+
`mode=flat`,
|
|
463
|
+
`profile=${profile.name}`
|
|
464
|
+
].join(" | "));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const result = await pushSnapshotEntries({
|
|
468
|
+
client,
|
|
469
|
+
link,
|
|
470
|
+
entries,
|
|
471
|
+
checksum,
|
|
472
|
+
updatedAt,
|
|
473
|
+
updatedBy,
|
|
474
|
+
encryptionSecret,
|
|
475
|
+
encrypt: options.encrypt
|
|
476
|
+
});
|
|
477
|
+
console.log([
|
|
478
|
+
`Pushed ${Object.keys(entries).length} keys`,
|
|
479
|
+
`project=${link.project}`,
|
|
480
|
+
`env=${link.environment}`,
|
|
481
|
+
`version=${result.versionId}`,
|
|
482
|
+
`mode=snapshot`,
|
|
483
|
+
`profile=${profile.name}`,
|
|
484
|
+
`encrypted=${options.encrypt ? "yes" : "no"}`
|
|
485
|
+
].join(" | "));
|
|
486
|
+
}));
|
|
487
|
+
program
|
|
488
|
+
.command("pull")
|
|
489
|
+
.description("Pull env variables from Cloudflare KV")
|
|
490
|
+
.option("--profile <name>", "Profile override")
|
|
491
|
+
.option("--project <name>", "Project override from local config")
|
|
492
|
+
.option("--env <name>", "Environment override from local config")
|
|
493
|
+
.option("--mode <mode>", "Storage mode override: flat or snapshot")
|
|
494
|
+
.option("--version <id>", "Version ID to pull (snapshot mode only; defaults to latest pointer)")
|
|
495
|
+
.option("--out <path>", "Output file path", ".env")
|
|
496
|
+
.option("--encryption-key <secret>", "Encryption secret (snapshot mode only). Defaults to CFENV_ENCRYPTION_KEY")
|
|
497
|
+
.option("--overwrite", "Overwrite output file if it already exists", false)
|
|
498
|
+
.action(runAction(async (options) => {
|
|
499
|
+
const link = await requireLocalConfig({
|
|
500
|
+
project: options.project,
|
|
501
|
+
environment: options.env
|
|
502
|
+
});
|
|
503
|
+
const { profile, client } = await getApiClient(options.profile ?? link.profile);
|
|
504
|
+
const mode = resolveOperationMode(link, options.mode);
|
|
505
|
+
const outputPath = path.resolve(options.out);
|
|
506
|
+
const encryptionSecret = resolveEncryptionSecret(options.encryptionKey);
|
|
507
|
+
if (!options.overwrite && (await exists(outputPath))) {
|
|
508
|
+
throw new Error(`Output path already exists: ${outputPath}. Use --overwrite to replace it.`);
|
|
509
|
+
}
|
|
510
|
+
if (mode === "flat") {
|
|
511
|
+
const pulled = await pullFlatEntries({ client, link });
|
|
512
|
+
const serialized = serializeEnvFile(pulled.entries);
|
|
513
|
+
await writeEnvFileAtomic(outputPath, serialized);
|
|
514
|
+
if (process.platform !== "win32") {
|
|
515
|
+
await fs.chmod(outputPath, 0o600).catch(() => undefined);
|
|
516
|
+
}
|
|
517
|
+
console.log([
|
|
518
|
+
`Pulled ${Object.keys(pulled.entries).length} keys`,
|
|
519
|
+
`project=${link.project}`,
|
|
520
|
+
`env=${link.environment}`,
|
|
521
|
+
`mode=flat`,
|
|
522
|
+
`profile=${profile.name}`,
|
|
523
|
+
`out=${outputPath}`
|
|
524
|
+
].join(" | "));
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const pulled = await pullSnapshotEntries({
|
|
528
|
+
client,
|
|
529
|
+
link,
|
|
530
|
+
encryptionSecret,
|
|
531
|
+
versionId: options.version
|
|
532
|
+
});
|
|
533
|
+
const serialized = serializeEnvFile(pulled.entries);
|
|
534
|
+
await writeEnvFileAtomic(outputPath, serialized);
|
|
535
|
+
if (process.platform !== "win32") {
|
|
536
|
+
await fs.chmod(outputPath, 0o600).catch(() => undefined);
|
|
537
|
+
}
|
|
538
|
+
console.log([
|
|
539
|
+
`Pulled ${Object.keys(pulled.entries).length} keys`,
|
|
540
|
+
`project=${pulled.project}`,
|
|
541
|
+
`env=${pulled.environment}`,
|
|
542
|
+
`version=${pulled.versionId}`,
|
|
543
|
+
`mode=snapshot`,
|
|
544
|
+
`profile=${profile.name}`,
|
|
545
|
+
`out=${outputPath}`,
|
|
546
|
+
`encrypted=${pulled.encrypted ? "yes" : "no"}`
|
|
547
|
+
].join(" | "));
|
|
548
|
+
}));
|
|
549
|
+
program
|
|
550
|
+
.command("export")
|
|
551
|
+
.description("Export env values for CI/runtime integration")
|
|
552
|
+
.option("--profile <name>", "Profile override")
|
|
553
|
+
.option("--project <name>", "Project override from local config")
|
|
554
|
+
.option("--env <name>", "Environment override from local config")
|
|
555
|
+
.option("--mode <mode>", "Storage mode override: flat or snapshot")
|
|
556
|
+
.option("--version <id>", "Version ID to export (snapshot mode only)")
|
|
557
|
+
.option("--encryption-key <secret>", "Encryption secret (snapshot mode only). Defaults to CFENV_ENCRYPTION_KEY")
|
|
558
|
+
.option("--format <format>", "Output format: dotenv or json", "dotenv")
|
|
559
|
+
.option("--out <path>", "Write output to file")
|
|
560
|
+
.option("--stdout", "Always print output to stdout", false)
|
|
561
|
+
.option("--overwrite", "Overwrite --out file if it exists", false)
|
|
562
|
+
.action(runAction(async (options) => {
|
|
563
|
+
const link = await requireLocalConfig({
|
|
564
|
+
project: options.project,
|
|
565
|
+
environment: options.env
|
|
566
|
+
});
|
|
567
|
+
const { profile, client } = await getApiClient(options.profile ?? link.profile);
|
|
568
|
+
const mode = resolveOperationMode(link, options.mode);
|
|
569
|
+
const encryptionSecret = resolveEncryptionSecret(options.encryptionKey);
|
|
570
|
+
const format = parseExportFormat(options.format);
|
|
571
|
+
let entries;
|
|
572
|
+
if (mode === "flat") {
|
|
573
|
+
const pulled = await pullFlatEntries({ client, link });
|
|
574
|
+
entries = pulled.entries;
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
const pulled = await pullSnapshotEntries({
|
|
578
|
+
client,
|
|
579
|
+
link,
|
|
580
|
+
encryptionSecret,
|
|
581
|
+
versionId: options.version
|
|
582
|
+
});
|
|
583
|
+
entries = pulled.entries;
|
|
584
|
+
}
|
|
585
|
+
const outputContent = format === "dotenv"
|
|
586
|
+
? serializeEnvFile(entries)
|
|
587
|
+
: `${JSON.stringify(entries, null, 2)}\n`;
|
|
588
|
+
const outPath = options.out ? path.resolve(options.out) : undefined;
|
|
589
|
+
if (outPath) {
|
|
590
|
+
if (!options.overwrite && (await exists(outPath))) {
|
|
591
|
+
throw new Error(`Output path already exists: ${outPath}. Use --overwrite to replace it.`);
|
|
592
|
+
}
|
|
593
|
+
await writeTextFileAtomic(outPath, outputContent);
|
|
594
|
+
if (process.platform !== "win32") {
|
|
595
|
+
await fs.chmod(outPath, 0o600).catch(() => undefined);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const shouldWriteStdout = options.stdout || !options.out;
|
|
599
|
+
if (shouldWriteStdout) {
|
|
600
|
+
process.stdout.write(outputContent);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
console.log([
|
|
604
|
+
`Exported ${Object.keys(entries).length} keys`,
|
|
605
|
+
`project=${link.project}`,
|
|
606
|
+
`env=${link.environment}`,
|
|
607
|
+
`mode=${mode}`,
|
|
608
|
+
`profile=${profile.name}`,
|
|
609
|
+
`format=${format}`,
|
|
610
|
+
`out=${outPath}`
|
|
611
|
+
].join(" | "));
|
|
612
|
+
}));
|
|
613
|
+
program
|
|
614
|
+
.command("history")
|
|
615
|
+
.description("List snapshot versions or flat metadata for the linked project/environment")
|
|
616
|
+
.option("--profile <name>", "Profile override")
|
|
617
|
+
.option("--project <name>", "Project override from local config")
|
|
618
|
+
.option("--env <name>", "Environment override from local config")
|
|
619
|
+
.option("--mode <mode>", "Storage mode override: flat or snapshot")
|
|
620
|
+
.option("--limit <n>", "Maximum versions to show", "20")
|
|
621
|
+
.action(runAction(async (options) => {
|
|
622
|
+
const link = await requireLocalConfig({
|
|
623
|
+
project: options.project,
|
|
624
|
+
environment: options.env
|
|
625
|
+
});
|
|
626
|
+
const { client } = await getApiClient(options.profile ?? link.profile);
|
|
627
|
+
const mode = resolveOperationMode(link, options.mode);
|
|
628
|
+
if (mode === "flat") {
|
|
629
|
+
const metaRaw = await client.getValue(link.namespaceId, flatEnvMetaKey(link));
|
|
630
|
+
if (!metaRaw) {
|
|
631
|
+
console.log("No flat metadata found.");
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const metadata = JSON.parse(metaRaw);
|
|
635
|
+
console.log([
|
|
636
|
+
`mode=flat`,
|
|
637
|
+
`updatedAt=${metadata.updatedAt}`,
|
|
638
|
+
`updatedBy=${metadata.updatedBy ?? "unknown"}`,
|
|
639
|
+
`entries=${metadata.entriesCount}`,
|
|
640
|
+
`checksum=${metadata.checksum}`
|
|
641
|
+
].join(" | "));
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
const prefix = versionsPrefix(link);
|
|
645
|
+
const keys = await client.listKeys(link.namespaceId, prefix);
|
|
646
|
+
const versionIds = keys
|
|
647
|
+
.map((key) => key.name)
|
|
648
|
+
.filter((name) => name.startsWith(prefix))
|
|
649
|
+
.map((name) => name.slice(prefix.length))
|
|
650
|
+
.filter(Boolean)
|
|
651
|
+
.sort((a, b) => b.localeCompare(a))
|
|
652
|
+
.slice(0, Number(options.limit));
|
|
653
|
+
if (!versionIds.length) {
|
|
654
|
+
console.log("No versions found.");
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
for (const id of versionIds) {
|
|
658
|
+
console.log(id);
|
|
659
|
+
}
|
|
660
|
+
}));
|
|
661
|
+
program.parseAsync(process.argv).catch((error) => {
|
|
662
|
+
console.error(`Error: ${unwrapError(error)}`);
|
|
663
|
+
process.exitCode = 1;
|
|
664
|
+
});
|