@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 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.0.0",
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://git.winetree94.com/winetree94/devsync.git"
10
+ "url": "git+https://github.com/tinyrack-net/devsync.git"
11
11
  },
12
- "homepage": "https://git.winetree94.com/winetree94/devsync",
12
+ "homepage": "https://github.com/tinyrack-net/devsync#readme",
13
13
  "bugs": {
14
- "url": "https://git.winetree94.com/winetree94/devsync/issues"
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
+ });
@@ -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
+ };