@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
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
|
|
@@ -53,6 +53,20 @@ If you want the `devsync` command available from this checkout during developmen
|
|
|
53
53
|
npm link
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
## Release
|
|
57
|
+
|
|
58
|
+
- CI runs on every push and pull request with `npm run check` on Node.js 24.
|
|
59
|
+
- npm publishing runs automatically when a Git tag matching `v*.*.*` is pushed.
|
|
60
|
+
- The release workflow uses npm Trusted Publishing, so npm access is granted through GitHub Actions OIDC instead of an `NPM_TOKEN` secret.
|
|
61
|
+
- The release workflow fails if the pushed tag does not match `package.json` `version`.
|
|
62
|
+
|
|
63
|
+
Typical release flow:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm version patch
|
|
67
|
+
git push --follow-tags
|
|
68
|
+
```
|
|
69
|
+
|
|
56
70
|
## Storage Layout
|
|
57
71
|
|
|
58
72
|
- Sync repo: `~/.config/devsync/sync`
|
|
@@ -115,6 +129,30 @@ cd ~/mytool && devsync forget ./settings.json
|
|
|
115
129
|
devsync forget .config/mytool
|
|
116
130
|
```
|
|
117
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
|
+
|
|
118
156
|
### `push`
|
|
119
157
|
|
|
120
158
|
Mirror local config into the sync repository.
|
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinyrack/devsync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A personal CLI tool for git-backed configuration sync.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "winetree94",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": "https://
|
|
10
|
+
"url": "git+https://github.com/tinyrack-net/devsync.git"
|
|
11
11
|
},
|
|
12
|
-
"homepage": "https://
|
|
12
|
+
"homepage": "https://github.com/tinyrack-net/devsync#readme",
|
|
13
13
|
"bugs": {
|
|
14
|
-
"url": "https://
|
|
14
|
+
"url": "https://github.com/tinyrack-net/devsync/issues"
|
|
15
15
|
},
|
|
16
16
|
"publishConfig": {
|
|
17
17
|
"access": "public"
|
|
@@ -62,7 +62,10 @@
|
|
|
62
62
|
"coverage": "vitest run --coverage",
|
|
63
63
|
"check": "npm run typecheck && biome check . && npm run test",
|
|
64
64
|
"check:fix": "biome check --write .",
|
|
65
|
-
"format": "biome format --write ."
|
|
65
|
+
"format": "biome format --write .",
|
|
66
|
+
"release:patch": "npm version patch -m \"chore(release): %s\"",
|
|
67
|
+
"release:minor": "npm version minor -m \"chore(release): %s\"",
|
|
68
|
+
"release:major": "npm version major -m \"chore(release): %s\""
|
|
66
69
|
},
|
|
67
70
|
"dependencies": {
|
|
68
71
|
"@oclif/core": "^4.9.0",
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
formatSyncAddResult,
|
|
5
|
+
formatSyncDoctorResult,
|
|
6
|
+
formatSyncForgetResult,
|
|
7
|
+
formatSyncInitResult,
|
|
8
|
+
formatSyncListResult,
|
|
9
|
+
formatSyncPullResult,
|
|
10
|
+
formatSyncPushResult,
|
|
11
|
+
formatSyncSetResult,
|
|
12
|
+
formatSyncStatusResult,
|
|
13
|
+
} from "#app/cli/sync-output.ts";
|
|
14
|
+
|
|
15
|
+
describe("sync output formatting", () => {
|
|
16
|
+
it("formats init results for cloned repositories", () => {
|
|
17
|
+
expect(
|
|
18
|
+
formatSyncInitResult({
|
|
19
|
+
alreadyInitialized: false,
|
|
20
|
+
configPath: "/tmp/sync/config.json",
|
|
21
|
+
entryCount: 2,
|
|
22
|
+
generatedIdentity: true,
|
|
23
|
+
gitAction: "cloned",
|
|
24
|
+
gitSource: "/tmp/remote",
|
|
25
|
+
identityFile: "/tmp/xdg/devsync/age/keys.txt",
|
|
26
|
+
recipientCount: 3,
|
|
27
|
+
ruleCount: 4,
|
|
28
|
+
syncDirectory: "/tmp/sync",
|
|
29
|
+
}),
|
|
30
|
+
).toBe(
|
|
31
|
+
[
|
|
32
|
+
"Initialized sync directory.",
|
|
33
|
+
"Sync directory: /tmp/sync",
|
|
34
|
+
"Config file: /tmp/sync/config.json",
|
|
35
|
+
"Age identity file: /tmp/xdg/devsync/age/keys.txt",
|
|
36
|
+
"Git repository: cloned from /tmp/remote",
|
|
37
|
+
"Age bootstrap: generated a new local identity.",
|
|
38
|
+
"Summary: 3 recipients, 2 entries, 4 rules.",
|
|
39
|
+
"",
|
|
40
|
+
].join("\n"),
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("formats add, forget, and set results", () => {
|
|
45
|
+
expect(
|
|
46
|
+
formatSyncAddResult({
|
|
47
|
+
alreadyTracked: false,
|
|
48
|
+
configPath: "/tmp/sync/config.json",
|
|
49
|
+
kind: "file",
|
|
50
|
+
localPath: "/tmp/home/.zshrc",
|
|
51
|
+
mode: "secret",
|
|
52
|
+
repoPath: ".zshrc",
|
|
53
|
+
syncDirectory: "/tmp/sync",
|
|
54
|
+
}),
|
|
55
|
+
).toContain("Added sync target.\n");
|
|
56
|
+
expect(
|
|
57
|
+
formatSyncForgetResult({
|
|
58
|
+
configPath: "/tmp/sync/config.json",
|
|
59
|
+
localPath: "/tmp/home/.zshrc",
|
|
60
|
+
plainArtifactCount: 1,
|
|
61
|
+
repoPath: ".zshrc",
|
|
62
|
+
secretArtifactCount: 2,
|
|
63
|
+
syncDirectory: "/tmp/sync",
|
|
64
|
+
}),
|
|
65
|
+
).toContain("Removed repo artifacts: 1 plain, 2 secret.\n");
|
|
66
|
+
expect(
|
|
67
|
+
formatSyncSetResult({
|
|
68
|
+
action: "updated",
|
|
69
|
+
configPath: "/tmp/sync/config.json",
|
|
70
|
+
entryRepoPath: "bundle",
|
|
71
|
+
localPath: "/tmp/home/bundle/private.json",
|
|
72
|
+
mode: "ignore",
|
|
73
|
+
repoPath: "bundle/private.json",
|
|
74
|
+
scope: "exact",
|
|
75
|
+
syncDirectory: "/tmp/sync",
|
|
76
|
+
}),
|
|
77
|
+
).toContain("Scope: exact rule\nAction: updated\n");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("formats push and pull dry-run summaries", () => {
|
|
81
|
+
expect(
|
|
82
|
+
formatSyncPushResult({
|
|
83
|
+
configPath: "/tmp/sync/config.json",
|
|
84
|
+
deletedArtifactCount: 4,
|
|
85
|
+
directoryCount: 1,
|
|
86
|
+
dryRun: true,
|
|
87
|
+
encryptedFileCount: 2,
|
|
88
|
+
plainFileCount: 3,
|
|
89
|
+
symlinkCount: 1,
|
|
90
|
+
syncDirectory: "/tmp/sync",
|
|
91
|
+
}),
|
|
92
|
+
).toContain("No filesystem changes were made.\n");
|
|
93
|
+
expect(
|
|
94
|
+
formatSyncPullResult({
|
|
95
|
+
configPath: "/tmp/sync/config.json",
|
|
96
|
+
decryptedFileCount: 2,
|
|
97
|
+
deletedLocalCount: 5,
|
|
98
|
+
directoryCount: 1,
|
|
99
|
+
dryRun: true,
|
|
100
|
+
plainFileCount: 3,
|
|
101
|
+
symlinkCount: 1,
|
|
102
|
+
syncDirectory: "/tmp/sync",
|
|
103
|
+
}),
|
|
104
|
+
).toContain(
|
|
105
|
+
"local paths would be removed.\nNo filesystem changes were made.\n",
|
|
106
|
+
);
|
|
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
|
+
});
|
|
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
|
+
};
|