claude-nomad 0.17.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/.gitleaks.toml +16 -0
- package/CHANGELOG.md +293 -0
- package/LICENSE +21 -0
- package/README.md +464 -0
- package/package.json +79 -0
- package/shared/.gitignore +8 -0
- package/src/color.ts +81 -0
- package/src/commands.doctor.checks.ts +343 -0
- package/src/commands.doctor.format.ts +68 -0
- package/src/commands.doctor.ts +56 -0
- package/src/commands.doctor.version.ts +190 -0
- package/src/commands.drop-session.ts +173 -0
- package/src/commands.pull.ts +88 -0
- package/src/commands.push.ts +215 -0
- package/src/commands.update.ts +279 -0
- package/src/config.ts +149 -0
- package/src/diff.ts +49 -0
- package/src/init.snapshot.ts +53 -0
- package/src/init.ts +190 -0
- package/src/links.ts +123 -0
- package/src/nomad.ts +227 -0
- package/src/preview.ts +141 -0
- package/src/push-checks.ts +170 -0
- package/src/push-gitleaks.ts +217 -0
- package/src/remap.ts +158 -0
- package/src/resume.ts +159 -0
- package/src/summary.ts +43 -0
- package/src/update.topology.ts +118 -0
- package/src/utils.ts +368 -0
package/src/resume.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
|
|
5
|
+
import { fail, readJson } from './utils.ts';
|
|
6
|
+
|
|
7
|
+
type TranscriptLine = { type?: string; cwd?: string };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read-only sidecar that resolves a session ID to a host-local
|
|
11
|
+
* `cd <abspath> && claude --resume <id>` line, printed to stdout for `eval`.
|
|
12
|
+
*
|
|
13
|
+
* Flow: locate `<id>.jsonl` under `~/.claude/projects/<encoded>/`, extract
|
|
14
|
+
* the first non-file-history-snapshot line's `cwd`, reverse-lookup the
|
|
15
|
+
* logical project name in `path-map.json`, then look up the current host's
|
|
16
|
+
* abspath for that logical project.
|
|
17
|
+
*
|
|
18
|
+
* Does NOT acquire the nomad lock (read-only paths stay race-tolerant) and
|
|
19
|
+
* does NOT mutate any `.jsonl` byte (preserves the transcript byte-equality
|
|
20
|
+
* invariant validated in the end-to-end sync phase). All errors go to stderr
|
|
21
|
+
* prefixed with the red `✗` fail glyph; success goes to stdout as a bare
|
|
22
|
+
* shell line (no glyph) so `eval "$(...)"` works.
|
|
23
|
+
*/
|
|
24
|
+
export function resumeCmd(sessionId: string): void {
|
|
25
|
+
if (!/^[A-Za-z0-9_-]+$/.test(sessionId) || sessionId.length > 128) {
|
|
26
|
+
fail(`invalid session id: ${sessionId}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const projectsRoot = join(CLAUDE_HOME, 'projects');
|
|
31
|
+
if (!existsSync(projectsRoot)) {
|
|
32
|
+
fail(`${projectsRoot} does not exist`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const jsonlPath = findTranscriptPath(projectsRoot, sessionId);
|
|
37
|
+
if (jsonlPath === null) {
|
|
38
|
+
fail(`session ${sessionId} not found in any ~/.claude/projects/<encoded>/`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
const recordedCwd = extractRecordedCwd(jsonlPath);
|
|
42
|
+
if (recordedCwd === null) {
|
|
43
|
+
fail(`no cwd field found in ${jsonlPath}`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
47
|
+
if (!existsSync(mapPath)) {
|
|
48
|
+
fail('path-map.json missing');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
const map = readJson<unknown>(mapPath);
|
|
52
|
+
const schemaError = validatePathMap(map);
|
|
53
|
+
if (schemaError !== null) {
|
|
54
|
+
fail(schemaError);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
const hit = lookupLocalPath(map as PathMap, recordedCwd);
|
|
58
|
+
if (hit === null) {
|
|
59
|
+
fail(`cwd ${recordedCwd} from session ${sessionId} not found in path-map.json`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
if (hit.localPath === undefined) {
|
|
63
|
+
fail(`session ${sessionId} not mapped on this host; add the logical to path-map.json`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Single-quote both interpolations so paths with spaces (or any shell
|
|
68
|
+
// metachar in sessionId) survive `eval` and the cd ends up at the
|
|
69
|
+
// intended directory rather than splitting on whitespace. Success line
|
|
70
|
+
// has NO glyph prefix; meant to be `eval`'d by the user.
|
|
71
|
+
console.log(`cd ${shQuote(hit.localPath)} && claude --resume ${shQuote(sessionId)}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Walks `<projectsRoot>/<dir>/<sessionId>.jsonl` and returns the first existing path, or null. */
|
|
75
|
+
function findTranscriptPath(projectsRoot: string, sessionId: string): string | null {
|
|
76
|
+
for (const dir of readdirSync(projectsRoot)) {
|
|
77
|
+
const candidate = join(projectsRoot, dir, `${sessionId}.jsonl`);
|
|
78
|
+
if (existsSync(candidate)) return candidate;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Returns the first non-file-history-snapshot line's `cwd` from the transcript, or null. */
|
|
84
|
+
function extractRecordedCwd(jsonlPath: string): string | null {
|
|
85
|
+
for (const line of readFileSync(jsonlPath, 'utf8').split('\n')) {
|
|
86
|
+
if (!line.trim()) continue;
|
|
87
|
+
try {
|
|
88
|
+
const obj = JSON.parse(line) as TranscriptLine;
|
|
89
|
+
if (obj.type === 'file-history-snapshot') continue;
|
|
90
|
+
if (typeof obj.cwd === 'string' && obj.cwd.length > 0) return obj.cwd;
|
|
91
|
+
} catch {
|
|
92
|
+
// Skip non-JSON or partial lines; transcripts can be appended mid-write.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Validate that `raw` parses as `{ projects: { [logical]: { [host]: string } } }`
|
|
100
|
+
* deeply enough that downstream iteration cannot throw. Returns `null` on
|
|
101
|
+
* success or a human-readable reason on failure. Type-narrow callers must
|
|
102
|
+
* cast to `PathMap` after a `null` return; the cast is sound because every
|
|
103
|
+
* branch the consumers walk is verified here.
|
|
104
|
+
*/
|
|
105
|
+
function validatePathMap(raw: unknown): string | null {
|
|
106
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
107
|
+
return 'path-map.json invalid schema: top-level value must be an object';
|
|
108
|
+
}
|
|
109
|
+
const projects: unknown = (raw as { projects?: unknown }).projects;
|
|
110
|
+
if (projects === null || typeof projects !== 'object' || Array.isArray(projects)) {
|
|
111
|
+
return 'path-map.json invalid schema: "projects" must be an object';
|
|
112
|
+
}
|
|
113
|
+
for (const [name, hosts] of Object.entries(projects as Record<string, unknown>)) {
|
|
114
|
+
if (hosts === null || typeof hosts !== 'object' || Array.isArray(hosts)) {
|
|
115
|
+
return `path-map.json invalid schema: project "${name}" hosts must be an object`;
|
|
116
|
+
}
|
|
117
|
+
for (const [host, value] of Object.entries(hosts as Record<string, unknown>)) {
|
|
118
|
+
if (typeof value !== 'string') {
|
|
119
|
+
return `path-map.json invalid schema: project "${name}" host "${host}" path must be a string`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Reverse-lookups the logical project from `recordedCwd`. Matches when
|
|
128
|
+
* `recordedCwd` equals a mapped abspath OR is a descendant of one (a session
|
|
129
|
+
* started inside a subdirectory of a mapped project still resolves). The
|
|
130
|
+
* descendant test uses `startsWith(${root}/)` so `/orig/foo` does not match
|
|
131
|
+
* the project mapped at `/orig/foo-other`. Returns `{ logical, localPath }`
|
|
132
|
+
* (localPath undefined when host missing or 'TBD') or null on no match.
|
|
133
|
+
*/
|
|
134
|
+
function lookupLocalPath(
|
|
135
|
+
map: PathMap,
|
|
136
|
+
recordedCwd: string,
|
|
137
|
+
): { logical: string; localPath: string | undefined } | null {
|
|
138
|
+
for (const [logical, hosts] of Object.entries(map.projects)) {
|
|
139
|
+
const isUnderMappedPath = Object.values(hosts).some(
|
|
140
|
+
(p) => recordedCwd === p || recordedCwd.startsWith(`${p}/`),
|
|
141
|
+
);
|
|
142
|
+
if (isUnderMappedPath) {
|
|
143
|
+
const localPath = hosts[HOST];
|
|
144
|
+
return { logical, localPath: localPath === 'TBD' ? undefined : localPath };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* POSIX single-quote escape: wraps `s` in `'...'` and rewrites each interior
|
|
152
|
+
* `'` as `'\''`. Safe for `eval` and `bash -c`. Used to keep shell
|
|
153
|
+
* metacharacters in the resume output from being interpreted by the user's
|
|
154
|
+
* shell when they pipe the output through `eval`.
|
|
155
|
+
*/
|
|
156
|
+
function shQuote(s: string): string {
|
|
157
|
+
const escaped = s.replaceAll("'", String.raw`'\''`);
|
|
158
|
+
return `'${escaped}'`;
|
|
159
|
+
}
|
package/src/summary.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ok, warn } from './utils.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Emit the single end-of-run summary line shared by cmdPull, cmdPush, and
|
|
5
|
+
* cmdDiff. Canonical phrasing:
|
|
6
|
+
* - `summary: clean` when nothing was unmapped (and, for push, no
|
|
7
|
+
* collisions). Always printed so users see a consistent terminator
|
|
8
|
+
* and can spot when behavior changes.
|
|
9
|
+
* - `summary: <N> unmapped on pull (run nomad doctor to list)`
|
|
10
|
+
* - `summary: <N> unmapped on diff (run nomad doctor to list)`
|
|
11
|
+
* - `summary: <N> unmapped on push, <M> collisions (run nomad doctor to list)`
|
|
12
|
+
*
|
|
13
|
+
* Clean outcomes go through `ok()` (green `✓` glyph, stdout) and unmapped /
|
|
14
|
+
* collision outcomes go through `warn()` (yellow `⚠︎` glyph, stderr). The
|
|
15
|
+
* status glyph carries the success/warn semantics; users see e.g.
|
|
16
|
+
* `✓ summary: clean` or `⚠︎ summary: 3 unmapped on pull (...)`. Note: clean
|
|
17
|
+
* still goes to stdout so it survives backgrounded shell-rc invocations
|
|
18
|
+
* like `nomad pull 2>/dev/null &`. `collisions` is meaningful only for
|
|
19
|
+
* `'push'`; for `'pull'` / `'diff'` it is ignored and defaults to 0. This
|
|
20
|
+
* module is the SINGLE source of truth for the phrasing, eliminating drift
|
|
21
|
+
* risk across the three callers by construction.
|
|
22
|
+
*/
|
|
23
|
+
export function emitSummary(
|
|
24
|
+
verb: 'pull' | 'push' | 'diff',
|
|
25
|
+
unmapped: number,
|
|
26
|
+
collisions = 0,
|
|
27
|
+
): void {
|
|
28
|
+
if (verb === 'push') {
|
|
29
|
+
if (unmapped === 0 && collisions === 0) {
|
|
30
|
+
ok('summary: clean');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
warn(
|
|
34
|
+
`summary: ${unmapped} unmapped on push, ${collisions} collisions (run nomad doctor to list)`,
|
|
35
|
+
);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (unmapped === 0) {
|
|
39
|
+
ok('summary: clean');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
warn(`summary: ${unmapped} unmapped on ${verb} (run nomad doctor to list)`);
|
|
43
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
import { REPO_HOME, UPSTREAM_REPO_SLUG } from './config.ts';
|
|
4
|
+
import { NomadFatal } from './utils.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Topology label resolved by `detectTopology`. `vanilla` is a single `origin`
|
|
8
|
+
* remote pointing at the public repo (read-only consumer). `fork` is the
|
|
9
|
+
* private-config layout: `upstream` is the public repo and `origin` is the
|
|
10
|
+
* user's private mirror. `unknown` is anything else: no `upstream`, an
|
|
11
|
+
* `origin` that does not match the public repo, etc. Unknown topologies
|
|
12
|
+
* surface a fatal so the user can run the two-command manual fallback
|
|
13
|
+
* without nomad guessing at intent.
|
|
14
|
+
*/
|
|
15
|
+
export type Topology = 'vanilla' | 'fork' | 'unknown';
|
|
16
|
+
|
|
17
|
+
/** Escape regex metacharacters so a config value with `.` or `+` interpolated
|
|
18
|
+
* into a `new RegExp(...)` does not silently broaden the match. The current
|
|
19
|
+
* slug has no metachars, but this keeps the call defensible if it changes. */
|
|
20
|
+
function escapeRe(s: string): string {
|
|
21
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Strict patterns matching the public repo's SSH and HTTPS remote URL forms,
|
|
26
|
+
* with and without a `.git` suffix. Both `git remote add upstream ...` styles
|
|
27
|
+
* (set-url ssh, gh-clone https) must round-trip through `detectTopology`.
|
|
28
|
+
*/
|
|
29
|
+
const SLUG_RE = escapeRe(UPSTREAM_REPO_SLUG);
|
|
30
|
+
const SSH_REGEX = new RegExp(String.raw`^git@github\.com:${SLUG_RE}(\.git)?$`);
|
|
31
|
+
const HTTPS_REGEX = new RegExp(String.raw`^https://github\.com/${SLUG_RE}(\.git)?$`);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Determines whether a remote URL matches the canonical upstream repository forms (SSH or HTTPS).
|
|
35
|
+
*
|
|
36
|
+
* @param url - The remote URL to test
|
|
37
|
+
* @returns `true` if `url` matches the canonical upstream SSH or HTTPS form, `false` otherwise.
|
|
38
|
+
*/
|
|
39
|
+
function matchesUpstream(url: string): boolean {
|
|
40
|
+
return SSH_REGEX.test(url) || HTTPS_REGEX.test(url);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse the output of `git remote -v` into a mapping of remote names to their fetch URLs.
|
|
45
|
+
*
|
|
46
|
+
* Ignores `(push)` entries because topology detection drives the
|
|
47
|
+
* `git pull`/`fetch`/`merge` invocations, which all use the fetch URL.
|
|
48
|
+
* Lines that do not match the expected `<name> <url> (fetch)` format are
|
|
49
|
+
* skipped.
|
|
50
|
+
*
|
|
51
|
+
* @param out - Raw stdout from `git remote -v`
|
|
52
|
+
* @returns A record mapping each remote name to its fetch URL
|
|
53
|
+
*/
|
|
54
|
+
export function parseRemotes(out: string): Record<string, string> {
|
|
55
|
+
const remotes: Record<string, string> = {};
|
|
56
|
+
for (const line of out.split('\n')) {
|
|
57
|
+
const trimmed = line.trim();
|
|
58
|
+
if (trimmed === '') continue;
|
|
59
|
+
const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/.exec(trimmed);
|
|
60
|
+
if (match === null) continue;
|
|
61
|
+
remotes[match[1]] = match[2];
|
|
62
|
+
}
|
|
63
|
+
return remotes;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Classify a parsed `{ name: fetchUrl }` remote map into a topology label.
|
|
68
|
+
*
|
|
69
|
+
* Classification rules:
|
|
70
|
+
* - `vanilla` — exactly one remote named `origin` matching the public repo.
|
|
71
|
+
* - `fork` — an `upstream` matching the public repo and a separate `origin`
|
|
72
|
+
* (any URL).
|
|
73
|
+
* - `unknown` — anything else (no `upstream`, an `origin` that does not
|
|
74
|
+
* match the public repo, etc.).
|
|
75
|
+
*
|
|
76
|
+
* Pure and side-effect-free; tests drive it directly with hand-built maps.
|
|
77
|
+
*
|
|
78
|
+
* @param remotes - Map of remote name -> fetch URL produced by `parseRemotes`.
|
|
79
|
+
* @returns The resolved `Topology` label.
|
|
80
|
+
*/
|
|
81
|
+
export function detectTopology(remotes: Record<string, string>): Topology {
|
|
82
|
+
const origin = remotes.origin;
|
|
83
|
+
const upstream = remotes.upstream;
|
|
84
|
+
if (typeof upstream === 'string' && matchesUpstream(upstream) && typeof origin === 'string') {
|
|
85
|
+
return 'fork';
|
|
86
|
+
}
|
|
87
|
+
const names = Object.keys(remotes);
|
|
88
|
+
if (names.length === 1 && names[0] === 'origin' && matchesUpstream(origin ?? '')) {
|
|
89
|
+
return 'vanilla';
|
|
90
|
+
}
|
|
91
|
+
return 'unknown';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Detect the repository remote topology by running `git remote -v` in REPO_HOME
|
|
96
|
+
* and routing the output through `parseRemotes` + `detectTopology`.
|
|
97
|
+
*
|
|
98
|
+
* `git remote -v` is read-only so failures here are unexpected; we still
|
|
99
|
+
* route through `NomadFatal` so the dispatcher prints ``✗ ...``
|
|
100
|
+
* rather than dumping a stack trace.
|
|
101
|
+
*
|
|
102
|
+
* @returns The detected topology label: `'vanilla'`, `'fork'`, or `'unknown'`.
|
|
103
|
+
* @throws NomadFatal when `git remote -v` fails; any captured git stderr is written to `process.stderr` before the error is thrown.
|
|
104
|
+
*/
|
|
105
|
+
export function loadTopology(): Topology {
|
|
106
|
+
let out: string;
|
|
107
|
+
try {
|
|
108
|
+
out = execFileSync('git', ['remote', '-v'], {
|
|
109
|
+
cwd: REPO_HOME,
|
|
110
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
111
|
+
}).toString();
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const e = err as Error & { stderr?: Buffer };
|
|
114
|
+
if (e.stderr) process.stderr.write(e.stderr);
|
|
115
|
+
throw new NomadFatal('git remote -v failed');
|
|
116
|
+
}
|
|
117
|
+
return detectTopology(parseRemotes(out));
|
|
118
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
cpSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
fsyncSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
statSync,
|
|
13
|
+
symlinkSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { dirname, join, relative } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { dim, failGlyph, green, infoGlyph, okGlyph, red, warnGlyph, yellow } from './color.ts';
|
|
20
|
+
import { CLAUDE_HOME, HOME } from './config.ts';
|
|
21
|
+
|
|
22
|
+
const LOCK_PATH = join(HOME, '.cache', 'claude-nomad', 'nomad.lock');
|
|
23
|
+
|
|
24
|
+
/** Opaque handle for an acquired lockfile. Pass to `releaseLock` in a `finally`. */
|
|
25
|
+
export type LockHandle = { fd: number };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Print an informational line prefixed with the dim `ℹ︎` glyph (U+2139+VS15)
|
|
29
|
+
* to stdout. Matches the doctor-style left-gutter glyph format so the whole
|
|
30
|
+
* CLI shares one visual vocabulary instead of the prior `[nomad]` text prefix
|
|
31
|
+
* coexisting with doctor's status glyphs.
|
|
32
|
+
*/
|
|
33
|
+
export const log = (msg: string): void => console.log(`${dim(infoGlyph)} ${msg}`);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Print a success line prefixed with the green `✓` glyph to stdout. Use for
|
|
37
|
+
* positive terminators (e.g., `summary: clean`) where a status confirmation is
|
|
38
|
+
* load-bearing.
|
|
39
|
+
*/
|
|
40
|
+
export const ok = (msg: string): void => console.log(`${green(okGlyph)} ${msg}`);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Print a warning line prefixed with the yellow `⚠︎` glyph to stderr. Use for
|
|
44
|
+
* non-fatal conditions the operator should notice (lock contention, partial
|
|
45
|
+
* sync outcomes, schema drift). Routes through `console.error` so both
|
|
46
|
+
* `console.error` spies and `process.stderr.write` spies in tests catch it.
|
|
47
|
+
*/
|
|
48
|
+
export const warn = (msg: string): void => {
|
|
49
|
+
console.error(`${yellow(warnGlyph)} ${msg}`);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Print a fatal-error line prefixed with the red `✗` glyph to stderr. Use for
|
|
54
|
+
* NomadFatal-equivalent failures surfaced to the user; the glyph carries the
|
|
55
|
+
* severity so callers do not need a redundant `FATAL:` text token. Routes
|
|
56
|
+
* through `console.error` so both `console.error` spies and
|
|
57
|
+
* `process.stderr.write` spies in tests catch it.
|
|
58
|
+
*/
|
|
59
|
+
export const fail = (msg: string): void => {
|
|
60
|
+
console.error(`${red(failGlyph)} ${msg}`);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Sentinel error class for fatal nomad failures. Thrown by `die()` and caught
|
|
65
|
+
* by top-level command wrappers (cmdPull, cmdPush, nomad.ts dispatcher) so a
|
|
66
|
+
* `finally` block can release locks before the process exits. Avoids the
|
|
67
|
+
* pre-fix bug where `process.exit()` skipped pending `finally` clauses and
|
|
68
|
+
* leaked the lockfile.
|
|
69
|
+
*/
|
|
70
|
+
export class NomadFatal extends Error {
|
|
71
|
+
constructor(message: string) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = 'NomadFatal';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Throw a `NomadFatal` with the given message. Callers should `catch` it in
|
|
79
|
+
* the cmdPull/cmdPush try/finally so the lock is released before exit.
|
|
80
|
+
*/
|
|
81
|
+
export const die = (msg: string): never => {
|
|
82
|
+
throw new NomadFatal(msg);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Shell-free, untrimmed `git status --porcelain=v1 -z` reader. Untrimmed
|
|
87
|
+
* because porcelain v1 -z records start with a 2-char status plus 1 space,
|
|
88
|
+
* and the first record's leading space is part of the format (e.g.
|
|
89
|
+
* `" M path\0"` for unstaged-modified). Going through `sh` would strip that
|
|
90
|
+
* space and shift the fixed-offset parse in `parsePorcelainZ`.
|
|
91
|
+
*/
|
|
92
|
+
export const gitStatusPorcelainZ = (cwd?: string): string =>
|
|
93
|
+
execFileSync('git', ['status', '--porcelain=v1', '-z'], {
|
|
94
|
+
cwd,
|
|
95
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
96
|
+
}).toString();
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Run `git <args>` in `cwd`, forwarding stderr and converting non-zero exits
|
|
100
|
+
* to `NomadFatal`. Without this wrap, an ExecException would bubble past the
|
|
101
|
+
* cmdPull/cmdPush NomadFatal-only catch blocks and surface as a stack trace;
|
|
102
|
+
* the finally still releases the lock, but the user UX degrades.
|
|
103
|
+
*/
|
|
104
|
+
export function gitOrFatal(args: readonly string[], context: string, cwd?: string): void {
|
|
105
|
+
try {
|
|
106
|
+
execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
107
|
+
} catch (err) {
|
|
108
|
+
const e = err as Error & { stderr?: Buffer };
|
|
109
|
+
if (e.stderr) process.stderr.write(e.stderr);
|
|
110
|
+
throw new NomadFatal(`${context} failed`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Read and JSON-parse `path`. Throws `SyntaxError` on malformed content. */
|
|
115
|
+
export function readJson<T>(path: string): T {
|
|
116
|
+
const data: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
117
|
+
return data as T;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Write `data` as pretty-printed JSON (2-space indent, trailing newline). Non-atomic. */
|
|
121
|
+
export function writeJson(path: string, data: unknown): void {
|
|
122
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Atomic write: temp + fsync + rename + parent-dir fsync. Survives
|
|
127
|
+
* interrupted pulls. Preserves the destination file's existing mode when it
|
|
128
|
+
* exists, defaults to 0o600 otherwise so credentials in `settings.json` are
|
|
129
|
+
* not widened by the process umask on every regenerate.
|
|
130
|
+
*/
|
|
131
|
+
export function writeJsonAtomic(path: string, data: unknown): void {
|
|
132
|
+
const mode = existsSync(path) ? statSync(path).mode & 0o777 : 0o600;
|
|
133
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
134
|
+
const fd = openSync(tmp, 'w', mode);
|
|
135
|
+
try {
|
|
136
|
+
writeFileSync(fd, JSON.stringify(data, null, 2) + '\n');
|
|
137
|
+
fsyncSync(fd);
|
|
138
|
+
} finally {
|
|
139
|
+
closeSync(fd);
|
|
140
|
+
}
|
|
141
|
+
renameSync(tmp, path);
|
|
142
|
+
// Fsync the parent directory so the rename itself is durable across a crash;
|
|
143
|
+
// otherwise the file contents are persisted but the directory entry can be
|
|
144
|
+
// lost. Linux/macOS support this on a read-only fd to the dir.
|
|
145
|
+
const dirFd = openSync(dirname(path), 'r');
|
|
146
|
+
try {
|
|
147
|
+
fsyncSync(dirFd);
|
|
148
|
+
} finally {
|
|
149
|
+
closeSync(dirFd);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Deep merge: source overrides target. Arrays replace, objects merge recursively. */
|
|
154
|
+
export function deepMerge<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
|
|
155
|
+
const out: Record<string, unknown> = { ...target };
|
|
156
|
+
for (const [key, value] of Object.entries(source)) {
|
|
157
|
+
const existing = out[key];
|
|
158
|
+
const bothObjects =
|
|
159
|
+
value !== null &&
|
|
160
|
+
typeof value === 'object' &&
|
|
161
|
+
!Array.isArray(value) &&
|
|
162
|
+
existing !== null &&
|
|
163
|
+
typeof existing === 'object' &&
|
|
164
|
+
!Array.isArray(existing);
|
|
165
|
+
out[key] = bothObjects
|
|
166
|
+
? deepMerge(existing as Record<string, unknown>, value as Record<string, unknown>)
|
|
167
|
+
: value;
|
|
168
|
+
}
|
|
169
|
+
return out as T;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Claude Code encodes absolute project paths by replacing `/` with `-`. */
|
|
173
|
+
export const encodePath = (absPath: string): string => absPath.replaceAll('/', '-');
|
|
174
|
+
|
|
175
|
+
/** Local-time YYYYMMDD-HHMMSS timestamp; lexicographically sortable. Pure. */
|
|
176
|
+
export function nowTimestamp(): string {
|
|
177
|
+
const d = new Date();
|
|
178
|
+
const pad = (n: number): string => n.toString().padStart(2, '0');
|
|
179
|
+
return (
|
|
180
|
+
d.getFullYear().toString() +
|
|
181
|
+
pad(d.getMonth() + 1) +
|
|
182
|
+
pad(d.getDate()) +
|
|
183
|
+
'-' +
|
|
184
|
+
pad(d.getHours()) +
|
|
185
|
+
pad(d.getMinutes()) +
|
|
186
|
+
pad(d.getSeconds())
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Collision-resistant backup timestamp. `nowTimestamp()` is second-resolution,
|
|
192
|
+
* so two pulls in the same wall-clock second would share `ts`, and the
|
|
193
|
+
* second's `backupBeforeWrite` calls (which use `cpSync` with `force:false`)
|
|
194
|
+
* would silently no-op against the existing first snapshot. Append a `-N`
|
|
195
|
+
* suffix until the backup dir is unique.
|
|
196
|
+
*/
|
|
197
|
+
export function freshBackupTs(backupRoot: string): string {
|
|
198
|
+
const base = nowTimestamp();
|
|
199
|
+
if (!existsSync(join(backupRoot, base))) return base;
|
|
200
|
+
let n = 1;
|
|
201
|
+
while (existsSync(join(backupRoot, `${base}-${n}`))) n++;
|
|
202
|
+
return `${base}-${n}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Create a symlink at `linkPath` pointing to `target`, idempotently. No-op if
|
|
207
|
+
* a symlink already exists at `linkPath`; dies if a non-symlink exists there
|
|
208
|
+
* (caller should pre-scan and back up first; see `applySharedLinks`).
|
|
209
|
+
*/
|
|
210
|
+
export function ensureSymlink(linkPath: string, target: string): void {
|
|
211
|
+
if (existsSync(linkPath)) {
|
|
212
|
+
if (lstatSync(linkPath).isSymbolicLink()) return;
|
|
213
|
+
die(`${linkPath} exists and is not a symlink. Move it aside first.`);
|
|
214
|
+
}
|
|
215
|
+
mkdirSync(dirname(linkPath), { recursive: true });
|
|
216
|
+
symlinkSync(target, linkPath);
|
|
217
|
+
log(`linked ${linkPath} -> ${target}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Snapshot `absPath` into `~/.cache/claude-nomad/backup/<ts>/<rel>` before destructive write.
|
|
222
|
+
* No-op if source missing or outside CLAUDE_HOME. Recursive for directories.
|
|
223
|
+
*/
|
|
224
|
+
export function backupBeforeWrite(absPath: string, ts: string): void {
|
|
225
|
+
if (!existsSync(absPath)) return;
|
|
226
|
+
const rel = relative(CLAUDE_HOME, absPath);
|
|
227
|
+
if (rel.startsWith('..') || rel === '') return;
|
|
228
|
+
const backupRoot = join(HOME, '.cache', 'claude-nomad', 'backup', ts);
|
|
229
|
+
const dst = join(backupRoot, rel);
|
|
230
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
231
|
+
cpSync(absPath, dst, { recursive: true, force: false, preserveTimestamps: true });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Parallel of `backupBeforeWrite`, but scoped to `REPO_HOME` instead of
|
|
236
|
+
* `CLAUDE_HOME`. Used by `remapPush` to snapshot repo-side encoded-dir
|
|
237
|
+
* state before `copyDir` clobbers it. Backup root is repo-prefixed so the
|
|
238
|
+
* dump is distinguishable from `CLAUDE_HOME` backups in the same `ts` dir.
|
|
239
|
+
*/
|
|
240
|
+
export function backupRepoWrite(absPath: string, ts: string, repoHome: string): void {
|
|
241
|
+
if (!existsSync(absPath)) return;
|
|
242
|
+
const rel = relative(repoHome, absPath);
|
|
243
|
+
if (rel.startsWith('..') || rel === '') return;
|
|
244
|
+
const backupRoot = join(HOME, '.cache', 'claude-nomad', 'backup', ts, 'repo');
|
|
245
|
+
const dst = join(backupRoot, rel);
|
|
246
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
247
|
+
cpSync(absPath, dst, { recursive: true, force: false, preserveTimestamps: true });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Acquire the exclusive nomad lockfile so two pulls/pushes cannot mutate
|
|
252
|
+
* `~/.claude/` concurrently. Returns the handle on success, or `null` on
|
|
253
|
+
* contention (caller should `process.exit(0)`; skip-on-contention is the
|
|
254
|
+
* intended UX for backgrounded shell-rc invocations). Detects stale locks by
|
|
255
|
+
* probing the recorded pid with `kill(pid, 0)` and recovers via
|
|
256
|
+
* `unlinkIfSamePid` + `retryOnce`. `verb` is `'pull'` or `'push'`; surfaces
|
|
257
|
+
* in the contention-skip message.
|
|
258
|
+
*/
|
|
259
|
+
export function acquireLock(verb: string): LockHandle | null {
|
|
260
|
+
mkdirSync(dirname(LOCK_PATH), { recursive: true });
|
|
261
|
+
try {
|
|
262
|
+
const fd = openSync(LOCK_PATH, 'wx');
|
|
263
|
+
writeFileSync(fd, String(process.pid));
|
|
264
|
+
return { fd };
|
|
265
|
+
} catch (err) {
|
|
266
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
267
|
+
if (code !== 'EEXIST') throw err;
|
|
268
|
+
return checkStaleAndRetry(verb);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Release a previously-acquired lock handle. No-op when `handle` is null
|
|
274
|
+
* (matches `acquireLock`'s contention return). Tolerates the lockfile having
|
|
275
|
+
* already been unlinked. MUST be called from a `finally` so it runs even when
|
|
276
|
+
* the wrapped command throws.
|
|
277
|
+
*/
|
|
278
|
+
export function releaseLock(handle: LockHandle | null): void {
|
|
279
|
+
if (handle === null) return;
|
|
280
|
+
try {
|
|
281
|
+
closeSync(handle.fd);
|
|
282
|
+
} catch {
|
|
283
|
+
/* already closed; ignore */
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
unlinkSync(LOCK_PATH);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Compare-and-delete helper that closes most of the TOCTOU window between
|
|
294
|
+
* reading a stale lock's pid and removing it: another process could
|
|
295
|
+
* legitimately acquire the lock between those steps, and a naive unlink
|
|
296
|
+
* would clobber it. Re-reads the file and only unlinks if the contents
|
|
297
|
+
* still equal `expectedPidStr`. Returns `true` if the lock was unlinked,
|
|
298
|
+
* `false` if the content drifted or the file already vanished. A microsecond
|
|
299
|
+
* window between the re-read and the unlink remains; the residual race is
|
|
300
|
+
* documented as a backlog item rather than fully closed here.
|
|
301
|
+
*/
|
|
302
|
+
function unlinkIfSamePid(expectedPidStr: string): boolean {
|
|
303
|
+
let current: string;
|
|
304
|
+
try {
|
|
305
|
+
current = readFileSync(LOCK_PATH, 'utf8').trim();
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
if (current !== expectedPidStr) return false;
|
|
310
|
+
try {
|
|
311
|
+
unlinkSync(LOCK_PATH);
|
|
312
|
+
return true;
|
|
313
|
+
} catch {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* EEXIST recovery path for `acquireLock`. Reads the lockfile pid, probes
|
|
320
|
+
* liveness with `kill(pid, 0)`, and tries one retry only when the pid is
|
|
321
|
+
* dead AND the compare-and-delete in `unlinkIfSamePid` confirms the file
|
|
322
|
+
* has not been replaced under us. Returns `null` (contention skip) in any
|
|
323
|
+
* other case.
|
|
324
|
+
*/
|
|
325
|
+
function checkStaleAndRetry(verb: string): LockHandle | null {
|
|
326
|
+
let pidStr: string;
|
|
327
|
+
try {
|
|
328
|
+
pidStr = readFileSync(LOCK_PATH, 'utf8').trim();
|
|
329
|
+
} catch {
|
|
330
|
+
pidStr = '';
|
|
331
|
+
}
|
|
332
|
+
const pid = Number.parseInt(pidStr, 10);
|
|
333
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
334
|
+
if (unlinkIfSamePid(pidStr)) return retryOnce(verb);
|
|
335
|
+
warn(`another nomad ${verb} running, skipping`);
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
process.kill(pid, 0);
|
|
340
|
+
warn(`another nomad ${verb} running, skipping`);
|
|
341
|
+
return null;
|
|
342
|
+
} catch (err) {
|
|
343
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
344
|
+
if (code === 'ESRCH') {
|
|
345
|
+
if (unlinkIfSamePid(pidStr)) return retryOnce(verb);
|
|
346
|
+
warn(`another nomad ${verb} running, skipping`);
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
warn(`another nomad ${verb} running, skipping`);
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Single retry of `openSync(..., 'wx')` after `unlinkIfSamePid` cleared a
|
|
356
|
+
* confirmed-stale lock. Bounded to one attempt to avoid spin loops if the
|
|
357
|
+
* lock is being rapidly recreated by another live process.
|
|
358
|
+
*/
|
|
359
|
+
function retryOnce(verb: string): LockHandle | null {
|
|
360
|
+
try {
|
|
361
|
+
const fd = openSync(LOCK_PATH, 'wx');
|
|
362
|
+
writeFileSync(fd, String(process.pid));
|
|
363
|
+
return { fd };
|
|
364
|
+
} catch {
|
|
365
|
+
warn(`another nomad ${verb} running, skipping`);
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|