@kodrunhq/claudefy 1.1.2 → 1.2.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
@@ -7,11 +7,36 @@ Sync your Claude Code environment across machines.
7
7
  claudefy syncs your `~/.claude` directory (commands, skills, agents, hooks, rules, plans, plugins, settings, and project configs) across multiple machines using a private git repository as the backend. It handles:
8
8
 
9
9
  - **Selective sync** — three-tier filter (allow/deny/unknown) controls what syncs
10
- - **Encryption** — sensitive and unknown files encrypted with age before push
10
+ - **Encryption** — sensitive and unknown files encrypted with AES-256-SIV before push
11
11
  - **Path remapping** — machine-specific paths normalized to canonical IDs
12
12
  - **Deep merge** — settings.json merged at the key level; other files use last-write-wins
13
13
  - **Override** — wipe remote and push local as source of truth when needed
14
14
 
15
+ ## Architecture
16
+
17
+ claudefy uses a private git repository as the sync backend, with per-machine branches to avoid conflicts.
18
+
19
+ **Branch model:**
20
+ - Each machine syncs to its own branch: `machines/<machineId>`
21
+ - `main` holds the merged state from all machines
22
+
23
+ **Push (SessionEnd):**
24
+ Commits local `~/.claude` changes to the machine branch, merges into `main`, and pushes both.
25
+
26
+ **Pull (SessionStart):**
27
+ Fetches `main`, merges into the machine branch, decrypts, remaps paths, and applies to `~/.claude`.
28
+
29
+ **Override:**
30
+ Wipes `main` and force-updates it to match your machine's current state. Useful when one machine should become the canonical source of truth.
31
+
32
+ ## What Gets Synced
33
+
34
+ | Category | Items | Notes |
35
+ |----------|-------|-------|
36
+ | **Synced** | commands, agents, skills, hooks, rules, plans, plugins, agent-memory, projects, settings.json, history.jsonl, package.json | Core config that should travel with you |
37
+ | **Never synced** | cache, backups, file-history, shell-snapshots, paste-cache, session-env, tasks, .credentials.json | Machine-local or sensitive data |
38
+ | **Unknown** | Anything not in either list | Encrypted by default before commit |
39
+
15
40
  ## Install
16
41
 
17
42
  ```bash
@@ -95,7 +120,14 @@ Pass `--hooks` to `init` or `join` to install auto-sync hooks automatically.
95
120
 
96
121
  ## Encryption
97
122
 
98
- claudefy encrypts files using [age](https://age-encryption.org/) (WASM-based, no native binary needed).
123
+ claudefy encrypts files using AES-256-SIV deterministic encryption via `@noble/ciphers`.
124
+
125
+ **Why deterministic?** Same plaintext always produces the same ciphertext. This means unchanged files produce no git diff, and git's merge machinery can work with encrypted content.
126
+
127
+ **How different file types are handled:**
128
+ - **Session transcripts (.jsonl):** encrypted per-line, so git can diff and merge individual lines
129
+ - **Other files:** encrypted as a whole file; deterministic output avoids spurious diffs
130
+ - **Size overhead:** ~38% from base64 encoding
99
131
 
100
132
  **Passphrase resolution order:**
101
133
  1. `--passphrase` CLI flag (highest priority; avoid — visible in process list)
@@ -135,13 +167,23 @@ claudefy config set encryption.enabled false
135
167
  claudefy config set encryption.useKeychain true
136
168
  ```
137
169
 
138
- ## Security
170
+ ## Security Model
139
171
 
172
+ - `.credentials.json` is never synced (hardcoded in the deny list)
173
+ - All hooks from remote are stripped on pull to prevent code injection
174
+ - Secret scanner detects common patterns (API keys, tokens, high-entropy strings) before push — not exhaustive, but catches the obvious ones
175
+ - Files with detected secrets are encrypted before commit rather than blocking the push
140
176
  - Passphrases never stored in plain text on disk
141
- - Secret scanner detects API keys, tokens, and high-entropy strings before push
142
177
  - Unknown files always encrypted — never pushed in cleartext
143
178
  - `--passphrase` CLI flag warns about process list exposure
144
179
 
180
+ ## Multi-Machine Workflow
181
+
182
+ 1. **First machine:** `claudefy init --backend <url> --hooks` — creates the store and installs auto-sync hooks
183
+ 2. **Other machines:** `claudefy join --backend <url> --hooks` — clones the store, registers the machine, pulls config, installs hooks
184
+ 3. **Automatic sync:** with hooks installed, `pull` runs on SessionStart and `push` runs on SessionEnd — no manual steps needed
185
+ 4. **Canonical override:** if one machine has the "right" config, run `claudefy override --confirm` to wipe main and push that machine's state as the source of truth
186
+
145
187
  ## License
146
188
 
147
189
  MIT
@@ -60,6 +60,7 @@ export class InitCommand {
60
60
  // 2. Initialize git store
61
61
  const gitAdapter = new GitAdapter(join(this.homeDir, ".claudefy"));
62
62
  await gitAdapter.initStore(backend);
63
+ await gitAdapter.ensureMachineBranch(config.machineId);
63
64
  // 3. Write .gitattributes for LFS tracking of large session files
64
65
  await writeFile(join(gitAdapter.getStorePath(), ".gitattributes"), LFS_GITATTRIBUTES);
65
66
  // 4. Run initial push
@@ -1 +1 @@
1
- {"version":3,"file":"init.js","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"init.js","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"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"}
@@ -24,17 +24,21 @@ export class JoinCommand {
24
24
  // 2. Initialize git store and pull
25
25
  const gitAdapter = new GitAdapter(join(this.homeDir, ".claudefy"));
26
26
  await gitAdapter.initStore(options.backend);
27
- // 3. Register this machine and commit
28
- const registry = new MachineRegistry(join(gitAdapter.getStorePath(), "manifest.json"));
29
- await registry.register(config.machineId, hostname(), platform());
30
- await gitAdapter.commitAndPush(`sync: ${config.machineId} joined`);
31
- // 4. Run pull to get remote config
27
+ await gitAdapter.ensureMachineBranch(config.machineId);
28
+ // 3. Run pull to get remote config
32
29
  const pullCommand = new PullCommand(this.homeDir);
33
30
  await pullCommand.execute({
34
31
  quiet: options.quiet,
35
32
  skipEncryption: options.skipEncryption,
36
33
  passphrase: options.passphrase,
37
34
  });
35
+ // 4. Register this machine and commit
36
+ const registry = new MachineRegistry(join(gitAdapter.getStorePath(), "manifest.json"));
37
+ await registry.register(config.machineId, hostname(), platform());
38
+ const commitResult = await gitAdapter.commitAndPush(`sync: ${config.machineId} joined`, config.machineId);
39
+ if (!commitResult.pushed && !options.quiet) {
40
+ output.warn("Machine registry update could not be pushed to the remote. Check your connection and try 'claudefy push'.");
41
+ }
38
42
  // 5. Install hooks if requested
39
43
  if (options.installHooks) {
40
44
  const hookManager = new HookManager(join(this.homeDir, ".claude", "settings.json"));
@@ -1 +1 @@
1
- {"version":3,"file":"join.js","sourceRoot":"","sources":["../../src/commands/join.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAC;AAC3D,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,WAAW,EAAE,MAAM,iCAAiC,CAAC;AAC9D,OAAO,EAAE,eAAe,EAAE,MAAM,yCAAyC,CAAC;AAC1E,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAUtC,MAAM,OAAO,WAAW;IACd,OAAO,CAAS;IAExB,YAAY,OAAe;QACzB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IACzB,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,OAAoB;QAChC,MAAM,aAAa,GAAG,IAAI,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAEtD,IAAI,aAAa,CAAC,aAAa,EAAE,EAAE,CAAC;YAClC,MAAM,IAAI,KAAK,CAAC,+DAA+D,CAAC,CAAC;QACnF,CAAC;QAED,uBAAuB;QACvB,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAE/D,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;YACnB,MAAM,CAAC,IAAI,CAAC,gCAAgC,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC;QAClE,CAAC;QAED,mCAAmC;QACnC,MAAM,UAAU,GAAG,IAAI,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC;QACnE,MAAM,UAAU,CAAC,SAAS,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAE5C,sCAAsC;QACtC,MAAM,QAAQ,GAAG,IAAI,eAAe,CAAC,IAAI,CAAC,UAAU,CAAC,YAAY,EAAE,EAAE,eAAe,CAAC,CAAC,CAAC;QACvF,MAAM,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,SAAS,EAAE,QAAQ,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAC;QAClE,MAAM,UAAU,CAAC,aAAa,CAAC,SAAS,MAAM,CAAC,SAAS,SAAS,CAAC,CAAC;QAEnE,mCAAmC;QACnC,MAAM,WAAW,GAAG,IAAI,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAClD,MAAM,WAAW,CAAC,OAAO,CAAC;YACxB,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,cAAc,EAAE,OAAO,CAAC,cAAc;YACtC,UAAU,EAAE,OAAO,CAAC,UAAU;SAC/B,CAAC,CAAC;QAEH,gCAAgC;QAChC,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC;YACzB,MAAM,WAAW,GAAG,IAAI,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,SAAS,EAAE,eAAe,CAAC,CAAC,CAAC;YACpF,MAAM,WAAW,CAAC,OAAO,EAAE,CAAC;YAC5B,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;gBACnB,MAAM,CAAC,IAAI,CAAC,4BAA4B,CAAC,CAAC;YAC5C,CAAC;QACH,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;YACnB,MAAM,CAAC,OAAO,CAAC,gEAAgE,CAAC,CAAC;QACnF,CAAC;IACH,CAAC;CACF"}
1
+ {"version":3,"file":"join.js","sourceRoot":"","sources":["../../src/commands/join.ts"],"names":[],"mappings":"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"}
@@ -21,6 +21,7 @@ export class OverrideCommand {
21
21
  // 1. Initialize git adapter
22
22
  const gitAdapter = new GitAdapter(join(this.homeDir, ".claudefy"));
23
23
  await gitAdapter.initStore(config.backend.url);
24
+ await gitAdapter.ensureMachineBranch(config.machineId);
24
25
  try {
25
26
  await gitAdapter.pull();
26
27
  }
@@ -1 +1 @@
1
- {"version":3,"file":"override.js","sourceRoot":"","sources":["../../src/commands/override.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAC;AAC3D,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAStC,MAAM,OAAO,eAAe;IAClB,OAAO,CAAS;IAChB,aAAa,CAAgB;IAErC,YAAY,OAAe;QACzB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,aAAa,GAAG,IAAI,aAAa,CAAC,OAAO,CAAC,CAAC;IAClD,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,OAAwB;QACpC,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;YACrB,MAAM,IAAI,KAAK,CACb,0GAA0G,CAC3G,CAAC;QACJ,CAAC;QAED,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC,IAAI,EAAE,CAAC;QAE/C,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;YACnB,MAAM,CAAC,IAAI,CAAC,uCAAuC,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC;QACzE,CAAC;QAED,4BAA4B;QAC5B,MAAM,UAAU,GAAG,IAAI,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC;QACnE,MAAM,UAAU,CAAC,SAAS,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC/C,IAAI,CAAC;YACH,MAAM,UAAU,CAAC,IAAI,EAAE,CAAC;QAC1B,CAAC;QAAC,MAAM,CAAC;YACP,yCAAyC;QAC3C,CAAC;QAED,2CAA2C;QAC3C,MAAM,UAAU,CAAC,WAAW,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAE/C,gDAAgD;QAChD,MAAM,WAAW,GAAG,IAAI,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAClD,MAAM,WAAW,CAAC,OAAO,CAAC;YACxB,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,cAAc,EAAE,OAAO,CAAC,cAAc;YACtC,UAAU,EAAE,OAAO,CAAC,UAAU;SAC/B,CAAC,CAAC;QAEH,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;YACnB,MAAM,CAAC,OAAO,CACZ,8EAA8E,CAC/E,CAAC;QACJ,CAAC;IACH,CAAC;CACF"}
1
+ {"version":3,"file":"override.js","sourceRoot":"","sources":["../../src/commands/override.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAC;AAC3D,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAStC,MAAM,OAAO,eAAe;IAClB,OAAO,CAAS;IAChB,aAAa,CAAgB;IAErC,YAAY,OAAe;QACzB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,aAAa,GAAG,IAAI,aAAa,CAAC,OAAO,CAAC,CAAC;IAClD,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,OAAwB;QACpC,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;YACrB,MAAM,IAAI,KAAK,CACb,0GAA0G,CAC3G,CAAC;QACJ,CAAC;QAED,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC,IAAI,EAAE,CAAC;QAE/C,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;YACnB,MAAM,CAAC,IAAI,CAAC,uCAAuC,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC;QACzE,CAAC;QAED,4BAA4B;QAC5B,MAAM,UAAU,GAAG,IAAI,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC;QACnE,MAAM,UAAU,CAAC,SAAS,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC/C,MAAM,UAAU,CAAC,mBAAmB,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QACvD,IAAI,CAAC;YACH,MAAM,UAAU,CAAC,IAAI,EAAE,CAAC;QAC1B,CAAC;QAAC,MAAM,CAAC;YACP,yCAAyC;QAC3C,CAAC;QAED,2CAA2C;QAC3C,MAAM,UAAU,CAAC,WAAW,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAE/C,gDAAgD;QAChD,MAAM,WAAW,GAAG,IAAI,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAClD,MAAM,WAAW,CAAC,OAAO,CAAC;YACxB,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,cAAc,EAAE,OAAO,CAAC,cAAc;YACtC,UAAU,EAAE,OAAO,CAAC,UAAU;SAC/B,CAAC,CAAC;QAEH,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;YACnB,MAAM,CAAC,OAAO,CACZ,8EAA8E,CAC/E,CAAC;QACJ,CAAC;IACH,CAAC;CACF"}
@@ -14,4 +14,12 @@ export declare class PullCommand {
14
14
  private configManager;
15
15
  constructor(homeDir: string);
16
16
  execute(options: PullOptions): Promise<PullResult>;
17
+ /**
18
+ * Check for an override marker. First checks current branch, then checks
19
+ * the main branch (override markers may not survive merge into machine branch
20
+ * due to conflicts from wipeAndPush).
21
+ */
22
+ private checkOverrideOnMain;
23
+ private collectAgeFiles;
24
+ private walkAgeFiles;
17
25
  }
@@ -1,10 +1,9 @@
1
1
  import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
- import { join, resolve } from "node:path";
2
+ import { join, relative, resolve } from "node:path";
3
3
  import { existsSync } from "node:fs";
4
4
  import { ConfigManager } from "../config/config-manager.js";
5
5
  import { GitAdapter } from "../git-adapter/git-adapter.js";
6
6
  import { PathMapper } from "../path-mapper/path-mapper.js";
7
- import { MachineRegistry } from "../machine-registry/machine-registry.js";
8
7
  import { Encryptor } from "../encryptor/encryptor.js";
9
8
  import { Merger } from "../merger/merger.js";
10
9
  import { BackupManager } from "../backup-manager/backup-manager.js";
@@ -21,22 +20,21 @@ export class PullCommand {
21
20
  async execute(options) {
22
21
  const config = await this.configManager.load();
23
22
  const result = { overrideDetected: false, filesUpdated: 0 };
24
- // 1. Initialize git adapter and pull
23
+ // 1. Initialize git adapter, switch to machine branch, pull & merge main
25
24
  const claudefyDir = join(this.homeDir, ".claudefy");
26
25
  const gitAdapter = new GitAdapter(claudefyDir);
27
26
  await gitAdapter.initStore(config.backend.url);
27
+ await gitAdapter.ensureMachineBranch(config.machineId);
28
28
  try {
29
- await gitAdapter.pull();
29
+ await gitAdapter.pullAndMergeMain();
30
30
  }
31
31
  catch {
32
32
  // Fresh store with no remote history yet
33
33
  }
34
34
  const storePath = gitAdapter.getStorePath();
35
- const configDir = join(storePath, "config");
36
- const unknownDir = join(storePath, "unknown");
37
35
  const resolvedClaudeDir = resolve(this.claudeDir);
38
- // 2. Check for override marker
39
- const override = await gitAdapter.checkOverrideMarker();
36
+ // 2. Check for override marker (check on main branch where overrides are written)
37
+ const override = await this.checkOverrideOnMain(gitAdapter);
40
38
  if (override) {
41
39
  result.overrideDetected = true;
42
40
  if (!options.quiet) {
@@ -50,171 +48,227 @@ export class PullCommand {
50
48
  if (!options.quiet && result.backupPath) {
51
49
  output.info(`Backup created at: ${result.backupPath}`);
52
50
  }
53
- // Remove override marker
51
+ // Reset machine branch to main so we apply the override content
52
+ const { simpleGit: sg } = await import("simple-git");
53
+ const git = sg(storePath);
54
+ await git.reset(["--hard", "main"]);
55
+ // Remove override marker and commit the acknowledgement locally.
56
+ // The commit will be pushed on the next normal push.
54
57
  await gitAdapter.removeOverrideMarker();
55
- await gitAdapter.commitAndPush("pull: acknowledge override");
56
- }
57
- // 3. Decrypt .age files if encryption is enabled
58
- if (config.encryption.enabled && !options.skipEncryption) {
59
- if (!options.passphrase) {
60
- throw new Error("Encryption is enabled but no passphrase found. Set CLAUDEFY_PASSPHRASE or store it in your OS keychain via 'claudefy init'.");
58
+ const { simpleGit: sgCommit } = await import("simple-git");
59
+ const gitCommit = sgCommit(storePath);
60
+ await gitCommit.add(["."]);
61
+ const status = await gitCommit.status();
62
+ if (!status.isClean()) {
63
+ await gitCommit.commit("acknowledge override marker removal");
61
64
  }
62
- const encryptor = new Encryptor(options.passphrase);
63
- // Decrypt sensitive config files
64
- const filesToDecrypt = ["settings.json.age", "history.jsonl.age"];
65
- for (const fileName of filesToDecrypt) {
66
- const filePath = join(configDir, fileName);
67
- if (existsSync(filePath)) {
68
- const outputPath = filePath.replace(/\.age$/, "");
69
- await encryptor.decryptFile(filePath, outputPath);
70
- await rm(filePath);
65
+ }
66
+ // 3. Create temp working directory
67
+ const tmpDir = join(claudefyDir, ".pull-tmp");
68
+ if (existsSync(tmpDir))
69
+ await rm(tmpDir, { recursive: true });
70
+ await mkdir(tmpDir, { recursive: true });
71
+ try {
72
+ // 4. Copy store to temp dir
73
+ const storeConfigDir = join(storePath, "config");
74
+ const storeUnknownDir = join(storePath, "unknown");
75
+ const tmpConfigDir = join(tmpDir, "config");
76
+ const tmpUnknownDir = join(tmpDir, "unknown");
77
+ if (existsSync(storeConfigDir))
78
+ await cp(storeConfigDir, tmpConfigDir, { recursive: true });
79
+ if (existsSync(storeUnknownDir))
80
+ await cp(storeUnknownDir, tmpUnknownDir, { recursive: true });
81
+ // 5. Decrypt any .age files in temp dir
82
+ const encryptedFiles = await this.collectAgeFiles(tmpConfigDir, tmpUnknownDir);
83
+ if (encryptedFiles.length > 0 && !options.skipEncryption) {
84
+ if (!options.passphrase) {
85
+ throw new Error("Encrypted files found but no passphrase available. Set CLAUDEFY_PASSPHRASE or store it in your OS keychain via 'claudefy init'.");
86
+ }
87
+ const encryptor = new Encryptor(options.passphrase);
88
+ if (existsSync(tmpConfigDir)) {
89
+ await encryptor.decryptDirectory(tmpConfigDir);
90
+ }
91
+ if (existsSync(tmpUnknownDir)) {
92
+ await encryptor.decryptDirectory(tmpUnknownDir);
71
93
  }
72
94
  }
73
- // Decrypt all .age files in unknown/
74
- if (existsSync(unknownDir)) {
75
- await encryptor.decryptDirectory(unknownDir);
95
+ // 6. Remap paths (canonical -> local) in temp dir
96
+ const links = await this.configManager.getLinks();
97
+ const pathMapper = new PathMapper(links);
98
+ // settings.json
99
+ const remoteSettingsPath = join(tmpConfigDir, "settings.json");
100
+ if (existsSync(remoteSettingsPath)) {
101
+ const settings = JSON.parse(await readFile(remoteSettingsPath, "utf-8"));
102
+ const remapped = pathMapper.remapSettingsPaths(settings, this.claudeDir);
103
+ await writeFile(remoteSettingsPath, JSON.stringify(remapped, null, 2));
76
104
  }
77
- }
78
- // 4. Remap paths (canonical local)
79
- const links = await this.configManager.getLinks();
80
- const pathMapper = new PathMapper(links);
81
- // settings.json
82
- const remoteSettingsPath = join(configDir, "settings.json");
83
- if (existsSync(remoteSettingsPath)) {
84
- const settings = JSON.parse(await readFile(remoteSettingsPath, "utf-8"));
85
- const remapped = pathMapper.remapSettingsPaths(settings, this.claudeDir);
86
- await writeFile(remoteSettingsPath, JSON.stringify(remapped, null, 2));
87
- }
88
- // installed_plugins.json
89
- const pluginsJsonPath = join(configDir, "plugins", "installed_plugins.json");
90
- if (existsSync(pluginsJsonPath)) {
91
- const plugins = JSON.parse(await readFile(pluginsJsonPath, "utf-8"));
92
- const remapped = pathMapper.remapPluginPaths(plugins, this.claudeDir);
93
- await writeFile(pluginsJsonPath, JSON.stringify(remapped, null, 2));
94
- }
95
- // known_marketplaces.json
96
- const marketplacesPath = join(configDir, "plugins", "known_marketplaces.json");
97
- if (existsSync(marketplacesPath)) {
98
- const mp = JSON.parse(await readFile(marketplacesPath, "utf-8"));
99
- const remapped = pathMapper.remapPluginPaths(mp, this.claudeDir);
100
- await writeFile(marketplacesPath, JSON.stringify(remapped, null, 2));
101
- }
102
- // history.jsonl
103
- const historyPath = join(configDir, "history.jsonl");
104
- if (existsSync(historyPath)) {
105
- const content = await readFile(historyPath, "utf-8");
106
- const remapped = content
107
- .split("\n")
108
- .filter(Boolean)
109
- .map((line) => pathMapper.remapJsonlLine(line))
110
- .join("\n") + "\n";
111
- await writeFile(historyPath, remapped);
112
- }
113
- // projects/ directory renaming (canonical → local)
114
- const projectsDir = join(configDir, "projects");
115
- if (existsSync(projectsDir)) {
116
- const projectDirs = await readdir(projectsDir);
117
- for (const dirName of projectDirs) {
118
- const localName = pathMapper.remapDirName(dirName);
119
- if (localName) {
120
- const destPath = resolve(join(projectsDir, localName));
121
- // Path containment: ensure renamed dir stays within projects/
122
- if (!destPath.startsWith(resolve(projectsDir) + "/")) {
123
- console.warn(`Skipping directory rename "${dirName}" → "${localName}": path escapes projects directory`);
124
- continue;
125
- }
126
- await rename(join(projectsDir, dirName), destPath);
127
- }
105
+ // installed_plugins.json
106
+ const pluginsJsonPath = join(tmpConfigDir, "plugins", "installed_plugins.json");
107
+ if (existsSync(pluginsJsonPath)) {
108
+ const plugins = JSON.parse(await readFile(pluginsJsonPath, "utf-8"));
109
+ const remapped = pathMapper.remapPluginPaths(plugins, this.claudeDir);
110
+ await writeFile(pluginsJsonPath, JSON.stringify(remapped, null, 2));
128
111
  }
129
- }
130
- // 5. Merge and copy to ~/.claude
131
- const merger = new Merger();
132
- await mkdir(this.claudeDir, { recursive: true });
133
- // 5a. Deep merge settings.json
134
- if (existsSync(remoteSettingsPath)) {
135
- const localSettingsPath = join(this.claudeDir, "settings.json");
136
- const remoteSettings = JSON.parse(await readFile(remoteSettingsPath, "utf-8"));
137
- // Security: strip hooks from remote settings to prevent remote hook injection.
138
- // Local hooks should never be overwritten by remote data, as an attacker with
139
- // store access could inject arbitrary shell commands via SessionStart/SessionEnd hooks.
140
- delete remoteSettings.hooks;
141
- if (existsSync(localSettingsPath) && !result.overrideDetected) {
142
- const localSettings = JSON.parse(await readFile(localSettingsPath, "utf-8"));
143
- const merged = merger.deepMergeJson(localSettings, remoteSettings);
144
- await writeFile(localSettingsPath, JSON.stringify(merged, null, 2));
112
+ // known_marketplaces.json
113
+ const marketplacesPath = join(tmpConfigDir, "plugins", "known_marketplaces.json");
114
+ if (existsSync(marketplacesPath)) {
115
+ const mp = JSON.parse(await readFile(marketplacesPath, "utf-8"));
116
+ const remapped = pathMapper.remapPluginPaths(mp, this.claudeDir);
117
+ await writeFile(marketplacesPath, JSON.stringify(remapped, null, 2));
145
118
  }
146
- else {
147
- await writeFile(localSettingsPath, JSON.stringify(remoteSettings, null, 2));
119
+ // history.jsonl
120
+ const historyPath = join(tmpConfigDir, "history.jsonl");
121
+ if (existsSync(historyPath)) {
122
+ const content = await readFile(historyPath, "utf-8");
123
+ const remapped = content
124
+ .split("\n")
125
+ .filter(Boolean)
126
+ .map((line) => pathMapper.remapJsonlLine(line))
127
+ .join("\n") + "\n";
128
+ await writeFile(historyPath, remapped);
148
129
  }
149
- result.filesUpdated++;
150
- }
151
- // 5b. Copy remaining config items (remote overwrites local)
152
- if (existsSync(configDir)) {
153
- const entries = await readdir(configDir, { withFileTypes: true });
154
- for (const entry of entries) {
155
- if (entry.name === "settings.json")
156
- continue; // Already handled
157
- // Security: skip symlinks to prevent path traversal attacks
158
- if (entry.isSymbolicLink()) {
159
- console.warn(`Skipping symlink in config store: ${entry.name}`);
160
- continue;
161
- }
162
- const src = join(configDir, entry.name);
163
- const dest = resolve(join(this.claudeDir, entry.name));
164
- // Path containment: ensure destination stays within ~/.claude/
165
- if (!dest.startsWith(resolvedClaudeDir + "/") && dest !== resolvedClaudeDir) {
166
- console.warn(`Skipping "${entry.name}": resolved path escapes ~/.claude/`);
167
- continue;
130
+ // projects/ directory renaming (canonical -> local)
131
+ const projectsDir = join(tmpConfigDir, "projects");
132
+ if (existsSync(projectsDir)) {
133
+ const projectDirs = await readdir(projectsDir);
134
+ for (const dirName of projectDirs) {
135
+ const localName = pathMapper.remapDirName(dirName);
136
+ if (localName) {
137
+ const destPath = resolve(join(projectsDir, localName));
138
+ // Path containment: ensure renamed dir stays within projects/
139
+ const rel = relative(resolve(projectsDir), destPath);
140
+ if (rel.startsWith("..") || resolve(destPath) === resolve(projectsDir)) {
141
+ output.warn(`Skipping directory rename "${dirName}" -> "${localName}": path escapes projects directory`);
142
+ continue;
143
+ }
144
+ await rename(join(projectsDir, dirName), destPath);
145
+ }
168
146
  }
169
- await cp(src, dest, { recursive: true, force: true });
170
- result.filesUpdated++;
171
147
  }
172
- }
173
- // 5c. Copy unknown items back
174
- if (existsSync(unknownDir)) {
175
- const entries = await readdir(unknownDir, { withFileTypes: true });
176
- for (const entry of entries) {
177
- // Security: skip symlinks to prevent path traversal attacks
178
- if (entry.isSymbolicLink()) {
179
- console.warn(`Skipping symlink in unknown store: ${entry.name}`);
180
- continue;
148
+ // 7. Merge and copy to ~/.claude
149
+ const merger = new Merger();
150
+ await mkdir(this.claudeDir, { recursive: true });
151
+ // 7a. Deep merge settings.json with hook filtering
152
+ if (existsSync(remoteSettingsPath)) {
153
+ const localSettingsPath = join(this.claudeDir, "settings.json");
154
+ const remoteSettings = JSON.parse(await readFile(remoteSettingsPath, "utf-8"));
155
+ // Security: strip all hooks from remote to prevent code injection
156
+ if (remoteSettings && typeof remoteSettings === "object" && "hooks" in remoteSettings) {
157
+ delete remoteSettings.hooks;
158
+ }
159
+ if (existsSync(localSettingsPath) && !result.overrideDetected) {
160
+ const localSettings = JSON.parse(await readFile(localSettingsPath, "utf-8"));
161
+ const merged = merger.deepMergeJson(localSettings, remoteSettings);
162
+ await writeFile(localSettingsPath, JSON.stringify(merged, null, 2));
181
163
  }
182
- const src = join(unknownDir, entry.name);
183
- const dest = resolve(join(this.claudeDir, entry.name));
184
- // Path containment: ensure destination stays within ~/.claude/
185
- if (!dest.startsWith(resolvedClaudeDir + "/") && dest !== resolvedClaudeDir) {
186
- console.warn(`Skipping "${entry.name}": resolved path escapes ~/.claude/`);
187
- continue;
164
+ else {
165
+ await writeFile(localSettingsPath, JSON.stringify(remoteSettings, null, 2));
188
166
  }
189
- await cp(src, dest, { recursive: true, force: true });
190
167
  result.filesUpdated++;
191
168
  }
192
- }
193
- // 6. Re-encrypt decrypted files in the store before committing,
194
- // so plaintext is never committed to git history.
195
- if (config.encryption.enabled && !options.skipEncryption) {
196
- const encryptor = new Encryptor(options.passphrase);
197
- const filesToReencrypt = ["settings.json", "history.jsonl"];
198
- for (const fileName of filesToReencrypt) {
199
- const filePath = join(configDir, fileName);
200
- if (existsSync(filePath)) {
201
- await encryptor.encryptFile(filePath, filePath + ".age");
202
- await rm(filePath);
169
+ // 7b. Copy remaining config items (remote overwrites local)
170
+ if (existsSync(tmpConfigDir)) {
171
+ const entries = await readdir(tmpConfigDir, { withFileTypes: true });
172
+ for (const entry of entries) {
173
+ if (entry.name === "settings.json")
174
+ continue; // Already handled
175
+ // Security: skip symlinks to prevent path traversal attacks
176
+ if (entry.isSymbolicLink()) {
177
+ output.warn(`Skipping symlink in config store: ${entry.name}`);
178
+ continue;
179
+ }
180
+ const src = join(tmpConfigDir, entry.name);
181
+ const dest = resolve(join(this.claudeDir, entry.name));
182
+ // Path containment: ensure destination stays within ~/.claude/
183
+ const relPath = relative(resolvedClaudeDir, dest);
184
+ if (relPath.startsWith("..") || resolve(dest) === resolvedClaudeDir) {
185
+ output.warn(`Skipping "${entry.name}": resolved path escapes ~/.claude/`);
186
+ continue;
187
+ }
188
+ await cp(src, dest, { recursive: true, force: true });
189
+ result.filesUpdated++;
203
190
  }
204
191
  }
205
- // Re-encrypt all plaintext files in unknown/
206
- if (existsSync(unknownDir)) {
207
- await encryptor.encryptDirectory(unknownDir);
192
+ // 7c. Copy unknown items back
193
+ if (existsSync(tmpUnknownDir)) {
194
+ const entries = await readdir(tmpUnknownDir, { withFileTypes: true });
195
+ for (const entry of entries) {
196
+ // Security: skip symlinks to prevent path traversal attacks
197
+ if (entry.isSymbolicLink()) {
198
+ output.warn(`Skipping symlink in unknown store: ${entry.name}`);
199
+ continue;
200
+ }
201
+ const src = join(tmpUnknownDir, entry.name);
202
+ const dest = resolve(join(this.claudeDir, entry.name));
203
+ // Path containment: ensure destination stays within ~/.claude/
204
+ const relPath = relative(resolvedClaudeDir, dest);
205
+ if (relPath.startsWith("..") || resolve(dest) === resolvedClaudeDir) {
206
+ output.warn(`Skipping "${entry.name}": resolved path escapes ~/.claude/`);
207
+ continue;
208
+ }
209
+ await cp(src, dest, { recursive: true, force: true });
210
+ result.filesUpdated++;
211
+ }
208
212
  }
209
213
  }
210
- // 7. Update machine registry last sync time and commit
211
- const registry = new MachineRegistry(join(storePath, "manifest.json"));
212
- await registry.updateLastSync(config.machineId);
213
- await gitAdapter.commitAndPush(`sync: pull on ${config.machineId}`);
214
+ finally {
215
+ if (existsSync(tmpDir))
216
+ await rm(tmpDir, { recursive: true });
217
+ }
218
+ // NO re-encryption step (store is never modified)
219
+ // NO commitAndPush (pull should not create commits)
214
220
  if (!options.quiet) {
215
221
  output.success(`Pull complete. ${result.filesUpdated} items updated.`);
216
222
  }
217
223
  return result;
218
224
  }
225
+ /**
226
+ * Check for an override marker. First checks current branch, then checks
227
+ * the main branch (override markers may not survive merge into machine branch
228
+ * due to conflicts from wipeAndPush).
229
+ */
230
+ async checkOverrideOnMain(gitAdapter) {
231
+ // First check on current branch (works when merge succeeded)
232
+ const override = await gitAdapter.checkOverrideMarker();
233
+ if (override)
234
+ return override;
235
+ // If not found, check on main branch by temporarily switching
236
+ const currentBranch = await gitAdapter.getCurrentBranch();
237
+ if (currentBranch === "main")
238
+ return null;
239
+ const storePath = gitAdapter.getStorePath();
240
+ try {
241
+ const { simpleGit } = await import("simple-git");
242
+ const git = simpleGit(storePath);
243
+ // Use git show to read .override from main without switching branches
244
+ const content = await git.show(["main:.override"]);
245
+ const marker = JSON.parse(content);
246
+ return { machine: marker.machine, timestamp: marker.timestamp };
247
+ }
248
+ catch {
249
+ return null;
250
+ }
251
+ }
252
+ async collectAgeFiles(...dirs) {
253
+ const results = [];
254
+ for (const dir of dirs) {
255
+ if (!existsSync(dir))
256
+ continue;
257
+ await this.walkAgeFiles(dir, results);
258
+ }
259
+ return results;
260
+ }
261
+ async walkAgeFiles(dirPath, results) {
262
+ const entries = await readdir(dirPath, { withFileTypes: true });
263
+ for (const entry of entries) {
264
+ const fullPath = join(dirPath, entry.name);
265
+ if (entry.isDirectory()) {
266
+ await this.walkAgeFiles(fullPath, results);
267
+ }
268
+ else if (entry.name.endsWith(".age")) {
269
+ results.push(fullPath);
270
+ }
271
+ }
272
+ }
219
273
  }
220
274
  //# sourceMappingURL=pull.js.map