@prnv/tuck 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/index.js ADDED
@@ -0,0 +1,2885 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command as Command12 } from "commander";
5
+ import chalk16 from "chalk";
6
+
7
+ // src/commands/init.ts
8
+ import { Command } from "commander";
9
+ import { join as join3 } from "path";
10
+ import { writeFile as writeFile3 } from "fs/promises";
11
+ import { ensureDir } from "fs-extra";
12
+
13
+ // src/ui/banner.ts
14
+ import chalk from "chalk";
15
+ import boxen from "boxen";
16
+ var banner = () => {
17
+ const art = `
18
+ \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557
19
+ \u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2554\u255D
20
+ \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2554\u255D
21
+ \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2588\u2588\u2557
22
+ \u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2557
23
+ \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D`;
24
+ console.log(chalk.cyan(art));
25
+ console.log(chalk.dim(" Modern Dotfiles Manager\n"));
26
+ };
27
+ var nextSteps = (steps) => {
28
+ const content = steps.map((step, i) => `${chalk.cyan(`${i + 1}.`)} ${step}`).join("\n");
29
+ console.log(
30
+ boxen(content, {
31
+ padding: 1,
32
+ margin: { top: 1, bottom: 0, left: 0, right: 0 },
33
+ borderStyle: "round",
34
+ borderColor: "cyan",
35
+ title: "Next Steps",
36
+ titleAlignment: "left"
37
+ })
38
+ );
39
+ };
40
+
41
+ // src/ui/logger.ts
42
+ import chalk2 from "chalk";
43
+ var logger = {
44
+ info: (msg) => {
45
+ console.log(chalk2.blue("\u2139"), msg);
46
+ },
47
+ success: (msg) => {
48
+ console.log(chalk2.green("\u2713"), msg);
49
+ },
50
+ warning: (msg) => {
51
+ console.log(chalk2.yellow("\u26A0"), msg);
52
+ },
53
+ error: (msg) => {
54
+ console.log(chalk2.red("\u2717"), msg);
55
+ },
56
+ debug: (msg) => {
57
+ if (process.env.DEBUG) {
58
+ console.log(chalk2.gray("\u2699"), chalk2.gray(msg));
59
+ }
60
+ },
61
+ step: (current, total, msg) => {
62
+ console.log(chalk2.dim(`[${current}/${total}]`), msg);
63
+ },
64
+ file: (action, path) => {
65
+ const icons = {
66
+ add: chalk2.green("+"),
67
+ modify: chalk2.yellow("~"),
68
+ delete: chalk2.red("-"),
69
+ sync: chalk2.blue("\u2194")
70
+ };
71
+ console.log(` ${icons[action]} ${path}`);
72
+ },
73
+ tree: (items) => {
74
+ items.forEach(({ name, isLast, indent = 0 }) => {
75
+ const indentation = " ".repeat(indent);
76
+ const prefix = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
77
+ console.log(chalk2.dim(indentation + prefix) + name);
78
+ });
79
+ },
80
+ blank: () => {
81
+ console.log();
82
+ },
83
+ dim: (msg) => {
84
+ console.log(chalk2.dim(msg));
85
+ },
86
+ heading: (msg) => {
87
+ console.log(chalk2.bold.cyan(msg));
88
+ }
89
+ };
90
+ var formatCount = (count, singular, plural) => {
91
+ const word = count === 1 ? singular : plural || `${singular}s`;
92
+ return `${chalk2.bold(count.toString())} ${word}`;
93
+ };
94
+ var formatStatus = (status) => {
95
+ switch (status) {
96
+ case "modified":
97
+ return chalk2.yellow("modified");
98
+ case "added":
99
+ return chalk2.green("added");
100
+ case "deleted":
101
+ return chalk2.red("deleted");
102
+ case "untracked":
103
+ return chalk2.gray("untracked");
104
+ default:
105
+ return status;
106
+ }
107
+ };
108
+
109
+ // src/ui/prompts.ts
110
+ import * as p from "@clack/prompts";
111
+ import chalk3 from "chalk";
112
+ var prompts = {
113
+ intro: (title) => {
114
+ p.intro(chalk3.bgCyan(chalk3.black(` ${title} `)));
115
+ },
116
+ outro: (message) => {
117
+ p.outro(chalk3.green(message));
118
+ },
119
+ confirm: async (message, initial = false) => {
120
+ const result = await p.confirm({ message, initialValue: initial });
121
+ if (p.isCancel(result)) {
122
+ prompts.cancel();
123
+ }
124
+ return result;
125
+ },
126
+ select: async (message, options) => {
127
+ const result = await p.select({
128
+ message,
129
+ options: options.map((opt) => ({
130
+ value: opt.value,
131
+ label: opt.label,
132
+ hint: opt.hint
133
+ }))
134
+ });
135
+ if (p.isCancel(result)) {
136
+ prompts.cancel();
137
+ }
138
+ return result;
139
+ },
140
+ multiselect: async (message, options, required = false) => {
141
+ const result = await p.multiselect({
142
+ message,
143
+ options: options.map((opt) => ({
144
+ value: opt.value,
145
+ label: opt.label,
146
+ hint: opt.hint
147
+ })),
148
+ required
149
+ });
150
+ if (p.isCancel(result)) {
151
+ prompts.cancel();
152
+ }
153
+ return result;
154
+ },
155
+ text: async (message, options) => {
156
+ const result = await p.text({
157
+ message,
158
+ placeholder: options?.placeholder,
159
+ defaultValue: options?.defaultValue,
160
+ validate: options?.validate
161
+ });
162
+ if (p.isCancel(result)) {
163
+ prompts.cancel();
164
+ }
165
+ return result;
166
+ },
167
+ password: async (message) => {
168
+ const result = await p.password({ message });
169
+ if (p.isCancel(result)) {
170
+ prompts.cancel();
171
+ }
172
+ return result;
173
+ },
174
+ spinner: () => p.spinner(),
175
+ note: (message, title) => {
176
+ p.note(message, title);
177
+ },
178
+ cancel: (message = "Operation cancelled") => {
179
+ p.cancel(message);
180
+ process.exit(0);
181
+ },
182
+ log: {
183
+ info: (message) => {
184
+ p.log.info(message);
185
+ },
186
+ success: (message) => {
187
+ p.log.success(message);
188
+ },
189
+ warning: (message) => {
190
+ p.log.warning(message);
191
+ },
192
+ error: (message) => {
193
+ p.log.error(message);
194
+ },
195
+ step: (message) => {
196
+ p.log.step(message);
197
+ },
198
+ message: (message) => {
199
+ p.log.message(message);
200
+ }
201
+ },
202
+ group: async (steps, options) => {
203
+ const results = await p.group(steps, {
204
+ onCancel: () => {
205
+ if (options?.onCancel) {
206
+ options.onCancel();
207
+ } else {
208
+ prompts.cancel();
209
+ }
210
+ }
211
+ });
212
+ return results;
213
+ }
214
+ };
215
+
216
+ // src/ui/spinner.ts
217
+ import ora from "ora";
218
+ import chalk4 from "chalk";
219
+ var createSpinner = (initialText) => {
220
+ const spinner2 = ora({
221
+ text: initialText,
222
+ color: "cyan",
223
+ spinner: "dots"
224
+ });
225
+ return {
226
+ start: (text2) => {
227
+ if (text2) spinner2.text = text2;
228
+ spinner2.start();
229
+ },
230
+ stop: () => {
231
+ spinner2.stop();
232
+ },
233
+ succeed: (text2) => {
234
+ spinner2.succeed(text2 ? chalk4.green(text2) : void 0);
235
+ },
236
+ fail: (text2) => {
237
+ spinner2.fail(text2 ? chalk4.red(text2) : void 0);
238
+ },
239
+ warn: (text2) => {
240
+ spinner2.warn(text2 ? chalk4.yellow(text2) : void 0);
241
+ },
242
+ info: (text2) => {
243
+ spinner2.info(text2 ? chalk4.blue(text2) : void 0);
244
+ },
245
+ text: (text2) => {
246
+ spinner2.text = text2;
247
+ }
248
+ };
249
+ };
250
+ var withSpinner = async (text2, fn, options) => {
251
+ const spinner2 = createSpinner(text2);
252
+ spinner2.start();
253
+ try {
254
+ const result = await fn();
255
+ spinner2.succeed(options?.successText || text2);
256
+ return result;
257
+ } catch (error) {
258
+ spinner2.fail(options?.failText || text2);
259
+ throw error;
260
+ }
261
+ };
262
+
263
+ // src/ui/table.ts
264
+ import chalk5 from "chalk";
265
+
266
+ // src/lib/paths.ts
267
+ import { homedir as homedir2 } from "os";
268
+ import { join as join2, basename, dirname, relative, isAbsolute, resolve } from "path";
269
+ import { stat, access } from "fs/promises";
270
+ import { constants } from "fs";
271
+
272
+ // src/constants.ts
273
+ import { homedir } from "os";
274
+ import { join } from "path";
275
+ var VERSION = "0.1.0";
276
+ var DESCRIPTION = "Modern dotfiles manager with a beautiful CLI";
277
+ var HOME_DIR = homedir();
278
+ var DEFAULT_TUCK_DIR = join(HOME_DIR, ".tuck");
279
+ var MANIFEST_FILE = ".tuckmanifest.json";
280
+ var CONFIG_FILE = ".tuckrc.json";
281
+ var BACKUP_DIR = join(HOME_DIR, ".tuck-backups");
282
+ var FILES_DIR = "files";
283
+ var CATEGORIES = {
284
+ shell: {
285
+ patterns: [
286
+ ".zshrc",
287
+ ".bashrc",
288
+ ".bash_profile",
289
+ ".zprofile",
290
+ ".profile",
291
+ ".aliases",
292
+ ".zshenv",
293
+ ".bash_aliases",
294
+ ".inputrc"
295
+ ],
296
+ icon: "\u{1F41A}"
297
+ },
298
+ git: {
299
+ patterns: [".gitconfig", ".gitignore_global", ".gitmessage", ".gitattributes"],
300
+ icon: "\u{1F4E6}"
301
+ },
302
+ editors: {
303
+ patterns: [
304
+ ".vimrc",
305
+ ".config/nvim",
306
+ ".emacs",
307
+ ".emacs.d",
308
+ ".config/Code",
309
+ ".ideavimrc",
310
+ ".nanorc"
311
+ ],
312
+ icon: "\u270F\uFE0F"
313
+ },
314
+ terminal: {
315
+ patterns: [
316
+ ".tmux.conf",
317
+ ".config/alacritty",
318
+ ".config/kitty",
319
+ ".wezterm.lua",
320
+ ".config/wezterm",
321
+ ".config/hyper",
322
+ ".config/starship.toml"
323
+ ],
324
+ icon: "\u{1F4BB}"
325
+ },
326
+ ssh: {
327
+ patterns: [".ssh/config"],
328
+ icon: "\u{1F510}"
329
+ },
330
+ misc: {
331
+ patterns: [],
332
+ icon: "\u{1F4C4}"
333
+ }
334
+ };
335
+ var COMMON_DOTFILES = [
336
+ { path: "~/.zshrc", category: "shell" },
337
+ { path: "~/.bashrc", category: "shell" },
338
+ { path: "~/.bash_profile", category: "shell" },
339
+ { path: "~/.gitconfig", category: "git" },
340
+ { path: "~/.config/nvim", category: "editors" },
341
+ { path: "~/.vimrc", category: "editors" },
342
+ { path: "~/.tmux.conf", category: "terminal" },
343
+ { path: "~/.ssh/config", category: "ssh" },
344
+ { path: "~/.config/starship.toml", category: "terminal" }
345
+ ];
346
+
347
+ // src/lib/paths.ts
348
+ var expandPath = (path) => {
349
+ if (path.startsWith("~/")) {
350
+ return join2(homedir2(), path.slice(2));
351
+ }
352
+ if (path.startsWith("$HOME/")) {
353
+ return join2(homedir2(), path.slice(6));
354
+ }
355
+ return isAbsolute(path) ? path : resolve(path);
356
+ };
357
+ var collapsePath = (path) => {
358
+ const home = homedir2();
359
+ if (path.startsWith(home)) {
360
+ return "~" + path.slice(home.length);
361
+ }
362
+ return path;
363
+ };
364
+ var getTuckDir = (customDir) => {
365
+ return expandPath(customDir || DEFAULT_TUCK_DIR);
366
+ };
367
+ var getManifestPath = (tuckDir) => {
368
+ return join2(tuckDir, MANIFEST_FILE);
369
+ };
370
+ var getConfigPath = (tuckDir) => {
371
+ return join2(tuckDir, CONFIG_FILE);
372
+ };
373
+ var getFilesDir = (tuckDir) => {
374
+ return join2(tuckDir, FILES_DIR);
375
+ };
376
+ var getCategoryDir = (tuckDir, category) => {
377
+ return join2(getFilesDir(tuckDir), category);
378
+ };
379
+ var getDestinationPath = (tuckDir, category, filename) => {
380
+ return join2(getCategoryDir(tuckDir, category), filename);
381
+ };
382
+ var getRelativeDestination = (category, filename) => {
383
+ return join2(FILES_DIR, category, filename);
384
+ };
385
+ var sanitizeFilename = (filepath) => {
386
+ const base = basename(filepath);
387
+ return base.startsWith(".") ? base.slice(1) : base;
388
+ };
389
+ var detectCategory = (filepath) => {
390
+ const expandedPath = expandPath(filepath);
391
+ const relativePath = collapsePath(expandedPath);
392
+ for (const [category, config] of Object.entries(CATEGORIES)) {
393
+ for (const pattern of config.patterns) {
394
+ if (relativePath.endsWith(pattern) || relativePath.includes(pattern)) {
395
+ return category;
396
+ }
397
+ const filename = basename(expandedPath);
398
+ if (filename === pattern || filename === basename(pattern)) {
399
+ return category;
400
+ }
401
+ }
402
+ }
403
+ return "misc";
404
+ };
405
+ var pathExists = async (path) => {
406
+ try {
407
+ await access(path, constants.F_OK);
408
+ return true;
409
+ } catch {
410
+ return false;
411
+ }
412
+ };
413
+ var isDirectory = async (path) => {
414
+ try {
415
+ const stats = await stat(path);
416
+ return stats.isDirectory();
417
+ } catch {
418
+ return false;
419
+ }
420
+ };
421
+ var generateFileId = (source) => {
422
+ const collapsed = collapsePath(source);
423
+ return collapsed.replace(/^~\//, "").replace(/\//g, "_").replace(/\./g, "-").replace(/^-/, "");
424
+ };
425
+
426
+ // src/lib/config.ts
427
+ import { readFile, writeFile } from "fs/promises";
428
+ import { cosmiconfig } from "cosmiconfig";
429
+
430
+ // src/schemas/config.schema.ts
431
+ import { z } from "zod";
432
+ var fileStrategySchema = z.enum(["copy", "symlink"]);
433
+ var categoryConfigSchema = z.object({
434
+ patterns: z.array(z.string()),
435
+ icon: z.string().optional()
436
+ });
437
+ var tuckConfigSchema = z.object({
438
+ repository: z.object({
439
+ path: z.string(),
440
+ defaultBranch: z.string().default("main"),
441
+ autoCommit: z.boolean().default(true),
442
+ autoPush: z.boolean().default(false)
443
+ }).partial().default({}),
444
+ files: z.object({
445
+ strategy: fileStrategySchema.default("copy"),
446
+ backupOnRestore: z.boolean().default(true),
447
+ backupDir: z.string().optional()
448
+ }).partial().default({}),
449
+ categories: z.record(categoryConfigSchema).optional().default({}),
450
+ ignore: z.array(z.string()).optional().default([]),
451
+ hooks: z.object({
452
+ preSync: z.string().optional(),
453
+ postSync: z.string().optional(),
454
+ preRestore: z.string().optional(),
455
+ postRestore: z.string().optional()
456
+ }).partial().default({}),
457
+ templates: z.object({
458
+ enabled: z.boolean().default(false),
459
+ variables: z.record(z.string()).default({})
460
+ }).partial().default({}),
461
+ encryption: z.object({
462
+ enabled: z.boolean().default(false),
463
+ gpgKey: z.string().optional(),
464
+ files: z.array(z.string()).default([])
465
+ }).partial().default({}),
466
+ ui: z.object({
467
+ colors: z.boolean().default(true),
468
+ emoji: z.boolean().default(true),
469
+ verbose: z.boolean().default(false)
470
+ }).partial().default({})
471
+ });
472
+ var defaultConfig = {
473
+ repository: {
474
+ defaultBranch: "main",
475
+ autoCommit: true,
476
+ autoPush: false
477
+ },
478
+ files: {
479
+ strategy: "copy",
480
+ backupOnRestore: true
481
+ },
482
+ categories: {},
483
+ ignore: [],
484
+ hooks: {},
485
+ templates: {
486
+ enabled: false,
487
+ variables: {}
488
+ },
489
+ encryption: {
490
+ enabled: false,
491
+ files: []
492
+ },
493
+ ui: {
494
+ colors: true,
495
+ emoji: true,
496
+ verbose: false
497
+ }
498
+ };
499
+
500
+ // src/errors.ts
501
+ import chalk6 from "chalk";
502
+ var TuckError = class extends Error {
503
+ constructor(message, code, suggestions) {
504
+ super(message);
505
+ this.code = code;
506
+ this.suggestions = suggestions;
507
+ this.name = "TuckError";
508
+ }
509
+ };
510
+ var NotInitializedError = class extends TuckError {
511
+ constructor() {
512
+ super("Tuck is not initialized in this system", "NOT_INITIALIZED", [
513
+ "Run `tuck init` to get started"
514
+ ]);
515
+ }
516
+ };
517
+ var AlreadyInitializedError = class extends TuckError {
518
+ constructor(path) {
519
+ super(`Tuck is already initialized at ${path}`, "ALREADY_INITIALIZED", [
520
+ "Use `tuck status` to see current state",
521
+ `Remove ${path} to reinitialize`
522
+ ]);
523
+ }
524
+ };
525
+ var FileNotFoundError = class extends TuckError {
526
+ constructor(path) {
527
+ super(`File not found: ${path}`, "FILE_NOT_FOUND", [
528
+ "Check that the path is correct",
529
+ "Use absolute paths or paths relative to home directory"
530
+ ]);
531
+ }
532
+ };
533
+ var FileNotTrackedError = class extends TuckError {
534
+ constructor(path) {
535
+ super(`File is not tracked: ${path}`, "FILE_NOT_TRACKED", [
536
+ `Run \`tuck add ${path}\` to track this file`,
537
+ "Run `tuck list` to see all tracked files"
538
+ ]);
539
+ }
540
+ };
541
+ var FileAlreadyTrackedError = class extends TuckError {
542
+ constructor(path) {
543
+ super(`File is already tracked: ${path}`, "FILE_ALREADY_TRACKED", [
544
+ "Run `tuck sync` to update it",
545
+ `Run \`tuck remove ${path}\` to untrack`
546
+ ]);
547
+ }
548
+ };
549
+ var GitError = class extends TuckError {
550
+ constructor(message, gitError) {
551
+ super(`Git operation failed: ${message}`, "GIT_ERROR", gitError ? [gitError] : void 0);
552
+ }
553
+ };
554
+ var ConfigError = class extends TuckError {
555
+ constructor(message) {
556
+ super(`Configuration error: ${message}`, "CONFIG_ERROR", [
557
+ "Run `tuck config edit` to fix configuration",
558
+ "Run `tuck config reset` to restore defaults"
559
+ ]);
560
+ }
561
+ };
562
+ var ManifestError = class extends TuckError {
563
+ constructor(message) {
564
+ super(`Manifest error: ${message}`, "MANIFEST_ERROR", [
565
+ "The manifest file may be corrupted",
566
+ "Run `tuck init --from <remote>` to restore from remote"
567
+ ]);
568
+ }
569
+ };
570
+ var PermissionError = class extends TuckError {
571
+ constructor(path, operation) {
572
+ super(`Permission denied: cannot ${operation} ${path}`, "PERMISSION_ERROR", [
573
+ "Check file permissions",
574
+ "Try running with appropriate permissions"
575
+ ]);
576
+ }
577
+ };
578
+ var handleError = (error) => {
579
+ if (error instanceof TuckError) {
580
+ console.error(chalk6.red("\u2717"), error.message);
581
+ if (error.suggestions && error.suggestions.length > 0) {
582
+ console.error();
583
+ console.error(chalk6.dim("Suggestions:"));
584
+ error.suggestions.forEach((s) => console.error(chalk6.dim(` \u2192 ${s}`)));
585
+ }
586
+ process.exit(1);
587
+ }
588
+ if (error instanceof Error) {
589
+ console.error(chalk6.red("\u2717"), "An unexpected error occurred:", error.message);
590
+ if (process.env.DEBUG) {
591
+ console.error(error.stack);
592
+ }
593
+ process.exit(1);
594
+ }
595
+ console.error(chalk6.red("\u2717"), "An unknown error occurred");
596
+ process.exit(1);
597
+ };
598
+
599
+ // src/lib/config.ts
600
+ var cachedConfig = null;
601
+ var cachedTuckDir = null;
602
+ var loadConfig = async (tuckDir) => {
603
+ const dir = tuckDir || getTuckDir();
604
+ if (cachedConfig && cachedTuckDir === dir) {
605
+ return cachedConfig;
606
+ }
607
+ const configPath = getConfigPath(dir);
608
+ if (!await pathExists(configPath)) {
609
+ cachedConfig = { ...defaultConfig, repository: { ...defaultConfig.repository, path: dir } };
610
+ cachedTuckDir = dir;
611
+ return cachedConfig;
612
+ }
613
+ try {
614
+ const content = await readFile(configPath, "utf-8");
615
+ const rawConfig = JSON.parse(content);
616
+ const result = tuckConfigSchema.safeParse(rawConfig);
617
+ if (!result.success) {
618
+ throw new ConfigError(`Invalid configuration: ${result.error.message}`);
619
+ }
620
+ cachedConfig = {
621
+ ...defaultConfig,
622
+ ...result.data,
623
+ repository: {
624
+ ...defaultConfig.repository,
625
+ ...result.data.repository,
626
+ path: dir
627
+ },
628
+ files: {
629
+ ...defaultConfig.files,
630
+ ...result.data.files,
631
+ backupDir: result.data.files?.backupDir || BACKUP_DIR
632
+ }
633
+ };
634
+ cachedTuckDir = dir;
635
+ return cachedConfig;
636
+ } catch (error) {
637
+ if (error instanceof ConfigError) {
638
+ throw error;
639
+ }
640
+ if (error instanceof SyntaxError) {
641
+ throw new ConfigError("Configuration file contains invalid JSON");
642
+ }
643
+ throw new ConfigError(`Failed to load configuration: ${error}`);
644
+ }
645
+ };
646
+ var saveConfig = async (config, tuckDir) => {
647
+ const dir = tuckDir || getTuckDir();
648
+ const configPath = getConfigPath(dir);
649
+ const existing = await loadConfig(dir);
650
+ const merged = {
651
+ ...existing,
652
+ ...config,
653
+ repository: {
654
+ ...existing.repository,
655
+ ...config.repository
656
+ },
657
+ files: {
658
+ ...existing.files,
659
+ ...config.files
660
+ },
661
+ hooks: {
662
+ ...existing.hooks,
663
+ ...config.hooks
664
+ },
665
+ templates: {
666
+ ...existing.templates,
667
+ ...config.templates
668
+ },
669
+ encryption: {
670
+ ...existing.encryption,
671
+ ...config.encryption
672
+ },
673
+ ui: {
674
+ ...existing.ui,
675
+ ...config.ui
676
+ }
677
+ };
678
+ const result = tuckConfigSchema.safeParse(merged);
679
+ if (!result.success) {
680
+ throw new ConfigError(`Invalid configuration: ${result.error.message}`);
681
+ }
682
+ try {
683
+ await writeFile(configPath, JSON.stringify(result.data, null, 2) + "\n", "utf-8");
684
+ cachedConfig = result.data;
685
+ cachedTuckDir = dir;
686
+ } catch (error) {
687
+ throw new ConfigError(`Failed to save configuration: ${error}`);
688
+ }
689
+ };
690
+ var resetConfig = async (tuckDir) => {
691
+ const dir = tuckDir || getTuckDir();
692
+ const configPath = getConfigPath(dir);
693
+ const resetTo = { ...defaultConfig, repository: { ...defaultConfig.repository, path: dir } };
694
+ try {
695
+ await writeFile(configPath, JSON.stringify(resetTo, null, 2) + "\n", "utf-8");
696
+ cachedConfig = resetTo;
697
+ cachedTuckDir = dir;
698
+ } catch (error) {
699
+ throw new ConfigError(`Failed to reset configuration: ${error}`);
700
+ }
701
+ };
702
+
703
+ // src/lib/manifest.ts
704
+ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
705
+
706
+ // src/schemas/manifest.schema.ts
707
+ import { z as z2 } from "zod";
708
+ var fileStrategySchema2 = z2.enum(["copy", "symlink"]);
709
+ var trackedFileSchema = z2.object({
710
+ source: z2.string(),
711
+ destination: z2.string(),
712
+ category: z2.string(),
713
+ strategy: fileStrategySchema2,
714
+ encrypted: z2.boolean().default(false),
715
+ template: z2.boolean().default(false),
716
+ permissions: z2.string().optional(),
717
+ added: z2.string(),
718
+ modified: z2.string(),
719
+ checksum: z2.string()
720
+ });
721
+ var tuckManifestSchema = z2.object({
722
+ version: z2.string(),
723
+ created: z2.string(),
724
+ updated: z2.string(),
725
+ machine: z2.string().optional(),
726
+ files: z2.record(trackedFileSchema)
727
+ });
728
+ var createEmptyManifest = (machine) => {
729
+ const now = (/* @__PURE__ */ new Date()).toISOString();
730
+ return {
731
+ version: "1.0.0",
732
+ created: now,
733
+ updated: now,
734
+ machine,
735
+ files: {}
736
+ };
737
+ };
738
+
739
+ // src/lib/manifest.ts
740
+ var cachedManifest = null;
741
+ var cachedManifestDir = null;
742
+ var loadManifest = async (tuckDir) => {
743
+ if (cachedManifest && cachedManifestDir === tuckDir) {
744
+ return cachedManifest;
745
+ }
746
+ const manifestPath = getManifestPath(tuckDir);
747
+ if (!await pathExists(manifestPath)) {
748
+ throw new ManifestError("Manifest file not found. Is tuck initialized?");
749
+ }
750
+ try {
751
+ const content = await readFile2(manifestPath, "utf-8");
752
+ const rawManifest = JSON.parse(content);
753
+ const result = tuckManifestSchema.safeParse(rawManifest);
754
+ if (!result.success) {
755
+ throw new ManifestError(`Invalid manifest: ${result.error.message}`);
756
+ }
757
+ cachedManifest = result.data;
758
+ cachedManifestDir = tuckDir;
759
+ return cachedManifest;
760
+ } catch (error) {
761
+ if (error instanceof ManifestError) {
762
+ throw error;
763
+ }
764
+ if (error instanceof SyntaxError) {
765
+ throw new ManifestError("Manifest file contains invalid JSON");
766
+ }
767
+ throw new ManifestError(`Failed to load manifest: ${error}`);
768
+ }
769
+ };
770
+ var saveManifest = async (manifest, tuckDir) => {
771
+ const manifestPath = getManifestPath(tuckDir);
772
+ manifest.updated = (/* @__PURE__ */ new Date()).toISOString();
773
+ const result = tuckManifestSchema.safeParse(manifest);
774
+ if (!result.success) {
775
+ throw new ManifestError(`Invalid manifest: ${result.error.message}`);
776
+ }
777
+ try {
778
+ await writeFile2(manifestPath, JSON.stringify(result.data, null, 2) + "\n", "utf-8");
779
+ cachedManifest = result.data;
780
+ cachedManifestDir = tuckDir;
781
+ } catch (error) {
782
+ throw new ManifestError(`Failed to save manifest: ${error}`);
783
+ }
784
+ };
785
+ var createManifest = async (tuckDir, machine) => {
786
+ const manifestPath = getManifestPath(tuckDir);
787
+ if (await pathExists(manifestPath)) {
788
+ throw new ManifestError("Manifest already exists");
789
+ }
790
+ const manifest = createEmptyManifest(machine);
791
+ try {
792
+ await writeFile2(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
793
+ cachedManifest = manifest;
794
+ cachedManifestDir = tuckDir;
795
+ return manifest;
796
+ } catch (error) {
797
+ throw new ManifestError(`Failed to create manifest: ${error}`);
798
+ }
799
+ };
800
+ var addFileToManifest = async (tuckDir, id, file) => {
801
+ const manifest = await loadManifest(tuckDir);
802
+ if (manifest.files[id]) {
803
+ throw new ManifestError(`File already tracked with ID: ${id}`);
804
+ }
805
+ manifest.files[id] = file;
806
+ await saveManifest(manifest, tuckDir);
807
+ };
808
+ var updateFileInManifest = async (tuckDir, id, updates) => {
809
+ const manifest = await loadManifest(tuckDir);
810
+ if (!manifest.files[id]) {
811
+ throw new ManifestError(`File not found in manifest: ${id}`);
812
+ }
813
+ manifest.files[id] = {
814
+ ...manifest.files[id],
815
+ ...updates,
816
+ modified: (/* @__PURE__ */ new Date()).toISOString()
817
+ };
818
+ await saveManifest(manifest, tuckDir);
819
+ };
820
+ var removeFileFromManifest = async (tuckDir, id) => {
821
+ const manifest = await loadManifest(tuckDir);
822
+ if (!manifest.files[id]) {
823
+ throw new ManifestError(`File not found in manifest: ${id}`);
824
+ }
825
+ delete manifest.files[id];
826
+ await saveManifest(manifest, tuckDir);
827
+ };
828
+ var getTrackedFileBySource = async (tuckDir, source) => {
829
+ const manifest = await loadManifest(tuckDir);
830
+ for (const [id, file] of Object.entries(manifest.files)) {
831
+ if (file.source === source) {
832
+ return { id, file };
833
+ }
834
+ }
835
+ return null;
836
+ };
837
+ var getAllTrackedFiles = async (tuckDir) => {
838
+ const manifest = await loadManifest(tuckDir);
839
+ return manifest.files;
840
+ };
841
+ var isFileTracked = async (tuckDir, source) => {
842
+ const result = await getTrackedFileBySource(tuckDir, source);
843
+ return result !== null;
844
+ };
845
+
846
+ // src/lib/git.ts
847
+ import simpleGit from "simple-git";
848
+ var createGit = (dir) => {
849
+ return simpleGit(dir, {
850
+ binary: "git",
851
+ maxConcurrentProcesses: 6,
852
+ trimmed: true
853
+ });
854
+ };
855
+ var initRepo = async (dir) => {
856
+ try {
857
+ const git = createGit(dir);
858
+ await git.init();
859
+ } catch (error) {
860
+ throw new GitError("Failed to initialize repository", String(error));
861
+ }
862
+ };
863
+ var cloneRepo = async (url, dir) => {
864
+ try {
865
+ const git = simpleGit();
866
+ await git.clone(url, dir);
867
+ } catch (error) {
868
+ throw new GitError(`Failed to clone repository from ${url}`, String(error));
869
+ }
870
+ };
871
+ var addRemote = async (dir, name, url) => {
872
+ try {
873
+ const git = createGit(dir);
874
+ await git.addRemote(name, url);
875
+ } catch (error) {
876
+ throw new GitError("Failed to add remote", String(error));
877
+ }
878
+ };
879
+ var getRemotes = async (dir) => {
880
+ try {
881
+ const git = createGit(dir);
882
+ const remotes = await git.getRemotes(true);
883
+ return remotes.map((r) => ({ name: r.name, url: r.refs.fetch || r.refs.push || "" }));
884
+ } catch (error) {
885
+ throw new GitError("Failed to get remotes", String(error));
886
+ }
887
+ };
888
+ var getStatus = async (dir) => {
889
+ try {
890
+ const git = createGit(dir);
891
+ const status = await git.status();
892
+ return {
893
+ isRepo: true,
894
+ branch: status.current || "main",
895
+ tracking: status.tracking || void 0,
896
+ ahead: status.ahead,
897
+ behind: status.behind,
898
+ staged: status.staged,
899
+ modified: status.modified,
900
+ untracked: status.not_added,
901
+ deleted: status.deleted,
902
+ hasChanges: !status.isClean()
903
+ };
904
+ } catch (error) {
905
+ throw new GitError("Failed to get status", String(error));
906
+ }
907
+ };
908
+ var stageAll = async (dir) => {
909
+ try {
910
+ const git = createGit(dir);
911
+ await git.add(".");
912
+ } catch (error) {
913
+ throw new GitError("Failed to stage all files", String(error));
914
+ }
915
+ };
916
+ var commit = async (dir, message) => {
917
+ try {
918
+ const git = createGit(dir);
919
+ const result = await git.commit(message);
920
+ return result.commit;
921
+ } catch (error) {
922
+ throw new GitError("Failed to commit", String(error));
923
+ }
924
+ };
925
+ var push = async (dir, options) => {
926
+ try {
927
+ const git = createGit(dir);
928
+ const args = [];
929
+ if (options?.setUpstream) {
930
+ args.push("-u");
931
+ }
932
+ if (options?.force) {
933
+ args.push("--force");
934
+ }
935
+ const remote = options?.remote || "origin";
936
+ const branch = options?.branch;
937
+ if (branch) {
938
+ await git.push([remote, branch, ...args]);
939
+ } else {
940
+ await git.push([remote, ...args]);
941
+ }
942
+ } catch (error) {
943
+ throw new GitError("Failed to push", String(error));
944
+ }
945
+ };
946
+ var pull = async (dir, options) => {
947
+ try {
948
+ const git = createGit(dir);
949
+ const args = [];
950
+ if (options?.rebase) {
951
+ args.push("--rebase");
952
+ }
953
+ const remote = options?.remote || "origin";
954
+ const branch = options?.branch;
955
+ if (branch) {
956
+ await git.pull(remote, branch, args);
957
+ } else {
958
+ await git.pull(remote, void 0, args);
959
+ }
960
+ } catch (error) {
961
+ throw new GitError("Failed to pull", String(error));
962
+ }
963
+ };
964
+ var fetch = async (dir, remote = "origin") => {
965
+ try {
966
+ const git = createGit(dir);
967
+ await git.fetch(remote);
968
+ } catch (error) {
969
+ throw new GitError("Failed to fetch", String(error));
970
+ }
971
+ };
972
+ var getDiff = async (dir, options) => {
973
+ try {
974
+ const git = createGit(dir);
975
+ const args = [];
976
+ if (options?.staged) {
977
+ args.push("--staged");
978
+ }
979
+ if (options?.stat) {
980
+ args.push("--stat");
981
+ }
982
+ if (options?.files) {
983
+ args.push("--");
984
+ args.push(...options.files);
985
+ }
986
+ const result = await git.diff(args);
987
+ return result;
988
+ } catch (error) {
989
+ throw new GitError("Failed to get diff", String(error));
990
+ }
991
+ };
992
+ var getCurrentBranch = async (dir) => {
993
+ try {
994
+ const git = createGit(dir);
995
+ const branch = await git.revparse(["--abbrev-ref", "HEAD"]);
996
+ return branch;
997
+ } catch (error) {
998
+ throw new GitError("Failed to get current branch", String(error));
999
+ }
1000
+ };
1001
+ var hasRemote = async (dir, name = "origin") => {
1002
+ try {
1003
+ const remotes = await getRemotes(dir);
1004
+ return remotes.some((r) => r.name === name);
1005
+ } catch {
1006
+ return false;
1007
+ }
1008
+ };
1009
+ var getRemoteUrl = async (dir, name = "origin") => {
1010
+ try {
1011
+ const remotes = await getRemotes(dir);
1012
+ const remote = remotes.find((r) => r.name === name);
1013
+ return remote?.url || null;
1014
+ } catch {
1015
+ return null;
1016
+ }
1017
+ };
1018
+ var setDefaultBranch = async (dir, branch) => {
1019
+ try {
1020
+ const git = createGit(dir);
1021
+ await git.branch(["-M", branch]);
1022
+ } catch (error) {
1023
+ throw new GitError("Failed to set default branch", String(error));
1024
+ }
1025
+ };
1026
+
1027
+ // src/commands/init.ts
1028
+ var GITIGNORE_TEMPLATE = `# OS generated files
1029
+ .DS_Store
1030
+ .DS_Store?
1031
+ ._*
1032
+ .Spotlight-V100
1033
+ .Trashes
1034
+ ehthumbs.db
1035
+ Thumbs.db
1036
+
1037
+ # Backup files
1038
+ *.bak
1039
+ *.backup
1040
+ *~
1041
+
1042
+ # Secret files (add patterns for files you want to exclude)
1043
+ # *.secret
1044
+ # .env.local
1045
+ `;
1046
+ var README_TEMPLATE = (machine) => `# Dotfiles
1047
+
1048
+ Managed with [tuck](https://github.com/Pranav-Karra-3301/tuck) - Modern Dotfiles Manager
1049
+
1050
+ ${machine ? `## Machine: ${machine}
1051
+ ` : ""}
1052
+
1053
+ ## Quick Start
1054
+
1055
+ \`\`\`bash
1056
+ # Restore dotfiles to a new machine
1057
+ tuck init --from <this-repo-url>
1058
+
1059
+ # Or clone and restore manually
1060
+ git clone <this-repo-url> ~/.tuck
1061
+ tuck restore --all
1062
+ \`\`\`
1063
+
1064
+ ## Commands
1065
+
1066
+ | Command | Description |
1067
+ |---------|-------------|
1068
+ | \`tuck add <paths>\` | Track new dotfiles |
1069
+ | \`tuck sync\` | Sync changes to repository |
1070
+ | \`tuck push\` | Push to remote |
1071
+ | \`tuck pull\` | Pull from remote |
1072
+ | \`tuck restore\` | Restore dotfiles to system |
1073
+ | \`tuck status\` | Show tracking status |
1074
+ | \`tuck list\` | List tracked files |
1075
+
1076
+ ## Structure
1077
+
1078
+ \`\`\`
1079
+ .tuck/
1080
+ \u251C\u2500\u2500 files/ # Tracked dotfiles organized by category
1081
+ \u2502 \u251C\u2500\u2500 shell/ # Shell configs (.zshrc, .bashrc, etc.)
1082
+ \u2502 \u251C\u2500\u2500 git/ # Git configs (.gitconfig, etc.)
1083
+ \u2502 \u251C\u2500\u2500 editors/ # Editor configs (nvim, vim, etc.)
1084
+ \u2502 \u251C\u2500\u2500 terminal/ # Terminal configs (tmux, alacritty, etc.)
1085
+ \u2502 \u2514\u2500\u2500 misc/ # Other dotfiles
1086
+ \u251C\u2500\u2500 .tuckmanifest.json # Tracks all managed files
1087
+ \u2514\u2500\u2500 .tuckrc.json # Tuck configuration
1088
+ \`\`\`
1089
+ `;
1090
+ var createDirectoryStructure = async (tuckDir) => {
1091
+ await ensureDir(tuckDir);
1092
+ await ensureDir(getFilesDir(tuckDir));
1093
+ for (const category of Object.keys(CATEGORIES)) {
1094
+ await ensureDir(getCategoryDir(tuckDir, category));
1095
+ }
1096
+ };
1097
+ var createDefaultFiles = async (tuckDir, machine) => {
1098
+ const gitignorePath = join3(tuckDir, ".gitignore");
1099
+ await writeFile3(gitignorePath, GITIGNORE_TEMPLATE, "utf-8");
1100
+ const readmePath = join3(tuckDir, "README.md");
1101
+ await writeFile3(readmePath, README_TEMPLATE(machine), "utf-8");
1102
+ };
1103
+ var initFromScratch = async (tuckDir, options) => {
1104
+ if (await pathExists(getManifestPath(tuckDir))) {
1105
+ throw new AlreadyInitializedError(tuckDir);
1106
+ }
1107
+ await withSpinner("Creating directory structure...", async () => {
1108
+ await createDirectoryStructure(tuckDir);
1109
+ });
1110
+ await withSpinner("Initializing git repository...", async () => {
1111
+ await initRepo(tuckDir);
1112
+ await setDefaultBranch(tuckDir, "main");
1113
+ });
1114
+ await withSpinner("Creating manifest...", async () => {
1115
+ const hostname = (await import("os")).hostname();
1116
+ await createManifest(tuckDir, hostname);
1117
+ });
1118
+ await withSpinner("Creating configuration...", async () => {
1119
+ await saveConfig(
1120
+ {
1121
+ ...defaultConfig,
1122
+ repository: { ...defaultConfig.repository, path: tuckDir }
1123
+ },
1124
+ tuckDir
1125
+ );
1126
+ });
1127
+ if (!options.bare) {
1128
+ await withSpinner("Creating default files...", async () => {
1129
+ const hostname = (await import("os")).hostname();
1130
+ await createDefaultFiles(tuckDir, hostname);
1131
+ });
1132
+ }
1133
+ if (options.remote) {
1134
+ await withSpinner("Adding remote...", async () => {
1135
+ await addRemote(tuckDir, "origin", options.remote);
1136
+ });
1137
+ }
1138
+ };
1139
+ var initFromRemote = async (tuckDir, remoteUrl) => {
1140
+ await withSpinner(`Cloning from ${remoteUrl}...`, async () => {
1141
+ await cloneRepo(remoteUrl, tuckDir);
1142
+ });
1143
+ if (!await pathExists(getManifestPath(tuckDir))) {
1144
+ logger.warning("No manifest found in cloned repository. Creating new manifest...");
1145
+ const hostname = (await import("os")).hostname();
1146
+ await createManifest(tuckDir, hostname);
1147
+ }
1148
+ if (!await pathExists(getConfigPath(tuckDir))) {
1149
+ logger.warning("No config found in cloned repository. Creating default config...");
1150
+ await saveConfig(
1151
+ {
1152
+ ...defaultConfig,
1153
+ repository: { ...defaultConfig.repository, path: tuckDir }
1154
+ },
1155
+ tuckDir
1156
+ );
1157
+ }
1158
+ };
1159
+ var runInteractiveInit = async () => {
1160
+ banner();
1161
+ prompts.intro("tuck init");
1162
+ const dirInput = await prompts.text("Where should tuck store your dotfiles?", {
1163
+ defaultValue: "~/.tuck"
1164
+ });
1165
+ const tuckDir = getTuckDir(dirInput);
1166
+ if (await pathExists(getManifestPath(tuckDir))) {
1167
+ prompts.log.error(`Tuck is already initialized at ${collapsePath(tuckDir)}`);
1168
+ prompts.outro("Use `tuck status` to see current state");
1169
+ return;
1170
+ }
1171
+ const hasExisting = await prompts.select("Do you have an existing dotfiles repository?", [
1172
+ { value: "no", label: "No, start fresh" },
1173
+ { value: "yes", label: "Yes, clone from URL" }
1174
+ ]);
1175
+ if (hasExisting === "yes") {
1176
+ const repoUrl = await prompts.text("Enter repository URL:", {
1177
+ placeholder: "git@github.com:user/dotfiles.git",
1178
+ validate: (value) => {
1179
+ if (!value) return "Repository URL is required";
1180
+ if (!value.includes("github.com") && !value.includes("gitlab.com") && !value.includes("git@")) {
1181
+ return "Please enter a valid git URL";
1182
+ }
1183
+ return void 0;
1184
+ }
1185
+ });
1186
+ await initFromRemote(tuckDir, repoUrl);
1187
+ prompts.log.success("Repository cloned successfully!");
1188
+ const shouldRestore = await prompts.confirm("Would you like to restore dotfiles now?", true);
1189
+ if (shouldRestore) {
1190
+ prompts.log.info("Run `tuck restore --all` to restore all dotfiles");
1191
+ }
1192
+ } else {
1193
+ const existingDotfiles = [];
1194
+ for (const df of COMMON_DOTFILES) {
1195
+ const fullPath = expandPath(df.path);
1196
+ if (await pathExists(fullPath)) {
1197
+ existingDotfiles.push({
1198
+ path: df.path,
1199
+ label: `${df.path} (${df.category})`
1200
+ });
1201
+ }
1202
+ }
1203
+ await initFromScratch(tuckDir, {});
1204
+ if (existingDotfiles.length > 0) {
1205
+ const selectedFiles = await prompts.multiselect(
1206
+ "Would you like to track some common dotfiles?",
1207
+ existingDotfiles.map((f) => ({
1208
+ value: f.path,
1209
+ label: f.label
1210
+ }))
1211
+ );
1212
+ if (selectedFiles.length > 0) {
1213
+ prompts.log.step(
1214
+ `Run the following to track these files:
1215
+ tuck add ${selectedFiles.join(" ")}`
1216
+ );
1217
+ }
1218
+ }
1219
+ const wantsRemote = await prompts.confirm("Would you like to set up a remote repository?");
1220
+ if (wantsRemote) {
1221
+ const remoteUrl = await prompts.text("Enter remote URL:", {
1222
+ placeholder: "git@github.com:user/dotfiles.git"
1223
+ });
1224
+ if (remoteUrl) {
1225
+ await addRemote(tuckDir, "origin", remoteUrl);
1226
+ prompts.log.success("Remote added successfully");
1227
+ }
1228
+ }
1229
+ }
1230
+ prompts.outro("Tuck initialized successfully!");
1231
+ nextSteps([
1232
+ `Add files: tuck add ~/.zshrc`,
1233
+ `Sync changes: tuck sync`,
1234
+ `Push remote: tuck push`
1235
+ ]);
1236
+ };
1237
+ var runInit = async (options) => {
1238
+ const tuckDir = getTuckDir(options.dir);
1239
+ if (options.from) {
1240
+ await initFromRemote(tuckDir, options.from);
1241
+ logger.success(`Tuck initialized from ${options.from}`);
1242
+ logger.info("Run `tuck restore --all` to restore dotfiles");
1243
+ return;
1244
+ }
1245
+ await initFromScratch(tuckDir, {
1246
+ remote: options.remote,
1247
+ bare: options.bare
1248
+ });
1249
+ logger.success(`Tuck initialized at ${collapsePath(tuckDir)}`);
1250
+ nextSteps([
1251
+ `Add files: tuck add ~/.zshrc`,
1252
+ `Sync changes: tuck sync`,
1253
+ `Push remote: tuck push`
1254
+ ]);
1255
+ };
1256
+ var initCommand = new Command("init").description("Initialize tuck repository").option("-d, --dir <path>", "Directory for tuck repository", "~/.tuck").option("-r, --remote <url>", "Git remote URL to set up").option("--bare", "Initialize without any default files").option("--from <url>", "Clone from existing tuck repository").action(async (options) => {
1257
+ if (!options.remote && !options.bare && !options.from && options.dir === "~/.tuck") {
1258
+ await runInteractiveInit();
1259
+ } else {
1260
+ await runInit(options);
1261
+ }
1262
+ });
1263
+
1264
+ // src/commands/add.ts
1265
+ import { Command as Command2 } from "commander";
1266
+
1267
+ // src/lib/files.ts
1268
+ import { createHash } from "crypto";
1269
+ import { readFile as readFile3, stat as stat2, readdir, copyFile, symlink, unlink, rm } from "fs/promises";
1270
+ import { copy, ensureDir as ensureDir2 } from "fs-extra";
1271
+ import { join as join4, dirname as dirname2 } from "path";
1272
+ var getFileChecksum = async (filepath) => {
1273
+ const expandedPath = expandPath(filepath);
1274
+ if (await isDirectory(expandedPath)) {
1275
+ const files = await getDirectoryFiles(expandedPath);
1276
+ const hashes = [];
1277
+ for (const file of files) {
1278
+ const content2 = await readFile3(file);
1279
+ hashes.push(createHash("sha256").update(content2).digest("hex"));
1280
+ }
1281
+ return createHash("sha256").update(hashes.join("")).digest("hex");
1282
+ }
1283
+ const content = await readFile3(expandedPath);
1284
+ return createHash("sha256").update(content).digest("hex");
1285
+ };
1286
+ var getFileInfo = async (filepath) => {
1287
+ const expandedPath = expandPath(filepath);
1288
+ if (!await pathExists(expandedPath)) {
1289
+ throw new FileNotFoundError(filepath);
1290
+ }
1291
+ try {
1292
+ const stats = await stat2(expandedPath);
1293
+ const permissions = (stats.mode & 511).toString(8).padStart(3, "0");
1294
+ return {
1295
+ path: expandedPath,
1296
+ isDirectory: stats.isDirectory(),
1297
+ isSymlink: stats.isSymbolicLink(),
1298
+ size: stats.size,
1299
+ permissions,
1300
+ modified: stats.mtime
1301
+ };
1302
+ } catch (error) {
1303
+ throw new PermissionError(filepath, "read");
1304
+ }
1305
+ };
1306
+ var getDirectoryFiles = async (dirpath) => {
1307
+ const expandedPath = expandPath(dirpath);
1308
+ const files = [];
1309
+ const entries = await readdir(expandedPath, { withFileTypes: true });
1310
+ for (const entry of entries) {
1311
+ const entryPath = join4(expandedPath, entry.name);
1312
+ if (entry.isDirectory()) {
1313
+ const subFiles = await getDirectoryFiles(entryPath);
1314
+ files.push(...subFiles);
1315
+ } else if (entry.isFile()) {
1316
+ files.push(entryPath);
1317
+ }
1318
+ }
1319
+ return files.sort();
1320
+ };
1321
+ var getDirectoryFileCount = async (dirpath) => {
1322
+ const files = await getDirectoryFiles(dirpath);
1323
+ return files.length;
1324
+ };
1325
+ var copyFileOrDir = async (source, destination, options) => {
1326
+ const expandedSource = expandPath(source);
1327
+ const expandedDest = expandPath(destination);
1328
+ if (!await pathExists(expandedSource)) {
1329
+ throw new FileNotFoundError(source);
1330
+ }
1331
+ await ensureDir2(dirname2(expandedDest));
1332
+ const sourceIsDir = await isDirectory(expandedSource);
1333
+ try {
1334
+ if (sourceIsDir) {
1335
+ await copy(expandedSource, expandedDest, { overwrite: options?.overwrite ?? true });
1336
+ const fileCount = await getDirectoryFileCount(expandedDest);
1337
+ const files = await getDirectoryFiles(expandedDest);
1338
+ let totalSize = 0;
1339
+ for (const file of files) {
1340
+ const stats = await stat2(file);
1341
+ totalSize += stats.size;
1342
+ }
1343
+ return { source: expandedSource, destination: expandedDest, fileCount, totalSize };
1344
+ } else {
1345
+ await copyFile(expandedSource, expandedDest);
1346
+ const stats = await stat2(expandedDest);
1347
+ return { source: expandedSource, destination: expandedDest, fileCount: 1, totalSize: stats.size };
1348
+ }
1349
+ } catch (error) {
1350
+ throw new PermissionError(destination, "write");
1351
+ }
1352
+ };
1353
+ var createSymlink = async (target, linkPath, options) => {
1354
+ const expandedTarget = expandPath(target);
1355
+ const expandedLink = expandPath(linkPath);
1356
+ if (!await pathExists(expandedTarget)) {
1357
+ throw new FileNotFoundError(target);
1358
+ }
1359
+ await ensureDir2(dirname2(expandedLink));
1360
+ if (options?.overwrite && await pathExists(expandedLink)) {
1361
+ await unlink(expandedLink);
1362
+ }
1363
+ try {
1364
+ await symlink(expandedTarget, expandedLink);
1365
+ } catch (error) {
1366
+ throw new PermissionError(linkPath, "create symlink");
1367
+ }
1368
+ };
1369
+ var deleteFileOrDir = async (filepath) => {
1370
+ const expandedPath = expandPath(filepath);
1371
+ if (!await pathExists(expandedPath)) {
1372
+ return;
1373
+ }
1374
+ try {
1375
+ if (await isDirectory(expandedPath)) {
1376
+ await rm(expandedPath, { recursive: true });
1377
+ } else {
1378
+ await unlink(expandedPath);
1379
+ }
1380
+ } catch (error) {
1381
+ throw new PermissionError(filepath, "delete");
1382
+ }
1383
+ };
1384
+
1385
+ // src/commands/add.ts
1386
+ var validateAndPrepareFiles = async (paths, tuckDir, options) => {
1387
+ const filesToAdd = [];
1388
+ for (const path of paths) {
1389
+ const expandedPath = expandPath(path);
1390
+ const collapsedPath = collapsePath(expandedPath);
1391
+ if (!await pathExists(expandedPath)) {
1392
+ throw new FileNotFoundError(path);
1393
+ }
1394
+ if (await isFileTracked(tuckDir, collapsedPath)) {
1395
+ throw new FileAlreadyTrackedError(path);
1396
+ }
1397
+ const isDir = await isDirectory(expandedPath);
1398
+ const fileCount = isDir ? await getDirectoryFileCount(expandedPath) : 1;
1399
+ const category = options.category || detectCategory(expandedPath);
1400
+ const filename = options.name || sanitizeFilename(expandedPath);
1401
+ const destination = getDestinationPath(tuckDir, category, filename);
1402
+ filesToAdd.push({
1403
+ source: collapsedPath,
1404
+ destination,
1405
+ category,
1406
+ filename,
1407
+ isDir,
1408
+ fileCount
1409
+ });
1410
+ }
1411
+ return filesToAdd;
1412
+ };
1413
+ var addFiles = async (filesToAdd, tuckDir, options) => {
1414
+ const config = await loadConfig(tuckDir);
1415
+ const strategy = options.symlink ? "symlink" : config.files.strategy || "copy";
1416
+ for (const file of filesToAdd) {
1417
+ const expandedSource = expandPath(file.source);
1418
+ await withSpinner(`Copying ${file.source}...`, async () => {
1419
+ await copyFileOrDir(expandedSource, file.destination, { overwrite: true });
1420
+ });
1421
+ const checksum = await getFileChecksum(file.destination);
1422
+ const info = await getFileInfo(expandedSource);
1423
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1424
+ const id = generateFileId(file.source);
1425
+ await addFileToManifest(tuckDir, id, {
1426
+ source: file.source,
1427
+ destination: getRelativeDestination(file.category, file.filename),
1428
+ category: file.category,
1429
+ strategy,
1430
+ encrypted: options.encrypt || false,
1431
+ template: options.template || false,
1432
+ permissions: info.permissions,
1433
+ added: now,
1434
+ modified: now,
1435
+ checksum
1436
+ });
1437
+ const categoryInfo = CATEGORIES[file.category];
1438
+ const icon = categoryInfo?.icon || "\u{1F4C4}";
1439
+ logger.success(`Added ${file.source}`);
1440
+ logger.dim(` ${icon} Category: ${file.category}`);
1441
+ if (file.isDir) {
1442
+ logger.dim(` \u{1F4C1} Directory with ${file.fileCount} files`);
1443
+ }
1444
+ }
1445
+ };
1446
+ var runInteractiveAdd = async (tuckDir) => {
1447
+ prompts.intro("tuck add");
1448
+ const pathsInput = await prompts.text("Enter file paths to track (space-separated):", {
1449
+ placeholder: "~/.zshrc ~/.gitconfig",
1450
+ validate: (value) => {
1451
+ if (!value.trim()) return "At least one path is required";
1452
+ return void 0;
1453
+ }
1454
+ });
1455
+ const paths = pathsInput.split(/\s+/).filter(Boolean);
1456
+ let filesToAdd;
1457
+ try {
1458
+ filesToAdd = await validateAndPrepareFiles(paths, tuckDir, {});
1459
+ } catch (error) {
1460
+ if (error instanceof Error) {
1461
+ prompts.log.error(error.message);
1462
+ }
1463
+ prompts.cancel();
1464
+ return;
1465
+ }
1466
+ for (const file of filesToAdd) {
1467
+ prompts.log.step(`${file.source}`);
1468
+ const categoryOptions = Object.entries(CATEGORIES).map(([name, config]) => ({
1469
+ value: name,
1470
+ label: `${config.icon} ${name}`,
1471
+ hint: file.category === name ? "(auto-detected)" : void 0
1472
+ }));
1473
+ categoryOptions.sort((a, b) => {
1474
+ if (a.value === file.category) return -1;
1475
+ if (b.value === file.category) return 1;
1476
+ return 0;
1477
+ });
1478
+ const selectedCategory = await prompts.select("Category:", categoryOptions);
1479
+ file.category = selectedCategory;
1480
+ file.destination = getDestinationPath(tuckDir, file.category, file.filename);
1481
+ }
1482
+ const confirm2 = await prompts.confirm(
1483
+ `Add ${filesToAdd.length} ${filesToAdd.length === 1 ? "file" : "files"}?`,
1484
+ true
1485
+ );
1486
+ if (!confirm2) {
1487
+ prompts.cancel("Operation cancelled");
1488
+ return;
1489
+ }
1490
+ await addFiles(filesToAdd, tuckDir, {});
1491
+ prompts.outro(`Added ${filesToAdd.length} ${filesToAdd.length === 1 ? "file" : "files"}`);
1492
+ logger.info("Run 'tuck sync' to commit changes");
1493
+ };
1494
+ var runAdd = async (paths, options) => {
1495
+ const tuckDir = getTuckDir();
1496
+ try {
1497
+ await loadManifest(tuckDir);
1498
+ } catch {
1499
+ throw new NotInitializedError();
1500
+ }
1501
+ if (paths.length === 0) {
1502
+ await runInteractiveAdd(tuckDir);
1503
+ return;
1504
+ }
1505
+ const filesToAdd = await validateAndPrepareFiles(paths, tuckDir, options);
1506
+ await addFiles(filesToAdd, tuckDir, options);
1507
+ logger.blank();
1508
+ logger.success(`Added ${filesToAdd.length} ${filesToAdd.length === 1 ? "item" : "items"}`);
1509
+ logger.info("Run 'tuck sync' to commit changes");
1510
+ };
1511
+ var addCommand = new Command2("add").description("Track new dotfiles").argument("[paths...]", "Paths to dotfiles to track").option("-c, --category <name>", "Category to organize under").option("-n, --name <name>", "Custom name for the file in manifest").option("--symlink", "Create symlink instead of copy").option("--encrypt", "Encrypt this file (requires GPG setup)").option("--template", "Treat as template with variable substitution").action(async (paths, options) => {
1512
+ await runAdd(paths, options);
1513
+ });
1514
+
1515
+ // src/commands/remove.ts
1516
+ import { Command as Command3 } from "commander";
1517
+ import { join as join5 } from "path";
1518
+ var validateAndPrepareFiles2 = async (paths, tuckDir) => {
1519
+ const filesToRemove = [];
1520
+ for (const path of paths) {
1521
+ const expandedPath = expandPath(path);
1522
+ const collapsedPath = collapsePath(expandedPath);
1523
+ const tracked = await getTrackedFileBySource(tuckDir, collapsedPath);
1524
+ if (!tracked) {
1525
+ throw new FileNotTrackedError(path);
1526
+ }
1527
+ filesToRemove.push({
1528
+ id: tracked.id,
1529
+ source: tracked.file.source,
1530
+ destination: join5(tuckDir, tracked.file.destination)
1531
+ });
1532
+ }
1533
+ return filesToRemove;
1534
+ };
1535
+ var removeFiles = async (filesToRemove, tuckDir, options) => {
1536
+ for (const file of filesToRemove) {
1537
+ await removeFileFromManifest(tuckDir, file.id);
1538
+ if (options.delete) {
1539
+ if (await pathExists(file.destination)) {
1540
+ await withSpinner(`Deleting ${file.source} from repository...`, async () => {
1541
+ await deleteFileOrDir(file.destination);
1542
+ });
1543
+ }
1544
+ }
1545
+ logger.success(`Removed ${file.source} from tracking`);
1546
+ if (options.delete) {
1547
+ logger.dim(" Also deleted from repository");
1548
+ }
1549
+ }
1550
+ };
1551
+ var runInteractiveRemove = async (tuckDir) => {
1552
+ prompts.intro("tuck remove");
1553
+ const trackedFiles = await getAllTrackedFiles(tuckDir);
1554
+ const fileEntries = Object.entries(trackedFiles);
1555
+ if (fileEntries.length === 0) {
1556
+ prompts.log.warning("No files are currently tracked");
1557
+ prompts.outro("");
1558
+ return;
1559
+ }
1560
+ const selectedFiles = await prompts.multiselect(
1561
+ "Select files to stop tracking:",
1562
+ fileEntries.map(([id, file]) => ({
1563
+ value: id,
1564
+ label: file.source,
1565
+ hint: file.category
1566
+ })),
1567
+ true
1568
+ );
1569
+ if (selectedFiles.length === 0) {
1570
+ prompts.cancel("No files selected");
1571
+ return;
1572
+ }
1573
+ const shouldDelete = await prompts.confirm("Also delete files from repository?");
1574
+ const confirm2 = await prompts.confirm(
1575
+ `Remove ${selectedFiles.length} ${selectedFiles.length === 1 ? "file" : "files"} from tracking?`,
1576
+ true
1577
+ );
1578
+ if (!confirm2) {
1579
+ prompts.cancel("Operation cancelled");
1580
+ return;
1581
+ }
1582
+ const filesToRemove = selectedFiles.map((id) => {
1583
+ const file = trackedFiles[id];
1584
+ return {
1585
+ id,
1586
+ source: file.source,
1587
+ destination: join5(tuckDir, file.destination)
1588
+ };
1589
+ });
1590
+ await removeFiles(filesToRemove, tuckDir, { delete: shouldDelete });
1591
+ prompts.outro(`Removed ${selectedFiles.length} ${selectedFiles.length === 1 ? "file" : "files"}`);
1592
+ logger.info("Run 'tuck sync' to commit changes");
1593
+ };
1594
+ var runRemove = async (paths, options) => {
1595
+ const tuckDir = getTuckDir();
1596
+ try {
1597
+ await loadManifest(tuckDir);
1598
+ } catch {
1599
+ throw new NotInitializedError();
1600
+ }
1601
+ if (paths.length === 0) {
1602
+ await runInteractiveRemove(tuckDir);
1603
+ return;
1604
+ }
1605
+ const filesToRemove = await validateAndPrepareFiles2(paths, tuckDir);
1606
+ await removeFiles(filesToRemove, tuckDir, options);
1607
+ logger.blank();
1608
+ logger.success(`Removed ${filesToRemove.length} ${filesToRemove.length === 1 ? "item" : "items"} from tracking`);
1609
+ logger.info("Run 'tuck sync' to commit changes");
1610
+ };
1611
+ var removeCommand = new Command3("remove").description("Stop tracking dotfiles").argument("[paths...]", "Paths to dotfiles to untrack").option("--delete", "Also delete from tuck repository").option("--keep-original", "Don't restore symlinks to regular files").action(async (paths, options) => {
1612
+ await runRemove(paths, options);
1613
+ });
1614
+
1615
+ // src/commands/sync.ts
1616
+ import { Command as Command4 } from "commander";
1617
+ import chalk8 from "chalk";
1618
+ import { join as join6 } from "path";
1619
+
1620
+ // src/lib/hooks.ts
1621
+ import { exec } from "child_process";
1622
+ import { promisify } from "util";
1623
+ import chalk7 from "chalk";
1624
+ var execAsync = promisify(exec);
1625
+ var runHook = async (hookType, tuckDir, options) => {
1626
+ if (options?.skipHooks) {
1627
+ return { success: true, skipped: true };
1628
+ }
1629
+ const config = await loadConfig(tuckDir);
1630
+ const command = config.hooks[hookType];
1631
+ if (!command) {
1632
+ return { success: true };
1633
+ }
1634
+ if (!options?.trustHooks) {
1635
+ console.log();
1636
+ console.log(chalk7.yellow.bold("\u26A0\uFE0F Hook Execution Warning"));
1637
+ console.log(chalk7.dim("\u2500".repeat(50)));
1638
+ console.log(chalk7.white(`Hook type: ${chalk7.cyan(hookType)}`));
1639
+ console.log(chalk7.white("Command:"));
1640
+ console.log(chalk7.red(` ${command}`));
1641
+ console.log(chalk7.dim("\u2500".repeat(50)));
1642
+ console.log(
1643
+ chalk7.yellow(
1644
+ "SECURITY: Hooks can execute arbitrary commands on your system."
1645
+ )
1646
+ );
1647
+ console.log(
1648
+ chalk7.yellow(
1649
+ "Only proceed if you trust the source of this configuration."
1650
+ )
1651
+ );
1652
+ console.log();
1653
+ const confirmed = await prompts.confirm(
1654
+ "Execute this hook?",
1655
+ false
1656
+ // Default to NO for safety
1657
+ );
1658
+ if (!confirmed) {
1659
+ logger.warning(`Hook ${hookType} skipped by user`);
1660
+ return { success: true, skipped: true };
1661
+ }
1662
+ }
1663
+ if (!options?.silent) {
1664
+ logger.dim(`Running ${hookType} hook...`);
1665
+ }
1666
+ try {
1667
+ const { stdout, stderr } = await execAsync(command, {
1668
+ cwd: tuckDir,
1669
+ timeout: 3e4,
1670
+ // 30 second timeout
1671
+ env: {
1672
+ ...process.env,
1673
+ TUCK_DIR: tuckDir,
1674
+ TUCK_HOOK: hookType
1675
+ }
1676
+ });
1677
+ if (stdout && !options?.silent) {
1678
+ logger.dim(stdout.trim());
1679
+ }
1680
+ if (stderr && !options?.silent) {
1681
+ logger.warning(stderr.trim());
1682
+ }
1683
+ return { success: true, output: stdout };
1684
+ } catch (error) {
1685
+ const errorMessage = error instanceof Error ? error.message : String(error);
1686
+ if (!options?.silent) {
1687
+ logger.error(`Hook ${hookType} failed: ${errorMessage}`);
1688
+ }
1689
+ return { success: false, error: errorMessage };
1690
+ }
1691
+ };
1692
+ var runPreSyncHook = async (tuckDir, options) => {
1693
+ return runHook("preSync", tuckDir, options);
1694
+ };
1695
+ var runPostSyncHook = async (tuckDir, options) => {
1696
+ return runHook("postSync", tuckDir, options);
1697
+ };
1698
+ var runPreRestoreHook = async (tuckDir, options) => {
1699
+ return runHook("preRestore", tuckDir, options);
1700
+ };
1701
+ var runPostRestoreHook = async (tuckDir, options) => {
1702
+ return runHook("postRestore", tuckDir, options);
1703
+ };
1704
+
1705
+ // src/commands/sync.ts
1706
+ var detectChanges = async (tuckDir) => {
1707
+ const files = await getAllTrackedFiles(tuckDir);
1708
+ const changes = [];
1709
+ for (const [, file] of Object.entries(files)) {
1710
+ const sourcePath = expandPath(file.source);
1711
+ if (!await pathExists(sourcePath)) {
1712
+ changes.push({
1713
+ path: file.source,
1714
+ status: "deleted",
1715
+ source: file.source,
1716
+ destination: file.destination
1717
+ });
1718
+ continue;
1719
+ }
1720
+ try {
1721
+ const sourceChecksum = await getFileChecksum(sourcePath);
1722
+ if (sourceChecksum !== file.checksum) {
1723
+ changes.push({
1724
+ path: file.source,
1725
+ status: "modified",
1726
+ source: file.source,
1727
+ destination: file.destination
1728
+ });
1729
+ }
1730
+ } catch {
1731
+ changes.push({
1732
+ path: file.source,
1733
+ status: "modified",
1734
+ source: file.source,
1735
+ destination: file.destination
1736
+ });
1737
+ }
1738
+ }
1739
+ return changes;
1740
+ };
1741
+ var generateCommitMessage = (result) => {
1742
+ const parts = [];
1743
+ if (result.added.length > 0) {
1744
+ parts.push(`Add: ${result.added.join(", ")}`);
1745
+ }
1746
+ if (result.modified.length > 0) {
1747
+ parts.push(`Update: ${result.modified.join(", ")}`);
1748
+ }
1749
+ if (result.deleted.length > 0) {
1750
+ parts.push(`Remove: ${result.deleted.join(", ")}`);
1751
+ }
1752
+ if (parts.length === 0) {
1753
+ return "Sync dotfiles";
1754
+ }
1755
+ const totalCount = result.added.length + result.modified.length + result.deleted.length;
1756
+ if (parts.length === 1 && totalCount <= 3) {
1757
+ return parts[0];
1758
+ }
1759
+ return `Sync: ${totalCount} file${totalCount > 1 ? "s" : ""} changed`;
1760
+ };
1761
+ var syncFiles = async (tuckDir, changes, options) => {
1762
+ const result = {
1763
+ modified: [],
1764
+ added: [],
1765
+ deleted: []
1766
+ };
1767
+ const hookOptions = {
1768
+ skipHooks: options.noHooks,
1769
+ trustHooks: options.trustHooks
1770
+ };
1771
+ await runPreSyncHook(tuckDir, hookOptions);
1772
+ for (const change of changes) {
1773
+ const sourcePath = expandPath(change.source);
1774
+ const destPath = join6(tuckDir, change.destination);
1775
+ if (change.status === "modified") {
1776
+ await withSpinner(`Syncing ${change.path}...`, async () => {
1777
+ await copyFileOrDir(sourcePath, destPath, { overwrite: true });
1778
+ const newChecksum = await getFileChecksum(destPath);
1779
+ const files = await getAllTrackedFiles(tuckDir);
1780
+ const fileId = Object.entries(files).find(([, f]) => f.source === change.source)?.[0];
1781
+ if (fileId) {
1782
+ await updateFileInManifest(tuckDir, fileId, {
1783
+ checksum: newChecksum,
1784
+ modified: (/* @__PURE__ */ new Date()).toISOString()
1785
+ });
1786
+ }
1787
+ });
1788
+ result.modified.push(change.path.split("/").pop() || change.path);
1789
+ } else if (change.status === "deleted") {
1790
+ logger.warning(`Source file deleted: ${change.path}`);
1791
+ result.deleted.push(change.path.split("/").pop() || change.path);
1792
+ }
1793
+ }
1794
+ if (!options.noCommit && (result.modified.length > 0 || result.deleted.length > 0)) {
1795
+ await withSpinner("Staging changes...", async () => {
1796
+ await stageAll(tuckDir);
1797
+ });
1798
+ const message = options.message || generateCommitMessage(result);
1799
+ await withSpinner("Committing...", async () => {
1800
+ result.commitHash = await commit(tuckDir, message);
1801
+ });
1802
+ }
1803
+ await runPostSyncHook(tuckDir, hookOptions);
1804
+ return result;
1805
+ };
1806
+ var runInteractiveSync = async (tuckDir) => {
1807
+ prompts.intro("tuck sync");
1808
+ const spinner2 = prompts.spinner();
1809
+ spinner2.start("Detecting changes...");
1810
+ const changes = await detectChanges(tuckDir);
1811
+ spinner2.stop("Changes detected");
1812
+ if (changes.length === 0) {
1813
+ const gitStatus = await getStatus(tuckDir);
1814
+ if (gitStatus.hasChanges) {
1815
+ prompts.log.info("No dotfile changes, but repository has uncommitted changes");
1816
+ const commitAnyway = await prompts.confirm("Commit repository changes?");
1817
+ if (commitAnyway) {
1818
+ const message2 = await prompts.text("Commit message:", {
1819
+ defaultValue: "Update dotfiles"
1820
+ });
1821
+ await stageAll(tuckDir);
1822
+ const hash = await commit(tuckDir, message2);
1823
+ prompts.log.success(`Committed: ${hash.slice(0, 7)}`);
1824
+ }
1825
+ } else {
1826
+ prompts.log.success("Everything is up to date");
1827
+ }
1828
+ return;
1829
+ }
1830
+ console.log();
1831
+ console.log(chalk8.bold("Changes detected:"));
1832
+ for (const change of changes) {
1833
+ if (change.status === "modified") {
1834
+ console.log(chalk8.yellow(` ~ ${change.path}`));
1835
+ } else if (change.status === "deleted") {
1836
+ console.log(chalk8.red(` - ${change.path}`));
1837
+ }
1838
+ }
1839
+ console.log();
1840
+ const confirm2 = await prompts.confirm("Sync these changes?", true);
1841
+ if (!confirm2) {
1842
+ prompts.cancel("Operation cancelled");
1843
+ return;
1844
+ }
1845
+ const autoMessage = generateCommitMessage({
1846
+ modified: changes.filter((c) => c.status === "modified").map((c) => c.path),
1847
+ added: [],
1848
+ deleted: changes.filter((c) => c.status === "deleted").map((c) => c.path)
1849
+ });
1850
+ const message = await prompts.text("Commit message:", {
1851
+ defaultValue: autoMessage
1852
+ });
1853
+ const result = await syncFiles(tuckDir, changes, { message });
1854
+ console.log();
1855
+ if (result.commitHash) {
1856
+ prompts.log.success(`Committed: ${result.commitHash.slice(0, 7)}`);
1857
+ }
1858
+ prompts.outro("Synced successfully!");
1859
+ logger.info("Run 'tuck push' to upload to remote");
1860
+ };
1861
+ var runSync = async (messageArg, options) => {
1862
+ const tuckDir = getTuckDir();
1863
+ try {
1864
+ await loadManifest(tuckDir);
1865
+ } catch {
1866
+ throw new NotInitializedError();
1867
+ }
1868
+ if (!messageArg && !options.message && !options.all && !options.noCommit && !options.amend) {
1869
+ await runInteractiveSync(tuckDir);
1870
+ return;
1871
+ }
1872
+ const changes = await detectChanges(tuckDir);
1873
+ if (changes.length === 0) {
1874
+ logger.info("No changes detected");
1875
+ return;
1876
+ }
1877
+ logger.heading("Changes detected:");
1878
+ for (const change of changes) {
1879
+ logger.file(change.status === "modified" ? "modify" : "delete", change.path);
1880
+ }
1881
+ logger.blank();
1882
+ const message = messageArg || options.message;
1883
+ const result = await syncFiles(tuckDir, changes, { ...options, message });
1884
+ logger.blank();
1885
+ logger.success(`Synced ${changes.length} file${changes.length > 1 ? "s" : ""}`);
1886
+ if (result.commitHash) {
1887
+ logger.info(`Commit: ${result.commitHash.slice(0, 7)}`);
1888
+ }
1889
+ logger.info("Run 'tuck push' to upload to remote");
1890
+ };
1891
+ var syncCommand = new Command4("sync").description("Sync changes to repository").argument("[message]", "Commit message").option("-m, --message <msg>", "Commit message").option("-a, --all", "Sync all tracked files, not just changed").option("--no-commit", "Stage changes but don't commit").option("--amend", "Amend previous commit").option("--no-hooks", "Skip execution of pre/post sync hooks").option("--trust-hooks", "Trust and run hooks without confirmation (use with caution)").action(async (messageArg, options) => {
1892
+ await runSync(messageArg, options);
1893
+ });
1894
+
1895
+ // src/commands/push.ts
1896
+ import { Command as Command5 } from "commander";
1897
+ import chalk9 from "chalk";
1898
+ var runInteractivePush = async (tuckDir) => {
1899
+ prompts.intro("tuck push");
1900
+ const hasRemoteRepo = await hasRemote(tuckDir);
1901
+ if (!hasRemoteRepo) {
1902
+ prompts.log.warning("No remote configured");
1903
+ const addRemoteNow = await prompts.confirm("Would you like to add a remote?");
1904
+ if (!addRemoteNow) {
1905
+ prompts.cancel("No remote to push to");
1906
+ return;
1907
+ }
1908
+ const remoteUrl2 = await prompts.text("Enter remote URL:", {
1909
+ placeholder: "git@github.com:user/dotfiles.git",
1910
+ validate: (value) => {
1911
+ if (!value) return "Remote URL is required";
1912
+ return void 0;
1913
+ }
1914
+ });
1915
+ await addRemote(tuckDir, "origin", remoteUrl2);
1916
+ prompts.log.success("Remote added");
1917
+ }
1918
+ const status = await getStatus(tuckDir);
1919
+ const branch = await getCurrentBranch(tuckDir);
1920
+ const remoteUrl = await getRemoteUrl(tuckDir);
1921
+ if (status.ahead === 0 && status.tracking) {
1922
+ prompts.log.success("Already up to date with remote");
1923
+ return;
1924
+ }
1925
+ console.log();
1926
+ console.log(chalk9.dim("Remote:"), remoteUrl);
1927
+ console.log(chalk9.dim("Branch:"), branch);
1928
+ if (status.ahead > 0) {
1929
+ console.log(chalk9.dim("Commits:"), chalk9.green(`\u2191 ${status.ahead} to push`));
1930
+ }
1931
+ if (status.behind > 0) {
1932
+ console.log(chalk9.dim("Warning:"), chalk9.yellow(`\u2193 ${status.behind} commits behind remote`));
1933
+ const pullFirst = await prompts.confirm("Pull changes first?", true);
1934
+ if (pullFirst) {
1935
+ prompts.log.info("Run 'tuck pull' first, then push");
1936
+ return;
1937
+ }
1938
+ }
1939
+ console.log();
1940
+ const confirm2 = await prompts.confirm("Push to remote?", true);
1941
+ if (!confirm2) {
1942
+ prompts.cancel("Operation cancelled");
1943
+ return;
1944
+ }
1945
+ const needsUpstream = !status.tracking;
1946
+ await withSpinner("Pushing...", async () => {
1947
+ await push(tuckDir, {
1948
+ setUpstream: needsUpstream,
1949
+ branch: needsUpstream ? branch : void 0
1950
+ });
1951
+ });
1952
+ prompts.log.success("Pushed successfully!");
1953
+ if (remoteUrl) {
1954
+ let viewUrl = remoteUrl;
1955
+ if (remoteUrl.startsWith("git@github.com:")) {
1956
+ viewUrl = remoteUrl.replace("git@github.com:", "https://github.com/").replace(".git", "");
1957
+ }
1958
+ console.log();
1959
+ console.log(chalk9.dim("View at:"), chalk9.cyan(viewUrl));
1960
+ }
1961
+ prompts.outro("");
1962
+ };
1963
+ var runPush = async (options) => {
1964
+ const tuckDir = getTuckDir();
1965
+ try {
1966
+ await loadManifest(tuckDir);
1967
+ } catch {
1968
+ throw new NotInitializedError();
1969
+ }
1970
+ if (!options.force && !options.setUpstream) {
1971
+ await runInteractivePush(tuckDir);
1972
+ return;
1973
+ }
1974
+ const hasRemoteRepo = await hasRemote(tuckDir);
1975
+ if (!hasRemoteRepo) {
1976
+ throw new GitError("No remote configured", "Run 'tuck init -r <url>' or add a remote manually");
1977
+ }
1978
+ const branch = await getCurrentBranch(tuckDir);
1979
+ await withSpinner("Pushing...", async () => {
1980
+ await push(tuckDir, {
1981
+ force: options.force,
1982
+ setUpstream: Boolean(options.setUpstream),
1983
+ branch: options.setUpstream || branch
1984
+ });
1985
+ });
1986
+ logger.success("Pushed successfully!");
1987
+ };
1988
+ var pushCommand = new Command5("push").description("Push changes to remote repository").option("-f, --force", "Force push").option("--set-upstream <name>", "Set upstream branch").action(async (options) => {
1989
+ await runPush(options);
1990
+ });
1991
+
1992
+ // src/commands/pull.ts
1993
+ import { Command as Command6 } from "commander";
1994
+ import chalk10 from "chalk";
1995
+ var runInteractivePull = async (tuckDir) => {
1996
+ prompts.intro("tuck pull");
1997
+ const hasRemoteRepo = await hasRemote(tuckDir);
1998
+ if (!hasRemoteRepo) {
1999
+ prompts.log.error("No remote configured");
2000
+ prompts.note("Run 'tuck init -r <url>' or add a remote manually", "Tip");
2001
+ return;
2002
+ }
2003
+ await withSpinner("Fetching...", async () => {
2004
+ await fetch(tuckDir);
2005
+ });
2006
+ const status = await getStatus(tuckDir);
2007
+ const branch = await getCurrentBranch(tuckDir);
2008
+ const remoteUrl = await getRemoteUrl(tuckDir);
2009
+ console.log();
2010
+ console.log(chalk10.dim("Remote:"), remoteUrl);
2011
+ console.log(chalk10.dim("Branch:"), branch);
2012
+ if (status.behind === 0) {
2013
+ prompts.log.success("Already up to date");
2014
+ return;
2015
+ }
2016
+ console.log(chalk10.dim("Commits:"), chalk10.yellow(`\u2193 ${status.behind} to pull`));
2017
+ if (status.ahead > 0) {
2018
+ console.log(
2019
+ chalk10.dim("Note:"),
2020
+ chalk10.yellow(`You also have ${status.ahead} local commit${status.ahead > 1 ? "s" : ""} to push`)
2021
+ );
2022
+ }
2023
+ if (status.modified.length > 0 || status.staged.length > 0) {
2024
+ console.log();
2025
+ prompts.log.warning("You have uncommitted changes");
2026
+ console.log(chalk10.dim("Modified:"), status.modified.join(", "));
2027
+ const continueAnyway = await prompts.confirm("Pull anyway? (may cause merge conflicts)");
2028
+ if (!continueAnyway) {
2029
+ prompts.cancel("Commit or stash your changes first with 'tuck sync'");
2030
+ return;
2031
+ }
2032
+ }
2033
+ console.log();
2034
+ const useRebase = await prompts.confirm("Use rebase instead of merge?");
2035
+ await withSpinner("Pulling...", async () => {
2036
+ await pull(tuckDir, { rebase: useRebase });
2037
+ });
2038
+ prompts.log.success("Pulled successfully!");
2039
+ const shouldRestore = await prompts.confirm("Restore updated dotfiles to system?", true);
2040
+ if (shouldRestore) {
2041
+ prompts.note("Run 'tuck restore --all' to restore all dotfiles", "Next step");
2042
+ }
2043
+ prompts.outro("");
2044
+ };
2045
+ var runPull = async (options) => {
2046
+ const tuckDir = getTuckDir();
2047
+ try {
2048
+ await loadManifest(tuckDir);
2049
+ } catch {
2050
+ throw new NotInitializedError();
2051
+ }
2052
+ if (!options.rebase && !options.restore) {
2053
+ await runInteractivePull(tuckDir);
2054
+ return;
2055
+ }
2056
+ const hasRemoteRepo = await hasRemote(tuckDir);
2057
+ if (!hasRemoteRepo) {
2058
+ throw new GitError("No remote configured", "Run 'tuck init -r <url>' or add a remote manually");
2059
+ }
2060
+ await withSpinner("Fetching...", async () => {
2061
+ await fetch(tuckDir);
2062
+ });
2063
+ await withSpinner("Pulling...", async () => {
2064
+ await pull(tuckDir, { rebase: options.rebase });
2065
+ });
2066
+ logger.success("Pulled successfully!");
2067
+ if (options.restore) {
2068
+ logger.info("Run 'tuck restore --all' to restore dotfiles");
2069
+ }
2070
+ };
2071
+ var pullCommand = new Command6("pull").description("Pull changes from remote").option("--rebase", "Pull with rebase").option("--restore", "Also restore files to system after pull").action(async (options) => {
2072
+ await runPull(options);
2073
+ });
2074
+
2075
+ // src/commands/restore.ts
2076
+ import { Command as Command7 } from "commander";
2077
+ import chalk11 from "chalk";
2078
+ import { join as join8 } from "path";
2079
+
2080
+ // src/lib/backup.ts
2081
+ import { join as join7 } from "path";
2082
+ import { copy as copy2, ensureDir as ensureDir3, pathExists as pathExists2 } from "fs-extra";
2083
+ var getBackupDir = () => {
2084
+ return expandPath(BACKUP_DIR);
2085
+ };
2086
+ var formatDateForBackup = (date) => {
2087
+ return date.toISOString().slice(0, 10);
2088
+ };
2089
+ var getTimestampedBackupDir = (date) => {
2090
+ const backupRoot = getBackupDir();
2091
+ const timestamp = formatDateForBackup(date);
2092
+ return join7(backupRoot, timestamp);
2093
+ };
2094
+ var createBackup = async (sourcePath, customBackupDir) => {
2095
+ const expandedSource = expandPath(sourcePath);
2096
+ const date = /* @__PURE__ */ new Date();
2097
+ if (!await pathExists(expandedSource)) {
2098
+ throw new Error(`Source path does not exist: ${sourcePath}`);
2099
+ }
2100
+ const backupRoot = customBackupDir ? expandPath(customBackupDir) : getTimestampedBackupDir(date);
2101
+ await ensureDir3(backupRoot);
2102
+ const collapsed = collapsePath(expandedSource);
2103
+ const backupName = collapsed.replace(/^~\//, "").replace(/\//g, "_").replace(/^\./, "dot-");
2104
+ const timestamp = date.toISOString().replace(/[:.]/g, "-").slice(11, 19);
2105
+ const backupPath = join7(backupRoot, `${backupName}_${timestamp}`);
2106
+ await copy2(expandedSource, backupPath, { overwrite: true });
2107
+ return {
2108
+ originalPath: expandedSource,
2109
+ backupPath,
2110
+ date
2111
+ };
2112
+ };
2113
+
2114
+ // src/commands/restore.ts
2115
+ var prepareFilesToRestore = async (tuckDir, paths) => {
2116
+ const allFiles = await getAllTrackedFiles(tuckDir);
2117
+ const filesToRestore = [];
2118
+ if (paths && paths.length > 0) {
2119
+ for (const path of paths) {
2120
+ const expandedPath = expandPath(path);
2121
+ const collapsedPath = collapsePath(expandedPath);
2122
+ const tracked = await getTrackedFileBySource(tuckDir, collapsedPath);
2123
+ if (!tracked) {
2124
+ throw new FileNotFoundError(`Not tracked: ${path}`);
2125
+ }
2126
+ filesToRestore.push({
2127
+ id: tracked.id,
2128
+ source: tracked.file.source,
2129
+ destination: join8(tuckDir, tracked.file.destination),
2130
+ category: tracked.file.category,
2131
+ existsAtTarget: await pathExists(expandedPath)
2132
+ });
2133
+ }
2134
+ } else {
2135
+ for (const [id, file] of Object.entries(allFiles)) {
2136
+ const targetPath = expandPath(file.source);
2137
+ filesToRestore.push({
2138
+ id,
2139
+ source: file.source,
2140
+ destination: join8(tuckDir, file.destination),
2141
+ category: file.category,
2142
+ existsAtTarget: await pathExists(targetPath)
2143
+ });
2144
+ }
2145
+ }
2146
+ return filesToRestore;
2147
+ };
2148
+ var restoreFiles = async (tuckDir, files, options) => {
2149
+ const config = await loadConfig(tuckDir);
2150
+ const useSymlink = options.symlink || config.files.strategy === "symlink";
2151
+ const shouldBackup = options.backup ?? config.files.backupOnRestore;
2152
+ const hookOptions = {
2153
+ skipHooks: options.noHooks,
2154
+ trustHooks: options.trustHooks
2155
+ };
2156
+ await runPreRestoreHook(tuckDir, hookOptions);
2157
+ let restoredCount = 0;
2158
+ for (const file of files) {
2159
+ const targetPath = expandPath(file.source);
2160
+ if (!await pathExists(file.destination)) {
2161
+ logger.warning(`Source not found in repository: ${file.source}`);
2162
+ continue;
2163
+ }
2164
+ if (options.dryRun) {
2165
+ if (file.existsAtTarget) {
2166
+ logger.file("modify", `${file.source} (would overwrite)`);
2167
+ } else {
2168
+ logger.file("add", `${file.source} (would create)`);
2169
+ }
2170
+ continue;
2171
+ }
2172
+ if (shouldBackup && file.existsAtTarget) {
2173
+ await withSpinner(`Backing up ${file.source}...`, async () => {
2174
+ await createBackup(targetPath);
2175
+ });
2176
+ }
2177
+ await withSpinner(`Restoring ${file.source}...`, async () => {
2178
+ if (useSymlink) {
2179
+ await createSymlink(file.destination, targetPath, { overwrite: true });
2180
+ } else {
2181
+ await copyFileOrDir(file.destination, targetPath, { overwrite: true });
2182
+ }
2183
+ });
2184
+ restoredCount++;
2185
+ }
2186
+ await runPostRestoreHook(tuckDir, hookOptions);
2187
+ return restoredCount;
2188
+ };
2189
+ var runInteractiveRestore = async (tuckDir) => {
2190
+ prompts.intro("tuck restore");
2191
+ const files = await prepareFilesToRestore(tuckDir);
2192
+ if (files.length === 0) {
2193
+ prompts.log.warning("No files to restore");
2194
+ prompts.note("Run 'tuck add <path>' to track files first", "Tip");
2195
+ return;
2196
+ }
2197
+ const fileOptions = files.map((file) => {
2198
+ const categoryConfig = CATEGORIES[file.category] || { icon: "\u{1F4C4}" };
2199
+ const status = file.existsAtTarget ? chalk11.yellow("(exists, will backup)") : "";
2200
+ return {
2201
+ value: file.id,
2202
+ label: `${categoryConfig.icon} ${file.source} ${status}`,
2203
+ hint: file.category
2204
+ };
2205
+ });
2206
+ const selectedIds = await prompts.multiselect("Select files to restore:", fileOptions, true);
2207
+ if (selectedIds.length === 0) {
2208
+ prompts.cancel("No files selected");
2209
+ return;
2210
+ }
2211
+ const selectedFiles = files.filter((f) => selectedIds.includes(f.id));
2212
+ const existingFiles = selectedFiles.filter((f) => f.existsAtTarget);
2213
+ if (existingFiles.length > 0) {
2214
+ console.log();
2215
+ prompts.log.warning(
2216
+ `${existingFiles.length} file${existingFiles.length > 1 ? "s" : ""} will be backed up:`
2217
+ );
2218
+ existingFiles.forEach((f) => console.log(chalk11.dim(` ${f.source}`)));
2219
+ console.log();
2220
+ }
2221
+ const useSymlink = await prompts.select("Restore method:", [
2222
+ { value: false, label: "Copy files", hint: "Recommended" },
2223
+ { value: true, label: "Create symlinks", hint: "Files stay in tuck repo" }
2224
+ ]);
2225
+ const confirm2 = await prompts.confirm(
2226
+ `Restore ${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""}?`,
2227
+ true
2228
+ );
2229
+ if (!confirm2) {
2230
+ prompts.cancel("Operation cancelled");
2231
+ return;
2232
+ }
2233
+ const restoredCount = await restoreFiles(tuckDir, selectedFiles, {
2234
+ symlink: useSymlink,
2235
+ backup: true
2236
+ });
2237
+ console.log();
2238
+ prompts.outro(`Restored ${restoredCount} file${restoredCount > 1 ? "s" : ""}`);
2239
+ };
2240
+ var runRestore = async (paths, options) => {
2241
+ const tuckDir = getTuckDir();
2242
+ try {
2243
+ await loadManifest(tuckDir);
2244
+ } catch {
2245
+ throw new NotInitializedError();
2246
+ }
2247
+ if (paths.length === 0 && !options.all) {
2248
+ await runInteractiveRestore(tuckDir);
2249
+ return;
2250
+ }
2251
+ const files = await prepareFilesToRestore(tuckDir, options.all ? void 0 : paths);
2252
+ if (files.length === 0) {
2253
+ logger.warning("No files to restore");
2254
+ return;
2255
+ }
2256
+ if (options.dryRun) {
2257
+ logger.heading("Dry run - would restore:");
2258
+ } else {
2259
+ logger.heading("Restoring:");
2260
+ }
2261
+ const restoredCount = await restoreFiles(tuckDir, files, options);
2262
+ logger.blank();
2263
+ if (options.dryRun) {
2264
+ logger.info(`Would restore ${files.length} file${files.length > 1 ? "s" : ""}`);
2265
+ } else {
2266
+ logger.success(`Restored ${restoredCount} file${restoredCount > 1 ? "s" : ""}`);
2267
+ }
2268
+ };
2269
+ var restoreCommand = new Command7("restore").description("Restore dotfiles to the system").argument("[paths...]", "Paths to restore (or use --all)").option("-a, --all", "Restore all tracked files").option("--symlink", "Create symlinks instead of copies").option("--backup", "Backup existing files before restore").option("--no-backup", "Skip backup of existing files").option("--dry-run", "Show what would be done").option("--no-hooks", "Skip execution of pre/post restore hooks").option("--trust-hooks", "Trust and run hooks without confirmation (use with caution)").action(async (paths, options) => {
2270
+ await runRestore(paths, options);
2271
+ });
2272
+
2273
+ // src/commands/status.ts
2274
+ import { Command as Command8 } from "commander";
2275
+ import chalk12 from "chalk";
2276
+ var detectFileChanges = async (tuckDir) => {
2277
+ const files = await getAllTrackedFiles(tuckDir);
2278
+ const changes = [];
2279
+ for (const [, file] of Object.entries(files)) {
2280
+ const sourcePath = expandPath(file.source);
2281
+ if (!await pathExists(sourcePath)) {
2282
+ changes.push({
2283
+ path: file.source,
2284
+ status: "deleted",
2285
+ source: file.source,
2286
+ destination: file.destination
2287
+ });
2288
+ continue;
2289
+ }
2290
+ try {
2291
+ const sourceChecksum = await getFileChecksum(sourcePath);
2292
+ if (sourceChecksum !== file.checksum) {
2293
+ changes.push({
2294
+ path: file.source,
2295
+ status: "modified",
2296
+ source: file.source,
2297
+ destination: file.destination
2298
+ });
2299
+ }
2300
+ } catch {
2301
+ changes.push({
2302
+ path: file.source,
2303
+ status: "modified",
2304
+ source: file.source,
2305
+ destination: file.destination
2306
+ });
2307
+ }
2308
+ }
2309
+ return changes;
2310
+ };
2311
+ var getFullStatus = async (tuckDir) => {
2312
+ const manifest = await loadManifest(tuckDir);
2313
+ const gitStatus = await getStatus(tuckDir);
2314
+ const branch = await getCurrentBranch(tuckDir);
2315
+ const hasRemoteRepo = await hasRemote(tuckDir);
2316
+ const remoteUrl = hasRemoteRepo ? await getRemoteUrl(tuckDir) : void 0;
2317
+ let remoteStatus = "no-remote";
2318
+ if (hasRemoteRepo) {
2319
+ if (gitStatus.ahead > 0 && gitStatus.behind > 0) {
2320
+ remoteStatus = "diverged";
2321
+ } else if (gitStatus.ahead > 0) {
2322
+ remoteStatus = "ahead";
2323
+ } else if (gitStatus.behind > 0) {
2324
+ remoteStatus = "behind";
2325
+ } else {
2326
+ remoteStatus = "up-to-date";
2327
+ }
2328
+ }
2329
+ const fileChanges = await detectFileChanges(tuckDir);
2330
+ return {
2331
+ tuckDir,
2332
+ branch,
2333
+ remote: remoteUrl || void 0,
2334
+ remoteStatus,
2335
+ ahead: gitStatus.ahead,
2336
+ behind: gitStatus.behind,
2337
+ trackedCount: Object.keys(manifest.files).length,
2338
+ changes: fileChanges,
2339
+ gitChanges: {
2340
+ staged: gitStatus.staged,
2341
+ modified: gitStatus.modified,
2342
+ untracked: gitStatus.untracked
2343
+ }
2344
+ };
2345
+ };
2346
+ var printStatus = (status) => {
2347
+ prompts.intro("tuck status");
2348
+ console.log();
2349
+ console.log(chalk12.dim("Repository:"), collapsePath(status.tuckDir));
2350
+ console.log(chalk12.dim("Branch:"), chalk12.cyan(status.branch));
2351
+ if (status.remote) {
2352
+ console.log(chalk12.dim("Remote:"), status.remote);
2353
+ let remoteInfo = "";
2354
+ switch (status.remoteStatus) {
2355
+ case "up-to-date":
2356
+ remoteInfo = chalk12.green("up to date");
2357
+ break;
2358
+ case "ahead":
2359
+ remoteInfo = chalk12.yellow(`${status.ahead} commit${status.ahead > 1 ? "s" : ""} ahead`);
2360
+ break;
2361
+ case "behind":
2362
+ remoteInfo = chalk12.yellow(`${status.behind} commit${status.behind > 1 ? "s" : ""} behind`);
2363
+ break;
2364
+ case "diverged":
2365
+ remoteInfo = chalk12.red(`diverged (${status.ahead} ahead, ${status.behind} behind)`);
2366
+ break;
2367
+ }
2368
+ console.log(chalk12.dim("Status:"), remoteInfo);
2369
+ } else {
2370
+ console.log(chalk12.dim("Remote:"), chalk12.yellow("not configured"));
2371
+ }
2372
+ console.log();
2373
+ console.log(chalk12.dim("Tracked files:"), status.trackedCount);
2374
+ if (status.changes.length > 0) {
2375
+ console.log();
2376
+ console.log(chalk12.bold("Changes detected:"));
2377
+ for (const change of status.changes) {
2378
+ const statusText = formatStatus(change.status);
2379
+ console.log(` ${statusText}: ${chalk12.cyan(change.path)}`);
2380
+ }
2381
+ }
2382
+ const hasGitChanges = status.gitChanges.staged.length > 0 || status.gitChanges.modified.length > 0 || status.gitChanges.untracked.length > 0;
2383
+ if (hasGitChanges) {
2384
+ console.log();
2385
+ console.log(chalk12.bold("Repository changes:"));
2386
+ if (status.gitChanges.staged.length > 0) {
2387
+ console.log(chalk12.green(" Staged:"));
2388
+ status.gitChanges.staged.forEach((f) => console.log(chalk12.green(` + ${f}`)));
2389
+ }
2390
+ if (status.gitChanges.modified.length > 0) {
2391
+ console.log(chalk12.yellow(" Modified:"));
2392
+ status.gitChanges.modified.forEach((f) => console.log(chalk12.yellow(` ~ ${f}`)));
2393
+ }
2394
+ if (status.gitChanges.untracked.length > 0) {
2395
+ console.log(chalk12.dim(" Untracked:"));
2396
+ status.gitChanges.untracked.forEach((f) => console.log(chalk12.dim(` ? ${f}`)));
2397
+ }
2398
+ }
2399
+ console.log();
2400
+ if (status.changes.length > 0) {
2401
+ prompts.note("Run 'tuck sync' to commit changes", "Next step");
2402
+ } else if (status.remoteStatus === "ahead") {
2403
+ prompts.note("Run 'tuck push' to push changes to remote", "Next step");
2404
+ } else if (status.remoteStatus === "behind") {
2405
+ prompts.note("Run 'tuck pull' to pull changes from remote", "Next step");
2406
+ } else if (status.trackedCount === 0) {
2407
+ prompts.note("Run 'tuck add <path>' to start tracking files", "Next step");
2408
+ } else {
2409
+ prompts.outro("Everything is up to date");
2410
+ }
2411
+ };
2412
+ var printShortStatus = (status) => {
2413
+ const parts = [];
2414
+ parts.push(`[${status.branch}]`);
2415
+ if (status.remoteStatus === "ahead") {
2416
+ parts.push(`\u2191${status.ahead}`);
2417
+ } else if (status.remoteStatus === "behind") {
2418
+ parts.push(`\u2193${status.behind}`);
2419
+ } else if (status.remoteStatus === "diverged") {
2420
+ parts.push(`\u2191${status.ahead}\u2193${status.behind}`);
2421
+ }
2422
+ if (status.changes.length > 0) {
2423
+ const modified = status.changes.filter((c) => c.status === "modified").length;
2424
+ const deleted = status.changes.filter((c) => c.status === "deleted").length;
2425
+ if (modified > 0) parts.push(`~${modified}`);
2426
+ if (deleted > 0) parts.push(`-${deleted}`);
2427
+ }
2428
+ parts.push(`(${status.trackedCount} tracked)`);
2429
+ console.log(parts.join(" "));
2430
+ };
2431
+ var printJsonStatus = (status) => {
2432
+ console.log(JSON.stringify(status, null, 2));
2433
+ };
2434
+ var runStatus = async (options) => {
2435
+ const tuckDir = getTuckDir();
2436
+ try {
2437
+ await loadManifest(tuckDir);
2438
+ } catch {
2439
+ throw new NotInitializedError();
2440
+ }
2441
+ const status = await getFullStatus(tuckDir);
2442
+ if (options.json) {
2443
+ printJsonStatus(status);
2444
+ } else if (options.short) {
2445
+ printShortStatus(status);
2446
+ } else {
2447
+ printStatus(status);
2448
+ }
2449
+ };
2450
+ var statusCommand = new Command8("status").description("Show current tracking status").option("--short", "Short format").option("--json", "Output as JSON").action(async (options) => {
2451
+ await runStatus(options);
2452
+ });
2453
+
2454
+ // src/commands/list.ts
2455
+ import { Command as Command9 } from "commander";
2456
+ import chalk13 from "chalk";
2457
+ var groupByCategory = async (tuckDir) => {
2458
+ const files = await getAllTrackedFiles(tuckDir);
2459
+ const groups = /* @__PURE__ */ new Map();
2460
+ for (const [id, file] of Object.entries(files)) {
2461
+ const category = file.category;
2462
+ const categoryConfig = CATEGORIES[category] || { icon: "\u{1F4C4}" };
2463
+ if (!groups.has(category)) {
2464
+ groups.set(category, {
2465
+ name: category,
2466
+ icon: categoryConfig.icon,
2467
+ files: []
2468
+ });
2469
+ }
2470
+ groups.get(category).files.push({
2471
+ id,
2472
+ source: file.source,
2473
+ destination: file.destination,
2474
+ isDir: file.destination.endsWith("/") || file.destination.includes("nvim")
2475
+ });
2476
+ }
2477
+ return Array.from(groups.values()).sort((a, b) => a.name.localeCompare(b.name)).map((group2) => ({
2478
+ ...group2,
2479
+ files: group2.files.sort((a, b) => a.source.localeCompare(b.source))
2480
+ }));
2481
+ };
2482
+ var printList = (groups) => {
2483
+ prompts.intro("tuck list");
2484
+ if (groups.length === 0) {
2485
+ prompts.log.warning("No files are currently tracked");
2486
+ prompts.note("Run 'tuck add <path>' to start tracking files", "Tip");
2487
+ return;
2488
+ }
2489
+ let totalFiles = 0;
2490
+ for (const group2 of groups) {
2491
+ const fileCount = group2.files.length;
2492
+ totalFiles += fileCount;
2493
+ console.log();
2494
+ console.log(
2495
+ chalk13.bold(`${group2.icon} ${group2.name}`) + chalk13.dim(` (${formatCount(fileCount, "file")})`)
2496
+ );
2497
+ group2.files.forEach((file, index) => {
2498
+ const isLast = index === group2.files.length - 1;
2499
+ const prefix = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
2500
+ const name = file.source.split("/").pop() || file.source;
2501
+ const arrow = chalk13.dim(" \u2192 ");
2502
+ const dest = chalk13.dim(file.source);
2503
+ console.log(chalk13.dim(prefix) + chalk13.cyan(name) + arrow + dest);
2504
+ });
2505
+ }
2506
+ console.log();
2507
+ prompts.outro(`Total: ${formatCount(totalFiles, "tracked item")}`);
2508
+ };
2509
+ var printPathsOnly = (groups) => {
2510
+ for (const group2 of groups) {
2511
+ for (const file of group2.files) {
2512
+ console.log(file.source);
2513
+ }
2514
+ }
2515
+ };
2516
+ var printJson = (groups) => {
2517
+ const output = groups.reduce(
2518
+ (acc, group2) => {
2519
+ acc[group2.name] = group2.files.map((f) => ({
2520
+ source: f.source,
2521
+ destination: f.destination
2522
+ }));
2523
+ return acc;
2524
+ },
2525
+ {}
2526
+ );
2527
+ console.log(JSON.stringify(output, null, 2));
2528
+ };
2529
+ var runList = async (options) => {
2530
+ const tuckDir = getTuckDir();
2531
+ try {
2532
+ await loadManifest(tuckDir);
2533
+ } catch {
2534
+ throw new NotInitializedError();
2535
+ }
2536
+ let groups = await groupByCategory(tuckDir);
2537
+ if (options.category) {
2538
+ groups = groups.filter((g) => g.name === options.category);
2539
+ if (groups.length === 0) {
2540
+ logger.warning(`No files found in category: ${options.category}`);
2541
+ return;
2542
+ }
2543
+ }
2544
+ if (options.json) {
2545
+ printJson(groups);
2546
+ } else if (options.paths) {
2547
+ printPathsOnly(groups);
2548
+ } else {
2549
+ printList(groups);
2550
+ }
2551
+ };
2552
+ var listCommand = new Command9("list").description("List all tracked files").option("-c, --category <name>", "Filter by category").option("--paths", "Show only paths").option("--json", "Output as JSON").action(async (options) => {
2553
+ await runList(options);
2554
+ });
2555
+
2556
+ // src/commands/diff.ts
2557
+ import { Command as Command10 } from "commander";
2558
+ import chalk14 from "chalk";
2559
+ import { join as join9 } from "path";
2560
+ import { readFile as readFile4 } from "fs/promises";
2561
+ var getFileDiff = async (tuckDir, source) => {
2562
+ const tracked = await getTrackedFileBySource(tuckDir, source);
2563
+ if (!tracked) {
2564
+ throw new FileNotFoundError(`Not tracked: ${source}`);
2565
+ }
2566
+ const systemPath = expandPath(source);
2567
+ const repoPath = join9(tuckDir, tracked.file.destination);
2568
+ const diff = {
2569
+ source,
2570
+ destination: tracked.file.destination,
2571
+ hasChanges: false
2572
+ };
2573
+ if (!await pathExists(systemPath)) {
2574
+ diff.hasChanges = true;
2575
+ if (await pathExists(repoPath)) {
2576
+ diff.repoContent = await readFile4(repoPath, "utf-8");
2577
+ }
2578
+ return diff;
2579
+ }
2580
+ if (!await pathExists(repoPath)) {
2581
+ diff.hasChanges = true;
2582
+ diff.systemContent = await readFile4(systemPath, "utf-8");
2583
+ return diff;
2584
+ }
2585
+ const systemChecksum = await getFileChecksum(systemPath);
2586
+ const repoChecksum = await getFileChecksum(repoPath);
2587
+ if (systemChecksum !== repoChecksum) {
2588
+ diff.hasChanges = true;
2589
+ diff.systemContent = await readFile4(systemPath, "utf-8");
2590
+ diff.repoContent = await readFile4(repoPath, "utf-8");
2591
+ }
2592
+ return diff;
2593
+ };
2594
+ var formatUnifiedDiff = (source, systemContent, repoContent) => {
2595
+ const lines = [];
2596
+ lines.push(chalk14.bold(`--- a/${source} (system)`));
2597
+ lines.push(chalk14.bold(`+++ b/${source} (repository)`));
2598
+ if (!systemContent && repoContent) {
2599
+ lines.push(chalk14.red("File missing on system"));
2600
+ lines.push(chalk14.dim("Repository content:"));
2601
+ repoContent.split("\n").forEach((line) => {
2602
+ lines.push(chalk14.green(`+ ${line}`));
2603
+ });
2604
+ } else if (systemContent && !repoContent) {
2605
+ lines.push(chalk14.yellow("File not yet synced to repository"));
2606
+ lines.push(chalk14.dim("System content:"));
2607
+ systemContent.split("\n").forEach((line) => {
2608
+ lines.push(chalk14.red(`- ${line}`));
2609
+ });
2610
+ } else if (systemContent && repoContent) {
2611
+ const systemLines = systemContent.split("\n");
2612
+ const repoLines = repoContent.split("\n");
2613
+ const maxLines = Math.max(systemLines.length, repoLines.length);
2614
+ let inDiff = false;
2615
+ let diffStart = 0;
2616
+ for (let i = 0; i < maxLines; i++) {
2617
+ const sysLine = systemLines[i];
2618
+ const repoLine = repoLines[i];
2619
+ if (sysLine !== repoLine) {
2620
+ if (!inDiff) {
2621
+ inDiff = true;
2622
+ diffStart = i;
2623
+ lines.push(chalk14.cyan(`@@ -${i + 1} +${i + 1} @@`));
2624
+ }
2625
+ if (sysLine !== void 0) {
2626
+ lines.push(chalk14.red(`- ${sysLine}`));
2627
+ }
2628
+ if (repoLine !== void 0) {
2629
+ lines.push(chalk14.green(`+ ${repoLine}`));
2630
+ }
2631
+ } else if (inDiff) {
2632
+ lines.push(chalk14.dim(` ${sysLine || ""}`));
2633
+ if (i - diffStart > 3) {
2634
+ inDiff = false;
2635
+ }
2636
+ }
2637
+ }
2638
+ }
2639
+ return lines.join("\n");
2640
+ };
2641
+ var runDiff = async (paths, options) => {
2642
+ const tuckDir = getTuckDir();
2643
+ try {
2644
+ await loadManifest(tuckDir);
2645
+ } catch {
2646
+ throw new NotInitializedError();
2647
+ }
2648
+ if (options.staged) {
2649
+ const diff = await getDiff(tuckDir, { staged: true, stat: options.stat });
2650
+ if (diff) {
2651
+ console.log(diff);
2652
+ } else {
2653
+ logger.info("No staged changes");
2654
+ }
2655
+ return;
2656
+ }
2657
+ if (paths.length === 0) {
2658
+ const allFiles = await getAllTrackedFiles(tuckDir);
2659
+ const changedFiles = [];
2660
+ for (const [, file] of Object.entries(allFiles)) {
2661
+ const diff = await getFileDiff(tuckDir, file.source);
2662
+ if (diff.hasChanges) {
2663
+ changedFiles.push(diff);
2664
+ }
2665
+ }
2666
+ if (changedFiles.length === 0) {
2667
+ logger.success("No differences found");
2668
+ return;
2669
+ }
2670
+ if (options.stat) {
2671
+ prompts.intro("tuck diff");
2672
+ console.log();
2673
+ console.log(chalk14.bold(`${changedFiles.length} file${changedFiles.length > 1 ? "s" : ""} changed:`));
2674
+ console.log();
2675
+ for (const diff of changedFiles) {
2676
+ console.log(chalk14.yellow(` ~ ${diff.source}`));
2677
+ }
2678
+ console.log();
2679
+ return;
2680
+ }
2681
+ for (const diff of changedFiles) {
2682
+ console.log();
2683
+ console.log(formatUnifiedDiff(diff.source, diff.systemContent, diff.repoContent));
2684
+ console.log();
2685
+ }
2686
+ return;
2687
+ }
2688
+ for (const path of paths) {
2689
+ const expandedPath = expandPath(path);
2690
+ const collapsedPath = collapsePath(expandedPath);
2691
+ const diff = await getFileDiff(tuckDir, collapsedPath);
2692
+ if (!diff.hasChanges) {
2693
+ logger.info(`No changes: ${path}`);
2694
+ continue;
2695
+ }
2696
+ if (options.stat) {
2697
+ console.log(chalk14.yellow(`~ ${path}`));
2698
+ } else {
2699
+ console.log(formatUnifiedDiff(path, diff.systemContent, diff.repoContent));
2700
+ console.log();
2701
+ }
2702
+ }
2703
+ };
2704
+ var diffCommand = new Command10("diff").description("Show differences between system and repository").argument("[paths...]", "Specific files to diff").option("--staged", "Show staged git changes").option("--stat", "Show diffstat only").action(async (paths, options) => {
2705
+ await runDiff(paths, options);
2706
+ });
2707
+
2708
+ // src/commands/config.ts
2709
+ import { Command as Command11 } from "commander";
2710
+ import chalk15 from "chalk";
2711
+ import { spawn } from "child_process";
2712
+ var printConfig = (config) => {
2713
+ console.log(JSON.stringify(config, null, 2));
2714
+ };
2715
+ var getNestedValue = (obj, path) => {
2716
+ const keys = path.split(".");
2717
+ let current = obj;
2718
+ for (const key of keys) {
2719
+ if (current === null || current === void 0) {
2720
+ return void 0;
2721
+ }
2722
+ if (typeof current !== "object") {
2723
+ return void 0;
2724
+ }
2725
+ current = current[key];
2726
+ }
2727
+ return current;
2728
+ };
2729
+ var setNestedValue = (obj, path, value) => {
2730
+ const keys = path.split(".");
2731
+ let current = obj;
2732
+ for (let i = 0; i < keys.length - 1; i++) {
2733
+ const key = keys[i];
2734
+ if (!(key in current) || typeof current[key] !== "object") {
2735
+ current[key] = {};
2736
+ }
2737
+ current = current[key];
2738
+ }
2739
+ current[keys[keys.length - 1]] = value;
2740
+ };
2741
+ var parseValue = (value) => {
2742
+ try {
2743
+ return JSON.parse(value);
2744
+ } catch {
2745
+ return value;
2746
+ }
2747
+ };
2748
+ var runConfigGet = async (key) => {
2749
+ const tuckDir = getTuckDir();
2750
+ const config = await loadConfig(tuckDir);
2751
+ const value = getNestedValue(config, key);
2752
+ if (value === void 0) {
2753
+ logger.error(`Key not found: ${key}`);
2754
+ return;
2755
+ }
2756
+ if (typeof value === "object") {
2757
+ console.log(JSON.stringify(value, null, 2));
2758
+ } else {
2759
+ console.log(value);
2760
+ }
2761
+ };
2762
+ var runConfigSet = async (key, value) => {
2763
+ const tuckDir = getTuckDir();
2764
+ const config = await loadConfig(tuckDir);
2765
+ const parsedValue = parseValue(value);
2766
+ const configObj = config;
2767
+ setNestedValue(configObj, key, parsedValue);
2768
+ await saveConfig(config, tuckDir);
2769
+ logger.success(`Set ${key} = ${JSON.stringify(parsedValue)}`);
2770
+ };
2771
+ var runConfigList = async () => {
2772
+ const tuckDir = getTuckDir();
2773
+ const config = await loadConfig(tuckDir);
2774
+ prompts.intro("tuck config");
2775
+ console.log();
2776
+ console.log(chalk15.dim("Configuration file:"), collapsePath(getConfigPath(tuckDir)));
2777
+ console.log();
2778
+ printConfig(config);
2779
+ };
2780
+ var runConfigEdit = async () => {
2781
+ const tuckDir = getTuckDir();
2782
+ const configPath = getConfigPath(tuckDir);
2783
+ const editor = process.env.EDITOR || process.env.VISUAL || "vim";
2784
+ logger.info(`Opening ${collapsePath(configPath)} in ${editor}...`);
2785
+ return new Promise((resolve2, reject) => {
2786
+ const child = spawn(editor, [configPath], {
2787
+ stdio: "inherit"
2788
+ });
2789
+ child.on("exit", (code) => {
2790
+ if (code === 0) {
2791
+ logger.success("Configuration updated");
2792
+ resolve2();
2793
+ } else {
2794
+ reject(new ConfigError(`Editor exited with code ${code}`));
2795
+ }
2796
+ });
2797
+ child.on("error", (err) => {
2798
+ reject(new ConfigError(`Failed to open editor: ${err.message}`));
2799
+ });
2800
+ });
2801
+ };
2802
+ var runConfigReset = async () => {
2803
+ const tuckDir = getTuckDir();
2804
+ const confirm2 = await prompts.confirm("Reset configuration to defaults? This cannot be undone.", false);
2805
+ if (!confirm2) {
2806
+ prompts.cancel("Operation cancelled");
2807
+ return;
2808
+ }
2809
+ await resetConfig(tuckDir);
2810
+ logger.success("Configuration reset to defaults");
2811
+ };
2812
+ var configCommand = new Command11("config").description("Manage tuck configuration").addCommand(
2813
+ new Command11("get").description("Get a config value").argument("<key>", 'Config key (e.g., "repository.autoCommit")').action(async (key) => {
2814
+ const tuckDir = getTuckDir();
2815
+ try {
2816
+ await loadManifest(tuckDir);
2817
+ } catch {
2818
+ throw new NotInitializedError();
2819
+ }
2820
+ await runConfigGet(key);
2821
+ })
2822
+ ).addCommand(
2823
+ new Command11("set").description("Set a config value").argument("<key>", "Config key").argument("<value>", "Value to set (JSON or string)").action(async (key, value) => {
2824
+ const tuckDir = getTuckDir();
2825
+ try {
2826
+ await loadManifest(tuckDir);
2827
+ } catch {
2828
+ throw new NotInitializedError();
2829
+ }
2830
+ await runConfigSet(key, value);
2831
+ })
2832
+ ).addCommand(
2833
+ new Command11("list").description("List all config").action(async () => {
2834
+ const tuckDir = getTuckDir();
2835
+ try {
2836
+ await loadManifest(tuckDir);
2837
+ } catch {
2838
+ throw new NotInitializedError();
2839
+ }
2840
+ await runConfigList();
2841
+ })
2842
+ ).addCommand(
2843
+ new Command11("edit").description("Open config in editor").action(async () => {
2844
+ const tuckDir = getTuckDir();
2845
+ try {
2846
+ await loadManifest(tuckDir);
2847
+ } catch {
2848
+ throw new NotInitializedError();
2849
+ }
2850
+ await runConfigEdit();
2851
+ })
2852
+ ).addCommand(
2853
+ new Command11("reset").description("Reset to defaults").action(async () => {
2854
+ const tuckDir = getTuckDir();
2855
+ try {
2856
+ await loadManifest(tuckDir);
2857
+ } catch {
2858
+ throw new NotInitializedError();
2859
+ }
2860
+ await runConfigReset();
2861
+ })
2862
+ );
2863
+
2864
+ // src/index.ts
2865
+ var program = new Command12();
2866
+ program.name("tuck").description(DESCRIPTION).version(VERSION, "-v, --version", "Display version number").configureOutput({
2867
+ outputError: (str, write) => write(chalk16.red(str))
2868
+ });
2869
+ program.addCommand(initCommand);
2870
+ program.addCommand(addCommand);
2871
+ program.addCommand(removeCommand);
2872
+ program.addCommand(syncCommand);
2873
+ program.addCommand(pushCommand);
2874
+ program.addCommand(pullCommand);
2875
+ program.addCommand(restoreCommand);
2876
+ program.addCommand(statusCommand);
2877
+ program.addCommand(listCommand);
2878
+ program.addCommand(diffCommand);
2879
+ program.addCommand(configCommand);
2880
+ process.on("uncaughtException", handleError);
2881
+ process.on("unhandledRejection", (reason) => {
2882
+ handleError(reason instanceof Error ? reason : new Error(String(reason)));
2883
+ });
2884
+ program.parseAsync(process.argv).catch(handleError);
2885
+ //# sourceMappingURL=index.js.map