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.
@@ -0,0 +1,86 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { ensurePrivateDir, exists, writePrivateFile } from "./fs-utils.js";
3
+ import { getGlobalConfigDir, getProfilesPath } from "./paths.js";
4
+ function parseProfile(raw, keyHint) {
5
+ if (!raw || typeof raw !== "object") {
6
+ throw new Error(`Invalid profile "${keyHint}".`);
7
+ }
8
+ const record = raw;
9
+ const name = typeof record.name === "string" && record.name.trim() ? record.name : keyHint;
10
+ const accountId = typeof record.accountId === "string" ? record.accountId : "";
11
+ const createdAt = typeof record.createdAt === "string" ? record.createdAt : new Date().toISOString();
12
+ const updatedAt = typeof record.updatedAt === "string" ? record.updatedAt : createdAt;
13
+ const apiToken = typeof record.apiToken === "string" ? record.apiToken : undefined;
14
+ const authSource = record.authSource === "wrangler" || record.authSource === "api-token"
15
+ ? record.authSource
16
+ : undefined;
17
+ if (!accountId.trim()) {
18
+ throw new Error(`Invalid profile "${keyHint}": missing accountId.`);
19
+ }
20
+ return {
21
+ name,
22
+ accountId,
23
+ apiToken,
24
+ authSource,
25
+ createdAt,
26
+ updatedAt
27
+ };
28
+ }
29
+ export async function loadProfiles() {
30
+ const profilesPath = getProfilesPath();
31
+ if (!(await exists(profilesPath))) {
32
+ return {
33
+ version: 1,
34
+ profiles: {},
35
+ defaultProfile: undefined
36
+ };
37
+ }
38
+ const raw = await fs.readFile(profilesPath, "utf8");
39
+ const parsed = JSON.parse(raw);
40
+ const rawProfiles = parsed.profiles;
41
+ const profiles = {};
42
+ if (rawProfiles && typeof rawProfiles === "object") {
43
+ for (const [key, value] of Object.entries(rawProfiles)) {
44
+ profiles[key] = parseProfile(value, key);
45
+ }
46
+ }
47
+ return {
48
+ version: 1,
49
+ profiles,
50
+ defaultProfile: typeof parsed.defaultProfile === "string" ? parsed.defaultProfile : undefined
51
+ };
52
+ }
53
+ export async function saveProfiles(data) {
54
+ const configDir = getGlobalConfigDir();
55
+ const profilesPath = getProfilesPath();
56
+ await ensurePrivateDir(configDir);
57
+ await writePrivateFile(profilesPath, `${JSON.stringify(data, null, 2)}\n`);
58
+ }
59
+ export async function upsertProfile(profile, setAsDefault = true) {
60
+ const profiles = await loadProfiles();
61
+ profiles.profiles[profile.name] = profile;
62
+ if (setAsDefault) {
63
+ profiles.defaultProfile = profile.name;
64
+ }
65
+ await saveProfiles(profiles);
66
+ }
67
+ export async function getProfile(name) {
68
+ const profiles = await loadProfiles();
69
+ const profileName = name ?? profiles.defaultProfile;
70
+ if (!profileName) {
71
+ const allNames = Object.keys(profiles.profiles);
72
+ if (allNames.length === 1) {
73
+ return profiles.profiles[allNames[0]];
74
+ }
75
+ throw new Error("No profile selected. Run `cfenv login` first or pass --profile.");
76
+ }
77
+ const profile = profiles.profiles[profileName];
78
+ if (!profile) {
79
+ throw new Error(`Profile "${profileName}" does not exist. Run \`cfenv login --profile ${profileName}\`.`);
80
+ }
81
+ return profile;
82
+ }
83
+ export async function listProfileNames() {
84
+ const profiles = await loadProfiles();
85
+ return Object.keys(profiles.profiles).sort((a, b) => a.localeCompare(b));
86
+ }
@@ -0,0 +1,159 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
5
+ async function runWrangler(args) {
6
+ try {
7
+ const { stdout } = await execFileAsync("wrangler", args, {
8
+ maxBuffer: 1024 * 1024
9
+ });
10
+ return stdout.trim();
11
+ }
12
+ catch (error) {
13
+ if (typeof error === "object" && error !== null) {
14
+ const item = error;
15
+ const details = [item.message, item.stderr, item.stdout]
16
+ .map((value) => value?.trim())
17
+ .filter(Boolean)
18
+ .join("\n");
19
+ throw new Error(`Wrangler command failed (${["wrangler", ...args].join(" ")}): ${details}`);
20
+ }
21
+ if (error instanceof Error) {
22
+ throw new Error(`Wrangler command failed (${["wrangler", ...args].join(" ")}): ${error.message}`);
23
+ }
24
+ throw error;
25
+ }
26
+ }
27
+ function stripAnsi(input) {
28
+ return input.replace(ANSI_PATTERN, "");
29
+ }
30
+ function extractTokenFromOutput(raw) {
31
+ const lines = stripAnsi(raw)
32
+ .split(/\r?\n/)
33
+ .map((line) => line.trim())
34
+ .filter(Boolean);
35
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
36
+ const line = lines[i];
37
+ if (line.includes(" ")) {
38
+ continue;
39
+ }
40
+ if (/^[A-Za-z0-9._-]{20,}$/.test(line)) {
41
+ return line;
42
+ }
43
+ }
44
+ return undefined;
45
+ }
46
+ function extractAccountId(account) {
47
+ if (!account) {
48
+ return undefined;
49
+ }
50
+ return account.id ?? account.accountId ?? account.account_id;
51
+ }
52
+ function extractAccountIdFromText(raw) {
53
+ const cleaned = stripAnsi(raw);
54
+ const directPatterns = [
55
+ /account\s+id\s*[:=]\s*([A-Fa-f0-9]{32})/i,
56
+ /account\s*[:=]\s*([A-Fa-f0-9]{32})/i
57
+ ];
58
+ for (const pattern of directPatterns) {
59
+ const match = cleaned.match(pattern);
60
+ if (match?.[1]) {
61
+ return match[1].toLowerCase();
62
+ }
63
+ }
64
+ const candidates = cleaned.match(/\b[A-Fa-f0-9]{32}\b/g);
65
+ if (candidates?.length) {
66
+ return candidates[0].toLowerCase();
67
+ }
68
+ return undefined;
69
+ }
70
+ function parseWhoamiJson(raw) {
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(stripAnsi(raw));
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ return extractAccountId(parsed.account) ?? extractAccountId(parsed.accounts?.[0]);
79
+ }
80
+ export async function getWranglerAccessToken() {
81
+ const raw = await runWrangler(["auth", "token"]);
82
+ const token = extractTokenFromOutput(raw);
83
+ if (!token) {
84
+ throw new Error("Wrangler returned an unusable token. Run `wrangler login` first.");
85
+ }
86
+ return token;
87
+ }
88
+ async function getAccountIdFromApi(token) {
89
+ const response = await fetch("https://api.cloudflare.com/client/v4/accounts?page=1&per_page=1", {
90
+ method: "GET",
91
+ headers: {
92
+ Authorization: `Bearer ${token}`
93
+ }
94
+ });
95
+ const raw = await response.text();
96
+ let payload;
97
+ try {
98
+ payload = JSON.parse(raw);
99
+ }
100
+ catch {
101
+ throw new Error(`Unable to parse accounts API response (${response.status}).`);
102
+ }
103
+ if (!response.ok || !payload.success) {
104
+ const msg = payload.errors?.map((item) => item.message).filter(Boolean).join("; ");
105
+ throw new Error(msg || `Accounts API request failed (${response.status}).`);
106
+ }
107
+ const accountId = payload.result?.[0]?.id;
108
+ return accountId;
109
+ }
110
+ export async function getWranglerAccountId() {
111
+ const errors = [];
112
+ const jsonAttempts = [
113
+ ["whoami", "--json"],
114
+ ["whoami", "--format", "json"]
115
+ ];
116
+ for (const args of jsonAttempts) {
117
+ try {
118
+ const raw = await runWrangler(args);
119
+ const accountId = parseWhoamiJson(raw);
120
+ if (accountId) {
121
+ return accountId;
122
+ }
123
+ }
124
+ catch (error) {
125
+ errors.push(String(error));
126
+ }
127
+ }
128
+ try {
129
+ const raw = await runWrangler(["whoami"]);
130
+ const accountId = extractAccountIdFromText(raw);
131
+ if (accountId) {
132
+ return accountId;
133
+ }
134
+ }
135
+ catch (error) {
136
+ errors.push(String(error));
137
+ }
138
+ const accountFromEnv = process.env.CLOUDFLARE_ACCOUNT_ID;
139
+ if (accountFromEnv?.trim()) {
140
+ return accountFromEnv.trim();
141
+ }
142
+ try {
143
+ const token = await getWranglerAccessToken();
144
+ const accountId = await getAccountIdFromApi(token);
145
+ if (accountId) {
146
+ return accountId;
147
+ }
148
+ }
149
+ catch (error) {
150
+ errors.push(String(error));
151
+ }
152
+ throw new Error([
153
+ "Could not determine Cloudflare account ID from Wrangler.",
154
+ "Pass --account-id explicitly.",
155
+ errors.length ? `Wrangler errors:\n${errors.join("\n")}` : ""
156
+ ]
157
+ .filter(Boolean)
158
+ .join("\n"));
159
+ }
@@ -0,0 +1,161 @@
1
+ import { CloudflareApiClient } from "../lib/cloudflare-api.js";
2
+ import { checksumEntries } from "../lib/hash.js";
3
+ import { flatEnvMetaKey, flatEnvVarsPrefix } from "../lib/kv-keys.js";
4
+ const DEFAULT_INTERVAL_MS = 30_000;
5
+ const DEFAULT_MAX_INTERVAL_MS = 300_000;
6
+ function sleep(ms) {
7
+ return new Promise((resolve) => {
8
+ setTimeout(resolve, ms);
9
+ });
10
+ }
11
+ function toError(value) {
12
+ if (value instanceof Error) {
13
+ return value;
14
+ }
15
+ return new Error(String(value));
16
+ }
17
+ function buildLink(options) {
18
+ return {
19
+ version: 1,
20
+ profile: "sdk",
21
+ namespaceId: options.namespaceId,
22
+ keyPrefix: options.keyPrefix ?? "cfenv",
23
+ project: options.project,
24
+ environment: options.environment,
25
+ storageMode: "flat"
26
+ };
27
+ }
28
+ export class CfenvHotUpdateClient {
29
+ options;
30
+ client;
31
+ link;
32
+ intervalMs;
33
+ maxIntervalMs;
34
+ bootstrap;
35
+ running = false;
36
+ timer = null;
37
+ consecutiveErrors = 0;
38
+ lastChecksum;
39
+ lastSnapshot = null;
40
+ constructor(options) {
41
+ this.options = options;
42
+ this.client = options.client ?? new CloudflareApiClient({
43
+ accountId: options.accountId,
44
+ apiToken: options.apiToken
45
+ });
46
+ this.link = buildLink(options);
47
+ this.intervalMs = Math.max(1_000, options.intervalMs ?? DEFAULT_INTERVAL_MS);
48
+ this.maxIntervalMs = Math.max(this.intervalMs, options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS);
49
+ this.bootstrap = options.bootstrap ?? true;
50
+ }
51
+ async start() {
52
+ if (this.running) {
53
+ return;
54
+ }
55
+ this.running = true;
56
+ if (this.bootstrap) {
57
+ await this.refreshOnce("initial");
58
+ }
59
+ this.schedule(this.intervalMs);
60
+ }
61
+ stop() {
62
+ this.running = false;
63
+ if (this.timer) {
64
+ clearTimeout(this.timer);
65
+ this.timer = null;
66
+ }
67
+ }
68
+ isRunning() {
69
+ return this.running;
70
+ }
71
+ getSnapshot() {
72
+ return this.lastSnapshot;
73
+ }
74
+ async refreshOnce(reason = "changed") {
75
+ const snapshot = await this.fetchSnapshot();
76
+ const changed = snapshot.checksum !== this.lastChecksum;
77
+ if (!changed) {
78
+ return false;
79
+ }
80
+ this.lastChecksum = snapshot.checksum;
81
+ this.lastSnapshot = snapshot;
82
+ if (this.options.onUpdate) {
83
+ await this.options.onUpdate(snapshot, reason);
84
+ }
85
+ return true;
86
+ }
87
+ schedule(delayMs) {
88
+ if (!this.running) {
89
+ return;
90
+ }
91
+ this.timer = setTimeout(async () => {
92
+ if (!this.running) {
93
+ return;
94
+ }
95
+ try {
96
+ await this.refreshOnce("changed");
97
+ this.consecutiveErrors = 0;
98
+ this.schedule(this.intervalMs);
99
+ }
100
+ catch (error) {
101
+ this.consecutiveErrors += 1;
102
+ const nextDelay = Math.min(this.maxIntervalMs, this.intervalMs * 2 ** Math.min(this.consecutiveErrors, 6));
103
+ if (this.options.onError) {
104
+ await this.options.onError(toError(error));
105
+ }
106
+ await sleep(0);
107
+ this.schedule(nextDelay);
108
+ }
109
+ }, delayMs);
110
+ }
111
+ async fetchSnapshot() {
112
+ const metaRaw = await this.client.getValue(this.link.namespaceId, flatEnvMetaKey(this.link));
113
+ if (!metaRaw) {
114
+ throw new Error("No flat metadata found in KV for hot update.");
115
+ }
116
+ let metadata;
117
+ try {
118
+ metadata = JSON.parse(metaRaw);
119
+ }
120
+ catch {
121
+ throw new Error("Invalid flat metadata payload.");
122
+ }
123
+ const prefix = flatEnvVarsPrefix(this.link);
124
+ const keys = await this.client.listKeys(this.link.namespaceId, prefix);
125
+ const envVarKeys = keys
126
+ .map((item) => item.name)
127
+ .filter((name) => name.startsWith(prefix))
128
+ .sort((a, b) => a.localeCompare(b));
129
+ const entries = {};
130
+ for (const fullKey of envVarKeys) {
131
+ const envVarName = fullKey.slice(prefix.length);
132
+ const envVarValue = await this.client.getValue(this.link.namespaceId, fullKey);
133
+ if (envVarValue !== null) {
134
+ entries[envVarName] = envVarValue;
135
+ }
136
+ }
137
+ const computedChecksum = checksumEntries(entries);
138
+ if (computedChecksum !== metadata.checksum) {
139
+ throw new Error("Hot update checksum mismatch.");
140
+ }
141
+ return {
142
+ project: this.link.project,
143
+ environment: this.link.environment,
144
+ namespaceId: this.link.namespaceId,
145
+ checksum: metadata.checksum,
146
+ updatedAt: metadata.updatedAt,
147
+ updatedBy: metadata.updatedBy,
148
+ entriesCount: metadata.entriesCount,
149
+ entries
150
+ };
151
+ }
152
+ }
153
+ export function applyEntriesToProcessEnv(entries, options = {}) {
154
+ const overwrite = options.overwrite ?? true;
155
+ for (const [key, value] of Object.entries(entries)) {
156
+ if (!overwrite && process.env[key] !== undefined) {
157
+ continue;
158
+ }
159
+ process.env[key] = value;
160
+ }
161
+ }
@@ -0,0 +1 @@
1
+ export { applyEntriesToProcessEnv, CfenvHotUpdateClient } from "./hot-update.js";
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "cfenv-kv-sync",
3
+ "version": "0.1.0-beta.1",
4
+ "description": "Cloudflare KV-backed environment sync CLI",
5
+ "type": "module",
6
+ "main": "dist/sdk/index.js",
7
+ "exports": {
8
+ ".": "./dist/sdk/index.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "bin": {
19
+ "cfenv": "dist/index.js"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json",
23
+ "check": "tsc --noEmit -p tsconfig.json",
24
+ "test": "tsx --test test/*.test.ts",
25
+ "dev": "tsx src/index.ts",
26
+ "start": "node dist/index.js",
27
+ "prepublishOnly": "npm run check && npm run test && npm run build"
28
+ },
29
+ "engines": {
30
+ "node": ">=20.0.0"
31
+ },
32
+ "dependencies": {
33
+ "commander": "^13.1.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22.13.1",
37
+ "tsx": "^4.19.3",
38
+ "typescript": "^5.7.3"
39
+ }
40
+ }