@tinyrack/devsync 1.0.3 → 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 +25 -1
- package/package.json +1 -1
- 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 +68 -0
- package/src/cli/sync-output.ts +71 -0
- package/src/services/doctor.ts +142 -0
- package/src/services/list.ts +63 -0
- package/src/services/local-materialization.ts +1 -1
- 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.service.test.ts +144 -0
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ A personal CLI tool for git-backed configuration sync.
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- Flat sync-focused CLI: `init`, `add`, `set`, `forget`, `push`, `pull`, `cd`
|
|
9
|
+
- Flat sync-focused CLI: `init`, `add`, `set`, `forget`, `list`, `status`, `doctor`, `push`, `pull`, `cd`
|
|
10
10
|
- Git-backed sync repository under `~/.config/devsync/sync`
|
|
11
11
|
- Age-encrypted secret file support
|
|
12
12
|
- Rule-based `normal`, `secret`, and `ignore` modes
|
|
@@ -129,6 +129,30 @@ cd ~/mytool && devsync forget ./settings.json
|
|
|
129
129
|
devsync forget .config/mytool
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
+
### `list`
|
|
133
|
+
|
|
134
|
+
Show tracked entries, modes, and override rules.
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
devsync list
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### `status`
|
|
141
|
+
|
|
142
|
+
Show planned push and pull changes for the current sync config.
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
devsync status
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### `doctor`
|
|
149
|
+
|
|
150
|
+
Check the sync repository, config, age identity, and tracked local paths.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
devsync doctor
|
|
154
|
+
```
|
|
155
|
+
|
|
132
156
|
### `push`
|
|
133
157
|
|
|
134
158
|
Mirror local config into the sync repository.
|
package/package.json
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Command } from "@oclif/core";
|
|
2
|
+
|
|
3
|
+
import { formatSyncDoctorResult } from "#app/cli/sync-output.ts";
|
|
4
|
+
import { runSyncDoctor } from "#app/services/doctor.ts";
|
|
5
|
+
import { createSyncContext } from "#app/services/runtime.ts";
|
|
6
|
+
|
|
7
|
+
export default class SyncDoctor extends Command {
|
|
8
|
+
public static override summary =
|
|
9
|
+
"Check sync repository, config, age identity, and tracked local paths";
|
|
10
|
+
|
|
11
|
+
public override async run(): Promise<void> {
|
|
12
|
+
const result = await runSyncDoctor(createSyncContext());
|
|
13
|
+
|
|
14
|
+
process.stdout.write(formatSyncDoctorResult(result));
|
|
15
|
+
|
|
16
|
+
if (result.hasFailures) {
|
|
17
|
+
this.exit(1);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import SyncAdd from "#app/cli/commands/add.ts";
|
|
2
2
|
import SyncCd from "#app/cli/commands/cd.ts";
|
|
3
|
+
import SyncDoctor from "#app/cli/commands/doctor.ts";
|
|
3
4
|
import SyncForget from "#app/cli/commands/forget.ts";
|
|
4
5
|
import SyncInit from "#app/cli/commands/init.ts";
|
|
6
|
+
import SyncList from "#app/cli/commands/list.ts";
|
|
5
7
|
import SyncPull from "#app/cli/commands/pull.ts";
|
|
6
8
|
import SyncPush from "#app/cli/commands/push.ts";
|
|
7
9
|
import SyncSet from "#app/cli/commands/set.ts";
|
|
10
|
+
import SyncStatus from "#app/cli/commands/status.ts";
|
|
8
11
|
|
|
9
12
|
export const COMMANDS = {
|
|
10
13
|
add: SyncAdd,
|
|
11
14
|
cd: SyncCd,
|
|
15
|
+
doctor: SyncDoctor,
|
|
12
16
|
forget: SyncForget,
|
|
13
17
|
init: SyncInit,
|
|
18
|
+
list: SyncList,
|
|
14
19
|
pull: SyncPull,
|
|
15
20
|
push: SyncPush,
|
|
21
|
+
status: SyncStatus,
|
|
16
22
|
set: SyncSet,
|
|
17
23
|
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Command } from "@oclif/core";
|
|
2
|
+
|
|
3
|
+
import { formatSyncListResult } from "#app/cli/sync-output.ts";
|
|
4
|
+
import { listSyncConfig } from "#app/services/list.ts";
|
|
5
|
+
import { createSyncContext } from "#app/services/runtime.ts";
|
|
6
|
+
|
|
7
|
+
export default class SyncList extends Command {
|
|
8
|
+
public static override summary = "Show tracked sync entries and overrides";
|
|
9
|
+
|
|
10
|
+
public override async run(): Promise<void> {
|
|
11
|
+
const output = formatSyncListResult(
|
|
12
|
+
await listSyncConfig(createSyncContext()),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
process.stdout.write(output);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Command } from "@oclif/core";
|
|
2
|
+
|
|
3
|
+
import { formatSyncStatusResult } from "#app/cli/sync-output.ts";
|
|
4
|
+
import { createSyncContext } from "#app/services/runtime.ts";
|
|
5
|
+
import { getSyncStatus } from "#app/services/status.ts";
|
|
6
|
+
|
|
7
|
+
export default class SyncStatus extends Command {
|
|
8
|
+
public static override summary =
|
|
9
|
+
"Show planned push and pull changes for the current sync config";
|
|
10
|
+
|
|
11
|
+
public override async run(): Promise<void> {
|
|
12
|
+
const output = formatSyncStatusResult(
|
|
13
|
+
await getSyncStatus(createSyncContext()),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
process.stdout.write(output);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -2,11 +2,14 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
formatSyncAddResult,
|
|
5
|
+
formatSyncDoctorResult,
|
|
5
6
|
formatSyncForgetResult,
|
|
6
7
|
formatSyncInitResult,
|
|
8
|
+
formatSyncListResult,
|
|
7
9
|
formatSyncPullResult,
|
|
8
10
|
formatSyncPushResult,
|
|
9
11
|
formatSyncSetResult,
|
|
12
|
+
formatSyncStatusResult,
|
|
10
13
|
} from "#app/cli/sync-output.ts";
|
|
11
14
|
|
|
12
15
|
describe("sync output formatting", () => {
|
|
@@ -102,4 +105,69 @@ describe("sync output formatting", () => {
|
|
|
102
105
|
"local paths would be removed.\nNo filesystem changes were made.\n",
|
|
103
106
|
);
|
|
104
107
|
});
|
|
108
|
+
|
|
109
|
+
it("formats list, status, and doctor results", () => {
|
|
110
|
+
expect(
|
|
111
|
+
formatSyncListResult({
|
|
112
|
+
configPath: "/tmp/sync/config.json",
|
|
113
|
+
entries: [
|
|
114
|
+
{
|
|
115
|
+
kind: "directory",
|
|
116
|
+
localPath: "/tmp/home/.config/tool",
|
|
117
|
+
mode: "normal",
|
|
118
|
+
name: ".config/tool",
|
|
119
|
+
overrides: [{ mode: "secret", selector: "token.json" }],
|
|
120
|
+
repoPath: ".config/tool",
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
recipientCount: 1,
|
|
124
|
+
ruleCount: 1,
|
|
125
|
+
syncDirectory: "/tmp/sync",
|
|
126
|
+
}),
|
|
127
|
+
).toContain("override token.json: secret\n");
|
|
128
|
+
expect(
|
|
129
|
+
formatSyncStatusResult({
|
|
130
|
+
configPath: "/tmp/sync/config.json",
|
|
131
|
+
entryCount: 1,
|
|
132
|
+
pull: {
|
|
133
|
+
configPath: "/tmp/sync/config.json",
|
|
134
|
+
decryptedFileCount: 1,
|
|
135
|
+
deletedLocalCount: 2,
|
|
136
|
+
directoryCount: 1,
|
|
137
|
+
dryRun: true,
|
|
138
|
+
plainFileCount: 0,
|
|
139
|
+
preview: ["bundle", "bundle/token.txt"],
|
|
140
|
+
symlinkCount: 0,
|
|
141
|
+
syncDirectory: "/tmp/sync",
|
|
142
|
+
},
|
|
143
|
+
push: {
|
|
144
|
+
configPath: "/tmp/sync/config.json",
|
|
145
|
+
deletedArtifactCount: 1,
|
|
146
|
+
directoryCount: 1,
|
|
147
|
+
dryRun: true,
|
|
148
|
+
encryptedFileCount: 1,
|
|
149
|
+
plainFileCount: 0,
|
|
150
|
+
preview: ["bundle", "bundle/token.txt"],
|
|
151
|
+
symlinkCount: 0,
|
|
152
|
+
syncDirectory: "/tmp/sync",
|
|
153
|
+
},
|
|
154
|
+
recipientCount: 1,
|
|
155
|
+
ruleCount: 0,
|
|
156
|
+
syncDirectory: "/tmp/sync",
|
|
157
|
+
}),
|
|
158
|
+
).toContain("Push preview: bundle, bundle/token.txt\n");
|
|
159
|
+
expect(
|
|
160
|
+
formatSyncDoctorResult({
|
|
161
|
+
checks: [
|
|
162
|
+
{ detail: "ok", level: "ok", name: "git" },
|
|
163
|
+
{ detail: "warn", level: "warn", name: "entries" },
|
|
164
|
+
{ detail: "fail", level: "fail", name: "age" },
|
|
165
|
+
],
|
|
166
|
+
configPath: "/tmp/sync/config.json",
|
|
167
|
+
hasFailures: true,
|
|
168
|
+
hasWarnings: true,
|
|
169
|
+
syncDirectory: "/tmp/sync",
|
|
170
|
+
}),
|
|
171
|
+
).toContain("Summary: 1 ok, 1 warnings, 1 failures.\n");
|
|
172
|
+
});
|
|
105
173
|
});
|
package/src/cli/sync-output.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { ensureTrailingNewline } from "#app/lib/string.ts";
|
|
2
2
|
import type { SyncAddResult } from "#app/services/add.ts";
|
|
3
|
+
import type { SyncDoctorResult } from "#app/services/doctor.ts";
|
|
3
4
|
import type { SyncForgetResult } from "#app/services/forget.ts";
|
|
4
5
|
import type { SyncInitResult } from "#app/services/init.ts";
|
|
6
|
+
import type { SyncListResult } from "#app/services/list.ts";
|
|
5
7
|
import type { SyncPullResult } from "#app/services/pull.ts";
|
|
6
8
|
import type { SyncPushResult } from "#app/services/push.ts";
|
|
7
9
|
import type { SyncSetResult } from "#app/services/set.ts";
|
|
10
|
+
import type { SyncStatusResult } from "#app/services/status.ts";
|
|
8
11
|
|
|
9
12
|
export const formatSyncInitResult = (result: SyncInitResult) => {
|
|
10
13
|
const lines = [
|
|
@@ -127,3 +130,71 @@ export const formatSyncPullResult = (result: SyncPullResult) => {
|
|
|
127
130
|
|
|
128
131
|
return ensureTrailingNewline(lines.join("\n"));
|
|
129
132
|
};
|
|
133
|
+
|
|
134
|
+
export const formatSyncListResult = (result: SyncListResult) => {
|
|
135
|
+
const lines = [
|
|
136
|
+
"Tracked sync configuration.",
|
|
137
|
+
`Sync directory: ${result.syncDirectory}`,
|
|
138
|
+
`Config file: ${result.configPath}`,
|
|
139
|
+
`Summary: ${result.recipientCount} recipients, ${result.entries.length} entries, ${result.ruleCount} rules.`,
|
|
140
|
+
...(result.entries.length === 0
|
|
141
|
+
? ["Entries: none"]
|
|
142
|
+
: result.entries.flatMap((entry) => {
|
|
143
|
+
return [
|
|
144
|
+
`- ${entry.repoPath} [${entry.kind}, ${entry.mode}] -> ${entry.localPath}`,
|
|
145
|
+
...(entry.overrides.length === 0
|
|
146
|
+
? []
|
|
147
|
+
: entry.overrides.map((override) => {
|
|
148
|
+
return ` override ${override.selector}: ${override.mode}`;
|
|
149
|
+
})),
|
|
150
|
+
];
|
|
151
|
+
})),
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
return ensureTrailingNewline(lines.join("\n"));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const formatSyncStatusResult = (result: SyncStatusResult) => {
|
|
158
|
+
const lines = [
|
|
159
|
+
"Sync status overview.",
|
|
160
|
+
`Sync directory: ${result.syncDirectory}`,
|
|
161
|
+
`Config file: ${result.configPath}`,
|
|
162
|
+
`Summary: ${result.recipientCount} recipients, ${result.entryCount} entries, ${result.ruleCount} rules.`,
|
|
163
|
+
`Push plan: ${result.push.plainFileCount} plain files, ${result.push.encryptedFileCount} encrypted files, ${result.push.symlinkCount} symlinks, ${result.push.directoryCount} directory roots, ${result.push.deletedArtifactCount} stale repository artifacts.`,
|
|
164
|
+
...(result.push.preview.length === 0
|
|
165
|
+
? ["Push preview: no tracked paths"]
|
|
166
|
+
: [`Push preview: ${result.push.preview.join(", ")}`]),
|
|
167
|
+
`Pull plan: ${result.pull.plainFileCount} plain files, ${result.pull.decryptedFileCount} decrypted files, ${result.pull.symlinkCount} symlinks, ${result.pull.directoryCount} directory roots, ${result.pull.deletedLocalCount} local paths.`,
|
|
168
|
+
...(result.pull.preview.length === 0
|
|
169
|
+
? ["Pull preview: no tracked paths"]
|
|
170
|
+
: [`Pull preview: ${result.pull.preview.join(", ")}`]),
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
return ensureTrailingNewline(lines.join("\n"));
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export const formatSyncDoctorResult = (result: SyncDoctorResult) => {
|
|
177
|
+
const counts = result.checks.reduce(
|
|
178
|
+
(accumulator, check) => {
|
|
179
|
+
accumulator[check.level] += 1;
|
|
180
|
+
|
|
181
|
+
return accumulator;
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
fail: 0,
|
|
185
|
+
ok: 0,
|
|
186
|
+
warn: 0,
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
const lines = [
|
|
190
|
+
result.hasFailures ? "Sync doctor found issues." : "Sync doctor passed.",
|
|
191
|
+
`Sync directory: ${result.syncDirectory}`,
|
|
192
|
+
`Config file: ${result.configPath}`,
|
|
193
|
+
`Summary: ${counts.ok} ok, ${counts.warn} warnings, ${counts.fail} failures.`,
|
|
194
|
+
...result.checks.map((check) => {
|
|
195
|
+
return `[${check.level.toUpperCase()}] ${check.name}: ${check.detail}`;
|
|
196
|
+
}),
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
return ensureTrailingNewline(lines.join("\n"));
|
|
200
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { readSyncConfig } from "#app/config/sync.ts";
|
|
2
|
+
|
|
3
|
+
import { countConfiguredRules } from "./config-file.ts";
|
|
4
|
+
import { pathExists } from "./filesystem.ts";
|
|
5
|
+
import { ensureRepository } from "./git.ts";
|
|
6
|
+
import type { SyncContext } from "./runtime.ts";
|
|
7
|
+
|
|
8
|
+
export type DoctorCheckLevel = "fail" | "ok" | "warn";
|
|
9
|
+
|
|
10
|
+
export type DoctorCheck = Readonly<{
|
|
11
|
+
detail: string;
|
|
12
|
+
level: DoctorCheckLevel;
|
|
13
|
+
name: string;
|
|
14
|
+
}>;
|
|
15
|
+
|
|
16
|
+
export type SyncDoctorResult = Readonly<{
|
|
17
|
+
checks: readonly DoctorCheck[];
|
|
18
|
+
configPath: string;
|
|
19
|
+
hasFailures: boolean;
|
|
20
|
+
hasWarnings: boolean;
|
|
21
|
+
syncDirectory: string;
|
|
22
|
+
}>;
|
|
23
|
+
|
|
24
|
+
const ok = (name: string, detail: string): DoctorCheck => ({
|
|
25
|
+
detail,
|
|
26
|
+
level: "ok",
|
|
27
|
+
name,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const warn = (name: string, detail: string): DoctorCheck => ({
|
|
31
|
+
detail,
|
|
32
|
+
level: "warn",
|
|
33
|
+
name,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const fail = (name: string, detail: string): DoctorCheck => ({
|
|
37
|
+
detail,
|
|
38
|
+
level: "fail",
|
|
39
|
+
name,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const runSyncDoctor = async (
|
|
43
|
+
context: SyncContext,
|
|
44
|
+
): Promise<SyncDoctorResult> => {
|
|
45
|
+
const checks: DoctorCheck[] = [];
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await ensureRepository(context.paths.syncDirectory);
|
|
49
|
+
checks.push(ok("git", "Sync directory is a git repository."));
|
|
50
|
+
} catch (error: unknown) {
|
|
51
|
+
checks.push(
|
|
52
|
+
fail(
|
|
53
|
+
"git",
|
|
54
|
+
error instanceof Error ? error.message : "Git repository check failed.",
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
checks,
|
|
60
|
+
configPath: context.paths.configPath,
|
|
61
|
+
hasFailures: true,
|
|
62
|
+
hasWarnings: false,
|
|
63
|
+
syncDirectory: context.paths.syncDirectory,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let config: Awaited<ReturnType<typeof readSyncConfig>>;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
config = await readSyncConfig(
|
|
71
|
+
context.paths.syncDirectory,
|
|
72
|
+
context.environment,
|
|
73
|
+
);
|
|
74
|
+
checks.push(
|
|
75
|
+
ok(
|
|
76
|
+
"config",
|
|
77
|
+
`Loaded config with ${config.entries.length} entries, ${countConfiguredRules(config)} rules, and ${config.age.recipients.length} recipients.`,
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
} catch (error: unknown) {
|
|
81
|
+
checks.push(
|
|
82
|
+
fail(
|
|
83
|
+
"config",
|
|
84
|
+
error instanceof Error
|
|
85
|
+
? error.message
|
|
86
|
+
: "Sync configuration could not be read.",
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
checks,
|
|
92
|
+
configPath: context.paths.configPath,
|
|
93
|
+
hasFailures: true,
|
|
94
|
+
hasWarnings: false,
|
|
95
|
+
syncDirectory: context.paths.syncDirectory,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
checks.push(
|
|
100
|
+
(await pathExists(config.age.identityFile))
|
|
101
|
+
? ok("age", `Age identity file exists at ${config.age.identityFile}.`)
|
|
102
|
+
: fail("age", `Age identity file is missing: ${config.age.identityFile}`),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
checks.push(
|
|
106
|
+
config.entries.length === 0
|
|
107
|
+
? warn("entries", "No sync entries are configured yet.")
|
|
108
|
+
: ok("entries", `Tracked ${config.entries.length} sync entries.`),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const missingEntries = config.entries.filter((entry) => {
|
|
112
|
+
return !context.environment || entry.localPath.length > 0;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
let missingCount = 0;
|
|
116
|
+
|
|
117
|
+
for (const entry of missingEntries) {
|
|
118
|
+
if (!(await pathExists(entry.localPath))) {
|
|
119
|
+
missingCount += 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
checks.push(
|
|
124
|
+
missingCount === 0
|
|
125
|
+
? ok("local-paths", "All tracked local paths currently exist.")
|
|
126
|
+
: warn(
|
|
127
|
+
"local-paths",
|
|
128
|
+
`${missingCount} tracked local path${missingCount === 1 ? " is" : "s are"} missing.`,
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const hasFailures = checks.some((check) => check.level === "fail");
|
|
133
|
+
const hasWarnings = checks.some((check) => check.level === "warn");
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
checks,
|
|
137
|
+
configPath: context.paths.configPath,
|
|
138
|
+
hasFailures,
|
|
139
|
+
hasWarnings,
|
|
140
|
+
syncDirectory: context.paths.syncDirectory,
|
|
141
|
+
};
|
|
142
|
+
};
|
|
@@ -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);
|
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
|
+
};
|
|
@@ -5,13 +5,16 @@ import { afterEach, describe, expect, it } from "vitest";
|
|
|
5
5
|
|
|
6
6
|
import { syncSecretArtifactSuffix } from "#app/config/sync.ts";
|
|
7
7
|
import { addSyncTarget } from "#app/services/add.ts";
|
|
8
|
+
import { runSyncDoctor } from "#app/services/doctor.ts";
|
|
8
9
|
import { DevsyncError } from "#app/services/error.ts";
|
|
9
10
|
import { forgetSyncTarget } from "#app/services/forget.ts";
|
|
10
11
|
import { initializeSync } from "#app/services/init.ts";
|
|
12
|
+
import { listSyncConfig } from "#app/services/list.ts";
|
|
11
13
|
import { pullSync } from "#app/services/pull.ts";
|
|
12
14
|
import { pushSync } from "#app/services/push.ts";
|
|
13
15
|
import { createSyncContext } from "#app/services/runtime.ts";
|
|
14
16
|
import { setSyncTargetMode } from "#app/services/set.ts";
|
|
17
|
+
import { getSyncStatus } from "#app/services/status.ts";
|
|
15
18
|
import {
|
|
16
19
|
createAgeKeyPair,
|
|
17
20
|
createTemporaryDirectory,
|
|
@@ -1022,4 +1025,145 @@ describe("sync service", () => {
|
|
|
1022
1025
|
),
|
|
1023
1026
|
).rejects.toThrowError();
|
|
1024
1027
|
});
|
|
1028
|
+
|
|
1029
|
+
it("lists tracked entries with overrides", async () => {
|
|
1030
|
+
const workspace = await createWorkspace();
|
|
1031
|
+
const homeDirectory = join(workspace, "home");
|
|
1032
|
+
const xdgConfigHome = join(workspace, "xdg");
|
|
1033
|
+
const bundleDirectory = join(homeDirectory, "bundle");
|
|
1034
|
+
const tokenFile = join(bundleDirectory, "token.txt");
|
|
1035
|
+
const ageKeys = await createAgeKeyPair();
|
|
1036
|
+
|
|
1037
|
+
await writeIdentityFile(xdgConfigHome, ageKeys.identity);
|
|
1038
|
+
await mkdir(bundleDirectory, { recursive: true });
|
|
1039
|
+
await writeFile(tokenFile, "secret\n");
|
|
1040
|
+
|
|
1041
|
+
const context = createSyncContext({
|
|
1042
|
+
environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
await initializeSync(
|
|
1046
|
+
{
|
|
1047
|
+
identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
|
|
1048
|
+
recipients: [ageKeys.recipient],
|
|
1049
|
+
},
|
|
1050
|
+
context,
|
|
1051
|
+
);
|
|
1052
|
+
await addSyncTarget(
|
|
1053
|
+
{
|
|
1054
|
+
secret: false,
|
|
1055
|
+
target: bundleDirectory,
|
|
1056
|
+
},
|
|
1057
|
+
context,
|
|
1058
|
+
);
|
|
1059
|
+
await setSyncTargetMode(
|
|
1060
|
+
{
|
|
1061
|
+
recursive: false,
|
|
1062
|
+
state: "secret",
|
|
1063
|
+
target: tokenFile,
|
|
1064
|
+
},
|
|
1065
|
+
context,
|
|
1066
|
+
);
|
|
1067
|
+
|
|
1068
|
+
const result = await listSyncConfig(context);
|
|
1069
|
+
|
|
1070
|
+
expect(result.entries).toEqual([
|
|
1071
|
+
{
|
|
1072
|
+
kind: "directory",
|
|
1073
|
+
localPath: bundleDirectory,
|
|
1074
|
+
mode: "normal",
|
|
1075
|
+
name: "bundle",
|
|
1076
|
+
overrides: [{ mode: "secret", selector: "token.txt" }],
|
|
1077
|
+
repoPath: "bundle",
|
|
1078
|
+
},
|
|
1079
|
+
]);
|
|
1080
|
+
expect(result.ruleCount).toBe(1);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
it("builds status previews for push and pull plans", async () => {
|
|
1084
|
+
const workspace = await createWorkspace();
|
|
1085
|
+
const homeDirectory = join(workspace, "home");
|
|
1086
|
+
const xdgConfigHome = join(workspace, "xdg");
|
|
1087
|
+
const bundleDirectory = join(homeDirectory, "bundle");
|
|
1088
|
+
const plainFile = join(bundleDirectory, "plain.txt");
|
|
1089
|
+
const ageKeys = await createAgeKeyPair();
|
|
1090
|
+
|
|
1091
|
+
await writeIdentityFile(xdgConfigHome, ageKeys.identity);
|
|
1092
|
+
await mkdir(bundleDirectory, { recursive: true });
|
|
1093
|
+
await writeFile(plainFile, "plain\n");
|
|
1094
|
+
|
|
1095
|
+
const context = createSyncContext({
|
|
1096
|
+
environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
await initializeSync(
|
|
1100
|
+
{
|
|
1101
|
+
identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
|
|
1102
|
+
recipients: [ageKeys.recipient],
|
|
1103
|
+
},
|
|
1104
|
+
context,
|
|
1105
|
+
);
|
|
1106
|
+
await addSyncTarget(
|
|
1107
|
+
{
|
|
1108
|
+
secret: false,
|
|
1109
|
+
target: bundleDirectory,
|
|
1110
|
+
},
|
|
1111
|
+
context,
|
|
1112
|
+
);
|
|
1113
|
+
await pushSync(
|
|
1114
|
+
{
|
|
1115
|
+
dryRun: false,
|
|
1116
|
+
},
|
|
1117
|
+
context,
|
|
1118
|
+
);
|
|
1119
|
+
|
|
1120
|
+
const status = await getSyncStatus(context);
|
|
1121
|
+
|
|
1122
|
+
expect(status.push.preview).toContain("bundle/plain.txt");
|
|
1123
|
+
expect(status.pull.preview).toContain("bundle/plain.txt");
|
|
1124
|
+
expect(status.push.plainFileCount).toBe(1);
|
|
1125
|
+
expect(status.pull.plainFileCount).toBe(1);
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it("reports doctor warnings for missing tracked local paths", async () => {
|
|
1129
|
+
const workspace = await createWorkspace();
|
|
1130
|
+
const homeDirectory = join(workspace, "home");
|
|
1131
|
+
const xdgConfigHome = join(workspace, "xdg");
|
|
1132
|
+
const trackedFile = join(homeDirectory, ".gitconfig");
|
|
1133
|
+
const ageKeys = await createAgeKeyPair();
|
|
1134
|
+
|
|
1135
|
+
await writeIdentityFile(xdgConfigHome, ageKeys.identity);
|
|
1136
|
+
await mkdir(homeDirectory, { recursive: true });
|
|
1137
|
+
await writeFile(trackedFile, "[user]\n name = test\n");
|
|
1138
|
+
|
|
1139
|
+
const context = createSyncContext({
|
|
1140
|
+
environment: createSyncEnvironment(homeDirectory, xdgConfigHome),
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
await initializeSync(
|
|
1144
|
+
{
|
|
1145
|
+
identityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
|
|
1146
|
+
recipients: [ageKeys.recipient],
|
|
1147
|
+
},
|
|
1148
|
+
context,
|
|
1149
|
+
);
|
|
1150
|
+
await addSyncTarget(
|
|
1151
|
+
{
|
|
1152
|
+
secret: false,
|
|
1153
|
+
target: trackedFile,
|
|
1154
|
+
},
|
|
1155
|
+
context,
|
|
1156
|
+
);
|
|
1157
|
+
await rm(trackedFile);
|
|
1158
|
+
|
|
1159
|
+
const result = await runSyncDoctor(context);
|
|
1160
|
+
|
|
1161
|
+
expect(result.hasFailures).toBe(false);
|
|
1162
|
+
expect(result.hasWarnings).toBe(true);
|
|
1163
|
+
expect(result.checks).toContainEqual({
|
|
1164
|
+
detail: "1 tracked local path is missing.",
|
|
1165
|
+
level: "warn",
|
|
1166
|
+
name: "local-paths",
|
|
1167
|
+
});
|
|
1168
|
+
});
|
|
1025
1169
|
});
|