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
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { closeSync, existsSync, openSync, readSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
import { cmdDoctor } from './commands.doctor.ts';
|
|
5
|
+
import { REPO_HOME } from './config.ts';
|
|
6
|
+
import { loadTopology } from './update.topology.ts';
|
|
7
|
+
import { die, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal, warn } from './utils.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Caller-supplied options for `cmdUpdate`. All flags optional; defaults are
|
|
11
|
+
* conservative (no dirty-tree override, prompt for fork push, mutate state).
|
|
12
|
+
*/
|
|
13
|
+
export type CmdUpdateOpts = {
|
|
14
|
+
/** When true, run topology detection + pre-flight only; print would-be git
|
|
15
|
+
* commands without mutating the repo. Skips the trailing `cmdDoctor` call. */
|
|
16
|
+
dryRun?: boolean;
|
|
17
|
+
/** When true, proceed even when `gitStatusPorcelainZ(REPO_HOME)` is
|
|
18
|
+
* non-empty. Emits a WARN log line before continuing. */
|
|
19
|
+
force?: boolean;
|
|
20
|
+
/** Fork topology only: when true, push the post-merge HEAD to `origin/main`
|
|
21
|
+
* without prompting. When false/unset, the user is prompted y/N. */
|
|
22
|
+
pushOrigin?: boolean;
|
|
23
|
+
/** Test injection point for the interactive y/N prompt. Production code
|
|
24
|
+
* reads one line from `/dev/tty`; tests override this to return a
|
|
25
|
+
* deterministic answer without a real controlling terminal. */
|
|
26
|
+
prompt?: (question: string) => string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the current Git branch name for the repository at REPO_HOME.
|
|
31
|
+
*
|
|
32
|
+
* Wraps the failure path so a corrupt or missing `.git` directory surfaces as
|
|
33
|
+
* ``✗ ...`` via the top-level dispatcher's `NomadFatal` catch
|
|
34
|
+
* rather than a raw `ExecException` stack trace.
|
|
35
|
+
*
|
|
36
|
+
* @returns The current branch name (trimmed).
|
|
37
|
+
* @throws NomadFatal when the git command fails; if the command produced stderr, that stderr is written to process.stderr before the exception is thrown.
|
|
38
|
+
*/
|
|
39
|
+
function currentBranch(): string {
|
|
40
|
+
try {
|
|
41
|
+
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
42
|
+
cwd: REPO_HOME,
|
|
43
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
44
|
+
})
|
|
45
|
+
.toString()
|
|
46
|
+
.trim();
|
|
47
|
+
} catch (err) {
|
|
48
|
+
const e = err as Error & { stderr?: Buffer };
|
|
49
|
+
if (e.stderr) process.stderr.write(e.stderr);
|
|
50
|
+
throw new NomadFatal('git rev-parse --abbrev-ref HEAD failed');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Default y/N prompt used when `opts.prompt` is not injected.
|
|
56
|
+
*
|
|
57
|
+
* Reads from `/dev/tty` byte-by-byte until newline so the call returns after
|
|
58
|
+
* the user presses Enter (cooked-mode TTY line buffering). The naive
|
|
59
|
+
* `readFileSync(0)` approach reads until EOF, which hangs interactive use
|
|
60
|
+
* until Ctrl-D. Opening `/dev/tty` directly also means the prompt still
|
|
61
|
+
* works when stdin is piped or redirected.
|
|
62
|
+
*
|
|
63
|
+
* @param question - Prompt text written to stdout before reading input.
|
|
64
|
+
* @returns The user's trimmed answer; `''` on any failure (no controlling TTY, read error), which `runFork` treats as "no" and skips the push.
|
|
65
|
+
*/
|
|
66
|
+
function defaultPrompt(question: string): string {
|
|
67
|
+
process.stdout.write(question);
|
|
68
|
+
let fd: number;
|
|
69
|
+
try {
|
|
70
|
+
fd = openSync('/dev/tty', 'r');
|
|
71
|
+
} catch {
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const buf = Buffer.alloc(1);
|
|
76
|
+
let answer = '';
|
|
77
|
+
while (true) {
|
|
78
|
+
const n = readSync(fd, buf, 0, 1, null);
|
|
79
|
+
if (n === 0) break;
|
|
80
|
+
const ch = buf.toString('utf8', 0, 1);
|
|
81
|
+
if (ch === '\n' || ch === '\r') break;
|
|
82
|
+
answer += ch;
|
|
83
|
+
}
|
|
84
|
+
return answer.trim();
|
|
85
|
+
} catch {
|
|
86
|
+
return '';
|
|
87
|
+
} finally {
|
|
88
|
+
closeSync(fd);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read and return the current `HEAD` commit SHA from the repository.
|
|
94
|
+
*
|
|
95
|
+
* Used to pin the pre-update commit so the post-update lockfile diff is
|
|
96
|
+
* exact regardless of whether the pull was a fast-forward, a no-op, or a
|
|
97
|
+
* merge. `HEAD@{1}` is unreliable here: a no-op `git pull --ff-only` does
|
|
98
|
+
* not always write a reflog entry, and a freshly cloned repo has no
|
|
99
|
+
* `HEAD@{1}` at all.
|
|
100
|
+
*
|
|
101
|
+
* @returns The `HEAD` commit SHA as a trimmed string.
|
|
102
|
+
* @throws NomadFatal if `git rev-parse HEAD` fails (stderr is written to stderr when present).
|
|
103
|
+
*/
|
|
104
|
+
function headSha(): string {
|
|
105
|
+
try {
|
|
106
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
107
|
+
cwd: REPO_HOME,
|
|
108
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
109
|
+
})
|
|
110
|
+
.toString()
|
|
111
|
+
.trim();
|
|
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 rev-parse HEAD failed');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* List files changed between the given commit and the current HEAD.
|
|
121
|
+
*
|
|
122
|
+
* @param beforeSha - Commit SHA to compare against HEAD
|
|
123
|
+
* @returns An array of file paths changed between `beforeSha` and `HEAD`; an empty array if there are no changes
|
|
124
|
+
*/
|
|
125
|
+
function changedFilesSince(beforeSha: string): string[] {
|
|
126
|
+
const out = execFileSync('git', ['diff', '--name-only', `${beforeSha}..HEAD`], {
|
|
127
|
+
cwd: REPO_HOME,
|
|
128
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
129
|
+
}).toString();
|
|
130
|
+
return out.split('\n').filter((line) => line !== '');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Run `npm install` in the repository only if `package-lock.json` changed since a given commit.
|
|
135
|
+
*
|
|
136
|
+
* If `package-lock.json` did not change between `beforeSha` and `HEAD`, logs
|
|
137
|
+
* a skip message; otherwise runs `npm install` with working directory set to
|
|
138
|
+
* `REPO_HOME`. Routing through `execFileSync` (no shell) keeps the call
|
|
139
|
+
* mockable in tests and prevents any chance of argv injection.
|
|
140
|
+
*
|
|
141
|
+
* @param beforeSha - Commit SHA to compare against `HEAD` when determining whether the lockfile changed
|
|
142
|
+
*/
|
|
143
|
+
function reinstallIfNeeded(beforeSha: string): void {
|
|
144
|
+
const changed = changedFilesSince(beforeSha);
|
|
145
|
+
if (!changed.includes('package-lock.json')) {
|
|
146
|
+
log('skipping npm install (lockfile unchanged)');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
log('package-lock.json changed, running npm install');
|
|
150
|
+
execFileSync('npm', ['install'], { cwd: REPO_HOME, stdio: 'inherit' });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Perform a vanilla update by fast-forward pulling `origin/main`.
|
|
155
|
+
*
|
|
156
|
+
* Non-ff pulls (someone else pushed in the meantime) surface as `NomadFatal`
|
|
157
|
+
* via `gitOrFatal`. If `opts.dryRun` is true, logs the would-be pull command
|
|
158
|
+
* instead of executing it. Takes the full `CmdUpdateOpts` so the signature
|
|
159
|
+
* stays symmetric with `runFork` even though only `dryRun` is consulted
|
|
160
|
+
* today.
|
|
161
|
+
*
|
|
162
|
+
* @param opts - Update options; only `dryRun` is observed for this topology.
|
|
163
|
+
*/
|
|
164
|
+
function runVanilla(opts: CmdUpdateOpts): void {
|
|
165
|
+
if (opts.dryRun === true) {
|
|
166
|
+
log('DRY-RUN: would run `git pull --ff-only origin main`');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
gitOrFatal(['pull', '--ff-only', 'origin', 'main'], 'git pull', REPO_HOME);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Perform a fork-style update by fetching from `upstream`, merging `upstream/main` into `main`, and optionally pushing the merge to `origin`.
|
|
174
|
+
*
|
|
175
|
+
* The prompt step is gated by `pushOrigin` (no prompt when explicit) and by
|
|
176
|
+
* `dryRun` (no prompt, no push when previewing). When `opts.dryRun === true`
|
|
177
|
+
* the function only logs the git actions it would perform and returns
|
|
178
|
+
* without running any commands. When `opts.pushOrigin === true` the function
|
|
179
|
+
* pushes to `origin/main` without prompting; otherwise it prompts (via
|
|
180
|
+
* `opts.prompt` if provided, or the default `/dev/tty` prompt) and only
|
|
181
|
+
* pushes when the answer is `y` or `yes` (case-insensitive). Non-affirmative
|
|
182
|
+
* answers skip the push and log a "run later" hint.
|
|
183
|
+
*
|
|
184
|
+
* @param opts - Update options; respected fields are:
|
|
185
|
+
* - `dryRun`: when true, log actions instead of executing them
|
|
186
|
+
* - `pushOrigin`: when true, push to `origin/main` without prompting
|
|
187
|
+
* - `prompt`: optional prompt function used for interactive confirmation
|
|
188
|
+
*/
|
|
189
|
+
function runFork(opts: CmdUpdateOpts): void {
|
|
190
|
+
const promptFn = opts.prompt ?? defaultPrompt;
|
|
191
|
+
if (opts.dryRun === true) {
|
|
192
|
+
log('DRY-RUN: would run `git fetch upstream`');
|
|
193
|
+
log('DRY-RUN: would run `git merge upstream/main`');
|
|
194
|
+
if (opts.pushOrigin === true) {
|
|
195
|
+
log('DRY-RUN: would run `git push origin main`');
|
|
196
|
+
} else {
|
|
197
|
+
log('DRY-RUN: would prompt before pushing to origin/main');
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
gitOrFatal(['fetch', 'upstream'], 'git fetch upstream', REPO_HOME);
|
|
202
|
+
gitOrFatal(['merge', 'upstream/main'], 'git merge upstream/main', REPO_HOME);
|
|
203
|
+
if (opts.pushOrigin === true) {
|
|
204
|
+
gitOrFatal(['push', 'origin', 'main'], 'git push origin main', REPO_HOME);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const answer = promptFn('push merge to origin/main? [y/N] ').toLowerCase();
|
|
208
|
+
if (answer === 'y' || answer === 'yes') {
|
|
209
|
+
gitOrFatal(['push', 'origin', 'main'], 'git push origin main', REPO_HOME);
|
|
210
|
+
} else {
|
|
211
|
+
log('skipping push to origin (run `git push origin main` later)');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Perform a topology-aware repository update.
|
|
217
|
+
*
|
|
218
|
+
* Detects `vanilla` (`origin` -> public) vs `fork` (`upstream` -> public,
|
|
219
|
+
* `origin` -> private mirror) layouts, runs the right git invocation, runs
|
|
220
|
+
* `npm install` only when `package-lock.json` changed in the update, and
|
|
221
|
+
* ends with `cmdDoctor()` so the version-check PASS line confirms the
|
|
222
|
+
* upgrade landed.
|
|
223
|
+
*
|
|
224
|
+
* Pre-flight (each fatal unless overridden):
|
|
225
|
+
* 1. `REPO_HOME` exists.
|
|
226
|
+
* 2. Topology resolves to `vanilla` or `fork`.
|
|
227
|
+
* 3. `--push-origin` is fork-only (rejected on `vanilla`).
|
|
228
|
+
* 4. Current branch is `main`.
|
|
229
|
+
* 5. Working tree clean per `gitStatusPorcelainZ` (override with `force`).
|
|
230
|
+
*
|
|
231
|
+
* Fork-topology prompts read one line from `/dev/tty`; tests inject
|
|
232
|
+
* `opts.prompt` to bypass the TTY read.
|
|
233
|
+
*
|
|
234
|
+
* @param opts - Update options. `dryRun` runs pre-flight + logs the would-be git, install, and doctor actions without mutating the repo or invoking `cmdDoctor`. `force` proceeds past a dirty working tree. `pushOrigin` (fork topology only) skips the y/N prompt. `prompt` injects a synchronous answer function for tests.
|
|
235
|
+
*/
|
|
236
|
+
export function cmdUpdate(opts: CmdUpdateOpts = {}): void {
|
|
237
|
+
if (!existsSync(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
|
|
238
|
+
|
|
239
|
+
const topology = loadTopology();
|
|
240
|
+
if (topology === 'unknown') {
|
|
241
|
+
die(
|
|
242
|
+
`could not detect upstream remote in ${REPO_HOME}. Run \`git fetch <remote>\` and \`git merge <remote>/main\` manually.`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (topology === 'vanilla' && opts.pushOrigin === true) {
|
|
247
|
+
die('`--push-origin` is only valid for fork topology');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const branch = currentBranch();
|
|
251
|
+
if (branch !== 'main') {
|
|
252
|
+
die(`current branch is \`${branch}\`, expected \`main\``);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const status = gitStatusPorcelainZ(REPO_HOME);
|
|
256
|
+
if (status.length > 0) {
|
|
257
|
+
if (opts.force !== true) {
|
|
258
|
+
die('working tree is not clean, use `--force` to override');
|
|
259
|
+
}
|
|
260
|
+
warn('working tree is not clean, proceeding because --force was passed');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
log(`topology: ${topology}`);
|
|
264
|
+
|
|
265
|
+
if (opts.dryRun === true) {
|
|
266
|
+
if (topology === 'vanilla') runVanilla(opts);
|
|
267
|
+
else runFork(opts);
|
|
268
|
+
log('DRY-RUN: would run `npm install` only if `package-lock.json` changed');
|
|
269
|
+
log('DRY-RUN: would run `nomad doctor` to confirm the upgrade');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const beforeSha = headSha();
|
|
274
|
+
if (topology === 'vanilla') runVanilla(opts);
|
|
275
|
+
else runFork(opts);
|
|
276
|
+
|
|
277
|
+
reinstallIfNeeded(beforeSha);
|
|
278
|
+
cmdDoctor();
|
|
279
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { homedir, hostname } from 'node:os';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolved home directory. Uses Node's `os.homedir()` which reads `$HOME` on
|
|
6
|
+
* POSIX and falls back to `getpwuid_r()` when the env var is unset. Returns
|
|
7
|
+
* `""` only in pathological environments (no env, no uid mapping); callers
|
|
8
|
+
* should verify it is non-empty at CLI entry via `nomad.ts`. Centralizing the
|
|
9
|
+
* lookup here prevents the `process.env.HOME ?? ''` footgun where an unset
|
|
10
|
+
* `HOME` silently produced relative lockfile/backup paths.
|
|
11
|
+
*/
|
|
12
|
+
export const HOME = homedir();
|
|
13
|
+
|
|
14
|
+
/** Absolute path to the user's Claude Code config directory (`~/.claude`). */
|
|
15
|
+
export const CLAUDE_HOME = resolve(HOME, '.claude');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Absolute path to the local checkout of the private sync repo. Reads
|
|
19
|
+
* `NOMAD_REPO` first, falls back to `~/claude-nomad`. A set-but-empty
|
|
20
|
+
* `NOMAD_REPO` (e.g. `export NOMAD_REPO=` in a dotfile that clobbers the
|
|
21
|
+
* variable) must also fall through to the default. `??` only triggers on
|
|
22
|
+
* null/undefined, so `||` is used here to fall through on empty strings too.
|
|
23
|
+
* Relative paths in `NOMAD_REPO` are resolved against the current working
|
|
24
|
+
* directory at first use (downstream `existsSync` / `cpSync` / git invocations
|
|
25
|
+
* accept either absolute or relative paths); we intentionally do NOT
|
|
26
|
+
* `resolve()` here so developers can point the override at relative checkouts.
|
|
27
|
+
*/
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
29
|
+
export const REPO_HOME = process.env.NOMAD_REPO || resolve(HOME, 'claude-nomad');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Upstream GitHub repository slug for the release-version check in
|
|
33
|
+
* `nomad doctor`. Hardcoded for the same reason `REPO_HOME` is hardcoded:
|
|
34
|
+
* the deployed sync target is canonical for this CLI. Source of truth for
|
|
35
|
+
* the `GET /repos/<slug>/releases/latest` call in `reportVersionCheck`.
|
|
36
|
+
*/
|
|
37
|
+
export const UPSTREAM_REPO_SLUG = 'funkadelic/claude-nomad';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolved host identity used to pick `hosts/<HOST>.json` and key entries in
|
|
41
|
+
* `path-map.json`. Reads `NOMAD_HOST` first, falls back to `hostname()`, then
|
|
42
|
+
* lowercases. A set-but-empty `NOMAD_HOST` (e.g. `export NOMAD_HOST=` in a
|
|
43
|
+
* dotfile that clobbers the variable) must also fall through to `hostname()`.
|
|
44
|
+
* `??` only triggers on null/undefined, so `||` is used here to fall through
|
|
45
|
+
* on empty strings too.
|
|
46
|
+
*/
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
48
|
+
export const HOST = (process.env.NOMAD_HOST || hostname()).toLowerCase();
|
|
49
|
+
|
|
50
|
+
/** Names under `shared/` that are symlinked into `~/.claude/` on every pull. */
|
|
51
|
+
export const SHARED_LINKS = [
|
|
52
|
+
'CLAUDE.md',
|
|
53
|
+
'agents',
|
|
54
|
+
'skills',
|
|
55
|
+
'commands',
|
|
56
|
+
'rules',
|
|
57
|
+
'my-statusline.cjs',
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Path segments that must never cross the sync boundary in either direction.
|
|
62
|
+
* Defense-in-depth pair with `PUSH_ALLOWED_STATIC`: even if the allow-list
|
|
63
|
+
* misses a path, anything containing one of these segments is hard-blocked.
|
|
64
|
+
*/
|
|
65
|
+
export const NEVER_SYNC = new Set([
|
|
66
|
+
'.claude.json',
|
|
67
|
+
'history.jsonl',
|
|
68
|
+
'stats-cache.json',
|
|
69
|
+
'todos',
|
|
70
|
+
'shell-snapshots',
|
|
71
|
+
'debug',
|
|
72
|
+
'file-history',
|
|
73
|
+
'plans',
|
|
74
|
+
'session-env',
|
|
75
|
+
'statsig',
|
|
76
|
+
'telemetry',
|
|
77
|
+
'ide',
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Schema-drift baseline for `~/.claude/settings.json`. Top-level keys not in
|
|
82
|
+
* this set trigger a `nomad doctor` WARN line so we notice when Anthropic
|
|
83
|
+
* adds new settings before they silently round-trip through pull. Update on
|
|
84
|
+
* Anthropic settings.json schema changes.
|
|
85
|
+
*/
|
|
86
|
+
export const KNOWN_SETTINGS_KEYS = new Set<string>([
|
|
87
|
+
'$schema',
|
|
88
|
+
'agent',
|
|
89
|
+
'agents',
|
|
90
|
+
'agentPushNotifEnabled',
|
|
91
|
+
'allowedHttpHookUrls',
|
|
92
|
+
'apiKeyHelper',
|
|
93
|
+
'apiKeyHelperTimeoutMs',
|
|
94
|
+
'awsAuthRefresh',
|
|
95
|
+
'awsCredentialExport',
|
|
96
|
+
'awsLoginRefresh',
|
|
97
|
+
'awsRegion',
|
|
98
|
+
'awsRetryMode',
|
|
99
|
+
'cleanupPeriodDays',
|
|
100
|
+
'disableNonEssentialModelCalls',
|
|
101
|
+
'enabledExperimentalFeatures',
|
|
102
|
+
'enabledPlugins',
|
|
103
|
+
'env',
|
|
104
|
+
'forceLoginMethod',
|
|
105
|
+
'forceLoginOrgUUID',
|
|
106
|
+
'hooks',
|
|
107
|
+
'includeCoAuthoredBy',
|
|
108
|
+
'installMethod',
|
|
109
|
+
'model',
|
|
110
|
+
'outputStyle',
|
|
111
|
+
'permissions',
|
|
112
|
+
'pluginGroups',
|
|
113
|
+
'pluginRepositoryEnabled',
|
|
114
|
+
'pluginsLocalConfig',
|
|
115
|
+
'proxy',
|
|
116
|
+
'statsig',
|
|
117
|
+
'statusLine',
|
|
118
|
+
'subagents',
|
|
119
|
+
'theme',
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Static half of the push allow-list. Entries with trailing `/` are prefix
|
|
124
|
+
* matches; others are exact matches. The `hosts/` entry has special-case
|
|
125
|
+
* handling in `isAllowed` to limit it to `hosts/<name>.json` (single-level
|
|
126
|
+
* `.json` files only, no credentials). Data-driven
|
|
127
|
+
* `shared/projects/<logical>/` entries are added at runtime in
|
|
128
|
+
* `enforceAllowList`.
|
|
129
|
+
*/
|
|
130
|
+
export const PUSH_ALLOWED_STATIC = [
|
|
131
|
+
'shared/CLAUDE.md',
|
|
132
|
+
'shared/my-statusline.cjs',
|
|
133
|
+
'shared/settings.base.json',
|
|
134
|
+
'shared/agents/',
|
|
135
|
+
'shared/skills/',
|
|
136
|
+
'shared/commands/',
|
|
137
|
+
'shared/rules/',
|
|
138
|
+
'shared/.gitignore',
|
|
139
|
+
'hosts/',
|
|
140
|
+
'path-map.json',
|
|
141
|
+
] as const;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Shape of `path-map.json`. Each logical project name maps a hostname (matched
|
|
145
|
+
* against `HOST`) to the absolute path the project lives at on that host. Use
|
|
146
|
+
* the literal string `'TBD'` as a placeholder while a host has not yet cloned
|
|
147
|
+
* the project; `remapPull` / `remapPush` skip `'TBD'` entries.
|
|
148
|
+
*/
|
|
149
|
+
export type PathMap = { projects: Record<string, Record<string, string>> };
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { HOME, REPO_HOME } from './config.ts';
|
|
5
|
+
import { computePreview } from './preview.ts';
|
|
6
|
+
import { emitSummary } from './summary.ts';
|
|
7
|
+
import { die, fail, freshBackupTs, NomadFatal } from './utils.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `nomad diff` command. Offline-safe, read-only preview surface that runs
|
|
11
|
+
* the same `computePreview` orchestration as `pull --dry-run` but WITHOUT
|
|
12
|
+
* acquiring the pull/push lockfile and WITHOUT running `git pull --rebase`.
|
|
13
|
+
*
|
|
14
|
+
* Intent: answer "what would be applied from local repo state right now"
|
|
15
|
+
* (offline-safe). For "what will the next real pull do" (with the network
|
|
16
|
+
* round-trip), use `pull --dry-run` instead.
|
|
17
|
+
*
|
|
18
|
+
* Does NOT create the per-run backup directory under
|
|
19
|
+
* `~/.cache/claude-nomad/backup/<ts>/`: cmdDiff writes nothing, and an empty
|
|
20
|
+
* dir would pollute the cache. The `ts` value is still computed for log
|
|
21
|
+
* lines (so the preview output is consistent with `pull --dry-run`).
|
|
22
|
+
*
|
|
23
|
+
* Errors:
|
|
24
|
+
* - REPO_HOME missing surfaces as the canonical `repo not cloned at <path>`
|
|
25
|
+
* FATAL, matching cmdPull / cmdPush.
|
|
26
|
+
* - computePreview is tolerant of partial scaffold; cmdDiff inherits the
|
|
27
|
+
* same tolerance.
|
|
28
|
+
* - Any NomadFatal escapes into the local catch which writes the FATAL
|
|
29
|
+
* line to stderr and sets `process.exitCode = 1`. Non-NomadFatal errors
|
|
30
|
+
* rethrow.
|
|
31
|
+
*/
|
|
32
|
+
export function cmdDiff(): void {
|
|
33
|
+
try {
|
|
34
|
+
if (!existsSync(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
|
|
35
|
+
const backupBase = join(HOME, '.cache', 'claude-nomad', 'backup');
|
|
36
|
+
const ts = freshBackupTs(backupBase);
|
|
37
|
+
// Preview log lines reference `ts` so output stays consistent with
|
|
38
|
+
// pull --dry-run; the backup root itself is intentionally NOT created.
|
|
39
|
+
const result = computePreview(ts);
|
|
40
|
+
emitSummary('diff', result.unmapped);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (err instanceof NomadFatal) {
|
|
43
|
+
fail(err.message);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { copyFileSync, cpSync, existsSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { CLAUDE_HOME, HOST, REPO_HOME, SHARED_LINKS } from './config.ts';
|
|
5
|
+
import { die, log, readJson, writeJsonAtomic } from './utils.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Overlay `~/.claude/` SHARED_LINKS onto the freshly-written scaffold under
|
|
9
|
+
* `REPO_HOME/shared/`. Regular files (`CLAUDE.md`, `my-statusline.cjs`) go
|
|
10
|
+
* through `copyFileSync` so the placeholder is overwritten; directories
|
|
11
|
+
* (`agents`, `skills`, `commands`, `rules`) drop their just-written
|
|
12
|
+
* `.gitkeep` marker first and then go through `cpSync` with `force: false`,
|
|
13
|
+
* so any unexpected pre-existing destination content (an out-of-band write
|
|
14
|
+
* between the preflight check and this step) surfaces as an error. Also
|
|
15
|
+
* translates `~/.claude/settings.json` (when present) into `hosts/<HOST>.json`
|
|
16
|
+
* via `writeJsonAtomic`. Does NOT modify `~/.claude/`; the caller emits the
|
|
17
|
+
* user-visible next-step + originals-not-removed log lines so the canonical
|
|
18
|
+
* phrasing stays co-located with `cmdInit` itself.
|
|
19
|
+
*/
|
|
20
|
+
export function snapshotIntoShared(): void {
|
|
21
|
+
for (const name of SHARED_LINKS) {
|
|
22
|
+
const src = join(CLAUDE_HOME, name);
|
|
23
|
+
if (!existsSync(src)) continue;
|
|
24
|
+
const dst = join(REPO_HOME, 'shared', name);
|
|
25
|
+
if (statSync(src).isDirectory()) {
|
|
26
|
+
// Remove the .gitkeep first so cpSync starts against an empty dst.
|
|
27
|
+
// Force is false so existing files are not overwritten; errorOnExist
|
|
28
|
+
// is true because cpSync silently ignores destination collisions when
|
|
29
|
+
// it is omitted, defeating the intent of surfacing unexpected content
|
|
30
|
+
// (e.g. an out-of-band write between the preflight check and here).
|
|
31
|
+
const gk = join(dst, '.gitkeep');
|
|
32
|
+
if (existsSync(gk)) rmSync(gk);
|
|
33
|
+
cpSync(src, dst, { recursive: true, force: false, errorOnExist: true });
|
|
34
|
+
} else {
|
|
35
|
+
copyFileSync(src, dst);
|
|
36
|
+
}
|
|
37
|
+
log(`snapshotted shared/${name} from ${src}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const userSettings = join(CLAUDE_HOME, 'settings.json');
|
|
41
|
+
if (existsSync(userSettings)) {
|
|
42
|
+
// `return die(...)` keeps `parsed` definitely-assigned for the writeJsonAtomic call.
|
|
43
|
+
let parsed: Record<string, unknown>;
|
|
44
|
+
try {
|
|
45
|
+
parsed = readJson<Record<string, unknown>>(userSettings);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return die(`malformed ${userSettings}: ${(err as Error).message}`);
|
|
48
|
+
}
|
|
49
|
+
const hostFile = join(REPO_HOME, 'hosts', `${HOST}.json`);
|
|
50
|
+
writeJsonAtomic(hostFile, parsed);
|
|
51
|
+
log(`snapshotted hosts/${HOST}.json from ${userSettings}`);
|
|
52
|
+
}
|
|
53
|
+
}
|