@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinyrack/devsync",
3
- "version": "1.0.3",
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",
@@ -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
  });
@@ -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);
@@ -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
+ };
@@ -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
  });