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/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 { };