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,252 @@
1
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
2
+ const DEFAULT_MAX_RETRIES = 3;
3
+ const DEFAULT_RETRY_BASE_DELAY_MS = 500;
4
+ function sleep(ms) {
5
+ return new Promise((resolve) => {
6
+ setTimeout(resolve, ms);
7
+ });
8
+ }
9
+ function parseRetryAfter(retryAfterHeader) {
10
+ if (!retryAfterHeader) {
11
+ return null;
12
+ }
13
+ const seconds = Number(retryAfterHeader);
14
+ if (Number.isFinite(seconds) && seconds >= 0) {
15
+ return Math.floor(seconds * 1000);
16
+ }
17
+ const retryDate = Date.parse(retryAfterHeader);
18
+ if (Number.isNaN(retryDate)) {
19
+ return null;
20
+ }
21
+ const ms = retryDate - Date.now();
22
+ if (ms <= 0) {
23
+ return null;
24
+ }
25
+ return ms;
26
+ }
27
+ function jitteredExponentialBackoff(baseMs, attempt) {
28
+ const exponential = baseMs * 2 ** attempt;
29
+ const jitter = Math.floor(Math.random() * baseMs);
30
+ return Math.min(exponential + jitter, 30_000);
31
+ }
32
+ function normalizeHeaders(input) {
33
+ if (!input) {
34
+ return {};
35
+ }
36
+ if (Array.isArray(input)) {
37
+ return Object.fromEntries(input);
38
+ }
39
+ if (input instanceof Headers) {
40
+ const result = {};
41
+ for (const [key, value] of input.entries()) {
42
+ result[key] = value;
43
+ }
44
+ return result;
45
+ }
46
+ return { ...input };
47
+ }
48
+ export class CloudflareApiClient {
49
+ accountId;
50
+ apiToken;
51
+ baseUrl = "https://api.cloudflare.com/client/v4";
52
+ requestTimeoutMs;
53
+ maxRetries;
54
+ retryBaseDelayMs;
55
+ userAgent;
56
+ constructor(input) {
57
+ this.accountId = input.accountId;
58
+ this.apiToken = input.apiToken;
59
+ this.requestTimeoutMs = input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
60
+ this.maxRetries = input.maxRetries ?? DEFAULT_MAX_RETRIES;
61
+ this.retryBaseDelayMs = input.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS;
62
+ this.userAgent = input.userAgent ?? "cfenv-kv-sync/0.1.0";
63
+ }
64
+ async verifyToken() {
65
+ const url = `${this.baseUrl}/user/tokens/verify`;
66
+ return this.requestJson(url, { method: "GET" });
67
+ }
68
+ async putValue(namespaceId, key, value) {
69
+ const url = this.accountUrl(`/storage/kv/namespaces/${encodeURIComponent(namespaceId)}/values/${encodeURIComponent(key)}`);
70
+ const response = await this.executeFetch(url, {
71
+ method: "PUT",
72
+ headers: this.authHeaders({
73
+ "Content-Type": "text/plain; charset=utf-8"
74
+ }),
75
+ body: value
76
+ });
77
+ if (!response.ok) {
78
+ throw new Error(await this.extractError(response));
79
+ }
80
+ }
81
+ async getValue(namespaceId, key) {
82
+ const url = this.accountUrl(`/storage/kv/namespaces/${encodeURIComponent(namespaceId)}/values/${encodeURIComponent(key)}`);
83
+ const response = await this.executeFetch(url, {
84
+ method: "GET",
85
+ headers: this.authHeaders()
86
+ });
87
+ if (response.status === 404) {
88
+ return null;
89
+ }
90
+ if (!response.ok) {
91
+ throw new Error(await this.extractError(response));
92
+ }
93
+ return response.text();
94
+ }
95
+ async deleteValue(namespaceId, key) {
96
+ const url = this.accountUrl(`/storage/kv/namespaces/${encodeURIComponent(namespaceId)}/values/${encodeURIComponent(key)}`);
97
+ const response = await this.executeFetch(url, {
98
+ method: "DELETE",
99
+ headers: this.authHeaders()
100
+ });
101
+ if (!response.ok) {
102
+ throw new Error(await this.extractError(response));
103
+ }
104
+ }
105
+ async listKeys(namespaceId, prefix, limit = 1000) {
106
+ const results = [];
107
+ let cursor;
108
+ do {
109
+ const url = new URL(this.accountUrl(`/storage/kv/namespaces/${encodeURIComponent(namespaceId)}/keys`));
110
+ url.searchParams.set("prefix", prefix);
111
+ url.searchParams.set("limit", String(limit));
112
+ if (cursor) {
113
+ url.searchParams.set("cursor", cursor);
114
+ }
115
+ const response = await this.requestEnvelope(url.toString(), { method: "GET" });
116
+ results.push(...response.result);
117
+ cursor = response.result_info?.cursor;
118
+ } while (cursor);
119
+ return results;
120
+ }
121
+ async listNamespaces(perPage = 100) {
122
+ const results = [];
123
+ let page = 1;
124
+ let totalPages = 1;
125
+ while (page <= totalPages) {
126
+ const url = new URL(this.accountUrl("/storage/kv/namespaces"));
127
+ url.searchParams.set("page", String(page));
128
+ url.searchParams.set("per_page", String(perPage));
129
+ const response = await this.requestEnvelope(url.toString(), { method: "GET" });
130
+ results.push(...response.result);
131
+ totalPages = response.result_info?.total_pages ?? page;
132
+ page += 1;
133
+ }
134
+ return results;
135
+ }
136
+ async createNamespace(title) {
137
+ if (!title.trim()) {
138
+ throw new Error("Namespace title cannot be empty.");
139
+ }
140
+ const url = this.accountUrl("/storage/kv/namespaces");
141
+ return this.requestJson(url, {
142
+ method: "POST",
143
+ headers: {
144
+ "Content-Type": "application/json"
145
+ },
146
+ body: JSON.stringify({ title })
147
+ });
148
+ }
149
+ accountUrl(pathname) {
150
+ return `${this.baseUrl}/accounts/${encodeURIComponent(this.accountId)}${pathname}`;
151
+ }
152
+ authHeaders(extra = {}) {
153
+ return {
154
+ Authorization: `Bearer ${this.apiToken}`,
155
+ "User-Agent": this.userAgent,
156
+ ...extra
157
+ };
158
+ }
159
+ async requestJson(url, init) {
160
+ const envelope = await this.requestEnvelope(url, init);
161
+ return envelope.result;
162
+ }
163
+ async requestEnvelope(url, init) {
164
+ const response = await this.executeFetch(url, {
165
+ ...init,
166
+ headers: this.authHeaders(normalizeHeaders(init.headers))
167
+ });
168
+ let payload;
169
+ try {
170
+ payload = (await response.json());
171
+ }
172
+ catch {
173
+ throw new Error(`Cloudflare API returned non-JSON response (${response.status}).`);
174
+ }
175
+ if (!response.ok || !payload.success) {
176
+ const apiMessage = payload.errors?.map((err) => err.message).join("; ");
177
+ throw new Error(apiMessage || `Cloudflare API request failed with HTTP ${response.status}.`);
178
+ }
179
+ return payload;
180
+ }
181
+ async executeFetch(url, init) {
182
+ let lastError;
183
+ for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
184
+ const controller = new AbortController();
185
+ const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs);
186
+ try {
187
+ const response = await fetch(url, {
188
+ ...init,
189
+ signal: controller.signal
190
+ });
191
+ clearTimeout(timeout);
192
+ if (!this.shouldRetryResponse(response) || attempt >= this.maxRetries) {
193
+ return response;
194
+ }
195
+ const waitMs = parseRetryAfter(response.headers.get("retry-after"))
196
+ ?? jitteredExponentialBackoff(this.retryBaseDelayMs, attempt);
197
+ await sleep(waitMs);
198
+ }
199
+ catch (error) {
200
+ clearTimeout(timeout);
201
+ lastError = error;
202
+ if (!this.shouldRetryError(error) || attempt >= this.maxRetries) {
203
+ throw this.toNetworkError(error);
204
+ }
205
+ const waitMs = jitteredExponentialBackoff(this.retryBaseDelayMs, attempt);
206
+ await sleep(waitMs);
207
+ }
208
+ }
209
+ throw this.toNetworkError(lastError);
210
+ }
211
+ shouldRetryResponse(response) {
212
+ return response.status === 408 || response.status === 429 || response.status >= 500;
213
+ }
214
+ shouldRetryError(error) {
215
+ if (!(error instanceof Error)) {
216
+ return false;
217
+ }
218
+ if (error.name === "AbortError") {
219
+ return true;
220
+ }
221
+ // Undici/network failures in Node fetch commonly show as TypeError.
222
+ if (error instanceof TypeError) {
223
+ return true;
224
+ }
225
+ return /fetch failed|network/i.test(error.message);
226
+ }
227
+ toNetworkError(error) {
228
+ if (error instanceof Error) {
229
+ if (error.name === "AbortError") {
230
+ return new Error(`Cloudflare API request timed out after ${this.requestTimeoutMs}ms.`);
231
+ }
232
+ return new Error(`Cloudflare API network error: ${error.message}`);
233
+ }
234
+ return new Error("Cloudflare API network error.");
235
+ }
236
+ async extractError(response) {
237
+ try {
238
+ const payload = (await response.json());
239
+ const apiMessage = payload.errors?.map((err) => err.message).join("; ");
240
+ if (apiMessage) {
241
+ return apiMessage;
242
+ }
243
+ }
244
+ catch {
245
+ const text = await response.text().catch(() => "");
246
+ if (text) {
247
+ return text;
248
+ }
249
+ }
250
+ return `Cloudflare API request failed with HTTP ${response.status}.`;
251
+ }
252
+ }
@@ -0,0 +1,93 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
2
+ const ENCRYPTION_FORMAT = "cfenv-aes-256-gcm-v1";
3
+ const ENCRYPTION_ALGO = "aes-256-gcm";
4
+ const KEY_LENGTH_BYTES = 32;
5
+ const SALT_LENGTH_BYTES = 16;
6
+ const IV_LENGTH_BYTES = 12;
7
+ const DEFAULT_SECRET_BYTES = 32;
8
+ function deriveKey(secret, salt) {
9
+ return scryptSync(secret, salt, KEY_LENGTH_BYTES);
10
+ }
11
+ function encode(value) {
12
+ return value.toString("base64");
13
+ }
14
+ function decode(value) {
15
+ return Buffer.from(value, "base64");
16
+ }
17
+ export function generateEncryptionSecret(byteLength = DEFAULT_SECRET_BYTES) {
18
+ if (!Number.isInteger(byteLength) || byteLength < 16 || byteLength > 1024) {
19
+ throw new Error("Encryption secret length must be an integer between 16 and 1024 bytes.");
20
+ }
21
+ return randomBytes(byteLength).toString("base64url");
22
+ }
23
+ export function encryptSnapshotPayload(plaintext, secret) {
24
+ if (!secret.trim()) {
25
+ throw new Error("Missing encryption secret.");
26
+ }
27
+ const salt = randomBytes(SALT_LENGTH_BYTES);
28
+ const iv = randomBytes(IV_LENGTH_BYTES);
29
+ const key = deriveKey(secret, salt);
30
+ const cipher = createCipheriv(ENCRYPTION_ALGO, key, iv);
31
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
32
+ const authTag = cipher.getAuthTag();
33
+ const envelope = {
34
+ format: ENCRYPTION_FORMAT,
35
+ kdf: "scrypt",
36
+ saltB64: encode(salt),
37
+ ivB64: encode(iv),
38
+ authTagB64: encode(authTag),
39
+ ciphertextB64: encode(ciphertext)
40
+ };
41
+ return JSON.stringify(envelope);
42
+ }
43
+ function isEncryptedEnvelope(value) {
44
+ if (!value || typeof value !== "object") {
45
+ return false;
46
+ }
47
+ const item = value;
48
+ return (item.format === ENCRYPTION_FORMAT &&
49
+ item.kdf === "scrypt" &&
50
+ typeof item.saltB64 === "string" &&
51
+ typeof item.ivB64 === "string" &&
52
+ typeof item.authTagB64 === "string" &&
53
+ typeof item.ciphertextB64 === "string");
54
+ }
55
+ export function decryptSnapshotPayload(payload, secret) {
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(payload);
59
+ }
60
+ catch {
61
+ return payload;
62
+ }
63
+ if (!isEncryptedEnvelope(parsed)) {
64
+ return payload;
65
+ }
66
+ if (!secret?.trim()) {
67
+ throw new Error("Snapshot is encrypted. Pass --encryption-key or set CFENV_ENCRYPTION_KEY.");
68
+ }
69
+ const envelope = parsed;
70
+ const salt = decode(envelope.saltB64);
71
+ const iv = decode(envelope.ivB64);
72
+ const authTag = decode(envelope.authTagB64);
73
+ const ciphertext = decode(envelope.ciphertextB64);
74
+ const key = deriveKey(secret, salt);
75
+ try {
76
+ const decipher = createDecipheriv(ENCRYPTION_ALGO, key, iv);
77
+ decipher.setAuthTag(authTag);
78
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
79
+ return plaintext.toString("utf8");
80
+ }
81
+ catch {
82
+ throw new Error("Failed to decrypt snapshot. Encryption key is missing or incorrect.");
83
+ }
84
+ }
85
+ export function isEncryptedSnapshotPayload(payload) {
86
+ try {
87
+ const parsed = JSON.parse(payload);
88
+ return isEncryptedEnvelope(parsed);
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ }
@@ -0,0 +1,60 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ const VALID_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
4
+ function decodeValue(rawValue) {
5
+ const value = rawValue.trim();
6
+ if (value.startsWith("\"") && value.endsWith("\"")) {
7
+ try {
8
+ return JSON.parse(value);
9
+ }
10
+ catch {
11
+ return value.slice(1, -1);
12
+ }
13
+ }
14
+ if (value.startsWith("'") && value.endsWith("'")) {
15
+ return value.slice(1, -1);
16
+ }
17
+ return value;
18
+ }
19
+ export async function parseEnvFile(filePath) {
20
+ const absolutePath = path.resolve(filePath);
21
+ const raw = await fs.readFile(absolutePath, "utf8");
22
+ const entries = {};
23
+ for (const line of raw.split(/\r?\n/)) {
24
+ const trimmed = line.trim();
25
+ if (!trimmed || trimmed.startsWith("#")) {
26
+ continue;
27
+ }
28
+ const exportPrefix = trimmed.startsWith("export ") ? "export ".length : 0;
29
+ const content = trimmed.slice(exportPrefix);
30
+ const separator = content.indexOf("=");
31
+ if (separator <= 0) {
32
+ continue;
33
+ }
34
+ const key = content.slice(0, separator).trim();
35
+ if (!VALID_ENV_KEY.test(key)) {
36
+ throw new Error(`Invalid env key "${key}" in ${absolutePath}.`);
37
+ }
38
+ const value = decodeValue(content.slice(separator + 1));
39
+ entries[key] = value;
40
+ }
41
+ return entries;
42
+ }
43
+ export function serializeEnvFile(entries) {
44
+ const lines = Object.keys(entries)
45
+ .sort((a, b) => a.localeCompare(b))
46
+ .map((key) => `${key}=${JSON.stringify(entries[key])}`);
47
+ return `${lines.join("\n")}\n`;
48
+ }
49
+ export async function writeTextFileAtomic(filePath, content) {
50
+ const absolutePath = path.resolve(filePath);
51
+ const tmpPath = `${absolutePath}.cfenv.tmp-${process.pid}-${Date.now()}`;
52
+ await fs.writeFile(tmpPath, content, "utf8");
53
+ if (process.platform !== "win32") {
54
+ await fs.chmod(tmpPath, 0o600).catch(() => undefined);
55
+ }
56
+ await fs.rename(tmpPath, absolutePath);
57
+ }
58
+ export async function writeEnvFileAtomic(filePath, content) {
59
+ await writeTextFileAtomic(filePath, content);
60
+ }
@@ -0,0 +1,22 @@
1
+ import { promises as fs } from "node:fs";
2
+ export async function exists(filePath) {
3
+ try {
4
+ await fs.access(filePath);
5
+ return true;
6
+ }
7
+ catch {
8
+ return false;
9
+ }
10
+ }
11
+ export async function ensurePrivateDir(dirPath) {
12
+ await fs.mkdir(dirPath, { recursive: true });
13
+ if (process.platform !== "win32") {
14
+ await fs.chmod(dirPath, 0o700).catch(() => undefined);
15
+ }
16
+ }
17
+ export async function writePrivateFile(filePath, content) {
18
+ await fs.writeFile(filePath, content, { encoding: "utf8" });
19
+ if (process.platform !== "win32") {
20
+ await fs.chmod(filePath, 0o600).catch(() => undefined);
21
+ }
22
+ }
@@ -0,0 +1,17 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ export function canonicalizeEntries(entries) {
3
+ return Object.keys(entries)
4
+ .sort((a, b) => a.localeCompare(b))
5
+ .map((key) => `${key}=${JSON.stringify(entries[key])}`)
6
+ .join("\n");
7
+ }
8
+ export function sha256(input) {
9
+ return createHash("sha256").update(input).digest("hex");
10
+ }
11
+ export function checksumEntries(entries) {
12
+ return sha256(canonicalizeEntries(entries));
13
+ }
14
+ export function makeVersionId(now = new Date()) {
15
+ const stamp = now.toISOString().replace(/[-:.TZ]/g, "");
16
+ return `${stamp}-${randomUUID().slice(0, 8)}`;
17
+ }
@@ -0,0 +1,21 @@
1
+ function base(link) {
2
+ return `${link.keyPrefix}:${link.project}:${link.environment}`;
3
+ }
4
+ export function currentPointerKey(link) {
5
+ return `${base(link)}:current`;
6
+ }
7
+ export function versionsPrefix(link) {
8
+ return `${base(link)}:versions:`;
9
+ }
10
+ export function versionKey(link, versionId) {
11
+ return `${versionsPrefix(link)}${versionId}`;
12
+ }
13
+ export function flatEnvVarsPrefix(link) {
14
+ return `${base(link)}:vars:`;
15
+ }
16
+ export function flatEnvVarKey(link, envVarName) {
17
+ return `${flatEnvVarsPrefix(link)}${envVarName}`;
18
+ }
19
+ export function flatEnvMetaKey(link) {
20
+ return `${base(link)}:meta`;
21
+ }
@@ -0,0 +1,180 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { ensurePrivateDir, exists, writePrivateFile } from "./fs-utils.js";
3
+ import { getLocalConfigDir, getLocalConfigPath } from "./paths.js";
4
+ function makeLinkKey(project, environment) {
5
+ return `${project}:${environment}`;
6
+ }
7
+ function assertStringField(record, field) {
8
+ const value = record[field];
9
+ if (typeof value !== "string" || !value.trim()) {
10
+ throw new Error(`Invalid local config: "${field}" must be a non-empty string.`);
11
+ }
12
+ return value;
13
+ }
14
+ function parseProjectLink(raw) {
15
+ if (!raw || typeof raw !== "object") {
16
+ throw new Error("Invalid local config: link entry must be an object.");
17
+ }
18
+ const record = raw;
19
+ const storageModeRaw = record.storageMode;
20
+ const storageMode = storageModeRaw === "snapshot" || storageModeRaw === "flat" ? storageModeRaw : undefined;
21
+ return {
22
+ version: 1,
23
+ profile: assertStringField(record, "profile"),
24
+ namespaceId: assertStringField(record, "namespaceId"),
25
+ keyPrefix: assertStringField(record, "keyPrefix"),
26
+ project: assertStringField(record, "project"),
27
+ environment: assertStringField(record, "environment"),
28
+ storageMode
29
+ };
30
+ }
31
+ function normalizeConfig(raw) {
32
+ if (!raw || typeof raw !== "object") {
33
+ throw new Error("Invalid local config format.");
34
+ }
35
+ const parsed = raw;
36
+ if (parsed.version === 2 && typeof parsed.links === "object" && parsed.links !== null) {
37
+ const linksRaw = parsed.links;
38
+ const links = {};
39
+ for (const [key, value] of Object.entries(linksRaw)) {
40
+ links[key] = parseProjectLink(value);
41
+ }
42
+ return {
43
+ version: 2,
44
+ defaultLinkKey: typeof parsed.defaultLinkKey === "string" ? parsed.defaultLinkKey : undefined,
45
+ links
46
+ };
47
+ }
48
+ // Backward compatibility with old single-link config format.
49
+ if (typeof parsed.project === "string" && typeof parsed.environment === "string") {
50
+ const link = parseProjectLink(parsed);
51
+ const key = makeLinkKey(link.project, link.environment);
52
+ return {
53
+ version: 2,
54
+ defaultLinkKey: key,
55
+ links: {
56
+ [key]: link
57
+ }
58
+ };
59
+ }
60
+ throw new Error("Invalid local config format.");
61
+ }
62
+ export async function loadLocalConfig(cwd = process.cwd()) {
63
+ const configPath = getLocalConfigPath(cwd);
64
+ if (!(await exists(configPath))) {
65
+ return null;
66
+ }
67
+ const raw = await fs.readFile(configPath, "utf8");
68
+ const parsed = JSON.parse(raw);
69
+ return normalizeConfig(parsed);
70
+ }
71
+ export async function saveLocalConfig(config, cwd = process.cwd()) {
72
+ const configDir = getLocalConfigDir(cwd);
73
+ const configPath = getLocalConfigPath(cwd);
74
+ await ensurePrivateDir(configDir);
75
+ await writePrivateFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
76
+ }
77
+ export async function upsertLocalLink(link, input = {}) {
78
+ const cwd = input.cwd ?? process.cwd();
79
+ const existing = await loadLocalConfig(cwd);
80
+ const config = existing ?? {
81
+ version: 2,
82
+ defaultLinkKey: undefined,
83
+ links: {}
84
+ };
85
+ const key = makeLinkKey(link.project, link.environment);
86
+ config.links[key] = link;
87
+ if (input.setAsDefault ?? true) {
88
+ config.defaultLinkKey = key;
89
+ }
90
+ await saveLocalConfig(config, cwd);
91
+ }
92
+ export async function listLocalLinks(cwd = process.cwd()) {
93
+ const config = await loadLocalConfig(cwd);
94
+ if (!config) {
95
+ return [];
96
+ }
97
+ return Object.values(config.links).sort((a, b) => {
98
+ const projectCmp = a.project.localeCompare(b.project);
99
+ if (projectCmp !== 0) {
100
+ return projectCmp;
101
+ }
102
+ return a.environment.localeCompare(b.environment);
103
+ });
104
+ }
105
+ function resolveFromFilters(links, input) {
106
+ return links.filter((link) => {
107
+ if (input.project && link.project !== input.project) {
108
+ return false;
109
+ }
110
+ if (input.environment && link.environment !== input.environment) {
111
+ return false;
112
+ }
113
+ return true;
114
+ });
115
+ }
116
+ export async function requireLocalConfig(input = {}) {
117
+ const cwd = input.cwd ?? process.cwd();
118
+ const config = await loadLocalConfig(cwd);
119
+ if (!config) {
120
+ throw new Error("Missing local config. Run `cfenv setup` or `cfenv link` first.");
121
+ }
122
+ const links = Object.values(config.links);
123
+ if (!links.length) {
124
+ throw new Error("No links found in local config. Run `cfenv setup` or `cfenv link` first.");
125
+ }
126
+ const matches = resolveFromFilters(links, {
127
+ project: input.project,
128
+ environment: input.environment
129
+ });
130
+ if (matches.length === 1) {
131
+ return matches[0];
132
+ }
133
+ if (matches.length > 1) {
134
+ if (config.defaultLinkKey) {
135
+ const defaultLink = config.links[config.defaultLinkKey];
136
+ if (defaultLink && matches.some((item) => item.project === defaultLink.project && item.environment === defaultLink.environment)) {
137
+ return defaultLink;
138
+ }
139
+ }
140
+ const options = matches.map((item) => `${item.project}/${item.environment}`).join(", ");
141
+ throw new Error(`Multiple matching links found. Specify --project/--env. Options: ${options}`);
142
+ }
143
+ if (input.project || input.environment) {
144
+ throw new Error(`No link found for project/env filters (${input.project ?? "*"} / ${input.environment ?? "*"}).`);
145
+ }
146
+ if (config.defaultLinkKey) {
147
+ const defaultLink = config.links[config.defaultLinkKey];
148
+ if (defaultLink) {
149
+ return defaultLink;
150
+ }
151
+ }
152
+ if (links.length === 1) {
153
+ return links[0];
154
+ }
155
+ const options = links.map((item) => `${item.project}/${item.environment}`).join(", ");
156
+ throw new Error(`Multiple environments configured. Specify --env (and optionally --project). Options: ${options}`);
157
+ }
158
+ export async function setDefaultLocalLink(input) {
159
+ const cwd = input.cwd ?? process.cwd();
160
+ const config = await loadLocalConfig(cwd);
161
+ if (!config) {
162
+ throw new Error("Missing local config. Run `cfenv setup` or `cfenv link` first.");
163
+ }
164
+ const links = Object.values(config.links);
165
+ const matches = resolveFromFilters(links, {
166
+ project: input.project,
167
+ environment: input.environment
168
+ });
169
+ if (matches.length !== 1) {
170
+ if (!matches.length) {
171
+ throw new Error(`No link found for environment "${input.environment}".`);
172
+ }
173
+ const options = matches.map((item) => `${item.project}/${item.environment}`).join(", ");
174
+ throw new Error(`Multiple matches for environment. Pass --project. Options: ${options}`);
175
+ }
176
+ const target = matches[0];
177
+ config.defaultLinkKey = makeLinkKey(target.project, target.environment);
178
+ await saveLocalConfig(config, cwd);
179
+ return target;
180
+ }
@@ -0,0 +1,21 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ export function getGlobalConfigDir() {
4
+ if (process.platform === "win32") {
5
+ return path.join(process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"), "cfenv");
6
+ }
7
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
8
+ if (xdgConfig) {
9
+ return path.join(xdgConfig, "cfenv");
10
+ }
11
+ return path.join(os.homedir(), ".config", "cfenv");
12
+ }
13
+ export function getProfilesPath() {
14
+ return path.join(getGlobalConfigDir(), "profiles.json");
15
+ }
16
+ export function getLocalConfigDir(cwd) {
17
+ return path.join(cwd, ".cfenv");
18
+ }
19
+ export function getLocalConfigPath(cwd) {
20
+ return path.join(getLocalConfigDir(cwd), "config.json");
21
+ }