@tinyrack/devsync 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 winetree94
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # devsync
2
+
3
+ A personal CLI tool for git-backed configuration sync.
4
+
5
+ `devsync` is a Node.js + TypeScript command-line utility for managing a synced configuration repository under your XDG config directory. It tracks files and directories under `HOME`, stores plain and encrypted artifacts in a git-backed sync repo, and can push local state into that repo or pull the repo back onto the machine.
6
+
7
+ ## Features
8
+
9
+ - Flat sync-focused CLI: `init`, `add`, `set`, `forget`, `push`, `pull`, `cd`
10
+ - Git-backed sync repository under `~/.config/devsync/sync`
11
+ - Age-encrypted secret file support
12
+ - Rule-based `normal`, `secret`, and `ignore` modes
13
+ - Direct TypeScript execution with Node.js 24+
14
+ - Shell autocomplete via oclif
15
+
16
+ ## Requirements
17
+
18
+ - Node.js 24+
19
+ - npm
20
+ - git
21
+
22
+ ## Installation
23
+
24
+ Install globally:
25
+
26
+ ```bash
27
+ npm install -g @tinyrack/devsync
28
+ ```
29
+
30
+ Run without installing globally:
31
+
32
+ ```bash
33
+ npx @tinyrack/devsync --help
34
+ ```
35
+
36
+ Run the CLI locally:
37
+
38
+ ```bash
39
+ npm run start -- --help
40
+ ```
41
+
42
+ For development with file watching:
43
+
44
+ ```bash
45
+ npm run dev
46
+ ```
47
+
48
+ The published package name is `@tinyrack/devsync`, but the installed command is still `devsync`.
49
+
50
+ If you want the `devsync` command available from this checkout during development:
51
+
52
+ ```bash
53
+ npm link
54
+ ```
55
+
56
+ ## Storage Layout
57
+
58
+ - Sync repo: `~/.config/devsync/sync`
59
+ - Default age identity file: `$XDG_CONFIG_HOME/devsync/age/keys.txt`
60
+ - Tracked artifacts live under `~/.config/devsync/sync/files`
61
+ - Secret file artifacts use the suffix `.devsync.secret`, for example `token.json.devsync.secret`
62
+
63
+ ## Usage
64
+
65
+ ```bash
66
+ devsync <command>
67
+ ```
68
+
69
+ Or without linking:
70
+
71
+ ```bash
72
+ npm run start -- <command>
73
+ ```
74
+
75
+ ## Commands
76
+
77
+ ### `init`
78
+
79
+ Initialize the git-backed sync directory.
80
+
81
+ ```bash
82
+ devsync init
83
+ devsync init https://example.com/my-sync-repo.git
84
+ devsync init --identity "$XDG_CONFIG_HOME/devsync/age/keys.txt" --recipient age1...
85
+ ```
86
+
87
+ ### `add`
88
+
89
+ Track a local file or directory under your home directory.
90
+
91
+ ```bash
92
+ devsync add ~/.gitconfig
93
+ devsync add ./.zshrc
94
+ devsync add ~/.config/mytool --secret
95
+ ```
96
+
97
+ ### `set`
98
+
99
+ Set mode for a tracked directory root, child file, or child subtree.
100
+
101
+ ```bash
102
+ devsync set secret ~/.config/mytool/token.json
103
+ devsync set ignore ~/.config/mytool/cache --recursive
104
+ cd ~/.ssh && devsync set ignore known_hosts
105
+ devsync set normal .config/mytool/public.json
106
+ ```
107
+
108
+ ### `forget`
109
+
110
+ Remove a tracked local path or repository path from sync config.
111
+
112
+ ```bash
113
+ devsync forget ~/.gitconfig
114
+ cd ~/mytool && devsync forget ./settings.json
115
+ devsync forget .config/mytool
116
+ ```
117
+
118
+ ### `push`
119
+
120
+ Mirror local config into the sync repository.
121
+
122
+ ```bash
123
+ devsync push
124
+ devsync push --dry-run
125
+ ```
126
+
127
+ ### `pull`
128
+
129
+ Apply the sync repository to local config paths.
130
+
131
+ ```bash
132
+ devsync pull
133
+ devsync pull --dry-run
134
+ ```
135
+
136
+ ### `cd`
137
+
138
+ Print the sync directory in non-interactive mode, or open a shell there in interactive mode.
139
+
140
+ ```bash
141
+ devsync cd
142
+ devsync cd --print
143
+ ```
144
+
145
+ ## Development
146
+
147
+ Validation commands:
148
+
149
+ ```bash
150
+ npm run typecheck
151
+ biome check .
152
+ npm run test
153
+ ```
154
+
155
+ Or run the full validation sequence:
156
+
157
+ ```bash
158
+ npm run check
159
+ ```
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@tinyrack/devsync",
3
+ "version": "1.0.0",
4
+ "description": "A personal CLI tool for git-backed configuration sync.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "winetree94",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://git.winetree94.com/winetree94/devsync.git"
11
+ },
12
+ "homepage": "https://git.winetree94.com/winetree94/devsync",
13
+ "bugs": {
14
+ "url": "https://git.winetree94.com/winetree94/devsync/issues"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "keywords": [
20
+ "cli",
21
+ "sync",
22
+ "dotfiles",
23
+ "config-sync",
24
+ "devsync"
25
+ ],
26
+ "imports": {
27
+ "#app/*": "./src/*"
28
+ },
29
+ "bin": {
30
+ "devsync": "./src/index.ts"
31
+ },
32
+ "files": [
33
+ "src",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "oclif": {
38
+ "bin": "devsync",
39
+ "dirname": "devsync",
40
+ "commands": {
41
+ "strategy": "explicit",
42
+ "target": "./src/cli/commands/index.ts",
43
+ "identifier": "COMMANDS"
44
+ },
45
+ "topicSeparator": " ",
46
+ "plugins": [
47
+ "@oclif/plugin-autocomplete"
48
+ ],
49
+ "additionalHelpFlags": [
50
+ "-h"
51
+ ]
52
+ },
53
+ "engines": {
54
+ "node": ">=24"
55
+ },
56
+ "scripts": {
57
+ "dev": "node --watch src/index.ts",
58
+ "start": "node src/index.ts",
59
+ "typecheck": "tsc -p tsconfig.json",
60
+ "test": "vitest run",
61
+ "test:watch": "vitest",
62
+ "coverage": "vitest run --coverage",
63
+ "check": "npm run typecheck && biome check . && npm run test",
64
+ "check:fix": "biome check --write .",
65
+ "format": "biome format --write ."
66
+ },
67
+ "dependencies": {
68
+ "@oclif/core": "^4.9.0",
69
+ "@oclif/plugin-autocomplete": "^3.2.41",
70
+ "age-encryption": "^0.3.0",
71
+ "zod": "^4.3.6"
72
+ },
73
+ "devDependencies": {
74
+ "@biomejs/biome": "^2.4.8",
75
+ "@types/node": "^25.5.0",
76
+ "@vitest/coverage-v8": "^4.1.0",
77
+ "execa": "^9.6.1",
78
+ "typescript": "^5.9.3",
79
+ "vitest": "^4.1.0"
80
+ }
81
+ }
@@ -0,0 +1,40 @@
1
+ import { Args, Command, Flags } from "@oclif/core";
2
+
3
+ import { formatSyncAddResult } from "#app/cli/sync-output.ts";
4
+ import { addSyncTarget } from "#app/services/add.ts";
5
+ import { createSyncContext } from "#app/services/runtime.ts";
6
+
7
+ export default class SyncAdd extends Command {
8
+ public static override summary =
9
+ "Add a local file or directory under your home directory to sync config.json";
10
+
11
+ public static override args = {
12
+ target: Args.string({
13
+ description:
14
+ "Local file or directory under your home directory to track, including cwd-relative paths",
15
+ required: true,
16
+ }),
17
+ };
18
+
19
+ public static override flags = {
20
+ secret: Flags.boolean({
21
+ default: false,
22
+ description: "Set the added target mode to secret in sync config.json",
23
+ }),
24
+ };
25
+
26
+ public override async run(): Promise<void> {
27
+ const { args, flags } = await this.parse(SyncAdd);
28
+ const output = formatSyncAddResult(
29
+ await addSyncTarget(
30
+ {
31
+ secret: flags.secret,
32
+ target: args.target,
33
+ },
34
+ createSyncContext(),
35
+ ),
36
+ );
37
+
38
+ process.stdout.write(output);
39
+ }
40
+ }
@@ -0,0 +1,80 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir } from "node:fs/promises";
3
+
4
+ import { Command, Flags } from "@oclif/core";
5
+
6
+ import { resolveDevsyncSyncDirectory } from "#app/config/xdg.ts";
7
+ import { ensureTrailingNewline } from "#app/lib/string.ts";
8
+
9
+ const readEnvironmentVariable = (name: "ComSpec" | "SHELL") => {
10
+ return process.env[name]?.trim();
11
+ };
12
+
13
+ const resolveCommandShell = () => {
14
+ if (process.platform === "win32") {
15
+ return {
16
+ args: [] as string[],
17
+ command: readEnvironmentVariable("ComSpec") || "cmd.exe",
18
+ };
19
+ }
20
+
21
+ return {
22
+ args: ["-i"],
23
+ command: readEnvironmentVariable("SHELL") || "/bin/sh",
24
+ };
25
+ };
26
+
27
+ const spawnShellInDirectory = async (directory: string) => {
28
+ await mkdir(directory, { recursive: true });
29
+
30
+ const shell = resolveCommandShell();
31
+
32
+ await new Promise<void>((resolve, reject) => {
33
+ const child = spawn(shell.command, shell.args, {
34
+ cwd: directory,
35
+ stdio: "inherit",
36
+ });
37
+
38
+ child.once("error", reject);
39
+ child.once("exit", (code, signal) => {
40
+ if (signal !== null) {
41
+ reject(new Error(`Shell exited with signal ${signal}.`));
42
+
43
+ return;
44
+ }
45
+
46
+ if (code === 0) {
47
+ resolve();
48
+
49
+ return;
50
+ }
51
+
52
+ reject(new Error(`Shell exited with code ${code ?? 1}.`));
53
+ });
54
+ });
55
+ };
56
+
57
+ export default class SyncCd extends Command {
58
+ public static override summary =
59
+ "Open a shell in the sync directory or print its path";
60
+
61
+ public static override flags = {
62
+ print: Flags.boolean({
63
+ default: false,
64
+ description: "Print the sync directory path instead of opening a shell",
65
+ }),
66
+ };
67
+
68
+ public override async run(): Promise<void> {
69
+ const { flags } = await this.parse(SyncCd);
70
+ const syncDirectory = resolveDevsyncSyncDirectory();
71
+
72
+ if (flags.print || !process.stdin.isTTY || !process.stdout.isTTY) {
73
+ process.stdout.write(ensureTrailingNewline(syncDirectory));
74
+
75
+ return;
76
+ }
77
+
78
+ await spawnShellInDirectory(syncDirectory);
79
+ }
80
+ }
@@ -0,0 +1,32 @@
1
+ import { Args, Command } from "@oclif/core";
2
+
3
+ import { formatSyncForgetResult } from "#app/cli/sync-output.ts";
4
+ import { forgetSyncTarget } from "#app/services/forget.ts";
5
+ import { createSyncContext } from "#app/services/runtime.ts";
6
+
7
+ export default class SyncForget extends Command {
8
+ public static override summary =
9
+ "Remove a tracked local path or repository path from sync config.json";
10
+
11
+ public static override args = {
12
+ target: Args.string({
13
+ description:
14
+ "Tracked local path (including cwd-relative) or repository path to forget",
15
+ required: true,
16
+ }),
17
+ };
18
+
19
+ public override async run(): Promise<void> {
20
+ const { args } = await this.parse(SyncForget);
21
+ const output = formatSyncForgetResult(
22
+ await forgetSyncTarget(
23
+ {
24
+ target: args.target,
25
+ },
26
+ createSyncContext(),
27
+ ),
28
+ );
29
+
30
+ process.stdout.write(output);
31
+ }
32
+ }
@@ -0,0 +1,17 @@
1
+ import SyncAdd from "#app/cli/commands/add.ts";
2
+ import SyncCd from "#app/cli/commands/cd.ts";
3
+ import SyncForget from "#app/cli/commands/forget.ts";
4
+ import SyncInit from "#app/cli/commands/init.ts";
5
+ import SyncPull from "#app/cli/commands/pull.ts";
6
+ import SyncPush from "#app/cli/commands/push.ts";
7
+ import SyncSet from "#app/cli/commands/set.ts";
8
+
9
+ export const COMMANDS = {
10
+ add: SyncAdd,
11
+ cd: SyncCd,
12
+ forget: SyncForget,
13
+ init: SyncInit,
14
+ pull: SyncPull,
15
+ push: SyncPush,
16
+ set: SyncSet,
17
+ };
@@ -0,0 +1,43 @@
1
+ import { Args, Command, Flags } from "@oclif/core";
2
+
3
+ import { formatSyncInitResult } from "#app/cli/sync-output.ts";
4
+ import { initializeSync } from "#app/services/init.ts";
5
+ import { createSyncContext } from "#app/services/runtime.ts";
6
+
7
+ export default class SyncInit extends Command {
8
+ public static override summary = "Initialize the git-backed sync directory";
9
+
10
+ public static override args = {
11
+ repository: Args.string({
12
+ description: "Remote URL or local git repository path to clone",
13
+ required: false,
14
+ }),
15
+ };
16
+
17
+ public static override flags = {
18
+ identity: Flags.string({
19
+ description:
20
+ "Age identity file path to persist in config.json for later pulls",
21
+ }),
22
+ recipient: Flags.string({
23
+ description: "Age recipient public key to persist in config.json",
24
+ multiple: true,
25
+ }),
26
+ };
27
+
28
+ public override async run(): Promise<void> {
29
+ const { args, flags } = await this.parse(SyncInit);
30
+ const output = formatSyncInitResult(
31
+ await initializeSync(
32
+ {
33
+ identityFile: flags.identity,
34
+ recipients: flags.recipient ?? [],
35
+ repository: args.repository,
36
+ },
37
+ createSyncContext(),
38
+ ),
39
+ );
40
+
41
+ process.stdout.write(output);
42
+ }
43
+ }
@@ -0,0 +1,31 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+
3
+ import { formatSyncPullResult } from "#app/cli/sync-output.ts";
4
+ import { pullSync } from "#app/services/pull.ts";
5
+ import { createSyncContext } from "#app/services/runtime.ts";
6
+
7
+ export default class SyncPull extends Command {
8
+ public static override summary =
9
+ "Apply the git-backed sync repository to local config paths";
10
+
11
+ public static override flags = {
12
+ "dry-run": Flags.boolean({
13
+ default: false,
14
+ description: "Preview local config changes without writing files",
15
+ }),
16
+ };
17
+
18
+ public override async run(): Promise<void> {
19
+ const { flags } = await this.parse(SyncPull);
20
+ const output = formatSyncPullResult(
21
+ await pullSync(
22
+ {
23
+ dryRun: flags["dry-run"],
24
+ },
25
+ createSyncContext(),
26
+ ),
27
+ );
28
+
29
+ process.stdout.write(output);
30
+ }
31
+ }
@@ -0,0 +1,31 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+
3
+ import { formatSyncPushResult } from "#app/cli/sync-output.ts";
4
+ import { pushSync } from "#app/services/push.ts";
5
+ import { createSyncContext } from "#app/services/runtime.ts";
6
+
7
+ export default class SyncPush extends Command {
8
+ public static override summary =
9
+ "Mirror local config into the git-backed sync repository";
10
+
11
+ public static override flags = {
12
+ "dry-run": Flags.boolean({
13
+ default: false,
14
+ description: "Preview sync repository changes without writing files",
15
+ }),
16
+ };
17
+
18
+ public override async run(): Promise<void> {
19
+ const { flags } = await this.parse(SyncPush);
20
+ const output = formatSyncPushResult(
21
+ await pushSync(
22
+ {
23
+ dryRun: flags["dry-run"],
24
+ },
25
+ createSyncContext(),
26
+ ),
27
+ );
28
+
29
+ process.stdout.write(output);
30
+ }
31
+ }
@@ -0,0 +1,47 @@
1
+ import { Args, Command, Flags } from "@oclif/core";
2
+
3
+ import { formatSyncSetResult } from "#app/cli/sync-output.ts";
4
+ import { createSyncContext } from "#app/services/runtime.ts";
5
+ import { setSyncTargetMode } from "#app/services/set.ts";
6
+
7
+ export default class SyncSet extends Command {
8
+ public static override summary =
9
+ "Set sync mode for a tracked directory root, child file, or child subtree";
10
+
11
+ public static override args = {
12
+ state: Args.string({
13
+ description: "Mode to apply: normal, secret, or ignore",
14
+ options: ["normal", "secret", "ignore"],
15
+ required: true,
16
+ }),
17
+ target: Args.string({
18
+ description:
19
+ "Tracked local path (including cwd-relative) or repository path inside a tracked directory",
20
+ required: true,
21
+ }),
22
+ };
23
+
24
+ public static override flags = {
25
+ recursive: Flags.boolean({
26
+ default: false,
27
+ description:
28
+ "Apply the mode to a directory subtree or update a tracked directory root default",
29
+ }),
30
+ };
31
+
32
+ public override async run(): Promise<void> {
33
+ const { args, flags } = await this.parse(SyncSet);
34
+ const output = formatSyncSetResult(
35
+ await setSyncTargetMode(
36
+ {
37
+ recursive: flags.recursive,
38
+ state: args.state as "ignore" | "normal" | "secret",
39
+ target: args.target,
40
+ },
41
+ createSyncContext(),
42
+ ),
43
+ );
44
+
45
+ process.stdout.write(output);
46
+ }
47
+ }