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/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
+ });