@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.
- package/README.md +39 -1
- package/package.json +8 -5
- package/src/cli/commands/doctor.ts +20 -0
- package/src/cli/commands/index.ts +6 -0
- package/src/cli/commands/list.ts +17 -0
- package/src/cli/commands/status.ts +18 -0
- package/src/cli/sync-output.test.ts +173 -0
- package/src/cli/sync-output.ts +71 -0
- package/src/config/sync.test.ts +609 -0
- package/src/lib/string.test.ts +13 -0
- package/src/lib/validation.test.ts +32 -0
- package/src/services/config-file.test.ts +161 -0
- package/src/services/crypto.test.ts +132 -0
- package/src/services/doctor.ts +142 -0
- package/src/services/filesystem.test.ts +171 -0
- package/src/services/git.test.ts +83 -0
- package/src/services/init.test.ts +109 -0
- package/src/services/list.ts +63 -0
- package/src/services/local-materialization.ts +1 -1
- package/src/services/paths.test.ts +74 -0
- package/src/services/pull.ts +87 -18
- package/src/services/push.ts +65 -18
- package/src/services/status.ts +57 -0
- package/src/services/sync.dry-run.test.ts +179 -0
- package/src/services/sync.runtime.test.ts +756 -0
- package/src/services/sync.service.test.ts +1169 -0
- package/src/test/helpers/sync-fixture.ts +47 -0
|
@@ -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
|
+
});
|
package/src/services/pull.ts
CHANGED
|
@@ -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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
};
|
package/src/services/push.ts
CHANGED
|
@@ -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
|
|
67
|
-
|
|
75
|
+
export const buildPushPlan = async (
|
|
76
|
+
config: ResolvedSyncConfig,
|
|
68
77
|
context: SyncContext,
|
|
69
|
-
): Promise<
|
|
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
|
-
|
|
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
|
+
};
|