@tinyrack/devsync 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,75 @@
1
+ import { readSyncConfig } from "#app/config/sync.ts";
2
+
3
+ import {
4
+ applyEntryMaterialization,
5
+ buildEntryMaterialization,
6
+ buildPullCounts,
7
+ countDeletedLocalNodes,
8
+ } from "./local-materialization.ts";
9
+ import { buildRepositorySnapshot } from "./repo-snapshot.ts";
10
+ import { ensureSyncRepository, type SyncContext } from "./runtime.ts";
11
+
12
+ export type SyncPullRequest = Readonly<{
13
+ dryRun: boolean;
14
+ }>;
15
+
16
+ export type SyncPullResult = Readonly<{
17
+ configPath: string;
18
+ decryptedFileCount: number;
19
+ deletedLocalCount: number;
20
+ directoryCount: number;
21
+ dryRun: boolean;
22
+ plainFileCount: number;
23
+ symlinkCount: number;
24
+ syncDirectory: string;
25
+ }>;
26
+
27
+ export const pullSync = async (
28
+ request: SyncPullRequest,
29
+ context: SyncContext,
30
+ ): Promise<SyncPullResult> => {
31
+ await ensureSyncRepository(context);
32
+
33
+ const config = await readSyncConfig(
34
+ context.paths.syncDirectory,
35
+ context.environment,
36
+ );
37
+ const snapshot = await buildRepositorySnapshot(
38
+ context.paths.syncDirectory,
39
+ config,
40
+ );
41
+ const materializations = config.entries.map((entry) => {
42
+ return buildEntryMaterialization(entry, snapshot);
43
+ });
44
+
45
+ let deletedLocalCount = 0;
46
+
47
+ for (let index = 0; index < config.entries.length; index += 1) {
48
+ const entry = config.entries[index];
49
+ const materialization = materializations[index];
50
+
51
+ if (entry === undefined || materialization === undefined) {
52
+ continue;
53
+ }
54
+
55
+ deletedLocalCount += await countDeletedLocalNodes(
56
+ entry,
57
+ materialization.desiredKeys,
58
+ config,
59
+ );
60
+
61
+ if (!request.dryRun) {
62
+ await applyEntryMaterialization(entry, materialization, config);
63
+ }
64
+ }
65
+
66
+ const counts = buildPullCounts(materializations);
67
+
68
+ return {
69
+ configPath: context.paths.configPath,
70
+ deletedLocalCount,
71
+ dryRun: request.dryRun,
72
+ syncDirectory: context.paths.syncDirectory,
73
+ ...counts,
74
+ };
75
+ };
@@ -0,0 +1,121 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import {
5
+ readSyncConfig,
6
+ resolveSyncArtifactsDirectoryPath,
7
+ } from "#app/config/sync.ts";
8
+
9
+ import { replacePathAtomically } from "./filesystem.ts";
10
+ import { buildLocalSnapshot, type SnapshotNode } from "./local-snapshot.ts";
11
+ import {
12
+ buildArtifactKey,
13
+ buildRepoArtifacts,
14
+ collectExistingArtifactKeys,
15
+ writeArtifactsToDirectory,
16
+ } from "./repo-artifacts.ts";
17
+ import { ensureSyncRepository, type SyncContext } from "./runtime.ts";
18
+
19
+ export type SyncPushRequest = Readonly<{
20
+ dryRun: boolean;
21
+ }>;
22
+
23
+ export type SyncPushResult = Readonly<{
24
+ configPath: string;
25
+ deletedArtifactCount: number;
26
+ directoryCount: number;
27
+ dryRun: boolean;
28
+ encryptedFileCount: number;
29
+ plainFileCount: number;
30
+ symlinkCount: number;
31
+ syncDirectory: string;
32
+ }>;
33
+
34
+ const buildPushCounts = (snapshot: ReadonlyMap<string, SnapshotNode>) => {
35
+ let directoryCount = 0;
36
+ let encryptedFileCount = 0;
37
+ let plainFileCount = 0;
38
+ let symlinkCount = 0;
39
+
40
+ for (const node of snapshot.values()) {
41
+ if (node.type === "directory") {
42
+ directoryCount += 1;
43
+ continue;
44
+ }
45
+
46
+ if (node.type === "symlink") {
47
+ symlinkCount += 1;
48
+ continue;
49
+ }
50
+
51
+ if (node.secret) {
52
+ encryptedFileCount += 1;
53
+ } else {
54
+ plainFileCount += 1;
55
+ }
56
+ }
57
+
58
+ return {
59
+ directoryCount,
60
+ encryptedFileCount,
61
+ plainFileCount,
62
+ symlinkCount,
63
+ };
64
+ };
65
+
66
+ export const pushSync = async (
67
+ request: SyncPushRequest,
68
+ context: SyncContext,
69
+ ): Promise<SyncPushResult> => {
70
+ await ensureSyncRepository(context);
71
+
72
+ const config = await readSyncConfig(
73
+ context.paths.syncDirectory,
74
+ context.environment,
75
+ );
76
+ const snapshot = await buildLocalSnapshot(config);
77
+ const artifacts = await buildRepoArtifacts(snapshot, config);
78
+ const desiredArtifactKeys = new Set(
79
+ artifacts.map((artifact) => {
80
+ return buildArtifactKey(artifact);
81
+ }),
82
+ );
83
+ const existingArtifactKeys = await collectExistingArtifactKeys(
84
+ context.paths.syncDirectory,
85
+ config,
86
+ );
87
+ const deletedArtifactCount = [...existingArtifactKeys].filter((key) => {
88
+ return !desiredArtifactKeys.has(key);
89
+ }).length;
90
+
91
+ if (!request.dryRun) {
92
+ const stagingRoot = await mkdtemp(
93
+ join(context.paths.syncDirectory, ".devsync-sync-push-"),
94
+ );
95
+ const nextArtifactsDirectory = join(stagingRoot, "files");
96
+
97
+ try {
98
+ await writeArtifactsToDirectory(nextArtifactsDirectory, artifacts);
99
+
100
+ await replacePathAtomically(
101
+ resolveSyncArtifactsDirectoryPath(context.paths.syncDirectory),
102
+ nextArtifactsDirectory,
103
+ );
104
+ } finally {
105
+ await rm(stagingRoot, {
106
+ force: true,
107
+ recursive: true,
108
+ });
109
+ }
110
+ }
111
+
112
+ const counts = buildPushCounts(snapshot);
113
+
114
+ return {
115
+ configPath: context.paths.configPath,
116
+ deletedArtifactCount,
117
+ dryRun: request.dryRun,
118
+ syncDirectory: context.paths.syncDirectory,
119
+ ...counts,
120
+ };
121
+ };
@@ -0,0 +1,262 @@
1
+ import { lstat, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import {
5
+ hasReservedSyncArtifactSuffixSegment,
6
+ type ResolvedSyncConfig,
7
+ resolveSyncArtifactsDirectoryPath,
8
+ syncSecretArtifactSuffix,
9
+ } from "#app/config/sync.ts";
10
+
11
+ import { encryptSecretFile } from "./crypto.ts";
12
+ import { DevsyncError } from "./error.ts";
13
+ import {
14
+ getPathStats,
15
+ listDirectoryEntries,
16
+ pathExists,
17
+ writeFileNode,
18
+ writeSymlinkNode,
19
+ } from "./filesystem.ts";
20
+ import type { SnapshotNode } from "./local-snapshot.ts";
21
+ import { buildDirectoryKey } from "./paths.ts";
22
+
23
+ export type RepoArtifact =
24
+ | Readonly<{
25
+ category: "plain";
26
+ kind: "directory";
27
+ repoPath: string;
28
+ }>
29
+ | Readonly<{
30
+ category: "plain";
31
+ kind: "file";
32
+ repoPath: string;
33
+ contents: Uint8Array;
34
+ executable: boolean;
35
+ }>
36
+ | Readonly<{
37
+ category: "plain";
38
+ kind: "symlink";
39
+ repoPath: string;
40
+ linkTarget: string;
41
+ }>
42
+ | Readonly<{
43
+ category: "secret";
44
+ kind: "file";
45
+ repoPath: string;
46
+ contents: string;
47
+ executable: boolean;
48
+ }>;
49
+
50
+ export const buildArtifactKey = (artifact: RepoArtifact) => {
51
+ const relativePath = resolveArtifactRelativePath(artifact);
52
+
53
+ return artifact.kind === "directory"
54
+ ? buildDirectoryKey(relativePath)
55
+ : relativePath;
56
+ };
57
+
58
+ export const isSecretArtifactPath = (relativePath: string) => {
59
+ return relativePath.endsWith(syncSecretArtifactSuffix);
60
+ };
61
+
62
+ export const stripSecretArtifactSuffix = (relativePath: string) => {
63
+ if (!isSecretArtifactPath(relativePath)) {
64
+ return undefined;
65
+ }
66
+
67
+ return relativePath.slice(0, -syncSecretArtifactSuffix.length);
68
+ };
69
+
70
+ export const assertStorageSafeRepoPath = (repoPath: string) => {
71
+ if (!hasReservedSyncArtifactSuffixSegment(repoPath)) {
72
+ return;
73
+ }
74
+
75
+ throw new DevsyncError(
76
+ `Tracked sync paths must not use the reserved suffix ${syncSecretArtifactSuffix}: ${repoPath}`,
77
+ );
78
+ };
79
+
80
+ export const resolveArtifactRelativePath = (
81
+ artifact: Pick<RepoArtifact, "category" | "repoPath">,
82
+ ) => {
83
+ return artifact.category === "secret"
84
+ ? `${artifact.repoPath}${syncSecretArtifactSuffix}`
85
+ : artifact.repoPath;
86
+ };
87
+
88
+ export const buildRepoArtifacts = async (
89
+ snapshot: ReadonlyMap<string, SnapshotNode>,
90
+ config: ResolvedSyncConfig,
91
+ ) => {
92
+ const artifacts: RepoArtifact[] = [];
93
+ const seenArtifactKeys = new Set<string>();
94
+
95
+ for (const repoPath of [...snapshot.keys()].sort((left, right) => {
96
+ return left.localeCompare(right);
97
+ })) {
98
+ assertStorageSafeRepoPath(repoPath);
99
+ const node = snapshot.get(repoPath);
100
+
101
+ if (node === undefined) {
102
+ continue;
103
+ }
104
+
105
+ if (node.type === "directory") {
106
+ const artifact = {
107
+ category: "plain",
108
+ kind: "directory",
109
+ repoPath,
110
+ } satisfies RepoArtifact;
111
+ const key = buildArtifactKey(artifact);
112
+
113
+ if (seenArtifactKeys.has(key)) {
114
+ throw new DevsyncError(
115
+ `Duplicate repository artifact generated for ${key}`,
116
+ );
117
+ }
118
+
119
+ seenArtifactKeys.add(key);
120
+ artifacts.push(artifact);
121
+ continue;
122
+ }
123
+
124
+ if (node.type === "symlink") {
125
+ const artifact = {
126
+ category: "plain",
127
+ kind: "symlink",
128
+ linkTarget: node.linkTarget,
129
+ repoPath,
130
+ } satisfies RepoArtifact;
131
+ const key = buildArtifactKey(artifact);
132
+
133
+ if (seenArtifactKeys.has(key)) {
134
+ throw new DevsyncError(
135
+ `Duplicate repository artifact generated for ${key}`,
136
+ );
137
+ }
138
+
139
+ seenArtifactKeys.add(key);
140
+ artifacts.push(artifact);
141
+ continue;
142
+ }
143
+
144
+ if (!node.secret) {
145
+ const artifact = {
146
+ category: "plain",
147
+ contents: node.contents,
148
+ executable: node.executable,
149
+ kind: "file",
150
+ repoPath,
151
+ } satisfies RepoArtifact;
152
+ const key = buildArtifactKey(artifact);
153
+
154
+ if (seenArtifactKeys.has(key)) {
155
+ throw new DevsyncError(
156
+ `Duplicate repository artifact generated for ${key}`,
157
+ );
158
+ }
159
+
160
+ seenArtifactKeys.add(key);
161
+ artifacts.push(artifact);
162
+ continue;
163
+ }
164
+
165
+ const artifact = {
166
+ category: "secret",
167
+ contents: await encryptSecretFile(node.contents, config.age.recipients),
168
+ executable: node.executable,
169
+ kind: "file",
170
+ repoPath,
171
+ } satisfies RepoArtifact;
172
+ const key = buildArtifactKey(artifact);
173
+
174
+ if (seenArtifactKeys.has(key)) {
175
+ throw new DevsyncError(
176
+ `Duplicate repository artifact generated for ${key}`,
177
+ );
178
+ }
179
+
180
+ seenArtifactKeys.add(key);
181
+ artifacts.push(artifact);
182
+ }
183
+
184
+ return artifacts;
185
+ };
186
+
187
+ const collectArtifactLeafKeys = async (
188
+ rootDirectory: string,
189
+ keys: Set<string>,
190
+ prefix?: string,
191
+ ) => {
192
+ if (!(await pathExists(rootDirectory))) {
193
+ return;
194
+ }
195
+
196
+ const entries = await listDirectoryEntries(rootDirectory);
197
+
198
+ for (const entry of entries) {
199
+ const absolutePath = join(rootDirectory, entry.name);
200
+ const relativePath =
201
+ prefix === undefined ? entry.name : `${prefix}/${entry.name}`;
202
+ const stats = await lstat(absolutePath);
203
+
204
+ if (stats?.isDirectory()) {
205
+ await collectArtifactLeafKeys(absolutePath, keys, relativePath);
206
+ continue;
207
+ }
208
+
209
+ keys.add(relativePath);
210
+ }
211
+ };
212
+
213
+ export const collectExistingArtifactKeys = async (
214
+ syncDirectory: string,
215
+ config: ResolvedSyncConfig,
216
+ ) => {
217
+ const keys = new Set<string>();
218
+ const artifactsDirectory = resolveSyncArtifactsDirectoryPath(syncDirectory);
219
+
220
+ await collectArtifactLeafKeys(artifactsDirectory, keys);
221
+
222
+ for (const entry of config.entries) {
223
+ if (entry.kind !== "directory") {
224
+ continue;
225
+ }
226
+
227
+ const path = join(artifactsDirectory, ...entry.repoPath.split("/"));
228
+ const stats = await getPathStats(path);
229
+
230
+ if (stats?.isDirectory()) {
231
+ keys.add(buildDirectoryKey(entry.repoPath));
232
+ }
233
+ }
234
+
235
+ return keys;
236
+ };
237
+
238
+ export const writeArtifactsToDirectory = async (
239
+ rootDirectory: string,
240
+ artifacts: readonly RepoArtifact[],
241
+ ) => {
242
+ await mkdir(rootDirectory, { recursive: true });
243
+
244
+ for (const artifact of artifacts) {
245
+ const artifactPath = join(
246
+ rootDirectory,
247
+ ...resolveArtifactRelativePath(artifact).split("/"),
248
+ );
249
+
250
+ if (artifact.kind === "directory") {
251
+ await mkdir(artifactPath, { recursive: true });
252
+ continue;
253
+ }
254
+
255
+ if (artifact.kind === "symlink") {
256
+ await writeSymlinkNode(artifactPath, artifact.linkTarget);
257
+ continue;
258
+ }
259
+
260
+ await writeFileNode(artifactPath, artifact);
261
+ }
262
+ };
@@ -0,0 +1,197 @@
1
+ import { lstat, readFile, readlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import {
5
+ findOwningSyncEntry,
6
+ type ResolvedSyncConfig,
7
+ resolveManagedSyncMode,
8
+ resolveSyncArtifactsDirectoryPath,
9
+ } from "#app/config/sync.ts";
10
+
11
+ import { decryptSecretFile } from "./crypto.ts";
12
+ import { DevsyncError } from "./error.ts";
13
+ import {
14
+ getPathStats,
15
+ isExecutableMode,
16
+ listDirectoryEntries,
17
+ pathExists,
18
+ } from "./filesystem.ts";
19
+ import { addSnapshotNode, type SnapshotNode } from "./local-snapshot.ts";
20
+ import {
21
+ assertStorageSafeRepoPath,
22
+ isSecretArtifactPath,
23
+ stripSecretArtifactSuffix,
24
+ } from "./repo-artifacts.ts";
25
+
26
+ const readPlainSnapshotNode = async (
27
+ absolutePath: string,
28
+ repoPath: string,
29
+ config: ResolvedSyncConfig,
30
+ snapshot: Map<string, SnapshotNode>,
31
+ ) => {
32
+ assertStorageSafeRepoPath(repoPath);
33
+ const mode = resolveManagedSyncMode(config, repoPath);
34
+
35
+ if (mode === "ignore") {
36
+ return;
37
+ }
38
+
39
+ if (findOwningSyncEntry(config, repoPath) === undefined) {
40
+ throw new DevsyncError(
41
+ `Unmanaged plain sync path found in repository: ${repoPath}`,
42
+ );
43
+ }
44
+
45
+ if (mode === "secret") {
46
+ throw new DevsyncError(
47
+ `Secret sync path is stored in plain text in the repository: ${repoPath}`,
48
+ );
49
+ }
50
+
51
+ const stats = await lstat(absolutePath);
52
+
53
+ if (stats.isSymbolicLink()) {
54
+ addSnapshotNode(snapshot, repoPath, {
55
+ linkTarget: await readlink(absolutePath),
56
+ type: "symlink",
57
+ });
58
+
59
+ return;
60
+ }
61
+
62
+ if (!stats.isFile()) {
63
+ throw new DevsyncError(
64
+ `Unsupported plain repository entry: ${absolutePath}`,
65
+ );
66
+ }
67
+
68
+ addSnapshotNode(snapshot, repoPath, {
69
+ contents: await readFile(absolutePath),
70
+ executable: isExecutableMode(stats.mode),
71
+ secret: false,
72
+ type: "file",
73
+ });
74
+ };
75
+
76
+ const readRepositoryTree = async (
77
+ rootDirectory: string,
78
+ config: ResolvedSyncConfig,
79
+ snapshot: Map<string, SnapshotNode>,
80
+ prefix?: string,
81
+ ) => {
82
+ if (!(await pathExists(rootDirectory))) {
83
+ return;
84
+ }
85
+
86
+ const entries = await listDirectoryEntries(rootDirectory);
87
+
88
+ for (const entry of entries) {
89
+ const absolutePath = join(rootDirectory, entry.name);
90
+ const relativePath =
91
+ prefix === undefined ? entry.name : `${prefix}/${entry.name}`;
92
+ const stats = await lstat(absolutePath);
93
+
94
+ if (stats.isDirectory()) {
95
+ assertStorageSafeRepoPath(relativePath);
96
+ await readRepositoryTree(absolutePath, config, snapshot, relativePath);
97
+ continue;
98
+ }
99
+
100
+ if (stats.isSymbolicLink()) {
101
+ if (isSecretArtifactPath(relativePath)) {
102
+ throw new DevsyncError(
103
+ `Secret repository entries must be regular files, not symlinks: ${relativePath}`,
104
+ );
105
+ }
106
+
107
+ await readPlainSnapshotNode(absolutePath, relativePath, config, snapshot);
108
+ continue;
109
+ }
110
+
111
+ if (isSecretArtifactPath(relativePath)) {
112
+ const repoPath = stripSecretArtifactSuffix(relativePath);
113
+
114
+ if (repoPath === undefined || repoPath.length === 0) {
115
+ throw new DevsyncError(
116
+ `Secret repository files must include a path before ${relativePath}`,
117
+ );
118
+ }
119
+
120
+ assertStorageSafeRepoPath(repoPath);
121
+ const mode = resolveManagedSyncMode(config, repoPath);
122
+
123
+ if (findOwningSyncEntry(config, repoPath) === undefined) {
124
+ throw new DevsyncError(
125
+ `Unmanaged secret sync path found in repository: ${repoPath}`,
126
+ );
127
+ }
128
+
129
+ if (mode === "ignore") {
130
+ continue;
131
+ }
132
+
133
+ if (mode !== "secret") {
134
+ throw new DevsyncError(
135
+ `Plain sync path is stored in secret form in the repository: ${repoPath}`,
136
+ );
137
+ }
138
+
139
+ addSnapshotNode(snapshot, repoPath, {
140
+ contents: await decryptSecretFile(
141
+ await readFile(absolutePath, "utf8"),
142
+ config.age.identityFile,
143
+ ),
144
+ executable: isExecutableMode(stats.mode),
145
+ secret: true,
146
+ type: "file",
147
+ });
148
+ continue;
149
+ }
150
+
151
+ if (!stats.isFile()) {
152
+ throw new DevsyncError(
153
+ `Unsupported plain repository entry: ${absolutePath}`,
154
+ );
155
+ }
156
+
157
+ await readPlainSnapshotNode(absolutePath, relativePath, config, snapshot);
158
+ }
159
+ };
160
+
161
+ export const buildRepositorySnapshot = async (
162
+ syncDirectory: string,
163
+ config: ResolvedSyncConfig,
164
+ ) => {
165
+ const snapshot = new Map<string, SnapshotNode>();
166
+ const artifactsDirectory = resolveSyncArtifactsDirectoryPath(syncDirectory);
167
+
168
+ await readRepositoryTree(artifactsDirectory, config, snapshot);
169
+
170
+ for (const entry of config.entries) {
171
+ if (entry.kind !== "directory") {
172
+ continue;
173
+ }
174
+
175
+ const artifactPath = join(artifactsDirectory, ...entry.repoPath.split("/"));
176
+ const stats = await getPathStats(artifactPath);
177
+
178
+ if (stats !== undefined && !stats.isDirectory()) {
179
+ throw new DevsyncError(
180
+ `Directory sync entry is not stored as a directory in the repository: ${entry.repoPath}`,
181
+ );
182
+ }
183
+
184
+ const mode = resolveManagedSyncMode(config, entry.repoPath);
185
+ const hasTrackedChildren = [...snapshot.keys()].some((repoPath) => {
186
+ return repoPath.startsWith(`${entry.repoPath}/`);
187
+ });
188
+
189
+ if (stats?.isDirectory() && (mode !== "ignore" || hasTrackedChildren)) {
190
+ addSnapshotNode(snapshot, entry.repoPath, {
191
+ type: "directory",
192
+ });
193
+ }
194
+ }
195
+
196
+ return snapshot;
197
+ };
@@ -0,0 +1,57 @@
1
+ import {
2
+ resolveSyncArtifactsDirectoryPath,
3
+ resolveSyncConfigFilePath,
4
+ } from "#app/config/sync.ts";
5
+ import {
6
+ resolveDevsyncSyncDirectory,
7
+ resolveHomeDirectory,
8
+ } from "#app/config/xdg.ts";
9
+
10
+ import { ensureGitRepository } from "./git.ts";
11
+
12
+ export type SyncPaths = Readonly<{
13
+ artifactsDirectory: string;
14
+ configPath: string;
15
+ homeDirectory: string;
16
+ syncDirectory: string;
17
+ }>;
18
+
19
+ export type SyncContext = Readonly<{
20
+ cwd: string;
21
+ environment: NodeJS.ProcessEnv;
22
+ paths: SyncPaths;
23
+ }>;
24
+
25
+ export const createSyncPaths = (
26
+ environment: NodeJS.ProcessEnv = process.env,
27
+ ): SyncPaths => {
28
+ const syncDirectory = resolveDevsyncSyncDirectory(environment);
29
+
30
+ return {
31
+ artifactsDirectory: resolveSyncArtifactsDirectoryPath(syncDirectory),
32
+ configPath: resolveSyncConfigFilePath(syncDirectory),
33
+ homeDirectory: resolveHomeDirectory(environment),
34
+ syncDirectory,
35
+ };
36
+ };
37
+
38
+ export const createSyncContext = (
39
+ options: Readonly<{
40
+ cwd?: string;
41
+ environment?: NodeJS.ProcessEnv;
42
+ }> = {},
43
+ ): SyncContext => {
44
+ const environment = options.environment ?? process.env;
45
+
46
+ return {
47
+ cwd: options.cwd ?? process.cwd(),
48
+ environment,
49
+ paths: createSyncPaths(environment),
50
+ };
51
+ };
52
+
53
+ export const ensureSyncRepository = async (
54
+ context: Pick<SyncContext, "paths">,
55
+ ) => {
56
+ await ensureGitRepository(context.paths.syncDirectory);
57
+ };