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 +14 -0
- package/package.json +2 -1
- package/src/diff-lines.ts +43 -0
- package/src/preview.ts +17 -36
- package/src/remap.ts +62 -15
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.
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
107
|
-
*
|
|
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`.
|
|
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 =
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
}
|