@tinyrack/devsync 1.1.0 → 1.3.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.
Files changed (220) hide show
  1. package/README.md +230 -62
  2. package/dist/cli/base-command.d.ts +14 -0
  3. package/dist/cli/base-command.d.ts.map +1 -0
  4. package/dist/cli/base-command.js +22 -0
  5. package/dist/cli/base-command.js.map +1 -0
  6. package/dist/cli/commands/dir.d.ts +8 -0
  7. package/dist/cli/commands/dir.d.ts.map +1 -0
  8. package/dist/cli/commands/dir.js +16 -0
  9. package/dist/cli/commands/dir.js.map +1 -0
  10. package/dist/cli/commands/doctor.d.ts +8 -0
  11. package/dist/cli/commands/doctor.d.ts.map +1 -0
  12. package/dist/cli/commands/doctor.js +18 -0
  13. package/dist/cli/commands/doctor.js.map +1 -0
  14. package/dist/cli/commands/index.d.ts +23 -0
  15. package/dist/cli/commands/index.d.ts.map +1 -0
  16. package/dist/cli/commands/index.js +23 -0
  17. package/dist/cli/commands/index.js.map +1 -0
  18. package/dist/cli/commands/init.d.ts +15 -0
  19. package/dist/cli/commands/init.d.ts.map +1 -0
  20. package/dist/cli/commands/init.js +43 -0
  21. package/dist/cli/commands/init.js.map +1 -0
  22. package/dist/cli/commands/machine/list.d.ts +7 -0
  23. package/dist/cli/commands/machine/list.d.ts.map +1 -0
  24. package/dist/cli/commands/machine/list.js +12 -0
  25. package/dist/cli/commands/machine/list.js.map +1 -0
  26. package/dist/cli/commands/machine/use.d.ts +11 -0
  27. package/dist/cli/commands/machine/use.d.ts.map +1 -0
  28. package/dist/cli/commands/machine/use.js +28 -0
  29. package/dist/cli/commands/machine/use.js.map +1 -0
  30. package/dist/cli/commands/pull.d.ts +12 -0
  31. package/dist/cli/commands/pull.d.ts.map +1 -0
  32. package/dist/cli/commands/pull.js +34 -0
  33. package/dist/cli/commands/pull.js.map +1 -0
  34. package/dist/cli/commands/push.d.ts +12 -0
  35. package/dist/cli/commands/push.d.ts.map +1 -0
  36. package/dist/cli/commands/push.js +34 -0
  37. package/dist/cli/commands/push.js.map +1 -0
  38. package/dist/cli/commands/status.d.ts +11 -0
  39. package/dist/cli/commands/status.d.ts.map +1 -0
  40. package/dist/cli/commands/status.js +27 -0
  41. package/dist/cli/commands/status.js.map +1 -0
  42. package/dist/cli/commands/track.d.ts +16 -0
  43. package/dist/cli/commands/track.d.ts.map +1 -0
  44. package/dist/cli/commands/track.js +82 -0
  45. package/dist/cli/commands/track.js.map +1 -0
  46. package/dist/cli/commands/untrack.d.ts +11 -0
  47. package/dist/cli/commands/untrack.d.ts.map +1 -0
  48. package/dist/cli/commands/untrack.js +28 -0
  49. package/dist/cli/commands/untrack.js.map +1 -0
  50. package/dist/config/global-config.d.ts +21 -0
  51. package/dist/config/global-config.d.ts.map +1 -0
  52. package/dist/config/global-config.js +106 -0
  53. package/dist/config/global-config.js.map +1 -0
  54. package/dist/config/platform.d.ts +11 -0
  55. package/dist/config/platform.d.ts.map +1 -0
  56. package/dist/config/platform.js +19 -0
  57. package/dist/config/platform.js.map +1 -0
  58. package/dist/config/sync.d.ts +107 -0
  59. package/dist/config/sync.d.ts.map +1 -0
  60. package/dist/config/sync.js +424 -0
  61. package/dist/config/sync.js.map +1 -0
  62. package/dist/config/xdg.d.ts +14 -0
  63. package/dist/config/xdg.d.ts.map +1 -0
  64. package/dist/config/xdg.js +102 -0
  65. package/dist/config/xdg.js.map +1 -0
  66. package/dist/index.d.ts +3 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/{src/index.ts → dist/index.js} +1 -1
  69. package/dist/index.js.map +1 -0
  70. package/dist/lib/file-mode.d.ts +3 -0
  71. package/dist/lib/file-mode.d.ts.map +1 -0
  72. package/dist/lib/file-mode.js +7 -0
  73. package/dist/lib/file-mode.js.map +1 -0
  74. package/dist/lib/output.d.ts +31 -0
  75. package/dist/lib/output.d.ts.map +1 -0
  76. package/dist/lib/output.js +198 -0
  77. package/dist/lib/output.js.map +1 -0
  78. package/dist/lib/path.d.ts +5 -0
  79. package/dist/lib/path.d.ts.map +1 -0
  80. package/dist/lib/path.js +25 -0
  81. package/dist/lib/path.js.map +1 -0
  82. package/dist/lib/string.d.ts +2 -0
  83. package/dist/lib/string.d.ts.map +1 -0
  84. package/dist/lib/string.js +4 -0
  85. package/dist/lib/string.js.map +1 -0
  86. package/dist/lib/validation.d.ts +3 -0
  87. package/dist/lib/validation.d.ts.map +1 -0
  88. package/dist/lib/validation.js +9 -0
  89. package/dist/lib/validation.js.map +1 -0
  90. package/dist/services/add.d.ts +20 -0
  91. package/dist/services/add.d.ts.map +1 -0
  92. package/dist/services/add.js +161 -0
  93. package/dist/services/add.js.map +1 -0
  94. package/dist/services/config-file.d.ts +45 -0
  95. package/dist/services/config-file.d.ts.map +1 -0
  96. package/dist/services/config-file.js +35 -0
  97. package/dist/services/config-file.js.map +1 -0
  98. package/dist/services/crypto.d.ts +9 -0
  99. package/dist/services/crypto.d.ts.map +1 -0
  100. package/dist/services/crypto.js +75 -0
  101. package/dist/services/crypto.js.map +1 -0
  102. package/dist/services/doctor.d.ts +16 -0
  103. package/dist/services/doctor.d.ts.map +1 -0
  104. package/dist/services/doctor.js +84 -0
  105. package/dist/services/doctor.js.map +1 -0
  106. package/dist/services/error.d.ts +14 -0
  107. package/dist/services/error.d.ts.map +1 -0
  108. package/dist/services/error.js +38 -0
  109. package/dist/services/error.js.map +1 -0
  110. package/dist/services/filesystem.d.ts +15 -0
  111. package/dist/services/filesystem.d.ts.map +1 -0
  112. package/dist/services/filesystem.js +113 -0
  113. package/dist/services/filesystem.js.map +1 -0
  114. package/dist/services/forget.d.ts +14 -0
  115. package/dist/services/forget.d.ts.map +1 -0
  116. package/dist/services/forget.js +124 -0
  117. package/dist/services/forget.js.map +1 -0
  118. package/dist/services/git.d.ts +10 -0
  119. package/dist/services/git.d.ts.map +1 -0
  120. package/dist/services/git.js +57 -0
  121. package/dist/services/git.js.map +1 -0
  122. package/dist/services/init.d.ts +19 -0
  123. package/dist/services/init.d.ts.map +1 -0
  124. package/dist/services/init.js +203 -0
  125. package/dist/services/init.js.map +1 -0
  126. package/dist/services/local-materialization.d.ts +28 -0
  127. package/dist/services/local-materialization.d.ts.map +1 -0
  128. package/dist/services/local-materialization.js +262 -0
  129. package/dist/services/local-materialization.js.map +1 -0
  130. package/dist/services/local-snapshot.d.ts +25 -0
  131. package/dist/services/local-snapshot.d.ts.map +1 -0
  132. package/dist/services/local-snapshot.js +93 -0
  133. package/dist/services/local-snapshot.js.map +1 -0
  134. package/dist/services/machine.d.ts +40 -0
  135. package/dist/services/machine.d.ts.map +1 -0
  136. package/dist/services/machine.js +113 -0
  137. package/dist/services/machine.js.map +1 -0
  138. package/dist/services/paths.d.ts +13 -0
  139. package/dist/services/paths.d.ts.map +1 -0
  140. package/dist/services/paths.js +71 -0
  141. package/dist/services/paths.js.map +1 -0
  142. package/dist/services/pull.d.ts +28 -0
  143. package/dist/services/pull.d.ts.map +1 -0
  144. package/dist/services/pull.js +67 -0
  145. package/dist/services/pull.js.map +1 -0
  146. package/dist/services/push.d.ts +35 -0
  147. package/dist/services/push.d.ts.map +1 -0
  148. package/dist/services/push.js +96 -0
  149. package/dist/services/push.js.map +1 -0
  150. package/dist/services/repo-artifacts.d.ts +52 -0
  151. package/dist/services/repo-artifacts.d.ts.map +1 -0
  152. package/dist/services/repo-artifacts.js +251 -0
  153. package/dist/services/repo-artifacts.js.map +1 -0
  154. package/dist/services/repo-snapshot.d.ts +6 -0
  155. package/dist/services/repo-snapshot.d.ts.map +1 -0
  156. package/dist/services/repo-snapshot.js +163 -0
  157. package/dist/services/repo-snapshot.js.map +1 -0
  158. package/dist/services/runtime.d.ts +40 -0
  159. package/dist/services/runtime.d.ts.map +1 -0
  160. package/dist/services/runtime.js +71 -0
  161. package/dist/services/runtime.js.map +1 -0
  162. package/dist/services/set.d.ts +38 -0
  163. package/dist/services/set.d.ts.map +1 -0
  164. package/dist/services/set.js +184 -0
  165. package/dist/services/set.js.map +1 -0
  166. package/dist/services/status.d.ts +30 -0
  167. package/dist/services/status.d.ts.map +1 -0
  168. package/dist/services/status.js +35 -0
  169. package/dist/services/status.js.map +1 -0
  170. package/package.json +15 -7
  171. package/src/cli/commands/add.ts +0 -40
  172. package/src/cli/commands/cd.ts +0 -80
  173. package/src/cli/commands/doctor.ts +0 -20
  174. package/src/cli/commands/forget.ts +0 -32
  175. package/src/cli/commands/index.ts +0 -23
  176. package/src/cli/commands/init.ts +0 -43
  177. package/src/cli/commands/list.ts +0 -17
  178. package/src/cli/commands/pull.ts +0 -31
  179. package/src/cli/commands/push.ts +0 -31
  180. package/src/cli/commands/set.ts +0 -47
  181. package/src/cli/commands/status.ts +0 -18
  182. package/src/cli/sync-output.test.ts +0 -173
  183. package/src/cli/sync-output.ts +0 -200
  184. package/src/config/sync.test.ts +0 -609
  185. package/src/config/sync.ts +0 -572
  186. package/src/config/xdg.ts +0 -138
  187. package/src/lib/string.test.ts +0 -13
  188. package/src/lib/string.ts +0 -3
  189. package/src/lib/validation.test.ts +0 -32
  190. package/src/lib/validation.ts +0 -11
  191. package/src/services/add.ts +0 -178
  192. package/src/services/config-file.test.ts +0 -161
  193. package/src/services/config-file.ts +0 -101
  194. package/src/services/crypto.test.ts +0 -132
  195. package/src/services/crypto.ts +0 -83
  196. package/src/services/doctor.ts +0 -142
  197. package/src/services/error.ts +0 -6
  198. package/src/services/filesystem.test.ts +0 -171
  199. package/src/services/filesystem.ts +0 -183
  200. package/src/services/forget.ts +0 -261
  201. package/src/services/git.test.ts +0 -83
  202. package/src/services/git.ts +0 -74
  203. package/src/services/init.test.ts +0 -109
  204. package/src/services/init.ts +0 -244
  205. package/src/services/list.ts +0 -63
  206. package/src/services/local-materialization.ts +0 -421
  207. package/src/services/local-snapshot.ts +0 -173
  208. package/src/services/paths.test.ts +0 -74
  209. package/src/services/paths.ts +0 -98
  210. package/src/services/pull.ts +0 -144
  211. package/src/services/push.ts +0 -168
  212. package/src/services/repo-artifacts.ts +0 -262
  213. package/src/services/repo-snapshot.ts +0 -197
  214. package/src/services/runtime.ts +0 -57
  215. package/src/services/set.ts +0 -383
  216. package/src/services/status.ts +0 -57
  217. package/src/services/sync.dry-run.test.ts +0 -179
  218. package/src/services/sync.runtime.test.ts +0 -756
  219. package/src/services/sync.service.test.ts +0 -1169
  220. package/src/test/helpers/sync-fixture.ts +0 -47
package/src/lib/string.ts DELETED
@@ -1,3 +0,0 @@
1
- export const ensureTrailingNewline = (value: string) => {
2
- return value.endsWith("\n") ? value : `${value}\n`;
3
- };
@@ -1,32 +0,0 @@
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
- });
@@ -1,11 +0,0 @@
1
- import type { z } from "zod";
2
-
3
- export const formatInputIssues = (issues: z.ZodIssue[]): string => {
4
- return issues
5
- .map((issue) => {
6
- const path = issue.path.length === 0 ? "input" : issue.path.join(".");
7
-
8
- return `- ${path}: ${issue.message}`;
9
- })
10
- .join("\n");
11
- };
@@ -1,178 +0,0 @@
1
- import {
2
- type ResolvedSyncConfig,
3
- type ResolvedSyncConfigEntry,
4
- readSyncConfig,
5
- type SyncConfigEntryKind,
6
- type SyncMode,
7
- } from "#app/config/sync.ts";
8
-
9
- import {
10
- createSyncConfigDocument,
11
- createSyncConfigDocumentEntry,
12
- sortSyncConfigEntries,
13
- writeValidatedSyncConfig,
14
- } from "./config-file.ts";
15
- import { DevsyncError } from "./error.ts";
16
- import { getPathStats } from "./filesystem.ts";
17
- import {
18
- buildConfiguredHomeLocalPath,
19
- buildRepoPathWithinRoot,
20
- doPathsOverlap,
21
- resolveCommandTargetPath,
22
- } from "./paths.ts";
23
- import { ensureSyncRepository, type SyncContext } from "./runtime.ts";
24
-
25
- export type SyncAddRequest = Readonly<{
26
- secret: boolean;
27
- target: string;
28
- }>;
29
-
30
- export type SyncAddResult = Readonly<{
31
- alreadyTracked: boolean;
32
- configPath: string;
33
- kind: SyncConfigEntryKind;
34
- localPath: string;
35
- mode: SyncMode;
36
- repoPath: string;
37
- syncDirectory: string;
38
- }>;
39
-
40
- const buildAddEntryCandidate = async (
41
- targetPath: string,
42
- config: ResolvedSyncConfig,
43
- context: Pick<SyncContext, "paths">,
44
- ) => {
45
- const targetStats = await getPathStats(targetPath);
46
-
47
- if (targetStats === undefined) {
48
- throw new DevsyncError(`Sync target does not exist: ${targetPath}`);
49
- }
50
-
51
- const kind = (() => {
52
- if (targetStats.isDirectory()) {
53
- return "directory" as const;
54
- }
55
-
56
- if (targetStats.isFile() || targetStats.isSymbolicLink()) {
57
- return "file" as const;
58
- }
59
-
60
- throw new DevsyncError(`Unsupported sync target type: ${targetPath}`);
61
- })();
62
-
63
- if (doPathsOverlap(targetPath, context.paths.syncDirectory)) {
64
- throw new DevsyncError(
65
- `Sync target must not overlap the sync directory: ${targetPath}`,
66
- );
67
- }
68
-
69
- if (doPathsOverlap(targetPath, config.age.identityFile)) {
70
- throw new DevsyncError(
71
- `Sync target must not contain the age identity file: ${targetPath}`,
72
- );
73
- }
74
-
75
- const repoPath = buildRepoPathWithinRoot(
76
- targetPath,
77
- context.paths.homeDirectory,
78
- "Sync target",
79
- );
80
-
81
- return {
82
- configuredLocalPath: buildConfiguredHomeLocalPath(repoPath),
83
- kind,
84
- localPath: targetPath,
85
- mode: "normal",
86
- name: repoPath,
87
- overrides: [],
88
- repoPath,
89
- } satisfies ResolvedSyncConfigEntry;
90
- };
91
-
92
- export const addSyncTarget = async (
93
- request: SyncAddRequest,
94
- context: SyncContext,
95
- ): Promise<SyncAddResult> => {
96
- const target = request.target.trim();
97
-
98
- if (target.length === 0) {
99
- throw new DevsyncError("Target path is required.");
100
- }
101
-
102
- await ensureSyncRepository(context);
103
-
104
- const config = await readSyncConfig(
105
- context.paths.syncDirectory,
106
- context.environment,
107
- );
108
- const candidate = await buildAddEntryCandidate(
109
- resolveCommandTargetPath(target, context.environment, context.cwd),
110
- config,
111
- context,
112
- );
113
- const existingEntry = config.entries.find((entry) => {
114
- return (
115
- entry.localPath === candidate.localPath ||
116
- entry.repoPath === candidate.repoPath
117
- );
118
- });
119
- let alreadyTracked = false;
120
-
121
- if (existingEntry !== undefined) {
122
- if (
123
- existingEntry.localPath === candidate.localPath &&
124
- existingEntry.repoPath === candidate.repoPath &&
125
- existingEntry.kind === candidate.kind
126
- ) {
127
- alreadyTracked = true;
128
- } else {
129
- throw new DevsyncError(
130
- `Sync target conflicts with an existing entry: ${existingEntry.repoPath}`,
131
- );
132
- }
133
- }
134
-
135
- const nextConfig = createSyncConfigDocument(config);
136
- const desiredMode: SyncMode = request.secret ? "secret" : "normal";
137
- let mode = existingEntry?.mode ?? (request.secret ? "secret" : "normal");
138
-
139
- if (!alreadyTracked) {
140
- nextConfig.entries = sortSyncConfigEntries([
141
- ...nextConfig.entries,
142
- createSyncConfigDocumentEntry({
143
- ...candidate,
144
- mode: desiredMode,
145
- }),
146
- ]);
147
- mode = desiredMode;
148
- } else if (request.secret && existingEntry?.mode !== "secret") {
149
- nextConfig.entries = nextConfig.entries.map((entry) => {
150
- if (entry.repoPath !== candidate.repoPath) {
151
- return entry;
152
- }
153
-
154
- return {
155
- ...entry,
156
- mode: "secret",
157
- };
158
- });
159
-
160
- mode = "secret";
161
- }
162
-
163
- if (!alreadyTracked || (request.secret && existingEntry?.mode !== "secret")) {
164
- await writeValidatedSyncConfig(context.paths.syncDirectory, nextConfig, {
165
- environment: context.environment,
166
- });
167
- }
168
-
169
- return {
170
- alreadyTracked,
171
- configPath: context.paths.configPath,
172
- kind: candidate.kind,
173
- localPath: candidate.localPath,
174
- mode,
175
- repoPath: candidate.repoPath,
176
- syncDirectory: context.paths.syncDirectory,
177
- };
178
- };
@@ -1,161 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import type {
4
- ResolvedSyncConfig,
5
- ResolvedSyncConfigEntry,
6
- ResolvedSyncOverride,
7
- SyncConfig,
8
- } from "#app/config/sync.ts";
9
- import {
10
- countConfiguredRules,
11
- createSyncConfigDocument,
12
- createSyncConfigDocumentEntry,
13
- sortSyncConfigEntries,
14
- sortSyncOverrides,
15
- } from "#app/services/config-file.ts";
16
-
17
- const baseOverride = (
18
- value: Pick<ResolvedSyncOverride, "match" | "mode" | "path">,
19
- ): ResolvedSyncOverride => {
20
- return value;
21
- };
22
-
23
- const baseEntry = (
24
- value: Pick<
25
- ResolvedSyncConfigEntry,
26
- | "configuredLocalPath"
27
- | "kind"
28
- | "localPath"
29
- | "mode"
30
- | "name"
31
- | "overrides"
32
- | "repoPath"
33
- >,
34
- ): ResolvedSyncConfigEntry => {
35
- return value;
36
- };
37
-
38
- describe("config file helpers", () => {
39
- it("sorts overrides by their formatted selector", () => {
40
- const overrides = sortSyncOverrides([
41
- baseOverride({ match: "exact", mode: "secret", path: "z.txt" }),
42
- baseOverride({ match: "subtree", mode: "ignore", path: "cache" }),
43
- baseOverride({ match: "exact", mode: "normal", path: "a.txt" }),
44
- ]);
45
-
46
- expect(overrides).toEqual([
47
- { match: "exact", mode: "normal", path: "a.txt" },
48
- { match: "subtree", mode: "ignore", path: "cache" },
49
- { match: "exact", mode: "secret", path: "z.txt" },
50
- ]);
51
- });
52
-
53
- it("creates config document entries without empty overrides", () => {
54
- expect(
55
- createSyncConfigDocumentEntry(
56
- baseEntry({
57
- configuredLocalPath: "~/.zshrc",
58
- kind: "file",
59
- localPath: "/tmp/home/.zshrc",
60
- mode: "normal",
61
- name: ".zshrc",
62
- overrides: [],
63
- repoPath: ".zshrc",
64
- }),
65
- ),
66
- ).toEqual({
67
- kind: "file",
68
- localPath: "~/.zshrc",
69
- mode: "normal",
70
- name: ".zshrc",
71
- repoPath: ".zshrc",
72
- });
73
- });
74
-
75
- it("creates sorted override maps for config document entries", () => {
76
- expect(
77
- createSyncConfigDocumentEntry(
78
- baseEntry({
79
- configuredLocalPath: "~/bundle",
80
- kind: "directory",
81
- localPath: "/tmp/home/bundle",
82
- mode: "normal",
83
- name: "bundle",
84
- overrides: [
85
- baseOverride({ match: "exact", mode: "secret", path: "z.txt" }),
86
- baseOverride({ match: "subtree", mode: "ignore", path: "cache" }),
87
- baseOverride({ match: "exact", mode: "normal", path: "a.txt" }),
88
- ],
89
- repoPath: "bundle",
90
- }),
91
- ),
92
- ).toEqual({
93
- kind: "directory",
94
- localPath: "~/bundle",
95
- mode: "normal",
96
- name: "bundle",
97
- overrides: {
98
- "a.txt": "normal",
99
- "cache/": "ignore",
100
- "z.txt": "secret",
101
- },
102
- repoPath: "bundle",
103
- });
104
- });
105
-
106
- it("creates and sorts config documents from resolved config", () => {
107
- const config: ResolvedSyncConfig = {
108
- version: 1,
109
- age: {
110
- configuredIdentityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
111
- identityFile: "/tmp/xdg/devsync/age/keys.txt",
112
- recipients: ["age1a", "age1b"],
113
- },
114
- entries: [
115
- baseEntry({
116
- configuredLocalPath: "~/bundle",
117
- kind: "directory",
118
- localPath: "/tmp/home/bundle",
119
- mode: "secret",
120
- name: "bundle",
121
- overrides: [
122
- baseOverride({ match: "subtree", mode: "ignore", path: "cache" }),
123
- ],
124
- repoPath: "bundle",
125
- }),
126
- baseEntry({
127
- configuredLocalPath: "~/.zshrc",
128
- kind: "file",
129
- localPath: "/tmp/home/.zshrc",
130
- mode: "normal",
131
- name: ".zshrc",
132
- overrides: [],
133
- repoPath: ".zshrc",
134
- }),
135
- ],
136
- };
137
-
138
- expect(
139
- sortSyncConfigEntries(createSyncConfigDocument(config).entries),
140
- ).toEqual([
141
- {
142
- kind: "file",
143
- localPath: "~/.zshrc",
144
- mode: "normal",
145
- name: ".zshrc",
146
- repoPath: ".zshrc",
147
- },
148
- {
149
- kind: "directory",
150
- localPath: "~/bundle",
151
- mode: "secret",
152
- name: "bundle",
153
- overrides: {
154
- "cache/": "ignore",
155
- },
156
- repoPath: "bundle",
157
- },
158
- ] satisfies SyncConfig["entries"]);
159
- expect(countConfiguredRules(config)).toBe(1);
160
- });
161
- });
@@ -1,101 +0,0 @@
1
- import {
2
- formatSyncConfig,
3
- formatSyncOverrideSelector,
4
- parseSyncConfig,
5
- type ResolvedSyncConfig,
6
- type ResolvedSyncConfigEntry,
7
- type ResolvedSyncOverride,
8
- resolveSyncConfigFilePath,
9
- type SyncConfig,
10
- } from "#app/config/sync.ts";
11
-
12
- import { writeTextFileAtomically } from "./filesystem.ts";
13
-
14
- type SyncConfigDocumentEntry = SyncConfig["entries"][number];
15
-
16
- export const sortSyncOverrides = (
17
- overrides: readonly Pick<ResolvedSyncOverride, "match" | "mode" | "path">[],
18
- ) => {
19
- return [...overrides].sort((left, right) => {
20
- return formatSyncOverrideSelector(left).localeCompare(
21
- formatSyncOverrideSelector(right),
22
- );
23
- });
24
- };
25
-
26
- export const createSyncConfigDocumentEntry = (
27
- entry: Pick<
28
- ResolvedSyncConfigEntry,
29
- "configuredLocalPath" | "kind" | "name" | "mode" | "overrides" | "repoPath"
30
- >,
31
- ): SyncConfigDocumentEntry => {
32
- return {
33
- kind: entry.kind,
34
- localPath: entry.configuredLocalPath,
35
- mode: entry.mode,
36
- name: entry.name,
37
- ...(entry.overrides.length === 0
38
- ? {}
39
- : {
40
- overrides: Object.fromEntries(
41
- sortSyncOverrides(entry.overrides).map((override) => {
42
- return [formatSyncOverrideSelector(override), override.mode];
43
- }),
44
- ),
45
- }),
46
- repoPath: entry.repoPath,
47
- };
48
- };
49
-
50
- export const createSyncConfigDocument = (
51
- config: ResolvedSyncConfig,
52
- ): SyncConfig => {
53
- return {
54
- version: 1,
55
- age: {
56
- identityFile: config.age.configuredIdentityFile,
57
- recipients: [...config.age.recipients],
58
- },
59
- entries: config.entries.map((entry) => {
60
- return createSyncConfigDocumentEntry(entry);
61
- }),
62
- };
63
- };
64
-
65
- export const sortSyncConfigEntries = (
66
- entries: readonly SyncConfigDocumentEntry[],
67
- ) => {
68
- return [...entries].sort((left, right) => {
69
- return left.repoPath.localeCompare(right.repoPath);
70
- });
71
- };
72
-
73
- export const countConfiguredRules = (config: ResolvedSyncConfig) => {
74
- return config.entries.reduce((total, entry) => {
75
- return total + entry.overrides.length;
76
- }, 0);
77
- };
78
-
79
- export const writeValidatedSyncConfig = async (
80
- syncDirectory: string,
81
- config: SyncConfig,
82
- dependencies: Readonly<{
83
- environment: NodeJS.ProcessEnv;
84
- }>,
85
- ) => {
86
- const resolvedConfig = parseSyncConfig(
87
- {
88
- ...config,
89
- entries: sortSyncConfigEntries(config.entries),
90
- },
91
- dependencies.environment,
92
- );
93
- const nextConfig = createSyncConfigDocument(resolvedConfig);
94
-
95
- await writeTextFileAtomically(
96
- resolveSyncConfigFilePath(syncDirectory),
97
- formatSyncConfig(nextConfig),
98
- );
99
-
100
- return nextConfig;
101
- };
@@ -1,132 +0,0 @@
1
- import { 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 {
7
- createAgeIdentityFile,
8
- decryptSecretFile,
9
- encryptSecretFile,
10
- readAgeIdentityLines,
11
- readAgeRecipientsFromIdentityFile,
12
- } from "#app/services/crypto.ts";
13
- import {
14
- createAgeKeyPair,
15
- createTemporaryDirectory,
16
- } from "../test/helpers/sync-fixture.ts";
17
-
18
- const temporaryDirectories: string[] = [];
19
-
20
- const createWorkspace = async () => {
21
- const directory = await createTemporaryDirectory("devsync-sync-crypto-");
22
-
23
- temporaryDirectories.push(directory);
24
-
25
- return directory;
26
- };
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("sync crypto helpers", () => {
39
- it("reads identities while ignoring blank lines and comments", async () => {
40
- const workspace = await createWorkspace();
41
- const keyPair = await createAgeKeyPair();
42
- const identityFile = join(workspace, "keys.txt");
43
-
44
- await writeFile(
45
- identityFile,
46
- `\n# first\n${keyPair.identity}\n\n# second\n${keyPair.identity}\n`,
47
- "utf8",
48
- );
49
-
50
- expect(await readAgeIdentityLines(identityFile)).toEqual([
51
- keyPair.identity,
52
- keyPair.identity,
53
- ]);
54
- });
55
-
56
- it("fails when no usable identities are present", async () => {
57
- const workspace = await createWorkspace();
58
- const identityFile = join(workspace, "keys.txt");
59
-
60
- await writeFile(identityFile, "\n# comment only\n\n", "utf8");
61
-
62
- await expect(readAgeIdentityLines(identityFile)).rejects.toThrowError(
63
- /No age identities found/u,
64
- );
65
- });
66
-
67
- it("deduplicates recipients derived from repeated identities", async () => {
68
- const workspace = await createWorkspace();
69
- const keyPair = await createAgeKeyPair();
70
- const identityFile = join(workspace, "keys.txt");
71
-
72
- await writeFile(
73
- identityFile,
74
- `${keyPair.identity}\n${keyPair.identity}\n`,
75
- "utf8",
76
- );
77
-
78
- expect(await readAgeRecipientsFromIdentityFile(identityFile)).toEqual([
79
- keyPair.recipient,
80
- ]);
81
- });
82
-
83
- it("creates a new identity file with a trailing newline", async () => {
84
- const workspace = await createWorkspace();
85
- const identityFile = join(workspace, "nested", "keys.txt");
86
-
87
- const result = await createAgeIdentityFile(identityFile);
88
- const contents = await readFile(identityFile, "utf8");
89
-
90
- expect(contents.endsWith("\n")).toBe(true);
91
- expect(await readAgeRecipientsFromIdentityFile(identityFile)).toEqual([
92
- result.recipient,
93
- ]);
94
- });
95
-
96
- it("round-trips secret payloads through age encryption", async () => {
97
- const workspace = await createWorkspace();
98
- const keyPair = await createAgeKeyPair();
99
- const identityFile = join(workspace, "keys.txt");
100
- const payload = new TextEncoder().encode("super secret payload");
101
-
102
- await writeFile(identityFile, `${keyPair.identity}\n`, "utf8");
103
-
104
- const ciphertext = await encryptSecretFile(payload, [keyPair.recipient]);
105
- const plaintext = await decryptSecretFile(ciphertext, identityFile);
106
-
107
- expect(new TextDecoder().decode(plaintext)).toBe("super secret payload");
108
- });
109
-
110
- it("fails to decrypt with the wrong identity or malformed ciphertext", async () => {
111
- const workspace = await createWorkspace();
112
- const sender = await createAgeKeyPair();
113
- const wrongIdentity = await createAgeKeyPair();
114
- const senderIdentityFile = join(workspace, "sender.txt");
115
- const wrongIdentityFile = join(workspace, "wrong.txt");
116
-
117
- await writeFile(senderIdentityFile, `${sender.identity}\n`, "utf8");
118
- await writeFile(wrongIdentityFile, `${wrongIdentity.identity}\n`, "utf8");
119
-
120
- const ciphertext = await encryptSecretFile(
121
- new TextEncoder().encode("secret"),
122
- [sender.recipient],
123
- );
124
-
125
- await expect(
126
- decryptSecretFile(ciphertext, wrongIdentityFile),
127
- ).rejects.toThrowError();
128
- await expect(
129
- decryptSecretFile("not a valid age payload", senderIdentityFile),
130
- ).rejects.toThrowError();
131
- });
132
- });
@@ -1,83 +0,0 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
- import { dirname } from "node:path";
3
-
4
- import {
5
- armor,
6
- Decrypter,
7
- Encrypter,
8
- generateIdentity,
9
- identityToRecipient,
10
- } from "age-encryption";
11
-
12
- import { ensureTrailingNewline } from "#app/lib/string.ts";
13
-
14
- export const readAgeIdentityLines = async (identityFile: string) => {
15
- const contents = await readFile(identityFile, "utf8");
16
- const identities = contents
17
- .split(/\r?\n/u)
18
- .map((line) => line.trim())
19
- .filter((line) => {
20
- return line !== "" && !line.startsWith("#");
21
- });
22
-
23
- if (identities.length === 0) {
24
- throw new Error(`No age identities found in ${identityFile}`);
25
- }
26
-
27
- return identities;
28
- };
29
-
30
- export const readAgeRecipientsFromIdentityFile = async (
31
- identityFile: string,
32
- ) => {
33
- const identities = await readAgeIdentityLines(identityFile);
34
- const recipients = await Promise.all(
35
- identities.map(async (identity) => {
36
- return await identityToRecipient(identity);
37
- }),
38
- );
39
-
40
- return [...new Set(recipients)];
41
- };
42
-
43
- export const createAgeIdentityFile = async (identityFile: string) => {
44
- const identity = await generateIdentity();
45
- const recipient = await identityToRecipient(identity);
46
-
47
- await mkdir(dirname(identityFile), { recursive: true });
48
- await writeFile(identityFile, ensureTrailingNewline(identity), "utf8");
49
-
50
- return {
51
- identity,
52
- recipient,
53
- };
54
- };
55
-
56
- export const encryptSecretFile = async (
57
- contents: Uint8Array,
58
- recipients: readonly string[],
59
- ) => {
60
- const encrypter = new Encrypter();
61
-
62
- for (const recipient of recipients) {
63
- encrypter.addRecipient(recipient);
64
- }
65
-
66
- const ciphertext = await encrypter.encrypt(contents);
67
-
68
- return armor.encode(ciphertext);
69
- };
70
-
71
- export const decryptSecretFile = async (
72
- armoredCiphertext: string,
73
- identityFile: string,
74
- ) => {
75
- const decrypter = new Decrypter();
76
- const identities = await readAgeIdentityLines(identityFile);
77
-
78
- for (const identity of identities) {
79
- decrypter.addIdentity(identity);
80
- }
81
-
82
- return await decrypter.decrypt(armor.decode(armoredCiphertext));
83
- };