@tinyrack/devsync 1.0.0 → 1.0.3

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,609 @@
1
+ import { rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ import {
8
+ isIgnoredSyncPath,
9
+ isSecretSyncPath,
10
+ parseSyncConfig,
11
+ readSyncConfig,
12
+ resolveSyncMode,
13
+ syncSecretArtifactSuffix,
14
+ } from "#app/config/sync.ts";
15
+ import {
16
+ resolveConfiguredAbsolutePath,
17
+ resolveHomeConfiguredAbsolutePath,
18
+ resolveHomeDirectory,
19
+ resolveXdgConfigHome,
20
+ } from "#app/config/xdg.ts";
21
+ import { DevsyncError } from "#app/services/error.ts";
22
+ import { createTemporaryDirectory } from "../test/helpers/sync-fixture.ts";
23
+
24
+ const testHomeDirectory = "/tmp/devsync-home";
25
+ const testXdgConfigHome = "/tmp/devsync-xdg";
26
+ const temporaryDirectories: string[] = [];
27
+
28
+ afterEach(async () => {
29
+ while (temporaryDirectories.length > 0) {
30
+ const directory = temporaryDirectories.pop();
31
+
32
+ if (directory !== undefined) {
33
+ await rm(directory, { force: true, recursive: true });
34
+ }
35
+ }
36
+ });
37
+
38
+ describe("resolveHomeDirectory", () => {
39
+ it("falls back to the operating system home directory", () => {
40
+ expect(resolveHomeDirectory({})).toBe(homedir());
41
+ });
42
+
43
+ it("prefers HOME when set", () => {
44
+ expect(
45
+ resolveHomeDirectory({
46
+ HOME: testHomeDirectory,
47
+ }),
48
+ ).toBe(testHomeDirectory);
49
+ });
50
+ });
51
+
52
+ describe("resolveXdgConfigHome", () => {
53
+ it("falls back to the default XDG config home", () => {
54
+ expect(resolveXdgConfigHome({})).toBe(join(homedir(), ".config"));
55
+ });
56
+
57
+ it("derives the default XDG config home from HOME", () => {
58
+ expect(
59
+ resolveXdgConfigHome({
60
+ HOME: testHomeDirectory,
61
+ }),
62
+ ).toBe(join(testHomeDirectory, ".config"));
63
+ });
64
+
65
+ it("prefers XDG_CONFIG_HOME when set", () => {
66
+ expect(
67
+ resolveXdgConfigHome({
68
+ XDG_CONFIG_HOME: testXdgConfigHome,
69
+ }),
70
+ ).toBe(testXdgConfigHome);
71
+ });
72
+ });
73
+
74
+ describe("configured path resolution", () => {
75
+ it("expands home-relative path prefixes", () => {
76
+ expect(
77
+ resolveHomeConfiguredAbsolutePath("~/demo", {
78
+ HOME: testHomeDirectory,
79
+ }),
80
+ ).toBe(join(testHomeDirectory, "demo"));
81
+ });
82
+
83
+ it("expands supported path prefixes for devsync-owned paths", () => {
84
+ expect(
85
+ resolveConfiguredAbsolutePath("~/demo", {
86
+ HOME: testHomeDirectory,
87
+ }),
88
+ ).toBe(join(testHomeDirectory, "demo"));
89
+ expect(
90
+ resolveConfiguredAbsolutePath("$XDG_CONFIG_HOME/devsync/keys.txt", {
91
+ XDG_CONFIG_HOME: testXdgConfigHome,
92
+ }),
93
+ ).toBe(join(testXdgConfigHome, "devsync", "keys.txt"));
94
+ });
95
+ });
96
+
97
+ describe("parseSyncConfig", () => {
98
+ it("resolves home-scoped entry paths and normalizes overrides", () => {
99
+ const config = parseSyncConfig(
100
+ {
101
+ version: 1,
102
+ age: {
103
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
104
+ recipients: ["age1example"],
105
+ },
106
+ entries: [
107
+ {
108
+ kind: "directory",
109
+ localPath: "~/.config/mytool",
110
+ mode: "secret",
111
+ name: ".config/mytool",
112
+ overrides: {
113
+ "cache\\tmp/": "ignore",
114
+ "cache\\tmp\\keep.json": "normal",
115
+ },
116
+ repoPath: ".config\\mytool",
117
+ },
118
+ ],
119
+ },
120
+ {
121
+ HOME: testHomeDirectory,
122
+ XDG_CONFIG_HOME: testXdgConfigHome,
123
+ },
124
+ );
125
+
126
+ expect(config.age.identityFile).toBe(
127
+ join(testXdgConfigHome, "devsync", "age", "keys.txt"),
128
+ );
129
+ expect(config.entries).toEqual([
130
+ {
131
+ configuredLocalPath: "~/.config/mytool",
132
+ kind: "directory",
133
+ localPath: join(testHomeDirectory, ".config", "mytool"),
134
+ mode: "secret",
135
+ name: ".config/mytool",
136
+ overrides: [
137
+ {
138
+ match: "subtree",
139
+ mode: "ignore",
140
+ path: "cache/tmp",
141
+ },
142
+ {
143
+ match: "exact",
144
+ mode: "normal",
145
+ path: "cache/tmp/keep.json",
146
+ },
147
+ ],
148
+ repoPath: ".config/mytool",
149
+ },
150
+ ]);
151
+ });
152
+
153
+ it("accepts absolute sync entry paths that stay inside HOME", () => {
154
+ const config = parseSyncConfig(
155
+ {
156
+ version: 1,
157
+ age: {
158
+ identityFile: "/tmp/identity.txt",
159
+ recipients: ["age1example"],
160
+ },
161
+ entries: [
162
+ {
163
+ kind: "directory",
164
+ localPath: "/tmp/devsync-home/bundle",
165
+ mode: "normal",
166
+ name: "bundle",
167
+ repoPath: "bundle",
168
+ },
169
+ ],
170
+ },
171
+ {
172
+ HOME: testHomeDirectory,
173
+ },
174
+ );
175
+
176
+ expect(config.entries[0]?.localPath).toBe("/tmp/devsync-home/bundle");
177
+ expect(config.entries[0]?.mode).toBe("normal");
178
+ });
179
+
180
+ it("rejects sync entry local paths outside HOME", () => {
181
+ expect(() => {
182
+ parseSyncConfig(
183
+ {
184
+ version: 1,
185
+ age: {
186
+ identityFile: "/tmp/identity.txt",
187
+ recipients: ["age1example"],
188
+ },
189
+ entries: [
190
+ {
191
+ kind: "directory",
192
+ localPath: "/tmp/outside-home/bundle",
193
+ mode: "normal",
194
+ name: "bundle",
195
+ repoPath: "bundle",
196
+ },
197
+ ],
198
+ },
199
+ {
200
+ HOME: testHomeDirectory,
201
+ },
202
+ );
203
+ }).toThrowError(DevsyncError);
204
+ });
205
+
206
+ it("rejects XDG tokens for sync entry local paths", () => {
207
+ expect(() => {
208
+ parseSyncConfig(
209
+ {
210
+ version: 1,
211
+ age: {
212
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
213
+ recipients: ["age1example"],
214
+ },
215
+ entries: [
216
+ {
217
+ kind: "directory",
218
+ localPath: "$XDG_CONFIG_HOME/bundle",
219
+ mode: "normal",
220
+ name: "bundle",
221
+ repoPath: "bundle",
222
+ },
223
+ ],
224
+ },
225
+ {
226
+ HOME: testHomeDirectory,
227
+ XDG_CONFIG_HOME: testXdgConfigHome,
228
+ },
229
+ );
230
+ }).toThrowError(DevsyncError);
231
+ });
232
+
233
+ it("rejects unsupported glob fields", () => {
234
+ expect(() => {
235
+ parseSyncConfig(
236
+ {
237
+ version: 1,
238
+ age: {
239
+ identityFile: "/tmp/identity.txt",
240
+ recipients: ["age1example"],
241
+ },
242
+ entries: [
243
+ {
244
+ kind: "directory",
245
+ localPath: "~/bundle",
246
+ mode: "normal",
247
+ name: "bundle",
248
+ repoPath: "bundle",
249
+ secretGlobs: ["**"],
250
+ },
251
+ ],
252
+ },
253
+ {
254
+ HOME: testHomeDirectory,
255
+ },
256
+ );
257
+ }).toThrowError(DevsyncError);
258
+ });
259
+
260
+ it("rejects repository paths and overrides that use the reserved secret suffix", () => {
261
+ expect(() => {
262
+ parseSyncConfig(
263
+ {
264
+ version: 1,
265
+ age: {
266
+ identityFile: "/tmp/identity.txt",
267
+ recipients: ["age1example"],
268
+ },
269
+ entries: [
270
+ {
271
+ kind: "file",
272
+ localPath: "~/bundle/token.txt",
273
+ mode: "normal",
274
+ name: "bundle/token.txt",
275
+ repoPath: `bundle/token.txt${syncSecretArtifactSuffix}`,
276
+ },
277
+ ],
278
+ },
279
+ {
280
+ HOME: testHomeDirectory,
281
+ },
282
+ );
283
+ }).toThrowError(DevsyncError);
284
+
285
+ expect(() => {
286
+ parseSyncConfig(
287
+ {
288
+ version: 1,
289
+ age: {
290
+ identityFile: "/tmp/identity.txt",
291
+ recipients: ["age1example"],
292
+ },
293
+ entries: [
294
+ {
295
+ kind: "directory",
296
+ localPath: "~/bundle",
297
+ mode: "normal",
298
+ name: "bundle",
299
+ overrides: {
300
+ [`token.txt${syncSecretArtifactSuffix}`]: "secret",
301
+ },
302
+ repoPath: "bundle",
303
+ },
304
+ ],
305
+ },
306
+ {
307
+ HOME: testHomeDirectory,
308
+ },
309
+ );
310
+ }).toThrowError(DevsyncError);
311
+ });
312
+
313
+ it("rejects overrides on file entries", () => {
314
+ expect(() => {
315
+ parseSyncConfig(
316
+ {
317
+ version: 1,
318
+ age: {
319
+ identityFile: "/tmp/identity.txt",
320
+ recipients: ["age1example"],
321
+ },
322
+ entries: [
323
+ {
324
+ kind: "file",
325
+ localPath: "~/bundle.json",
326
+ mode: "normal",
327
+ name: "bundle.json",
328
+ overrides: {
329
+ "nested.json": "secret",
330
+ },
331
+ repoPath: "bundle.json",
332
+ },
333
+ ],
334
+ },
335
+ {
336
+ HOME: testHomeDirectory,
337
+ },
338
+ );
339
+ }).toThrowError(DevsyncError);
340
+ });
341
+
342
+ it("rejects duplicate overrides after normalization", () => {
343
+ expect(() => {
344
+ parseSyncConfig(
345
+ {
346
+ version: 1,
347
+ age: {
348
+ identityFile: "/tmp/identity.txt",
349
+ recipients: ["age1example"],
350
+ },
351
+ entries: [
352
+ {
353
+ kind: "directory",
354
+ localPath: "~/bundle",
355
+ mode: "normal",
356
+ name: "bundle",
357
+ overrides: {
358
+ "cache/": "ignore",
359
+ "cache//": "secret",
360
+ },
361
+ repoPath: "bundle",
362
+ },
363
+ ],
364
+ },
365
+ {
366
+ HOME: testHomeDirectory,
367
+ },
368
+ );
369
+ }).toThrowError(DevsyncError);
370
+ });
371
+
372
+ it("rejects duplicate entry names and overlapping entry paths", () => {
373
+ expect(() => {
374
+ parseSyncConfig(
375
+ {
376
+ version: 1,
377
+ age: {
378
+ identityFile: "/tmp/identity.txt",
379
+ recipients: ["age1example"],
380
+ },
381
+ entries: [
382
+ {
383
+ kind: "file",
384
+ localPath: "~/bundle/one.json",
385
+ mode: "normal",
386
+ name: "bundle",
387
+ repoPath: "bundle/one.json",
388
+ },
389
+ {
390
+ kind: "file",
391
+ localPath: "~/bundle/two.json",
392
+ mode: "normal",
393
+ name: "bundle",
394
+ repoPath: "bundle/two.json",
395
+ },
396
+ ],
397
+ },
398
+ {
399
+ HOME: testHomeDirectory,
400
+ },
401
+ );
402
+ }).toThrowError(DevsyncError);
403
+
404
+ expect(() => {
405
+ parseSyncConfig(
406
+ {
407
+ version: 1,
408
+ age: {
409
+ identityFile: "/tmp/identity.txt",
410
+ recipients: ["age1example"],
411
+ },
412
+ entries: [
413
+ {
414
+ kind: "directory",
415
+ localPath: "~/bundle",
416
+ mode: "normal",
417
+ name: "bundle",
418
+ repoPath: "bundle",
419
+ },
420
+ {
421
+ kind: "file",
422
+ localPath: "~/bundle/file.txt",
423
+ mode: "normal",
424
+ name: "bundle/file.txt",
425
+ repoPath: "bundle/file.txt",
426
+ },
427
+ ],
428
+ },
429
+ {
430
+ HOME: testHomeDirectory,
431
+ },
432
+ );
433
+ }).toThrowError(DevsyncError);
434
+ });
435
+
436
+ it("rejects the home directory itself and escaping rule paths", () => {
437
+ expect(() => {
438
+ parseSyncConfig(
439
+ {
440
+ version: 1,
441
+ age: {
442
+ identityFile: "/tmp/identity.txt",
443
+ recipients: ["age1example"],
444
+ },
445
+ entries: [
446
+ {
447
+ kind: "directory",
448
+ localPath: "~",
449
+ mode: "normal",
450
+ name: "bundle",
451
+ repoPath: "bundle",
452
+ },
453
+ ],
454
+ },
455
+ {
456
+ HOME: testHomeDirectory,
457
+ },
458
+ );
459
+ }).toThrowError(DevsyncError);
460
+
461
+ expect(() => {
462
+ parseSyncConfig(
463
+ {
464
+ version: 1,
465
+ age: {
466
+ identityFile: "/tmp/identity.txt",
467
+ recipients: ["age1example"],
468
+ },
469
+ entries: [
470
+ {
471
+ kind: "directory",
472
+ localPath: "~/bundle",
473
+ mode: "normal",
474
+ name: "bundle",
475
+ overrides: {
476
+ "../token.txt": "secret",
477
+ },
478
+ repoPath: "bundle",
479
+ },
480
+ ],
481
+ },
482
+ {
483
+ HOME: testHomeDirectory,
484
+ },
485
+ );
486
+ }).toThrowError(DevsyncError);
487
+ });
488
+
489
+ it("resolves modes with exact rules overriding subtree rules and defaults", () => {
490
+ const config = parseSyncConfig(
491
+ {
492
+ version: 1,
493
+ age: {
494
+ identityFile: "/tmp/identity.txt",
495
+ recipients: ["age1example"],
496
+ },
497
+ entries: [
498
+ {
499
+ kind: "directory",
500
+ localPath: "~/bundle",
501
+ mode: "secret",
502
+ name: "bundle",
503
+ overrides: {
504
+ "private/": "ignore",
505
+ "private/public.json": "normal",
506
+ },
507
+ repoPath: "bundle",
508
+ },
509
+ ],
510
+ },
511
+ {
512
+ HOME: testHomeDirectory,
513
+ },
514
+ );
515
+
516
+ expect(resolveSyncMode(config, "bundle/plain.txt")).toBe("secret");
517
+ expect(resolveSyncMode(config, "bundle/private/token.txt")).toBe("ignore");
518
+ expect(resolveSyncMode(config, "bundle/private/public.json")).toBe(
519
+ "normal",
520
+ );
521
+ });
522
+
523
+ it("prefers deeper subtree rules and exact matches over same-path subtrees", () => {
524
+ const config = parseSyncConfig(
525
+ {
526
+ version: 1,
527
+ age: {
528
+ identityFile: "/tmp/identity.txt",
529
+ recipients: ["age1example"],
530
+ },
531
+ entries: [
532
+ {
533
+ kind: "directory",
534
+ localPath: "~/bundle",
535
+ mode: "normal",
536
+ name: "bundle",
537
+ overrides: {
538
+ "private/": "secret",
539
+ "private/public/": "ignore",
540
+ "private/public/file.txt": "normal",
541
+ "private/public/file.txt/": "secret",
542
+ },
543
+ repoPath: "bundle",
544
+ },
545
+ ],
546
+ },
547
+ {
548
+ HOME: testHomeDirectory,
549
+ },
550
+ );
551
+
552
+ expect(resolveSyncMode(config, "bundle/private/secret.txt")).toBe("secret");
553
+ expect(resolveSyncMode(config, "bundle/private/public/child.txt")).toBe(
554
+ "ignore",
555
+ );
556
+ expect(resolveSyncMode(config, "bundle/private/public/file.txt")).toBe(
557
+ "normal",
558
+ );
559
+ });
560
+
561
+ it("returns undefined for unmanaged paths and exposes helper predicates", () => {
562
+ const config = parseSyncConfig(
563
+ {
564
+ version: 1,
565
+ age: {
566
+ identityFile: "/tmp/identity.txt",
567
+ recipients: ["age1example"],
568
+ },
569
+ entries: [
570
+ {
571
+ kind: "directory",
572
+ localPath: "~/bundle",
573
+ mode: "secret",
574
+ name: "bundle",
575
+ overrides: {
576
+ "ignored.txt": "ignore",
577
+ },
578
+ repoPath: "bundle",
579
+ },
580
+ ],
581
+ },
582
+ {
583
+ HOME: testHomeDirectory,
584
+ },
585
+ );
586
+
587
+ expect(resolveSyncMode(config, "elsewhere/file.txt")).toBeUndefined();
588
+ expect(isSecretSyncPath(config, "bundle/token.txt")).toBe(true);
589
+ expect(isIgnoredSyncPath(config, "bundle/ignored.txt")).toBe(true);
590
+ expect(isSecretSyncPath(config, "elsewhere/file.txt")).toBe(false);
591
+ expect(isIgnoredSyncPath(config, "elsewhere/file.txt")).toBe(false);
592
+ });
593
+
594
+ it("wraps malformed JSON when reading a sync config file", async () => {
595
+ const syncDirectory = await createTemporaryDirectory(
596
+ "devsync-sync-config-",
597
+ );
598
+
599
+ temporaryDirectories.push(syncDirectory);
600
+
601
+ await writeFile(join(syncDirectory, "config.json"), "{\n", "utf8");
602
+
603
+ await expect(
604
+ readSyncConfig(syncDirectory, {
605
+ HOME: testHomeDirectory,
606
+ }),
607
+ ).rejects.toThrowError(DevsyncError);
608
+ });
609
+ });
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { ensureTrailingNewline } from "#app/lib/string.ts";
4
+
5
+ describe("string helpers", () => {
6
+ it("adds a trailing newline when missing", () => {
7
+ expect(ensureTrailingNewline("value")).toBe("value\n");
8
+ });
9
+
10
+ it("preserves an existing trailing newline", () => {
11
+ expect(ensureTrailingNewline("value\n")).toBe("value\n");
12
+ });
13
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ZodIssue } from "zod";
3
+
4
+ import { formatInputIssues } from "#app/lib/validation.ts";
5
+
6
+ describe("validation helpers", () => {
7
+ it("formats root-level issues as input", () => {
8
+ const issues = [
9
+ {
10
+ code: "custom",
11
+ message: "Invalid request.",
12
+ path: [],
13
+ },
14
+ ] satisfies ZodIssue[];
15
+
16
+ expect(formatInputIssues(issues)).toBe("- input: Invalid request.");
17
+ });
18
+
19
+ it("formats nested issue paths with dot notation", () => {
20
+ const issues = [
21
+ {
22
+ code: "custom",
23
+ message: "Value must not be empty.",
24
+ path: ["entries", 0, "repoPath"],
25
+ },
26
+ ] satisfies ZodIssue[];
27
+
28
+ expect(formatInputIssues(issues)).toBe(
29
+ "- entries.0.repoPath: Value must not be empty.",
30
+ );
31
+ });
32
+ });