@tinyrack/devsync 1.1.0 → 1.2.2

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 (216) 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/sync.d.ts +96 -0
  55. package/dist/config/sync.d.ts.map +1 -0
  56. package/dist/config/sync.js +412 -0
  57. package/dist/config/sync.js.map +1 -0
  58. package/dist/config/xdg.d.ts +11 -0
  59. package/dist/config/xdg.d.ts.map +1 -0
  60. package/dist/config/xdg.js +79 -0
  61. package/dist/config/xdg.js.map +1 -0
  62. package/dist/index.d.ts +3 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/{src/index.ts → dist/index.js} +1 -1
  65. package/dist/index.js.map +1 -0
  66. package/dist/lib/file-mode.d.ts +3 -0
  67. package/dist/lib/file-mode.d.ts.map +1 -0
  68. package/dist/lib/file-mode.js +7 -0
  69. package/dist/lib/file-mode.js.map +1 -0
  70. package/dist/lib/output.d.ts +31 -0
  71. package/dist/lib/output.d.ts.map +1 -0
  72. package/dist/lib/output.js +198 -0
  73. package/dist/lib/output.js.map +1 -0
  74. package/dist/lib/path.d.ts +5 -0
  75. package/dist/lib/path.d.ts.map +1 -0
  76. package/dist/lib/path.js +25 -0
  77. package/dist/lib/path.js.map +1 -0
  78. package/dist/lib/string.d.ts +2 -0
  79. package/dist/lib/string.d.ts.map +1 -0
  80. package/dist/lib/string.js +4 -0
  81. package/dist/lib/string.js.map +1 -0
  82. package/dist/lib/validation.d.ts +3 -0
  83. package/dist/lib/validation.d.ts.map +1 -0
  84. package/dist/lib/validation.js +9 -0
  85. package/dist/lib/validation.js.map +1 -0
  86. package/dist/services/add.d.ts +20 -0
  87. package/dist/services/add.d.ts.map +1 -0
  88. package/dist/services/add.js +161 -0
  89. package/dist/services/add.js.map +1 -0
  90. package/dist/services/config-file.d.ts +30 -0
  91. package/dist/services/config-file.d.ts.map +1 -0
  92. package/dist/services/config-file.js +34 -0
  93. package/dist/services/config-file.js.map +1 -0
  94. package/dist/services/crypto.d.ts +9 -0
  95. package/dist/services/crypto.d.ts.map +1 -0
  96. package/dist/services/crypto.js +75 -0
  97. package/dist/services/crypto.js.map +1 -0
  98. package/dist/services/doctor.d.ts +16 -0
  99. package/dist/services/doctor.d.ts.map +1 -0
  100. package/dist/services/doctor.js +84 -0
  101. package/dist/services/doctor.js.map +1 -0
  102. package/dist/services/error.d.ts +14 -0
  103. package/dist/services/error.d.ts.map +1 -0
  104. package/dist/services/error.js +38 -0
  105. package/dist/services/error.js.map +1 -0
  106. package/dist/services/filesystem.d.ts +15 -0
  107. package/dist/services/filesystem.d.ts.map +1 -0
  108. package/dist/services/filesystem.js +113 -0
  109. package/dist/services/filesystem.js.map +1 -0
  110. package/dist/services/forget.d.ts +14 -0
  111. package/dist/services/forget.d.ts.map +1 -0
  112. package/dist/services/forget.js +124 -0
  113. package/dist/services/forget.js.map +1 -0
  114. package/dist/services/git.d.ts +10 -0
  115. package/dist/services/git.d.ts.map +1 -0
  116. package/dist/services/git.js +57 -0
  117. package/dist/services/git.js.map +1 -0
  118. package/dist/services/init.d.ts +19 -0
  119. package/dist/services/init.d.ts.map +1 -0
  120. package/dist/services/init.js +203 -0
  121. package/dist/services/init.js.map +1 -0
  122. package/dist/services/local-materialization.d.ts +28 -0
  123. package/dist/services/local-materialization.d.ts.map +1 -0
  124. package/dist/services/local-materialization.js +262 -0
  125. package/dist/services/local-materialization.js.map +1 -0
  126. package/dist/services/local-snapshot.d.ts +25 -0
  127. package/dist/services/local-snapshot.d.ts.map +1 -0
  128. package/dist/services/local-snapshot.js +93 -0
  129. package/dist/services/local-snapshot.js.map +1 -0
  130. package/dist/services/machine.d.ts +40 -0
  131. package/dist/services/machine.d.ts.map +1 -0
  132. package/dist/services/machine.js +113 -0
  133. package/dist/services/machine.js.map +1 -0
  134. package/dist/services/paths.d.ts +12 -0
  135. package/dist/services/paths.d.ts.map +1 -0
  136. package/dist/services/paths.js +71 -0
  137. package/dist/services/paths.js.map +1 -0
  138. package/dist/services/pull.d.ts +28 -0
  139. package/dist/services/pull.d.ts.map +1 -0
  140. package/dist/services/pull.js +67 -0
  141. package/dist/services/pull.js.map +1 -0
  142. package/dist/services/push.d.ts +35 -0
  143. package/dist/services/push.d.ts.map +1 -0
  144. package/dist/services/push.js +96 -0
  145. package/dist/services/push.js.map +1 -0
  146. package/dist/services/repo-artifacts.d.ts +52 -0
  147. package/dist/services/repo-artifacts.d.ts.map +1 -0
  148. package/dist/services/repo-artifacts.js +251 -0
  149. package/dist/services/repo-artifacts.js.map +1 -0
  150. package/dist/services/repo-snapshot.d.ts +6 -0
  151. package/dist/services/repo-snapshot.d.ts.map +1 -0
  152. package/dist/services/repo-snapshot.js +163 -0
  153. package/dist/services/repo-snapshot.js.map +1 -0
  154. package/dist/services/runtime.d.ts +40 -0
  155. package/dist/services/runtime.d.ts.map +1 -0
  156. package/dist/services/runtime.js +71 -0
  157. package/dist/services/runtime.js.map +1 -0
  158. package/dist/services/set.d.ts +38 -0
  159. package/dist/services/set.d.ts.map +1 -0
  160. package/dist/services/set.js +184 -0
  161. package/dist/services/set.js.map +1 -0
  162. package/dist/services/status.d.ts +30 -0
  163. package/dist/services/status.d.ts.map +1 -0
  164. package/dist/services/status.js +35 -0
  165. package/dist/services/status.js.map +1 -0
  166. package/package.json +15 -7
  167. package/src/cli/commands/add.ts +0 -40
  168. package/src/cli/commands/cd.ts +0 -80
  169. package/src/cli/commands/doctor.ts +0 -20
  170. package/src/cli/commands/forget.ts +0 -32
  171. package/src/cli/commands/index.ts +0 -23
  172. package/src/cli/commands/init.ts +0 -43
  173. package/src/cli/commands/list.ts +0 -17
  174. package/src/cli/commands/pull.ts +0 -31
  175. package/src/cli/commands/push.ts +0 -31
  176. package/src/cli/commands/set.ts +0 -47
  177. package/src/cli/commands/status.ts +0 -18
  178. package/src/cli/sync-output.test.ts +0 -173
  179. package/src/cli/sync-output.ts +0 -200
  180. package/src/config/sync.test.ts +0 -609
  181. package/src/config/sync.ts +0 -572
  182. package/src/config/xdg.ts +0 -138
  183. package/src/lib/string.test.ts +0 -13
  184. package/src/lib/string.ts +0 -3
  185. package/src/lib/validation.test.ts +0 -32
  186. package/src/lib/validation.ts +0 -11
  187. package/src/services/add.ts +0 -178
  188. package/src/services/config-file.test.ts +0 -161
  189. package/src/services/config-file.ts +0 -101
  190. package/src/services/crypto.test.ts +0 -132
  191. package/src/services/crypto.ts +0 -83
  192. package/src/services/doctor.ts +0 -142
  193. package/src/services/error.ts +0 -6
  194. package/src/services/filesystem.test.ts +0 -171
  195. package/src/services/filesystem.ts +0 -183
  196. package/src/services/forget.ts +0 -261
  197. package/src/services/git.test.ts +0 -83
  198. package/src/services/git.ts +0 -74
  199. package/src/services/init.test.ts +0 -109
  200. package/src/services/init.ts +0 -244
  201. package/src/services/list.ts +0 -63
  202. package/src/services/local-materialization.ts +0 -421
  203. package/src/services/local-snapshot.ts +0 -173
  204. package/src/services/paths.test.ts +0 -74
  205. package/src/services/paths.ts +0 -98
  206. package/src/services/pull.ts +0 -144
  207. package/src/services/push.ts +0 -168
  208. package/src/services/repo-artifacts.ts +0 -262
  209. package/src/services/repo-snapshot.ts +0 -197
  210. package/src/services/runtime.ts +0 -57
  211. package/src/services/set.ts +0 -383
  212. package/src/services/status.ts +0 -57
  213. package/src/services/sync.dry-run.test.ts +0 -179
  214. package/src/services/sync.runtime.test.ts +0 -756
  215. package/src/services/sync.service.test.ts +0 -1169
  216. 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
- };