claude-nomad 0.26.0 → 0.26.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.26.1](https://github.com/funkadelic/claude-nomad/compare/v0.26.0...v0.26.1) (2026-05-27)
4
+
5
+
6
+ ### Fixed
7
+
8
+ * **preview:** replace diffJsonStrings parallel walk with jsdiff LCS line diff ([#159](https://github.com/funkadelic/claude-nomad/issues/159)) ([d9c0dca](https://github.com/funkadelic/claude-nomad/commit/d9c0dcae7b417d069e826ce3b825e9f23115ba2d))
9
+ * **remap:** fail closed on path-map collisions in remapPush before any write ([#156](https://github.com/funkadelic/claude-nomad/issues/156)) ([db33157](https://github.com/funkadelic/claude-nomad/commit/db33157faa02b5439278ae4e92a02bd671faee72))
10
+
11
+
12
+ ### Changed
13
+
14
+ * **codeql:** skip analysis on release-please PR branches ([#160](https://github.com/funkadelic/claude-nomad/issues/160)) ([2bfed46](https://github.com/funkadelic/claude-nomad/commit/2bfed463ccd76f6331e5ab775bbd5d19df2898e9))
15
+ * **tests:** skip PR-time test matrix on release-please PR branches ([#158](https://github.com/funkadelic/claude-nomad/issues/158)) ([222e27b](https://github.com/funkadelic/claude-nomad/commit/222e27b2643382136505f72e148c17c7ecb7a08f))
16
+
3
17
  ## [0.26.0](https://github.com/funkadelic/claude-nomad/compare/v0.25.5...v0.26.0) (2026-05-27)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
4
4
  "type": "module",
5
5
  "description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
6
6
  "keywords": [
@@ -80,6 +80,7 @@
80
80
  "vitest": "^4.1.6"
81
81
  },
82
82
  "dependencies": {
83
+ "diff": "^9.0.0",
83
84
  "picocolors": "^1.1.1",
84
85
  "tsx": "^4.22.2"
85
86
  }
@@ -0,0 +1,43 @@
1
+ import { diffLines } from 'diff';
2
+
3
+ import { green, red } from './color.ts';
4
+
5
+ /**
6
+ * Map a jsdiff `diffLines` result for two pre-stringified JSON strings into
7
+ * an array of unified-diff body lines (the two-line header is the caller's
8
+ * responsibility).
9
+ *
10
+ * Each jsdiff part has a `value` that may span multiple lines and may carry a
11
+ * trailing `\n`. The value is split on `\n` and any trailing empty element
12
+ * (produced by the trailing newline) is dropped so that it does not become a
13
+ * spurious blank output line.
14
+ *
15
+ * Line prefix mapping per part type:
16
+ * - context (neither added nor removed): a single leading space then the line
17
+ * - removed (`part.removed === true`): `red('-' + line)`
18
+ * - added (`part.added === true`): `green('+' + line)`
19
+ *
20
+ * Coloring routes through `color.ts` `red`/`green` helpers, so `NO_COLOR` /
21
+ * non-TTY environments degrade to literal `-` / `+` prefixed lines with no
22
+ * ANSI escape sequences. Picocolors owns the detection logic.
23
+ */
24
+ export function diffLinesToUnified(oldStr: string, newStr: string): string[] {
25
+ const parts = diffLines(oldStr, newStr);
26
+ const lines: string[] = [];
27
+ for (const part of parts) {
28
+ const partLines = part.value.split('\n');
29
+ // A part value ending in '\n' yields a trailing '' after split; drop it.
30
+ if (partLines[partLines.length - 1] === '') {
31
+ partLines.pop();
32
+ }
33
+ const prefix = part.removed
34
+ ? (line: string) => red(`-${line}`)
35
+ : part.added
36
+ ? (line: string) => green(`+${line}`)
37
+ : (line: string) => ` ${line}`;
38
+ for (const line of partLines) {
39
+ lines.push(prefix(line));
40
+ }
41
+ }
42
+ return lines;
43
+ }
package/src/preview.ts CHANGED
@@ -1,53 +1,34 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { green, red } from './color.ts';
5
4
  import { CLAUDE_HOME, HOST, REPO_HOME } from './config.ts';
5
+ import { diffLinesToUnified } from './diff-lines.ts';
6
6
  import { applySharedLinks } from './links.ts';
7
7
  import { remapPull } from './remap.ts';
8
8
  import { log } from './utils.ts';
9
9
  import { deepMerge, readJson } from './utils.json.ts';
10
10
 
11
11
  /**
12
- * Minimal in-tree unified-diff helper for two pre-stringified JSON
13
- * documents. Walks the line arrays in parallel and emits a unified-diff
14
- * style output: unchanged lines prefixed with a space, removed lines with
15
- * `-` (red), added lines with `+` (green), plus at most three lines of
16
- * surrounding context per changed block. The implementation is intentionally
17
- * naive (no LCS); for two ~50-line settings JSON inputs the result is
18
- * acceptable even if not optimal.
12
+ * LCS line diff for two pre-stringified JSON documents via jsdiff. Returns a
13
+ * unified-diff style string: the two literal header lines
14
+ * `--- ~/.claude/settings.json` and `+++ would write`, followed by body lines
15
+ * where unchanged lines are prefixed with a space, removed lines with `-`
16
+ * (red), and added lines with `+` (green). Coloring routes through `color.ts`
17
+ * so `NO_COLOR` / non-TTY environments degrade to literal prefixes with no
18
+ * ANSI escape sequences.
19
19
  *
20
- * Returns the empty string when the inputs are byte-identical so the caller
21
- * can suppress the section. Picocolors handles `NO_COLOR` / `FORCE_COLOR`
22
- * detection, so the `red`/`green` wrappers degrade to identity in non-TTY
23
- * environments and the output stays literal `-` / `+` prefixed.
24
- *
25
- * The header line `--- ~/.claude/settings.json` / `+++ would write` is
26
- * literal; callers that want a different header can prepend their own.
20
+ * Returns the empty string when inputs are byte-identical so the caller can
21
+ * suppress the section. jsdiff `diffLines` aligns on the longest common
22
+ * subsequence, so a mid-document insertion does not cascade false `-`/`+`
23
+ * pairs for the unchanged tail.
27
24
  */
28
25
  export function diffJsonStrings(currentJsonText: string, newJsonText: string): string {
29
26
  if (currentJsonText === newJsonText) return '';
30
- const a = currentJsonText.split('\n');
31
- const b = newJsonText.split('\n');
32
- const lines: string[] = [];
33
- lines.push('--- ~/.claude/settings.json');
34
- lines.push('+++ would write');
35
-
36
- // Walk both arrays in parallel. Lines that match index-wise are context;
37
- // others are emitted as -a / +b. A real unified-diff would compute the
38
- // longest common subsequence; this naive walk is good enough for two JSON
39
- // documents pretty-printed at the same indentation level.
40
- const maxLen = Math.max(a.length, b.length);
41
- for (let i = 0; i < maxLen; i++) {
42
- const av = a[i];
43
- const bv = b[i];
44
- if (av === bv) {
45
- if (av !== undefined) lines.push(` ${av}`);
46
- continue;
47
- }
48
- if (av !== undefined) lines.push(red(`-${av}`));
49
- if (bv !== undefined) lines.push(green(`+${bv}`));
50
- }
27
+ const lines: string[] = [
28
+ '--- ~/.claude/settings.json',
29
+ '+++ would write',
30
+ ...diffLinesToUnified(currentJsonText, newJsonText),
31
+ ];
51
32
  return lines.join('\n');
52
33
  }
53
34
 
package/src/remap.ts CHANGED
@@ -2,7 +2,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'no
2
2
  import { join, relative, sep } from 'node:path';
3
3
 
4
4
  import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
5
- import { log } from './utils.ts';
5
+ import { die, log } from './utils.ts';
6
6
  import { backupBeforeWrite, backupRepoWrite } from './utils.fs.ts';
7
7
  import { encodePath, readJson } from './utils.json.ts';
8
8
 
@@ -98,17 +98,67 @@ export function remapPull(ts: string, opts: { dryRun?: boolean } = {}): { unmapp
98
98
  return { unmapped };
99
99
  }
100
100
 
101
+ /**
102
+ * Build the encoded-key to logical-name reverse map for the current host,
103
+ * failing closed on any `path-map.json` shape that would silently lose session
104
+ * data on push. Both failure modes `die()` (throw `NomadFatal`) before the
105
+ * caller writes anything:
106
+ *
107
+ * - Encoded-path collision: two distinct host paths that `encodePath` maps to
108
+ * the same key (every `/` becomes `-`), which would clobber each other under
109
+ * one repo directory.
110
+ * - Duplicate path: two logical names mapping to the same host path, where only
111
+ * one logical could be pushed and the other's `shared/projects/` copy would
112
+ * be orphaned.
113
+ *
114
+ * @param map - the parsed `path-map.json`
115
+ * @returns reverse lookup from encoded local dir name to logical project name
116
+ */
117
+ function buildReverseMap(map: PathMap): Map<string, string> {
118
+ const reverse = new Map<string, string>();
119
+ const encodedPaths = new Map<string, string>();
120
+ for (const [logical, hosts] of Object.entries(map.projects)) {
121
+ const p = hosts[HOST];
122
+ if (!p || p === 'TBD') continue;
123
+ const encoded = encodePath(p);
124
+ const prior = encodedPaths.get(encoded);
125
+ if (prior !== undefined) {
126
+ if (prior !== p) {
127
+ die(
128
+ `encoded-path collision in path-map.json: "${prior}" and "${p}" both encode to` +
129
+ ` "${encoded}" (encodePath replaces every / with -).` +
130
+ ` Edit path-map.json so the two paths do not encode identically.` +
131
+ ` Run nomad doctor for the full list of collisions.`,
132
+ );
133
+ }
134
+ die(
135
+ `duplicate path in path-map.json: logical names "${reverse.get(encoded)}" and "${logical}"` +
136
+ ` both map to "${p}" for ${HOST}, so only one could be pushed and the other's` +
137
+ ` shared/projects/ copy would be orphaned.` +
138
+ ` Edit path-map.json so each host path maps to a single logical name.`,
139
+ );
140
+ }
141
+ encodedPaths.set(encoded, p);
142
+ reverse.set(encoded, logical);
143
+ }
144
+ return reverse;
145
+ }
146
+
101
147
  /**
102
148
  * Push: copy local path-encoded dirs back to repo under logical names.
103
149
  *
104
150
  * Returns `{ unmapped: N, collisions: M }` where `unmapped` is the count of
105
151
  * `~/.claude/projects/<dir>/` entries that have no path-map reverse-lookup
106
- * for this host. `collisions` is reserved for a future slice's path-encoding
107
- * collision detection and is always `0` here.
152
+ * for this host. `collisions` is always `0` on the success path: any
153
+ * `path-map.json` shape that would silently lose data (an encoded-path
154
+ * collision between two distinct host paths, or two logical names mapping to
155
+ * the same host path) makes `buildReverseMap` `die()` (throw `NomadFatal`) to
156
+ * refuse the push before any `shared/projects/` content is written. Detection
157
+ * runs during the reverse-map build, so it fires under `dryRun` too.
108
158
  *
109
159
  * `opts.dryRun` (default `false`): when `true`, log `would push:` lines
110
- * instead of calling `backupRepoWrite` + `copyDir`. Counts are computed
111
- * identically in both modes.
160
+ * instead of calling `backupRepoWrite` + `copyDir`. Collision detection
161
+ * runs identically in both modes.
112
162
  */
113
163
  export function remapPush(
114
164
  ts: string,
@@ -116,7 +166,6 @@ export function remapPush(
116
166
  ): { unmapped: number; collisions: number } {
117
167
  const dryRun = opts.dryRun === true;
118
168
  let unmapped = 0;
119
- const collisions = 0;
120
169
  const mapPath = join(REPO_HOME, 'path-map.json');
121
170
  if (!existsSync(mapPath)) {
122
171
  log('no path-map.json; skipping session export');
@@ -126,16 +175,14 @@ export function remapPush(
126
175
  const map = readJson<PathMap>(mapPath);
127
176
  const localProjects = join(CLAUDE_HOME, 'projects');
128
177
  const repoProjects = join(REPO_HOME, 'shared', 'projects');
129
- if (!dryRun) mkdirSync(repoProjects, { recursive: true });
130
178
 
131
- const reverse = new Map<string, string>();
132
- for (const [logical, hosts] of Object.entries(map.projects)) {
133
- const p = hosts[HOST];
134
- if (!p || p === 'TBD') continue;
135
- reverse.set(encodePath(p), logical);
136
- }
179
+ const reverse = buildReverseMap(map);
180
+ if (!existsSync(localProjects)) return { unmapped, collisions: 0 };
181
+ // Create the repo destination only after collision detection passes and we
182
+ // know there is something to push, so a failing or no-op push is fully
183
+ // side-effect-free (no empty shared/projects/ left behind).
184
+ if (!dryRun) mkdirSync(repoProjects, { recursive: true });
137
185
 
138
- if (!existsSync(localProjects)) return { unmapped, collisions };
139
186
  for (const dir of readdirSync(localProjects)) {
140
187
  const logical = reverse.get(dir);
141
188
  if (!logical) {
@@ -156,5 +203,5 @@ export function remapPush(
156
203
  copyDirJsonlOnly(join(localProjects, dir), repoDst);
157
204
  log(`pushed ${dir} -> ${logical}`);
158
205
  }
159
- return { unmapped, collisions };
206
+ return { unmapped, collisions: 0 };
160
207
  }