@tinyrack/devsync 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import { DevsyncError } from "#app/services/error.ts";
7
+ import { initializeSync } from "#app/services/init.ts";
8
+ import { createSyncContext } from "#app/services/runtime.ts";
9
+ import {
10
+ createAgeKeyPair,
11
+ createTemporaryDirectory,
12
+ runGit,
13
+ writeIdentityFile,
14
+ } from "../test/helpers/sync-fixture.ts";
15
+
16
+ const temporaryDirectories: string[] = [];
17
+
18
+ const createWorkspace = async () => {
19
+ const directory = await createTemporaryDirectory("devsync-init-");
20
+
21
+ temporaryDirectories.push(directory);
22
+
23
+ return directory;
24
+ };
25
+
26
+ const createEnvironment = (
27
+ homeDirectory: string,
28
+ xdgConfigHome: string,
29
+ ): NodeJS.ProcessEnv => {
30
+ return {
31
+ HOME: homeDirectory,
32
+ XDG_CONFIG_HOME: xdgConfigHome,
33
+ };
34
+ };
35
+
36
+ afterEach(async () => {
37
+ while (temporaryDirectories.length > 0) {
38
+ const directory = temporaryDirectories.pop();
39
+
40
+ if (directory !== undefined) {
41
+ await rm(directory, { force: true, recursive: true });
42
+ }
43
+ }
44
+ });
45
+
46
+ describe("init service", () => {
47
+ it("clones a configured repository source during initialization", async () => {
48
+ const workspace = await createWorkspace();
49
+ const homeDirectory = join(workspace, "home");
50
+ const xdgConfigHome = join(workspace, "xdg");
51
+ const sourceRepository = join(workspace, "remote-sync");
52
+ const ageKeys = await createAgeKeyPair();
53
+
54
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
55
+ await runGit(["init", "-b", "main", sourceRepository], workspace);
56
+
57
+ const result = await initializeSync(
58
+ {
59
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
60
+ recipients: [ageKeys.recipient],
61
+ repository: sourceRepository,
62
+ },
63
+ createSyncContext({
64
+ environment: createEnvironment(homeDirectory, xdgConfigHome),
65
+ }),
66
+ );
67
+
68
+ expect(result.gitAction).toBe("cloned");
69
+ expect(result.gitSource).toBe(sourceRepository);
70
+ expect(
71
+ await readFile(join(result.syncDirectory, "config.json"), "utf8"),
72
+ ).toContain("$XDG_CONFIG_HOME/devsync/age/keys.txt");
73
+ });
74
+
75
+ it("rejects non-empty sync directories that are not git repositories", async () => {
76
+ const workspace = await createWorkspace();
77
+ const homeDirectory = join(workspace, "home");
78
+ const xdgConfigHome = join(workspace, "xdg");
79
+ const syncDirectory = join(xdgConfigHome, "devsync", "sync");
80
+ const ageKeys = await createAgeKeyPair();
81
+
82
+ await writeIdentityFile(xdgConfigHome, ageKeys.identity);
83
+ await mkdir(syncDirectory, { recursive: true });
84
+ await writeFile(join(syncDirectory, "placeholder.txt"), "keep\n", "utf8");
85
+
86
+ await expect(
87
+ initializeSync(
88
+ {
89
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
90
+ recipients: [ageKeys.recipient],
91
+ },
92
+ createSyncContext({
93
+ environment: createEnvironment(homeDirectory, xdgConfigHome),
94
+ }),
95
+ ),
96
+ ).rejects.toThrowError(DevsyncError);
97
+ await expect(
98
+ initializeSync(
99
+ {
100
+ identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
101
+ recipients: [ageKeys.recipient],
102
+ },
103
+ createSyncContext({
104
+ environment: createEnvironment(homeDirectory, xdgConfigHome),
105
+ }),
106
+ ),
107
+ ).rejects.toThrowError(/already exists and is not empty/u);
108
+ });
109
+ });
@@ -0,0 +1,63 @@
1
+ import {
2
+ formatSyncOverrideSelector,
3
+ readSyncConfig,
4
+ type SyncMode,
5
+ } from "#app/config/sync.ts";
6
+
7
+ import { countConfiguredRules } from "./config-file.ts";
8
+ import { ensureSyncRepository, type SyncContext } from "./runtime.ts";
9
+
10
+ export type SyncListOverride = Readonly<{
11
+ mode: SyncMode;
12
+ selector: string;
13
+ }>;
14
+
15
+ export type SyncListEntry = Readonly<{
16
+ kind: "directory" | "file";
17
+ localPath: string;
18
+ mode: SyncMode;
19
+ name: string;
20
+ overrides: readonly SyncListOverride[];
21
+ repoPath: string;
22
+ }>;
23
+
24
+ export type SyncListResult = Readonly<{
25
+ configPath: string;
26
+ entries: readonly SyncListEntry[];
27
+ recipientCount: number;
28
+ ruleCount: number;
29
+ syncDirectory: string;
30
+ }>;
31
+
32
+ export const listSyncConfig = async (
33
+ context: SyncContext,
34
+ ): Promise<SyncListResult> => {
35
+ await ensureSyncRepository(context);
36
+
37
+ const config = await readSyncConfig(
38
+ context.paths.syncDirectory,
39
+ context.environment,
40
+ );
41
+
42
+ return {
43
+ configPath: context.paths.configPath,
44
+ entries: config.entries.map((entry) => {
45
+ return {
46
+ kind: entry.kind,
47
+ localPath: entry.localPath,
48
+ mode: entry.mode,
49
+ name: entry.name,
50
+ overrides: entry.overrides.map((override) => {
51
+ return {
52
+ mode: override.mode,
53
+ selector: formatSyncOverrideSelector(override),
54
+ };
55
+ }),
56
+ repoPath: entry.repoPath,
57
+ };
58
+ }),
59
+ recipientCount: config.age.recipients.length,
60
+ ruleCount: countConfiguredRules(config),
61
+ syncDirectory: context.paths.syncDirectory,
62
+ };
63
+ };
@@ -316,8 +316,8 @@ export const countDeletedLocalNodes = async (
316
316
  entry: ResolvedSyncConfigEntry,
317
317
  desiredKeys: ReadonlySet<string>,
318
318
  config: ResolvedSyncConfig,
319
+ existingKeys: Set<string> = new Set<string>(),
319
320
  ) => {
320
- const existingKeys = new Set<string>();
321
321
  const preservedIgnoredKeys = new Set<string>();
322
322
 
323
323
  await collectLocalLeafKeys(entry.localPath, entry.repoPath, existingKeys);
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { DevsyncError } from "#app/services/error.ts";
4
+ import {
5
+ buildConfiguredHomeLocalPath,
6
+ buildDirectoryKey,
7
+ buildRepoPathWithinRoot,
8
+ doPathsOverlap,
9
+ isExplicitLocalPath,
10
+ isPathEqualOrNested,
11
+ resolveCommandTargetPath,
12
+ tryBuildRepoPathWithinRoot,
13
+ tryNormalizeRepoPathInput,
14
+ } from "#app/services/paths.ts";
15
+
16
+ describe("path helpers", () => {
17
+ it("builds repository directory keys", () => {
18
+ expect(buildDirectoryKey("bundle/cache")).toBe("bundle/cache/");
19
+ });
20
+
21
+ it("detects nested and overlapping paths", () => {
22
+ expect(isPathEqualOrNested("/tmp/home/project/file.txt", "/tmp/home")).toBe(
23
+ true,
24
+ );
25
+ expect(isPathEqualOrNested("/tmp/elsewhere", "/tmp/home")).toBe(false);
26
+ expect(doPathsOverlap("/tmp/home/project", "/tmp/home")).toBe(true);
27
+ expect(doPathsOverlap("/tmp/home/one", "/tmp/home/two")).toBe(false);
28
+ });
29
+
30
+ it("recognizes explicit local path inputs", () => {
31
+ expect(isExplicitLocalPath(".")).toBe(true);
32
+ expect(isExplicitLocalPath("~/bundle")).toBe(true);
33
+ expect(isExplicitLocalPath("../bundle")).toBe(true);
34
+ expect(isExplicitLocalPath("bundle/file.txt")).toBe(false);
35
+ });
36
+
37
+ it("resolves command targets from cwd and home prefixes", () => {
38
+ expect(
39
+ resolveCommandTargetPath("~/bundle", { HOME: "/tmp/home" }, "/tmp/cwd"),
40
+ ).toBe("/tmp/home/bundle");
41
+ expect(
42
+ resolveCommandTargetPath("./bundle", { HOME: "/tmp/home" }, "/tmp/cwd"),
43
+ ).toBe("/tmp/cwd/bundle");
44
+ });
45
+
46
+ it("builds repository paths within a root", () => {
47
+ expect(
48
+ buildRepoPathWithinRoot(
49
+ "/tmp/home/.config/tool/settings.json",
50
+ "/tmp/home",
51
+ "Sync target",
52
+ ),
53
+ ).toBe(".config/tool/settings.json");
54
+ expect(buildConfiguredHomeLocalPath(".config/tool/settings.json")).toBe(
55
+ "~/.config/tool/settings.json",
56
+ );
57
+ });
58
+
59
+ it("rejects root and out-of-root repository paths", () => {
60
+ expect(() => {
61
+ buildRepoPathWithinRoot("/tmp/home", "/tmp/home", "Sync target");
62
+ }).toThrowError(DevsyncError);
63
+ expect(() => {
64
+ buildRepoPathWithinRoot("/tmp/elsewhere", "/tmp/home", "Sync target");
65
+ }).toThrowError(DevsyncError);
66
+ });
67
+
68
+ it("returns undefined from tolerant helpers for invalid inputs", () => {
69
+ expect(
70
+ tryBuildRepoPathWithinRoot("/tmp/elsewhere", "/tmp/home", "Sync target"),
71
+ ).toBeUndefined();
72
+ expect(tryNormalizeRepoPathInput("../bundle")).toBeUndefined();
73
+ });
74
+ });
@@ -1,4 +1,4 @@
1
- import { readSyncConfig } from "#app/config/sync.ts";
1
+ import { type ResolvedSyncConfig, readSyncConfig } from "#app/config/sync.ts";
2
2
 
3
3
  import {
4
4
  applyEntryMaterialization,
@@ -24,16 +24,32 @@ export type SyncPullResult = Readonly<{
24
24
  syncDirectory: string;
25
25
  }>;
26
26
 
27
- export const pullSync = async (
28
- request: SyncPullRequest,
29
- context: SyncContext,
30
- ): Promise<SyncPullResult> => {
31
- await ensureSyncRepository(context);
27
+ export type PullPlan = Readonly<{
28
+ counts: ReturnType<typeof buildPullCounts>;
29
+ deletedLocalCount: number;
30
+ desiredKeys: ReadonlySet<string>;
31
+ existingKeys: ReadonlySet<string>;
32
+ materializations: readonly ReturnType<typeof buildEntryMaterialization>[];
33
+ }>;
32
34
 
33
- const config = await readSyncConfig(
34
- context.paths.syncDirectory,
35
- context.environment,
36
- );
35
+ const collectDesiredKeys = (
36
+ materializations: readonly ReturnType<typeof buildEntryMaterialization>[],
37
+ ) => {
38
+ const keys = new Set<string>();
39
+
40
+ for (const materialization of materializations) {
41
+ for (const key of materialization.desiredKeys) {
42
+ keys.add(key);
43
+ }
44
+ }
45
+
46
+ return keys;
47
+ };
48
+
49
+ export const buildPullPlan = async (
50
+ config: ResolvedSyncConfig,
51
+ context: SyncContext,
52
+ ): Promise<PullPlan> => {
37
53
  const snapshot = await buildRepositorySnapshot(
38
54
  context.paths.syncDirectory,
39
55
  config,
@@ -43,6 +59,7 @@ export const pullSync = async (
43
59
  });
44
60
 
45
61
  let deletedLocalCount = 0;
62
+ const existingKeys = new Set<string>();
46
63
 
47
64
  for (let index = 0; index < config.entries.length; index += 1) {
48
65
  const entry = config.entries[index];
@@ -56,20 +73,72 @@ export const pullSync = async (
56
73
  entry,
57
74
  materialization.desiredKeys,
58
75
  config,
76
+ existingKeys,
59
77
  );
60
-
61
- if (!request.dryRun) {
62
- await applyEntryMaterialization(entry, materialization, config);
63
- }
64
78
  }
65
79
 
66
- const counts = buildPullCounts(materializations);
80
+ return {
81
+ counts: buildPullCounts(materializations),
82
+ deletedLocalCount,
83
+ desiredKeys: collectDesiredKeys(materializations),
84
+ existingKeys,
85
+ materializations,
86
+ };
87
+ };
67
88
 
89
+ export const buildPullPlanPreview = (plan: PullPlan) => {
90
+ const desired = [...plan.desiredKeys].sort((left, right) => {
91
+ return left.localeCompare(right);
92
+ });
93
+ const deleted = [...plan.existingKeys]
94
+ .filter((key) => {
95
+ return !plan.desiredKeys.has(key);
96
+ })
97
+ .sort((left, right) => {
98
+ return left.localeCompare(right);
99
+ });
100
+
101
+ return [...desired.slice(0, 4), ...deleted.slice(0, 4)].slice(0, 6);
102
+ };
103
+
104
+ export const buildPullResultFromPlan = (
105
+ plan: PullPlan,
106
+ context: SyncContext,
107
+ dryRun: boolean,
108
+ ): SyncPullResult => {
68
109
  return {
69
110
  configPath: context.paths.configPath,
70
- deletedLocalCount,
71
- dryRun: request.dryRun,
111
+ deletedLocalCount: plan.deletedLocalCount,
112
+ dryRun,
72
113
  syncDirectory: context.paths.syncDirectory,
73
- ...counts,
114
+ ...plan.counts,
74
115
  };
75
116
  };
117
+
118
+ export const pullSync = async (
119
+ request: SyncPullRequest,
120
+ context: SyncContext,
121
+ ): Promise<SyncPullResult> => {
122
+ await ensureSyncRepository(context);
123
+
124
+ const config = await readSyncConfig(
125
+ context.paths.syncDirectory,
126
+ context.environment,
127
+ );
128
+ const plan = await buildPullPlan(config, context);
129
+
130
+ for (let index = 0; index < config.entries.length; index += 1) {
131
+ const entry = config.entries[index];
132
+ const materialization = plan.materializations[index];
133
+
134
+ if (entry === undefined || materialization === undefined) {
135
+ continue;
136
+ }
137
+
138
+ if (!request.dryRun) {
139
+ await applyEntryMaterialization(entry, materialization, config);
140
+ }
141
+ }
142
+
143
+ return buildPullResultFromPlan(plan, context, request.dryRun);
144
+ };
@@ -2,6 +2,7 @@ import { mkdtemp, rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
 
4
4
  import {
5
+ type ResolvedSyncConfig,
5
6
  readSyncConfig,
6
7
  resolveSyncArtifactsDirectoryPath,
7
8
  } from "#app/config/sync.ts";
@@ -31,6 +32,14 @@ export type SyncPushResult = Readonly<{
31
32
  syncDirectory: string;
32
33
  }>;
33
34
 
35
+ export type PushPlan = Readonly<{
36
+ counts: ReturnType<typeof buildPushCounts>;
37
+ deletedArtifactCount: number;
38
+ desiredArtifactKeys: ReadonlySet<string>;
39
+ existingArtifactKeys: ReadonlySet<string>;
40
+ snapshot: ReadonlyMap<string, SnapshotNode>;
41
+ }>;
42
+
34
43
  const buildPushCounts = (snapshot: ReadonlyMap<string, SnapshotNode>) => {
35
44
  let directoryCount = 0;
36
45
  let encryptedFileCount = 0;
@@ -63,16 +72,10 @@ const buildPushCounts = (snapshot: ReadonlyMap<string, SnapshotNode>) => {
63
72
  };
64
73
  };
65
74
 
66
- export const pushSync = async (
67
- request: SyncPushRequest,
75
+ export const buildPushPlan = async (
76
+ config: ResolvedSyncConfig,
68
77
  context: SyncContext,
69
- ): Promise<SyncPushResult> => {
70
- await ensureSyncRepository(context);
71
-
72
- const config = await readSyncConfig(
73
- context.paths.syncDirectory,
74
- context.environment,
75
- );
78
+ ): Promise<PushPlan> => {
76
79
  const snapshot = await buildLocalSnapshot(config);
77
80
  const artifacts = await buildRepoArtifacts(snapshot, config);
78
81
  const desiredArtifactKeys = new Set(
@@ -88,6 +91,56 @@ export const pushSync = async (
88
91
  return !desiredArtifactKeys.has(key);
89
92
  }).length;
90
93
 
94
+ return {
95
+ counts: buildPushCounts(snapshot),
96
+ deletedArtifactCount,
97
+ desiredArtifactKeys,
98
+ existingArtifactKeys,
99
+ snapshot,
100
+ };
101
+ };
102
+
103
+ export const buildPushPlanPreview = (plan: PushPlan) => {
104
+ const createdOrUpdated = [...plan.snapshot.keys()].sort((left, right) => {
105
+ return left.localeCompare(right);
106
+ });
107
+ const deleted = [...plan.existingArtifactKeys]
108
+ .filter((key) => {
109
+ return !plan.desiredArtifactKeys.has(key);
110
+ })
111
+ .sort((left, right) => {
112
+ return left.localeCompare(right);
113
+ });
114
+
115
+ return [...createdOrUpdated.slice(0, 4), ...deleted.slice(0, 4)].slice(0, 6);
116
+ };
117
+
118
+ export const buildPushResultFromPlan = (
119
+ plan: PushPlan,
120
+ context: SyncContext,
121
+ dryRun: boolean,
122
+ ): SyncPushResult => {
123
+ return {
124
+ configPath: context.paths.configPath,
125
+ deletedArtifactCount: plan.deletedArtifactCount,
126
+ dryRun,
127
+ syncDirectory: context.paths.syncDirectory,
128
+ ...plan.counts,
129
+ };
130
+ };
131
+
132
+ export const pushSync = async (
133
+ request: SyncPushRequest,
134
+ context: SyncContext,
135
+ ): Promise<SyncPushResult> => {
136
+ await ensureSyncRepository(context);
137
+
138
+ const config = await readSyncConfig(
139
+ context.paths.syncDirectory,
140
+ context.environment,
141
+ );
142
+ const plan = await buildPushPlan(config, context);
143
+
91
144
  if (!request.dryRun) {
92
145
  const stagingRoot = await mkdtemp(
93
146
  join(context.paths.syncDirectory, ".devsync-sync-push-"),
@@ -95,6 +148,8 @@ export const pushSync = async (
95
148
  const nextArtifactsDirectory = join(stagingRoot, "files");
96
149
 
97
150
  try {
151
+ const artifacts = await buildRepoArtifacts(plan.snapshot, config);
152
+
98
153
  await writeArtifactsToDirectory(nextArtifactsDirectory, artifacts);
99
154
 
100
155
  await replacePathAtomically(
@@ -109,13 +164,5 @@ export const pushSync = async (
109
164
  }
110
165
  }
111
166
 
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
- };
167
+ return buildPushResultFromPlan(plan, context, request.dryRun);
121
168
  };
@@ -0,0 +1,57 @@
1
+ import { readSyncConfig } from "#app/config/sync.ts";
2
+
3
+ import { countConfiguredRules } from "./config-file.ts";
4
+ import {
5
+ buildPullPlan,
6
+ buildPullPlanPreview,
7
+ buildPullResultFromPlan,
8
+ } from "./pull.ts";
9
+ import {
10
+ buildPushPlan,
11
+ buildPushPlanPreview,
12
+ buildPushResultFromPlan,
13
+ } from "./push.ts";
14
+ import { ensureSyncRepository, type SyncContext } from "./runtime.ts";
15
+
16
+ export type SyncStatusResult = Readonly<{
17
+ configPath: string;
18
+ entryCount: number;
19
+ pull: ReturnType<typeof buildPullResultFromPlan> & {
20
+ preview: readonly string[];
21
+ };
22
+ push: ReturnType<typeof buildPushResultFromPlan> & {
23
+ preview: readonly string[];
24
+ };
25
+ recipientCount: number;
26
+ ruleCount: number;
27
+ syncDirectory: string;
28
+ }>;
29
+
30
+ export const getSyncStatus = async (
31
+ context: SyncContext,
32
+ ): Promise<SyncStatusResult> => {
33
+ await ensureSyncRepository(context);
34
+
35
+ const config = await readSyncConfig(
36
+ context.paths.syncDirectory,
37
+ context.environment,
38
+ );
39
+ const pushPlan = await buildPushPlan(config, context);
40
+ const pullPlan = await buildPullPlan(config, context);
41
+
42
+ return {
43
+ configPath: context.paths.configPath,
44
+ entryCount: config.entries.length,
45
+ pull: {
46
+ ...buildPullResultFromPlan(pullPlan, context, true),
47
+ preview: buildPullPlanPreview(pullPlan),
48
+ },
49
+ push: {
50
+ ...buildPushResultFromPlan(pushPlan, context, true),
51
+ preview: buildPushPlanPreview(pushPlan),
52
+ },
53
+ recipientCount: config.age.recipients.length,
54
+ ruleCount: countConfiguredRules(config),
55
+ syncDirectory: context.paths.syncDirectory,
56
+ };
57
+ };