dotswitch 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +586 -0
- package/dist/index.cjs +246 -0
- package/dist/index.d.cts +69 -0
- package/dist/index.d.mts +69 -0
- package/dist/index.mjs +202 -0
- package/package.json +79 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import pc from "picocolors";
|
|
7
|
+
import select from "@inquirer/select";
|
|
8
|
+
|
|
9
|
+
//#region src/lib/constants.ts
|
|
10
|
+
const ENV_LOCAL = ".env.local";
|
|
11
|
+
const TRACKER_PREFIX = "# dotswitch:";
|
|
12
|
+
const EXCLUDED_ENV_FILES = new Set([
|
|
13
|
+
".env",
|
|
14
|
+
".env.local",
|
|
15
|
+
".env.local.backup",
|
|
16
|
+
".env.example"
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/lib/tracker.ts
|
|
21
|
+
function createTrackerHeader(env) {
|
|
22
|
+
return `${TRACKER_PREFIX}${env}`;
|
|
23
|
+
}
|
|
24
|
+
function parseTrackerHeader(content) {
|
|
25
|
+
const firstLine = content.split("\n")[0];
|
|
26
|
+
if (firstLine?.startsWith(TRACKER_PREFIX)) return firstLine.slice(TRACKER_PREFIX.length).trim();
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function addTrackerHeader(content, env) {
|
|
30
|
+
const header = createTrackerHeader(env);
|
|
31
|
+
if (parseTrackerHeader(content) !== null) {
|
|
32
|
+
const lines = content.split("\n");
|
|
33
|
+
lines[0] = header;
|
|
34
|
+
return lines.join("\n");
|
|
35
|
+
}
|
|
36
|
+
return `${header}\n${content}`;
|
|
37
|
+
}
|
|
38
|
+
function removeTrackerHeader(content) {
|
|
39
|
+
if (parseTrackerHeader(content) !== null) {
|
|
40
|
+
const lines = content.split("\n");
|
|
41
|
+
lines.shift();
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
44
|
+
return content;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/lib/logger.ts
|
|
49
|
+
const logger = {
|
|
50
|
+
success(message) {
|
|
51
|
+
console.log(pc.green(`✓ ${message}`));
|
|
52
|
+
},
|
|
53
|
+
info(message) {
|
|
54
|
+
console.log(pc.cyan(message));
|
|
55
|
+
},
|
|
56
|
+
warn(message) {
|
|
57
|
+
console.log(pc.yellow(`⚠ ${message}`));
|
|
58
|
+
},
|
|
59
|
+
error(message) {
|
|
60
|
+
console.error(pc.red(`✗ ${message}`));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/lib/config.ts
|
|
66
|
+
const CONFIG_FILENAME = ".dotswitchrc.json";
|
|
67
|
+
const DEFAULT_CONFIG = {
|
|
68
|
+
target: ".env.local",
|
|
69
|
+
exclude: [],
|
|
70
|
+
hooks: {}
|
|
71
|
+
};
|
|
72
|
+
function loadConfig(dir, fsModule = fs) {
|
|
73
|
+
const configPath = path.join(dir, CONFIG_FILENAME);
|
|
74
|
+
try {
|
|
75
|
+
if (fsModule.existsSync(configPath)) {
|
|
76
|
+
const raw = JSON.parse(fsModule.readFileSync(configPath, "utf-8"));
|
|
77
|
+
return {
|
|
78
|
+
target: raw.target ?? DEFAULT_CONFIG.target,
|
|
79
|
+
exclude: raw.exclude ?? DEFAULT_CONFIG.exclude,
|
|
80
|
+
hooks: raw.hooks ?? DEFAULT_CONFIG.hooks
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
} catch {}
|
|
84
|
+
return { ...DEFAULT_CONFIG };
|
|
85
|
+
}
|
|
86
|
+
function getTargetFile(config) {
|
|
87
|
+
return config.target;
|
|
88
|
+
}
|
|
89
|
+
function getBackupFile(config) {
|
|
90
|
+
return `${config.target}.backup`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/lib/env.ts
|
|
95
|
+
function resolveConfig(dir, config, fsModule) {
|
|
96
|
+
return config ?? loadConfig(dir, fsModule);
|
|
97
|
+
}
|
|
98
|
+
function listEnvFiles(dir, fsModule = fs, config) {
|
|
99
|
+
const cfg = resolveConfig(dir, config, fsModule);
|
|
100
|
+
const entries = fsModule.readdirSync(dir);
|
|
101
|
+
const activeEnv = getActiveEnv(dir, fsModule, cfg);
|
|
102
|
+
const target = getTargetFile(cfg);
|
|
103
|
+
const backup = getBackupFile(cfg);
|
|
104
|
+
const excluded = new Set([
|
|
105
|
+
...EXCLUDED_ENV_FILES,
|
|
106
|
+
...cfg.exclude,
|
|
107
|
+
target,
|
|
108
|
+
backup
|
|
109
|
+
]);
|
|
110
|
+
return entries.filter((name) => name.startsWith(".env.") && !excluded.has(name)).sort().map((name) => {
|
|
111
|
+
const env = name.replace(/^\.env\./, "");
|
|
112
|
+
return {
|
|
113
|
+
name,
|
|
114
|
+
env,
|
|
115
|
+
path: path.join(dir, name),
|
|
116
|
+
active: env === activeEnv
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function getActiveEnv(dir, fsModule = fs, config) {
|
|
121
|
+
const cfg = resolveConfig(dir, config, fsModule);
|
|
122
|
+
const targetPath = path.join(dir, getTargetFile(cfg));
|
|
123
|
+
try {
|
|
124
|
+
return parseTrackerHeader(fsModule.readFileSync(targetPath, "utf-8"));
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function backupEnvLocal(dir, fsModule = fs, config) {
|
|
130
|
+
const cfg = resolveConfig(dir, config, fsModule);
|
|
131
|
+
const target = getTargetFile(cfg);
|
|
132
|
+
const targetPath = path.join(dir, target);
|
|
133
|
+
const backupPath = path.join(dir, getBackupFile(cfg));
|
|
134
|
+
try {
|
|
135
|
+
if (fsModule.existsSync(targetPath)) {
|
|
136
|
+
fsModule.copyFileSync(targetPath, backupPath);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
logger.warn(`Failed to back up ${target}: ${error instanceof Error ? error.message : String(error)}`);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function restoreEnvLocal(dir, fsModule = fs, config) {
|
|
146
|
+
const cfg = resolveConfig(dir, config, fsModule);
|
|
147
|
+
const target = getTargetFile(cfg);
|
|
148
|
+
const backup = getBackupFile(cfg);
|
|
149
|
+
const backupPath = path.join(dir, backup);
|
|
150
|
+
const targetPath = path.join(dir, target);
|
|
151
|
+
if (!fsModule.existsSync(backupPath)) throw new Error(`No backup file found (${backup})`);
|
|
152
|
+
fsModule.copyFileSync(backupPath, targetPath);
|
|
153
|
+
}
|
|
154
|
+
function switchEnv(dir, env, options = { backup: true }, fsModule = fs, config) {
|
|
155
|
+
const cfg = resolveConfig(dir, config, fsModule);
|
|
156
|
+
const sourcePath = path.join(dir, `.env.${env}`);
|
|
157
|
+
const targetPath = path.join(dir, getTargetFile(cfg));
|
|
158
|
+
if (!fsModule.existsSync(sourcePath)) throw new Error(`Environment file .env.${env} does not exist`);
|
|
159
|
+
if (options.backup) backupEnvLocal(dir, fsModule, cfg);
|
|
160
|
+
const tracked = addTrackerHeader(fsModule.readFileSync(sourcePath, "utf-8"), env);
|
|
161
|
+
fsModule.writeFileSync(targetPath, tracked, "utf-8");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/lib/paths.ts
|
|
166
|
+
/**
|
|
167
|
+
* Resolve a path pattern that may contain a simple glob (* wildcard).
|
|
168
|
+
* Returns an array of matching directories.
|
|
169
|
+
* Non-glob paths return a single-element array.
|
|
170
|
+
*/
|
|
171
|
+
function resolvePaths(pattern) {
|
|
172
|
+
if (!pattern.includes("*")) return [path.resolve(pattern)];
|
|
173
|
+
const parts = pattern.split("*");
|
|
174
|
+
if (parts.length !== 2) return [path.resolve(pattern)];
|
|
175
|
+
const baseDir = path.resolve(parts[0]);
|
|
176
|
+
const suffix = parts[1];
|
|
177
|
+
if (!fs.existsSync(baseDir)) return [];
|
|
178
|
+
return fs.readdirSync(baseDir).map((entry) => path.join(baseDir, entry) + suffix).filter((p) => {
|
|
179
|
+
try {
|
|
180
|
+
return fs.statSync(p).isDirectory();
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}).sort();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/lib/prompt.ts
|
|
189
|
+
async function promptEnvSelection(envFiles) {
|
|
190
|
+
return await select({
|
|
191
|
+
message: "Select an environment:",
|
|
192
|
+
choices: envFiles.map((file) => ({
|
|
193
|
+
name: file.active ? `${file.env} (active)` : file.env,
|
|
194
|
+
value: file.env
|
|
195
|
+
}))
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/commands/use.ts
|
|
201
|
+
async function useSinglePath(env, options, dir, showPrefix) {
|
|
202
|
+
const prefix = showPrefix ? `[${dir}] ` : "";
|
|
203
|
+
const files = listEnvFiles(dir);
|
|
204
|
+
if (files.length === 0) {
|
|
205
|
+
logger.error(`${prefix}No .env.* files found`);
|
|
206
|
+
process.exitCode = 1;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!env) {
|
|
210
|
+
if (!process.stdin.isTTY) {
|
|
211
|
+
logger.error(`${prefix}No environment specified. Usage: dotswitch use <env>`);
|
|
212
|
+
process.exitCode = 1;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
env = await promptEnvSelection(files);
|
|
216
|
+
}
|
|
217
|
+
if (!files.find((f) => f.env === env)) {
|
|
218
|
+
logger.error(`${prefix}Environment "${env}" not found. Available: ${files.map((f) => f.env).join(", ")}`);
|
|
219
|
+
process.exitCode = 1;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (getActiveEnv(dir) === env && !options.force) {
|
|
223
|
+
logger.info(`${prefix}Already using "${env}"`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (options.dryRun) {
|
|
227
|
+
logger.info(`${prefix}Would switch to ${env}`);
|
|
228
|
+
if (options.backup) logger.info(`${prefix}Would back up .env.local to .env.local.backup`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
switchEnv(dir, env, { backup: options.backup });
|
|
233
|
+
logger.success(`${prefix}Switched to ${env}`);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
logger.error(`${prefix}${error instanceof Error ? error.message : "Failed to switch environment"}`);
|
|
236
|
+
process.exitCode = 1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function useCommand(env, options) {
|
|
240
|
+
const dirs = resolvePaths(options.path);
|
|
241
|
+
if (dirs.length === 0) {
|
|
242
|
+
logger.error("No directories match the given path pattern");
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const showPrefix = dirs.length > 1;
|
|
247
|
+
for (const dir of dirs) await useSinglePath(env, options, dir, showPrefix);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region src/commands/ls.ts
|
|
252
|
+
function lsCommand(options) {
|
|
253
|
+
const files = listEnvFiles(options.path);
|
|
254
|
+
if (options.json) {
|
|
255
|
+
const output = files.map(({ name, env, active }) => ({
|
|
256
|
+
name,
|
|
257
|
+
env,
|
|
258
|
+
active
|
|
259
|
+
}));
|
|
260
|
+
console.log(JSON.stringify(output));
|
|
261
|
+
if (files.length === 0) process.exitCode = 1;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (files.length === 0) {
|
|
265
|
+
logger.warn("No .env.* files found");
|
|
266
|
+
process.exitCode = 1;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
console.log(pc.bold("Available environments:\n"));
|
|
270
|
+
for (const file of files) {
|
|
271
|
+
const marker = file.active ? pc.green("▸ ") : " ";
|
|
272
|
+
const name = file.active ? pc.green(pc.bold(file.env)) : file.env;
|
|
273
|
+
const label = file.active ? ` ${pc.dim("(active)")}` : "";
|
|
274
|
+
console.log(`${marker}${name}${label}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
//#endregion
|
|
279
|
+
//#region src/commands/current.ts
|
|
280
|
+
function currentCommand(options) {
|
|
281
|
+
const activeEnv = getActiveEnv(options.path);
|
|
282
|
+
if (options.json) {
|
|
283
|
+
console.log(JSON.stringify({ active: activeEnv ?? null }));
|
|
284
|
+
if (!activeEnv) process.exitCode = 1;
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const isTTY = process.stdout.isTTY;
|
|
288
|
+
if (activeEnv) if (isTTY) logger.info(`Active environment: ${activeEnv}`);
|
|
289
|
+
else console.log(activeEnv);
|
|
290
|
+
else {
|
|
291
|
+
if (isTTY) logger.warn("No active environment detected");
|
|
292
|
+
process.exitCode = 1;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/commands/restore.ts
|
|
298
|
+
function restoreCommand(options) {
|
|
299
|
+
try {
|
|
300
|
+
restoreEnvLocal(options.path);
|
|
301
|
+
const activeEnv = getActiveEnv(options.path);
|
|
302
|
+
if (activeEnv) logger.success(`Restored .env.local from backup (now: ${activeEnv})`);
|
|
303
|
+
else logger.success("Restored .env.local from backup");
|
|
304
|
+
} catch (error) {
|
|
305
|
+
logger.error(error instanceof Error ? error.message : "Failed to restore backup");
|
|
306
|
+
process.exitCode = 1;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
//#endregion
|
|
311
|
+
//#region src/lib/parser.ts
|
|
312
|
+
/**
|
|
313
|
+
* Parse a .env file into a key-value map.
|
|
314
|
+
* Strips comments (lines starting with #) and empty lines.
|
|
315
|
+
*/
|
|
316
|
+
function parseEnvContent(content) {
|
|
317
|
+
const result = /* @__PURE__ */ new Map();
|
|
318
|
+
for (const line of content.split("\n")) {
|
|
319
|
+
const trimmed = line.trim();
|
|
320
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
321
|
+
const eqIndex = trimmed.indexOf("=");
|
|
322
|
+
if (eqIndex === -1) continue;
|
|
323
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
324
|
+
const value = trimmed.slice(eqIndex + 1).trim();
|
|
325
|
+
if (key) result.set(key, value);
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Compute the diff between two parsed env maps.
|
|
331
|
+
* "added" = keys in `to` but not in `from`.
|
|
332
|
+
* "removed" = keys in `from` but not in `to`.
|
|
333
|
+
* "changed" = keys in both with different values.
|
|
334
|
+
*/
|
|
335
|
+
function diffEnvMaps(from, to) {
|
|
336
|
+
const added = [];
|
|
337
|
+
const removed = [];
|
|
338
|
+
const changed = [];
|
|
339
|
+
const unchanged = [];
|
|
340
|
+
for (const key of from.keys()) if (!to.has(key)) removed.push(key);
|
|
341
|
+
else if (from.get(key) !== to.get(key)) changed.push(key);
|
|
342
|
+
else unchanged.push(key);
|
|
343
|
+
for (const key of to.keys()) if (!from.has(key)) added.push(key);
|
|
344
|
+
return {
|
|
345
|
+
added: added.sort(),
|
|
346
|
+
removed: removed.sort(),
|
|
347
|
+
changed: changed.sort(),
|
|
348
|
+
unchanged: unchanged.sort()
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
//#endregion
|
|
353
|
+
//#region src/commands/diff.ts
|
|
354
|
+
function readEnvFile(dir, name) {
|
|
355
|
+
const filePath = name === ".env.local" ? path.join(dir, ENV_LOCAL) : path.join(dir, `.env.${name}`);
|
|
356
|
+
if (!fs.existsSync(filePath)) {
|
|
357
|
+
const literal = path.join(dir, name);
|
|
358
|
+
if (fs.existsSync(literal)) return removeTrackerHeader(fs.readFileSync(literal, "utf-8"));
|
|
359
|
+
throw new Error(`File not found: ${name}`);
|
|
360
|
+
}
|
|
361
|
+
return removeTrackerHeader(fs.readFileSync(filePath, "utf-8"));
|
|
362
|
+
}
|
|
363
|
+
function diffCommand(env1, env2, options) {
|
|
364
|
+
try {
|
|
365
|
+
const fromName = env2 ? env1 : ".env.local";
|
|
366
|
+
const toName = env2 ?? env1;
|
|
367
|
+
const fromContent = readEnvFile(options.path, fromName);
|
|
368
|
+
const toContent = readEnvFile(options.path, toName);
|
|
369
|
+
const fromMap = parseEnvContent(fromContent);
|
|
370
|
+
const toMap = parseEnvContent(toContent);
|
|
371
|
+
const diff = diffEnvMaps(fromMap, toMap);
|
|
372
|
+
if (options.json) {
|
|
373
|
+
const output = {
|
|
374
|
+
from: fromName,
|
|
375
|
+
to: toName,
|
|
376
|
+
added: diff.added,
|
|
377
|
+
removed: diff.removed,
|
|
378
|
+
changed: diff.changed
|
|
379
|
+
};
|
|
380
|
+
if (options.showValues) output.details = {
|
|
381
|
+
added: Object.fromEntries(diff.added.map((k) => [k, toMap.get(k)])),
|
|
382
|
+
removed: Object.fromEntries(diff.removed.map((k) => [k, fromMap.get(k)])),
|
|
383
|
+
changed: Object.fromEntries(diff.changed.map((k) => [k, {
|
|
384
|
+
from: fromMap.get(k),
|
|
385
|
+
to: toMap.get(k)
|
|
386
|
+
}]))
|
|
387
|
+
};
|
|
388
|
+
console.log(JSON.stringify(output));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (!(diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0)) {
|
|
392
|
+
logger.success(`${fromName} and ${toName} are identical`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
console.log(pc.bold(`\nComparing ${fromName} → ${toName}\n`));
|
|
396
|
+
if (diff.added.length > 0) {
|
|
397
|
+
console.log(pc.green(`Added (${diff.added.length}):`));
|
|
398
|
+
for (const key of diff.added) {
|
|
399
|
+
const val = options.showValues ? ` = ${toMap.get(key)}` : "";
|
|
400
|
+
console.log(pc.green(` + ${key}${val}`));
|
|
401
|
+
}
|
|
402
|
+
console.log();
|
|
403
|
+
}
|
|
404
|
+
if (diff.removed.length > 0) {
|
|
405
|
+
console.log(pc.red(`Removed (${diff.removed.length}):`));
|
|
406
|
+
for (const key of diff.removed) {
|
|
407
|
+
const val = options.showValues ? ` = ${fromMap.get(key)}` : "";
|
|
408
|
+
console.log(pc.red(` - ${key}${val}`));
|
|
409
|
+
}
|
|
410
|
+
console.log();
|
|
411
|
+
}
|
|
412
|
+
if (diff.changed.length > 0) {
|
|
413
|
+
console.log(pc.yellow(`Changed (${diff.changed.length}):`));
|
|
414
|
+
for (const key of diff.changed) if (options.showValues) console.log(pc.yellow(` ~ ${key}: ${fromMap.get(key)} → ${toMap.get(key)}`));
|
|
415
|
+
else console.log(pc.yellow(` ~ ${key}`));
|
|
416
|
+
console.log();
|
|
417
|
+
}
|
|
418
|
+
} catch (error) {
|
|
419
|
+
logger.error(error instanceof Error ? error.message : "Failed to diff environments");
|
|
420
|
+
process.exitCode = 1;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region src/lib/hooks.ts
|
|
426
|
+
const HOOK_FILENAME = "post-checkout";
|
|
427
|
+
const HOOK_MARKER_START = "# >>> dotswitch hook >>>";
|
|
428
|
+
const HOOK_MARKER_END = "# <<< dotswitch hook <<<";
|
|
429
|
+
function getHookScript() {
|
|
430
|
+
return `${HOOK_MARKER_START}
|
|
431
|
+
# Auto-switch .env files based on branch name
|
|
432
|
+
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
|
|
433
|
+
if [ -n "$BRANCH" ] && command -v dotswitch >/dev/null 2>&1; then
|
|
434
|
+
dotswitch use --hook-branch "$BRANCH" 2>/dev/null || true
|
|
435
|
+
fi
|
|
436
|
+
${HOOK_MARKER_END}`;
|
|
437
|
+
}
|
|
438
|
+
function getHooksDir(dir) {
|
|
439
|
+
const gitDir = path.join(dir, ".git");
|
|
440
|
+
if (!fs.existsSync(gitDir)) return null;
|
|
441
|
+
return path.join(gitDir, "hooks");
|
|
442
|
+
}
|
|
443
|
+
function installHook(dir) {
|
|
444
|
+
const hooksDir = getHooksDir(dir);
|
|
445
|
+
if (!hooksDir) throw new Error("Not a git repository");
|
|
446
|
+
if (!fs.existsSync(hooksDir)) fs.mkdirSync(hooksDir, { recursive: true });
|
|
447
|
+
const hookPath = path.join(hooksDir, HOOK_FILENAME);
|
|
448
|
+
const hookScript = getHookScript();
|
|
449
|
+
if (fs.existsSync(hookPath)) {
|
|
450
|
+
const existing = fs.readFileSync(hookPath, "utf-8");
|
|
451
|
+
if (existing.includes(HOOK_MARKER_START)) {
|
|
452
|
+
const before = existing.slice(0, existing.indexOf(HOOK_MARKER_START));
|
|
453
|
+
const after = existing.slice(existing.indexOf(HOOK_MARKER_END) + 24);
|
|
454
|
+
fs.writeFileSync(hookPath, before + hookScript + after, { mode: 493 });
|
|
455
|
+
return {
|
|
456
|
+
created: false,
|
|
457
|
+
path: hookPath
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
fs.appendFileSync(hookPath, `\n${hookScript}\n`);
|
|
461
|
+
fs.chmodSync(hookPath, 493);
|
|
462
|
+
return {
|
|
463
|
+
created: false,
|
|
464
|
+
path: hookPath
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
fs.writeFileSync(hookPath, `#!/bin/sh\n${hookScript}\n`, { mode: 493 });
|
|
468
|
+
return {
|
|
469
|
+
created: true,
|
|
470
|
+
path: hookPath
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
function removeHook(dir) {
|
|
474
|
+
const hooksDir = getHooksDir(dir);
|
|
475
|
+
if (!hooksDir) throw new Error("Not a git repository");
|
|
476
|
+
const hookPath = path.join(hooksDir, HOOK_FILENAME);
|
|
477
|
+
if (!fs.existsSync(hookPath)) return false;
|
|
478
|
+
const content = fs.readFileSync(hookPath, "utf-8");
|
|
479
|
+
if (!content.includes(HOOK_MARKER_START)) return false;
|
|
480
|
+
const remaining = (content.slice(0, content.indexOf(HOOK_MARKER_START)) + content.slice(content.indexOf(HOOK_MARKER_END) + 24)).trim();
|
|
481
|
+
if (!remaining || remaining === "#!/bin/sh") fs.unlinkSync(hookPath);
|
|
482
|
+
else fs.writeFileSync(hookPath, remaining + "\n", { mode: 493 });
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Match a branch name against hook patterns from config.
|
|
487
|
+
* Supports simple glob patterns: "staging/*" matches "staging/feat-x".
|
|
488
|
+
*/
|
|
489
|
+
function matchBranchToEnv(branch, hooks) {
|
|
490
|
+
if (hooks[branch]) return hooks[branch];
|
|
491
|
+
for (const [pattern, env] of Object.entries(hooks)) if (pattern.endsWith("/*")) {
|
|
492
|
+
const prefix = pattern.slice(0, -2);
|
|
493
|
+
if (branch.startsWith(`${prefix}/`)) return env;
|
|
494
|
+
} else if (pattern.endsWith("*")) {
|
|
495
|
+
const prefix = pattern.slice(0, -1);
|
|
496
|
+
if (branch.startsWith(prefix)) return env;
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
//#endregion
|
|
502
|
+
//#region src/commands/hook.ts
|
|
503
|
+
function hookInstallCommand(options) {
|
|
504
|
+
try {
|
|
505
|
+
const config = loadConfig(options.path);
|
|
506
|
+
if (Object.keys(config.hooks).length === 0) {
|
|
507
|
+
logger.warn("No hook mappings defined. Add \"hooks\" to .dotswitchrc.json first.");
|
|
508
|
+
logger.info("Example: { \"hooks\": { \"staging/*\": \"staging\" } }");
|
|
509
|
+
process.exitCode = 1;
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (installHook(options.path).created) logger.success("Installed post-checkout hook");
|
|
513
|
+
else logger.success("Updated post-checkout hook");
|
|
514
|
+
} catch (error) {
|
|
515
|
+
logger.error(error instanceof Error ? error.message : "Failed to install hook");
|
|
516
|
+
process.exitCode = 1;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
function hookRemoveCommand(options) {
|
|
520
|
+
try {
|
|
521
|
+
if (removeHook(options.path)) logger.success("Removed dotswitch post-checkout hook");
|
|
522
|
+
else logger.info("No dotswitch hook found");
|
|
523
|
+
} catch (error) {
|
|
524
|
+
logger.error(error instanceof Error ? error.message : "Failed to remove hook");
|
|
525
|
+
process.exitCode = 1;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function hookBranchCommand(branch, options) {
|
|
529
|
+
const env = matchBranchToEnv(branch, loadConfig(options.path).hooks);
|
|
530
|
+
if (!env) return;
|
|
531
|
+
try {
|
|
532
|
+
switchEnv(options.path, env, { backup: true });
|
|
533
|
+
logger.success(`Auto-switched to ${env} (branch: ${branch})`);
|
|
534
|
+
} catch {}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
//#endregion
|
|
538
|
+
//#region src/cli.ts
|
|
539
|
+
const pkg = createRequire(import.meta.url)("../package.json");
|
|
540
|
+
const program = new Command();
|
|
541
|
+
program.name("dotswitch").description("Quickly switch between .env files").version(pkg.version);
|
|
542
|
+
program.command("use [env]").description("Switch to a .env.<env> file (interactive if no env given)").option("-f, --force", "skip confirmation if already active", false).option("--no-backup", "skip .env.local backup").option("-n, --dry-run", "show what would happen without making changes", false).option("-p, --path <dir>", "project directory", process.cwd()).option("--hook-branch <branch>", "internal: auto-switch by branch name").action(async (env, opts) => {
|
|
543
|
+
if (opts.hookBranch) {
|
|
544
|
+
hookBranchCommand(opts.hookBranch, { path: opts.path });
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
await useCommand(env, {
|
|
548
|
+
force: opts.force,
|
|
549
|
+
backup: opts.backup,
|
|
550
|
+
dryRun: opts.dryRun,
|
|
551
|
+
path: opts.path
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
program.command("ls").description("List available .env.* files").option("-p, --path <dir>", "project directory", process.cwd()).option("--json", "output as JSON", false).action((opts) => {
|
|
555
|
+
lsCommand({
|
|
556
|
+
path: opts.path,
|
|
557
|
+
json: opts.json
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
program.command("current").description("Show the currently active environment").option("-p, --path <dir>", "project directory", process.cwd()).option("--json", "output as JSON", false).action((opts) => {
|
|
561
|
+
currentCommand({
|
|
562
|
+
path: opts.path,
|
|
563
|
+
json: opts.json
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
program.command("restore").description("Restore .env.local from the backup file").option("-p, --path <dir>", "project directory", process.cwd()).action((opts) => {
|
|
567
|
+
restoreCommand({ path: opts.path });
|
|
568
|
+
});
|
|
569
|
+
program.command("diff <env1> [env2]").description("Compare keys between two env files (defaults: .env.local vs env1)").option("-p, --path <dir>", "project directory", process.cwd()).option("--show-values", "show actual values in the diff", false).option("--json", "output as JSON", false).action((env1, env2, opts) => {
|
|
570
|
+
diffCommand(env1, env2, {
|
|
571
|
+
path: opts.path,
|
|
572
|
+
showValues: opts.showValues,
|
|
573
|
+
json: opts.json
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
const hookCmd = program.command("hook").description("Manage git post-checkout hook for auto-switching");
|
|
577
|
+
hookCmd.command("install").description("Install the post-checkout git hook").option("-p, --path <dir>", "project directory", process.cwd()).action((opts) => {
|
|
578
|
+
hookInstallCommand({ path: opts.path });
|
|
579
|
+
});
|
|
580
|
+
hookCmd.command("remove").description("Remove the post-checkout git hook").option("-p, --path <dir>", "project directory", process.cwd()).action((opts) => {
|
|
581
|
+
hookRemoveCommand({ path: opts.path });
|
|
582
|
+
});
|
|
583
|
+
program.parse();
|
|
584
|
+
|
|
585
|
+
//#endregion
|
|
586
|
+
export { };
|