@tinyrack/devsync 1.0.0 → 1.1.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.
@@ -0,0 +1,1169 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import { syncSecretArtifactSuffix } from "#app/config/sync.ts";
7
+ import { addSyncTarget } from "#app/services/add.ts";
8
+ import { runSyncDoctor } from "#app/services/doctor.ts";
9
+ import { DevsyncError } from "#app/services/error.ts";
10
+ import { forgetSyncTarget } from "#app/services/forget.ts";
11
+ import { initializeSync } from "#app/services/init.ts";
12
+ import { listSyncConfig } from "#app/services/list.ts";
13
+ import { pullSync } from "#app/services/pull.ts";
14
+ import { pushSync } from "#app/services/push.ts";
15
+ import { createSyncContext } from "#app/services/runtime.ts";
16
+ import { setSyncTargetMode } from "#app/services/set.ts";
17
+ import { getSyncStatus } from "#app/services/status.ts";
18
+ import {
19
+ createAgeKeyPair,
20
+ createTemporaryDirectory,
21
+ runGit,
22
+ writeIdentityFile,
23
+ } from "../test/helpers/sync-fixture.ts";
24
+
25
+ const temporaryDirectories: string[] = [];
26
+
27
+ const createWorkspace = async () => {
28
+ const directory = await createTemporaryDirectory("devsync-sync-test-");
29
+
30
+ temporaryDirectories.push(directory);
31
+
32
+ return directory;
33
+ };
34
+
35
+ const createSyncEnvironment = (
36
+ homeDirectory: string,
37
+ xdgConfigHome: string,
38
+ ): NodeJS.ProcessEnv => {
39
+ return {
40
+ HOME: homeDirectory,
41
+ XDG_CONFIG_HOME: xdgConfigHome,
42
+ };
43
+ };
44
+
45
+ afterEach(async () => {
46
+ while (temporaryDirectories.length > 0) {
47
+ const directory = temporaryDirectories.pop();
48
+
49
+ if (directory !== undefined) {
50
+ await rm(directory, { force: true, recursive: true });
51
+ }
52
+ }
53
+ });
54
+
55
+ describe("sync service", () => {
56
+ it("generates a default local age identity when init flags are omitted", async () => {
57
+ const workspace = await createWorkspace();
58
+ const homeDirectory = join(workspace, "home");
59
+ const xdgConfigHome = join(workspace, "xdg");
60
+ const context = createSyncContext({
61
+ environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
62
+ });
63
+
64
+ const result = await initializeSync(
65
+ {
66
+ recipients: [],
67
+ },
68
+ context,
69
+ );
70
+ const config = JSON.parse(
71
+ await readFile(join(result.syncDirectory, "config.json"), "utf8"),
72
+ ) as {
73
+ age: {
74
+ identityFile: string;
75
+ recipients: string[];
76
+ };
77
+ };
78
+
79
+ expect(result.generatedIdentity).toBe(true);
80
+ expect(result.identityFile).toBe(
81
+ join(xdgConfigHome, "devsync", "age", "keys.txt"),
82
+ );
83
+ expect(config.age.identityFile).toBe(
84
+ "$XDG_CONFIG_HOME/devsync/age/keys.txt",
85
+ );
86
+ expect(config.age.recipients).toHaveLength(1);
87
+ expect(
88
+ await readFile(join(xdgConfigHome, "devsync", "age", "keys.txt"), "utf8"),
89
+ ).toContain("AGE-SECRET-KEY-");
90
+ });
91
+
92
+ it("initializes the sync repository inside the XDG config path", async () => {
93
+ const workspace = await createWorkspace();
94
+ const homeDirectory = join(workspace, "home");
95
+ const xdgConfigHome = join(workspace, "xdg");
96
+ const ageKeys = await createAgeKeyPair();
97
+
98
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
99
+
100
+ const context = createSyncContext({
101
+ environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
102
+ });
103
+ const result = await initializeSync(
104
+ {
105
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
106
+ recipients: [ageKeys.recipient],
107
+ },
108
+ context,
109
+ );
110
+
111
+ expect(result.syncDirectory).toBe(join(xdgConfigHome, "devsync", "sync"));
112
+ expect(result.gitAction).toBe("initialized");
113
+ expect(
114
+ await readFile(join(result.syncDirectory, "config.json"), "utf8"),
115
+ ).toContain("$XDG_CONFIG_HOME/devsync/age/keys.txt");
116
+
117
+ const gitResult = await runGit(
118
+ ["-C", result.syncDirectory, "rev-parse", "--is-inside-work-tree"],
119
+ workspace,
120
+ );
121
+
122
+ expect(gitResult.stdout.trim()).toBe("true");
123
+ });
124
+ it("adds tracked entries and stores default modes instead of glob fields", async () => {
125
+ const workspace = await createWorkspace();
126
+ const homeDirectory = join(workspace, "home");
127
+ const xdgConfigHome = join(workspace, "xdg");
128
+ const settingsDirectory = join(homeDirectory, ".config", "mytool");
129
+ const settingsFile = join(settingsDirectory, "settings.json");
130
+ const secretsDirectory = join(settingsDirectory, "secrets");
131
+ const ageKeys = await createAgeKeyPair();
132
+
133
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
134
+ await mkdir(secretsDirectory, { recursive: true });
135
+ await writeFile(settingsFile, "{}\n");
136
+ await writeFile(join(secretsDirectory, "token.txt"), "secret\n");
137
+
138
+ const context = createSyncContext({
139
+ environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
140
+ });
141
+ const initResult = await initializeSync(
142
+ {
143
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
144
+ recipients: [ageKeys.recipient],
145
+ },
146
+ context,
147
+ );
148
+
149
+ const fileAddResult = await addSyncTarget(
150
+ {
151
+ secret: false,
152
+ target: settingsFile,
153
+ },
154
+ context,
155
+ );
156
+ const repeatFileAddResult = await addSyncTarget(
157
+ {
158
+ secret: true,
159
+ target: settingsFile,
160
+ },
161
+ context,
162
+ );
163
+ const directoryAddResult = await addSyncTarget(
164
+ {
165
+ secret: true,
166
+ target: secretsDirectory,
167
+ },
168
+ context,
169
+ );
170
+ const config = JSON.parse(
171
+ await readFile(join(initResult.syncDirectory, "config.json"), "utf8"),
172
+ ) as {
173
+ entries: Array<{
174
+ kind: string;
175
+ localPath: string;
176
+ mode: string;
177
+ name: string;
178
+ overrides?: Record<string, string>;
179
+ repoPath: string;
180
+ }>;
181
+ };
182
+
183
+ expect(fileAddResult.alreadyTracked).toBe(false);
184
+ expect(fileAddResult.mode).toBe("normal");
185
+ expect(fileAddResult.repoPath).toBe(".config/mytool/settings.json");
186
+ expect(fileAddResult.localPath).toBe(settingsFile);
187
+ expect(repeatFileAddResult.alreadyTracked).toBe(true);
188
+ expect(repeatFileAddResult.mode).toBe("secret");
189
+ expect(directoryAddResult.repoPath).toBe(".config/mytool/secrets");
190
+ expect(directoryAddResult.mode).toBe("secret");
191
+ expect(config.entries).toEqual([
192
+ {
193
+ kind: "directory",
194
+ localPath: "~/.config/mytool/secrets",
195
+ mode: "secret",
196
+ name: ".config/mytool/secrets",
197
+ repoPath: ".config/mytool/secrets",
198
+ },
199
+ {
200
+ kind: "file",
201
+ localPath: "~/.config/mytool/settings.json",
202
+ mode: "secret",
203
+ name: ".config/mytool/settings.json",
204
+ repoPath: ".config/mytool/settings.json",
205
+ },
206
+ ]);
207
+ expect("ignoreGlobs" in config).toBe(false);
208
+ expect("secretGlobs" in config).toBe(false);
209
+ });
210
+
211
+ it("sets exact rules, subtree rules, and removes redundant normal overrides", async () => {
212
+ const workspace = await createWorkspace();
213
+ const homeDirectory = join(workspace, "home");
214
+ const xdgConfigHome = join(workspace, "xdg");
215
+ const bundleDirectory = join(homeDirectory, "bundle");
216
+ const publicFile = join(bundleDirectory, "private", "public.json");
217
+ const cacheDirectory = join(bundleDirectory, "cache");
218
+ const ageKeys = await createAgeKeyPair();
219
+
220
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
221
+ await mkdir(join(bundleDirectory, "private"), { recursive: true });
222
+ await mkdir(cacheDirectory, { recursive: true });
223
+ await writeFile(publicFile, "{}\n");
224
+ await writeFile(join(cacheDirectory, "state.txt"), "cache\n");
225
+
226
+ const context = createSyncContext({
227
+ environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
228
+ });
229
+
230
+ await initializeSync(
231
+ {
232
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
233
+ recipients: [ageKeys.recipient],
234
+ },
235
+ context,
236
+ );
237
+ await addSyncTarget(
238
+ {
239
+ secret: true,
240
+ target: bundleDirectory,
241
+ },
242
+ context,
243
+ );
244
+
245
+ const exactAdd = await setSyncTargetMode(
246
+ {
247
+ recursive: false,
248
+ state: "normal",
249
+ target: publicFile,
250
+ },
251
+ context,
252
+ );
253
+ const subtreeAdd = await setSyncTargetMode(
254
+ {
255
+ recursive: true,
256
+ state: "ignore",
257
+ target: cacheDirectory,
258
+ },
259
+ context,
260
+ );
261
+ const rootUpdate = await setSyncTargetMode(
262
+ {
263
+ recursive: true,
264
+ state: "normal",
265
+ target: bundleDirectory,
266
+ },
267
+ context,
268
+ );
269
+ const exactRemove = await setSyncTargetMode(
270
+ {
271
+ recursive: false,
272
+ state: "normal",
273
+ target: publicFile,
274
+ },
275
+ context,
276
+ );
277
+ const config = JSON.parse(
278
+ await readFile(
279
+ join(xdgConfigHome, "devsync", "sync", "config.json"),
280
+ "utf8",
281
+ ),
282
+ ) as {
283
+ entries: Array<{
284
+ overrides?: Record<string, string>;
285
+ }>;
286
+ };
287
+
288
+ expect(exactAdd.action).toBe("added");
289
+ expect(exactAdd.scope).toBe("exact");
290
+ expect(subtreeAdd.action).toBe("added");
291
+ expect(subtreeAdd.scope).toBe("subtree");
292
+ expect(rootUpdate.action).toBe("updated");
293
+ expect(rootUpdate.scope).toBe("default");
294
+ expect(exactRemove.action).toBe("removed");
295
+ expect(config.entries).toHaveLength(1);
296
+ expect(config.entries).toMatchObject([
297
+ {
298
+ kind: "directory",
299
+ localPath: "~/bundle",
300
+ name: "bundle",
301
+ overrides: {
302
+ "cache/": "ignore",
303
+ },
304
+ repoPath: "bundle",
305
+ },
306
+ ]);
307
+ });
308
+
309
+ it("resolves bare relative sync set targets from the current working directory", async () => {
310
+ const workspace = await createWorkspace();
311
+ const homeDirectory = join(workspace, "home");
312
+ const xdgConfigHome = join(workspace, "xdg");
313
+ const sshDirectory = join(homeDirectory, ".ssh");
314
+ const knownHostsFile = join(sshDirectory, "known_hosts");
315
+ const ageKeys = await createAgeKeyPair();
316
+
317
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
318
+ await mkdir(sshDirectory, { recursive: true });
319
+ await writeFile(knownHostsFile, "github.com ssh-ed25519 AAAA...\n");
320
+
321
+ const context = createSyncContext({
322
+ cwd: sshDirectory,
323
+ environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
324
+ });
325
+
326
+ await initializeSync(
327
+ {
328
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
329
+ recipients: [ageKeys.recipient],
330
+ },
331
+ context,
332
+ );
333
+ await addSyncTarget(
334
+ {
335
+ secret: true,
336
+ target: sshDirectory,
337
+ },
338
+ context,
339
+ );
340
+
341
+ const result = await setSyncTargetMode(
342
+ {
343
+ recursive: false,
344
+ state: "ignore",
345
+ target: "known_hosts",
346
+ },
347
+ context,
348
+ );
349
+ const config = JSON.parse(
350
+ await readFile(
351
+ join(xdgConfigHome, "devsync", "sync", "config.json"),
352
+ "utf8",
353
+ ),
354
+ ) as {
355
+ entries: Array<{
356
+ repoPath: string;
357
+ overrides?: Record<string, string>;
358
+ }>;
359
+ };
360
+
361
+ expect(result.entryRepoPath).toBe(".ssh");
362
+ expect(result.localPath).toBe(knownHostsFile);
363
+ expect(result.repoPath).toBe(".ssh/known_hosts");
364
+ expect(result.scope).toBe("exact");
365
+ expect(config.entries).toMatchObject([
366
+ {
367
+ repoPath: ".ssh",
368
+ overrides: {
369
+ known_hosts: "ignore",
370
+ },
371
+ },
372
+ ]);
373
+ });
374
+
375
+ it("forgets tracked entries and removes repository artifacts", async () => {
376
+ const workspace = await createWorkspace();
377
+ const homeDirectory = join(workspace, "home");
378
+ const xdgConfigHome = join(workspace, "xdg");
379
+ const settingsDirectory = join(homeDirectory, "mytool");
380
+ const settingsFile = join(settingsDirectory, "settings.json");
381
+ const ageKeys = await createAgeKeyPair();
382
+
383
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
384
+ await mkdir(settingsDirectory, { recursive: true });
385
+ await writeFile(settingsFile, "{}\n");
386
+
387
+ const context = createSyncContext({
388
+ environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
389
+ });
390
+ const initResult = await initializeSync(
391
+ {
392
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
393
+ recipients: [ageKeys.recipient],
394
+ },
395
+ context,
396
+ );
397
+
398
+ await addSyncTarget(
399
+ {
400
+ secret: true,
401
+ target: settingsFile,
402
+ },
403
+ context,
404
+ );
405
+ await mkdir(join(initResult.syncDirectory, "files", "mytool"), {
406
+ recursive: true,
407
+ });
408
+ await writeFile(
409
+ join(initResult.syncDirectory, "files", "mytool", "settings.json"),
410
+ "stale plain copy\n",
411
+ );
412
+ await writeFile(
413
+ join(
414
+ initResult.syncDirectory,
415
+ "files",
416
+ "mytool",
417
+ `settings.json${syncSecretArtifactSuffix}`,
418
+ ),
419
+ "stale encrypted copy\n",
420
+ );
421
+
422
+ const forgetResult = await forgetSyncTarget(
423
+ {
424
+ target: "mytool/settings.json",
425
+ },
426
+ context,
427
+ );
428
+ const config = JSON.parse(
429
+ await readFile(join(initResult.syncDirectory, "config.json"), "utf8"),
430
+ ) as {
431
+ entries: unknown[];
432
+ };
433
+
434
+ expect(forgetResult.repoPath).toBe("mytool/settings.json");
435
+ expect(forgetResult.plainArtifactCount).toBe(1);
436
+ expect(forgetResult.secretArtifactCount).toBe(1);
437
+ expect("secretGlobRemoved" in forgetResult).toBe(false);
438
+ expect(config.entries).toEqual([]);
439
+ await expect(
440
+ readFile(
441
+ join(initResult.syncDirectory, "files", "mytool", "settings.json"),
442
+ "utf8",
443
+ ),
444
+ ).rejects.toMatchObject({
445
+ code: "ENOENT",
446
+ });
447
+ await expect(
448
+ readFile(
449
+ join(
450
+ initResult.syncDirectory,
451
+ "files",
452
+ "mytool",
453
+ `settings.json${syncSecretArtifactSuffix}`,
454
+ ),
455
+ "utf8",
456
+ ),
457
+ ).rejects.toMatchObject({
458
+ code: "ENOENT",
459
+ });
460
+ });
461
+
462
+ it("forgets tracked file entries via explicit local paths", async () => {
463
+ const workspace = await createWorkspace();
464
+ const homeDirectory = join(workspace, "home");
465
+ const xdgConfigHome = join(workspace, "xdg");
466
+ const settingsDirectory = join(homeDirectory, "mytool");
467
+ const settingsFile = join(settingsDirectory, "settings.json");
468
+ const ageKeys = await createAgeKeyPair();
469
+
470
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
471
+ await mkdir(settingsDirectory, { recursive: true });
472
+ await writeFile(settingsFile, "{}\n");
473
+
474
+ const context = createSyncContext({
475
+ cwd: settingsDirectory,
476
+ environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
477
+ });
478
+
479
+ await initializeSync(
480
+ {
481
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
482
+ recipients: [ageKeys.recipient],
483
+ },
484
+ context,
485
+ );
486
+ await addSyncTarget(
487
+ {
488
+ secret: false,
489
+ target: settingsFile,
490
+ },
491
+ context,
492
+ );
493
+
494
+ const forgetResult = await forgetSyncTarget(
495
+ {
496
+ target: "./settings.json",
497
+ },
498
+ context,
499
+ );
500
+ const config = JSON.parse(
501
+ await readFile(
502
+ join(xdgConfigHome, "devsync", "sync", "config.json"),
503
+ "utf8",
504
+ ),
505
+ ) as {
506
+ entries: unknown[];
507
+ };
508
+
509
+ expect(forgetResult.localPath).toBe(settingsFile);
510
+ expect(forgetResult.repoPath).toBe("mytool/settings.json");
511
+ expect(config.entries).toEqual([]);
512
+ });
513
+
514
+ it("pushes and pulls according to exact mode rules while preserving ignored files", async () => {
515
+ const workspace = await createWorkspace();
516
+ const homeDirectory = join(workspace, "home");
517
+ const xdgConfigHome = join(workspace, "xdg");
518
+ const bundleDirectory = join(homeDirectory, "bundle");
519
+ const plainFile = join(bundleDirectory, "plain.txt");
520
+ const secretFile = join(bundleDirectory, "secret.json");
521
+ const ignoredFile = join(bundleDirectory, "ignored.txt");
522
+ const extraFile = join(bundleDirectory, "extra.txt");
523
+ const ageKeys = await createAgeKeyPair();
524
+
525
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
526
+ await mkdir(bundleDirectory, { recursive: true });
527
+ await writeFile(plainFile, "plain value\n");
528
+ await writeFile(secretFile, JSON.stringify({ token: "secret" }, null, 2));
529
+ await writeFile(ignoredFile, "keep local\n");
530
+
531
+ const context = createSyncContext({
532
+ environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
533
+ });
534
+
535
+ await initializeSync(
536
+ {
537
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
538
+ recipients: [ageKeys.recipient],
539
+ },
540
+ context,
541
+ );
542
+ await addSyncTarget(
543
+ {
544
+ secret: false,
545
+ target: bundleDirectory,
546
+ },
547
+ context,
548
+ );
549
+ await setSyncTargetMode(
550
+ {
551
+ recursive: false,
552
+ state: "secret",
553
+ target: secretFile,
554
+ },
555
+ context,
556
+ );
557
+ await setSyncTargetMode(
558
+ {
559
+ recursive: false,
560
+ state: "ignore",
561
+ target: ignoredFile,
562
+ },
563
+ context,
564
+ );
565
+
566
+ const pushResult = await pushSync(
567
+ {
568
+ dryRun: false,
569
+ },
570
+ context,
571
+ );
572
+
573
+ expect(pushResult.plainFileCount).toBe(1);
574
+ expect(pushResult.encryptedFileCount).toBe(1);
575
+ expect(
576
+ await readFile(
577
+ join(xdgConfigHome, "devsync", "sync", "files", "bundle", "plain.txt"),
578
+ "utf8",
579
+ ),
580
+ ).toBe("plain value\n");
581
+ await expect(
582
+ readFile(
583
+ join(
584
+ xdgConfigHome,
585
+ "devsync",
586
+ "sync",
587
+ "files",
588
+ "bundle",
589
+ "ignored.txt",
590
+ ),
591
+ "utf8",
592
+ ),
593
+ ).rejects.toMatchObject({
594
+ code: "ENOENT",
595
+ });
596
+ expect(
597
+ await readFile(
598
+ join(
599
+ xdgConfigHome,
600
+ "devsync",
601
+ "sync",
602
+ "files",
603
+ "bundle",
604
+ `secret.json${syncSecretArtifactSuffix}`,
605
+ ),
606
+ "utf8",
607
+ ),
608
+ ).toContain("BEGIN AGE ENCRYPTED FILE");
609
+
610
+ await writeFile(plainFile, "wrong value\n");
611
+ await writeFile(
612
+ secretFile,
613
+ JSON.stringify({ token: "wrong-secret" }, null, 2),
614
+ );
615
+ await writeFile(ignoredFile, "preserve this\n");
616
+ await writeFile(extraFile, "delete me\n");
617
+
618
+ const pullResult = await pullSync(
619
+ {
620
+ dryRun: false,
621
+ },
622
+ context,
623
+ );
624
+
625
+ expect(pullResult.deletedLocalCount).toBeGreaterThanOrEqual(1);
626
+ expect(await readFile(plainFile, "utf8")).toBe("plain value\n");
627
+ expect(await readFile(secretFile, "utf8")).toBe(
628
+ `${JSON.stringify({ token: "secret" }, null, 2)}`,
629
+ );
630
+ expect(await readFile(ignoredFile, "utf8")).toBe("preserve this\n");
631
+ await expect(readFile(extraFile, "utf8")).rejects.toMatchObject({
632
+ code: "ENOENT",
633
+ });
634
+ });
635
+
636
+ it("rejects directory targets without --recursive and tracked file entries", async () => {
637
+ const workspace = await createWorkspace();
638
+ const homeDirectory = join(workspace, "home");
639
+ const xdgConfigHome = join(workspace, "xdg");
640
+ const bundleDirectory = join(homeDirectory, "bundle");
641
+ const cacheDirectory = join(bundleDirectory, "cache");
642
+ const trackedFile = join(homeDirectory, ".zshrc");
643
+ const ageKeys = await createAgeKeyPair();
644
+
645
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
646
+ await mkdir(cacheDirectory, { recursive: true });
647
+ await writeFile(join(cacheDirectory, "state.txt"), "cache\n");
648
+ await writeFile(trackedFile, "export TEST=1\n");
649
+
650
+ const context = createSyncContext({
651
+ environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
652
+ });
653
+
654
+ await initializeSync(
655
+ {
656
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
657
+ recipients: [ageKeys.recipient],
658
+ },
659
+ context,
660
+ );
661
+ await addSyncTarget(
662
+ {
663
+ secret: false,
664
+ target: bundleDirectory,
665
+ },
666
+ context,
667
+ );
668
+ await addSyncTarget(
669
+ {
670
+ secret: false,
671
+ target: trackedFile,
672
+ },
673
+ context,
674
+ );
675
+
676
+ await expect(
677
+ setSyncTargetMode(
678
+ {
679
+ recursive: false,
680
+ state: "ignore",
681
+ target: cacheDirectory,
682
+ },
683
+ context,
684
+ ),
685
+ ).rejects.toThrowError(DevsyncError);
686
+ await expect(
687
+ setSyncTargetMode(
688
+ {
689
+ recursive: false,
690
+ state: "secret",
691
+ target: trackedFile,
692
+ },
693
+ context,
694
+ ),
695
+ ).rejects.toThrowError(DevsyncError);
696
+ });
697
+
698
+ it("supports repo-path sync set for missing descendants and reports update transitions", async () => {
699
+ const workspace = await createWorkspace();
700
+ const homeDirectory = join(workspace, "home");
701
+ const xdgConfigHome = join(workspace, "xdg");
702
+ const bundleDirectory = join(homeDirectory, "bundle");
703
+ const cacheDirectory = join(bundleDirectory, "cache");
704
+ const missingLocalPath = join(bundleDirectory, "future.txt");
705
+ const ageKeys = await createAgeKeyPair();
706
+
707
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
708
+ await mkdir(cacheDirectory, { recursive: true });
709
+ await writeFile(join(cacheDirectory, "state.txt"), "cache\n");
710
+
711
+ const context = createSyncContext({
712
+ environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
713
+ });
714
+
715
+ await initializeSync(
716
+ {
717
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
718
+ recipients: [ageKeys.recipient],
719
+ },
720
+ context,
721
+ );
722
+ await addSyncTarget(
723
+ {
724
+ secret: false,
725
+ target: bundleDirectory,
726
+ },
727
+ context,
728
+ );
729
+
730
+ const exactAdded = await setSyncTargetMode(
731
+ {
732
+ recursive: false,
733
+ state: "secret",
734
+ target: "bundle/future.txt",
735
+ },
736
+ context,
737
+ );
738
+ const exactUpdated = await setSyncTargetMode(
739
+ {
740
+ recursive: false,
741
+ state: "ignore",
742
+ target: "bundle/future.txt",
743
+ },
744
+ context,
745
+ );
746
+ const exactUnchanged = await setSyncTargetMode(
747
+ {
748
+ recursive: false,
749
+ state: "ignore",
750
+ target: "bundle/future.txt",
751
+ },
752
+ context,
753
+ );
754
+ const subtreeAdded = await setSyncTargetMode(
755
+ {
756
+ recursive: true,
757
+ state: "ignore",
758
+ target: cacheDirectory,
759
+ },
760
+ context,
761
+ );
762
+ const subtreeUpdated = await setSyncTargetMode(
763
+ {
764
+ recursive: true,
765
+ state: "secret",
766
+ target: "bundle/cache",
767
+ },
768
+ context,
769
+ );
770
+ const subtreeUnchanged = await setSyncTargetMode(
771
+ {
772
+ recursive: true,
773
+ state: "secret",
774
+ target: "bundle/cache",
775
+ },
776
+ context,
777
+ );
778
+ const config = JSON.parse(
779
+ await readFile(
780
+ join(xdgConfigHome, "devsync", "sync", "config.json"),
781
+ "utf8",
782
+ ),
783
+ ) as {
784
+ entries: Array<{
785
+ overrides?: Record<string, string>;
786
+ }>;
787
+ };
788
+
789
+ expect(exactAdded.action).toBe("added");
790
+ expect(exactUpdated.action).toBe("updated");
791
+ expect(exactUnchanged.action).toBe("unchanged");
792
+ expect(subtreeAdded.action).toBe("added");
793
+ expect(subtreeUpdated.action).toBe("updated");
794
+ expect(subtreeUnchanged.action).toBe("unchanged");
795
+ expect(config.entries[0]?.overrides).toEqual({
796
+ "cache/": "secret",
797
+ "future.txt": "ignore",
798
+ });
799
+
800
+ await expect(
801
+ setSyncTargetMode(
802
+ {
803
+ recursive: false,
804
+ state: "secret",
805
+ target: missingLocalPath,
806
+ },
807
+ context,
808
+ ),
809
+ ).rejects.toThrowError(/does not exist/u);
810
+ await expect(
811
+ setSyncTargetMode(
812
+ {
813
+ recursive: false,
814
+ state: "secret",
815
+ target: bundleDirectory,
816
+ },
817
+ context,
818
+ ),
819
+ ).rejects.toThrowError(/require --recursive/u);
820
+ await expect(
821
+ setSyncTargetMode(
822
+ {
823
+ recursive: true,
824
+ state: "secret",
825
+ target: join(cacheDirectory, "state.txt"),
826
+ },
827
+ context,
828
+ ),
829
+ ).rejects.toThrowError(/can only be used with directories/u);
830
+ });
831
+
832
+ it("moves repository artifacts across normal, secret, and ignore mode transitions", async () => {
833
+ const workspace = await createWorkspace();
834
+ const homeDirectory = join(workspace, "home");
835
+ const xdgConfigHome = join(workspace, "xdg");
836
+ const bundleDirectory = join(homeDirectory, "bundle");
837
+ const tokenFile = join(bundleDirectory, "token.txt");
838
+ const syncDirectory = join(xdgConfigHome, "devsync", "sync");
839
+ const ageKeys = await createAgeKeyPair();
840
+
841
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
842
+ await mkdir(bundleDirectory, { recursive: true });
843
+ await writeFile(tokenFile, "token-v1\n");
844
+
845
+ const context = createSyncContext({
846
+ environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
847
+ });
848
+
849
+ await initializeSync(
850
+ {
851
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
852
+ recipients: [ageKeys.recipient],
853
+ },
854
+ context,
855
+ );
856
+ await addSyncTarget(
857
+ {
858
+ secret: false,
859
+ target: bundleDirectory,
860
+ },
861
+ context,
862
+ );
863
+
864
+ const normalPush = await pushSync(
865
+ {
866
+ dryRun: false,
867
+ },
868
+ context,
869
+ );
870
+
871
+ expect(normalPush.plainFileCount).toBe(1);
872
+ expect(
873
+ await readFile(
874
+ join(syncDirectory, "files", "bundle", "token.txt"),
875
+ "utf8",
876
+ ),
877
+ ).toBe("token-v1\n");
878
+ await expect(
879
+ readFile(
880
+ join(
881
+ syncDirectory,
882
+ "files",
883
+ "bundle",
884
+ `token.txt${syncSecretArtifactSuffix}`,
885
+ ),
886
+ "utf8",
887
+ ),
888
+ ).rejects.toMatchObject({
889
+ code: "ENOENT",
890
+ });
891
+
892
+ await setSyncTargetMode(
893
+ {
894
+ recursive: false,
895
+ state: "secret",
896
+ target: tokenFile,
897
+ },
898
+ context,
899
+ );
900
+
901
+ const secretPush = await pushSync(
902
+ {
903
+ dryRun: false,
904
+ },
905
+ context,
906
+ );
907
+
908
+ expect(secretPush.encryptedFileCount).toBe(1);
909
+ await expect(
910
+ readFile(join(syncDirectory, "files", "bundle", "token.txt"), "utf8"),
911
+ ).rejects.toMatchObject({
912
+ code: "ENOENT",
913
+ });
914
+ expect(
915
+ await readFile(
916
+ join(
917
+ syncDirectory,
918
+ "files",
919
+ "bundle",
920
+ `token.txt${syncSecretArtifactSuffix}`,
921
+ ),
922
+ "utf8",
923
+ ),
924
+ ).toContain("BEGIN AGE ENCRYPTED FILE");
925
+
926
+ await setSyncTargetMode(
927
+ {
928
+ recursive: false,
929
+ state: "ignore",
930
+ target: tokenFile,
931
+ },
932
+ context,
933
+ );
934
+
935
+ const ignorePush = await pushSync(
936
+ {
937
+ dryRun: false,
938
+ },
939
+ context,
940
+ );
941
+
942
+ expect(ignorePush.deletedArtifactCount).toBeGreaterThanOrEqual(1);
943
+ await expect(
944
+ readFile(join(syncDirectory, "files", "bundle", "token.txt"), "utf8"),
945
+ ).rejects.toMatchObject({
946
+ code: "ENOENT",
947
+ });
948
+ await expect(
949
+ readFile(
950
+ join(
951
+ syncDirectory,
952
+ "files",
953
+ "bundle",
954
+ `token.txt${syncSecretArtifactSuffix}`,
955
+ ),
956
+ "utf8",
957
+ ),
958
+ ).rejects.toMatchObject({
959
+ code: "ENOENT",
960
+ });
961
+ });
962
+
963
+ it("fails pull when a tracked secret artifact is corrupted", async () => {
964
+ const workspace = await createWorkspace();
965
+ const homeDirectory = join(workspace, "home");
966
+ const xdgConfigHome = join(workspace, "xdg");
967
+ const bundleDirectory = join(homeDirectory, "bundle");
968
+ const tokenFile = join(bundleDirectory, "token.txt");
969
+ const syncDirectory = join(xdgConfigHome, "devsync", "sync");
970
+ const ageKeys = await createAgeKeyPair();
971
+
972
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
973
+ await mkdir(bundleDirectory, { recursive: true });
974
+ await writeFile(tokenFile, "token-v1\n");
975
+
976
+ const context = createSyncContext({
977
+ environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
978
+ });
979
+
980
+ await initializeSync(
981
+ {
982
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
983
+ recipients: [ageKeys.recipient],
984
+ },
985
+ context,
986
+ );
987
+ await addSyncTarget(
988
+ {
989
+ secret: false,
990
+ target: bundleDirectory,
991
+ },
992
+ context,
993
+ );
994
+ await setSyncTargetMode(
995
+ {
996
+ recursive: false,
997
+ state: "secret",
998
+ target: tokenFile,
999
+ },
1000
+ context,
1001
+ );
1002
+ await pushSync(
1003
+ {
1004
+ dryRun: false,
1005
+ },
1006
+ context,
1007
+ );
1008
+ await writeFile(
1009
+ join(
1010
+ syncDirectory,
1011
+ "files",
1012
+ "bundle",
1013
+ `token.txt${syncSecretArtifactSuffix}`,
1014
+ ),
1015
+ "not a valid age payload",
1016
+ "utf8",
1017
+ );
1018
+
1019
+ await expect(
1020
+ pullSync(
1021
+ {
1022
+ dryRun: false,
1023
+ },
1024
+ context,
1025
+ ),
1026
+ ).rejects.toThrowError();
1027
+ });
1028
+
1029
+ it("lists tracked entries with overrides", async () => {
1030
+ const workspace = await createWorkspace();
1031
+ const homeDirectory = join(workspace, "home");
1032
+ const xdgConfigHome = join(workspace, "xdg");
1033
+ const bundleDirectory = join(homeDirectory, "bundle");
1034
+ const tokenFile = join(bundleDirectory, "token.txt");
1035
+ const ageKeys = await createAgeKeyPair();
1036
+
1037
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
1038
+ await mkdir(bundleDirectory, { recursive: true });
1039
+ await writeFile(tokenFile, "secret\n");
1040
+
1041
+ const context = createSyncContext({
1042
+ environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
1043
+ });
1044
+
1045
+ await initializeSync(
1046
+ {
1047
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
1048
+ recipients: [ageKeys.recipient],
1049
+ },
1050
+ context,
1051
+ );
1052
+ await addSyncTarget(
1053
+ {
1054
+ secret: false,
1055
+ target: bundleDirectory,
1056
+ },
1057
+ context,
1058
+ );
1059
+ await setSyncTargetMode(
1060
+ {
1061
+ recursive: false,
1062
+ state: "secret",
1063
+ target: tokenFile,
1064
+ },
1065
+ context,
1066
+ );
1067
+
1068
+ const result = await listSyncConfig(context);
1069
+
1070
+ expect(result.entries).toEqual([
1071
+ {
1072
+ kind: "directory",
1073
+ localPath: bundleDirectory,
1074
+ mode: "normal",
1075
+ name: "bundle",
1076
+ overrides: [{ mode: "secret", selector: "token.txt" }],
1077
+ repoPath: "bundle",
1078
+ },
1079
+ ]);
1080
+ expect(result.ruleCount).toBe(1);
1081
+ });
1082
+
1083
+ it("builds status previews for push and pull plans", async () => {
1084
+ const workspace = await createWorkspace();
1085
+ const homeDirectory = join(workspace, "home");
1086
+ const xdgConfigHome = join(workspace, "xdg");
1087
+ const bundleDirectory = join(homeDirectory, "bundle");
1088
+ const plainFile = join(bundleDirectory, "plain.txt");
1089
+ const ageKeys = await createAgeKeyPair();
1090
+
1091
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
1092
+ await mkdir(bundleDirectory, { recursive: true });
1093
+ await writeFile(plainFile, "plain\n");
1094
+
1095
+ const context = createSyncContext({
1096
+ environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
1097
+ });
1098
+
1099
+ await initializeSync(
1100
+ {
1101
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
1102
+ recipients: [ageKeys.recipient],
1103
+ },
1104
+ context,
1105
+ );
1106
+ await addSyncTarget(
1107
+ {
1108
+ secret: false,
1109
+ target: bundleDirectory,
1110
+ },
1111
+ context,
1112
+ );
1113
+ await pushSync(
1114
+ {
1115
+ dryRun: false,
1116
+ },
1117
+ context,
1118
+ );
1119
+
1120
+ const status = await getSyncStatus(context);
1121
+
1122
+ expect(status.push.preview).toContain("bundle/plain.txt");
1123
+ expect(status.pull.preview).toContain("bundle/plain.txt");
1124
+ expect(status.push.plainFileCount).toBe(1);
1125
+ expect(status.pull.plainFileCount).toBe(1);
1126
+ });
1127
+
1128
+ it("reports doctor warnings for missing tracked local paths", async () => {
1129
+ const workspace = await createWorkspace();
1130
+ const homeDirectory = join(workspace, "home");
1131
+ const xdgConfigHome = join(workspace, "xdg");
1132
+ const trackedFile = join(homeDirectory, ".gitconfig");
1133
+ const ageKeys = await createAgeKeyPair();
1134
+
1135
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
1136
+ await mkdir(homeDirectory, { recursive: true });
1137
+ await writeFile(trackedFile, "[user]\n name = test\n");
1138
+
1139
+ const context = createSyncContext({
1140
+ environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
1141
+ });
1142
+
1143
+ await initializeSync(
1144
+ {
1145
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
1146
+ recipients: [ageKeys.recipient],
1147
+ },
1148
+ context,
1149
+ );
1150
+ await addSyncTarget(
1151
+ {
1152
+ secret: false,
1153
+ target: trackedFile,
1154
+ },
1155
+ context,
1156
+ );
1157
+ await rm(trackedFile);
1158
+
1159
+ const result = await runSyncDoctor(context);
1160
+
1161
+ expect(result.hasFailures).toBe(false);
1162
+ expect(result.hasWarnings).toBe(true);
1163
+ expect(result.checks).toContainEqual({
1164
+ detail: "1 tracked local path is missing.",
1165
+ level: "warn",
1166
+ name: "local-paths",
1167
+ });
1168
+ });
1169
+ });